mirror of
https://github.com/prymitive/karma
synced 2026-05-07 03:26:52 +00:00
feat(ui): add UI for managing all silences
This change adds a new view mode in the silence modal that allows to browse and edit any silence found in alertmanager upstream. Fixes #625
This commit is contained in:
@@ -367,4 +367,9 @@ const Silence = inject("alertStore")(
|
||||
)
|
||||
);
|
||||
|
||||
export { Silence, SilenceDetails, SilenceExpiryBadgeWithProgress };
|
||||
export {
|
||||
Silence,
|
||||
SilenceDetails,
|
||||
SilenceComment,
|
||||
SilenceExpiryBadgeWithProgress
|
||||
};
|
||||
|
||||
@@ -6,25 +6,10 @@ import { observable, action } from "mobx";
|
||||
|
||||
import { AlertStore } from "Stores/AlertStore";
|
||||
import { Settings } from "Stores/Settings";
|
||||
import { Tab } from "Components/Modal/Tab";
|
||||
import { Configuration } from "./Configuration";
|
||||
import { Help } from "./Help";
|
||||
|
||||
const Tab = ({ title, active, onClick }) => (
|
||||
<span
|
||||
className={`nav-item nav-link cursor-pointer ${
|
||||
active ? "active" : "text-primary"
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
);
|
||||
Tab.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
active: PropTypes.bool,
|
||||
onClick: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
const TabNames = Object.freeze({
|
||||
Configuration: "configuration",
|
||||
Help: "help"
|
||||
|
||||
@@ -5,10 +5,10 @@ exports[`<MainModalContent /> matches snapshot 1`] = `
|
||||
<span>
|
||||
<div class=\\"modal-header py-2\\">
|
||||
<nav class=\\"nav nav-pills nav-justified w-100\\">
|
||||
<span class=\\"nav-item nav-link cursor-pointer active\\">
|
||||
<span class=\\"nav-item nav-link cursor-pointer mx-1 active\\">
|
||||
Configuration
|
||||
</span>
|
||||
<span class=\\"nav-item nav-link cursor-pointer text-primary\\">
|
||||
<span class=\\"nav-item nav-link cursor-pointer mx-1 components-tab-inactive\\">
|
||||
Help
|
||||
</span>
|
||||
<button type=\\"button\\"
|
||||
|
||||
324
ui/src/Components/ManagedSilence/DeleteSilence.js
Normal file
324
ui/src/Components/ManagedSilence/DeleteSilence.js
Normal file
@@ -0,0 +1,324 @@
|
||||
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 { APISilence } from "Models/API";
|
||||
import { AlertStore, FormatBackendURI, FormatAlertsQ } from "Stores/AlertStore";
|
||||
import { SilenceFormStore } from "Stores/SilenceFormStore";
|
||||
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,
|
||||
silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired,
|
||||
cluster: PropTypes.string.isRequired,
|
||||
silence: APISilence.isRequired,
|
||||
onHide: PropTypes.func
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
);
|
||||
|
||||
getAlertmanager = () =>
|
||||
this.props.alertStore.data.upstreams.instances
|
||||
.filter(u => u.cluster === this.props.cluster)
|
||||
.slice(0, 1)[0];
|
||||
|
||||
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 { silence } = this.props;
|
||||
|
||||
const alertsURI =
|
||||
FormatBackendURI("alerts.json?") +
|
||||
FormatAlertsQ([
|
||||
FormatQuery(StaticLabels.SilenceID, QueryOperators.Equal, silence.id)
|
||||
]);
|
||||
|
||||
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 { silence } = 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 alertmanager = this.getAlertmanager();
|
||||
|
||||
const isOpenAPI = semver.satisfies(alertmanager.version, ">=0.16.0");
|
||||
|
||||
const uri = isOpenAPI
|
||||
? `${alertmanager.uri}/api/v2/silence/${silence.id}`
|
||||
: `${alertmanager.uri}/api/v1/silence/${silence.id}`;
|
||||
|
||||
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() {
|
||||
const { silenceFormStore } = this.props;
|
||||
silenceFormStore.toggle.setBlur(true);
|
||||
this.onFetchPreview();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const { silenceFormStore } = this.props;
|
||||
silenceFormStore.toggle.setBlur(false);
|
||||
}
|
||||
|
||||
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.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,
|
||||
silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired,
|
||||
cluster: PropTypes.string.isRequired,
|
||||
silence: APISilence.isRequired
|
||||
};
|
||||
|
||||
toggle = observable(
|
||||
{
|
||||
visible: false,
|
||||
toggle() {
|
||||
this.visible = !this.visible;
|
||||
}
|
||||
},
|
||||
{ toggle: action.bound }
|
||||
);
|
||||
|
||||
render() {
|
||||
const { alertStore, silenceFormStore, cluster, silence } = this.props;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<button
|
||||
className="btn btn-outline-danger btn-sm"
|
||||
onClick={this.toggle.toggle}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
className="mr-1 d-none d-sm-inline-block"
|
||||
icon={faTrash}
|
||||
/>
|
||||
Delete
|
||||
</button>
|
||||
<Modal
|
||||
isOpen={this.toggle.visible}
|
||||
isUpper={true}
|
||||
toggleOpen={this.toggle.toggle}
|
||||
>
|
||||
<DeleteSilenceModalContent
|
||||
alertStore={alertStore}
|
||||
silenceFormStore={silenceFormStore}
|
||||
cluster={cluster}
|
||||
silence={silence}
|
||||
onHide={this.toggle.toggle}
|
||||
/>
|
||||
</Modal>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { DeleteSilence, DeleteSilenceModalContent };
|
||||
274
ui/src/Components/ManagedSilence/DeleteSilence.test.js
Normal file
274
ui/src/Components/ManagedSilence/DeleteSilence.test.js
Normal file
@@ -0,0 +1,274 @@
|
||||
import React from "react";
|
||||
|
||||
import { mount } from "enzyme";
|
||||
|
||||
import { EmptyAPIResponse } from "__mocks__/Fetch";
|
||||
import { MockAlertGroup, MockAlert, MockSilence } from "__mocks__/Alerts";
|
||||
import { AlertStore } from "Stores/AlertStore";
|
||||
import { SilenceFormStore } from "Stores/SilenceFormStore";
|
||||
import { DeleteSilence, DeleteSilenceModalContent } from "./DeleteSilence";
|
||||
|
||||
let alertStore;
|
||||
let silenceFormStore;
|
||||
let cluster;
|
||||
let silence;
|
||||
|
||||
beforeEach(() => {
|
||||
alertStore = new AlertStore([]);
|
||||
silenceFormStore = new SilenceFormStore();
|
||||
cluster = "am";
|
||||
silence = MockSilence();
|
||||
fetch.mockResponseOnce(JSON.stringify(MockAPIResponse()));
|
||||
|
||||
alertStore.data.upstreams = {
|
||||
instances: [
|
||||
{
|
||||
name: "am1",
|
||||
cluster: "am",
|
||||
uri: "http://localhost:9093",
|
||||
error: "",
|
||||
version: "0.15.3",
|
||||
headers: {}
|
||||
}
|
||||
],
|
||||
clusters: { am: ["am1"] }
|
||||
};
|
||||
|
||||
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}
|
||||
silenceFormStore={silenceFormStore}
|
||||
cluster={cluster}
|
||||
silence={silence}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const MountedDeleteSilenceModalContent = () => {
|
||||
return mount(
|
||||
<DeleteSilenceModalContent
|
||||
alertStore={alertStore}
|
||||
silenceFormStore={silenceFormStore}
|
||||
cluster={cluster}
|
||||
silence={silence}
|
||||
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("button")
|
||||
.at(0)
|
||||
.simulate("click");
|
||||
expect(tree.find(".modal-body")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DeleteSilenceModalContent />", () => {
|
||||
it("blurs silence form on mount", () => {
|
||||
expect(silenceFormStore.toggle.blurred).toBe(false);
|
||||
MountedDeleteSilenceModalContent();
|
||||
expect(silenceFormStore.toggle.blurred).toBe(true);
|
||||
});
|
||||
|
||||
it("unblurs silence form on unmount", () => {
|
||||
const tree = MountedDeleteSilenceModalContent();
|
||||
expect(silenceFormStore.toggle.blurred).toBe(true);
|
||||
tree.unmount();
|
||||
expect(silenceFormStore.toggle.blurred).toBe(false);
|
||||
});
|
||||
|
||||
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:9093/api/v1/silence/04d37636-2350-4878-b382-e0b50353230f"
|
||||
);
|
||||
expect(fetch.mock.calls[1][1]).toMatchObject({ method: "DELETE" });
|
||||
});
|
||||
|
||||
it("[v2] sends a DELETE request after clicking 'Confirm' button", async () => {
|
||||
alertStore.data.upstreams.instances[0].version = "0.16.2";
|
||||
await VerifyResponse({ status: "success" });
|
||||
expect(fetch.mock.calls[1][0]).toBe(
|
||||
"http://localhost:9093/api/v2/silence/04d37636-2350-4878-b382-e0b50353230f"
|
||||
);
|
||||
expect(fetch.mock.calls[1][1]).toMatchObject({ method: "DELETE" });
|
||||
});
|
||||
|
||||
it("[v1] sends headers from alertmanager config", async () => {
|
||||
alertStore.data.upstreams.instances[0].headers = {
|
||||
Authorization: "Basic ***"
|
||||
};
|
||||
await VerifyResponse({ status: "success" });
|
||||
expect(fetch.mock.calls[1][0]).toBe(
|
||||
"http://localhost:9093/api/v1/silence/04d37636-2350-4878-b382-e0b50353230f"
|
||||
);
|
||||
expect(fetch.mock.calls[1][1]).toMatchObject({
|
||||
credentials: "include",
|
||||
method: "DELETE",
|
||||
headers: { Authorization: "Basic ***" }
|
||||
});
|
||||
});
|
||||
|
||||
it("[v1] sends headers from alertmanager config", async () => {
|
||||
alertStore.data.upstreams.instances[0].headers = {
|
||||
Authorization: "Basic ***"
|
||||
};
|
||||
alertStore.data.upstreams.instances[0].version = "0.16.2";
|
||||
await VerifyResponse({ status: "success" });
|
||||
expect(fetch.mock.calls[1][0]).toBe(
|
||||
"http://localhost:9093/api/v2/silence/04d37636-2350-4878-b382-e0b50353230f"
|
||||
);
|
||||
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:9093/api/v1/silence/04d37636-2350-4878-b382-e0b50353230f"
|
||||
);
|
||||
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 () => {
|
||||
alertStore.data.upstreams.instances[0].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);
|
||||
});
|
||||
});
|
||||
32
ui/src/Components/ManagedSilence/SilenceComment.js
Normal file
32
ui/src/Components/ManagedSilence/SilenceComment.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import Truncate from "react-truncate";
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons/faExternalLinkAlt";
|
||||
|
||||
import { APISilence } from "Models/API";
|
||||
|
||||
const SilenceComment = ({ silence, collapsed, afterUpdate }) => {
|
||||
const comment = (
|
||||
<Truncate className="font-italic" lines={collapsed ? 2 : false}>
|
||||
{silence.comment}
|
||||
</Truncate>
|
||||
);
|
||||
if (silence.jiraURL) {
|
||||
return (
|
||||
<a href={silence.jiraURL} target="_blank" rel="noopener noreferrer">
|
||||
<FontAwesomeIcon className="mr-2" icon={faExternalLinkAlt} />
|
||||
{comment}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return <React.Fragment>{comment}</React.Fragment>;
|
||||
};
|
||||
SilenceComment.propTypes = {
|
||||
silence: APISilence.isRequired,
|
||||
collapsed: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export { SilenceComment };
|
||||
42
ui/src/Components/ManagedSilence/SilenceComment.test.js
Normal file
42
ui/src/Components/ManagedSilence/SilenceComment.test.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
|
||||
import { mount } from "enzyme";
|
||||
|
||||
import toDiffableHtml from "diffable-html";
|
||||
|
||||
import { MockSilence } from "__mocks__/Alerts";
|
||||
import { SilenceComment } from "./SilenceComment";
|
||||
|
||||
let silence;
|
||||
|
||||
beforeEach(() => {
|
||||
silence = MockSilence();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
fetch.resetMocks();
|
||||
});
|
||||
|
||||
const MountedSilenceComment = collapsed => {
|
||||
return mount(<SilenceComment silence={silence} collapsed={collapsed} />);
|
||||
};
|
||||
|
||||
describe("<SilenceComment />", () => {
|
||||
it("Matches snapshot when collapsed", () => {
|
||||
const tree = MountedSilenceComment(true);
|
||||
expect(toDiffableHtml(tree.html())).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("Matches snapshot when expanded", () => {
|
||||
const tree = MountedSilenceComment(false);
|
||||
expect(toDiffableHtml(tree.html())).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("Renders a JIRA link if present", () => {
|
||||
silence.jiraURL = "http://localhost/1234";
|
||||
silence.jiraID = "1234";
|
||||
const tree = MountedSilenceComment(true);
|
||||
expect(tree.find("a[href='http://localhost/1234']")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
144
ui/src/Components/ManagedSilence/SilenceDetails.js
Normal file
144
ui/src/Components/ManagedSilence/SilenceDetails.js
Normal file
@@ -0,0 +1,144 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import hash from "object-hash";
|
||||
|
||||
import moment from "moment";
|
||||
import Moment from "react-moment";
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
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 { faHome } from "@fortawesome/free-solid-svg-icons/faHome";
|
||||
|
||||
import { APISilence } from "Models/API";
|
||||
import { SilenceFormStore } from "Stores/SilenceFormStore";
|
||||
import { QueryOperators } from "Common/Query";
|
||||
import { RenderLinkAnnotation } from "Components/Grid/AlertGrid/AlertGroup/Annotation";
|
||||
import { DeleteSilence } from "./DeleteSilence";
|
||||
|
||||
const SilenceDetails = ({
|
||||
alertStore,
|
||||
silenceFormStore,
|
||||
silence,
|
||||
cluster,
|
||||
onEditSilence
|
||||
}) => {
|
||||
let isExpired = moment(silence.endsAt) < moment();
|
||||
let expiresClass = "";
|
||||
let expiresLabel = "Expires";
|
||||
if (isExpired) {
|
||||
expiresClass = "text-danger";
|
||||
expiresLabel = "Expired";
|
||||
}
|
||||
|
||||
const alertmanagers = alertStore.data.upstreams.instances.filter(
|
||||
u => u.cluster === cluster
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-1">
|
||||
<div className="d-flex flex-row justify-content-between">
|
||||
<div className="flex-shrink-1 flex-grow-1">
|
||||
<div>
|
||||
<span className="badge px-1 mr-1 components-label">
|
||||
<FontAwesomeIcon
|
||||
className="text-muted mr-1"
|
||||
icon={faCalendarCheck}
|
||||
fixedWidth
|
||||
/>
|
||||
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>
|
||||
</div>
|
||||
<div className="my-1">
|
||||
<span className="badge px-1 mr-1 components-label">
|
||||
<FontAwesomeIcon
|
||||
className="text-muted mr-1"
|
||||
icon={faHome}
|
||||
fixedWidth
|
||||
/>
|
||||
View in Alertmanager:
|
||||
</span>
|
||||
{alertmanagers.map(alertmanager => (
|
||||
<RenderLinkAnnotation
|
||||
key={alertmanager.name}
|
||||
name={alertmanager.name}
|
||||
value={`${alertmanager.publicURI}/#/silences/${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}
|
||||
fixedWidth
|
||||
/>
|
||||
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>
|
||||
<div className="flex-shrink-0 flex-grow-0">
|
||||
<div className="d-flex flex-column">
|
||||
<button
|
||||
className="btn btn-outline-secondary btn-sm mb-2"
|
||||
onClick={onEditSilence}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
className="mr-1 d-none d-sm-inline-block"
|
||||
icon={faEdit}
|
||||
/>
|
||||
{isExpired ? "Recreate" : "Edit"}
|
||||
</button>
|
||||
{!isExpired && (
|
||||
<DeleteSilence
|
||||
alertStore={alertStore}
|
||||
silenceFormStore={silenceFormStore}
|
||||
cluster={cluster}
|
||||
silence={silence}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
SilenceDetails.propTypes = {
|
||||
silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired,
|
||||
cluster: PropTypes.string.isRequired,
|
||||
silence: APISilence.isRequired,
|
||||
onEditSilence: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export { SilenceDetails };
|
||||
87
ui/src/Components/ManagedSilence/SilenceDetails.test.js
Normal file
87
ui/src/Components/ManagedSilence/SilenceDetails.test.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from "react";
|
||||
|
||||
import { mount } from "enzyme";
|
||||
|
||||
import toDiffableHtml from "diffable-html";
|
||||
|
||||
import moment from "moment";
|
||||
import { advanceTo, clear } from "jest-date-mock";
|
||||
|
||||
import { MockSilence } from "__mocks__/Alerts";
|
||||
import { AlertStore } from "Stores/AlertStore";
|
||||
import { SilenceFormStore } from "Stores/SilenceFormStore";
|
||||
import { SilenceDetails } from "./SilenceDetails";
|
||||
|
||||
let alertStore;
|
||||
let silenceFormStore;
|
||||
let cluster;
|
||||
let silence;
|
||||
|
||||
const MockEditSilence = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
alertStore = new AlertStore([]);
|
||||
silenceFormStore = new SilenceFormStore();
|
||||
cluster = "am";
|
||||
silence = MockSilence();
|
||||
|
||||
alertStore.data.upstreams = {
|
||||
instances: [
|
||||
{
|
||||
name: "am1",
|
||||
cluster: "am",
|
||||
uri: "http://localhost:9093",
|
||||
publicURI: "http://example.com",
|
||||
error: "",
|
||||
version: "0.15.3",
|
||||
headers: {}
|
||||
}
|
||||
],
|
||||
clusters: { am: ["am1"] }
|
||||
};
|
||||
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
fetch.resetMocks();
|
||||
// reset Date() to current time
|
||||
clear();
|
||||
});
|
||||
|
||||
const MountedSilenceDetails = () => {
|
||||
return mount(
|
||||
<SilenceDetails
|
||||
alertStore={alertStore}
|
||||
silenceFormStore={silenceFormStore}
|
||||
silence={silence}
|
||||
cluster={cluster}
|
||||
onEditSilence={MockEditSilence}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
describe("<SilenceDetails />", () => {
|
||||
it("unexpired silence endsAt label doesn't use 'danger' class", () => {
|
||||
advanceTo(moment.utc([2000, 0, 1, 0, 30, 0]));
|
||||
const tree = MountedSilenceDetails();
|
||||
const endsAt = tree.find("span.badge").at(1);
|
||||
expect(toDiffableHtml(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();
|
||||
const endsAt = tree.find("span.badge").at(1);
|
||||
expect(toDiffableHtml(endsAt.html())).toMatch(/text-danger/);
|
||||
});
|
||||
|
||||
it("id links to Alertmanager silence view via alertmanager.publicURI", () => {
|
||||
const tree = MountedSilenceDetails();
|
||||
const link = tree.find("a");
|
||||
expect(link.props().href).toBe(
|
||||
"http://example.com/#/silences/04d37636-2350-4878-b382-e0b50353230f"
|
||||
);
|
||||
});
|
||||
});
|
||||
95
ui/src/Components/ManagedSilence/SilenceProgress.js
Normal file
95
ui/src/Components/ManagedSilence/SilenceProgress.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import React, { Component } from "react";
|
||||
|
||||
import { observable, action } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
import moment from "moment";
|
||||
import Moment from "react-moment";
|
||||
|
||||
import { APISilence } from "Models/API";
|
||||
|
||||
import "./SilenceProgress.scss";
|
||||
|
||||
const SilenceProgress = observer(
|
||||
class SilenceProgress extends Component {
|
||||
static propTypes = {
|
||||
silence: APISilence.isRequired
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.progressTimer);
|
||||
this.progressTimer = null;
|
||||
}
|
||||
|
||||
recalculateProgress = () => {
|
||||
const { silence } = this.props;
|
||||
this.progress.calculate(silence.startsAt, silence.endsAt);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { silence } = this.props;
|
||||
|
||||
// if silence is expired we can skip progress value calculation
|
||||
if (moment(silence.endsAt) < moment()) {
|
||||
return (
|
||||
<span className="badge badge-danger align-text-bottom p-1">
|
||||
Expired <Moment fromNow>{silence.endsAt}</Moment>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
let progressClass;
|
||||
if (this.progress.value > 90) {
|
||||
progressClass = "progress-bar bg-danger";
|
||||
} else if (this.progress.value > 75) {
|
||||
progressClass = "progress-bar bg-warning";
|
||||
} else {
|
||||
progressClass = "progress-bar bg-success";
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="badge badge-light nmb-05 align-text-bottom p-1">
|
||||
Expires <Moment fromNow>{silence.endsAt}</Moment>
|
||||
<div className="progress silence-progress bg-white">
|
||||
<div
|
||||
className={progressClass}
|
||||
role="progressbar"
|
||||
style={{ width: this.progress.value + "%" }}
|
||||
aria-valuenow={this.progress.value}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { SilenceProgress };
|
||||
7
ui/src/Components/ManagedSilence/SilenceProgress.scss
Normal file
7
ui/src/Components/ManagedSilence/SilenceProgress.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
.silence-progress.progress {
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.silence-progress.progress > .progress-bar {
|
||||
height: 2px;
|
||||
}
|
||||
80
ui/src/Components/ManagedSilence/SilenceProgress.test.js
Normal file
80
ui/src/Components/ManagedSilence/SilenceProgress.test.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from "react";
|
||||
|
||||
import { mount } from "enzyme";
|
||||
|
||||
import { toJS } from "mobx";
|
||||
|
||||
import toDiffableHtml from "diffable-html";
|
||||
|
||||
import moment from "moment";
|
||||
import { advanceTo, clear } from "jest-date-mock";
|
||||
|
||||
import { MockSilence } from "__mocks__/Alerts";
|
||||
import { SilenceProgress } from "./SilenceProgress";
|
||||
|
||||
let silence;
|
||||
|
||||
beforeEach(() => {
|
||||
silence = MockSilence();
|
||||
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
fetch.resetMocks();
|
||||
// reset Date() to current time
|
||||
clear();
|
||||
});
|
||||
|
||||
const MountedSilenceProgress = () => {
|
||||
return mount(<SilenceProgress silence={silence} />);
|
||||
};
|
||||
|
||||
describe("<SilenceProgress />", () => {
|
||||
it("renders with class 'danger' and no progressbar when expired", () => {
|
||||
advanceTo(moment.utc([2001, 0, 1, 23, 0, 0]));
|
||||
const tree = MountedSilenceProgress();
|
||||
expect(toDiffableHtml(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, 0, 55, 0]));
|
||||
const tree = MountedSilenceProgress();
|
||||
expect(toDiffableHtml(tree.html())).toMatch(/progress-bar bg-danger/);
|
||||
});
|
||||
|
||||
it("progressbar uses class 'danger' when > 75%", () => {
|
||||
advanceTo(moment.utc([2000, 0, 1, 0, 50, 0]));
|
||||
const tree = MountedSilenceProgress();
|
||||
expect(toDiffableHtml(tree.html())).toMatch(/progress-bar bg-warning/);
|
||||
});
|
||||
|
||||
it("progressbar uses class 'success' when <= 75%", () => {
|
||||
advanceTo(moment.utc([2000, 0, 1, 0, 30, 0]));
|
||||
const tree = MountedSilenceProgress();
|
||||
expect(toDiffableHtml(tree.html())).toMatch(/progress-bar bg-success/);
|
||||
});
|
||||
|
||||
it("calling calculate() on progress multiple times in a row doesn't change the value", () => {
|
||||
const startsAt = moment.utc([2000, 0, 1, 0, 0, 0]);
|
||||
const endsAt = moment.utc([2000, 0, 1, 1, 0, 0]);
|
||||
|
||||
const tree = MountedSilenceProgress();
|
||||
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);
|
||||
});
|
||||
|
||||
it("resets the timer on unmount", () => {
|
||||
const tree = MountedSilenceProgress();
|
||||
expect(tree.instance().progressTimer).not.toBeNull();
|
||||
tree.instance().componentWillUnmount();
|
||||
expect(tree.instance().progressTimer).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<SilenceComment /> Matches snapshot when collapsed 1`] = `
|
||||
"
|
||||
<span class=\\"font-italic\\"
|
||||
width=\\"0\\"
|
||||
>
|
||||
<span>
|
||||
</span>
|
||||
<span>
|
||||
Mocked Silence
|
||||
</span>
|
||||
<span style=\\"position: fixed; visibility: hidden; top: 0px; left: 0px;\\">
|
||||
…
|
||||
</span>
|
||||
</span>
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SilenceComment /> Matches snapshot when expanded 1`] = `
|
||||
"
|
||||
<span class=\\"font-italic\\"
|
||||
width=\\"0\\"
|
||||
>
|
||||
<span>
|
||||
</span>
|
||||
<span>
|
||||
Mocked Silence
|
||||
</span>
|
||||
<span style=\\"position: fixed; visibility: hidden; top: 0px; left: 0px;\\">
|
||||
…
|
||||
</span>
|
||||
</span>
|
||||
"
|
||||
`;
|
||||
@@ -0,0 +1,272 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<ManagedSilence /> matches snapshot when collapsed 1`] = `
|
||||
"
|
||||
<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\\"
|
||||
>
|
||||
<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-light nmb-05 align-text-bottom p-1\\">
|
||||
Expires
|
||||
<time datetime=\\"946688400000\\">
|
||||
in 30 minutes
|
||||
</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 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>
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ManagedSilence /> matches snapshot with expaned details 1`] = `
|
||||
"
|
||||
<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\\"
|
||||
>
|
||||
<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>
|
||||
</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-down\\"
|
||||
class=\\"svg-inline--fa fa-chevron-down 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=\\"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>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\\"card-body pt-0\\">
|
||||
<div class=\\"mt-1\\">
|
||||
<div class=\\"d-flex flex-row justify-content-between\\">
|
||||
<div class=\\"flex-shrink-1 flex-grow-1\\">
|
||||
<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 fa-fw 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=\\"946684800000\\">
|
||||
30 minutes 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=\\"946688400000\\">
|
||||
in 30 minutes
|
||||
</time>
|
||||
</span>
|
||||
</div>
|
||||
<div class=\\"my-1\\">
|
||||
<span class=\\"badge px-1 mr-1 components-label\\">
|
||||
<svg aria-hidden=\\"true\\"
|
||||
focusable=\\"false\\"
|
||||
data-prefix=\\"fas\\"
|
||||
data-icon=\\"home\\"
|
||||
class=\\"svg-inline--fa fa-home fa-w-18 fa-fw text-muted mr-1\\"
|
||||
role=\\"img\\"
|
||||
xmlns=\\"http://www.w3.org/2000/svg\\"
|
||||
viewbox=\\"0 0 576 512\\"
|
||||
>
|
||||
<path fill=\\"currentColor\\"
|
||||
d=\\"M280.37 148.26L96 300.11V464a16 16 0 0 0 16 16l112.06-.29a16 16 0 0 0 15.92-16V368a16 16 0 0 1 16-16h64a16 16 0 0 1 16 16v95.64a16 16 0 0 0 16 16.05L464 480a16 16 0 0 0 16-16V300L295.67 148.26a12.19 12.19 0 0 0-15.3 0zM571.6 251.47L488 182.56V44.05a12 12 0 0 0-12-12h-56a12 12 0 0 0-12 12v72.61L318.47 43a48 48 0 0 0-61 0L4.34 251.47a12 12 0 0 0-1.6 16.9l25.5 31A12 12 0 0 0 45.15 301l235.22-193.74a12.19 12.19 0 0 1 15.3 0L530.9 301a12 12 0 0 0 16.9-1.6l25.5-31a12 12 0 0 0-1.7-16.93z\\"
|
||||
>
|
||||
</path>
|
||||
</svg>
|
||||
View in Alertmanager:
|
||||
</span>
|
||||
<a href=\\"http://example.com/#/silences/04d37636-2350-4878-b382-e0b50353230f\\"
|
||||
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>
|
||||
am1
|
||||
</a>
|
||||
</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 fa-fw 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\\">
|
||||
foo=bar
|
||||
</span>
|
||||
<span class=\\"badge badge-light px-1 mr-1 components-label\\">
|
||||
baz=~regex
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\\"flex-shrink-0 flex-grow-0\\">
|
||||
<div class=\\"d-flex flex-column\\">
|
||||
<button class=\\"btn btn-outline-secondary btn-sm mb-2\\">
|
||||
<svg aria-hidden=\\"true\\"
|
||||
focusable=\\"false\\"
|
||||
data-prefix=\\"fas\\"
|
||||
data-icon=\\"edit\\"
|
||||
class=\\"svg-inline--fa fa-edit fa-w-18 mr-1 d-none d-sm-inline-block\\"
|
||||
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
|
||||
</button>
|
||||
<button class=\\"btn btn-outline-danger btn-sm\\">
|
||||
<svg aria-hidden=\\"true\\"
|
||||
focusable=\\"false\\"
|
||||
data-prefix=\\"fas\\"
|
||||
data-icon=\\"trash\\"
|
||||
class=\\"svg-inline--fa fa-trash fa-w-14 mr-1 d-none d-sm-inline-block\\"
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"
|
||||
`;
|
||||
116
ui/src/Components/ManagedSilence/index.js
Normal file
116
ui/src/Components/ManagedSilence/index.js
Normal file
@@ -0,0 +1,116 @@
|
||||
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 { faChevronUp } from "@fortawesome/free-solid-svg-icons/faChevronUp";
|
||||
import { faChevronDown } from "@fortawesome/free-solid-svg-icons/faChevronDown";
|
||||
|
||||
import { APISilence } from "Models/API";
|
||||
import { AlertStore } from "Stores/AlertStore";
|
||||
import { SilenceFormStore, SilenceTabNames } from "Stores/SilenceFormStore";
|
||||
import { MountFade } from "Components/Animations/MountFade";
|
||||
import { SilenceComment } from "./SilenceComment";
|
||||
import { SilenceDetails } from "./SilenceDetails";
|
||||
import { SilenceProgress } from "./SilenceProgress";
|
||||
|
||||
import "./index.scss";
|
||||
|
||||
const ManagedSilence = observer(
|
||||
class ManagedSilence extends Component {
|
||||
static propTypes = {
|
||||
cluster: PropTypes.string.isRequired,
|
||||
silence: APISilence.isRequired,
|
||||
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
|
||||
silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired,
|
||||
onDidUpdate: PropTypes.func
|
||||
};
|
||||
|
||||
// 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 }
|
||||
);
|
||||
|
||||
getAlertmanager = () =>
|
||||
this.props.alertStore.data.upstreams.instances
|
||||
.filter(u => u.cluster === this.props.cluster)
|
||||
.slice(0, 1)[0];
|
||||
|
||||
onEditSilence = () => {
|
||||
const { silenceFormStore, silence } = this.props;
|
||||
|
||||
const alertmanager = this.getAlertmanager();
|
||||
|
||||
silenceFormStore.data.fillFormFromSilence(alertmanager, silence);
|
||||
silenceFormStore.data.resetProgress();
|
||||
silenceFormStore.tab.setTab(SilenceTabNames.Editor);
|
||||
silenceFormStore.toggle.show();
|
||||
};
|
||||
|
||||
componentDidUpdate() {
|
||||
const { onDidUpdate } = this.props;
|
||||
if (onDidUpdate) onDidUpdate();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { cluster, silence, alertStore, silenceFormStore } = this.props;
|
||||
|
||||
return (
|
||||
<MountFade in={true}>
|
||||
<div className="card mb-1 components-managed-silence">
|
||||
<div className="card-header border-bottom-0">
|
||||
<div className="d-flex flex-row justify-content-between">
|
||||
<div className="flex-grow-1 flex-shrink-1 mr-2">
|
||||
<span className="my-1">
|
||||
<SilenceComment
|
||||
silence={silence}
|
||||
collapsed={this.collapse.value}
|
||||
/>
|
||||
<span className="blockquote-footer pt-1">
|
||||
<cite className="components-grid-alertgroup-silences mr-2">
|
||||
{silence.createdBy}
|
||||
</cite>
|
||||
{this.collapse.value ? (
|
||||
<SilenceProgress silence={silence} />
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-grow-0 flex-shrink-0 mt-auto mb-0">
|
||||
<FontAwesomeIcon
|
||||
icon={this.collapse.value ? faChevronUp : faChevronDown}
|
||||
className="text-muted cursor-pointer"
|
||||
onClick={this.collapse.toggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{this.collapse.value ? null : (
|
||||
<div className="card-body pt-0">
|
||||
<SilenceDetails
|
||||
cluster={cluster}
|
||||
silence={silence}
|
||||
alertStore={alertStore}
|
||||
silenceFormStore={silenceFormStore}
|
||||
onEditSilence={this.onEditSilence}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</MountFade>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { ManagedSilence };
|
||||
14
ui/src/Components/ManagedSilence/index.scss
Normal file
14
ui/src/Components/ManagedSilence/index.scss
Normal file
@@ -0,0 +1,14 @@
|
||||
@import "src/App.scss";
|
||||
|
||||
.components-managed-silence {
|
||||
.card,
|
||||
.card-header,
|
||||
.card-body {
|
||||
background-color: $gray-100;
|
||||
}
|
||||
|
||||
&.card {
|
||||
border-left-width: 4px;
|
||||
border-left-color: $gray-700;
|
||||
}
|
||||
}
|
||||
153
ui/src/Components/ManagedSilence/index.test.js
Normal file
153
ui/src/Components/ManagedSilence/index.test.js
Normal file
@@ -0,0 +1,153 @@
|
||||
import React from "react";
|
||||
|
||||
import { mount } from "enzyme";
|
||||
|
||||
import toDiffableHtml from "diffable-html";
|
||||
|
||||
import moment from "moment";
|
||||
import { advanceTo, clear } from "jest-date-mock";
|
||||
|
||||
import { MockSilence } from "__mocks__/Alerts";
|
||||
import { AlertStore } from "Stores/AlertStore";
|
||||
import { SilenceFormStore } from "Stores/SilenceFormStore";
|
||||
import { ManagedSilence } from ".";
|
||||
|
||||
let alertStore;
|
||||
let silenceFormStore;
|
||||
let cluster;
|
||||
let silence;
|
||||
|
||||
beforeEach(() => {
|
||||
advanceTo(moment.utc([2000, 0, 1, 0, 30, 0]));
|
||||
|
||||
alertStore = new AlertStore([]);
|
||||
silenceFormStore = new SilenceFormStore();
|
||||
cluster = "am";
|
||||
silence = MockSilence();
|
||||
|
||||
alertStore.data.upstreams = {
|
||||
instances: [
|
||||
{
|
||||
name: "am1",
|
||||
cluster: "am",
|
||||
clusterMembers: ["am1"],
|
||||
uri: "http://localhost:9093",
|
||||
publicURI: "http://example.com",
|
||||
error: "",
|
||||
version: "0.15.3",
|
||||
headers: {}
|
||||
}
|
||||
],
|
||||
clusters: { am: ["am1"] }
|
||||
};
|
||||
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
fetch.resetMocks();
|
||||
clear();
|
||||
});
|
||||
|
||||
const MountedManagedSilence = onDidUpdate => {
|
||||
return mount(
|
||||
<ManagedSilence
|
||||
cluster={cluster}
|
||||
silence={silence}
|
||||
alertStore={alertStore}
|
||||
silenceFormStore={silenceFormStore}
|
||||
onDidUpdate={onDidUpdate}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
describe("<ManagedSilence />", () => {
|
||||
it("matches snapshot when collapsed", () => {
|
||||
const tree = MountedManagedSilence();
|
||||
expect(toDiffableHtml(tree.html())).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("clicking on expand toggle shows silence details", () => {
|
||||
const tree = MountedManagedSilence();
|
||||
const toggle = tree.find("svg.text-muted.cursor-pointer");
|
||||
toggle.simulate("click");
|
||||
const details = tree.find("SilenceDetails");
|
||||
expect(details).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("matches snapshot with expaned details", () => {
|
||||
const tree = MountedManagedSilence();
|
||||
tree.instance().collapse.toggle();
|
||||
tree.update();
|
||||
expect(toDiffableHtml(tree.html())).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("getAlertmanager() returns alertmanager object from alertStore.data.upstreams.instances", () => {
|
||||
const tree = MountedManagedSilence();
|
||||
const instance = tree.instance();
|
||||
const am = instance.getAlertmanager();
|
||||
expect(am).toEqual({
|
||||
name: "am1",
|
||||
cluster: "am",
|
||||
clusterMembers: ["am1"],
|
||||
uri: "http://localhost:9093",
|
||||
publicURI: "http://example.com",
|
||||
error: "",
|
||||
version: "0.15.3",
|
||||
headers: {}
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Edit button on unexpired silence", () => {
|
||||
const tree = MountedManagedSilence();
|
||||
tree.instance().collapse.toggle();
|
||||
tree.update();
|
||||
|
||||
const button = tree.find(".btn-outline-secondary");
|
||||
expect(button.text()).toBe("Edit");
|
||||
});
|
||||
|
||||
it("shows Delete button on unexpired silence", () => {
|
||||
const tree = MountedManagedSilence();
|
||||
tree.instance().collapse.toggle();
|
||||
tree.update();
|
||||
|
||||
const button = tree.find(".btn-outline-danger");
|
||||
expect(button.text()).toBe("Delete");
|
||||
});
|
||||
|
||||
it("shows Recreate button on expired silence", () => {
|
||||
advanceTo(moment.utc([2000, 0, 1, 23, 30, 0]));
|
||||
const tree = MountedManagedSilence();
|
||||
tree.instance().collapse.toggle();
|
||||
tree.update();
|
||||
|
||||
const button = tree.find(".btn-outline-secondary");
|
||||
expect(button.text()).toBe("Recreate");
|
||||
});
|
||||
|
||||
it("clicking on Edit calls ", () => {
|
||||
const tree = MountedManagedSilence();
|
||||
tree.instance().collapse.toggle();
|
||||
tree.update();
|
||||
|
||||
expect(silenceFormStore.data.silenceID).toBeNull();
|
||||
|
||||
const button = tree.find(".btn-outline-secondary");
|
||||
expect(button.text()).toBe("Edit");
|
||||
|
||||
const fillSpy = jest.spyOn(silenceFormStore.data, "fillFormFromSilence");
|
||||
button.simulate("click");
|
||||
expect(silenceFormStore.data.silenceID).toBe(silence.id);
|
||||
expect(fillSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("call onDidUpdate if passed", () => {
|
||||
const fakeUpdate = jest.fn();
|
||||
const tree = MountedManagedSilence(fakeUpdate);
|
||||
tree.instance().collapse.toggle();
|
||||
tree.update();
|
||||
expect(fakeUpdate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
20
ui/src/Components/Modal/Tab.js
Normal file
20
ui/src/Components/Modal/Tab.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const Tab = ({ title, active, onClick }) => (
|
||||
<span
|
||||
className={`nav-item nav-link cursor-pointer mx-1 ${
|
||||
active ? "active" : "components-tab-inactive"
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
);
|
||||
Tab.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
active: PropTypes.bool,
|
||||
onClick: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export { Tab };
|
||||
@@ -14,16 +14,20 @@ import {
|
||||
MountModalBackdrop
|
||||
} from "Components/Animations/MountModal";
|
||||
|
||||
import "./index.scss";
|
||||
|
||||
const Modal = observer(
|
||||
class Modal extends Component {
|
||||
static propTypes = {
|
||||
size: PropTypes.oneOf(["lg", "xl"]),
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
isUpper: PropTypes.bool,
|
||||
toggleOpen: PropTypes.func.isRequired,
|
||||
children: PropTypes.node.isRequired
|
||||
};
|
||||
static defaultProps = {
|
||||
size: "lg"
|
||||
size: "lg",
|
||||
isUpper: false
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
@@ -65,7 +69,14 @@ const Modal = observer(
|
||||
}
|
||||
|
||||
render() {
|
||||
const { size, isOpen, toggleOpen, children, ...props } = this.props;
|
||||
const {
|
||||
size,
|
||||
isOpen,
|
||||
isUpper,
|
||||
toggleOpen,
|
||||
children,
|
||||
...props
|
||||
} = this.props;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<React.Fragment>
|
||||
@@ -76,7 +87,11 @@ const Modal = observer(
|
||||
handlers={{ CLOSE: toggleOpen }}
|
||||
>
|
||||
<div ref={this.modalRef} className="modal d-block" role="dialog">
|
||||
<div className={`modal-dialog modal-${size}`} role="document">
|
||||
<div
|
||||
className={`modal-dialog modal-${size} ${isUpper &&
|
||||
"modal-upper shadow"}`}
|
||||
role="document"
|
||||
>
|
||||
<div className="modal-content">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
30
ui/src/Components/Modal/index.scss
Normal file
30
ui/src/Components/Modal/index.scss
Normal file
@@ -0,0 +1,30 @@
|
||||
@import "src/App.scss";
|
||||
|
||||
.components-tab-inactive {
|
||||
&:hover {
|
||||
color: $white;
|
||||
background-color: $secondary;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-upper {
|
||||
&.modal-dialog {
|
||||
padding-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content-blur {
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(lg) {
|
||||
.modal-lg.modal-upper {
|
||||
max-width: $modal-lg + 10;
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(xl) {
|
||||
.modal-xl.modal-upper {
|
||||
max-width: $modal-xl + 10;
|
||||
}
|
||||
}
|
||||
210
ui/src/Components/SilenceModal/Browser/index.js
Normal file
210
ui/src/Components/SilenceModal/Browser/index.js
Normal file
@@ -0,0 +1,210 @@
|
||||
import React, { Component } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import { observable, action } from "mobx";
|
||||
import { observer, Provider } from "mobx-react";
|
||||
|
||||
import { debounce } from "lodash";
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faSpinner } from "@fortawesome/free-solid-svg-icons/faSpinner";
|
||||
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclamationCircle";
|
||||
|
||||
import { faSortAmountDownAlt } from "@fortawesome/free-solid-svg-icons/faSortAmountDownAlt";
|
||||
import { faSortAmountUp } from "@fortawesome/free-solid-svg-icons/faSortAmountUp";
|
||||
|
||||
import { AlertStore, FormatBackendURI } from "Stores/AlertStore";
|
||||
import { SilenceFormStore } from "Stores/SilenceFormStore";
|
||||
import { FetchWithCredentials } from "Common/Fetch";
|
||||
import { MountFade } from "Components/Animations/MountFade";
|
||||
import { ManagedSilence } from "Components/ManagedSilence";
|
||||
|
||||
const FetchError = ({ message }) => (
|
||||
<div className="text-center">
|
||||
<h2 className="display-2 text-danger">
|
||||
<FontAwesomeIcon icon={faExclamationCircle} />
|
||||
</h2>
|
||||
<p className="lead text-muted">{message}</p>
|
||||
</div>
|
||||
);
|
||||
FetchError.propTypes = {
|
||||
message: PropTypes.node.isRequired
|
||||
};
|
||||
|
||||
const Placeholder = ({ content }) => (
|
||||
<MountFade in={true}>
|
||||
<div className="jumbotron bg-white">
|
||||
<h1 className="display-5 text-secondary text-center">{content}</h1>
|
||||
</div>
|
||||
</MountFade>
|
||||
);
|
||||
Placeholder.propTypes = {
|
||||
content: PropTypes.node.isRequired
|
||||
};
|
||||
|
||||
const Browser = observer(
|
||||
class Browser extends Component {
|
||||
static propTypes = {
|
||||
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
|
||||
silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired
|
||||
};
|
||||
|
||||
fetchTimer = null;
|
||||
|
||||
dataSource = observable(
|
||||
{
|
||||
silences: [],
|
||||
sortReverse: false,
|
||||
showExpired: false,
|
||||
searchTerm: "",
|
||||
error: null,
|
||||
fetch: null,
|
||||
done: false,
|
||||
setDone() {
|
||||
this.done = true;
|
||||
},
|
||||
setError(value) {
|
||||
this.error = value;
|
||||
},
|
||||
toggleSortReverse() {
|
||||
this.sortReverse = !this.sortReverse;
|
||||
},
|
||||
toggleShowExpired() {
|
||||
this.showExpired = !this.showExpired;
|
||||
},
|
||||
setSearchTerm(value) {
|
||||
this.searchTerm = value;
|
||||
}
|
||||
},
|
||||
{
|
||||
setDone: action.bound,
|
||||
setError: action.bound,
|
||||
toggleSortReverse: action.bound,
|
||||
toggleShowExpired: action.bound,
|
||||
setSearchTerm: action.bound
|
||||
}
|
||||
);
|
||||
|
||||
onFetch = debounce(() => {
|
||||
const uri = FormatBackendURI(
|
||||
`silences.json?sortReverse=${
|
||||
this.dataSource.sortReverse ? "1" : "0"
|
||||
}&showExpired=${this.dataSource.showExpired ? "1" : "0"}&searchTerm=${
|
||||
this.dataSource.searchTerm
|
||||
}`
|
||||
);
|
||||
|
||||
this.dataSource.fetch = FetchWithCredentials(uri, {})
|
||||
.then(result => {
|
||||
return result.json();
|
||||
})
|
||||
.then(result => {
|
||||
this.dataSource.silences = result;
|
||||
this.dataSource.setDone();
|
||||
this.dataSource.setError(null);
|
||||
})
|
||||
.catch(err => {
|
||||
console.trace(err);
|
||||
this.dataSource.setDone();
|
||||
return this.dataSource.setError(
|
||||
`Request failed with: ${err.message}`
|
||||
);
|
||||
});
|
||||
}, 500);
|
||||
|
||||
componentDidMount() {
|
||||
this.onFetch();
|
||||
// FIXME use settings refresh interval
|
||||
this.fetchTimer = setInterval(this.onFetch, 10 * 1000);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.fetchTimer);
|
||||
this.fetchTimer = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { alertStore, silenceFormStore } = this.props;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="d-flex justify-content-between mb-3">
|
||||
<span className="custom-control custom-switch my-auto flex-grow-0 flex-shrink-0">
|
||||
<input
|
||||
id="silence-show-expired"
|
||||
className="custom-control-input"
|
||||
type="checkbox"
|
||||
value=""
|
||||
checked={this.dataSource.showExpired}
|
||||
onChange={() => {
|
||||
this.dataSource.toggleShowExpired();
|
||||
this.onFetch();
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
className="custom-control-label cursor-pointer"
|
||||
htmlFor="silence-show-expired"
|
||||
>
|
||||
Show expired
|
||||
</label>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control flex-grow-1 flex-shrink-1 mx-3"
|
||||
placeholder="Search query"
|
||||
value={this.dataSource.searchTerm}
|
||||
autoComplete="off"
|
||||
onChange={e => {
|
||||
this.dataSource.setSearchTerm(e.target.value);
|
||||
this.onFetch();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary flex-grow-0 flex-shrink-0"
|
||||
onClick={() => {
|
||||
this.dataSource.toggleSortReverse();
|
||||
this.onFetch();
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
className="mr-1"
|
||||
icon={
|
||||
this.dataSource.sortReverse
|
||||
? faSortAmountUp
|
||||
: faSortAmountDownAlt
|
||||
}
|
||||
/>
|
||||
Sort order
|
||||
</button>
|
||||
</div>
|
||||
{this.dataSource.error !== null ? (
|
||||
<FetchError message={this.dataSource.error} />
|
||||
) : this.dataSource.done ? (
|
||||
this.dataSource.silences.length === 0 ? (
|
||||
<Placeholder content="Nothing to show" />
|
||||
) : (
|
||||
<Provider alertStore={alertStore}>
|
||||
{this.dataSource.silences.map(silence => (
|
||||
<ManagedSilence
|
||||
key={`${silence.cluster}/${silence.silence.id}`}
|
||||
cluster={silence.cluster}
|
||||
silence={silence.silence}
|
||||
alertStore={alertStore}
|
||||
silenceFormStore={silenceFormStore}
|
||||
/>
|
||||
))}
|
||||
</Provider>
|
||||
)
|
||||
) : (
|
||||
<Placeholder
|
||||
content={<FontAwesomeIcon icon={faSpinner} size="lg" spin />}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { Browser };
|
||||
181
ui/src/Components/SilenceModal/Browser/index.test.js
Normal file
181
ui/src/Components/SilenceModal/Browser/index.test.js
Normal file
@@ -0,0 +1,181 @@
|
||||
import React from "react";
|
||||
|
||||
import { mount } from "enzyme";
|
||||
|
||||
import toDiffableHtml from "diffable-html";
|
||||
|
||||
import moment from "moment";
|
||||
import { advanceTo, clear } from "jest-date-mock";
|
||||
|
||||
import { MockSilence } from "__mocks__/Alerts";
|
||||
import { AlertStore } from "Stores/AlertStore";
|
||||
import { SilenceFormStore } from "Stores/SilenceFormStore";
|
||||
import { Browser } from ".";
|
||||
|
||||
let alertStore;
|
||||
let silenceFormStore;
|
||||
let cluster;
|
||||
let silence;
|
||||
|
||||
beforeEach(() => {
|
||||
advanceTo(moment.utc([2000, 0, 1, 0, 30, 0]));
|
||||
|
||||
alertStore = new AlertStore([]);
|
||||
silenceFormStore = new SilenceFormStore();
|
||||
cluster = "am";
|
||||
silence = MockSilence();
|
||||
|
||||
alertStore.data.upstreams = {
|
||||
instances: [
|
||||
{
|
||||
name: "am1",
|
||||
cluster: "am",
|
||||
clusterMembers: ["am1"],
|
||||
uri: "http://localhost:9093",
|
||||
publicURI: "http://example.com",
|
||||
error: "",
|
||||
version: "0.15.3",
|
||||
headers: {}
|
||||
}
|
||||
],
|
||||
clusters: { am: ["am1"] }
|
||||
};
|
||||
|
||||
fetch.resetMocks();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
fetch.resetMocks();
|
||||
clear();
|
||||
});
|
||||
|
||||
const MountedBrowser = () => {
|
||||
return mount(
|
||||
<Browser alertStore={alertStore} silenceFormStore={silenceFormStore} />
|
||||
);
|
||||
};
|
||||
|
||||
describe("<Browser />", () => {
|
||||
it("fetches /silences.json on mount", async () => {
|
||||
fetch.mockResponse(
|
||||
JSON.stringify([
|
||||
{
|
||||
cluster: cluster,
|
||||
silence: silence
|
||||
}
|
||||
])
|
||||
);
|
||||
const tree = MountedBrowser();
|
||||
await expect(tree.instance().dataSource.fetch).resolves.toBeUndefined();
|
||||
expect(fetch.mock.calls[0][0]).toBe(
|
||||
"./silences.json?sortReverse=0&showExpired=0&searchTerm="
|
||||
);
|
||||
});
|
||||
|
||||
it("enabling reverse sort passes sortReverse=1 to the API", async () => {
|
||||
fetch.mockResponse(
|
||||
JSON.stringify([
|
||||
{
|
||||
cluster: cluster,
|
||||
silence: silence
|
||||
}
|
||||
])
|
||||
);
|
||||
const tree = MountedBrowser();
|
||||
|
||||
const sortOrder = tree.find("button.btn-outline-secondary").at(0);
|
||||
expect(sortOrder.text()).toBe("Sort order");
|
||||
sortOrder.simulate("click");
|
||||
|
||||
await expect(tree.instance().dataSource.fetch).resolves.toBeUndefined();
|
||||
expect(fetch.mock.calls[1][0]).toBe(
|
||||
"./silences.json?sortReverse=1&showExpired=0&searchTerm="
|
||||
);
|
||||
});
|
||||
|
||||
it("enabling expired silences passes showExpired=1 to the API", async () => {
|
||||
fetch.mockResponse(
|
||||
JSON.stringify([
|
||||
{
|
||||
cluster: cluster,
|
||||
silence: silence
|
||||
}
|
||||
])
|
||||
);
|
||||
const tree = MountedBrowser();
|
||||
|
||||
const expiredCheckbox = tree.find("input[type='checkbox']");
|
||||
expiredCheckbox.simulate("change", { target: { checked: true } });
|
||||
|
||||
await expect(tree.instance().dataSource.fetch).resolves.toBeUndefined();
|
||||
expect(fetch.mock.calls[1][0]).toBe(
|
||||
"./silences.json?sortReverse=0&showExpired=1&searchTerm="
|
||||
);
|
||||
});
|
||||
|
||||
it("entering a search phrase passes searchTerm=foo to the API", async () => {
|
||||
fetch.mockResponse(JSON.stringify([]));
|
||||
const tree = MountedBrowser();
|
||||
|
||||
const input = tree.find("input[type='text']").at(0);
|
||||
input.simulate("change", { target: { value: "foo" } });
|
||||
|
||||
await expect(tree.instance().dataSource.fetch).resolves.toBeUndefined();
|
||||
expect(fetch.mock.calls[1][0]).toBe(
|
||||
"./silences.json?sortReverse=0&showExpired=0&searchTerm=foo"
|
||||
);
|
||||
});
|
||||
|
||||
it("renders loading placeholder before fetch finishes", async () => {
|
||||
fetch.mockResponse(JSON.stringify([]));
|
||||
const tree = MountedBrowser();
|
||||
expect(tree.find("Placeholder")).toHaveLength(1);
|
||||
expect(toDiffableHtml(tree.html())).toMatch(/fa-spinner/);
|
||||
await expect(tree.instance().dataSource.fetch).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("renders empty placeholder after fetch with zero results", async () => {
|
||||
fetch.mockResponse(JSON.stringify([]));
|
||||
const tree = MountedBrowser();
|
||||
await expect(tree.instance().dataSource.fetch).resolves.toBeUndefined();
|
||||
expect(tree.find("Placeholder")).toHaveLength(1);
|
||||
expect(toDiffableHtml(tree.html())).toMatch(/Nothing to show/);
|
||||
});
|
||||
|
||||
it("renders silences after successful fetch", async () => {
|
||||
fetch.mockResponse(
|
||||
JSON.stringify([
|
||||
{
|
||||
cluster: cluster,
|
||||
silence: silence
|
||||
}
|
||||
])
|
||||
);
|
||||
const tree = MountedBrowser();
|
||||
await expect(tree.instance().dataSource.fetch).resolves.toBeUndefined();
|
||||
tree.update();
|
||||
expect(tree.find("ManagedSilence")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders error after failed fetch", async () => {
|
||||
jest.spyOn(console, "trace").mockImplementation(() => {});
|
||||
fetch.mockReject("fake failure");
|
||||
const tree = MountedBrowser();
|
||||
await expect(tree.instance().dataSource.fetch).resolves.toBeUndefined();
|
||||
tree.update();
|
||||
expect(tree.find("FetchError")).toHaveLength(1);
|
||||
expect(toDiffableHtml(tree.html())).toMatch(/exclamation-circle/);
|
||||
});
|
||||
|
||||
it("resets the timer on unmount", async () => {
|
||||
fetch.mockResponse(JSON.stringify([]));
|
||||
const tree = MountedBrowser();
|
||||
await expect(tree.instance().dataSource.fetch).resolves.toBeUndefined();
|
||||
|
||||
expect(tree.instance().fetchTimer).not.toBeNull();
|
||||
tree.instance().componentWillUnmount();
|
||||
expect(tree.instance().fetchTimer).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -4,11 +4,17 @@ import PropTypes from "prop-types";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
import { AlertStore } from "Stores/AlertStore";
|
||||
import { SilenceFormStore, SilenceFormStage } from "Stores/SilenceFormStore";
|
||||
import {
|
||||
SilenceFormStore,
|
||||
SilenceFormStage,
|
||||
SilenceTabNames
|
||||
} from "Stores/SilenceFormStore";
|
||||
import { Settings } from "Stores/Settings";
|
||||
import { Tab } from "Components/Modal/Tab";
|
||||
import { SilenceForm } from "./SilenceForm";
|
||||
import { SilencePreview } from "./SilencePreview";
|
||||
import { SilenceSubmitController } from "./SilenceSubmit/SilenceSubmitController";
|
||||
import { Browser } from "./Browser";
|
||||
|
||||
import "./index.css";
|
||||
|
||||
@@ -36,43 +42,71 @@ const SilenceModalContent = observer(
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">
|
||||
{silenceFormStore.data.silenceID === null
|
||||
? silenceFormStore.data.currentStage ===
|
||||
<div className="modal-header py-2">
|
||||
<nav className="nav nav-pills nav-justified w-100">
|
||||
<Tab
|
||||
title={
|
||||
silenceFormStore.data.currentStage ===
|
||||
SilenceFormStage.UserInput
|
||||
? "Add new silence"
|
||||
: silenceFormStore.data.currentStage ===
|
||||
SilenceFormStage.Preview
|
||||
? "Preview silenced alerts"
|
||||
: "Silence submitted"
|
||||
: `Editing silence ${silenceFormStore.data.silenceID}`}
|
||||
</h5>
|
||||
<button type="button" className="close" onClick={onHide}>
|
||||
<span className="align-middle">×</span>
|
||||
</button>
|
||||
? silenceFormStore.data.silenceID === null
|
||||
? "New silence"
|
||||
: "Editing silence"
|
||||
: silenceFormStore.data.currentStage ===
|
||||
SilenceFormStage.Preview
|
||||
? "Preview silenced alerts"
|
||||
: "Silence submitted"
|
||||
}
|
||||
active={silenceFormStore.tab.current === SilenceTabNames.Editor}
|
||||
onClick={() =>
|
||||
silenceFormStore.tab.setTab(SilenceTabNames.Editor)
|
||||
}
|
||||
/>
|
||||
<Tab
|
||||
title="Browse"
|
||||
active={
|
||||
silenceFormStore.tab.current === SilenceTabNames.Browser
|
||||
}
|
||||
onClick={() =>
|
||||
silenceFormStore.tab.setTab(SilenceTabNames.Browser)
|
||||
}
|
||||
/>
|
||||
<button type="button" className="close" onClick={onHide}>
|
||||
<span>×</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{silenceFormStore.data.currentStage ===
|
||||
SilenceFormStage.UserInput ? (
|
||||
<SilenceForm
|
||||
alertStore={alertStore}
|
||||
silenceFormStore={silenceFormStore}
|
||||
settingsStore={settingsStore}
|
||||
previewOpen={previewOpen}
|
||||
/>
|
||||
) : silenceFormStore.data.currentStage ===
|
||||
SilenceFormStage.Preview ? (
|
||||
<SilencePreview
|
||||
<div
|
||||
className={`modal-body ${silenceFormStore.toggle.blurred &&
|
||||
"modal-content-blur"}`}
|
||||
>
|
||||
{silenceFormStore.tab.current === SilenceTabNames.Editor ? (
|
||||
silenceFormStore.data.currentStage ===
|
||||
SilenceFormStage.UserInput ? (
|
||||
<SilenceForm
|
||||
alertStore={alertStore}
|
||||
silenceFormStore={silenceFormStore}
|
||||
settingsStore={settingsStore}
|
||||
previewOpen={previewOpen}
|
||||
/>
|
||||
) : silenceFormStore.data.currentStage ===
|
||||
SilenceFormStage.Preview ? (
|
||||
<SilencePreview
|
||||
alertStore={alertStore}
|
||||
silenceFormStore={silenceFormStore}
|
||||
/>
|
||||
) : (
|
||||
<SilenceSubmitController
|
||||
alertStore={alertStore}
|
||||
silenceFormStore={silenceFormStore}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
{silenceFormStore.tab.current === SilenceTabNames.Browser ? (
|
||||
<Browser
|
||||
alertStore={alertStore}
|
||||
silenceFormStore={silenceFormStore}
|
||||
/>
|
||||
) : (
|
||||
<SilenceSubmitController
|
||||
alertStore={alertStore}
|
||||
silenceFormStore={silenceFormStore}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,11 @@ import { shallow } from "enzyme";
|
||||
|
||||
import { AlertStore } from "Stores/AlertStore";
|
||||
import { Settings } from "Stores/Settings";
|
||||
import { SilenceFormStore, SilenceFormStage } from "Stores/SilenceFormStore";
|
||||
import {
|
||||
SilenceFormStore,
|
||||
SilenceFormStage,
|
||||
SilenceTabNames
|
||||
} from "Stores/SilenceFormStore";
|
||||
import { SilenceModalContent } from "./SilenceModalContent";
|
||||
|
||||
let alertStore;
|
||||
@@ -15,6 +19,8 @@ beforeEach(() => {
|
||||
alertStore = new AlertStore([]);
|
||||
settingsStore = new Settings();
|
||||
silenceFormStore = new SilenceFormStore();
|
||||
|
||||
silenceFormStore.tab.current = SilenceTabNames.Editor;
|
||||
});
|
||||
|
||||
const MockOnHide = jest.fn();
|
||||
@@ -31,6 +37,66 @@ const ShallowSilenceModalContent = () => {
|
||||
};
|
||||
|
||||
describe("<SilenceModalContent />", () => {
|
||||
it("Clicking on the Browser tab changes content", () => {
|
||||
const tree = ShallowSilenceModalContent();
|
||||
const tabs = tree.find("Tab");
|
||||
tabs.at(1).simulate("click");
|
||||
const form = tree.find("Browser");
|
||||
expect(form).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("Clicking on the Editor tab changes content", () => {
|
||||
silenceFormStore.tab.current = SilenceTabNames.Browser;
|
||||
const tree = ShallowSilenceModalContent();
|
||||
const tabs = tree.find("Tab");
|
||||
tabs.at(0).simulate("click");
|
||||
const form = tree.find("SilenceForm");
|
||||
expect(form).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("Content is not blurred when silenceFormStore.toggle.blurred is false", () => {
|
||||
silenceFormStore.toggle.blurred = false;
|
||||
const tree = ShallowSilenceModalContent();
|
||||
expect(tree.find("div.modal-body.modal-content-blur")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("Content is blurred when silenceFormStore.toggle.blurred is true", () => {
|
||||
silenceFormStore.toggle.blurred = true;
|
||||
const tree = ShallowSilenceModalContent();
|
||||
expect(tree.find("div.modal-body.modal-content-blur")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SilenceModalContent /> Editor", () => {
|
||||
it("title is 'New silence' when creating new silence", () => {
|
||||
silenceFormStore.data.currentStage = SilenceFormStage.UserInput;
|
||||
silenceFormStore.data.silenceID = null;
|
||||
const tree = ShallowSilenceModalContent();
|
||||
const tab = tree.find("Tab").at(0);
|
||||
expect(tab.props().title).toBe("New silence");
|
||||
});
|
||||
it("title is 'Editing silence' when editing exiting silence", () => {
|
||||
silenceFormStore.data.currentStage = SilenceFormStage.UserInput;
|
||||
silenceFormStore.data.silenceID = "1234";
|
||||
const tree = ShallowSilenceModalContent();
|
||||
const tab = tree.find("Tab").at(0);
|
||||
expect(tab.props().title).toBe("Editing silence");
|
||||
});
|
||||
it("title is 'Preview silenced alerts' when previewing silenced alerts", () => {
|
||||
silenceFormStore.data.currentStage = SilenceFormStage.Preview;
|
||||
silenceFormStore.data.silenceID = "1234";
|
||||
const tree = ShallowSilenceModalContent();
|
||||
const tab = tree.find("Tab").at(0);
|
||||
expect(tab.props().title).toBe("Preview silenced alerts");
|
||||
});
|
||||
it("title is 'Silence submitted' after sending silence to Alertmanager", () => {
|
||||
silenceFormStore.data.currentStage = SilenceFormStage.Submit;
|
||||
silenceFormStore.data.silenceID = "1234";
|
||||
const tree = ShallowSilenceModalContent();
|
||||
const tab = tree.find("Tab").at(0);
|
||||
expect(tab.props().title).toBe("Silence submitted");
|
||||
});
|
||||
|
||||
it("renders SilenceForm when silenceFormStore.data.currentStage is 'UserInput'", () => {
|
||||
silenceFormStore.data.currentStage = SilenceFormStage.UserInput;
|
||||
const tree = ShallowSilenceModalContent();
|
||||
@@ -51,18 +117,13 @@ describe("<SilenceModalContent />", () => {
|
||||
const ctrl = tree.find("SilenceSubmitController");
|
||||
expect(ctrl).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("title is 'Add new silence' when silenceFormStore.data.silenceID is null", () => {
|
||||
silenceFormStore.data.silenceID = null;
|
||||
describe("<SilenceModalContent /> Browser", () => {
|
||||
it("renders silence browser when tab is set to Browser", () => {
|
||||
silenceFormStore.tab.current = SilenceTabNames.Browser;
|
||||
const tree = ShallowSilenceModalContent();
|
||||
const title = tree.find(".modal-title");
|
||||
expect(title.text()).toBe("Add new silence");
|
||||
});
|
||||
|
||||
it("title is 'Editing silence 12345' when silenceFormStore.data.silenceID is '12345'", () => {
|
||||
silenceFormStore.data.silenceID = "12345";
|
||||
const tree = ShallowSilenceModalContent();
|
||||
const title = tree.find(".modal-title");
|
||||
expect(title.text()).toBe("Editing silence 12345");
|
||||
const form = tree.find("Browser");
|
||||
expect(form).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ const SilenceModal = observer(
|
||||
silenceFormStore.toggle.visible ? "border-bottom border-info" : ""
|
||||
}`}
|
||||
>
|
||||
<TooltipWrapper title="Add new silence">
|
||||
<TooltipWrapper title="New silence">
|
||||
<span
|
||||
className="nav-link cursor-pointer"
|
||||
onClick={silenceFormStore.toggle.toggle}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
NewEmptyMatcher,
|
||||
MatcherValueToObject
|
||||
} from "Stores/SilenceFormStore";
|
||||
import { SilenceModalContent } from "./SilenceModalContent";
|
||||
import { SilenceModalContent, TabNames } from "./SilenceModalContent";
|
||||
|
||||
import "Percy.scss";
|
||||
|
||||
@@ -59,6 +59,22 @@ storiesOf("SilenceModal", module)
|
||||
settingsStore={settingsStore}
|
||||
onHide={() => {}}
|
||||
previewOpen={true}
|
||||
openTab={TabNames.Editor}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.add("Browser", () => {
|
||||
const alertStore = new AlertStore([]);
|
||||
const settingsStore = new Settings();
|
||||
const silenceFormStore = new SilenceFormStore();
|
||||
|
||||
return (
|
||||
<SilenceModalContent
|
||||
alertStore={alertStore}
|
||||
silenceFormStore={silenceFormStore}
|
||||
settingsStore={settingsStore}
|
||||
onHide={() => {}}
|
||||
openTab={TabNames.Browser}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -209,7 +209,6 @@ class AlertStore {
|
||||
},
|
||||
setFetching() {
|
||||
this.value = AlertStoreStatuses.Fetching;
|
||||
this.error = null;
|
||||
},
|
||||
setProcessing() {
|
||||
this.value = AlertStoreStatuses.Processing;
|
||||
|
||||
@@ -31,11 +31,17 @@ const SilenceFormStage = Object.freeze({
|
||||
Submit: "submit"
|
||||
});
|
||||
|
||||
const SilenceTabNames = Object.freeze({
|
||||
Editor: "editor",
|
||||
Browser: "browser"
|
||||
});
|
||||
|
||||
class SilenceFormStore {
|
||||
// this is used to store modal visibility toggle
|
||||
toggle = observable(
|
||||
{
|
||||
visible: false,
|
||||
blurred: false,
|
||||
toggle() {
|
||||
this.visible = !this.visible;
|
||||
},
|
||||
@@ -44,9 +50,29 @@ class SilenceFormStore {
|
||||
},
|
||||
show() {
|
||||
this.visible = true;
|
||||
},
|
||||
setBlur(val) {
|
||||
this.blurred = val;
|
||||
}
|
||||
},
|
||||
{ toggle: action.bound, hide: action.bound, show: action.bound }
|
||||
{
|
||||
toggle: action.bound,
|
||||
hide: action.bound,
|
||||
show: action.bound,
|
||||
setBlur: action.bound
|
||||
}
|
||||
);
|
||||
|
||||
tab = observable(
|
||||
{
|
||||
current: SilenceTabNames.Editor,
|
||||
setTab(value) {
|
||||
this.current = value;
|
||||
}
|
||||
},
|
||||
{
|
||||
setTab: action.bound
|
||||
}
|
||||
);
|
||||
|
||||
// form data is stored here, it's global (rather than attached to the form)
|
||||
@@ -279,5 +305,6 @@ export {
|
||||
SilenceFormStage,
|
||||
NewEmptyMatcher,
|
||||
MatcherValueToObject,
|
||||
AlertmanagerClustersToOption
|
||||
AlertmanagerClustersToOption,
|
||||
SilenceTabNames
|
||||
};
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
import {
|
||||
SilenceFormStore,
|
||||
SilenceFormStage,
|
||||
NewEmptyMatcher
|
||||
NewEmptyMatcher,
|
||||
SilenceTabNames
|
||||
} from "./SilenceFormStore";
|
||||
|
||||
let store;
|
||||
@@ -448,3 +449,15 @@ describe("SilenceFormStore.data startsAt & endsAt validation", () => {
|
||||
expect(diffMS).toBe(-1 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SilenceFormStore.tab", () => {
|
||||
it("current tab is Editor by default", () => {
|
||||
expect(store.tab.current).toBe(SilenceTabNames.Editor);
|
||||
});
|
||||
|
||||
it("setTab() sets the current tab", () => {
|
||||
expect(store.tab.current).toBe(SilenceTabNames.Editor);
|
||||
store.tab.setTab(SilenceTabNames.Browser);
|
||||
expect(store.tab.current).toBe(SilenceTabNames.Browser);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user