diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js index afae05196..026649b84 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js @@ -367,4 +367,9 @@ const Silence = inject("alertStore")( ) ); -export { Silence, SilenceDetails, SilenceExpiryBadgeWithProgress }; +export { + Silence, + SilenceDetails, + SilenceComment, + SilenceExpiryBadgeWithProgress +}; diff --git a/ui/src/Components/MainModal/MainModalContent.js b/ui/src/Components/MainModal/MainModalContent.js index 55c27c08e..66d44c340 100644 --- a/ui/src/Components/MainModal/MainModalContent.js +++ b/ui/src/Components/MainModal/MainModalContent.js @@ -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 }) => ( - - {title} - -); -Tab.propTypes = { - title: PropTypes.string.isRequired, - active: PropTypes.bool, - onClick: PropTypes.func.isRequired -}; - const TabNames = Object.freeze({ Configuration: "configuration", Help: "help" diff --git a/ui/src/Components/MainModal/__snapshots__/MainModalContent.test.js.snap b/ui/src/Components/MainModal/__snapshots__/MainModalContent.test.js.snap index 7fb667a8e..e12c04cc5 100644 --- a/ui/src/Components/MainModal/__snapshots__/MainModalContent.test.js.snap +++ b/ui/src/Components/MainModal/__snapshots__/MainModalContent.test.js.snap @@ -5,10 +5,10 @@ exports[` matches snapshot 1`] = `
+
+ {this.deleteState.done ? ( + this.deleteState.error !== null ? ( + + ) : ( + + ) + ) : this.deleteState.fetch !== null ? ( + + ) : this.previewState.error === null ? ( + + ) : ( + + )} + {isDone ? null : ( +
+ +
+ )} +
+ + ); + } + } +); + +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 ( + + + + + + + ); + } + } +); + +export { DeleteSilence, DeleteSilenceModalContent }; diff --git a/ui/src/Components/ManagedSilence/DeleteSilence.test.js b/ui/src/Components/ManagedSilence/DeleteSilence.test.js new file mode 100644 index 000000000..a3df8304e --- /dev/null +++ b/ui/src/Components/ManagedSilence/DeleteSilence.test.js @@ -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( + + ); +}; + +const MountedDeleteSilenceModalContent = () => { + return mount( + + ); +}; + +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("", () => { + 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("", () => { + 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); + }); +}); diff --git a/ui/src/Components/ManagedSilence/SilenceComment.js b/ui/src/Components/ManagedSilence/SilenceComment.js new file mode 100644 index 000000000..fb3ca1f6c --- /dev/null +++ b/ui/src/Components/ManagedSilence/SilenceComment.js @@ -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 = ( + + {silence.comment} + + ); + if (silence.jiraURL) { + return ( + + + {comment} + + ); + } + return {comment}; +}; +SilenceComment.propTypes = { + silence: APISilence.isRequired, + collapsed: PropTypes.bool.isRequired +}; + +export { SilenceComment }; diff --git a/ui/src/Components/ManagedSilence/SilenceComment.test.js b/ui/src/Components/ManagedSilence/SilenceComment.test.js new file mode 100644 index 000000000..d75beb5d5 --- /dev/null +++ b/ui/src/Components/ManagedSilence/SilenceComment.test.js @@ -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(); +}; + +describe("", () => { + 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); + }); +}); diff --git a/ui/src/Components/ManagedSilence/SilenceDetails.js b/ui/src/Components/ManagedSilence/SilenceDetails.js new file mode 100644 index 000000000..058a4ff3e --- /dev/null +++ b/ui/src/Components/ManagedSilence/SilenceDetails.js @@ -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 ( +
+
+
+
+ + + Started {silence.startsAt} + + + + {expiresLabel} {silence.endsAt} + +
+
+ + + View in Alertmanager: + + {alertmanagers.map(alertmanager => ( + + ))} +
+
+
+ + + Matchers: + +
+
+ {silence.matchers.map(matcher => ( + + {matcher.name} + {matcher.isRegex + ? QueryOperators.Regex + : QueryOperators.Equal} + {matcher.value} + + ))} +
+
+
+
+
+ + {!isExpired && ( + + )} +
+
+
+
+ ); +}; +SilenceDetails.propTypes = { + silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired, + cluster: PropTypes.string.isRequired, + silence: APISilence.isRequired, + onEditSilence: PropTypes.func.isRequired +}; + +export { SilenceDetails }; diff --git a/ui/src/Components/ManagedSilence/SilenceDetails.test.js b/ui/src/Components/ManagedSilence/SilenceDetails.test.js new file mode 100644 index 000000000..8896e9364 --- /dev/null +++ b/ui/src/Components/ManagedSilence/SilenceDetails.test.js @@ -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( + + ); +}; + +describe("", () => { + 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" + ); + }); +}); diff --git a/ui/src/Components/ManagedSilence/SilenceProgress.js b/ui/src/Components/ManagedSilence/SilenceProgress.js new file mode 100644 index 000000000..e6463a5de --- /dev/null +++ b/ui/src/Components/ManagedSilence/SilenceProgress.js @@ -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 ( + + Expired {silence.endsAt} + + ); + } + + 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 ( + + Expires {silence.endsAt} +
+
+
+ + ); + } + } +); + +export { SilenceProgress }; diff --git a/ui/src/Components/ManagedSilence/SilenceProgress.scss b/ui/src/Components/ManagedSilence/SilenceProgress.scss new file mode 100644 index 000000000..b605e6d4f --- /dev/null +++ b/ui/src/Components/ManagedSilence/SilenceProgress.scss @@ -0,0 +1,7 @@ +.silence-progress.progress { + height: 2px; +} + +.silence-progress.progress > .progress-bar { + height: 2px; +} diff --git a/ui/src/Components/ManagedSilence/SilenceProgress.test.js b/ui/src/Components/ManagedSilence/SilenceProgress.test.js new file mode 100644 index 000000000..f305a6cb2 --- /dev/null +++ b/ui/src/Components/ManagedSilence/SilenceProgress.test.js @@ -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(); +}; + +describe("", () => { + 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(); + }); +}); diff --git a/ui/src/Components/ManagedSilence/__snapshots__/SilenceComment.test.js.snap b/ui/src/Components/ManagedSilence/__snapshots__/SilenceComment.test.js.snap new file mode 100644 index 000000000..16ef62d63 --- /dev/null +++ b/ui/src/Components/ManagedSilence/__snapshots__/SilenceComment.test.js.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` Matches snapshot when collapsed 1`] = ` +" + + + + + Mocked Silence + + + … + + +" +`; + +exports[` Matches snapshot when expanded 1`] = ` +" + + + + + Mocked Silence + + + … + + +" +`; diff --git a/ui/src/Components/ManagedSilence/__snapshots__/index.test.js.snap b/ui/src/Components/ManagedSilence/__snapshots__/index.test.js.snap new file mode 100644 index 000000000..916890ea0 --- /dev/null +++ b/ui/src/Components/ManagedSilence/__snapshots__/index.test.js.snap @@ -0,0 +1,272 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` matches snapshot when collapsed 1`] = ` +" +
+
+
+
+ + + + + + Mocked Silence + + + … + + + + + me@example.com + + + Expires + +
+
+
+
+
+
+
+
+
+ + + + +
+
+
+
+" +`; + +exports[` matches snapshot with expaned details 1`] = ` +" +
+
+
+
+ + + + + + Mocked Silence + + + … + + + + + me@example.com + + + +
+
+ + + + +
+
+
+
+
+
+
+
+ + + + + + Started + + + + + + + + Expires + + +
+
+ + + + + + View in Alertmanager: + + + + + + + am1 + +
+
+
+ + + + + + Matchers: + +
+
+ + foo=bar + + + baz=~regex + +
+
+
+
+
+ + +
+
+
+
+
+
+" +`; diff --git a/ui/src/Components/ManagedSilence/index.js b/ui/src/Components/ManagedSilence/index.js new file mode 100644 index 000000000..6560988db --- /dev/null +++ b/ui/src/Components/ManagedSilence/index.js @@ -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 ( + +
+
+
+
+ + + + + {silence.createdBy} + + {this.collapse.value ? ( + + ) : null} + + +
+
+ +
+
+
+ + {this.collapse.value ? null : ( +
+ +
+ )} +
+
+ ); + } + } +); + +export { ManagedSilence }; diff --git a/ui/src/Components/ManagedSilence/index.scss b/ui/src/Components/ManagedSilence/index.scss new file mode 100644 index 000000000..abc275c3e --- /dev/null +++ b/ui/src/Components/ManagedSilence/index.scss @@ -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; + } +} diff --git a/ui/src/Components/ManagedSilence/index.test.js b/ui/src/Components/ManagedSilence/index.test.js new file mode 100644 index 000000000..f2fba5e95 --- /dev/null +++ b/ui/src/Components/ManagedSilence/index.test.js @@ -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( + + ); +}; + +describe("", () => { + 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(); + }); +}); diff --git a/ui/src/Components/Modal/Tab.js b/ui/src/Components/Modal/Tab.js new file mode 100644 index 000000000..4c196b44a --- /dev/null +++ b/ui/src/Components/Modal/Tab.js @@ -0,0 +1,20 @@ +import React from "react"; +import PropTypes from "prop-types"; + +const Tab = ({ title, active, onClick }) => ( + + {title} + +); +Tab.propTypes = { + title: PropTypes.string.isRequired, + active: PropTypes.bool, + onClick: PropTypes.func.isRequired +}; + +export { Tab }; diff --git a/ui/src/Components/Modal/index.js b/ui/src/Components/Modal/index.js index c23f37b76..11f0ca53a 100644 --- a/ui/src/Components/Modal/index.js +++ b/ui/src/Components/Modal/index.js @@ -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( @@ -76,7 +87,11 @@ const Modal = observer( handlers={{ CLOSE: toggleOpen }} >
-
+
{children}
diff --git a/ui/src/Components/Modal/index.scss b/ui/src/Components/Modal/index.scss new file mode 100644 index 000000000..26ef4b137 --- /dev/null +++ b/ui/src/Components/Modal/index.scss @@ -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; + } +} diff --git a/ui/src/Components/SilenceModal/Browser/index.js b/ui/src/Components/SilenceModal/Browser/index.js new file mode 100644 index 000000000..6009a1f8c --- /dev/null +++ b/ui/src/Components/SilenceModal/Browser/index.js @@ -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 }) => ( +
+

+ +

+

{message}

+
+); +FetchError.propTypes = { + message: PropTypes.node.isRequired +}; + +const Placeholder = ({ content }) => ( + +
+

{content}

+
+
+); +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 ( + +
+ + { + this.dataSource.toggleShowExpired(); + this.onFetch(); + }} + /> + + + { + this.dataSource.setSearchTerm(e.target.value); + this.onFetch(); + }} + /> + +
+ {this.dataSource.error !== null ? ( + + ) : this.dataSource.done ? ( + this.dataSource.silences.length === 0 ? ( + + ) : ( + + {this.dataSource.silences.map(silence => ( + + ))} + + ) + ) : ( + } + /> + )} +
+ ); + } + } +); + +export { Browser }; diff --git a/ui/src/Components/SilenceModal/Browser/index.test.js b/ui/src/Components/SilenceModal/Browser/index.test.js new file mode 100644 index 000000000..8264c1abb --- /dev/null +++ b/ui/src/Components/SilenceModal/Browser/index.test.js @@ -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( + + ); +}; + +describe("", () => { + 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(); + }); +}); diff --git a/ui/src/Components/SilenceModal/SilenceModalContent.js b/ui/src/Components/SilenceModal/SilenceModalContent.js index e42e6ae61..ebc3286c7 100644 --- a/ui/src/Components/SilenceModal/SilenceModalContent.js +++ b/ui/src/Components/SilenceModal/SilenceModalContent.js @@ -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 ( -
-
- {silenceFormStore.data.silenceID === null - ? silenceFormStore.data.currentStage === +
+
-
- {silenceFormStore.data.currentStage === - SilenceFormStage.UserInput ? ( - - ) : silenceFormStore.data.currentStage === - SilenceFormStage.Preview ? ( - + {silenceFormStore.tab.current === SilenceTabNames.Editor ? ( + silenceFormStore.data.currentStage === + SilenceFormStage.UserInput ? ( + + ) : silenceFormStore.data.currentStage === + SilenceFormStage.Preview ? ( + + ) : ( + + ) + ) : null} + {silenceFormStore.tab.current === SilenceTabNames.Browser ? ( + - ) : ( - - )} + ) : null}
); diff --git a/ui/src/Components/SilenceModal/SilenceModalContent.test.js b/ui/src/Components/SilenceModal/SilenceModalContent.test.js index edb033e25..9519f79ac 100644 --- a/ui/src/Components/SilenceModal/SilenceModalContent.test.js +++ b/ui/src/Components/SilenceModal/SilenceModalContent.test.js @@ -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("", () => { + 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(" 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("", () => { 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(" 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); }); }); diff --git a/ui/src/Components/SilenceModal/index.js b/ui/src/Components/SilenceModal/index.js index 4397a729a..8411de973 100644 --- a/ui/src/Components/SilenceModal/index.js +++ b/ui/src/Components/SilenceModal/index.js @@ -38,7 +38,7 @@ const SilenceModal = observer( silenceFormStore.toggle.visible ? "border-bottom border-info" : "" }`} > - + {}} previewOpen={true} + openTab={TabNames.Editor} + /> + ); + }) + .add("Browser", () => { + const alertStore = new AlertStore([]); + const settingsStore = new Settings(); + const silenceFormStore = new SilenceFormStore(); + + return ( + {}} + openTab={TabNames.Browser} /> ); }); diff --git a/ui/src/Stores/AlertStore.js b/ui/src/Stores/AlertStore.js index 64ecc7935..d19c1e1ad 100644 --- a/ui/src/Stores/AlertStore.js +++ b/ui/src/Stores/AlertStore.js @@ -209,7 +209,6 @@ class AlertStore { }, setFetching() { this.value = AlertStoreStatuses.Fetching; - this.error = null; }, setProcessing() { this.value = AlertStoreStatuses.Processing; diff --git a/ui/src/Stores/SilenceFormStore.js b/ui/src/Stores/SilenceFormStore.js index 3f41736dd..b7223e6b7 100644 --- a/ui/src/Stores/SilenceFormStore.js +++ b/ui/src/Stores/SilenceFormStore.js @@ -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 }; diff --git a/ui/src/Stores/SilenceFormStore.test.js b/ui/src/Stores/SilenceFormStore.test.js index f25ec17e2..2dcfee6c9 100644 --- a/ui/src/Stores/SilenceFormStore.test.js +++ b/ui/src/Stores/SilenceFormStore.test.js @@ -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); + }); +});