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("