From 96cd8a856bc97a6632341ddd39d4b15e6e9801d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Sat, 26 Oct 2019 17:35:46 +0100 Subject: [PATCH] chore(ui): replace Silence usage with ManagedSilence --- .../Grid/AlertGrid/AlertGroup/Alert/index.js | 22 +- .../AlertGrid/AlertGroup/Alert/index.test.js | 57 ++- .../__snapshots__/index.test.js.snap | 105 +++-- .../AlertGrid/AlertGroup/GroupFooter/index.js | 32 +- .../AlertGroup/GroupFooter/index.test.js | 35 +- .../AlertGroup/Silence/DeleteSilence.js | 299 -------------- .../AlertGroup/Silence/DeleteSilence.test.js | 237 ----------- .../Silence/__snapshots__/index.test.js.snap | 272 ------------- .../AlertGrid/AlertGroup/Silence/index.css | 8 - .../AlertGrid/AlertGroup/Silence/index.js | 375 ------------------ .../AlertGroup/Silence/index.test.js | 306 -------------- .../Grid/AlertGrid/AlertGroup/Silences.js | 57 +++ .../Grid/AlertGrid/AlertGroup/index.js | 1 + 13 files changed, 220 insertions(+), 1586 deletions(-) delete mode 100644 ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.js delete mode 100644 ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.test.js delete mode 100644 ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/__snapshots__/index.test.js.snap delete mode 100644 ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.css delete mode 100644 ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js delete mode 100644 ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.test.js create mode 100644 ui/src/Components/Grid/AlertGrid/AlertGroup/Silences.js diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.js index 9c9847b67..cd8e45c11 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.js @@ -14,8 +14,8 @@ import { StaticLabels } from "Common/Query"; import { FilteringLabel } from "Components/Labels/FilteringLabel"; import { TooltipWrapper } from "Components/TooltipWrapper"; import { RenderNonLinkAnnotation, RenderLinkAnnotation } from "../Annotation"; -import { Silence } from "../Silence"; import { AlertMenu } from "./AlertMenu"; +import { RenderSilence } from "../Silences"; import "./index.scss"; @@ -132,16 +132,16 @@ const Alert = observer( value={a.value} /> ))} - {Object.values(silences).map(clusterSilences => - clusterSilences.silences.map(silenceID => ( - - )) + {Object.entries(silences).map(([cluster, clusterSilences]) => + clusterSilences.silences.map(silenceID => + RenderSilence( + alertStore, + silenceFormStore, + afterUpdate, + cluster, + silenceID + ) + ) )} ); diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.test.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.test.js index f62772480..9e107b427 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.test.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.test.js @@ -10,7 +10,12 @@ import toDiffableHtml from "diffable-html"; import Moment from "react-moment"; -import { MockAlert, MockAnnotation, MockAlertGroup } from "__mocks__/Alerts.js"; +import { + MockAlert, + MockAnnotation, + MockAlertGroup, + MockSilence +} from "__mocks__/Alerts.js"; import { AlertStore } from "Stores/AlertStore"; import { SilenceFormStore } from "Stores/SilenceFormStore"; import { BorderClassMap } from "Common/Colors"; @@ -110,11 +115,46 @@ describe("", () => { it("renders a silence if alert is silenced", () => { const alert = MockedAlert(); alert.alertmanager[0].silencedBy = ["silence123456789"]; + alertStore.data.silences = { + default: { + silence123456789: MockSilence() + } + }; const group = MockAlertGroup({}, [alert], [], {}, { default: [] }); const tree = MountedAlert(alert, group, false, false); - const silence = tree.find("Silence"); + const silence = tree.find("ManagedSilence"); expect(silence).toHaveLength(1); - expect(silence.html()).toMatch(/silence123456789/); + expect(silence.html()).toMatch(/Mocked Silence/); + }); + + it("renders a fallback silence if the silence is not found in alertStore", () => { + const alert = MockedAlert(); + alert.alertmanager[0].silencedBy = ["silence123456789"]; + alertStore.data.silences = { + default: { + "123": MockSilence() + } + }; + const group = MockAlertGroup({}, [alert], [], {}, { default: [] }); + const tree = MountedAlert(alert, group, false, false); + const silence = tree.find("FallbackSilenceDesciption"); + expect(silence).toHaveLength(1); + expect(silence.html()).not.toMatch(/Mocked Silence/); + }); + + it("renders a fallback silence if the cluster is not found in alertStore", () => { + const alert = MockedAlert(); + alert.alertmanager[0].silencedBy = ["silence123456789"]; + alertStore.data.silences = { + foo: { + "123": MockSilence() + } + }; + const group = MockAlertGroup({}, [alert], [], {}, { default: [] }); + const tree = MountedAlert(alert, group, false, false); + const silence = tree.find("FallbackSilenceDesciption"); + expect(silence).toHaveLength(1); + expect(silence.html()).not.toMatch(/Mocked Silence/); }); it("renders only one silence for HA cluster", () => { @@ -139,11 +179,16 @@ describe("", () => { inhibitedBy: [] } ]; + alertStore.data.silences = { + ha: { + silence123456789: MockSilence() + } + }; const group = MockAlertGroup({}, [alert], [], {}, {}); const tree = MountedAlert(alert, group, false, false); - const silence = tree.find("Silence"); + const silence = tree.find("ManagedSilence"); expect(silence).toHaveLength(1); - expect(silence.html()).toMatch(/silence123456789/); + expect(silence.html()).toMatch(/Mocked Silence/); }); it("doesn't render shared silences", () => { @@ -157,7 +202,7 @@ describe("", () => { { default: ["silence123456789"] } ); const tree = MountedAlert(alert, group, false, false); - const silence = tree.find("Silence"); + const silence = tree.find("ManagedSilence"); expect(silence).toHaveLength(0); }); diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/__snapshots__/index.test.js.snap b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/__snapshots__/index.test.js.snap index a6f4d0a82..4e1fbe5fc 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/__snapshots__/index.test.js.snap +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/__snapshots__/index.test.js.snap @@ -152,7 +152,7 @@ exports[` mathes snapshot when silence is rendered 1`] = `
@@ -183,7 +183,7 @@ exports[` mathes snapshot when silence is rendered 1`] = `
@@ -208,7 +208,7 @@ exports[` mathes snapshot when silence is rendered 1`] = `
@@ -223,7 +223,7 @@ exports[` mathes snapshot when silence is rendered 1`] = `
@@ -238,7 +238,7 @@ exports[` mathes snapshot when silence is rendered 1`] = `
@@ -253,7 +253,7 @@ exports[` mathes snapshot when silence is rendered 1`] = `
@@ -286,55 +286,54 @@ exports[` mathes snapshot when silence is rendered 1`] = ` link -
-
-
- - - - - - Mocked Silence - - - … - - - - -
+
+
+
+
+ + - - - - -
+ + + + Mocked Silence + + + … + + + + + me@example.com + + + Expired + + + - - me@example.com - - - Expired - - - - +
+
+ + + + +
+
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.js index f9e6e82d3..62288f65b 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.js @@ -5,10 +5,11 @@ import { observer } from "mobx-react"; import { APIGroup } from "Models/API"; import { StaticLabels } from "Common/Query"; +import { AlertStore } from "Stores/AlertStore"; import { SilenceFormStore } from "Stores/SilenceFormStore"; import { FilteringLabel } from "Components/Labels/FilteringLabel"; import { RenderNonLinkAnnotation, RenderLinkAnnotation } from "../Annotation"; -import { Silence } from "../Silence"; +import { RenderSilence } from "../Silences"; import "./index.css"; @@ -18,6 +19,7 @@ const GroupFooter = observer( group: APIGroup.isRequired, alertmanagers: PropTypes.arrayOf(PropTypes.string).isRequired, afterUpdate: PropTypes.func.isRequired, + alertStore: PropTypes.instanceOf(AlertStore).isRequired, silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired }; @@ -26,6 +28,7 @@ const GroupFooter = observer( group, alertmanagers, afterUpdate, + alertStore, silenceFormStore } = this.props; @@ -65,25 +68,18 @@ const GroupFooter = observer( /> ))} {Object.keys(group.shared.silences).length === 0 ? null : ( -
+
{Object.entries(group.shared.silences).map( ([cluster, silences]) => - silences.map(silenceID => ( - - a.alertmanager.filter( - am => am.cluster === cluster - )[0] - )[0] - } - silenceID={silenceID} - afterUpdate={afterUpdate} - /> - )) + silences.map(silenceID => + RenderSilence( + alertStore, + silenceFormStore, + afterUpdate, + cluster, + silenceID + ) + ) )}
)} diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.test.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.test.js index 0849fc0ea..8da6f29e3 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.test.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.test.js @@ -64,6 +64,7 @@ const MountedGroupFooter = () => { group={group} alertmanagers={["default"]} afterUpdate={MockAfterUpdate} + alertStore={alertStore} silenceFormStore={silenceFormStore} /> @@ -81,8 +82,40 @@ describe("", () => { group.alerts[id].alertmanager[0].silencedBy = ["123456789"]; } group.shared.silences = { default: ["123456789"] }; + alertStore.data.silences = { + default: { + "123456789": MockSilence() + } + }; + const tree = MountedGroupFooter().find("GroupFooter"); - expect(tree.find("Silence")).toHaveLength(1); + expect(tree.find("ManagedSilence")).toHaveLength(1); + }); + + it("render fallback silence if not found in alertStore", () => { + for (const id of Object.keys(group.alerts)) { + group.alerts[id].alertmanager[0].silencedBy = ["123456789"]; + } + group.shared.silences = { default: ["123456789"] }; + alertStore.data.silences = { + default: {} + }; + + const tree = MountedGroupFooter().find("GroupFooter"); + expect(tree.find("FallbackSilenceDesciption")).toHaveLength(1); + }); + + it("render fallback silence if cluster not found in alertStore", () => { + for (const id of Object.keys(group.alerts)) { + group.alerts[id].alertmanager[0].silencedBy = ["123456789"]; + } + group.shared.silences = { default: ["123456789"] }; + alertStore.data.silences = { + foo: {} + }; + + const tree = MountedGroupFooter().find("GroupFooter"); + expect(tree.find("FallbackSilenceDesciption")).toHaveLength(1); }); it("mathes snapshot when silence is rendered", () => { diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.js deleted file mode 100644 index 42b8eaf29..000000000 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.js +++ /dev/null @@ -1,299 +0,0 @@ -import React, { Component } from "react"; -import PropTypes from "prop-types"; - -import { observable, action } from "mobx"; -import { observer } from "mobx-react"; - -import semver from "semver"; - -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 { faCircleNotch } from "@fortawesome/free-solid-svg-icons/faCircleNotch"; - -import { APIAlertmanagerUpstream } from "Models/API"; -import { AlertStore, FormatBackendURI, FormatAlertsQ } from "Stores/AlertStore"; -import { FormatQuery, QueryOperators, StaticLabels } from "Common/Query"; -import { FetchWithCredentials } from "Common/Fetch"; -import { Modal } from "Components/Modal"; -import { - LabelSetList, - GroupListToUniqueLabelsList -} from "Components/LabelSetList"; - -const ProgressMessage = () => ( -
- -
-); - -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; - }, - reset() { - this.done = false; - this.error = null; - } - }, - { - setDone: action.bound, - setError: action.bound, - reset: 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 = FetchWithCredentials(alertsURI, {}) - .then(result => 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; - - // reset state so we get a spinner - this.deleteState.reset(); - - const isOpenAPI = semver.satisfies(alertmanager.version, ">=0.16.0"); - - const uri = isOpenAPI - ? `${alertmanager.uri}/api/v2/silence/${silenceID}` - : `${alertmanager.uri}/api/v1/silence/${silenceID}`; - - this.deleteState.fetch = FetchWithCredentials(uri, { - method: "DELETE", - headers: alertmanager.headers - }) - .then(result => { - if (isOpenAPI) { - if (result.ok) { - this.deleteState.setError(null); - this.deleteState.setDone(); - } else { - result.text().then(this.deleteState.setError); - this.deleteState.setDone(); - } - } else { - result.json().then(this.parseAlertmanagerResponse); - } - }) - .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.deleteState.fetch !== null ? ( - - ) : this.previewState.error === null ? ( - - ) : ( - - )} - {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 deleted file mode 100644 index d5b677009..000000000 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.test.js +++ /dev/null @@ -1,237 +0,0 @@ -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([]); - alertStore.data.upstreams.instances[0] = alertmanager; - 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("", () => { - it("label is 'Delete' by default", () => { - const tree = MountedDeleteSilence(); - expect(tree.text()).toBe("Delete"); - }); - - it("opens modal on click", () => { - const tree = MountedDeleteSilence(); - tree - .find(".badge") - .at(0) - .simulate("click"); - expect(tree.find(".modal-body")).toHaveLength(1); - }); -}); - -describe("", () => { - it("renders LabelSetList on mount", () => { - const tree = MountedDeleteSilenceModalContent(); - expect(tree.find("LabelSetList")).toHaveLength(1); - }); - - it("fetches affected alerts on mount", () => { - 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("[v1] sends a DELETE request after clicking 'Confirm' button", async () => { - await VerifyResponse({ status: "success" }); - expect(fetch.mock.calls[1][0]).toBe( - "http://localhost/api/v1/silence/123456789" - ); - expect(fetch.mock.calls[1][1]).toMatchObject({ method: "DELETE" }); - }); - - it("[v2] sends a DELETE request after clicking 'Confirm' button", async () => { - alertmanager.version = "0.16.2"; - await VerifyResponse({ status: "success" }); - expect(fetch.mock.calls[1][0]).toBe( - "http://localhost/api/v2/silence/123456789" - ); - expect(fetch.mock.calls[1][1]).toMatchObject({ method: "DELETE" }); - }); - - it("[v1] sends headers from alertmanager config", async () => { - alertmanager.headers = { Authorization: "Basic ***" }; - await VerifyResponse({ status: "success" }); - expect(fetch.mock.calls[1][0]).toBe( - "http://localhost/api/v1/silence/123456789" - ); - expect(fetch.mock.calls[1][1]).toMatchObject({ - credentials: "include", - method: "DELETE", - headers: { Authorization: "Basic ***" } - }); - }); - - it("[v1] sends headers from alertmanager config", async () => { - alertmanager.headers = { Authorization: "Basic ***" }; - alertmanager.version = "0.16.2"; - await VerifyResponse({ status: "success" }); - expect(fetch.mock.calls[1][0]).toBe( - "http://localhost/api/v2/silence/123456789" - ); - expect(fetch.mock.calls[1][1]).toMatchObject({ - credentials: "include", - method: "DELETE", - headers: { Authorization: "Basic ***" } - }); - }); - - it("'Confirm' button is no-op after successful DELETE", async () => { - const tree = await VerifyResponse({ status: "success" }); - expect(fetch.mock.calls[1][0]).toBe( - "http://localhost/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); - tree.instance().onDelete(); - 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("[v1] 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); - }); - - it("[v2] renders ErrorMessage on failed fetch request", async () => { - alertmanager.version = "0.16.2"; - const tree = MountedDeleteSilenceModalContent(); - await expect(tree.instance().previewState.fetch).resolves.toBeUndefined(); - - jest.spyOn(console, "trace").mockImplementation(() => {}); - fetch.resetMocks(); - fetch.mockResponseOnce("500 Internal Server Error", { status: 500 }); - - 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 deleted file mode 100644 index cf84ad9ab..000000000 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/__snapshots__/index.test.js.snap +++ /dev/null @@ -1,272 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` matches snapshot when data is not present in alertStore 1`] = ` -" -
- - Silenced by default/4cf5fd82-1edd-4169-99d1-ff8415e72179 - -
-" -`; - -exports[` matches snapshot when data is present in alertStore 1`] = ` -" -
-
- - - - - - Fake silence - - - … - - - - -
- - - - -
-
- - me@example.com - - - Expires - -
-
-
-
-
-
-
-
-
-" -`; - -exports[` matches snapshot with expaned details 1`] = ` -" -
-
- - - - - - Fake silence - - - … - - - - -
- - - - -
-
- - me@example.com - -
-
-
-
-
-
- - - @alertmanager: - - - default - - -
- - - - - - 4cf5fd82-1edd-4169-99d1-ff8415e72179 - -
-
- - - - - - Started - - - - - - - - Expires - - - - - - - - Edit - - - - - - - Delete - -
-
-
- - - - - - Matchers: - -
-
- - alertname=MockAlert - - - instance=~foo[0-9]+ - -
-
-
-
-" -`; diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.css b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.css deleted file mode 100644 index d88dc1c88..000000000 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.css +++ /dev/null @@ -1,8 +0,0 @@ -.progress.silence-progress { - height: 2px; - margin-top: 2px; -} - -.cite.components-grid-alertgroup-silences { - font-size: 100%; -} diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js deleted file mode 100644 index 026649b84..000000000 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js +++ /dev/null @@ -1,375 +0,0 @@ -import React, { Component } from "react"; -import PropTypes from "prop-types"; - -import { observable, action } from "mobx"; -import { observer, inject } from "mobx-react"; - -import hash from "object-hash"; - -import moment from "moment"; -import Moment from "react-moment"; - -import Truncate from "react-truncate"; - -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons/faExternalLinkAlt"; -import { faChevronUp } from "@fortawesome/free-solid-svg-icons/faChevronUp"; -import { faChevronDown } from "@fortawesome/free-solid-svg-icons/faChevronDown"; -import { faEdit } from "@fortawesome/free-solid-svg-icons/faEdit"; -import { faCalendarCheck } from "@fortawesome/free-solid-svg-icons/faCalendarCheck"; -import { faCalendarTimes } from "@fortawesome/free-solid-svg-icons/faCalendarTimes"; -import { faFilter } from "@fortawesome/free-solid-svg-icons/faFilter"; - -import { - APIAlertAlertmanagerState, - APIAlertmanagerUpstream, - APISilence -} from "Models/API"; -import { AlertStore } from "Stores/AlertStore"; -import { SilenceFormStore } from "Stores/SilenceFormStore"; -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"; - -const SilenceComment = ({ silence, collapsed, afterUpdate }) => { - const showLines = 2; - if (silence.jiraURL) { - return ( - - - - {silence.comment} - - - ); - } - return ( - {silence.comment} - ); -}; -SilenceComment.propTypes = { - silence: APISilence.isRequired, - collapsed: PropTypes.bool.isRequired, - afterUpdate: PropTypes.func.isRequired -}; - -const SilenceExpiryBadgeWithProgress = ({ silence, progress }) => { - // if silence is expired we can skip progress value calculation - if (moment(silence.endsAt) < moment()) { - return ( - - Expired {silence.endsAt} - - ); - } - - let progressClass; - if (progress > 90) { - progressClass = "progress-bar bg-danger"; - } else if (progress > 75) { - progressClass = "progress-bar bg-warning"; - } else { - progressClass = "progress-bar bg-success"; - } - - return ( - - Expires {silence.endsAt} -
-
-
- - ); -}; -SilenceExpiryBadgeWithProgress.propTypes = { - silence: APISilence.isRequired, - progress: PropTypes.number.isRequired -}; - -const SilenceDetails = ({ - alertStore, - alertmanager, - silence, - onEditSilence -}) => { - let expiresClass = ""; - let expiresLabel = "Expires"; - if (moment(silence.endsAt) < moment()) { - expiresClass = "text-danger"; - expiresLabel = "Expired"; - } - - return ( -
-
- - -
-
- - - Started {silence.startsAt} - - - - {expiresLabel} {silence.endsAt} - - - - Edit - - -
-
-
- - - Matchers: - -
-
- {silence.matchers.map(matcher => ( - - {matcher.name} - {matcher.isRegex ? QueryOperators.Regex : QueryOperators.Equal} - {matcher.value} - - ))} -
-
-
- ); -}; -SilenceDetails.propTypes = { - alertmanager: APIAlertmanagerUpstream.isRequired, - silence: APISilence.isRequired, - onEditSilence: PropTypes.func.isRequired -}; - -// -const FallbackSilenceDesciption = ({ alertmanagerName, silenceID }) => { - return ( -
- - Silenced by {alertmanagerName}/{silenceID} - -
- ); -}; -FallbackSilenceDesciption.propTypes = { - alertmanagerName: PropTypes.string.isRequired, - silenceID: PropTypes.string.isRequired -}; - -const Silence = inject("alertStore")( - observer( - class Silence extends Component { - static propTypes = { - alertStore: PropTypes.instanceOf(AlertStore).isRequired, - silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired, - alertmanagerState: APIAlertAlertmanagerState.isRequired, - silenceID: PropTypes.string.isRequired, - afterUpdate: PropTypes.func.isRequired - }; - - // store collapse state, by default only silence comment is visible - // the rest of the silence is hidden until expanded by a click - collapse = observable( - { - value: true, - toggle() { - this.value = !this.value; - } - }, - { toggle: action.bound }, - { name: "Silence collpase toggle" } - ); - - progress = observable( - { - value: 0, - calculate(startsAt, endsAt) { - const durationDone = moment().unix() - moment(startsAt).unix(); - const durationTotal = - moment(endsAt).unix() - moment(startsAt).unix(); - const durationPercent = Math.floor( - (durationDone / durationTotal) * 100 - ); - if (this.value !== durationPercent) { - this.value = durationPercent; - } - } - }, - { - calculate: action.bound - } - ); - - constructor(props) { - super(props); - - this.recalculateProgress(); - this.progressTimer = setInterval(this.recalculateProgress, 30 * 1000); - } - - getAlertmanager = () => { - const { alertStore, alertmanagerState } = this.props; - - const alertmanager = alertStore.data.getAlertmanagerByName( - alertmanagerState.name - ); - - if (alertmanager) return alertmanager; - - return { - name: alertmanagerState.name - }; - }; - - getSilence = () => { - const { alertStore, alertmanagerState, silenceID } = this.props; - - // We pass alertmanager name and silence ID to Silence component - // and we need to lookup the actual silence data in the store. - // Data might be missing from the store so first check if we have - // anything for this alertmanager instance - const amSilences = alertStore.data.silences[alertmanagerState.cluster]; - if (!amSilences) return null; - - // next check if alertmanager has our silence ID - const silence = amSilences[silenceID]; - if (!silence) return null; - - return silence; - }; - - recalculateProgress = () => { - const silence = this.getSilence(); - if (silence !== null) { - this.progress.calculate(silence.startsAt, silence.endsAt); - } - }; - - onEditSilence = () => { - const { silenceFormStore } = this.props; - - const silence = this.getSilence(); - const alertmanager = this.getAlertmanager(); - - silenceFormStore.data.fillFormFromSilence(alertmanager, silence); - silenceFormStore.data.resetProgress(); - silenceFormStore.toggle.show(); - }; - - componentDidUpdate() { - const { afterUpdate } = this.props; - afterUpdate(); - } - - componentWillUnmount() { - clearInterval(this.progressTimer); - this.progressTimer = null; - } - - render() { - const { - alertStore, - alertmanagerState, - silenceID, - afterUpdate - } = this.props; - - const silence = this.getSilence(); - if (!silence) - return ( - - ); - - const alertmanager = this.getAlertmanager(); - - return ( -
-
- - - - - - - - - - {silence.createdBy} - - {this.collapse.value ? ( - - ) : null} - - -
- {this.collapse.value ? null : ( - - )} -
- ); - } - } - ) -); - -export { - Silence, - SilenceDetails, - SilenceComment, - SilenceExpiryBadgeWithProgress -}; diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.test.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.test.js deleted file mode 100644 index d877deeb2..000000000 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.test.js +++ /dev/null @@ -1,306 +0,0 @@ -import React from "react"; - -import { toJS } from "mobx"; -import { Provider } from "mobx-react"; - -import { mount } from "enzyme"; - -import toDiffableHtml from "diffable-html"; - -import moment from "moment"; -import { advanceTo, clear } from "jest-date-mock"; - -import { AlertStore } from "Stores/AlertStore"; -import { SilenceFormStore } from "Stores/SilenceFormStore"; -import { Silence, SilenceDetails } from "."; - -const mockAfterUpdate = jest.fn(); - -const alertmanager = { - name: "default", - cluster: "default", - state: "suppressed", - startsAt: "2000-01-01T10:00:00Z", - source: "localhost/prometheus", - silencedBy: ["4cf5fd82-1edd-4169-99d1-ff8415e72179"], - inhibitedBy: [] -}; - -const silence = { - id: "4cf5fd82-1edd-4169-99d1-ff8415e72179", - matchers: [ - { - name: "alertname", - value: "MockAlert", - isRegex: false - }, - { - name: "instance", - value: "foo[0-9]+", - isRegex: true - } - ], - startsAt: "2000-01-01T10:00:00Z", - endsAt: "2000-01-01T20:00:00Z", - createdAt: "0001-01-01T00:00:00Z", - createdBy: "me@example.com", - comment: "Fake silence", - jiraID: "", - jiraURL: "" -}; - -let alertStore; -let silenceFormStore; - -beforeEach(() => { - advanceTo(moment.utc([2000, 0, 1, 15, 0, 0])); - alertStore = new AlertStore([]); - alertStore.data.upstreams = { - counters: { - total: 1, - healthy: 1, - failed: 0 - }, - instances: [ - { - name: "default", - cluster: "default", - uri: "file:///mock", - publicURI: "http://example.com", - headers: {}, - error: "", - version: "0.15.0", - clusterMembers: ["default"] - } - ], - clusters: { default: ["default"] } - }; - alertStore.data.silences = { - default: { - "4cf5fd82-1edd-4169-99d1-ff8415e72179": silence - } - }; - silenceFormStore = new SilenceFormStore(); -}); - -afterEach(() => { - jest.restoreAllMocks(); - // reset Date() to current time - clear(); -}); - -const MountedSilence = alertmanagerState => { - return mount( - - - - ); -}; - -const MountedSilenceDetails = onEditSilence => { - return mount( - - - - ).find("SilenceDetails"); -}; - -describe("", () => { - it("matches snapshot when data is present in alertStore", () => { - const tree = MountedSilence(alertmanager).find("Silence"); - expect(toDiffableHtml(tree.html())).toMatchSnapshot(); - }); - - it("renders full silence when data is present in alertStore", () => { - const tree = MountedSilence(alertmanager).find("Silence"); - const fallback = tree.find("FallbackSilenceDesciption"); - expect(fallback).toHaveLength(0); - }); - - it("matches snapshot when data is not present in alertStore", () => { - alertStore.data.silences = {}; - const tree = MountedSilence(alertmanager).find("Silence"); - expect(toDiffableHtml(tree.html())).toMatchSnapshot(); - }); - - it("renders FallbackSilenceDesciption when Alertmanager data is not present in alertStore", () => { - alertStore.data.silences = {}; - const tree = MountedSilence(alertmanager); - const fallback = tree.find("FallbackSilenceDesciption"); - expect(fallback).toHaveLength(1); - expect(tree.text()).toBe( - "Silenced by default/4cf5fd82-1edd-4169-99d1-ff8415e72179" - ); - }); - - it("renders FallbackSilenceDesciption when silence data is not present in alertStore", () => { - alertStore.data.silences.default = {}; - const tree = MountedSilence(alertmanager); - const fallback = tree.find("FallbackSilenceDesciption"); - expect(fallback).toHaveLength(1); - expect(tree.text()).toBe( - "Silenced by default/4cf5fd82-1edd-4169-99d1-ff8415e72179" - ); - }); - - it("clicking on expand toggle shows silence details", () => { - const tree = MountedSilence(alertmanager); - const toggle = tree.find(".float-right.cursor-pointer"); - toggle.simulate("click"); - const details = tree.find("SilenceDetails"); - expect(details).toHaveLength(1); - }); - - it("matches snapshot with expaned details", () => { - const tree = MountedSilence(alertmanager).find("Silence"); - tree.instance().collapse.toggle(); - expect(toDiffableHtml(tree.html())).toMatchSnapshot(); - }); - - it("renders comment as link when jiraURL is set and silence is collapsed", () => { - alertStore.data.silences.default[silence.id].jiraURL = - "http://jira.example.com"; - const tree = MountedSilence(alertmanager).find("Silence"); - const link = tree.find("a[href='http://jira.example.com']"); - expect(link).toHaveLength(1); - expect(link.text()).toBe("Fake silence…"); - }); - - it("renders comment as link when jiraURL is set and silence is expaned", () => { - alertStore.data.silences.default[silence.id].jiraURL = - "http://jira.example.com"; - const tree = MountedSilence(alertmanager).find("Silence"); - tree.instance().collapse.toggle(); - const link = tree.find("a[href='http://jira.example.com']"); - expect(link).toHaveLength(1); - expect(link.text()).toBe("Fake silence…"); - }); - - it("clears progress timer on unmount", () => { - const tree = MountedSilence(alertmanager).find("Silence"); - const instance = tree.instance(); - expect(instance.progressTimer).toBeTruthy(); - instance.componentWillUnmount(); - expect(instance.progressTimer).toBeNull(); - }); - - it("getAlertmanager() returns alertmanager object from alertStore.data.upstreams.instances", () => { - const tree = MountedSilence(alertmanager).find("Silence"); - const instance = tree.instance(); - const am = instance.getAlertmanager(); - expect(am).toEqual({ - name: "default", - cluster: "default", - uri: "file:///mock", - publicURI: "http://example.com", - headers: {}, - error: "", - version: "0.15.0", - clusterMembers: ["default"] - }); - }); - - it("getAlertmanager() return object with only name if given name is not in alertStore", () => { - const missingAlertmanager = { ...alertmanager, name: "notDefault" }; - const tree = MountedSilence(missingAlertmanager).find("Silence"); - const instance = tree.instance(); - const am = instance.getAlertmanager(); - expect(am).toEqual({ - name: "notDefault" - }); - }); - - it("clicking on silence edit button calls silenceFormStore.data.fillFormFromSilence", () => { - const fillSpy = jest.spyOn(silenceFormStore.data, "fillFormFromSilence"); - const tree = MountedSilence(alertmanager); - - // expand silence - tree.find(".float-right.cursor-pointer").simulate("click"); - - const button = tree.find(".badge-secondary.components-label-with-hover"); - expect(button.text()).toBe("Edit"); - button.simulate("click"); - expect(fillSpy).toHaveBeenCalled(); - }); - - it("clicking on silence edit button opens the silence form", () => { - const tree = MountedSilence(alertmanager); - - // expand silence - tree.find(".float-right.cursor-pointer").simulate("click"); - - const button = tree.find(".badge-secondary.components-label-with-hover"); - expect(button.text()).toBe("Edit"); - button.simulate("click"); - expect(silenceFormStore.toggle.visible).toBe(true); - }); -}); - -describe("", () => { - it("unexpired silence endsAt label doesn't use 'danger' class", () => { - const tree = MountedSilenceDetails(jest.fn()); - const endsAt = tree.find("span.badge").at(1); - expect(endsAt.html()).not.toMatch(/text-danger/); - }); - - it("expired silence endsAt label uses 'danger' class", () => { - advanceTo(moment.utc([2000, 0, 1, 23, 0, 0])); - const tree = MountedSilenceDetails(jest.fn()); - const endsAt = tree.find("span.badge").at(2); - expect(endsAt.html()).toMatch(/text-danger/); - }); - - it("id links to Alertmanager silence view via alertmanager.publicURI", () => { - const tree = MountedSilenceDetails(jest.fn()); - const link = tree.find("a"); - expect(link.props().href).toBe( - "http://example.com/#/silences/4cf5fd82-1edd-4169-99d1-ff8415e72179" - ); - }); -}); - -describe("", () => { - it("renders with class 'danger' and no progressbar when expired", () => { - advanceTo(moment.utc([2001, 0, 1, 23, 0, 0])); - const tree = MountedSilence(alertmanager); - expect(tree.html()).toMatch(/badge-danger/); - expect(tree.text()).toMatch(/Expired a year ago/); - }); - - it("progressbar uses class 'danger' when > 90%", () => { - advanceTo(moment.utc([2000, 0, 1, 19, 30, 0])); - const tree = MountedSilence(alertmanager); - expect(tree.html()).toMatch(/progress-bar bg-danger/); - }); - - it("progressbar uses class 'danger' when > 75%", () => { - advanceTo(moment.utc([2000, 0, 1, 17, 45, 0])); - const tree = MountedSilence(alertmanager); - expect(tree.html()).toMatch(/progress-bar bg-warning/); - }); - - it("calling calculate() on progress multiple times in a row doesn't change the value", () => { - const startsAt = moment.utc([2000, 0, 1, 10, 0, 0]); - const endsAt = moment.utc([2000, 0, 1, 20, 0, 0]); - - const tree = MountedSilence(alertmanager).find("Silence"); - const instance = tree.instance(); - - const value = toJS(instance.progress.value); - instance.progress.calculate(startsAt, endsAt); - instance.progress.calculate(startsAt, endsAt); - instance.progress.calculate(startsAt, endsAt); - expect(toJS(instance.progress.value)).toBe(value); - }); -}); diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silences.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silences.js new file mode 100644 index 000000000..e14db7cdb --- /dev/null +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silences.js @@ -0,0 +1,57 @@ +import React from "react"; +import PropTypes from "prop-types"; + +import { ManagedSilence } from "Components/ManagedSilence"; +const FallbackSilenceDesciption = ({ silenceID }) => { + return ( +
+ Silenced by {silenceID} +
+ ); +}; +FallbackSilenceDesciption.propTypes = { + silenceID: PropTypes.string.isRequired +}; + +const GetSilenceFromStore = (alertStore, cluster, silenceID) => { + const amSilences = alertStore.data.silences[cluster]; + if (!amSilences) return null; + + // next check if alertmanager has our silence ID + const silence = amSilences[silenceID]; + if (!silence) return null; + + return silence; +}; + +const RenderSilence = ( + alertStore, + silenceFormStore, + afterUpdate, + cluster, + silenceID +) => { + const silence = GetSilenceFromStore(alertStore, cluster, silenceID); + + if (silence === null) { + return ( + + ); + } + + return ( + + ); +}; + +export { RenderSilence }; diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/index.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/index.js index 3e0cc34a1..5441f759e 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/index.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/index.js @@ -273,6 +273,7 @@ const AlertGroup = observer( group={group} alertmanagers={footerAlertmanagers} afterUpdate={afterUpdate} + alertStore={alertStore} silenceFormStore={silenceFormStore} /> ) : null}