feat(ui): allow deleting silences (with preview)

This commit is contained in:
Łukasz Mierzwa
2018-10-08 19:45:27 +01:00
parent 01c108fd41
commit 66d9bba680
6 changed files with 472 additions and 5 deletions

View File

@@ -7,7 +7,8 @@ const StaticLabels = Object.freeze({
AlertName: "alertname",
AlertManager: "@alertmanager",
Receiver: "@receiver",
State: "@state"
State: "@state",
SilenceID: "@silence_id"
});
function FormatQuery(name, operator, value) {

View File

@@ -0,0 +1,259 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import { observable, action } from "mobx";
import { observer } from "mobx-react";
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 { APIAlertmanagerUpstream } from "Models/API";
import { AlertStore, FormatBackendURI, FormatAlertsQ } from "Stores/AlertStore";
import { FormatQuery, QueryOperators, StaticLabels } from "Common/Query";
import { Modal } from "Components/Modal";
import {
LabelSetList,
GroupListToUniqueLabelsList
} from "Components/LabelSetList";
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;
}
},
{
setDone: action.bound,
setError: 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 = fetch(alertsURI, { credentials: "include" })
.then(result => {
return 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;
const uri = `${alertmanager.publicURI}/api/v1/silence/${silenceID}`;
this.deleteState.fetch = fetch(uri, { method: "DELETE" })
.then(result => result.json())
.then(result => this.parseAlertmanagerResponse(result))
.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.previewState.error === null ? (
<div>
<p className="lead text-center">
Alerts affected by this silence
</p>
<LabelSetList
alertStore={alertStore}
labelsList={this.previewState.alertLabels}
/>
</div>
) : (
<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}
>
<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 text-nowrap text-truncate px-1 cursor-pointer components-label-with-hover`}
onClick={this.toggle.toggle}
>
<FontAwesomeIcon className="mr-1" icon={faTrash} />
Delete
</span>
<Modal isOpen={this.toggle.visible}>
<DeleteSilenceModalContent
alertStore={alertStore}
alertmanager={alertmanager}
silenceID={silenceID}
onHide={this.toggle.toggle}
/>
</Modal>
</React.Fragment>
);
}
}
);
export { DeleteSilence, DeleteSilenceModalContent };

View File

@@ -0,0 +1,178 @@
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([]);
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 />", async () => {
it("label is 'Delete' by default", () => {
const tree = MountedDeleteSilence();
expect(tree.text()).toBe("Delete");
});
it("opens modal on click", async () => {
const tree = MountedDeleteSilence();
tree.simulate("click");
expect(tree.find(".modal-body")).toHaveLength(1);
});
});
describe("<DeleteSilenceModalContent />", () => {
it("renders LabelSetList on mount", async () => {
const tree = MountedDeleteSilenceModalContent();
expect(tree.find("LabelSetList")).toHaveLength(1);
});
it("fetches affected alerts on mount", async () => {
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("sends a DELETE request after clicking 'Confirm' button", async () => {
await VerifyResponse({ status: "success" });
expect(fetch.mock.calls[1][0]).toBe(
"http://am.example.com/api/v1/silence/123456789"
);
expect(fetch.mock.calls[1][1]).toMatchObject({ method: "DELETE" });
});
it("'Confirm' button is no-op after successful DELETE", async () => {
const tree = await VerifyResponse({ status: "success" });
expect(fetch.mock.calls[1][0]).toBe(
"http://am.example.com/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);
});
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("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);
});
});

View File

@@ -172,7 +172,7 @@ exports[`<Silence /> matches snapshot with expaned details 1`] = `
in 5 hours
</time>
</span>
<span class=\\"badge badge-secondary text-nowrap text-truncate px-1 cursor-pointer components-label-with-hover\\">
<span class=\\"badge badge-secondary text-nowrap text-truncate px-1 cursor-pointer components-label-with-hover mr-1\\">
<svg aria-hidden=\\"true\\"
data-prefix=\\"fas\\"
data-icon=\\"edit\\"
@@ -188,6 +188,22 @@ exports[`<Silence /> matches snapshot with expaned details 1`] = `
</svg>
Edit
</span>
<span class=\\"badge badge-danger text-nowrap text-truncate px-1 cursor-pointer components-label-with-hover\\">
<svg aria-hidden=\\"true\\"
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=\\"M0 84V56c0-13.3 10.7-24 24-24h112l9.4-18.7c4-8.2 12.3-13.3 21.4-13.3h114.3c9.1 0 17.4 5.1 21.5 13.3L312 32h112c13.3 0 24 10.7 24 24v28c0 6.6-5.4 12-12 12H12C5.4 96 0 90.6 0 84zm415.2 56.7L394.8 467c-1.6 25.3-22.6 45-47.9 45H101.1c-25.3 0-46.3-19.7-47.9-45L32.8 140.7c-.4-6.9 5.1-12.7 12-12.7h358.5c6.8 0 12.3 5.8 11.9 12.7z\\"
>
</path>
</svg>
Delete
</span>
</div>
<div>
<span class=\\"badge text-nowrap text-truncate px-1 mr-1\\">

View File

@@ -29,6 +29,7 @@ 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";
@@ -87,7 +88,12 @@ SilenceExpiryBadgeWithProgress.propTypes = {
progress: PropTypes.number.isRequired
};
const SilenceDetails = ({ alertmanager, silence, onEditSilence }) => {
const SilenceDetails = ({
alertStore,
alertmanager,
silence,
onEditSilence
}) => {
let expiresClass = "";
let expiresLabel = "Expires";
if (moment(silence.endsAt) < moment()) {
@@ -119,12 +125,17 @@ const SilenceDetails = ({ alertmanager, silence, onEditSilence }) => {
{expiresLabel} <Moment fromNow>{silence.endsAt}</Moment>
</span>
<span
className="badge badge-secondary text-nowrap text-truncate px-1 cursor-pointer components-label-with-hover"
className="badge badge-secondary text-nowrap text-truncate px-1 cursor-pointer 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>
<span className="badge text-nowrap text-truncate px-1 mr-1">
@@ -277,7 +288,7 @@ const Silence = inject("alertStore")(
}
render() {
const { alertmanagerState, silenceID } = this.props;
const { alertStore, alertmanagerState, silenceID } = this.props;
const silence = this.getSilence();
if (!silence)
@@ -320,6 +331,7 @@ const Silence = inject("alertStore")(
</div>
{this.collapse.value ? null : (
<SilenceDetails
alertStore={alertStore}
alertmanager={alertmanager}
silence={silence}
onEditSilence={this.onEditSilence}

View File

@@ -100,6 +100,7 @@ const MountedSilenceDetails = onEditSilence => {
return mount(
<Provider alertStore={alertStore}>
<SilenceDetails
alertStore={alertStore}
alertmanager={alertStore.data.upstreams.instances[0]}
silence={silence}
onEditSilence={onEditSilence}