diff --git a/ui/package.json b/ui/package.json index 63d765b6c..865fa9562 100644 --- a/ui/package.json +++ b/ui/package.json @@ -67,7 +67,8 @@ "jest": { "collectCoverageFrom": [ "src/**/*.{js,ts,tsx}", - "!src/**/*.stories.{js,ts,tsx}" + "!src/**/*.stories.{js,ts,tsx}", + "!src/react-app-env.d.ts" ] }, "devDependencies": { diff --git a/ui/src/Components/SilenceModal/SilenceForm.js b/ui/src/Components/SilenceModal/SilenceForm.js index 95aa8bcda..0882345d1 100644 --- a/ui/src/Components/SilenceModal/SilenceForm.js +++ b/ui/src/Components/SilenceModal/SilenceForm.js @@ -16,6 +16,7 @@ import { SilenceFormStage, NewEmptyMatcher, MatcherValueToObject, + NewClusterRequest, } from "Stores/SilenceFormStore"; import { Settings } from "Stores/Settings"; import { QueryOperators } from "Common/Query"; @@ -43,6 +44,9 @@ const SilenceForm = ({ silenceFormStore.data.verifyStarEnd(); } + // reset cluster request state + silenceFormStore.data.requestsByCluster = {}; + if ( silenceFormStore.data.matchers.filter( (m) => m.name !== "" || m.values.length @@ -100,6 +104,12 @@ const SilenceForm = ({ const handleSubmit = (event) => { event.preventDefault(); + let rbc = {}; + silenceFormStore.data.alertmanagers.forEach((am) => { + rbc[am.label] = NewClusterRequest(am.label, am.value); + }); + silenceFormStore.data.requestsByCluster = rbc; + settingsStore.silenceFormConfig.saveAuthor(silenceFormStore.data.author); if (silenceFormStore.data.isValid) diff --git a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitController.js b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitController.js index 07024dd65..a0a5970fd 100644 --- a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitController.js +++ b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitController.js @@ -1,26 +1,79 @@ -import React, { memo } from "react"; +import React from "react"; import PropTypes from "prop-types"; +import { useObserver } from "mobx-react-lite"; + import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faArrowLeft } from "@fortawesome/free-solid-svg-icons/faArrowLeft"; +import { faCheckCircle } from "@fortawesome/free-regular-svg-icons/faCheckCircle"; +import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclamationCircle"; import { AlertStore } from "Stores/AlertStore"; import { SilenceFormStore } from "Stores/SilenceFormStore"; import { SilenceSubmitProgress } from "./SilenceSubmitProgress"; -const SilenceSubmitController = memo(({ silenceFormStore, alertStore }) => { - return ( +const SilenceSubmitController = ({ silenceFormStore, alertStore }) => { + return useObserver(() => ( -
- {silenceFormStore.data.alertmanagers.map((am) => ( - - ))} +
+ + + {Object.values(silenceFormStore.data.requestsByCluster).map( + (clusterRequest) => ( + + + + + + ) + )} + +
+ {clusterRequest.isDone ? ( + clusterRequest.error ? ( + + ) : ( + + ) + ) : ( + + )} + {clusterRequest.cluster} +
+ {clusterRequest.isDone ? ( + clusterRequest.error ? ( + clusterRequest.error + ) : ( + + {clusterRequest.silenceID} + + ) + ) : null} +
+
- ); -}); + )); +}; SilenceSubmitController.propTypes = { alertStore: PropTypes.instanceOf(AlertStore).isRequired, silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired, diff --git a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitController.test.js b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitController.test.js index a8ddb66f8..5f0da2003 100644 --- a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitController.test.js +++ b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitController.test.js @@ -3,7 +3,11 @@ import React from "react"; import { shallow } from "enzyme"; import { AlertStore } from "Stores/AlertStore"; -import { SilenceFormStore, SilenceFormStage } from "Stores/SilenceFormStore"; +import { + SilenceFormStore, + SilenceFormStage, + NewClusterRequest, +} from "Stores/SilenceFormStore"; import { SilenceSubmitController } from "./SilenceSubmitController"; let alertStore; @@ -12,31 +16,126 @@ let silenceFormStore; beforeEach(() => { alertStore = new AlertStore([]); silenceFormStore = new SilenceFormStore(); -}); -const ShallowSilenceSubmitController = () => { - return shallow( - - ); -}; + alertStore.data.upstreams = { + clusters: { ha: ["am1", "am2"], single: "single" }, + instances: [ + { + name: "am1", + uri: "http://am1.example.com", + publicURI: "http://am1.example.com", + readonly: false, + headers: {}, + corsCredentials: "include", + error: "", + version: "0.17.0", + cluster: "ha", + clusterMembers: ["am1", "am2"], + }, + { + name: "am2", + uri: "http://am2.example.com", + publicURI: "http://am2.example.com", + readonly: false, + headers: {}, + corsCredentials: "include", + error: "", + version: "0.17.0", + cluster: "ha", + clusterMembers: ["am1", "am2"], + }, + { + name: "single", + uri: "http://single.example.com", + publicURI: "http://single.example.com", + readonly: false, + headers: {}, + corsCredentials: "include", + error: "", + version: "0.17.0", + cluster: "ha", + clusterMembers: ["single"], + }, + ], + }; +}); describe("", () => { it("renders all passed SilenceSubmitProgress", () => { - silenceFormStore.data.alertmanagers.push({ label: "am1", value: ["am1"] }); - silenceFormStore.data.alertmanagers.push({ - label: "ha", - value: ["am2", "am3"], - }); - const tree = ShallowSilenceSubmitController(); - expect(tree.find("div").at(0).children()).toHaveLength(2); + silenceFormStore.data.requestsByCluster = { + ha: NewClusterRequest("ha", ["am1", "am2"]), + single: NewClusterRequest("single", ["single"]), + }; + const tree = shallow( + + ); + expect(tree.find("tr")).toHaveLength(2); + }); + + it("renders spinner for pending requests", () => { + const single = NewClusterRequest("single", ["single"]); + silenceFormStore.data.requestsByCluster = { single: single }; + const tree = shallow( + + ); + expect(tree.find("tr")).toHaveLength(1); + expect(tree.find("td").at(0).html()).toMatch(/fa-circle-notch/); + expect(tree.find("td").at(1).text()).toBe("single"); + expect(tree.find("td").at(2).text()).toBe(""); + }); + + it("renders error for failed requests", () => { + const single = NewClusterRequest("single", ["single"]); + single.isDone = true; + single.error = "fake error"; + silenceFormStore.data.requestsByCluster = { single: single }; + const tree = shallow( + + ); + expect(tree.find("tr")).toHaveLength(1); + expect(tree.find("td").at(0).html()).toMatch(/fa-exclamation-circle/); + expect(tree.find("td").at(1).text()).toBe("single"); + expect(tree.find("td").at(2).text()).toBe("fake error"); + }); + + it("renders silence link for completed requests", () => { + const single = NewClusterRequest("single", ["single"]); + single.isDone = true; + single.silenceID = "123456789"; + single.silenceLink = "http://localhost"; + silenceFormStore.data.requestsByCluster = { single: single }; + const tree = shallow( + + ); + expect(tree.find("tr")).toHaveLength(1); + expect(tree.find("td").at(0).html()).toMatch(/fa-check-circle/); + expect(tree.find("td").at(1).text()).toBe("single"); + expect(tree.find("td").at(2).text()).toBe("123456789"); + expect( + tree.find("td").at(2).find('a[href="http://localhost"]') + ).toHaveLength(1); }); it("resets the form on 'Back' button click", () => { silenceFormStore.data.currentStage = SilenceFormStage.Submit; - const tree = ShallowSilenceSubmitController(); + const tree = shallow( + + ); const button = tree.find("button"); button.simulate("click"); expect(silenceFormStore.data.currentStage).toBe(SilenceFormStage.UserInput); diff --git a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js index 7ac5c81c9..ffdac418a 100644 --- a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js +++ b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js @@ -1,93 +1,75 @@ -import React, { useEffect, useState, memo } from "react"; +import React, { useEffect, useState } from "react"; import PropTypes from "prop-types"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faCircleNotch } from "@fortawesome/free-solid-svg-icons/faCircleNotch"; -import { faCheckCircle } from "@fortawesome/free-regular-svg-icons/faCheckCircle"; -import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclamationCircle"; import { APISilenceMatcher } from "Models/API"; import { AlertStore } from "Stores/AlertStore"; +import { SilenceFormStore } from "Stores/SilenceFormStore"; import { useFetchAny } from "Hooks/useFetchAny"; -const SilenceSubmitProgress = memo( - ({ alertStore, cluster, members, payload }) => { - const [upstreams, setUpstreams] = useState([]); - const { response, error, inProgress, responseURI } = useFetchAny(upstreams); - const [publicURIs, setPublicURIs] = useState({}); +const SilenceSubmitProgress = ({ + alertStore, + silenceFormStore, + cluster, + members, + payload, +}) => { + const [upstreams, setUpstreams] = useState([]); + const { response, error, inProgress, responseURI } = useFetchAny(upstreams); + const [publicURIs, setPublicURIs] = useState({}); - useEffect(() => { - let uris = {}; - let membersToTry = []; - for (const member of members) { - if (alertStore.data.isReadOnlyAlertmanager(member)) { - console.error(`Alertmanager instance "${member}" is read-only`); + useEffect(() => { + let uris = {}; + let membersToTry = []; + for (const member of members) { + if (alertStore.data.isReadOnlyAlertmanager(member)) { + console.error(`Alertmanager instance "${member}" is read-only`); + } else { + const am = alertStore.data.getAlertmanagerByName(member); + if (am === undefined) { + console.error(`Alertmanager instance "${member}" not found`); } else { - const am = alertStore.data.getAlertmanagerByName(member); - if (am === undefined) { - console.error(`Alertmanager instance "${member}" not found`); - } else { - const uri = `${am.uri}/api/v2/silences`; - membersToTry.push({ - uri: uri, - options: { - method: "POST", - body: JSON.stringify(payload), - credentials: am.corsCredentials, - headers: { - "Content-Type": "application/json", - ...am.headers, - }, + const uri = `${am.uri}/api/v2/silences`; + membersToTry.push({ + uri: uri, + options: { + method: "POST", + body: JSON.stringify(payload), + credentials: am.corsCredentials, + headers: { + "Content-Type": "application/json", + ...am.headers, }, - }); - uris[uri] = am.publicURI; - } + }, + }); + uris[uri] = am.publicURI; } } - if (membersToTry.length) { - setPublicURIs(uris); - setUpstreams(membersToTry); - } - }, [alertStore.data, members, payload]); + } + if (membersToTry.length) { + setPublicURIs(uris); + setUpstreams(membersToTry); + } + }, [alertStore.data, members, payload]); + + useEffect(() => { + if (!inProgress && error !== null) { + silenceFormStore.data.requestsByCluster[cluster].isDone = true; + silenceFormStore.data.requestsByCluster[cluster].error = error; + } else if (!inProgress && response !== null) { + silenceFormStore.data.requestsByCluster[cluster].isDone = true; + silenceFormStore.data.requestsByCluster[cluster].silenceID = + response.silenceID; + silenceFormStore.data.requestsByCluster[ + cluster + ].silenceLink = `${publicURIs[responseURI]}/#/silences/${response.silenceID}`; + } + }, [cluster, error, inProgress, publicURIs, response, responseURI]); // eslint-disable-line react-hooks/exhaustive-deps - return ( -
-
- {inProgress ? ( - - ) : error ? ( - - ) : ( - - )} -
-
- {cluster} -
-
- {error ? ( - error - ) : response && responseURI ? ( - - {response.silenceID} - - ) : null} -
-
- ); - } -); + return ; +}; SilenceSubmitProgress.propTypes = { cluster: PropTypes.string.isRequired, members: PropTypes.arrayOf(PropTypes.string).isRequired, @@ -100,6 +82,7 @@ SilenceSubmitProgress.propTypes = { id: PropTypes.string, }).isRequired, alertStore: PropTypes.instanceOf(AlertStore).isRequired, + silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired, }; export { SilenceSubmitProgress }; diff --git a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.test.js b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.test.js index ec9b8ade6..45a6dd01f 100644 --- a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.test.js +++ b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.test.js @@ -6,12 +6,16 @@ import { mount } from "enzyme"; import fetchMock from "fetch-mock"; import { AlertStore } from "Stores/AlertStore"; +import { SilenceFormStore, NewClusterRequest } from "Stores/SilenceFormStore"; import { SilenceSubmitProgress } from "./SilenceSubmitProgress"; let alertStore; +let silenceFormStore; beforeEach(() => { alertStore = new AlertStore([]); + silenceFormStore = new SilenceFormStore(); + alertStore.data.upstreams = { instances: [ { @@ -29,6 +33,12 @@ beforeEach(() => { ], }; + silenceFormStore.data.requestsByCluster = { + mockAlertmanager: NewClusterRequest("mockAlertmanager", [ + "mockAlertmanager", + ]), + }; + fetchMock.resetHistory(); fetchMock.any( { @@ -59,6 +69,7 @@ const MountedSilenceSubmitProgress = () => { comment: "fake payload", }} alertStore={alertStore} + silenceFormStore={silenceFormStore} /> ); }; @@ -151,6 +162,9 @@ describe("", () => { }, ], }; + silenceFormStore.data.requestsByCluster = { + ha: NewClusterRequest("ha", ["am1", "am2"]), + }; mount( ", () => { comment: "fake payload", }} alertStore={alertStore} + silenceFormStore={silenceFormStore} /> ); await act(async () => { @@ -178,7 +193,7 @@ describe("", () => { ); }); - it("will render error message from last failed cluster member", async () => { + it("will use error message from last failed cluster member", async () => { fetchMock.reset(); fetchMock.mock("http://am2.example.com/api/v2/silences", { throws: new TypeError("failed to fetch from am2"), @@ -215,6 +230,9 @@ describe("", () => { }, ], }; + silenceFormStore.data.requestsByCluster = { + ha: NewClusterRequest("ha", ["am1", "am2"]), + }; const tree = mount( ", () => { comment: "fake payload", }} alertStore={alertStore} + silenceFormStore={silenceFormStore} /> ); await act(async () => { @@ -235,8 +254,12 @@ describe("", () => { await fetchMock.flush(true); }); }); + tree.update(); expect(fetchMock.calls()).toHaveLength(2); - expect(tree.text()).toBe("hafailed to fetch from am1"); + expect(silenceFormStore.data.requestsByCluster.ha).toMatchObject({ + isDone: true, + error: "failed to fetch from am1", + }); }); it("will log an error if Alertmanager instance is missing from instances and try the next one", async () => { @@ -260,6 +283,9 @@ describe("", () => { }, ], }; + silenceFormStore.data.requestsByCluster = { + ha: NewClusterRequest("ha", ["am1", "am2"]), + }; mount( ", () => { comment: "fake payload", }} alertStore={alertStore} + silenceFormStore={silenceFormStore} /> ); await act(async () => { @@ -294,6 +321,9 @@ describe("", () => { clusters: { ha: ["am1", "am2"] }, instances: [], }; + silenceFormStore.data.requestsByCluster = { + ha: NewClusterRequest("ha", ["am1", "am2"]), + }; mount( ", () => { comment: "fake payload", }} alertStore={alertStore} + silenceFormStore={silenceFormStore} /> ); await act(async () => { @@ -355,6 +386,9 @@ describe("", () => { }, ], }; + silenceFormStore.data.requestsByCluster = { + ha: NewClusterRequest("ha", ["am1", "am2"]), + }; mount( ", () => { comment: "fake payload", }} alertStore={alertStore} + silenceFormStore={silenceFormStore} /> ); await act(async () => { @@ -420,6 +455,9 @@ describe("", () => { }, ], }; + silenceFormStore.data.requestsByCluster = { + ha: NewClusterRequest("ha", ["am1", "am2"]), + }; mount( ", () => { comment: "fake payload", }} alertStore={alertStore} + silenceFormStore={silenceFormStore} /> ); expect(fetchMock.calls()).toHaveLength(0); @@ -443,47 +482,6 @@ describe("", () => { expect(consoleSpy).toHaveBeenCalledTimes(2); }); - it("renders returned silence ID on successful fetch", async () => { - const tree = MountedSilenceSubmitProgress(); - await act(async () => { - await act(async () => { - await fetchMock.flush(true); - }); - }); - // force re-render - tree.update(); - const silenceLink = tree.find("a"); - expect(silenceLink).toHaveLength(1); - expect(silenceLink.text()).toBe("123456789"); - }); - - it("renders returned error message on failed fetch", async () => { - fetchMock.reset(); - fetchMock.any({ - status: 500, - body: "mock error message", - }); - const tree = MountedSilenceSubmitProgress(); - await act(async () => { - await act(async () => { - await fetchMock.flush(true); - }); - }); - expect(tree.text()).toBe("mockAlertmanagermock error message"); - }); - - it("renders success icon on successful fetch", async () => { - const tree = MountedSilenceSubmitProgress(); - await act(async () => { - await act(async () => { - await fetchMock.flush(true); - }); - }); - tree.update(); - expect(tree.find("FontAwesomeIcon.text-success")).toHaveLength(1); - expect(tree.find("FontAwesomeIcon.text-danger")).toHaveLength(0); - }); - it("renders silence link on successful fetch", async () => { const tree = MountedSilenceSubmitProgress(); await act(async () => { @@ -492,12 +490,17 @@ describe("", () => { }); }); tree.update(); - expect(tree.find("a").getDOMNode().getAttribute("href")).toBe( - "http://example.com/#/silences/123456789" - ); + expect( + silenceFormStore.data.requestsByCluster.mockAlertmanager + ).toMatchObject({ + isDone: true, + error: null, + silenceID: "123456789", + silenceLink: "http://example.com/#/silences/123456789", + }); }); - it("renders error icon on failed fetch", async () => { + it("sets error icon on failed fetch", async () => { fetchMock.reset(); fetchMock.any({ status: 500, @@ -510,7 +513,11 @@ describe("", () => { }); }); tree.update(); - expect(tree.find("FontAwesomeIcon.text-success")).toHaveLength(0); - expect(tree.find("FontAwesomeIcon.text-danger")).toHaveLength(1); + expect( + silenceFormStore.data.requestsByCluster.mockAlertmanager + ).toMatchObject({ + isDone: true, + error: "error message", + }); }); }); diff --git a/ui/src/Components/SilenceModal/__snapshots__/SilenceModalContent.test.js.snap b/ui/src/Components/SilenceModal/__snapshots__/SilenceModalContent.test.js.snap index 55ffd5d02..cce360396 100644 --- a/ui/src/Components/SilenceModal/__snapshots__/SilenceModalContent.test.js.snap +++ b/ui/src/Components/SilenceModal/__snapshots__/SilenceModalContent.test.js.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` Editor renders SilenceSubmitController when silenceFormStore.data.currentStage is 'Submit' 1`] = `"
"`; +exports[` Editor renders SilenceSubmitController when silenceFormStore.data.currentStage is 'Submit' 1`] = `"
"`; diff --git a/ui/src/Stores/SilenceFormStore.js b/ui/src/Stores/SilenceFormStore.js index 232c16099..e89108314 100644 --- a/ui/src/Stores/SilenceFormStore.js +++ b/ui/src/Stores/SilenceFormStore.js @@ -104,6 +104,15 @@ const MatchersFromGroup = (group, stripLabels, alerts, onlyActive) => { return matchers; }; +const NewClusterRequest = (cluster, members) => ({ + cluster: cluster, + members: members, + isDone: false, + silenceID: null, + silenceLink: null, + error: null, +}); + const GenerateAlertmanagerSilenceData = ( startsAt, endsAt, @@ -188,6 +197,7 @@ class SilenceFormStore { endsAt: addHours(new Date(), 1), comment: "", author: "", + requestsByCluster: {}, get isValid() { if (this.alertmanagers.length === 0) return false; @@ -371,4 +381,5 @@ export { SilenceTabNames, MatchersFromGroup, GenerateAlertmanagerSilenceData, + NewClusterRequest, };