feat(ui): allow copying alerts data

This commit is contained in:
Łukasz Mierzwa
2022-07-06 20:07:32 +01:00
committed by Łukasz Mierzwa
parent e2f20d227c
commit 1a0a4f827d
5 changed files with 200 additions and 2 deletions
+4
View File
@@ -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.
+109
View File
@@ -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);
});
});
+59
View File
@@ -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" />