diff --git a/ui/src/Common/Query.js b/ui/src/Common/Query.js
index a57c299cc..9bbba6ae2 100644
--- a/ui/src/Common/Query.js
+++ b/ui/src/Common/Query.js
@@ -7,7 +7,8 @@ const StaticLabels = Object.freeze({
AlertName: "alertname",
AlertManager: "@alertmanager",
Receiver: "@receiver",
- State: "@state"
+ State: "@state",
+ SilenceID: "@silence_id"
});
function FormatQuery(name, operator, value) {
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.js
new file mode 100644
index 000000000..673f588d4
--- /dev/null
+++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.js
@@ -0,0 +1,259 @@
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+
+import { observable, action } from "mobx";
+import { observer } from "mobx-react";
+
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faTrash } from "@fortawesome/free-solid-svg-icons/faTrash";
+import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclamationCircle";
+import { faCheckCircle } from "@fortawesome/free-solid-svg-icons/faCheckCircle";
+
+import { APIAlertmanagerUpstream } from "Models/API";
+import { AlertStore, FormatBackendURI, FormatAlertsQ } from "Stores/AlertStore";
+import { FormatQuery, QueryOperators, StaticLabels } from "Common/Query";
+import { Modal } from "Components/Modal";
+import {
+ LabelSetList,
+ GroupListToUniqueLabelsList
+} from "Components/LabelSetList";
+
+const ErrorMessage = ({ message }) => (
+
+);
+ErrorMessage.propTypes = {
+ message: PropTypes.node.isRequired
+};
+
+const SuccessMessage = () => (
+
+
+
+ Silence deleted, it might take a few minutes for affected alerts to change
+ state
+
+
+);
+
+const DeleteSilenceModalContent = observer(
+ class DeleteSilenceModalContent extends Component {
+ static propTypes = {
+ alertStore: PropTypes.instanceOf(AlertStore).isRequired,
+ alertmanager: APIAlertmanagerUpstream.isRequired,
+ silenceID: PropTypes.string.isRequired,
+ onHide: PropTypes.func.isRequired
+ };
+
+ previewState = observable(
+ {
+ fetch: null,
+ error: null,
+ alertLabels: [],
+ setError(err) {
+ this.error = err;
+ },
+ groupsToUniqueLabels(groups) {
+ this.alertLabels = GroupListToUniqueLabelsList(groups);
+ }
+ },
+ {
+ setError: action.bound,
+ groupsToUniqueLabels: action.bound
+ }
+ );
+
+ deleteState = observable(
+ {
+ fetch: null,
+ done: false,
+ error: null,
+ setDone() {
+ this.done = true;
+ },
+ setError(err) {
+ this.error = err;
+ }
+ },
+ {
+ setDone: action.bound,
+ setError: action.bound
+ }
+ );
+
+ parseAlertmanagerResponse = response => {
+ /*
+ {"status": "success"}
+ or
+ {
+ "status": "error",
+ "errorType": "bad_data",
+ "error": "silence 706959fd-4590-4e21-b983-859ba6ec0e1a already expired"
+ }
+ */
+ if (response.status === "success") {
+ this.deleteState.setError(null);
+ } else if (response.status === "error" && response.error) {
+ this.deleteState.setError(response.error);
+ } else {
+ this.deleteState.setError(JSON.stringify(response));
+ }
+ this.deleteState.setDone();
+ };
+
+ onFetchPreview = () => {
+ const { silenceID } = this.props;
+
+ const alertsURI =
+ FormatBackendURI("alerts.json?") +
+ FormatAlertsQ([
+ FormatQuery(StaticLabels.SilenceID, QueryOperators.Equal, silenceID)
+ ]);
+
+ this.previewState.fetch = fetch(alertsURI, { credentials: "include" })
+ .then(result => {
+ return result.json();
+ })
+ .then(result => {
+ this.previewState.groupsToUniqueLabels(Object.values(result.groups));
+ this.previewState.setError(null);
+ })
+ .catch(err => {
+ console.trace(err);
+ return this.previewState.setError(
+ `Request fetching affected alerts failed with: ${err.message}`
+ );
+ });
+ };
+
+ onDelete = () => {
+ const { alertmanager, silenceID } = this.props;
+
+ // if it's already deleted then do nothing
+ if (this.deleteState.done && this.deleteState.error === null) return;
+
+ const uri = `${alertmanager.publicURI}/api/v1/silence/${silenceID}`;
+ this.deleteState.fetch = fetch(uri, { method: "DELETE" })
+ .then(result => result.json())
+ .then(result => this.parseAlertmanagerResponse(result))
+ .catch(err => {
+ console.trace(err);
+ this.deleteState.setDone();
+ this.deleteState.setError(
+ `Delete request failed with: ${err.message}`
+ );
+ });
+ };
+
+ componentDidMount() {
+ this.onFetchPreview();
+ }
+
+ render() {
+ const { alertStore, onHide } = this.props;
+
+ const isDone = this.deleteState.done && this.deleteState.error === null;
+
+ return (
+
+
+
Delete silence
+
+
+
+ {this.deleteState.done ? (
+ this.deleteState.error !== null ? (
+
+ ) : (
+
+ )
+ ) : this.previewState.error === null ? (
+
+
+ Alerts affected by this silence
+
+
+
+ ) : (
+
+ )}
+ {isDone ? null : (
+
+
+
+ )}
+
+
+ );
+ }
+ }
+);
+
+const DeleteSilence = observer(
+ class DeleteSilence extends Component {
+ static propTypes = {
+ alertStore: PropTypes.instanceOf(AlertStore).isRequired,
+ alertmanager: APIAlertmanagerUpstream.isRequired,
+ silenceID: PropTypes.string.isRequired
+ };
+
+ toggle = observable(
+ {
+ visible: false,
+ toggle() {
+ this.visible = !this.visible;
+ }
+ },
+ { toggle: action.bound }
+ );
+
+ render() {
+ const { alertStore, alertmanager, silenceID } = this.props;
+
+ return (
+
+
+
+ Delete
+
+
+
+
+
+ );
+ }
+ }
+);
+
+export { DeleteSilence, DeleteSilenceModalContent };
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.test.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.test.js
new file mode 100644
index 000000000..68a22b0df
--- /dev/null
+++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.test.js
@@ -0,0 +1,178 @@
+import React from "react";
+
+import { mount } from "enzyme";
+
+import { EmptyAPIResponse } from "__mocks__/Fetch";
+import { MockAlertGroup, MockAlert, MockAlertmanager } from "__mocks__/Alerts";
+import { AlertStore } from "Stores/AlertStore";
+import { DeleteSilence, DeleteSilenceModalContent } from "./DeleteSilence";
+
+let alertmanager;
+let alertStore;
+
+beforeEach(() => {
+ alertmanager = MockAlertmanager();
+ alertStore = new AlertStore([]);
+ fetch.mockResponseOnce(JSON.stringify(MockAPIResponse()));
+
+ jest.restoreAllMocks();
+});
+
+afterEach(() => {
+ jest.restoreAllMocks();
+ fetch.resetMocks();
+});
+
+const MockOnHide = jest.fn();
+
+const MockAPIResponse = () => {
+ const response = EmptyAPIResponse();
+ response.groups = {
+ "1": MockAlertGroup(
+ { alertname: "foo" },
+ [MockAlert([], { instance: "foo" }, "suppressed")],
+ [],
+ { job: "foo" }
+ )
+ };
+ return response;
+};
+
+const MountedDeleteSilence = () => {
+ return mount(
+
+ );
+};
+
+const MountedDeleteSilenceModalContent = () => {
+ return mount(
+
+ );
+};
+
+const VerifyResponse = async response => {
+ const tree = MountedDeleteSilenceModalContent();
+ await expect(tree.instance().previewState.fetch).resolves.toBeUndefined();
+
+ fetch.mockResponseOnce(JSON.stringify(response));
+ tree.find(".btn-outline-danger").simulate("click");
+ await expect(tree.instance().deleteState.fetch).resolves.toBeUndefined();
+
+ return tree;
+};
+
+describe("", async () => {
+ it("label is 'Delete' by default", () => {
+ const tree = MountedDeleteSilence();
+ expect(tree.text()).toBe("Delete");
+ });
+
+ it("opens modal on click", async () => {
+ const tree = MountedDeleteSilence();
+ tree.simulate("click");
+ expect(tree.find(".modal-body")).toHaveLength(1);
+ });
+});
+
+describe("", () => {
+ it("renders LabelSetList on mount", async () => {
+ const tree = MountedDeleteSilenceModalContent();
+ expect(tree.find("LabelSetList")).toHaveLength(1);
+ });
+
+ it("fetches affected alerts on mount", async () => {
+ MountedDeleteSilenceModalContent();
+ expect(fetch).toHaveBeenCalled();
+ });
+
+ it("renders ErrorMessage on failed fetch", async () => {
+ jest.spyOn(console, "trace").mockImplementation(() => {});
+ fetch.resetMocks();
+ fetch.mockReject("Fetch error");
+
+ const tree = MountedDeleteSilenceModalContent();
+ await expect(tree.instance().previewState.fetch).resolves.toBeUndefined();
+ tree.update();
+ expect(tree.find("ErrorMessage")).toHaveLength(1);
+ });
+
+ it("renders ErrorMessage on fetch with non-JSON response", async () => {
+ fetch.mockResponseOnce("not json");
+ jest.spyOn(console, "trace").mockImplementation(() => {});
+ fetch.resetMocks();
+ fetch.mockReject("Fetch error");
+
+ const tree = MountedDeleteSilenceModalContent();
+ await expect(tree.instance().previewState.fetch).resolves.toBeUndefined();
+ tree.update();
+ expect(tree.find("ErrorMessage")).toHaveLength(1);
+ });
+
+ it("sends a DELETE request after clicking 'Confirm' button", async () => {
+ await VerifyResponse({ status: "success" });
+ expect(fetch.mock.calls[1][0]).toBe(
+ "http://am.example.com/api/v1/silence/123456789"
+ );
+ expect(fetch.mock.calls[1][1]).toMatchObject({ method: "DELETE" });
+ });
+
+ it("'Confirm' button is no-op after successful DELETE", async () => {
+ const tree = await VerifyResponse({ status: "success" });
+ expect(fetch.mock.calls[1][0]).toBe(
+ "http://am.example.com/api/v1/silence/123456789"
+ );
+ expect(fetch.mock.calls[1][1]).toMatchObject({ method: "DELETE" });
+
+ expect(fetch.mock.calls).toHaveLength(2);
+ tree.find(".btn-outline-danger").simulate("click");
+ expect(fetch.mock.calls).toHaveLength(2);
+ });
+
+ it("renders SuccessMessage on 'success' response status", async () => {
+ const tree = await VerifyResponse({ status: "success" });
+ tree.update();
+ expect(tree.find("SuccessMessage")).toHaveLength(1);
+ });
+
+ it("renders ErrorMessage on 'error' response status", async () => {
+ const tree = await VerifyResponse({ status: "error", error: "fake error" });
+ tree.update();
+ expect(tree.find("ErrorMessage")).toHaveLength(1);
+ });
+
+ it("renders ErrorMessage on unhandled response status", async () => {
+ const tree = await VerifyResponse({ status: "foo bar" });
+ tree.update();
+ expect(tree.find("ErrorMessage")).toHaveLength(1);
+ });
+
+ it("renders ErrorMessage on unhandled response body", async () => {
+ const tree = await VerifyResponse({ foo: "bar" });
+ tree.update();
+ expect(tree.find("ErrorMessage")).toHaveLength(1);
+ });
+
+ it("renders ErrorMessage on failed fetch request", async () => {
+ const tree = MountedDeleteSilenceModalContent();
+ await expect(tree.instance().previewState.fetch).resolves.toBeUndefined();
+
+ jest.spyOn(console, "trace").mockImplementation(() => {});
+ fetch.resetMocks();
+ fetch.mockReject("Fetch error");
+
+ tree.find(".btn-outline-danger").simulate("click");
+ await expect(tree.instance().deleteState.fetch).resolves.toBeUndefined();
+
+ tree.update();
+ expect(tree.find("ErrorMessage")).toHaveLength(1);
+ });
+});
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/__snapshots__/index.test.js.snap b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/__snapshots__/index.test.js.snap
index a50c3fed5..33a2ff890 100644
--- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/__snapshots__/index.test.js.snap
+++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/__snapshots__/index.test.js.snap
@@ -172,7 +172,7 @@ exports[` matches snapshot with expaned details 1`] = `
in 5 hours
-
+
matches snapshot with expaned details 1`] = `
Edit
+
+
+ Delete
+
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js
index 38d5f34af..4e26a0e13 100644
--- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js
+++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js
@@ -29,6 +29,7 @@ import { StaticLabels, QueryOperators } from "Common/Query";
import { FilteringLabel } from "Components/Labels/FilteringLabel";
import { TooltipWrapper } from "Components/TooltipWrapper";
import { RenderLinkAnnotation } from "../Annotation";
+import { DeleteSilence } from "./DeleteSilence";
import "./index.css";
@@ -87,7 +88,12 @@ SilenceExpiryBadgeWithProgress.propTypes = {
progress: PropTypes.number.isRequired
};
-const SilenceDetails = ({ alertmanager, silence, onEditSilence }) => {
+const SilenceDetails = ({
+ alertStore,
+ alertmanager,
+ silence,
+ onEditSilence
+}) => {
let expiresClass = "";
let expiresLabel = "Expires";
if (moment(silence.endsAt) < moment()) {
@@ -119,12 +125,17 @@ const SilenceDetails = ({ alertmanager, silence, onEditSilence }) => {
{expiresLabel} {silence.endsAt}
Edit
+
@@ -277,7 +288,7 @@ const Silence = inject("alertStore")(
}
render() {
- const { alertmanagerState, silenceID } = this.props;
+ const { alertStore, alertmanagerState, silenceID } = this.props;
const silence = this.getSilence();
if (!silence)
@@ -320,6 +331,7 @@ const Silence = inject("alertStore")(
{this.collapse.value ? null : (
{
return mount(