From 57017fa7b9a08965cab04306ce80341b333e227e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Sat, 6 Oct 2018 12:42:07 +0100 Subject: [PATCH] feat(ui): show number of hits for each matcher in silence form --- .../SilenceMatch/LabelValueInput.js | 22 ++- .../SilenceModal/SilenceMatch/MatchCounter.js | 127 ++++++++++++++++++ .../SilenceMatch/MatchCounter.test.js | 115 ++++++++++++++++ .../LabelValueInput.test.js.snap | 12 +- .../__snapshots__/MatchCounter.test.js.snap | 12 ++ ui/src/Stores/AlertStore.js | 15 ++- ui/src/Stores/AlertStore.test.js | 7 + 7 files changed, 303 insertions(+), 7 deletions(-) create mode 100644 ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.js create mode 100644 ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.test.js create mode 100644 ui/src/Components/SilenceModal/SilenceMatch/__snapshots__/MatchCounter.test.js.snap diff --git a/ui/src/Components/SilenceModal/SilenceMatch/LabelValueInput.js b/ui/src/Components/SilenceModal/SilenceMatch/LabelValueInput.js index da6b0d90d..6c0c1be1a 100644 --- a/ui/src/Components/SilenceModal/SilenceMatch/LabelValueInput.js +++ b/ui/src/Components/SilenceModal/SilenceMatch/LabelValueInput.js @@ -4,9 +4,27 @@ import PropTypes from "prop-types"; import { action } from "mobx"; import { observer } from "mobx-react"; +import { components } from "react-select"; + import { SilenceFormMatcher } from "Models/SilenceForm"; import { MultiSelect } from "Components/MultiSelect"; import { ValidationError } from "Components/MultiSelect/ValidationError"; +import { MatchCounter } from "./MatchCounter"; + +const Placeholder = props => { + return ( +
+ +
+ ); +}; + +const ValueContainer = ({ children, ...props }) => ( + + + {children} + +); const LabelValueInput = observer( class LabelValueInput extends MultiSelect { @@ -37,7 +55,9 @@ const LabelValueInput = observer( options: matcher.suggestions.values, placeholder: isValid ? "Label value" : , isMulti: true, - onChange: this.onChange + onChange: this.onChange, + components: { ValueContainer, Placeholder }, + matcher: matcher }; }; } diff --git a/ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.js b/ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.js new file mode 100644 index 000000000..f2ea8b3be --- /dev/null +++ b/ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.js @@ -0,0 +1,127 @@ +import React, { Component } from "react"; + +import { observable, action } from "mobx"; +import { observer } from "mobx-react"; + +import { throttle } from "lodash"; + +import hash from "object-hash"; + +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclamationCircle"; + +import { FormatQuery, QueryOperators } from "Common/Query"; +import { FormatBackendURI, FormatAlertsQ } from "Stores/AlertStore"; +import { SilenceFormMatcher } from "Models/SilenceForm"; + +const MatchCounter = observer( + class MatchCounter extends Component { + static propTypes = { + matcher: SilenceFormMatcher.isRequired + }; + + matchedAlerts = observable( + { + total: 0, + error: null, + fetch: null, + setTotal(value) { + this.total = value; + }, + setError(value) { + this.error = value; + } + }, + { + setTotal: action, + setError: action + } + ); + + onFetch = throttle(() => { + const { matcher } = this.props; + + const filters = []; + + // append current matcher values as a filter + const operator = matcher.isRegex + ? QueryOperators.Regex + : QueryOperators.Equal; + const value = + matcher.values.length > 1 + ? `(${matcher.values.map(v => v.value).join("|")})` + : matcher.values[0].value; + filters.push( + FormatQuery( + matcher.name, + operator, + matcher.isRegex ? `^${value}$` : value + ) + ); + + const alertsURI = + FormatBackendURI("alerts.json?") + FormatAlertsQ(filters); + + this.matchedAlerts.fetch = fetch(alertsURI, { credentials: "include" }) + .then(result => { + return result.json(); + }) + .then(result => { + this.matchedAlerts.setTotal(result.totalAlerts); + this.matchedAlerts.setError(null); + }) + .catch(err => { + console.trace(err); + return this.matchedAlerts.setError(err.message); + }); + }, 300); + + onUpdateCounter = () => { + const { matcher } = this.props; + + if (matcher.name === "" || matcher.values.length === 0) { + this.matchedAlerts.setTotal(0); + this.matchedAlerts.setError(null); + return; + } + + this.onFetch(); + }; + + componentDidMount() { + this.onUpdateCounter(); + } + + componentDidUpdate() { + this.onUpdateCounter(); + } + + render() { + const { matcher } = this.props; + + const matcherHash = hash({ + name: matcher.name, + values: matcher.values, + isRegex: matcher.isRegex + }); + + if (this.matchedAlerts.error !== null) { + return ( + + ); + } + + return ( + + {this.matchedAlerts.total} + + ); + } + } +); + +export { MatchCounter }; diff --git a/ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.test.js b/ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.test.js new file mode 100644 index 000000000..cc272859b --- /dev/null +++ b/ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.test.js @@ -0,0 +1,115 @@ +import React from "react"; + +import { mount } from "enzyme"; + +import toDiffableHtml from "diffable-html"; + +import { NewEmptyMatcher, MatcherValueToObject } from "Stores/SilenceFormStore"; +import { MatchCounter } from "./MatchCounter"; + +let matcher; + +beforeEach(() => { + fetch.resetMocks(); + + matcher = NewEmptyMatcher(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +const MountedMatchCounter = () => { + return mount(); +}; + +describe("", () => { + it("matches snapshot with empty matcher", () => { + const tree = MountedMatchCounter(); + expect(toDiffableHtml(tree.html())).toMatchSnapshot(); + }); + + it("logs a trace on failed fetch", async () => { + const consoleSpy = jest + .spyOn(console, "trace") + .mockImplementation(() => {}); + fetch.mockReject("Fetch error"); + + // we need to set name & value to trigger fetch + matcher.name = "foo"; + matcher.values = [MatcherValueToObject("bar")]; + + const tree = MountedMatchCounter(); + await expect(tree.instance().matchedAlerts.fetch).resolves.toBeUndefined(); + expect(consoleSpy).toHaveBeenCalled(); + }); + + it("renders error icon on failed fetch", async () => { + jest.spyOn(console, "trace").mockImplementation(() => {}); + fetch.mockReject("Fetch error"); + + // we need to set name & value to trigger fetch + matcher.name = "foo"; + matcher.values = [MatcherValueToObject("bar")]; + + const tree = MountedMatchCounter(); + await expect(tree.instance().matchedAlerts.fetch).resolves.toBeUndefined(); + expect(toDiffableHtml(tree.html())).toMatch(/exclamation-circle/); + }); + + it("totalAlerts is 0 after mount", async () => { + const tree = MountedMatchCounter(); + expect(tree.text()).toBe("0"); + }); + + it("updates totalAlerts after successful fetch", async () => { + fetch.mockResponse(JSON.stringify({ totalAlerts: 123 })); + + // we need to set name & value to trigger fetch + matcher.name = "foo"; + matcher.values = [MatcherValueToObject("bar")]; + + const tree = MountedMatchCounter(); + expect(tree.text()).toBe("0"); + await expect(tree.instance().matchedAlerts.fetch).resolves.toBeUndefined(); + expect(tree.text()).toBe("123"); + }); + + it("sends correct query string for a 'foo=bar' matcher", async () => { + fetch.mockResponse(JSON.stringify({ totalAlerts: 0 })); + + matcher.name = "foo"; + matcher.values = [MatcherValueToObject("bar")]; + matcher.isRegex = false; + + const tree = MountedMatchCounter(); + await expect(tree.instance().matchedAlerts.fetch).resolves.toBeUndefined(); + expect(fetch.mock.calls[0][0]).toBe("./alerts.json?q=foo%3Dbar"); + }); + + it("sends correct query string for a 'foo=~bar' matcher", async () => { + fetch.mockResponse(JSON.stringify({ totalAlerts: 0 })); + + matcher.name = "foo"; + matcher.values = [MatcherValueToObject("bar")]; + matcher.isRegex = true; + + const tree = MountedMatchCounter(); + await expect(tree.instance().matchedAlerts.fetch).resolves.toBeUndefined(); + expect(fetch.mock.calls[0][0]).toBe("./alerts.json?q=foo%3D~%5Ebar%24"); + }); + + it("sends correct query string for a 'foo=~(bar|baz)' matcher", async () => { + fetch.mockResponse(JSON.stringify({ totalAlerts: 0 })); + + matcher.name = "foo"; + matcher.values = [MatcherValueToObject("bar"), MatcherValueToObject("baz")]; + matcher.isRegex = true; + + const tree = MountedMatchCounter(); + await expect(tree.instance().matchedAlerts.fetch).resolves.toBeUndefined(); + expect(fetch.mock.calls[0][0]).toBe( + "./alerts.json?q=foo%3D~%5E%28bar%7Cbaz%29%24" + ); + }); +}); diff --git a/ui/src/Components/SilenceModal/SilenceMatch/__snapshots__/LabelValueInput.test.js.snap b/ui/src/Components/SilenceModal/SilenceMatch/__snapshots__/LabelValueInput.test.js.snap index e61f25c13..78194af2b 100644 --- a/ui/src/Components/SilenceModal/SilenceMatch/__snapshots__/LabelValueInput.test.js.snap +++ b/ui/src/Components/SilenceModal/SilenceMatch/__snapshots__/LabelValueInput.test.js.snap @@ -5,8 +5,16 @@ exports[` matches snapshot 1`] = `
-
- Label value + + 0 + +
+
+ Label value +
matches snapshot with empty matcher 1`] = ` +" + + 0 + +" +`; diff --git a/ui/src/Stores/AlertStore.js b/ui/src/Stores/AlertStore.js index 3648e3afd..7bd470fd6 100644 --- a/ui/src/Stores/AlertStore.js +++ b/ui/src/Stores/AlertStore.js @@ -6,16 +6,22 @@ import equal from "fast-deep-equal"; import qs from "qs"; +const QueryStringEncodeOptions = { + encodeValuesOnly: true, // don't encode q[] + indices: false // go-gin doesn't support parsing q[0]=foo&q[1]=bar +}; + +function FormatAlertsQ(filters) { + return qs.stringify({ q: filters }, QueryStringEncodeOptions); +} + // generate URL for the UI with a set of filters function FormatAPIFilterQuery(filters) { return qs.stringify( Object.assign(DecodeLocationSearch(window.location.search).params, { q: filters }), - { - encodeValuesOnly: true, // don't encode q[] - indices: false // go-gin doesn't support parsing q[0]=foo&q[1]=bar - } + QueryStringEncodeOptions ); } @@ -336,6 +342,7 @@ export { AlertStoreStatuses, FormatBackendURI, FormatAPIFilterQuery, + FormatAlertsQ, DecodeLocationSearch, UpdateLocationSearch, NewUnappliedFilter diff --git a/ui/src/Stores/AlertStore.test.js b/ui/src/Stores/AlertStore.test.js index 90e3473a2..a9df5f260 100644 --- a/ui/src/Stores/AlertStore.test.js +++ b/ui/src/Stores/AlertStore.test.js @@ -4,6 +4,7 @@ import { AlertStore, AlertStoreStatuses, FormatBackendURI, + FormatAlertsQ, DecodeLocationSearch, UpdateLocationSearch, NewUnappliedFilter @@ -146,6 +147,12 @@ describe("FormatBackendURI", () => { }); }); +describe("FormatAlertsQ", () => { + it("encodes multiple values without indices", () => { + expect(FormatAlertsQ(["a", "b"])).toBe("q=a&q=b"); + }); +}); + describe("DecodeLocationSearch", () => { const defaultParams = { defaultsUsed: true,