diff --git a/Makefile b/Makefile
index d40b0c01b..7925d47ab 100644
--- a/Makefile
+++ b/Makefile
@@ -76,6 +76,7 @@ clean:
run: $(NAME)
ALERTMANAGER_INTERVAL=36000h \
ALERTMANAGER_URI=$(ALERTMANAGER_URI) \
+ ANNOTATIONS_HIDDEN="help" \
LABELS_COLOR_UNIQUE="@receiver instance cluster" \
LABELS_COLOR_STATIC="job" \
FILTERS_DEFAULT="@state=active @receiver=by-cluster-service" \
@@ -94,6 +95,7 @@ run-docker: docker-image
-v $(MOCK_PATH):$(MOCK_PATH) \
-e ALERTMANAGER_INTERVAL=36000h \
-e ALERTMANAGER_URI=$(ALERTMANAGER_URI) \
+ -e ANNOTATIONS_HIDDEN="help" \
-e LABELS_COLOR_UNIQUE="instance cluster" \
-e LABELS_COLOR_STATIC="job" \
-e FILTERS_DEFAULT="@state=active @receiver=by-cluster-service" \
diff --git a/ui/__mocks__/copy-to-clipboard.js b/ui/__mocks__/copy-to-clipboard.js
new file mode 100644
index 000000000..d30cebfdf
--- /dev/null
+++ b/ui/__mocks__/copy-to-clipboard.js
@@ -0,0 +1,6 @@
+// mock copy-to-clipboard since it throws errors in tests
+// and we don't really need to copy anything, only ensure we're calling it
+
+const copy = jest.fn();
+
+export default copy;
diff --git a/ui/package-lock.json b/ui/package-lock.json
index 4e0401444..ace7fd269 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -3100,6 +3100,40 @@
"resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz",
"integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA=="
},
+ "diffable-html": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/diffable-html/-/diffable-html-3.0.0.tgz",
+ "integrity": "sha512-lUxHiU00DexR/wKcY56OiJZmB0D66ghidYfU4VxUMG09TDx+1jjO7/dFrZKI2p9z00tWY/7ZeO9BBEi6n0jUYQ==",
+ "dev": true,
+ "requires": {
+ "htmlparser2": "3.9.2"
+ },
+ "dependencies": {
+ "domhandler": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz",
+ "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==",
+ "dev": true,
+ "requires": {
+ "domelementtype": "1.3.0"
+ }
+ },
+ "htmlparser2": {
+ "version": "3.9.2",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.9.2.tgz",
+ "integrity": "sha1-G9+HrMoPP55T+k/M6w9LTLsAszg=",
+ "dev": true,
+ "requires": {
+ "domelementtype": "1.3.0",
+ "domhandler": "2.4.2",
+ "domutils": "1.5.1",
+ "entities": "1.1.1",
+ "inherits": "2.0.3",
+ "readable-stream": "2.3.6"
+ }
+ }
+ }
+ },
"diffie-hellman": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz",
@@ -6534,6 +6568,12 @@
"pretty-format": "20.0.3"
}
},
+ "jest-date-mock": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/jest-date-mock/-/jest-date-mock-1.0.3.tgz",
+ "integrity": "sha512-PLwqL0KI+zDKc6SoytvApudwFD8uDLOM7Bf4Z5C3KpJrHDJv5RawgSZUQOUqSukQ+TOhdHzliUOvGtE3aA+fWA==",
+ "dev": true
+ },
"jest-diff": {
"version": "20.0.3",
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-20.0.3.tgz",
diff --git a/ui/package.json b/ui/package.json
index cc5f4caac..3a20f3b8f 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -51,11 +51,13 @@
"watch-css": "npm run build-css && node_modules/.bin/node-sass-chokidar src/ -o src/ --watch --recursive"
},
"devDependencies": {
+ "diffable-html": "3.0.0",
"enzyme": "3.5.0",
"enzyme-adapter-react-16": "1.3.1",
"enzyme-to-json": "3.3.4",
"eslint-plugin-react": "7.11.1",
"jest-canvas-mock": "1.1.0",
+ "jest-date-mock": "1.0.3",
"jest-fetch-mock": "1.6.5",
"jest-localstorage-mock": "2.2.0",
"jest-mock-console": "0.4.0",
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/__snapshots__/index.test.js.snap b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/__snapshots__/index.test.js.snap
new file mode 100644
index 000000000..321615444
--- /dev/null
+++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/__snapshots__/index.test.js.snap
@@ -0,0 +1,78 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` matches snapshot with showAlertmanagers=false showReceiver=false 1`] = `
+"
+
+
+
+
+
+ help:
+
+
+ some long text
+
+
+
+
+
+
+
+
+ job: node_exporter
+
+
+ cluster: dev
+
+
+
+ link
+
+
+"
+`;
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.js
index 809d433bd..dd141e368 100644
--- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.js
+++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.js
@@ -50,6 +50,7 @@ const Alert = observer(
key={a.name}
name={a.name}
value={a.value}
+ visible={a.visible}
afterUpdate={afterUpdate}
/>
))}
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.test.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.test.js
new file mode 100644
index 000000000..67f424f33
--- /dev/null
+++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.test.js
@@ -0,0 +1,87 @@
+import React from "react";
+
+import { Provider } from "mobx-react";
+
+import { mount } from "enzyme";
+
+import { advanceTo, clear } from "jest-date-mock";
+
+import toDiffableHtml from "diffable-html";
+
+import { MockAlert, MockAnnotation } from "__mocks__/Alerts.js";
+import { AlertStore } from "Stores/AlertStore";
+import { Alert } from ".";
+
+let alertStore;
+
+beforeEach(() => {
+ advanceTo(new Date(2018, 7, 15, 20, 40, 0));
+ alertStore = new AlertStore([]);
+});
+
+afterEach(() => {
+ // reset Date() to current time
+ clear();
+});
+
+const MockAfterUpdate = jest.fn();
+
+const MockedAlert = () => {
+ return MockAlert(
+ [
+ MockAnnotation("help", "some long text", true, false),
+ MockAnnotation("hidden", "some hidden text", false, false),
+ MockAnnotation("link", "http://localhost", true, true)
+ ],
+ { job: "node_exporter", cluster: "dev" },
+ "active"
+ );
+};
+
+const MountedAlert = (alert, showAlertmanagers, showReceiver) => {
+ return mount(
+
+
+
+ );
+};
+
+describe("", () => {
+ it("matches snapshot with showAlertmanagers=false showReceiver=false", () => {
+ const alert = MockedAlert();
+ const tree = MountedAlert(alert, false, false);
+ expect(toDiffableHtml(tree.html())).toMatchSnapshot();
+ });
+
+ it("renders @alertmanager label with showAlertmanagers=true", () => {
+ const alert = MockedAlert();
+ const tree = MountedAlert(alert, true, false);
+ const label = tree
+ .find("FilteringLabel")
+ .filterWhere(elem => elem.props().name === "@alertmanager");
+ expect(label.text()).toBe("@alertmanager: default");
+ });
+
+ it("renders @receiver label with showReceiver=true", () => {
+ const alert = MockedAlert();
+ const tree = MountedAlert(alert, false, true);
+ const label = tree
+ .find("FilteringLabel")
+ .filterWhere(elem => elem.props().name === "@receiver");
+ expect(label.text()).toBe("@receiver: by-name");
+ });
+
+ it("renders a silence if alert is silenced", () => {
+ const alert = MockedAlert();
+ alert.alertmanager[0].silencedBy = ["silence123456789"];
+ const tree = MountedAlert(alert, false, false);
+ const silence = tree.find("Silence");
+ expect(silence).toHaveLength(1);
+ expect(silence.html()).toMatch(/silence123456789/);
+ });
+});
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/__snapshots__/index.test.js.snap b/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/__snapshots__/index.test.js.snap
new file mode 100644
index 000000000..dfaa05fd2
--- /dev/null
+++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/__snapshots__/index.test.js.snap
@@ -0,0 +1,90 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` matches snapshot 1`] = `
+
+
+
+ annotation name
+
+`;
+
+exports[` matches snapshot when visible=false 1`] = `
+"
+
+"
+`;
+
+exports[` matches snapshot when visible=true 1`] = `
+"
+
+
+
+ foo:
+
+
+ some long text
+
+
+"
+`;
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.js
index 2742cef7e..70eae7ec5 100644
--- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.js
+++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.js
@@ -1,7 +1,7 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
-import { observable, action, toJS } from "mobx";
+import { observable, action } from "mobx";
import { observer, inject } from "mobx-react";
import Linkify from "react-linkify";
@@ -20,6 +20,7 @@ const RenderNonLinkAnnotation = inject("alertStore")(
alertStore: PropTypes.object.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
+ visible: PropTypes.bool.isRequired,
afterUpdate: PropTypes.func.isRequired
};
@@ -45,7 +46,7 @@ const RenderNonLinkAnnotation = inject("alertStore")(
constructor(props) {
super(props);
- this.toggle.visible = this.isVisible();
+ this.toggle.visible = props.visible;
}
componentDidUpdate() {
@@ -54,34 +55,6 @@ const RenderNonLinkAnnotation = inject("alertStore")(
afterUpdate();
}
- // determinate if this annotation should be hidden by default or not
- isVisible() {
- const { alertStore, name } = this.props;
-
- const annotationsHidden = toJS(
- alertStore.settings.values.annotationsHidden
- );
- const isInHidden =
- annotationsHidden !== null && annotationsHidden.indexOf(name) >= 0;
-
- const annotationsVisible = toJS(
- alertStore.settings.values.annotationsVisible
- );
- const isInVisible =
- annotationsVisible !== null && annotationsVisible.indexOf(name) >= 0;
-
- if (isInVisible) return true;
-
- if (
- toJS(alertStore.settings.values.annotationsDefaultHidden) === true ||
- isInHidden === true
- ) {
- return false;
- }
-
- return true;
- }
-
render() {
const { name, value } = this.props;
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.test.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.test.js
new file mode 100644
index 000000000..ed1edbbad
--- /dev/null
+++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.test.js
@@ -0,0 +1,100 @@
+import React from "react";
+
+import { shallow, mount } from "enzyme";
+
+import toDiffableHtml from "diffable-html";
+
+import { AlertStore } from "Stores/AlertStore";
+import { RenderNonLinkAnnotation, RenderLinkAnnotation } from ".";
+
+let alertStore;
+
+beforeEach(() => {
+ alertStore = new AlertStore([]);
+});
+
+const ShallowLinkAnnotation = () => {
+ return shallow(
+
+ );
+};
+
+describe("", () => {
+ it("matches snapshot", () => {
+ const tree = ShallowLinkAnnotation();
+ expect(tree).toMatchSnapshot();
+ });
+
+ it("contains a link", () => {
+ const tree = ShallowLinkAnnotation();
+ const link = tree.find("a[href='http://localhost/foo']");
+ expect(link).toHaveLength(1);
+ expect(link.text()).toMatch(/annotation name/);
+ });
+});
+
+const MockAfterUpdate = jest.fn();
+
+const ShallowNonLinkAnnotation = visible => {
+ return shallow(
+
+ );
+};
+
+const MountedNonLinkAnnotation = visible => {
+ return mount(
+
+ );
+};
+
+describe("", () => {
+ it("matches snapshot when visible=true", () => {
+ const tree = ShallowNonLinkAnnotation(true);
+ expect(toDiffableHtml(tree.html())).toMatchSnapshot();
+ });
+
+ it("contains value when visible=true", () => {
+ const tree = ShallowNonLinkAnnotation(true);
+ expect(tree.html()).toMatch(/some long text/);
+ });
+
+ it("matches snapshot when visible=false", () => {
+ const tree = ShallowNonLinkAnnotation(false);
+ expect(toDiffableHtml(tree.html())).toMatchSnapshot();
+ });
+
+ it("doesn't contain value when visible=false", () => {
+ const tree = ShallowNonLinkAnnotation(false);
+ expect(tree.html()).not.toMatch(/some long text/);
+ });
+
+ it("clicking on + icon hides the value", () => {
+ const tree = MountedNonLinkAnnotation(true);
+ expect(tree.html()).toMatch(/fa-search-minus/);
+ expect(tree.html()).toMatch(/some long text/);
+ tree.find("div").simulate("click");
+ expect(tree.html()).toMatch(/fa-search-plus/);
+ expect(tree.html()).not.toMatch(/some long text/);
+ });
+
+ it("clicking on - icon shows the value", () => {
+ const tree = MountedNonLinkAnnotation(false);
+ expect(tree.html()).toMatch(/fa-search-plus/);
+ expect(tree.html()).not.toMatch(/some long text/);
+ tree.find("div").simulate("click");
+ expect(tree.html()).toMatch(/fa-search-minus/);
+ expect(tree.html()).toMatch(/some long text/);
+ });
+});
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/__snapshots__/index.test.js.snap b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/__snapshots__/index.test.js.snap
new file mode 100644
index 000000000..503472efe
--- /dev/null
+++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/__snapshots__/index.test.js.snap
@@ -0,0 +1,79 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` matches snapshot 1`] = `
+"
+
+"
+`;
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.js
index 8fa48fef5..9ba34c336 100644
--- a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.js
+++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.js
@@ -28,6 +28,7 @@ const GroupFooter = observer(
key={a.name}
name={a.name}
value={a.value}
+ visible={a.visible}
afterUpdate={afterUpdate}
/>
))}
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.test.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.test.js
new file mode 100644
index 000000000..c4d39ea2b
--- /dev/null
+++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.test.js
@@ -0,0 +1,54 @@
+import React from "react";
+
+import { Provider } from "mobx-react";
+
+import { mount } from "enzyme";
+
+import toDiffableHtml from "diffable-html";
+
+import { MockAlertGroup, MockAnnotation } from "__mocks__/Alerts.js";
+import { AlertStore } from "Stores/AlertStore";
+import { GroupFooter } from ".";
+
+let group;
+let alertStore;
+
+const MockGroup = () => {
+ const group = MockAlertGroup(
+ { alertname: "Fake Alert" },
+ [],
+ [
+ MockAnnotation("summary", "This is summary", true, false),
+ MockAnnotation("hidden", "This is hidden annotation", false, false),
+ MockAnnotation("link", "http://link.example.com", true, true)
+ ],
+ { label1: "foo", label2: "bar" }
+ );
+ return group;
+};
+
+const MockAfterUpdate = jest.fn();
+
+beforeEach(() => {
+ alertStore = new AlertStore([]);
+ group = MockGroup();
+});
+
+const MountedGroupFooter = () => {
+ return mount(
+
+
+
+ );
+};
+
+describe("", () => {
+ it("matches snapshot", () => {
+ const tree = MountedGroupFooter().find("GroupFooter");
+ expect(toDiffableHtml(tree.html())).toMatchSnapshot();
+ });
+});
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/GroupMenu.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/GroupMenu.js
index d61f1f72c..b337576f8 100644
--- a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/GroupMenu.js
+++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/GroupMenu.js
@@ -150,4 +150,4 @@ const GroupMenu = observer(
}
);
-export { GroupMenu };
+export { GroupMenu, MenuContent };
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/GroupMenu.test.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/GroupMenu.test.js
new file mode 100644
index 000000000..eb8c3a7ff
--- /dev/null
+++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/GroupMenu.test.js
@@ -0,0 +1,81 @@
+import React from "react";
+
+import { mount } from "enzyme";
+
+import copy from "copy-to-clipboard";
+
+import { MockAlertGroup } from "__mocks__/Alerts.js";
+import { SilenceFormStore } from "Stores/SilenceFormStore";
+import { GroupMenu, MenuContent } from "./GroupMenu";
+
+let silenceFormStore;
+
+beforeEach(() => {
+ silenceFormStore = new SilenceFormStore();
+});
+
+const MockAfterClick = jest.fn();
+
+const MountedGroupMenu = group => {
+ return mount();
+};
+
+describe("", () => {
+ it("is collapsed by default", () => {
+ const group = MockAlertGroup({ alertname: "Fake Alert" }, [], [], {});
+ const tree = MountedGroupMenu(group);
+ expect(tree.instance().collapse.value).toBe(true);
+ });
+
+ it("clicking toggle sets collapse value to 'false'", () => {
+ const group = MockAlertGroup({ alertname: "Fake Alert" }, [], [], {});
+ const tree = MountedGroupMenu(group);
+ const toggle = tree.find("a.cursor-pointer");
+ toggle.simulate("click");
+ expect(tree.instance().collapse.value).toBe(false);
+ });
+
+ it("handleClickOutside() call sets collapse value to 'true'", () => {
+ const group = MockAlertGroup({ alertname: "Fake Alert" }, [], [], {});
+ const tree = MountedGroupMenu(group);
+
+ const toggle = tree.find("a.cursor-pointer");
+ toggle.simulate("click");
+ expect(tree.instance().collapse.value).toBe(false);
+
+ tree.instance().handleClickOutside();
+
+ expect(tree.instance().collapse.value).toBe(true);
+ });
+});
+
+const MountedMenuContent = group => {
+ return mount(
+
+ );
+};
+
+describe("", () => {
+ it("clicking on 'Copy' icon copies the link to clickboard", () => {
+ const group = MockAlertGroup({ alertname: "Fake Alert" }, [], [], {});
+ const tree = MountedMenuContent(group);
+ const button = tree.find(".dropdown-item").at(0);
+ button.simulate("click");
+ expect(copy).toHaveBeenCalledTimes(1);
+ });
+
+ it("clicking on 'Silence' icon opens the silence form modal", () => {
+ const group = MockAlertGroup({ alertname: "Fake Alert" }, [], [], {});
+ const tree = MountedMenuContent(group);
+ const button = tree.find(".dropdown-item").at(1);
+ button.simulate("click");
+ expect(silenceFormStore.toggle.visible).toBe(true);
+ });
+});
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/__snapshots__/index.test.js.snap b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/__snapshots__/index.test.js.snap
new file mode 100644
index 000000000..2c7daac4d
--- /dev/null
+++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/__snapshots__/index.test.js.snap
@@ -0,0 +1,118 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` matches snapshot when data is not present in alertStore 1`] = `
+"
+
+
+ Silenced by default/4cf5fd82-1edd-4169-99d1-ff8415e72179
+
+
+"
+`;
+
+exports[` matches snapshot when data is present in alertStore 1`] = `
+"
+
+
+
+ Fake silence
+
+
+
+
+"
+`;
+
+exports[` matches snapshot with expaned details 1`] = `
+"
+
+
+
+ Fake silence
+
+
+
+
+
+"
+`;
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js
index 4a5975376..a265583e9 100644
--- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js
+++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js
@@ -14,7 +14,7 @@ import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons/faExternalL
import { faChevronUp } from "@fortawesome/free-solid-svg-icons/faChevronUp";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons/faChevronDown";
-import { StaticLabels } from "Common/Query";
+import { StaticLabels, QueryOperators } from "Common/Query";
import { FilteringLabel } from "Components/Labels/FilteringLabel";
import "./index.css";
@@ -114,7 +114,7 @@ const SilenceDetails = ({ alertmanager, silence }) => {
className="badge badge-success text-nowrap text-truncate px-1 mr-1"
>
{matcher.name}
- {matcher.isRegex ? "=~" : "="}
+ {matcher.isRegex ? QueryOperators.Regex : QueryOperators.Equal}
{matcher.value}
))}
@@ -228,4 +228,4 @@ const Silence = inject("alertStore")(
)
);
-export { Silence };
+export { Silence, SilenceDetails, SilenceExpiryBadgeWithProgress };
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.test.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.test.js
new file mode 100644
index 000000000..56ddd651b
--- /dev/null
+++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.test.js
@@ -0,0 +1,194 @@
+import React from "react";
+
+import { Provider } from "mobx-react";
+
+import { mount, shallow } from "enzyme";
+
+import toDiffableHtml from "diffable-html";
+
+import { advanceTo, clear } from "jest-date-mock";
+
+import { AlertStore } from "Stores/AlertStore";
+import { Silence, SilenceDetails, SilenceExpiryBadgeWithProgress } from ".";
+
+const mockAfterUpdate = jest.fn();
+
+const alertmanager = {
+ name: "default",
+ uri: "file:///mock",
+ state: "suppressed",
+ startsAt: "2000-01-01T10:00:00Z",
+ endsAt: "0001-01-01T00:00:00Z",
+ source: "localhost/prometheus",
+ silencedBy: ["4cf5fd82-1edd-4169-99d1-ff8415e72179"]
+};
+
+const silence = {
+ id: "4cf5fd82-1edd-4169-99d1-ff8415e72179",
+ matchers: [
+ {
+ name: "alertname",
+ value: "MockAlert",
+ isRegex: false
+ }
+ ],
+ startsAt: "2000-01-01T10:00:00Z",
+ endsAt: "2000-01-01T20:00:00Z",
+ createdAt: "0001-01-01T00:00:00Z",
+ createdBy: "me@example.com",
+ comment: "Fake silence",
+ jiraID: "",
+ jiraURL: ""
+};
+
+let alertStore;
+
+beforeEach(() => {
+ advanceTo(new Date(2000, 0, 1, 15, 0, 0));
+ alertStore = new AlertStore([]);
+ alertStore.data.upstreams = {
+ counters: {
+ total: 1,
+ healthy: 1,
+ failed: 0
+ },
+ instances: [
+ {
+ name: "default",
+ uri: "file:///mock",
+ error: ""
+ }
+ ]
+ };
+ alertStore.data.silences = {
+ default: {
+ "4cf5fd82-1edd-4169-99d1-ff8415e72179": silence
+ }
+ };
+});
+
+afterEach(() => {
+ // reset Date() to current time
+ clear();
+});
+
+const MountedSilence = () => {
+ return mount(
+
+
+
+ );
+};
+
+describe("", () => {
+ it("matches snapshot when data is present in alertStore", () => {
+ const tree = MountedSilence().find("Silence");
+ expect(toDiffableHtml(tree.html())).toMatchSnapshot();
+ });
+
+ it("renders full silence when data is present in alertStore", () => {
+ const tree = MountedSilence().find("Silence");
+ const fallback = tree.find("FallbackSilenceDesciption");
+ expect(fallback).toHaveLength(0);
+ });
+
+ it("matches snapshot when data is not present in alertStore", () => {
+ alertStore.data.silences = {};
+ const tree = MountedSilence().find("Silence");
+ expect(toDiffableHtml(tree.html())).toMatchSnapshot();
+ });
+
+ it("renders FallbackSilenceDesciption when Alertmanager data is not present in alertStore", () => {
+ alertStore.data.silences = {};
+ const tree = MountedSilence();
+ const fallback = tree.find("FallbackSilenceDesciption");
+ expect(fallback).toHaveLength(1);
+ expect(tree.text()).toBe(
+ "Silenced by default/4cf5fd82-1edd-4169-99d1-ff8415e72179"
+ );
+ });
+
+ it("renders FallbackSilenceDesciption when silence data is not present in alertStore", () => {
+ alertStore.data.silences.default = {};
+ const tree = MountedSilence();
+ const fallback = tree.find("FallbackSilenceDesciption");
+ expect(fallback).toHaveLength(1);
+ expect(tree.text()).toBe(
+ "Silenced by default/4cf5fd82-1edd-4169-99d1-ff8415e72179"
+ );
+ });
+
+ it("clicking on expand toggle shows silence details", () => {
+ const tree = MountedSilence();
+ const toggle = tree.find("a.float-right.cursor-pointer");
+ toggle.simulate("click");
+ const details = tree.find("SilenceDetails");
+ expect(details).toHaveLength(1);
+ });
+
+ it("matches snapshot with expaned details", () => {
+ const tree = MountedSilence().find("Silence");
+ tree.instance().collapse.toggle();
+ expect(toDiffableHtml(tree.html())).toMatchSnapshot();
+ });
+
+ it("renders comment as link when jiraURL is set", () => {
+ alertStore.data.silences.default[silence.id].jiraURL =
+ "http://jira.example.com";
+ const tree = MountedSilence().find("Silence");
+ const link = tree.find("a[href='http://jira.example.com']");
+ expect(link).toHaveLength(1);
+ expect(link.text()).toBe("Fake silence");
+ });
+});
+
+const ShallowSilenceDetails = () => {
+ return shallow(
+
+ );
+};
+
+describe("", () => {
+ it("unexpired silence endsAt label uses 'secondary' class", () => {
+ const tree = ShallowSilenceDetails();
+ const endsAt = tree.find("span.badge").at(1);
+ expect(endsAt.html()).toMatch(/badge-secondary/);
+ });
+
+ it("expired silence endsAt label uses 'danger' class", () => {
+ advanceTo(new Date(2000, 0, 1, 23, 0, 0));
+ const tree = ShallowSilenceDetails();
+ const endsAt = tree.find("span.badge").at(1);
+ expect(endsAt.html()).toMatch(/badge-danger/);
+ });
+});
+
+const ShallowSilenceExpiryBadgeWithProgress = () => {
+ return shallow();
+};
+
+describe("", () => {
+ it("renders with class 'danger' and no progressbar when expired", () => {
+ advanceTo(new Date(2001, 0, 1, 23, 0, 0));
+ const tree = ShallowSilenceExpiryBadgeWithProgress();
+ expect(tree.html()).toMatch(/badge-danger/);
+ expect(tree.text()).toBe("Expired ");
+ });
+
+ it("progressbar uses class 'danger' when > 90%", () => {
+ advanceTo(new Date(2000, 0, 1, 19, 30, 0));
+ const tree = ShallowSilenceExpiryBadgeWithProgress();
+ expect(tree.html()).toMatch(/progress-bar bg-danger/);
+ });
+
+ it("progressbar uses class 'danger' when > 75%", () => {
+ advanceTo(new Date(2000, 0, 1, 17, 45, 0));
+ const tree = ShallowSilenceExpiryBadgeWithProgress();
+ expect(tree.html()).toMatch(/progress-bar bg-warning/);
+ });
+});
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/index.test.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/index.test.js
new file mode 100644
index 000000000..9c71d5cac
--- /dev/null
+++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/index.test.js
@@ -0,0 +1,173 @@
+import React from "react";
+
+import { Provider } from "mobx-react";
+
+import { mount } from "enzyme";
+
+import moment from "moment";
+
+import { MockAlert, MockAlertGroup } from "__mocks__/Alerts.js";
+import { AlertStore } from "Stores/AlertStore";
+import { Settings } from "Stores/Settings";
+import { SilenceFormStore } from "Stores/SilenceFormStore";
+import { AlertGroup } from ".";
+
+let alertStore;
+let settingsStore;
+let silenceFormStore;
+let group;
+
+const MockGroup = (groupName, alertCount) => {
+ const group = MockAlertGroup(
+ { alertname: "Fake Alert", group: groupName },
+ [],
+ [],
+ {}
+ );
+ return group;
+};
+
+beforeEach(() => {
+ alertStore = new AlertStore([]);
+ settingsStore = new Settings();
+ silenceFormStore = new SilenceFormStore();
+ group = MockGroup();
+});
+
+const MockAlerts = alertCount => {
+ for (let i = 1; i <= alertCount; i++) {
+ let alert = MockAlert([], { instance: `instance${i}` });
+ const startsAt = moment().toISOString();
+ alert.startsAt = startsAt;
+ alert.alertmanager[0].startsAt = startsAt;
+ group.alerts.push(alert);
+ }
+};
+
+const MountedAlertGroup = (afterUpdate, showAlertmanagers) => {
+ return mount(
+
+
+
+ );
+};
+
+describe("", () => {
+ it("renders Alertmanager labels in footer if showAlertmanagersInFooter=true", () => {
+ MockAlerts(2);
+ const tree = MountedAlertGroup(jest.fn(), true).find("AlertGroup");
+ expect(tree.find("GroupFooter").html()).toMatch(/@alertmanager: default/);
+ });
+
+ it("only renders titlebar when collapsed", () => {
+ MockAlerts(10);
+ const tree = MountedAlertGroup(jest.fn(), false);
+ const alertGroup = tree.find("AlertGroup");
+ alertGroup.instance().collapse.toggle();
+ expect(alertGroup.instance().collapse.value).toBe(true);
+ tree.update();
+ expect(tree.find("Alert")).toHaveLength(0);
+ expect(tree.find("ul.list-group")).toHaveLength(0);
+ });
+});
+
+const ValidateLoadButtonPresent = (totalAlerts, isPresent) => {
+ MockAlerts(totalAlerts);
+ const tree = MountedAlertGroup(jest.fn(), false);
+ const buttons = tree.find("button");
+ expect(buttons).toHaveLength(isPresent ? 2 : 0);
+};
+
+const ValidateLoadButtonAction = (
+ totalAlerts,
+ buttonIndex,
+ iconMatch,
+ loadedAlerts,
+ alertsToRenderBeforeClick
+) => {
+ MockAlerts(totalAlerts);
+ const tree = MountedAlertGroup(jest.fn(), false);
+ if (alertsToRenderBeforeClick !== undefined) {
+ tree
+ .find("AlertGroup")
+ .instance().renderConfig.alertsToRender = alertsToRenderBeforeClick;
+ tree.update();
+ }
+ const loadMore = tree.find("button").at(buttonIndex);
+ expect(loadMore.html()).toMatch(iconMatch);
+ loadMore.simulate("click");
+ tree.update();
+ expect(tree.find("Alert")).toHaveLength(loadedAlerts);
+};
+
+describe(" renderConfig", () => {
+ it("settingsStore.alertGroupConfig.config.defaultRenderCount should be 5 by default", () => {
+ expect(settingsStore.alertGroupConfig.config.defaultRenderCount).toBe(5);
+ });
+
+ it("renderConfig.alertsToRender should be 5 by default", () => {
+ const tree = MountedAlertGroup(jest.fn(), false).find("AlertGroup");
+ expect(tree.instance().renderConfig.alertsToRender).toBe(5);
+ });
+
+ it("renders only up to renderConfig.alertsToRender alerts", () => {
+ MockAlerts(50);
+ const tree = MountedAlertGroup(jest.fn(), false).find("AlertGroup");
+ const alerts = tree.find("Alert");
+ expect(alerts).toHaveLength(tree.instance().renderConfig.alertsToRender);
+ });
+
+ it("load buttons are not rendered for 1 alert", () => {
+ ValidateLoadButtonPresent(1, false);
+ });
+
+ it("load buttons are not rendered for 5 alerts", () => {
+ ValidateLoadButtonPresent(5, false);
+ });
+
+ it("load buttons are rendered for 6 alert", () => {
+ ValidateLoadButtonPresent(6, true);
+ });
+
+ it("clicking - icon hides 1 alert if there's 6 in total", () => {
+ ValidateLoadButtonAction(6, 0, /fa-minus/, 5, 6);
+ });
+
+ it("clicking - icon hides 1 alert if there's 6 in total and we're showing 3", () => {
+ ValidateLoadButtonAction(6, 0, /fa-minus/, 2, 3);
+ });
+
+ it("clicking - icon hides 2 alerts if there's 7 in total and we're showing 7", () => {
+ ValidateLoadButtonAction(7, 0, /fa-minus/, 5, 7);
+ });
+
+ it("clicking - icon hides 5 alerts if there's 10 in total and we're showing 10", () => {
+ ValidateLoadButtonAction(10, 0, /fa-minus/, 5, 10);
+ });
+
+ it("clicking - icon hides 5 alerts if there's 18 in total and we're showing 17", () => {
+ ValidateLoadButtonAction(18, 0, /fa-minus/, 12, 17);
+ });
+
+ it("clicking + icon loads 1 more alert if there's 6 in total", () => {
+ ValidateLoadButtonAction(6, 1, /fa-plus/, 6);
+ });
+
+ it("clicking + icon loads 4 more alert if there's 9 in total", () => {
+ ValidateLoadButtonAction(9, 1, /fa-plus/, 9);
+ });
+
+ it("clicking + icon loads 5 more alert if there's 14 in total", () => {
+ ValidateLoadButtonAction(14, 1, /fa-plus/, 10);
+ });
+
+ it("clicking + icon loads 5 more alert if there's 25 in total and we're showing 16", () => {
+ ValidateLoadButtonAction(25, 1, /fa-plus/, 22, 17);
+ });
+});
diff --git a/ui/src/Components/Grid/AlertGrid/index.js b/ui/src/Components/Grid/AlertGrid/index.js
index 80305e178..6d3cd9c18 100644
--- a/ui/src/Components/Grid/AlertGrid/index.js
+++ b/ui/src/Components/Grid/AlertGrid/index.js
@@ -82,9 +82,7 @@ const AlertGrid = observer(
ref={this.storeMasonryRef}
pack={true}
sizes={GridSizesConfig}
- loadMore={() => {
- this.loadMore();
- }}
+ loadMore={this.loadMore}
hasMore={
this.groupsToRender.value <
Object.keys(alertStore.data.groups).length
diff --git a/ui/src/Components/Grid/AlertGrid/index.test.js b/ui/src/Components/Grid/AlertGrid/index.test.js
new file mode 100644
index 000000000..4f3f7f3e6
--- /dev/null
+++ b/ui/src/Components/Grid/AlertGrid/index.test.js
@@ -0,0 +1,98 @@
+import React from "react";
+
+import { shallow } from "enzyme";
+
+import { MockAlert, MockAlertGroup } from "__mocks__/Alerts.js";
+import { AlertStore } from "Stores/AlertStore";
+import { Settings } from "Stores/Settings";
+import { SilenceFormStore } from "Stores/SilenceFormStore";
+import { AlertGrid } from ".";
+
+let alertStore;
+let settingsStore;
+let silenceFormStore;
+
+beforeEach(() => {
+ alertStore = new AlertStore([]);
+ settingsStore = new Settings();
+ silenceFormStore = new SilenceFormStore();
+});
+
+const ShallowAlertGrid = () => {
+ return shallow(
+
+ );
+};
+
+const MockGroup = (groupName, alertCount) => {
+ let alerts = [];
+ for (let i = 1; i <= alertCount; i++) {
+ alerts.push(MockAlert([], { instance: `instance${i}` }));
+ }
+ const group = MockAlertGroup(
+ { alertname: "Fake Alert", group: groupName },
+ alerts,
+ [],
+ {}
+ );
+ return group;
+};
+
+const MockGroupList = count => {
+ let groups = {};
+ for (let i = 1; i <= count; i++) {
+ let id = `id${i}`;
+ let hash = `hash${i}`;
+ let group = MockGroup(`group${i}`, count);
+ group.id = id;
+ group.hash = hash;
+ groups[id] = group;
+ }
+ alertStore.data.upstreams = {
+ counters: { total: 0, healthy: 1, failed: 0 },
+ instances: [{ name: "am", uri: "http://am", error: "" }]
+ };
+ alertStore.data.groups = groups;
+};
+
+describe("", () => {
+ it("renders only first 50 alert groups", () => {
+ MockGroupList(60);
+ const tree = ShallowAlertGrid();
+ const alertGroups = tree.find("AlertGroup");
+ expect(alertGroups).toHaveLength(50);
+ });
+
+ it("appends 30 groups after loadMore() call", () => {
+ MockGroupList(100);
+ const tree = ShallowAlertGrid();
+ // call it directly, it should happen on scroll to the bottom of the page
+ tree.instance().loadMore();
+ const alertGroups = tree.find("AlertGroup");
+ expect(alertGroups).toHaveLength(80);
+ });
+
+ it("calls masonryRepack() after update`", () => {
+ const tree = ShallowAlertGrid();
+ const instance = tree.instance();
+ const repackSpy = jest.spyOn(instance, "masonryRepack");
+ // it's a shallow render so we don't really have masonry mounted, fake it
+ instance.masonryComponentReference.ref = {
+ forcePack: jest.fn()
+ };
+ instance.componentDidUpdate();
+ expect(repackSpy).toHaveBeenCalled();
+ expect(instance.masonryComponentReference.ref.forcePack).toHaveBeenCalled();
+ });
+
+ it("calling storeMasonryRef() saves the ref in local store", () => {
+ const tree = ShallowAlertGrid();
+ const instance = tree.instance();
+ instance.storeMasonryRef("foo");
+ expect(instance.masonryComponentReference.ref).toBe("foo");
+ });
+});
diff --git a/ui/src/Components/Grid/index.test.js b/ui/src/Components/Grid/index.test.js
new file mode 100644
index 000000000..d2a23fdcb
--- /dev/null
+++ b/ui/src/Components/Grid/index.test.js
@@ -0,0 +1,58 @@
+import React from "react";
+
+import { shallow } from "enzyme";
+
+import { AlertStore } from "Stores/AlertStore";
+import { Settings } from "Stores/Settings";
+import { SilenceFormStore } from "Stores/SilenceFormStore";
+import { Grid } from ".";
+
+let alertStore;
+let settingsStore;
+let silenceFormStore;
+
+beforeEach(() => {
+ alertStore = new AlertStore([]);
+ settingsStore = new Settings();
+ silenceFormStore = new SilenceFormStore();
+});
+
+const ShallowGrid = () => {
+ return shallow(
+
+ );
+};
+
+describe("", () => {
+ it("renders only AlertGrid when all upstreams are healthy", () => {
+ const tree = ShallowGrid();
+ expect(tree.text()).toBe("");
+ });
+
+ it("renders UpstreamError for each unhealthy upstream", () => {
+ alertStore.data.upstreams = {
+ counters: { total: 3, healthy: 1, failed: 2 },
+ instances: [
+ { name: "am1", uri: "http://am1", error: "error 1" },
+ { name: "am2", uri: "file:///mock", error: "" },
+ { name: "am3", uri: "http://am1", error: "error 2" }
+ ]
+ };
+ const tree = ShallowGrid();
+ expect(tree.text()).toBe("");
+ });
+
+ it("renders only FatalError on failed fetch", () => {
+ alertStore.status.error = "error";
+ alertStore.data.upstreams = {
+ counters: { total: 0, healthy: 0, failed: 1 },
+ instances: [{ name: "am", uri: "http://am1", error: "error" }]
+ };
+ const tree = ShallowGrid();
+ expect(tree.text()).toBe("");
+ });
+});
diff --git a/ui/src/index.js b/ui/src/index.js
index 6786560c4..4ba254936 100644
--- a/ui/src/index.js
+++ b/ui/src/index.js
@@ -16,7 +16,9 @@ SetupRaven(settingsElement);
Moment.startPooledTimer();
const defaultFilters = ParseDefaultFilters(settingsElement);
-ReactDOM.render(
+
+// https://wetainment.com/testing-indexjs/
+export default ReactDOM.render(
,
- document.getElementById("root")
+ document.getElementById("root") || document.createElement("div")
);
diff --git a/ui/src/index.test.js b/ui/src/index.test.js
new file mode 100644
index 000000000..be85982c3
--- /dev/null
+++ b/ui/src/index.test.js
@@ -0,0 +1,5 @@
+import Index from "./index.js";
+
+it("renders without crashing", () => {
+ expect(Index).toBeTruthy();
+});
diff --git a/ui/src/setupTests.js b/ui/src/setupTests.js
index a18bc9688..da247227d 100644
--- a/ui/src/setupTests.js
+++ b/ui/src/setupTests.js
@@ -15,6 +15,9 @@ require("jest-localstorage-mock");
// favico.js needs canvas
require("jest-canvas-mock");
+// used to mock current time since we render moment.fromNow() in some places
+require("jest-date-mock");
+
// fetch is used in multiple places to interact with Go backend
// or upstream Alertmanager API
global.fetch = require("jest-fetch-mock");