chore(ui): replace Silence usage with ManagedSilence

This commit is contained in:
Łukasz Mierzwa
2019-10-26 17:35:46 +01:00
parent 87efb25cd8
commit 96cd8a856b
13 changed files with 220 additions and 1586 deletions

View File

@@ -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 => (
<Silence
key={silenceID}
silenceFormStore={silenceFormStore}
alertmanagerState={clusterSilences.alertmanager}
silenceID={silenceID}
afterUpdate={afterUpdate}
/>
))
{Object.entries(silences).map(([cluster, clusterSilences]) =>
clusterSilences.silences.map(silenceID =>
RenderSilence(
alertStore,
silenceFormStore,
afterUpdate,
cluster,
silenceID
)
)
)}
</li>
);

View File

@@ -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("<Alert />", () => {
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("<Alert />", () => {
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("<Alert />", () => {
{ default: ["silence123456789"] }
);
const tree = MountedAlert(alert, group, false, false);
const silence = tree.find("Silence");
const silence = tree.find("ManagedSilence");
expect(silence).toHaveLength(0);
});

View File

@@ -152,7 +152,7 @@ exports[`<GroupFooter /> mathes snapshot when silence is rendered 1`] = `
<div class
style=\\"display: inline-block; max-width: 100%;\\"
data-tooltipped
aria-describedby=\\"tippy-tooltip-13\\"
aria-describedby=\\"tippy-tooltip-25\\"
data-original-title=\\"Click the icon to hide annotation value\\"
>
<div class=\\"mr-1 mb-1 p-1 bg-light d-inline-block rounded components-grid-annotation text-break mw-100\\">
@@ -183,7 +183,7 @@ exports[`<GroupFooter /> mathes snapshot when silence is rendered 1`] = `
<div class
style=\\"display: inline-block; max-width: 100%;\\"
data-tooltipped
aria-describedby=\\"tippy-tooltip-14\\"
aria-describedby=\\"tippy-tooltip-26\\"
data-original-title=\\"Click to show annotation value\\"
>
<div class=\\"mr-1 mb-1 p-1 bg-light d-inline-block rounded components-grid-annotation text-break mw-100 cursor-pointer\\">
@@ -208,7 +208,7 @@ exports[`<GroupFooter /> mathes snapshot when silence is rendered 1`] = `
<div class
style=\\"display: inline-block; max-width: 100%;\\"
data-tooltipped
aria-describedby=\\"tippy-tooltip-15\\"
aria-describedby=\\"tippy-tooltip-27\\"
data-original-title=\\"Click to only show alerts with this label or Alt+Click to hide them\\"
>
<span class=\\"components-label badge badge-warning components-label-dark components-label-with-hover\\">
@@ -223,7 +223,7 @@ exports[`<GroupFooter /> mathes snapshot when silence is rendered 1`] = `
<div class
style=\\"display: inline-block; max-width: 100%;\\"
data-tooltipped
aria-describedby=\\"tippy-tooltip-16\\"
aria-describedby=\\"tippy-tooltip-28\\"
data-original-title=\\"Click to only show alerts with this label or Alt+Click to hide them\\"
>
<span class=\\"components-label badge badge-warning components-label-dark components-label-with-hover\\">
@@ -238,7 +238,7 @@ exports[`<GroupFooter /> mathes snapshot when silence is rendered 1`] = `
<div class
style=\\"display: inline-block; max-width: 100%;\\"
data-tooltipped
aria-describedby=\\"tippy-tooltip-17\\"
aria-describedby=\\"tippy-tooltip-29\\"
data-original-title=\\"Click to only show alerts with this label or Alt+Click to hide them\\"
>
<span class=\\"components-label badge badge-warning components-label-dark components-label-with-hover\\">
@@ -253,7 +253,7 @@ exports[`<GroupFooter /> mathes snapshot when silence is rendered 1`] = `
<div class
style=\\"display: inline-block; max-width: 100%;\\"
data-tooltipped
aria-describedby=\\"tippy-tooltip-18\\"
aria-describedby=\\"tippy-tooltip-30\\"
data-original-title=\\"Click to only show alerts with this label or Alt+Click to hide them\\"
>
<span class=\\"components-label badge badge-warning components-label-dark components-label-with-hover\\">
@@ -286,55 +286,54 @@ exports[`<GroupFooter /> mathes snapshot when silence is rendered 1`] = `
</svg>
link
</a>
<div class=\\"components-grid-alertgrid-alertgroup-shared-silence rounded-0 border-left-1 border-right-0 border-top-0 border-bottom-0 border-success \\">
<div class=\\"card mt-1 border-0 p-1 bg-transparent\\">
<div class=\\"card-text mb-0\\">
<span class=\\"text-muted my-1\\">
<span width=\\"0\\">
<span>
</span>
<span>
Mocked Silence
</span>
<span style=\\"position: fixed; visibility: hidden; top: 0px; left: 0px;\\">
</span>
</span>
<span class=\\"blockquote-footer pt-1\\">
<span class=\\"float-right cursor-pointer\\">
<div class
style=\\"display: inline-block; max-width: 100%;\\"
data-tooltipped
aria-describedby=\\"tippy-tooltip-19\\"
data-original-title=\\"Toggle silence details\\"
<div class=\\"components-grid-alertgrid-alertgroup-shared-silence rounded-0 border-0\\">
<div class=\\"card mb-1 components-managed-silence components-animation-fade-appear components-animation-fade-appear-active\\">
<div class=\\"card-header border-bottom-0\\">
<div class=\\"d-flex flex-row justify-content-between\\">
<div class=\\"flex-grow-1 flex-shrink-1 mr-2\\">
<span class=\\"my-1\\">
<span class=\\"font-italic\\"
width=\\"0\\"
>
<svg aria-hidden=\\"true\\"
focusable=\\"false\\"
data-prefix=\\"fas\\"
data-icon=\\"chevron-up\\"
class=\\"svg-inline--fa fa-chevron-up fa-w-14 \\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 448 512\\"
>
<path fill=\\"currentColor\\"
d=\\"M240.971 130.524l194.343 194.343c9.373 9.373 9.373 24.569 0 33.941l-22.667 22.667c-9.357 9.357-24.522 9.375-33.901.04L224 227.495 69.255 381.516c-9.379 9.335-24.544 9.317-33.901-.04l-22.667-22.667c-9.373-9.373-9.373-24.569 0-33.941L207.03 130.525c9.372-9.373 24.568-9.373 33.941-.001z\\"
>
</path>
</svg>
</div>
<span>
</span>
<span>
Mocked Silence
</span>
<span style=\\"position: fixed; visibility: hidden; top: 0px; left: 0px;\\">
</span>
</span>
<span class=\\"blockquote-footer pt-1\\">
<cite class=\\"components-grid-alertgroup-silences mr-2\\">
me@example.com
</cite>
<span class=\\"badge badge-danger align-text-bottom p-1\\">
Expired
<time datetime=\\"946688400000\\">
14 hours ago
</time>
</span>
</span>
</span>
<cite class=\\"components-grid-alertgroup-silences mr-2\\">
me@example.com
</cite>
<span class=\\"badge badge-danger align-bottom\\">
Expired
<time datetime=\\"946688400000\\">
14 hours ago
</time>
</span>
</span>
</span>
</div>
<div class=\\"flex-grow-0 flex-shrink-0 mt-auto mb-0\\">
<svg aria-hidden=\\"true\\"
focusable=\\"false\\"
data-prefix=\\"fas\\"
data-icon=\\"chevron-up\\"
class=\\"svg-inline--fa fa-chevron-up fa-w-14 text-muted cursor-pointer\\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 448 512\\"
>
<path fill=\\"currentColor\\"
d=\\"M240.971 130.524l194.343 194.343c9.373 9.373 9.373 24.569 0 33.941l-22.667 22.667c-9.357 9.357-24.522 9.375-33.901.04L224 227.495 69.255 381.516c-9.379 9.335-24.544 9.317-33.901-.04l-22.667-22.667c-9.373-9.373-9.373-24.569 0-33.941L207.03 130.525c9.372-9.373 24.568-9.373 33.941-.001z\\"
>
</path>
</svg>
</div>
</div>
</div>
</div>
</div>

View File

@@ -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 : (
<div className="components-grid-alertgrid-alertgroup-shared-silence rounded-0 border-left-1 border-right-0 border-top-0 border-bottom-0 border-success ">
<div className="components-grid-alertgrid-alertgroup-shared-silence rounded-0 border-0">
{Object.entries(group.shared.silences).map(
([cluster, silences]) =>
silences.map(silenceID => (
<Silence
key={`${cluster}/${silenceID}`}
silenceFormStore={silenceFormStore}
alertmanagerState={
group.alerts.map(
a =>
a.alertmanager.filter(
am => am.cluster === cluster
)[0]
)[0]
}
silenceID={silenceID}
afterUpdate={afterUpdate}
/>
))
silences.map(silenceID =>
RenderSilence(
alertStore,
silenceFormStore,
afterUpdate,
cluster,
silenceID
)
)
)}
</div>
)}

View File

@@ -64,6 +64,7 @@ const MountedGroupFooter = () => {
group={group}
alertmanagers={["default"]}
afterUpdate={MockAfterUpdate}
alertStore={alertStore}
silenceFormStore={silenceFormStore}
/>
</Provider>
@@ -81,8 +82,40 @@ describe("<GroupFooter />", () => {
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", () => {

View File

@@ -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 = () => (
<div className="text-center">
<FontAwesomeIcon
icon={faCircleNotch}
className="text-muted display-1 mb-3"
spin
/>
</div>
);
const ErrorMessage = ({ message }) => (
<div className="text-center">
<FontAwesomeIcon
icon={faExclamationCircle}
className="text-danger display-1 mb-3"
/>
<p>{message}</p>
</div>
);
ErrorMessage.propTypes = {
message: PropTypes.node.isRequired
};
const SuccessMessage = () => (
<div className="text-center">
<FontAwesomeIcon
icon={faCheckCircle}
className="text-success display-1 mb-3"
/>
<p>
Silence deleted, it might take a few minutes for affected alerts to change
state
</p>
</div>
);
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 (
<React.Fragment>
<div className="modal-header">
<h5 className="modal-title">Delete silence</h5>
<button type="button" className="close" onClick={onHide}>
<span>&times;</span>
</button>
</div>
<div className="modal-body">
{this.deleteState.done ? (
this.deleteState.error !== null ? (
<ErrorMessage message={this.deleteState.error} />
) : (
<SuccessMessage />
)
) : this.deleteState.fetch !== null ? (
<ProgressMessage />
) : this.previewState.error === null ? (
<LabelSetList
alertStore={alertStore}
labelsList={this.previewState.alertLabels}
/>
) : (
<ErrorMessage message={this.previewState.error} />
)}
{isDone ? null : (
<div className="d-flex flex-row-reverse">
<button
type="button"
className="btn btn-outline-danger mr-2"
onClick={this.onDelete}
disabled={
this.deleteState.fetch !== null &&
this.deleteState.done === false
}
>
<FontAwesomeIcon icon={faCheckCircle} className="mr-1" />
{this.deleteState.fetch !== null &&
this.deleteState.error !== null
? "Retry"
: "Confirm"}
</button>
</div>
)}
</div>
</React.Fragment>
);
}
}
);
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 (
<React.Fragment>
<span
className={`badge badge-danger cursor-pointer components-label components-label-with-hover`}
onClick={this.toggle.toggle}
>
<FontAwesomeIcon className="mr-1" icon={faTrash} />
Delete
</span>
<Modal isOpen={this.toggle.visible} toggleOpen={this.toggle.toggle}>
<DeleteSilenceModalContent
alertStore={alertStore}
alertmanager={alertmanager}
silenceID={silenceID}
onHide={this.toggle.toggle}
/>
</Modal>
</React.Fragment>
);
}
}
);
export { DeleteSilence, DeleteSilenceModalContent };

View File

@@ -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(
<DeleteSilence
alertStore={alertStore}
alertmanager={alertmanager}
silenceID="123456789"
/>
);
};
const MountedDeleteSilenceModalContent = () => {
return mount(
<DeleteSilenceModalContent
alertStore={alertStore}
alertmanager={alertmanager}
silenceID="123456789"
onHide={MockOnHide}
/>
);
};
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("<DeleteSilence />", () => {
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("<DeleteSilenceModalContent />", () => {
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);
});
});

View File

@@ -1,272 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Silence /> matches snapshot when data is not present in alertStore 1`] = `
"
<div class=\\"m-1\\">
<small class=\\"text-muted\\">
Silenced by default/4cf5fd82-1edd-4169-99d1-ff8415e72179
</small>
</div>
"
`;
exports[`<Silence /> matches snapshot when data is present in alertStore 1`] = `
"
<div class=\\"card mt-1 border-0 p-1 bg-transparent\\">
<div class=\\"card-text mb-0\\">
<span class=\\"text-muted my-1\\">
<span width=\\"0\\">
<span>
</span>
<span>
Fake silence
</span>
<span style=\\"position: fixed; visibility: hidden; top: 0px; left: 0px;\\">
</span>
</span>
<span class=\\"blockquote-footer pt-1\\">
<span class=\\"float-right cursor-pointer\\">
<div class
style=\\"display: inline-block; max-width: 100%;\\"
data-tooltipped
aria-describedby=\\"tippy-tooltip-1\\"
data-original-title=\\"Toggle silence details\\"
>
<svg aria-hidden=\\"true\\"
focusable=\\"false\\"
data-prefix=\\"fas\\"
data-icon=\\"chevron-up\\"
class=\\"svg-inline--fa fa-chevron-up fa-w-14 \\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 448 512\\"
>
<path fill=\\"currentColor\\"
d=\\"M240.971 130.524l194.343 194.343c9.373 9.373 9.373 24.569 0 33.941l-22.667 22.667c-9.357 9.357-24.522 9.375-33.901.04L224 227.495 69.255 381.516c-9.379 9.335-24.544 9.317-33.901-.04l-22.667-22.667c-9.373-9.373-9.373-24.569 0-33.941L207.03 130.525c9.372-9.373 24.568-9.373 33.941-.001z\\"
>
</path>
</svg>
</div>
</span>
<cite class=\\"components-grid-alertgroup-silences mr-2\\">
me@example.com
</cite>
<span class=\\"badge badge-light nmb-05 align-bottom\\">
Expires
<time datetime=\\"946756800000\\">
in 5 hours
</time>
<div class=\\"progress silence-progress bg-white\\">
<div class=\\"progress-bar bg-success\\"
role=\\"progressbar\\"
style=\\"width: 50%;\\"
aria-valuenow=\\"50\\"
aria-valuemin=\\"0\\"
aria-valuemax=\\"100\\"
>
</div>
</div>
</span>
</span>
</span>
</div>
</div>
"
`;
exports[`<Silence /> matches snapshot with expaned details 1`] = `
"
<div class=\\"card mt-1 border-0 p-1 bg-transparent\\">
<div class=\\"card-text mb-0\\">
<span class=\\"text-muted my-1\\">
<span width=\\"0\\">
<span>
</span>
<span>
Fake silence
</span>
<span style=\\"position: fixed; visibility: hidden; top: 0px; left: 0px;\\">
</span>
</span>
<span class=\\"blockquote-footer pt-1\\">
<span class=\\"float-right cursor-pointer\\">
<div class
style=\\"display: inline-block; max-width: 100%;\\"
data-tooltipped
aria-describedby=\\"tippy-tooltip-5\\"
data-original-title=\\"Toggle silence details\\"
>
<svg aria-hidden=\\"true\\"
focusable=\\"false\\"
data-prefix=\\"fas\\"
data-icon=\\"chevron-down\\"
class=\\"svg-inline--fa fa-chevron-down fa-w-14 \\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 448 512\\"
>
<path fill=\\"currentColor\\"
d=\\"M207.029 381.476L12.686 187.132c-9.373-9.373-9.373-24.569 0-33.941l22.667-22.667c9.357-9.357 24.522-9.375 33.901-.04L224 284.505l154.745-154.021c9.379-9.335 24.544-9.317 33.901.04l22.667 22.667c9.373 9.373 9.373 24.569 0 33.941L240.971 381.476c-9.373 9.372-24.569 9.372-33.942 0z\\"
>
</path>
</svg>
</div>
</span>
<cite class=\\"components-grid-alertgroup-silences mr-2\\">
me@example.com
</cite>
</span>
</span>
</div>
<div class=\\"mt-1\\">
<div>
<div class
style=\\"display: inline-block; max-width: 100%;\\"
data-tooltipped
aria-describedby=\\"tippy-tooltip-6\\"
data-original-title=\\"Click to only show alerts with this label or Alt+Click to hide them\\"
>
<span class=\\"components-label badge badge-warning components-label-dark components-label-with-hover\\">
<span class=\\"components-label-name\\">
@alertmanager:
</span>
<span class=\\"components-label-value\\">
default
</span>
</span>
</div>
<a href=\\"http://example.com/#/silences/4cf5fd82-1edd-4169-99d1-ff8415e72179\\"
target=\\"_blank\\"
rel=\\"noopener noreferrer\\"
class=\\"components-label components-label-with-hover badge mr-1 components-grid-annotation-link\\"
>
<svg aria-hidden=\\"true\\"
focusable=\\"false\\"
data-prefix=\\"fas\\"
data-icon=\\"external-link-alt\\"
class=\\"svg-inline--fa fa-external-link-alt fa-w-16 \\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 512 512\\"
>
<path fill=\\"currentColor\\"
d=\\"M432,320H400a16,16,0,0,0-16,16V448H64V128H208a16,16,0,0,0,16-16V80a16,16,0,0,0-16-16H48A48,48,0,0,0,0,112V464a48,48,0,0,0,48,48H400a48,48,0,0,0,48-48V336A16,16,0,0,0,432,320ZM488,0h-128c-21.37,0-32.05,25.91-17,41l35.73,35.73L135,320.37a24,24,0,0,0,0,34L157.67,377a24,24,0,0,0,34,0L435.28,133.32,471,169c15,15,41,4.5,41-17V24A24,24,0,0,0,488,0Z\\"
>
</path>
</svg>
4cf5fd82-1edd-4169-99d1-ff8415e72179
</a>
</div>
<div>
<span class=\\"badge px-1 mr-1 components-label\\">
<svg aria-hidden=\\"true\\"
focusable=\\"false\\"
data-prefix=\\"fas\\"
data-icon=\\"calendar-check\\"
class=\\"svg-inline--fa fa-calendar-check fa-w-14 text-muted mr-1\\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 448 512\\"
>
<path fill=\\"currentColor\\"
d=\\"M436 160H12c-6.627 0-12-5.373-12-12v-36c0-26.51 21.49-48 48-48h48V12c0-6.627 5.373-12 12-12h40c6.627 0 12 5.373 12 12v52h128V12c0-6.627 5.373-12 12-12h40c6.627 0 12 5.373 12 12v52h48c26.51 0 48 21.49 48 48v36c0 6.627-5.373 12-12 12zM12 192h424c6.627 0 12 5.373 12 12v260c0 26.51-21.49 48-48 48H48c-26.51 0-48-21.49-48-48V204c0-6.627 5.373-12 12-12zm333.296 95.947l-28.169-28.398c-4.667-4.705-12.265-4.736-16.97-.068L194.12 364.665l-45.98-46.352c-4.667-4.705-12.266-4.736-16.971-.068l-28.397 28.17c-4.705 4.667-4.736 12.265-.068 16.97l82.601 83.269c4.667 4.705 12.265 4.736 16.97.068l142.953-141.805c4.705-4.667 4.736-12.265.068-16.97z\\"
>
</path>
</svg>
Started
<time datetime=\\"946720800000\\">
5 hours ago
</time>
</span>
<span class=\\"badge px-1 mr-1 components-label\\">
<svg aria-hidden=\\"true\\"
focusable=\\"false\\"
data-prefix=\\"fas\\"
data-icon=\\"calendar-times\\"
class=\\"svg-inline--fa fa-calendar-times fa-w-14 text-muted mr-1\\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 448 512\\"
>
<path fill=\\"currentColor\\"
d=\\"M436 160H12c-6.6 0-12-5.4-12-12v-36c0-26.5 21.5-48 48-48h48V12c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v52h128V12c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v52h48c26.5 0 48 21.5 48 48v36c0 6.6-5.4 12-12 12zM12 192h424c6.6 0 12 5.4 12 12v260c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V204c0-6.6 5.4-12 12-12zm257.3 160l48.1-48.1c4.7-4.7 4.7-12.3 0-17l-28.3-28.3c-4.7-4.7-12.3-4.7-17 0L224 306.7l-48.1-48.1c-4.7-4.7-12.3-4.7-17 0l-28.3 28.3c-4.7 4.7-4.7 12.3 0 17l48.1 48.1-48.1 48.1c-4.7 4.7-4.7 12.3 0 17l28.3 28.3c4.7 4.7 12.3 4.7 17 0l48.1-48.1 48.1 48.1c4.7 4.7 12.3 4.7 17 0l28.3-28.3c4.7-4.7 4.7-12.3 0-17L269.3 352z\\"
>
</path>
</svg>
Expires
<time datetime=\\"946756800000\\">
in 5 hours
</time>
</span>
<span class=\\"badge badge-secondary cursor-pointer components-label components-label-with-hover mr-1\\">
<svg aria-hidden=\\"true\\"
focusable=\\"false\\"
data-prefix=\\"fas\\"
data-icon=\\"edit\\"
class=\\"svg-inline--fa fa-edit fa-w-18 mr-1\\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 576 512\\"
>
<path fill=\\"currentColor\\"
d=\\"M402.6 83.2l90.2 90.2c3.8 3.8 3.8 10 0 13.8L274.4 405.6l-92.8 10.3c-12.4 1.4-22.9-9.1-21.5-21.5l10.3-92.8L388.8 83.2c3.8-3.8 10-3.8 13.8 0zm162-22.9l-48.8-48.8c-15.2-15.2-39.9-15.2-55.2 0l-35.4 35.4c-3.8 3.8-3.8 10 0 13.8l90.2 90.2c3.8 3.8 10 3.8 13.8 0l35.4-35.4c15.2-15.3 15.2-40 0-55.2zM384 346.2V448H64V128h229.8c3.2 0 6.2-1.3 8.5-3.5l40-40c7.6-7.6 2.2-20.5-8.5-20.5H48C21.5 64 0 85.5 0 112v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V306.2c0-10.7-12.9-16-20.5-8.5l-40 40c-2.2 2.3-3.5 5.3-3.5 8.5z\\"
>
</path>
</svg>
Edit
</span>
<span class=\\"badge badge-danger cursor-pointer components-label components-label-with-hover\\">
<svg aria-hidden=\\"true\\"
focusable=\\"false\\"
data-prefix=\\"fas\\"
data-icon=\\"trash\\"
class=\\"svg-inline--fa fa-trash fa-w-14 mr-1\\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 448 512\\"
>
<path fill=\\"currentColor\\"
d=\\"M432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16zM53.2 467a48 48 0 0 0 47.9 45h245.8a48 48 0 0 0 47.9-45L416 128H32z\\"
>
</path>
</svg>
Delete
</span>
</div>
<div class=\\"d-flex flex-row\\">
<div class=\\"flex-shrink-0 flex-grow-0\\">
<span class=\\"badge px-1 mr-1 components-label\\">
<svg aria-hidden=\\"true\\"
focusable=\\"false\\"
data-prefix=\\"fas\\"
data-icon=\\"filter\\"
class=\\"svg-inline--fa fa-filter fa-w-16 text-muted mr-1\\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 512 512\\"
>
<path fill=\\"currentColor\\"
d=\\"M487.976 0H24.028C2.71 0-8.047 25.866 7.058 40.971L192 225.941V432c0 7.831 3.821 15.17 10.237 19.662l80 55.98C298.02 518.69 320 507.493 320 487.98V225.941l184.947-184.97C520.021 25.896 509.338 0 487.976 0z\\"
>
</path>
</svg>
Matchers:
</span>
</div>
<div class=\\"flex-shrink-1 flex-grow-1\\"
style=\\"min-width: 0px;\\"
>
<span class=\\"badge badge-light px-1 mr-1 components-label\\">
alertname=MockAlert
</span>
<span class=\\"badge badge-light px-1 mr-1 components-label\\">
instance=~foo[0-9]+
</span>
</div>
</div>
</div>
</div>
"
`;

View File

@@ -1,8 +0,0 @@
.progress.silence-progress {
height: 2px;
margin-top: 2px;
}
.cite.components-grid-alertgroup-silences {
font-size: 100%;
}

View File

@@ -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 (
<a href={silence.jiraURL} target="_blank" rel="noopener noreferrer">
<FontAwesomeIcon className="mr-1" icon={faExternalLinkAlt} />
<Truncate
lines={collapsed ? showLines : false}
onTruncate={afterUpdate}
>
{silence.comment}
</Truncate>
</a>
);
}
return (
<Truncate lines={collapsed ? showLines : false}>{silence.comment}</Truncate>
);
};
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 (
<span className="badge badge-danger align-bottom">
Expired <Moment fromNow>{silence.endsAt}</Moment>
</span>
);
}
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 (
<span className="badge badge-light nmb-05 align-bottom">
Expires <Moment fromNow>{silence.endsAt}</Moment>
<div className="progress silence-progress bg-white">
<div
className={progressClass}
role="progressbar"
style={{ width: progress + "%" }}
aria-valuenow={progress}
aria-valuemin="0"
aria-valuemax="100"
/>
</div>
</span>
);
};
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 (
<div className="mt-1">
<div>
<FilteringLabel
name={StaticLabels.AlertManager}
value={alertmanager.name}
/>
<RenderLinkAnnotation
name={silence.id}
value={`${alertmanager.publicURI}/#/silences/${silence.id}`}
/>
</div>
<div>
<span className="badge px-1 mr-1 components-label">
<FontAwesomeIcon className="text-muted mr-1" icon={faCalendarCheck} />
Started <Moment fromNow>{silence.startsAt}</Moment>
</span>
<span className={`badge ${expiresClass} px-1 mr-1 components-label`}>
<FontAwesomeIcon className="text-muted mr-1" icon={faCalendarTimes} />
{expiresLabel} <Moment fromNow>{silence.endsAt}</Moment>
</span>
<span
className="badge badge-secondary cursor-pointer components-label components-label-with-hover mr-1"
onClick={onEditSilence}
>
<FontAwesomeIcon className="mr-1" icon={faEdit} />
Edit
</span>
<DeleteSilence
alertStore={alertStore}
alertmanager={alertmanager}
silenceID={silence.id}
/>
</div>
<div className="d-flex flex-row">
<div className="flex-shrink-0 flex-grow-0">
<span className="badge px-1 mr-1 components-label">
<FontAwesomeIcon className="text-muted mr-1" icon={faFilter} />
Matchers:
</span>
</div>
<div className="flex-shrink-1 flex-grow-1" style={{ minWidth: "0px" }}>
{silence.matchers.map(matcher => (
<span
key={hash(matcher)}
className="badge badge-light px-1 mr-1 components-label"
>
{matcher.name}
{matcher.isRegex ? QueryOperators.Regex : QueryOperators.Equal}
{matcher.value}
</span>
))}
</div>
</div>
</div>
);
};
SilenceDetails.propTypes = {
alertmanager: APIAlertmanagerUpstream.isRequired,
silence: APISilence.isRequired,
onEditSilence: PropTypes.func.isRequired
};
//
const FallbackSilenceDesciption = ({ alertmanagerName, silenceID }) => {
return (
<div className="m-1">
<small className="text-muted">
Silenced by {alertmanagerName}/{silenceID}
</small>
</div>
);
};
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 (
<FallbackSilenceDesciption
alertmanagerName={alertmanagerState.name}
silenceID={silenceID}
/>
);
const alertmanager = this.getAlertmanager();
return (
<div className="card mt-1 border-0 p-1 bg-transparent">
<div className="card-text mb-0">
<span className="text-muted my-1">
<SilenceComment
silence={silence}
collapsed={this.collapse.value}
afterUpdate={afterUpdate}
/>
<span className="blockquote-footer pt-1">
<span
className="float-right cursor-pointer"
onClick={this.collapse.toggle}
>
<TooltipWrapper title="Toggle silence details">
<FontAwesomeIcon
icon={this.collapse.value ? faChevronUp : faChevronDown}
/>
</TooltipWrapper>
</span>
<cite className="components-grid-alertgroup-silences mr-2">
{silence.createdBy}
</cite>
{this.collapse.value ? (
<SilenceExpiryBadgeWithProgress
silence={silence}
progress={this.progress.value}
/>
) : null}
</span>
</span>
</div>
{this.collapse.value ? null : (
<SilenceDetails
alertStore={alertStore}
alertmanager={alertmanager}
silence={silence}
onEditSilence={this.onEditSilence}
/>
)}
</div>
);
}
}
)
);
export {
Silence,
SilenceDetails,
SilenceComment,
SilenceExpiryBadgeWithProgress
};

