mirror of
https://github.com/prymitive/karma
synced 2026-05-05 03:16:51 +00:00
feat(ui): show number of hits for each matcher in silence form
This commit is contained in:
@@ -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
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
127
ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.js
Normal file
127
ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.js
Normal 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 };
|
||||
115
ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.test.js
Normal file
115
ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.test.js
Normal 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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
"
|
||||
`;
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user