Skip to content

Commit

Permalink
feat(ui): show modal with list of inhibiting alerts
Browse files Browse the repository at this point in the history
  • Loading branch information
prymitive committed Jun 30, 2020
1 parent c49a70e commit bd48ab6
Show file tree
Hide file tree
Showing 14 changed files with 449 additions and 164 deletions.
1 change: 1 addition & 0 deletions ui/src/Common/Query.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const StaticLabels = Object.freeze({
AlertName: "alertname",
AlertManager: "@alertmanager",
AlertmanagerCluster: "@cluster",
Fingerprint: "@fingerprint",
Receiver: "@receiver",
State: "@state",
SilenceID: "@silence_id",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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\\"
Expand Down
21 changes: 8 additions & 13 deletions ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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] = {
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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], [], {}, {});
Expand Down
38 changes: 38 additions & 0 deletions ui/src/Components/InhibitedByModal/InhibitedByModalContent.js
Original file line number Diff line number Diff line change
@@ -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">&times;</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 changes: 58 additions & 0 deletions ui/src/Components/InhibitedByModal/index.js
Original file line number Diff line number Diff line change
@@ -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 changes: 107 additions & 0 deletions ui/src/Components/InhibitedByModal/index.test.js
Original file line number Diff line number Diff line change
@@ -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");
});
});
11 changes: 8 additions & 3 deletions ui/src/Components/LabelSetList/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 };
6 changes: 5 additions & 1 deletion ui/src/Components/LabelSetList/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ afterEach(() => {

const MountedLabelSetList = (labelsList) => {
return mount(
<LabelSetList alertStore={alertStore} labelsList={labelsList} />
<LabelSetList
alertStore={alertStore}
labelsList={labelsList}
title="Affected alerts"
/>
);
};

Expand Down
Loading

0 comments on commit bd48ab6

Please sign in to comment.