diff --git a/ui/src/Common/Query.js b/ui/src/Common/Query.js index dcaea36c0..afb527a05 100644 --- a/ui/src/Common/Query.js +++ b/ui/src/Common/Query.js @@ -8,6 +8,7 @@ const StaticLabels = Object.freeze({ AlertName: "alertname", AlertManager: "@alertmanager", AlertmanagerCluster: "@cluster", + Fingerprint: "@fingerprint", Receiver: "@receiver", State: "@state", SilenceID: "@silence_id", diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/__snapshots__/index.test.js.snap b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/__snapshots__/index.test.js.snap index 3c344eb53..7bf9ce9d5 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/__snapshots__/index.test.js.snap +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/__snapshots__/index.test.js.snap @@ -81,7 +81,7 @@ exports[` matches snapshot when inhibited 1`] = `
- + 0) { - isInhibited = true; + for (const fingerprint of am.inhibitedBy) { + if (!inhibitedBy.includes(fingerprint)) { + inhibitedBy.push(fingerprint); + } } if (!silences[am.cluster]) { silences[am.cluster] = { @@ -87,12 +86,8 @@ const Alert = ({ silenceFormStore={silenceFormStore} setIsMenuOpen={setIsMenuOpen} /> - {isInhibited ? ( - - - - - + {inhibitedBy.length > 0 ? ( + ) : null} {Object.entries(alert.labels).map(([name, value]) => ( ", () => { }); it("renders inhibition icon when inhibited", () => { + const alert = MockedAlert(); + alert.alertmanager[0].inhibitedBy = ["123456"]; + alert.alertmanager.push({ + name: "ha2", + cluster: "HA", + state: "active", + startsAt: "2018-08-14T17:36:40.017867056Z", + source: "localhost/prometheus", + silencedBy: [], + inhibitedBy: ["123456"], + }); + const group = MockAlertGroup({}, [alert], [], {}, {}); + const tree = MountedAlert(alert, group, false, false); + expect(tree.find(".fa-volume-mute")).toHaveLength(1); + }); + + it("inhibition icon passes only unique fingerprints", () => { const alert = MockedAlert(); alert.alertmanager[0].inhibitedBy = ["123456"]; const group = MockAlertGroup({}, [alert], [], {}, {}); diff --git a/ui/src/Components/InhibitedByModal/InhibitedByModalContent.js b/ui/src/Components/InhibitedByModal/InhibitedByModalContent.js new file mode 100644 index 000000000..28780fe1a --- /dev/null +++ b/ui/src/Components/InhibitedByModal/InhibitedByModalContent.js @@ -0,0 +1,38 @@ +import React from "react"; +import PropTypes from "prop-types"; + +import { AlertStore } from "Stores/AlertStore"; +import { FormatQuery, QueryOperators, StaticLabels } from "Common/Query"; +import { PaginatedAlertList } from "Components/PaginatedAlertList"; + +const InhibitedByModalContent = ({ alertStore, fingerprints, onHide }) => { + return ( + +
+
Inhibiting alerts
+ +
+
+ +
+
+ ); +}; +InhibitedByModalContent.propTypes = { + alertStore: PropTypes.instanceOf(AlertStore).isRequired, + fingerprints: PropTypes.arrayOf(PropTypes.string).isRequired, + onHide: PropTypes.func.isRequired, +}; + +export { InhibitedByModalContent }; diff --git a/ui/src/Components/InhibitedByModal/index.js b/ui/src/Components/InhibitedByModal/index.js new file mode 100644 index 000000000..54800bcb0 --- /dev/null +++ b/ui/src/Components/InhibitedByModal/index.js @@ -0,0 +1,58 @@ +import React, { useState, useCallback } from "react"; +import PropTypes from "prop-types"; + +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faSpinner } from "@fortawesome/free-solid-svg-icons/faSpinner"; +import { faVolumeMute } from "@fortawesome/free-solid-svg-icons/faVolumeMute"; + +import { AlertStore } from "Stores/AlertStore"; +import { TooltipWrapper } from "Components/TooltipWrapper"; +import { Modal } from "Components/Modal"; + +// https://github.com/facebook/react/issues/14603 +const InhibitedByModalContent = React.lazy(() => + import("./InhibitedByModalContent").then((module) => ({ + default: module.InhibitedByModalContent, + })) +); + +const InhibitedByModal = ({ alertStore, fingerprints }) => { + const [isVisible, setIsVisible] = useState(false); + + const toggle = useCallback(() => setIsVisible(!isVisible), [isVisible]); + + return ( + + + + + + + + + + + } + > + setIsVisible(false)} + isVisible={isVisible} + fingerprints={fingerprints} + /> + + + + ); +}; +InhibitedByModal.propTypes = { + alertStore: PropTypes.instanceOf(AlertStore).isRequired, + fingerprints: PropTypes.arrayOf(PropTypes.string).isRequired, +}; + +export { InhibitedByModal }; diff --git a/ui/src/Components/InhibitedByModal/index.test.js b/ui/src/Components/InhibitedByModal/index.test.js new file mode 100644 index 000000000..682acaf37 --- /dev/null +++ b/ui/src/Components/InhibitedByModal/index.test.js @@ -0,0 +1,107 @@ +import React from "react"; +import { act } from "react-dom/test-utils"; + +import { mount } from "enzyme"; + +import { AlertStore } from "Stores/AlertStore"; +import { InhibitedByModal } from "."; + +let alertStore; + +beforeAll(() => { + jest.useFakeTimers(); +}); + +beforeEach(() => { + alertStore = new AlertStore([]); +}); + +afterEach(() => { + document.body.className = ""; +}); + +describe("", () => { + it("renders a spinner placeholder while modal content is loading", () => { + const tree = mount( + + ); + const toggle = tree.find("span.badge.badge-light"); + toggle.simulate("click"); + expect(tree.find("InhibitedByModalContent")).toHaveLength(0); + expect(tree.find(".modal-content").find("svg.fa-spinner")).toHaveLength(1); + }); + + it("renders modal content if fallback is not used", () => { + const tree = mount( + + ); + const toggle = tree.find("span.badge.badge-light"); + toggle.simulate("click"); + expect(tree.find(".modal-title").text()).toBe("Inhibiting alerts"); + expect(tree.find(".modal-content").find("svg.fa-spinner")).toHaveLength(0); + }); + + it("hides the modal when toggle() is called twice", () => { + const tree = mount( + + ); + const toggle = tree.find("span.badge.badge-light"); + + toggle.simulate("click"); + act(() => jest.runOnlyPendingTimers()); + tree.update(); + expect(tree.find(".modal-title").text()).toBe("Inhibiting alerts"); + + toggle.simulate("click"); + act(() => jest.runOnlyPendingTimers()); + tree.update(); + expect(tree.find(".modal-title")).toHaveLength(0); + }); + + it("hides the modal when button.close is clicked", () => { + const tree = mount( + + ); + const toggle = tree.find("span.badge.badge-light"); + + toggle.simulate("click"); + expect(tree.find(".modal-title").text()).toBe("Inhibiting alerts"); + + tree.find("button.close").simulate("click"); + act(() => jest.runOnlyPendingTimers()); + tree.update(); + expect(tree.find("InhibitedByModalContent")).toHaveLength(0); + }); + + it("'modal-open' class is appended to body node when modal is visible", () => { + const tree = mount( + + ); + const toggle = tree.find("span.badge.badge-light"); + toggle.simulate("click"); + expect(document.body.className.split(" ")).toContain("modal-open"); + }); + + it("'modal-open' class is removed from body node after modal is hidden", () => { + const tree = mount( + + ); + + tree.find("span.badge.badge-light").simulate("click"); + expect(document.body.className.split(" ")).toContain("modal-open"); + + tree.find("span.badge.badge-light").simulate("click"); + act(() => jest.runOnlyPendingTimers()); + expect(document.body.className.split(" ")).not.toContain("modal-open"); + }); + + it("'modal-open' class is removed from body node after modal is unmounted", () => { + const tree = mount( + + ); + const toggle = tree.find("span.badge.badge-light"); + toggle.simulate("click"); + tree.unmount(); + expect(document.body.className.split(" ")).not.toContain("modal-open"); + }); +}); diff --git a/ui/src/Components/LabelSetList/index.js b/ui/src/Components/LabelSetList/index.js index 019f5ca4f..b0e647520 100644 --- a/ui/src/Components/LabelSetList/index.js +++ b/ui/src/Components/LabelSetList/index.js @@ -26,14 +26,14 @@ const GroupListToUniqueLabelsList = (groups) => { return Object.values(alerts); }; -const LabelSetList = ({ alertStore, labelsList }) => { +const LabelSetList = ({ alertStore, labelsList, title }) => { const [activePage, setActivePage] = useState(1); const maxPerPage = IsMobile() ? 5 : 10; return labelsList.length > 0 ? (
-

Affected alerts

+ {title ?

{title}

: null}
    {labelsList @@ -63,12 +63,17 @@ const LabelSetList = ({ alertStore, labelsList }) => { />
) : ( -

No alerts matched

+
+

+ No alerts matched +

+
); }; LabelSetList.propTypes = { alertStore: PropTypes.instanceOf(AlertStore).isRequired, labelsList: PropTypes.arrayOf(PropTypes.object).isRequired, + title: PropTypes.string, }; export { LabelSetList, GroupListToUniqueLabelsList }; diff --git a/ui/src/Components/LabelSetList/index.test.js b/ui/src/Components/LabelSetList/index.test.js index a92bb4615..787095ad6 100644 --- a/ui/src/Components/LabelSetList/index.test.js +++ b/ui/src/Components/LabelSetList/index.test.js @@ -19,7 +19,11 @@ afterEach(() => { const MountedLabelSetList = (labelsList) => { return mount( - + ); }; diff --git a/ui/src/Components/ManagedSilence/DeleteSilence.js b/ui/src/Components/ManagedSilence/DeleteSilence.js index 14ae12539..5b758af06 100644 --- a/ui/src/Components/ManagedSilence/DeleteSilence.js +++ b/ui/src/Components/ManagedSilence/DeleteSilence.js @@ -8,16 +8,12 @@ import { faCheckCircle } from "@fortawesome/free-solid-svg-icons/faCheckCircle"; import { faCircleNotch } from "@fortawesome/free-solid-svg-icons/faCircleNotch"; import { APISilence } from "Models/API"; -import { AlertStore, FormatBackendURI, FormatAlertsQ } from "Stores/AlertStore"; +import { AlertStore } from "Stores/AlertStore"; import { SilenceFormStore } from "Stores/SilenceFormStore"; import { FormatQuery, QueryOperators, StaticLabels } from "Common/Query"; -import { useFetchGet } from "Hooks/useFetchGet"; import { useFetchDelete } from "Hooks/useFetchDelete"; import { Modal } from "Components/Modal"; -import { - LabelSetList, - GroupListToUniqueLabelsList, -} from "Components/LabelSetList"; +import { PaginatedAlertList } from "Components/PaginatedAlertList"; const ProgressMessage = () => (
@@ -55,32 +51,6 @@ const SuccessMessage = () => (
); -const DeletePreview = ({ alertStore, silence }) => { - const { response, error, isLoading } = useFetchGet( - FormatBackendURI("alerts.json?") + - FormatAlertsQ([ - FormatQuery(StaticLabels.SilenceID, QueryOperators.Equal, silence.id), - ]) - ); - - return isLoading ? ( - - ) : error ? ( - - ) : ( - - ); -}; -DeletePreview.propTypes = { - alertStore: PropTypes.instanceOf(AlertStore).isRequired, - silence: APISilence.isRequired, -}; - const DeleteResult = ({ alertStore, cluster, silence }) => { const [currentTime, setCurrentTime] = useState(Math.floor(Date.now())); @@ -164,7 +134,17 @@ const DeleteSilenceModalContent = ({ /> ) : ( - +