diff --git a/CHANGELOG.md b/CHANGELOG.md index e72de5701..317f733b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/ui/src/Common/Alert.test.ts b/ui/src/Common/Alert.test.ts new file mode 100644 index 000000000..332f1f623 --- /dev/null +++ b/ui/src/Common/Alert.test.ts @@ -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); + }); +}); diff --git a/ui/src/Common/Alert.ts b/ui/src/Common/Alert.ts new file mode 100644 index 000000000..0a9b727a7 --- /dev/null +++ b/ui/src/Common/Alert.ts @@ -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; +}; diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.test.tsx b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.test.tsx index e2a96ead9..8f6b3968c 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.test.tsx +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.test.tsx @@ -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("", () => { 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("", () => { 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("", () => { 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))); + }); }); diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.tsx b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.tsx index 1e6649203..715f367bc 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.tsx +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.tsx @@ -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} /> ))} +
+
{ + copy(JSON.stringify(alertToJSON(group, alert))); + afterClick(); + }} + > + + Copy to clipboard +
{actions.length ? ( <>