View File

@@ -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(
<Provider alertStore={alertStore}>
<Silence
alertStore={alertStore}
silenceFormStore={silenceFormStore}
alertmanagerState={alertmanagerState}
silenceID="4cf5fd82-1edd-4169-99d1-ff8415e72179"
afterUpdate={mockAfterUpdate}
/>
</Provider>
);
};
const MountedSilenceDetails = onEditSilence => {
return mount(
<Provider alertStore={alertStore}>
<SilenceDetails
alertStore={alertStore}
alertmanager={alertStore.data.upstreams.instances[0]}
silence={silence}
onEditSilence={onEditSilence}
/>
</Provider>
).find("SilenceDetails");
};
describe("<Silence />", () => {
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("<SilenceDetails />", () => {
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("<SilenceExpiryBadgeWithProgress />", () => {
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);
});
});

View File

@@ -0,0 +1,57 @@
import React from "react";
import PropTypes from "prop-types";
import { ManagedSilence } from "Components/ManagedSilence";
const FallbackSilenceDesciption = ({ silenceID }) => {
return (
<div className="m-1">
<small className="text-muted">Silenced by {silenceID}</small>
</div>
);
};
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 (
<FallbackSilenceDesciption
key={silenceID}
silenceID={silenceID}
></FallbackSilenceDesciption>
);
}
return (
<ManagedSilence
key={silenceID}
cluster={cluster}
silence={silence}
alertStore={alertStore}
silenceFormStore={silenceFormStore}
onDidUpdate={afterUpdate}
/>
);
};
export { RenderSilence };

View File

@@ -273,6 +273,7 @@ const AlertGroup = observer(
group={group}
alertmanagers={footerAlertmanagers}
afterUpdate={afterUpdate}
alertStore={alertStore}
silenceFormStore={silenceFormStore}
/>
) : null}