mirror of
https://github.com/prymitive/karma
synced 2026-05-07 03:26:52 +00:00
feat(ui): allow deleting silences (with preview)
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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>×</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 };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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\\">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user