mirror of
https://github.com/prymitive/karma
synced 2026-05-05 03:16:51 +00:00
feat(ui): show modal with list of inhibiting alerts
This commit is contained in:
committed by
Łukasz Mierzwa
parent
6f1fb98248
commit
b1cf816e24
@@ -8,6 +8,7 @@ const StaticLabels = Object.freeze({
|
||||
AlertName: "alertname",
|
||||
AlertManager: "@alertmanager",
|
||||
AlertmanagerCluster: "@cluster",
|
||||
Fingerprint: "@fingerprint",
|
||||
Receiver: "@receiver",
|
||||
State: "@state",
|
||||
SilenceID: "@silence_id",
|
||||
|
||||
@@ -81,7 +81,7 @@ exports[`<Alert /> matches snapshot when inhibited 1`] = `
|
||||
<div style=\\"display: inline-block; max-width: 100%;\\"
|
||||
class=\\" tooltip-trigger\\"
|
||||
>
|
||||
<span class=\\"badge badge-light components-label\\">
|
||||
<span class=\\"badge badge-light components-label components-label-with-hover cursor-pointer\\">
|
||||
<svg aria-hidden=\\"true\\"
|
||||
focusable=\\"false\\"
|
||||
data-prefix=\\"fas\\"
|
||||
|
||||
@@ -3,16 +3,13 @@ import PropTypes from "prop-types";
|
||||
|
||||
import { useObserver } from "mobx-react-lite";
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faVolumeMute } from "@fortawesome/free-solid-svg-icons/faVolumeMute";
|
||||
|
||||
import { APIAlert, APIGroup } from "Models/API";
|
||||
import { AlertStore } from "Stores/AlertStore";
|
||||
import { SilenceFormStore } from "Stores/SilenceFormStore";
|
||||
import { BorderClassMap } from "Common/Colors";
|
||||
import { StaticLabels } from "Common/Query";
|
||||
import { FilteringLabel } from "Components/Labels/FilteringLabel";
|
||||
import { TooltipWrapper } from "Components/TooltipWrapper";
|
||||
import { InhibitedByModal } from "Components/InhibitedByModal";
|
||||
import { RenderNonLinkAnnotation, RenderLinkAnnotation } from "../Annotation";
|
||||
import { AlertMenu } from "./AlertMenu";
|
||||
import { RenderSilence } from "../Silences";
|
||||
@@ -39,13 +36,15 @@ const Alert = ({
|
||||
|
||||
const silences = {};
|
||||
let clusters = [];
|
||||
let isInhibited = false;
|
||||
let inhibitedBy = [];
|
||||
for (const am of alert.alertmanager) {
|
||||
if (!clusters.includes(am.cluster)) {
|
||||
clusters.push(am.cluster);
|
||||
}
|
||||
if (am.inhibitedBy.length > 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 ? (
|
||||
<TooltipWrapper title="This alert is inhibited by other alerts">
|
||||
<span className="badge badge-light components-label">
|
||||
<FontAwesomeIcon className="text-success" icon={faVolumeMute} />
|
||||
</span>
|
||||
</TooltipWrapper>
|
||||
{inhibitedBy.length > 0 ? (
|
||||
<InhibitedByModal alertStore={alertStore} fingerprints={inhibitedBy} />
|
||||
) : null}
|
||||
{Object.entries(alert.labels).map(([name, value]) => (
|
||||
<FilteringLabel
|
||||
|
||||
@@ -86,6 +86,23 @@ describe("<Alert />", () => {
|
||||
});
|
||||
|
||||
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], [], {}, {});
|
||||
|
||||
@@ -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 (
|
||||
<React.Fragment>
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">Inhibiting alerts</h5>
|
||||
<button type="button" className="close" onClick={onHide}>
|
||||
<span className="align-middle">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<PaginatedAlertList
|
||||
alertStore={alertStore}
|
||||
filters={[
|
||||
FormatQuery(
|
||||
StaticLabels.Fingerprint,
|
||||
QueryOperators.Regex,
|
||||
`^(${fingerprints.join("|")})$`
|
||||
),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
InhibitedByModalContent.propTypes = {
|
||||
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
|
||||
fingerprints: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
onHide: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export { InhibitedByModalContent };
|
||||
58
ui/src/Components/InhibitedByModal/index.js
Normal file
58
ui/src/Components/InhibitedByModal/index.js
Normal file
@@ -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 (
|
||||
<React.Fragment>
|
||||
<TooltipWrapper title="This alert is inhibited by other alerts, click to see details">
|
||||
<span
|
||||
className="badge badge-light components-label components-label-with-hover cursor-pointer"
|
||||
onClick={toggle}
|
||||
>
|
||||
<FontAwesomeIcon className="text-success" icon={faVolumeMute} />
|
||||
</span>
|
||||
</TooltipWrapper>
|
||||
<Modal size="lg" isOpen={isVisible} toggleOpen={toggle}>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<h1 className="display-1 text-placeholder p-5 m-auto">
|
||||
<FontAwesomeIcon icon={faSpinner} size="lg" spin />
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InhibitedByModalContent
|
||||
alertStore={alertStore}
|
||||
onHide={() => setIsVisible(false)}
|
||||
isVisible={isVisible}
|
||||
fingerprints={fingerprints}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Modal>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
InhibitedByModal.propTypes = {
|
||||
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
|
||||
fingerprints: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
};
|
||||
|
||||
export { InhibitedByModal };
|
||||
107
ui/src/Components/InhibitedByModal/index.test.js
Normal file
107
ui/src/Components/InhibitedByModal/index.test.js
Normal file
@@ -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("<InhibitedByModal />", () => {
|
||||
it("renders a spinner placeholder while modal content is loading", () => {
|
||||
const tree = mount(
|
||||
<InhibitedByModal alertStore={alertStore} fingerprints={["foo=bar"]} />
|
||||
);
|
||||
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(
|
||||
<InhibitedByModal alertStore={alertStore} fingerprints={["foo=bar"]} />
|
||||
);
|
||||
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(
|
||||
<InhibitedByModal alertStore={alertStore} fingerprints={["foo=bar"]} />
|
||||
);
|
||||
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(
|
||||
<InhibitedByModal alertStore={alertStore} fingerprints={["foo=bar"]} />
|
||||
);
|
||||
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(
|
||||
<InhibitedByModal alertStore={alertStore} fingerprints={["foo=bar"]} />
|
||||
);
|
||||
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(
|
||||
<InhibitedByModal alertStore={alertStore} fingerprints={["foo=bar"]} />
|
||||
);
|
||||
|
||||
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(
|
||||
<InhibitedByModal alertStore={alertStore} fingerprints={["foo=bar"]} />
|
||||
);
|
||||
const toggle = tree.find("span.badge.badge-light");
|
||||
toggle.simulate("click");
|
||||
tree.unmount();
|
||||
expect(document.body.className.split(" ")).not.toContain("modal-open");
|
||||
});
|
||||
});
|
||||
@@ -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 ? (
|
||||
<div>
|
||||
<p className="lead text-center">Affected alerts</p>
|
||||
{title ? <p className="lead text-center">{title}</p> : null}
|
||||
<div>
|
||||
<ul className="list-group list-group-flush mb-3">
|
||||
{labelsList
|
||||
@@ -63,12 +63,17 @@ const LabelSetList = ({ alertStore, labelsList }) => {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted text-center">No alerts matched</p>
|
||||
<div className="jumbotron bg-transparent">
|
||||
<h1 className="display-5 text-placeholder text-center">
|
||||
No alerts matched
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
LabelSetList.propTypes = {
|
||||
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
|
||||
labelsList: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
title: PropTypes.string,
|
||||
};
|
||||
|
||||
export { LabelSetList, GroupListToUniqueLabelsList };
|
||||
|
||||
@@ -19,7 +19,11 @@ afterEach(() => {
|
||||
|
||||
const MountedLabelSetList = (labelsList) => {
|
||||
return mount(
|
||||
<LabelSetList alertStore={alertStore} labelsList={labelsList} />
|
||||
<LabelSetList
|
||||
alertStore={alertStore}
|
||||
labelsList={labelsList}
|
||||
title="Affected alerts"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = () => (
|
||||
<div className="text-center">
|
||||
@@ -55,32 +51,6 @@ const SuccessMessage = () => (
|
||||
</div>
|
||||
);
|
||||
|
||||
const DeletePreview = ({ alertStore, silence }) => {
|
||||
const { response, error, isLoading } = useFetchGet(
|
||||
FormatBackendURI("alerts.json?") +
|
||||
FormatAlertsQ([
|
||||
FormatQuery(StaticLabels.SilenceID, QueryOperators.Equal, silence.id),
|
||||
])
|
||||
);
|
||||
|
||||
return isLoading ? (
|
||||
<ProgressMessage />
|
||||
) : error ? (
|
||||
<ErrorMessage message={error} />
|
||||
) : (
|
||||
<LabelSetList
|
||||
alertStore={alertStore}
|
||||
labelsList={GroupListToUniqueLabelsList(
|
||||
response.grids.length ? response.grids[0].alertGroups : []
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
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 = ({
|
||||
/>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<DeletePreview alertStore={alertStore} silence={silence} />
|
||||
<PaginatedAlertList
|
||||
alertStore={alertStore}
|
||||
filters={[
|
||||
FormatQuery(
|
||||
StaticLabels.SilenceID,
|
||||
QueryOperators.Equal,
|
||||
silence.id
|
||||
),
|
||||
]}
|
||||
title="Affected alerts"
|
||||
/>
|
||||
<div className="d-flex flex-row-reverse">
|
||||
<button
|
||||
type="button"
|
||||
@@ -245,9 +225,4 @@ DeleteSilence.defaultProps = {
|
||||
isUpper: false,
|
||||
};
|
||||
|
||||
export {
|
||||
DeleteSilence,
|
||||
DeleteSilenceModalContent,
|
||||
DeletePreview,
|
||||
DeleteResult,
|
||||
};
|
||||
export { DeleteSilence, DeleteSilenceModalContent, DeleteResult };
|
||||
|
||||
@@ -5,7 +5,6 @@ import { mount } from "enzyme";
|
||||
|
||||
import { advanceTo, clear } from "jest-date-mock";
|
||||
|
||||
import { EmptyAPIResponse } from "__mocks__/Fetch";
|
||||
import { MockSilence } from "__mocks__/Alerts";
|
||||
import { PressKey } from "__mocks__/PressKey";
|
||||
import { AlertStore } from "Stores/AlertStore";
|
||||
@@ -137,68 +136,6 @@ describe("<DeleteSilenceModalContent />", () => {
|
||||
expect(silenceFormStore.toggle.blurred).toBe(false);
|
||||
});
|
||||
|
||||
it("renders ProgressMessage while loading preview", () => {
|
||||
useFetchGet.fetch.setMockedData({
|
||||
response: null,
|
||||
error: false,
|
||||
isLoading: true,
|
||||
isRetrying: false,
|
||||
});
|
||||
const tree = MountedDeleteSilenceModalContent();
|
||||
expect(tree.find("ProgressMessage")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders LabelSetList with StaticLabel on mount", () => {
|
||||
const tree = MountedDeleteSilenceModalContent();
|
||||
expect(tree.find("LabelSetList")).toHaveLength(1);
|
||||
expect(tree.find("StaticLabel")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("renders empty LabelSetList with empty response", () => {
|
||||
useFetchGet.fetch.setMockedData({
|
||||
response: EmptyAPIResponse(),
|
||||
error: false,
|
||||
isLoading: false,
|
||||
isRetrying: false,
|
||||
});
|
||||
const tree = MountedDeleteSilenceModalContent();
|
||||
expect(tree.find("LabelSetList")).toHaveLength(1);
|
||||
expect(tree.find("StaticLabel")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fetches affected alerts on mount", () => {
|
||||
MountedDeleteSilenceModalContent();
|
||||
expect(useFetchGet).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders StaticLabel after fetch", () => {
|
||||
const tree = MountedDeleteSilenceModalContent();
|
||||
expect(tree.text()).toMatch(/Affected alerts/);
|
||||
expect(tree.find("StaticLabel")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("handles empty grid response correctly", () => {
|
||||
useFetchGet.fetch.setMockedData({
|
||||
response: EmptyAPIResponse(),
|
||||
error: false,
|
||||
isLoading: false,
|
||||
isRetrying: false,
|
||||
});
|
||||
const tree = MountedDeleteSilenceModalContent();
|
||||
expect(tree.text()).toMatch(/No alerts matched/);
|
||||
});
|
||||
|
||||
it("renders ErrorMessage on failed preview fetch", () => {
|
||||
useFetchGet.fetch.setMockedData({
|
||||
response: null,
|
||||
error: "fake error",
|
||||
isLoading: false,
|
||||
isRetrying: false,
|
||||
});
|
||||
const tree = MountedDeleteSilenceModalContent();
|
||||
expect(tree.find("ErrorMessage")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("sends a DELETE request after clicking 'Confirm' button", () => {
|
||||
const tree = MountedDeleteSilenceModalContent();
|
||||
tree.find(".btn-danger").simulate("click");
|
||||
|
||||
60
ui/src/Components/PaginatedAlertList/index.js
Normal file
60
ui/src/Components/PaginatedAlertList/index.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclamationCircle";
|
||||
import { faSpinner } from "@fortawesome/free-solid-svg-icons/faSpinner";
|
||||
|
||||
import { AlertStore, FormatBackendURI, FormatAlertsQ } from "Stores/AlertStore";
|
||||
import {
|
||||
LabelSetList,
|
||||
GroupListToUniqueLabelsList,
|
||||
} from "Components/LabelSetList";
|
||||
import { useFetchGet } from "Hooks/useFetchGet";
|
||||
|
||||
const FetchError = ({ message }) => (
|
||||
<div className="text-center">
|
||||
<h2 className="display-2 text-danger">
|
||||
<FontAwesomeIcon icon={faExclamationCircle} />
|
||||
</h2>
|
||||
<p className="lead text-muted">{message}</p>
|
||||
</div>
|
||||
);
|
||||
FetchError.propTypes = {
|
||||
message: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
const Placeholder = () => (
|
||||
<div className="jumbotron bg-transparent">
|
||||
<h1 className="display-5 text-placeholder text-center">
|
||||
<FontAwesomeIcon icon={faSpinner} size="lg" spin />
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PaginatedAlertList = ({ alertStore, filters, title }) => {
|
||||
const { response, error, isLoading } = useFetchGet(
|
||||
FormatBackendURI("alerts.json?") + FormatAlertsQ(filters)
|
||||
);
|
||||
|
||||
return isLoading ? (
|
||||
<Placeholder />
|
||||
) : error ? (
|
||||
<FetchError message={error} />
|
||||
) : (
|
||||
<LabelSetList
|
||||
alertStore={alertStore}
|
||||
labelsList={GroupListToUniqueLabelsList(
|
||||
response.grids.length ? response.grids[0].alertGroups : []
|
||||
)}
|
||||
title={title}
|
||||
/>
|
||||
);
|
||||
};
|
||||
PaginatedAlertList.propTypes = {
|
||||
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
|
||||
filters: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
title: PropTypes.string,
|
||||
};
|
||||
|
||||
export { PaginatedAlertList };
|
||||
125
ui/src/Components/PaginatedAlertList/index.test.js
Normal file
125
ui/src/Components/PaginatedAlertList/index.test.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import React from "react";
|
||||
|
||||
import { mount } from "enzyme";
|
||||
|
||||
import { advanceTo, clear } from "jest-date-mock";
|
||||
|
||||
import { EmptyAPIResponse } from "__mocks__/Fetch";
|
||||
import { AlertStore } from "Stores/AlertStore";
|
||||
import { useFetchGet } from "Hooks/useFetchGet";
|
||||
import { useFetchDelete } from "Hooks/useFetchDelete";
|
||||
import { PaginatedAlertList } from ".";
|
||||
|
||||
let alertStore;
|
||||
|
||||
beforeEach(() => {
|
||||
advanceTo(new Date(Date.UTC(2000, 0, 1, 0, 30, 0)));
|
||||
jest.useFakeTimers();
|
||||
|
||||
alertStore = new AlertStore([]);
|
||||
|
||||
alertStore.data.upstreams = {
|
||||
instances: [
|
||||
{
|
||||
name: "am1",
|
||||
cluster: "am",
|
||||
uri: "http://localhost:9093",
|
||||
readonly: false,
|
||||
error: "",
|
||||
version: "0.17.0",
|
||||
headers: {},
|
||||
corsCredentials: "include",
|
||||
clusterMembers: ["am1"],
|
||||
},
|
||||
],
|
||||
clusters: { am: ["am1"] },
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
useFetchGet.mockReset();
|
||||
useFetchDelete.mockReset();
|
||||
clear();
|
||||
document.body.className = "";
|
||||
});
|
||||
|
||||
describe("<PaginatedAlertList />", () => {
|
||||
it("renders Placeholder while loading preview", () => {
|
||||
useFetchGet.fetch.setMockedData({
|
||||
response: null,
|
||||
error: false,
|
||||
isLoading: true,
|
||||
isRetrying: false,
|
||||
});
|
||||
const tree = mount(
|
||||
<PaginatedAlertList alertStore={alertStore} filters={["foo=bar"]} />
|
||||
);
|
||||
expect(tree.find("Placeholder")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders LabelSetList with StaticLabel on mount", () => {
|
||||
const tree = mount(
|
||||
<PaginatedAlertList alertStore={alertStore} filters={["foo=bar"]} />
|
||||
);
|
||||
expect(tree.find("LabelSetList")).toHaveLength(1);
|
||||
expect(tree.find("StaticLabel")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("renders empty LabelSetList with empty response", () => {
|
||||
useFetchGet.fetch.setMockedData({
|
||||
response: EmptyAPIResponse(),
|
||||
error: false,
|
||||
isLoading: false,
|
||||
isRetrying: false,
|
||||
});
|
||||
const tree = mount(
|
||||
<PaginatedAlertList alertStore={alertStore} filters={["foo=bar"]} />
|
||||
);
|
||||
expect(tree.find("LabelSetList")).toHaveLength(1);
|
||||
expect(tree.find("StaticLabel")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fetches affected alerts on mount", () => {
|
||||
mount(<PaginatedAlertList alertStore={alertStore} filters={["foo=bar"]} />);
|
||||
expect(useFetchGet).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders StaticLabel after fetch", () => {
|
||||
const tree = mount(
|
||||
<PaginatedAlertList
|
||||
alertStore={alertStore}
|
||||
filters={["foo=bar"]}
|
||||
title="Affected alerts"
|
||||
/>
|
||||
);
|
||||
expect(tree.text()).toMatch(/Affected alerts/);
|
||||
expect(tree.find("StaticLabel")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("handles empty grid response correctly", () => {
|
||||
useFetchGet.fetch.setMockedData({
|
||||
response: EmptyAPIResponse(),
|
||||
error: false,
|
||||
isLoading: false,
|
||||
isRetrying: false,
|
||||
});
|
||||
const tree = mount(
|
||||
<PaginatedAlertList alertStore={alertStore} filters={["foo=bar"]} />
|
||||
);
|
||||
expect(tree.text()).toMatch(/No alerts matched/);
|
||||
});
|
||||
|
||||
it("renders FetchError on failed preview fetch", () => {
|
||||
useFetchGet.fetch.setMockedData({
|
||||
response: null,
|
||||
error: "fake error",
|
||||
isLoading: false,
|
||||
isRetrying: false,
|
||||
});
|
||||
const tree = mount(
|
||||
<PaginatedAlertList alertStore={alertStore} filters={["foo=bar"]} />
|
||||
);
|
||||
expect(tree.find("FetchError")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -4,63 +4,26 @@ import PropTypes from "prop-types";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faArrowLeft } from "@fortawesome/free-solid-svg-icons/faArrowLeft";
|
||||
import { faCheckCircle } from "@fortawesome/free-solid-svg-icons/faCheckCircle";
|
||||
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclamationCircle";
|
||||
import { faSpinner } from "@fortawesome/free-solid-svg-icons/faSpinner";
|
||||
|
||||
import { AlertStore, FormatBackendURI, FormatAlertsQ } from "Stores/AlertStore";
|
||||
import { AlertStore } from "Stores/AlertStore";
|
||||
import { SilenceFormStore } from "Stores/SilenceFormStore";
|
||||
import {
|
||||
LabelSetList,
|
||||
GroupListToUniqueLabelsList,
|
||||
} from "Components/LabelSetList";
|
||||
import { useFetchGet } from "Hooks/useFetchGet";
|
||||
import { PaginatedAlertList } from "Components/PaginatedAlertList";
|
||||
import { MatcherToFilter, AlertManagersToFilter } from "../Matchers";
|
||||
|
||||
const FetchError = ({ message }) => (
|
||||
<div className="text-center">
|
||||
<h2 className="display-2 text-danger">
|
||||
<FontAwesomeIcon icon={faExclamationCircle} />
|
||||
</h2>
|
||||
<p className="lead text-muted">{message}</p>
|
||||
</div>
|
||||
);
|
||||
FetchError.propTypes = {
|
||||
message: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
const Placeholder = () => (
|
||||
<div className="jumbotron bg-transparent">
|
||||
<h1 className="display-5 text-placeholder text-center">
|
||||
<FontAwesomeIcon icon={faSpinner} size="lg" spin />
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SilencePreview = ({ alertStore, silenceFormStore }) => {
|
||||
const filters = [
|
||||
...silenceFormStore.data.matchers.map((m) => MatcherToFilter(m)),
|
||||
AlertManagersToFilter(silenceFormStore.data.alertmanagers),
|
||||
];
|
||||
|
||||
const { response, error, isLoading } = useFetchGet(
|
||||
FormatBackendURI("alerts.json?") + FormatAlertsQ(filters)
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="mb-3">
|
||||
{isLoading ? (
|
||||
<Placeholder />
|
||||
) : error ? (
|
||||
<FetchError message={error} />
|
||||
) : (
|
||||
<LabelSetList
|
||||
alertStore={alertStore}
|
||||
labelsList={GroupListToUniqueLabelsList(
|
||||
response.grids.length ? response.grids[0].alertGroups : []
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<PaginatedAlertList
|
||||
alertStore={alertStore}
|
||||
filters={filters}
|
||||
title="Affected alerts"
|
||||
/>
|
||||
</div>
|
||||
<div className="d-flex flex-row-reverse">
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user