mirror of
https://github.com/prymitive/karma
synced 2026-08-23 11:56:20 +00:00
feat(ui): allow copying alerts data
This commit is contained in:
committed by
Łukasz Mierzwa
parent
e2f20d227c
commit
1a0a4f827d
@@ -2,6 +2,10 @@
|
||||
|
||||
## v0.105
|
||||
|
||||
### Added
|
||||
|
||||
- Allow to copy alert payload from alerts dropdown menu #4378.
|
||||
|
||||
### Changed
|
||||
|
||||
- Change max value of `alertsPerGroup` to 25 #4420.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { MockAlert, MockAlertGroup } from "__fixtures__/Alerts";
|
||||
import { alertToJSON, VanillaAlertT } from "./Alert";
|
||||
|
||||
describe("alertToJSON", () => {
|
||||
it("simple alert", () => {
|
||||
const group = MockAlertGroup(
|
||||
[{ name: "alertname", value: "foo" }],
|
||||
[],
|
||||
[
|
||||
{
|
||||
name: "link",
|
||||
value: "url",
|
||||
visible: true,
|
||||
isAction: false,
|
||||
isLink: true,
|
||||
},
|
||||
],
|
||||
[{ name: "job", value: "mock" }],
|
||||
{}
|
||||
);
|
||||
const alert = MockAlert(
|
||||
[
|
||||
{
|
||||
name: "runbook",
|
||||
value: "readme.md",
|
||||
visible: false,
|
||||
isAction: false,
|
||||
isLink: true,
|
||||
},
|
||||
],
|
||||
[{ name: "instance", value: "server1" }],
|
||||
"active"
|
||||
);
|
||||
const expected: VanillaAlertT = {
|
||||
labels: { alertname: "foo", instance: "server1", job: "mock" },
|
||||
annotations: { link: "url", runbook: "readme.md" },
|
||||
fingerprint: "1234567",
|
||||
receivers: ["by-name"],
|
||||
startsAt: "2018-08-14T17:36:40.017867056Z",
|
||||
generatorURL: "localhost/graph",
|
||||
status: {
|
||||
inhibitedBy: [],
|
||||
silencedBy: [],
|
||||
state: "active",
|
||||
},
|
||||
};
|
||||
expect(alertToJSON(group, alert)).toStrictEqual([expected]);
|
||||
});
|
||||
|
||||
it("duplicated alert", () => {
|
||||
const group = MockAlertGroup([], [], [], [], {});
|
||||
const alert = MockAlert([], [], "active");
|
||||
alert.alertmanager.push({ ...alert.alertmanager[0] });
|
||||
const expected: VanillaAlertT = {
|
||||
labels: {},
|
||||
annotations: {},
|
||||
fingerprint: "1234567",
|
||||
receivers: ["by-name"],
|
||||
startsAt: "2018-08-14T17:36:40.017867056Z",
|
||||
generatorURL: "localhost/graph",
|
||||
status: {
|
||||
inhibitedBy: [],
|
||||
silencedBy: [],
|
||||
state: "active",
|
||||
},
|
||||
};
|
||||
expect(alertToJSON(group, alert)).toStrictEqual([expected]);
|
||||
});
|
||||
it("non-duplicated alert", () => {
|
||||
const group = MockAlertGroup([], [], [], [], {});
|
||||
const alert = MockAlert([], [], "active");
|
||||
alert.alertmanager.push({ ...alert.alertmanager[0] });
|
||||
alert.alertmanager[0].state = "active";
|
||||
alert.alertmanager[1].state = "suppressed";
|
||||
expect(alert.alertmanager).toHaveLength(2);
|
||||
expect(alert.alertmanager[0].state).toBe("active");
|
||||
expect(alert.alertmanager[1].state).toBe("suppressed");
|
||||
|
||||
const expected1: VanillaAlertT = {
|
||||
labels: {},
|
||||
annotations: {},
|
||||
fingerprint: "1234567",
|
||||
receivers: ["by-name"],
|
||||
startsAt: "2018-08-14T17:36:40.017867056Z",
|
||||
generatorURL: "localhost/graph",
|
||||
status: {
|
||||
inhibitedBy: [],
|
||||
silencedBy: [],
|
||||
state: "active",
|
||||
},
|
||||
};
|
||||
const expected2: VanillaAlertT = {
|
||||
labels: {},
|
||||
annotations: {},
|
||||
fingerprint: "1234567",
|
||||
receivers: ["by-name"],
|
||||
startsAt: "2018-08-14T17:36:40.017867056Z",
|
||||
generatorURL: "localhost/graph",
|
||||
status: {
|
||||
inhibitedBy: [],
|
||||
silencedBy: [],
|
||||
state: "suppressed",
|
||||
},
|
||||
};
|
||||
expect(alertToJSON(group, alert)).toHaveLength(2);
|
||||
expect(alertToJSON(group, alert)).toContainEqual(expected1);
|
||||
expect(alertToJSON(group, alert)).toContainEqual(expected2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { AlertStateT, APIAlertT, APIAlertGroupT } from "Models/APITypes";
|
||||
|
||||
export interface VanillaAlertT {
|
||||
labels: { [key: string]: string };
|
||||
annotations: { [key: string]: string };
|
||||
fingerprint: string;
|
||||
receivers: string[];
|
||||
startsAt: string;
|
||||
generatorURL: string;
|
||||
status: {
|
||||
inhibitedBy: string[];
|
||||
silencedBy: string[];
|
||||
state: AlertStateT;
|
||||
};
|
||||
}
|
||||
|
||||
export const alertToJSON = (
|
||||
group: APIAlertGroupT,
|
||||
alert: APIAlertT
|
||||
): VanillaAlertT[] => {
|
||||
const alerts: VanillaAlertT[] = [];
|
||||
|
||||
alert.alertmanager
|
||||
.map((am) => ({
|
||||
labels: Object.fromEntries([
|
||||
...group.labels.map((l) => [l.name, l.value]),
|
||||
...group.shared.labels.map((l) => [l.name, l.value]),
|
||||
...alert.labels.map((l) => [l.name, l.value]),
|
||||
]),
|
||||
annotations: Object.fromEntries([
|
||||
...group.shared.annotations.map((l) => [l.name, l.value]),
|
||||
...alert.annotations.map((l) => [l.name, l.value]),
|
||||
]),
|
||||
fingerprint: am.fingerprint,
|
||||
receivers: [alert.receiver],
|
||||
startsAt: am.startsAt,
|
||||
generatorURL: am.source,
|
||||
status: {
|
||||
inhibitedBy: am.inhibitedBy,
|
||||
silencedBy: am.silencedBy,
|
||||
state: am.state,
|
||||
},
|
||||
}))
|
||||
.forEach((a) => {
|
||||
let found = false;
|
||||
const js = JSON.stringify(a);
|
||||
for (const v of alerts) {
|
||||
if (JSON.stringify(v) === js) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
alerts.push(a);
|
||||
}
|
||||
});
|
||||
|
||||
return alerts;
|
||||
};
|
||||
@@ -2,6 +2,8 @@ import { act } from "react-dom/test-utils";
|
||||
|
||||
import { mount } from "enzyme";
|
||||
|
||||
import copy from "copy-to-clipboard";
|
||||
|
||||
import { MockAlertGroup, MockAlert } from "__fixtures__/Alerts";
|
||||
import type {
|
||||
APIAlertGroupT,
|
||||
@@ -12,6 +14,7 @@ import type {
|
||||
import { AlertStore } from "Stores/AlertStore";
|
||||
import { SilenceFormStore } from "Stores/SilenceFormStore";
|
||||
import { AlertMenu, MenuContent } from "./AlertMenu";
|
||||
import { alertToJSON } from "Common/Alert";
|
||||
|
||||
let alertStore: AlertStore;
|
||||
let silenceFormStore: SilenceFormStore;
|
||||
@@ -189,7 +192,7 @@ describe("<MenuContent />", () => {
|
||||
it("clicking on 'Silence' icon opens the silence form modal", () => {
|
||||
group.alertmanagerCount = { am1: 1, ro: 1 };
|
||||
const tree = MountedMenuContent(group);
|
||||
const button = tree.find(".dropdown-item").at(1);
|
||||
const button = tree.find(".dropdown-item").at(2);
|
||||
button.simulate("click");
|
||||
expect(silenceFormStore.toggle.visible).toBe(true);
|
||||
expect(silenceFormStore.data.alertmanagers).toMatchObject([
|
||||
@@ -203,7 +206,7 @@ describe("<MenuContent />", () => {
|
||||
upstreams.instances[2].readonly = true;
|
||||
alertStore.data.setUpstreams(upstreams);
|
||||
const tree = MountedMenuContent(group);
|
||||
const button = tree.find(".dropdown-item").at(1);
|
||||
const button = tree.find(".dropdown-item").at(2);
|
||||
expect(button.hasClass("disabled")).toBe(true);
|
||||
button.simulate("click");
|
||||
expect(silenceFormStore.toggle.visible).toBe(false);
|
||||
@@ -295,4 +298,12 @@ describe("<MenuContent />", () => {
|
||||
tree.find("a.dropdown-item[href='nonLinkNonActionShared']")
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("clicking on 'Copy' icon copies alert data to clipboard", async () => {
|
||||
group.alertmanagerCount = { am1: 1, ro: 1 };
|
||||
const tree = MountedMenuContent(group);
|
||||
const button = tree.find(".dropdown-item").at(1);
|
||||
button.simulate("click");
|
||||
expect(copy).toBeCalledWith(JSON.stringify(alertToJSON(group, alert)));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,11 +4,14 @@ import { observer } from "mobx-react-lite";
|
||||
|
||||
import { useFloating, shift, offset } from "@floating-ui/react-dom";
|
||||
|
||||
import copy from "copy-to-clipboard";
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCaretDown } from "@fortawesome/free-solid-svg-icons/faCaretDown";
|
||||
import { faBellSlash } from "@fortawesome/free-solid-svg-icons/faBellSlash";
|
||||
import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons/faExternalLinkAlt";
|
||||
import { faWrench } from "@fortawesome/free-solid-svg-icons/faWrench";
|
||||
import { faCopy } from "@fortawesome/free-solid-svg-icons/faCopy";
|
||||
|
||||
import type {
|
||||
APIGridT,
|
||||
@@ -26,6 +29,7 @@ import { DropdownSlide } from "Components/Animations/DropdownSlide";
|
||||
import { DateFromNow } from "Components/DateFromNow";
|
||||
import { useOnClickOutside } from "Hooks/useOnClickOutside";
|
||||
import { MenuLink } from "Components/Grid/AlertGrid/AlertGroup/MenuLink";
|
||||
import { alertToJSON } from "Common/Alert";
|
||||
|
||||
const onSilenceClick = (
|
||||
alertStore: AlertStore,
|
||||
@@ -104,6 +108,17 @@ const MenuContent: FC<{
|
||||
afterClick={afterClick}
|
||||
/>
|
||||
))}
|
||||
<div className="dropdown-divider" />
|
||||
<div
|
||||
className="dropdown-item cursor-pointer"
|
||||
onClick={() => {
|
||||
copy(JSON.stringify(alertToJSON(group, alert)));
|
||||
afterClick();
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon className="me-1" icon={faCopy} />
|
||||
Copy to clipboard
|
||||
</div>
|
||||
{actions.length ? (
|
||||
<>
|
||||
<div className="dropdown-divider" />
|
||||
|
||||
Reference in New Issue
Block a user