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 }) => ( +
+ +

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