feat(ui): show number of hits for each matcher in silence form

This commit is contained in:
Łukasz Mierzwa
2018-10-06 12:42:07 +01:00
parent e6b9041b01
commit 57017fa7b9
7 changed files with 303 additions and 7 deletions

View File

@@ -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 (
<div>
<components.Placeholder {...props} />
</div>
);
};
const ValueContainer = ({ children, ...props }) => (
<components.ValueContainer {...props}>
<MatchCounter matcher={props.selectProps.matcher} />
{children}
</components.ValueContainer>
);
const LabelValueInput = observer(
class LabelValueInput extends MultiSelect {
@@ -37,7 +55,9 @@ const LabelValueInput = observer(
options: matcher.suggestions.values,
placeholder: isValid ? "Label value" : <ValidationError />,
isMulti: true,
onChange: this.onChange
onChange: this.onChange,
components: { ValueContainer, Placeholder },
matcher: matcher
};
};
}

View File

@@ -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 (
<FontAwesomeIcon className="text-danger" icon={faExclamationCircle} />
);
}
return (
<span
className="badge badge-light badge-pill"
style={{ fontSize: "85%" }}
data-hash={matcherHash}
>
{this.matchedAlerts.total}
</span>
);
}
}
);
export { MatchCounter };

View File

@@ -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(<MatchCounter matcher={matcher} />);
};
describe("<MatchCounter />", () => {
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"
);
});
});

View File

@@ -5,8 +5,16 @@ exports[`<LabelValueInput /> matches snapshot 1`] = `
<div class=\\"css-10nd86i\\">
<div class=\\"css-7jxtyj\\">
<div class=\\"css-10war8y\\">
<div class=\\"css-1492t68\\">
Label value
<span class=\\"badge badge-light badge-pill\\"
style=\\"font-size:85%\\"
data-hash=\\"a970b397347f75d0af6435a66f0b3aa3e2bc0b99\\"
>
0
</span>
<div>
<div class=\\"css-1492t68\\">
Label value
</div>
</div>
<div class=\\"css-1g6gooi\\">
<div class

View File

@@ -0,0 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<MatchCounter /> matches snapshot with empty matcher 1`] = `
"
<span class=\\"badge badge-light badge-pill\\"
style=\\"font-size: 85%;\\"
data-hash=\\"a775f465445363786b29ebd6162e99f6ff0d22b7\\"
>
0
</span>
"
`;

View File

@@ -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

View File

@@ -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,