Merge pull request #126 from prymitive/silence-preview

feat(ui): preview affected alerts before submitting silence
This commit is contained in:
Łukasz Mierzwa
2018-10-06 17:34:21 +01:00
committed by GitHub
38 changed files with 933 additions and 72 deletions

View File

@@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<FilteringLabel /> matches snapshot 1`] = `
"
<span class=\\"components-label text-nowrap text-truncate badge badge-warning mw-100\\">
foo: bar
</span>
"
`;

View File

@@ -0,0 +1,29 @@
import React from "react";
import { inject, observer } from "mobx-react";
import { BaseLabel } from "Components/Labels/BaseLabel";
// Renders a static label element, no click actions, no hover
const StaticLabel = inject("alertStore")(
observer(
class FilteringLabel extends BaseLabel {
render() {
const { name, value } = this.props;
return (
<span
className={`components-label text-nowrap text-truncate badge badge-${this.getColorClass(
name,
value
)} mw-100`}
style={this.getColorStyle(name, value)}
>
{name}: {value}
</span>
);
}
}
)
);
export { StaticLabel };

View File

@@ -0,0 +1,24 @@
import React from "react";
import { mount } from "enzyme";
import toDiffableHtml from "diffable-html";
import { AlertStore } from "Stores/AlertStore";
import { StaticLabel } from ".";
let alertStore;
beforeEach(() => {
alertStore = new AlertStore([]);
});
describe("<FilteringLabel />", () => {
it("matches snapshot", () => {
const tree = mount(
<StaticLabel alertStore={alertStore} name="foo" value="bar" />
);
expect(toDiffableHtml(tree.html())).toMatchSnapshot();
});
});

View File

@@ -6,7 +6,7 @@ import toDiffableHtml from "diffable-html";
import { AlertStore } from "Stores/AlertStore";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import { AlertManagerInput } from "./AlertManagerInput";
import { AlertManagerInput } from ".";
let alertStore;
let silenceFormStore;

View File

@@ -0,0 +1,34 @@
import { FormatQuery, QueryOperators, StaticLabels } from "Common/Query";
const MatcherToFilter = matcher => {
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;
return FormatQuery(
matcher.name,
operator,
matcher.isRegex ? `^${value}$` : value
);
};
const AlertManagersToFilter = alertmanagers => {
if (alertmanagers.length > 1) {
return FormatQuery(
StaticLabels.AlertManager,
QueryOperators.Regex,
`^(${alertmanagers.map(am => am.label).join("|")})$`
);
} else if (alertmanagers.length === 1) {
return FormatQuery(
StaticLabels.AlertManager,
QueryOperators.Equal,
alertmanagers[0].label
);
}
};
export { MatcherToFilter, AlertManagersToFilter };

View File

@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SilencePreview /> matches snapshot 1`] = `
exports[`<PayloadPreview /> matches snapshot 1`] = `
"
<pre class=\\"json-pretty\\">
{
@@ -42,7 +42,7 @@ exports[`<SilencePreview /> matches snapshot 1`] = `
</span>
&quot;:
<span class=\\"json-string\\">
&quot;SilencePreview test&quot;
&quot;PayloadPreview test&quot;
</span>
}
</pre>

View File

@@ -8,8 +8,8 @@ import "react-json-pretty/src/JSONPretty.monikai.css";
import { SilenceFormStore } from "Stores/SilenceFormStore";
const SilencePreview = observer(
class SilencePreview extends Component {
const PayloadPreview = observer(
class PayloadPreview extends Component {
static propTypes = {
silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired
};
@@ -26,4 +26,4 @@ const SilencePreview = observer(
}
);
export { SilencePreview };
export { PayloadPreview };

View File

@@ -7,17 +7,17 @@ import toDiffableHtml from "diffable-html";
import moment from "moment";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import { SilencePreview } from "./SilencePreview";
import { PayloadPreview } from ".";
describe("<SilencePreview />", () => {
describe("<PayloadPreview />", () => {
it("matches snapshot", () => {
const silenceFormStore = new SilenceFormStore();
silenceFormStore.data.startsAt = moment([2000, 1, 1, 0, 0, 0]);
silenceFormStore.data.endsAt = moment([2000, 1, 1, 1, 0, 0]);
silenceFormStore.data.createdBy = "me@example.com";
silenceFormStore.data.comment = "SilencePreview test";
silenceFormStore.data.comment = "PayloadPreview test";
const tree = render(<SilencePreview silenceFormStore={silenceFormStore} />);
const tree = render(<PayloadPreview silenceFormStore={silenceFormStore} />);
expect(toDiffableHtml(tree.html())).toMatchSnapshot();
});
});

View File

@@ -9,17 +9,17 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons/faPlus";
import { faUser } from "@fortawesome/free-solid-svg-icons/faUser";
import { faCommentDots } from "@fortawesome/free-solid-svg-icons/faCommentDots";
import { faUndoAlt } from "@fortawesome/free-solid-svg-icons/faUndoAlt";
import { faSave } from "@fortawesome/free-regular-svg-icons/faSave";
import { faSearch } from "@fortawesome/free-solid-svg-icons/faSearch";
import { faChevronUp } from "@fortawesome/free-solid-svg-icons/faChevronUp";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons/faChevronDown";
import { AlertStore } from "Stores/AlertStore";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import { SilenceFormStore, SilenceFormStage } from "Stores/SilenceFormStore";
import { Settings } from "Stores/Settings";
import { AlertManagerInput } from "./AlertManagerInput";
import { SilenceMatch } from "./SilenceMatch";
import { DateTimeSelect } from "./DateTimeSelect";
import { SilencePreview } from "./SilencePreview";
import { PayloadPreview } from "./PayloadPreview";
const IconInput = ({
type,
@@ -122,7 +122,7 @@ const SilenceForm = observer(
settingsStore.silenceFormConfig.saveAuthor(silenceFormStore.data.author);
if (silenceFormStore.data.isValid)
silenceFormStore.data.inProgress = true;
silenceFormStore.data.currentStage = SilenceFormStage.Preview;
silenceFormStore.data.wasValidated = true;
});
@@ -141,6 +141,7 @@ const SilenceForm = observer(
{silenceFormStore.data.matchers.map(matcher => (
<SilenceMatch
key={matcher.id}
silenceFormStore={silenceFormStore}
matcher={matcher}
onDelete={() => {
silenceFormStore.data.deleteMatcher(matcher.id);
@@ -194,13 +195,13 @@ const SilenceForm = observer(
</button>
)}
<button type="submit" className="btn btn-outline-primary">
<FontAwesomeIcon icon={faSave} className="mr-1" />
Submit
<FontAwesomeIcon icon={faSearch} className="mr-1" />
Preview
</button>
</span>
</div>
{this.previewCollapse.hidden ? null : (
<SilencePreview silenceFormStore={silenceFormStore} />
<PayloadPreview silenceFormStore={silenceFormStore} />
)}
</form>
);

View File

@@ -4,7 +4,11 @@ import { mount, shallow } from "enzyme";
import { AlertStore } from "Stores/AlertStore";
import { Settings } from "Stores/Settings";
import { SilenceFormStore, NewEmptyMatcher } from "Stores/SilenceFormStore";
import {
SilenceFormStore,
SilenceFormStage,
NewEmptyMatcher
} from "Stores/SilenceFormStore";
import { SilenceForm } from "./SilenceForm";
let alertStore;
@@ -94,28 +98,28 @@ describe("<SilenceForm /> matchers", () => {
});
describe("<SilenceForm /> preview", () => {
it("doesn't render SilencePreview when previewCollapse.hidden is true", () => {
it("doesn't render PayloadPreview when previewCollapse.hidden is true", () => {
const tree = ShallowSilenceForm();
const instance = tree.instance();
instance.previewCollapse.hidden = true;
expect(tree.find("SilencePreview")).toHaveLength(0);
expect(tree.find("PayloadPreview")).toHaveLength(0);
});
it("renders SilencePreview when previewCollapse.hidden is false", () => {
it("renders PayloadPreview when previewCollapse.hidden is false", () => {
const tree = ShallowSilenceForm();
const instance = tree.instance();
instance.previewCollapse.hidden = false;
expect(tree.find("SilencePreview")).toHaveLength(1);
expect(tree.find("PayloadPreview")).toHaveLength(1);
});
it("clicking on the toggle icon toggles SilencePreview", () => {
it("clicking on the toggle icon toggles PayloadPreview", () => {
const tree = ShallowSilenceForm();
const button = tree.find(".btn.cursor-pointer.text-muted");
expect(tree.find("SilencePreview")).toHaveLength(0);
expect(tree.find("PayloadPreview")).toHaveLength(0);
button.simulate("click");
expect(tree.find("SilencePreview")).toHaveLength(1);
expect(tree.find("PayloadPreview")).toHaveLength(1);
button.simulate("click");
expect(tree.find("SilencePreview")).toHaveLength(0);
expect(tree.find("PayloadPreview")).toHaveLength(0);
});
});
@@ -152,13 +156,13 @@ describe("<SilenceForm /> inputs", () => {
});
describe("<SilenceForm />", () => {
it("calling submit doesn't mark form as in progress when form is invalid", () => {
it("calling submit doesn't move the form to Preview stage when form is invalid", () => {
const tree = ShallowSilenceForm();
tree.simulate("submit", { preventDefault: jest.fn() });
expect(silenceFormStore.data.inProgress).toBe(false);
expect(silenceFormStore.data.currentStage).toBe(SilenceFormStage.UserInput);
});
it("calling submit marks form as in progress when form is valid", () => {
it("calling submit move form to the 'Preview' stage when form is valid", () => {
const matcher = NewEmptyMatcher();
matcher.name = "job";
matcher.values = [{ label: "node_exporter", value: "node_exporter" }];
@@ -170,7 +174,7 @@ describe("<SilenceForm />", () => {
silenceFormStore.data.comment = "fake silence";
const tree = ShallowSilenceForm();
tree.simulate("submit", { preventDefault: jest.fn() });
expect(silenceFormStore.data.inProgress).toBe(true);
expect(silenceFormStore.data.currentStage).toBe(SilenceFormStage.Preview);
});
it("calling submit saves author value to the Settings store", () => {

View File

@@ -4,13 +4,36 @@ import PropTypes from "prop-types";
import { action } from "mobx";
import { observer } from "mobx-react";
import { components } from "react-select";
import { SilenceFormStore } from "Stores/SilenceFormStore";
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
silenceFormStore={props.selectProps.silenceFormStore}
matcher={props.selectProps.matcher}
/>
{children}
</components.ValueContainer>
);
const LabelValueInput = observer(
class LabelValueInput extends MultiSelect {
static propTypes = {
silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired,
matcher: SilenceFormMatcher.isRequired,
isValid: PropTypes.bool.isRequired
};
@@ -29,7 +52,7 @@ const LabelValueInput = observer(
});
renderProps = () => {
const { matcher, isValid } = this.props;
const { silenceFormStore, matcher, isValid } = this.props;
return {
instanceId: `silence-input-label-value-${matcher.id}`,
@@ -37,7 +60,10 @@ const LabelValueInput = observer(
options: matcher.suggestions.values,
placeholder: isValid ? "Label value" : <ValidationError />,
isMulti: true,
onChange: this.onChange
onChange: this.onChange,
components: { ValueContainer, Placeholder },
silenceFormStore: silenceFormStore,
matcher: matcher
};
};
}

View File

@@ -4,12 +4,18 @@ import { shallow, mount } from "enzyme";
import toDiffableHtml from "diffable-html";
import { NewEmptyMatcher, MatcherValueToObject } from "Stores/SilenceFormStore";
import {
SilenceFormStore,
NewEmptyMatcher,
MatcherValueToObject
} from "Stores/SilenceFormStore";
import { LabelValueInput } from "./LabelValueInput";
let silenceFormStore;
let matcher;
beforeEach(() => {
silenceFormStore = new SilenceFormStore();
matcher = NewEmptyMatcher();
matcher.name = "name";
matcher.suggestions.names = [
@@ -23,11 +29,23 @@ beforeEach(() => {
});
const ShallowLabelValueInput = isValid => {
return shallow(<LabelValueInput matcher={matcher} isValid={isValid} />);
return shallow(
<LabelValueInput
silenceFormStore={silenceFormStore}
matcher={matcher}
isValid={isValid}
/>
);
};
const MountedLabelValueInput = isValid => {
return mount(<LabelValueInput matcher={matcher} isValid={isValid} />);
return mount(
<LabelValueInput
silenceFormStore={silenceFormStore}
matcher={matcher}
isValid={isValid}
/>
);
};
const ValidateSuggestions = () => {

View File

@@ -0,0 +1,122 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
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 { FormatBackendURI, FormatAlertsQ } from "Stores/AlertStore";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import { SilenceFormMatcher } from "Models/SilenceForm";
import { MatcherToFilter, AlertManagersToFilter } from "../Matchers";
const MatchCounter = observer(
class MatchCounter extends Component {
static propTypes = {
silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired,
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 { silenceFormStore, matcher } = this.props;
const filters = [MatcherToFilter(matcher)];
if (silenceFormStore.data.alertmanagers.length) {
filters.push(
AlertManagersToFilter(silenceFormStore.data.alertmanagers)
);
}
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 { silenceFormStore, matcher } = this.props;
if (this.matchedAlerts.error !== null) {
return (
<FontAwesomeIcon className="text-danger" icon={faExclamationCircle} />
);
}
const matcherHash = hash({
alertmanagers: silenceFormStore.data.alertmanagers,
matcher: {
name: matcher.name,
values: matcher.values,
isRegex: matcher.isRegex
}
});
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,156 @@
import React from "react";
import { mount } from "enzyme";
import toDiffableHtml from "diffable-html";
import {
SilenceFormStore,
NewEmptyMatcher,
MatcherValueToObject
} from "Stores/SilenceFormStore";
import { MatchCounter } from "./MatchCounter";
let matcher;
let silenceFormStore;
beforeEach(() => {
fetch.resetMocks();
silenceFormStore = new SilenceFormStore();
matcher = NewEmptyMatcher();
});
afterEach(() => {
jest.restoreAllMocks();
});
const MountedMatchCounter = () => {
return mount(
<MatchCounter silenceFormStore={silenceFormStore} 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"
);
});
it("selecting one Alertmanager instance appends it to the filters", async () => {
fetch.mockResponse(JSON.stringify({ totalAlerts: 0 }));
silenceFormStore.data.alertmanagers = [MatcherValueToObject("am1")];
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&q=%40alertmanager%3Dam1"
);
});
it("selecting two Alertmanager instances appends it correctly to the filters", async () => {
fetch.mockResponse(JSON.stringify({ totalAlerts: 0 }));
silenceFormStore.data.alertmanagers = [
MatcherValueToObject("am1"),
MatcherValueToObject("am1")
];
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&q=%40alertmanager%3D~%5E%28am1%7Cam1%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=\\"76ea01a7b7d0189a690ed2b409ad07a87dbd039c\\"
>
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=\\"2153b6623363af13fa91f1ad35fa4cfc6462d349\\"
>
0
</span>
"
`;

View File

@@ -7,6 +7,7 @@ import { observer } from "mobx-react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrash } from "@fortawesome/free-solid-svg-icons/faTrash";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import { SilenceFormMatcher } from "Models/SilenceForm";
import { LabelNameInput } from "./LabelNameInput";
import { LabelValueInput } from "./LabelValueInput";
@@ -14,6 +15,7 @@ import { LabelValueInput } from "./LabelValueInput";
const SilenceMatch = observer(
class SilenceMatch extends Component {
static propTypes = {
silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired,
matcher: SilenceFormMatcher.isRequired,
showDelete: PropTypes.bool.isRequired,
onDelete: PropTypes.func.isRequired,
@@ -30,7 +32,13 @@ const SilenceMatch = observer(
});
render() {
const { matcher, showDelete, onDelete, isValid } = this.props;
const {
silenceFormStore,
matcher,
showDelete,
onDelete,
isValid
} = this.props;
return (
<div className="d-flex flex-fill flex-lg-row flex-column mb-3">
@@ -38,7 +46,11 @@ const SilenceMatch = observer(
<LabelNameInput matcher={matcher} isValid={isValid} />
</div>
<div className="flex-shrink-0 flex-grow-0 flex-basis-50 pr-lg-2 pb-2 pb-lg-0">
<LabelValueInput matcher={matcher} isValid={isValid} />
<LabelValueInput
silenceFormStore={silenceFormStore}
matcher={matcher}
isValid={isValid}
/>
</div>
<div className="flex-shrink-0 flex-grow-1 flex-basis-auto form-check form-check-inline d-flex justify-content-between m-0">
<span>

View File

@@ -7,7 +7,7 @@ import {
NewEmptyMatcher,
MatcherValueToObject
} from "Stores/SilenceFormStore";
import { SilenceMatch } from "./SilenceMatch";
import { SilenceMatch } from ".";
let silenceFormStore;
let matcher;

View File

@@ -7,11 +7,12 @@ import { observer } from "mobx-react";
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
import { AlertStore } from "Stores/AlertStore";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import { SilenceFormStore, SilenceFormStage } from "Stores/SilenceFormStore";
import { Settings } from "Stores/Settings";
import { MountModalBackdrop } from "Components/Animations/MountModal";
import { SilenceForm } from "./SilenceForm";
import { SilenceSubmitController } from "./SilenceSubmitController";
import { SilencePreview } from "./SilencePreview";
import { SilenceSubmitController } from "./SilenceSubmit/SilenceSubmitController";
const SilenceModalContent = observer(
class SilenceModalContent extends Component {
@@ -46,7 +47,13 @@ const SilenceModalContent = observer(
<div className="modal-header">
<h5 className="modal-title">
{silenceFormStore.data.silenceID === null
? "Add new silence"
? silenceFormStore.data.currentStage ===
SilenceFormStage.UserInput
? "Add new silence"
: silenceFormStore.data.currentStage ===
SilenceFormStage.Preview
? "Preview silenced alerts"
: "Silence submitted"
: `Editing silence ${silenceFormStore.data.silenceID}`}
</h5>
<button type="button" className="close" onClick={onHide}>
@@ -54,17 +61,24 @@ const SilenceModalContent = observer(
</button>
</div>
<div className="modal-body">
{silenceFormStore.data.inProgress ? (
<SilenceSubmitController
alertStore={alertStore}
silenceFormStore={silenceFormStore}
/>
) : (
{silenceFormStore.data.currentStage ===
SilenceFormStage.UserInput ? (
<SilenceForm
alertStore={alertStore}
silenceFormStore={silenceFormStore}
settingsStore={settingsStore}
/>
) : silenceFormStore.data.currentStage ===
SilenceFormStage.Preview ? (
<SilencePreview
alertStore={alertStore}
silenceFormStore={silenceFormStore}
/>
) : (
<SilenceSubmitController
alertStore={alertStore}
silenceFormStore={silenceFormStore}
/>
)}
</div>
</div>

View File

@@ -4,7 +4,7 @@ import { shallow } from "enzyme";
import { AlertStore } from "Stores/AlertStore";
import { Settings } from "Stores/Settings";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import { SilenceFormStore, SilenceFormStage } from "Stores/SilenceFormStore";
import { SilenceModalContent } from "./SilenceModalContent";
let alertStore;
@@ -31,15 +31,22 @@ const ShallowSilenceModalContent = () => {
};
describe("<SilenceModalContent />", () => {
it("renders SilenceForm when silenceFormStore.data.inProgress is false", () => {
silenceFormStore.data.inProgress = false;
it("renders SilenceForm when silenceFormStore.data.currentStage is 'UserInput'", () => {
silenceFormStore.data.currentStage = SilenceFormStage.UserInput;
const tree = ShallowSilenceModalContent();
const form = tree.find("SilenceForm");
expect(form).toHaveLength(1);
});
it("renders SilenceSubmitController when silenceFormStore.data.inProgress is true", () => {
silenceFormStore.data.inProgress = true;
it("renders SilencePreview when silenceFormStore.data.currentStage is 'Preview'", () => {
silenceFormStore.data.currentStage = SilenceFormStage.Preview;
const tree = ShallowSilenceModalContent();
const ctrl = tree.find("SilencePreview");
expect(ctrl).toHaveLength(1);
});
it("renders SilenceSubmitController when silenceFormStore.data.currentStage is 'Submit'", () => {
silenceFormStore.data.currentStage = SilenceFormStage.Submit;
const tree = ShallowSilenceModalContent();
const ctrl = tree.find("SilenceSubmitController");
expect(ctrl).toHaveLength(1);

View File

@@ -0,0 +1,52 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SilencePreview /> matches snapshot 1`] = `
"
<div class=\\"mb-2\\">
<p class=\\"lead text-center\\">
This silence will match
<strong>
3
</strong>
alerts
</p>
<div>
<ul class=\\"list-group list-group-flush px-2\\">
<li class=\\"list-group-item px-2 pt-2 pb-1\\">
<span class=\\"components-label text-nowrap text-truncate badge badge-dark mw-100\\">
alertname: foo
</span>
<span class=\\"components-label text-nowrap text-truncate badge badge-warning mw-100\\">
job: foo
</span>
<span class=\\"components-label text-nowrap text-truncate badge badge-warning mw-100\\">
instance: foo1
</span>
</li>
<li class=\\"list-group-item px-2 pt-2 pb-1\\">
<span class=\\"components-label text-nowrap text-truncate badge badge-dark mw-100\\">
alertname: bar
</span>
<span class=\\"components-label text-nowrap text-truncate badge badge-warning mw-100\\">
job: bar
</span>
<span class=\\"components-label text-nowrap text-truncate badge badge-warning mw-100\\">
instance: bar1
</span>
</li>
<li class=\\"list-group-item px-2 pt-2 pb-1\\">
<span class=\\"components-label text-nowrap text-truncate badge badge-dark mw-100\\">
alertname: bar
</span>
<span class=\\"components-label text-nowrap text-truncate badge badge-warning mw-100\\">
job: bar
</span>
<span class=\\"components-label text-nowrap text-truncate badge badge-warning mw-100\\">
instance: bar2
</span>
</li>
</ul>
</div>
</div>
"
`;

View File

@@ -0,0 +1,169 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import { observable, action } from "mobx";
import { observer } from "mobx-react";
import hash from "object-hash";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowLeft } from "@fortawesome/free-solid-svg-icons/faArrowLeft";
import { faCheckCircle } from "@fortawesome/free-solid-svg-icons/faCheckCircle";
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclamationCircle";
import { AlertStore, FormatBackendURI, FormatAlertsQ } from "Stores/AlertStore";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import { StaticLabel } from "Components/Labels/StaticLabel";
import { MatcherToFilter, AlertManagersToFilter } from "../Matchers";
const FetchError = ({ message }) => (
<div className="text-center">
<h2 className="display-2 text-danger">
<FontAwesomeIcon icon={faExclamationCircle} />
</h2>
<p className="lead text-muted">{message}</p>
</div>
);
FetchError.propTypes = {
message: PropTypes.node.isRequired
};
const Preview = ({ alertStore, labelsList }) => (
<ul className="list-group list-group-flush px-2">
{labelsList.map(labels => (
<li key={hash(labels)} className="list-group-item px-2 pt-2 pb-1">
{Object.entries(labels).map(([name, value]) => (
<StaticLabel
key={name}
alertStore={alertStore}
name={name}
value={value}
/>
))}
</li>
))}
</ul>
);
Preview.propTypes = {
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
labelsList: PropTypes.arrayOf(PropTypes.object).isRequired
};
const SilencePreview = observer(
class SilencePreview extends Component {
static propTypes = {
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired
};
matchedAlerts = observable(
{
alertLabels: [],
error: null,
fetch: null,
groupsToUniqueLabels(groups) {
const alerts = {};
for (const group of groups) {
for (const alert of group.alerts) {
const alertLabels = Object.assign(
{},
group.labels,
group.shared.labels,
alert.labels
);
const alertHash = hash(alertLabels);
alerts[alertHash] = alertLabels;
}
}
this.alertLabels = Object.values(alerts);
},
setError(value) {
this.error = value;
}
},
{
groupsToUniqueLabels: action,
setError: action
}
);
onFetch = () => {
const { silenceFormStore } = this.props;
const filters = [
...silenceFormStore.data.matchers.map(m => MatcherToFilter(m)),
AlertManagersToFilter(silenceFormStore.data.alertmanagers)
];
const alertsURI =
FormatBackendURI("alerts.json?") + FormatAlertsQ(filters);
this.matchedAlerts.fetch = fetch(alertsURI, { credentials: "include" })
.then(result => {
return result.json();
})
.then(result => {
this.matchedAlerts.groupsToUniqueLabels(Object.values(result.groups));
this.matchedAlerts.setError(null);
})
.catch(err => {
console.trace(err);
return this.matchedAlerts.setError(
`Request failed with: ${err.message}`
);
});
};
componentDidMount() {
this.onFetch();
}
render() {
const { alertStore, silenceFormStore } = this.props;
return (
<React.Fragment>
<div className="mb-2">
{this.matchedAlerts.error !== null ? (
<FetchError message={this.matchedAlerts.error} />
) : (
<React.Fragment>
<p className="lead text-center">
This silence will match{" "}
<strong>{this.matchedAlerts.alertLabels.length}</strong> alert
{this.matchedAlerts.alertLabels.length > 1 ? "s" : ""}
</p>
<div>
<Preview
alertStore={alertStore}
labelsList={this.matchedAlerts.alertLabels}
/>
</div>
</React.Fragment>
)}
</div>
<div className="d-flex flex-row-reverse">
<button
type="button"
className="btn btn-outline-primary"
onClick={silenceFormStore.data.setStageSubmit}
>
<FontAwesomeIcon icon={faCheckCircle} className="pr-1" />
Submit
</button>
<button
type="button"
className="btn btn-outline-secondary mr-2"
onClick={silenceFormStore.data.resetProgress}
>
<FontAwesomeIcon icon={faArrowLeft} className="pr-1" />
Back
</button>
</div>
</React.Fragment>
);
}
}
);
export { SilencePreview };

View File

@@ -0,0 +1,120 @@
import React from "react";
import { mount } from "enzyme";
import toDiffableHtml from "diffable-html";
import { EmptyAPIResponse } from "__mocks__/Fetch";
import { MockAlertGroup, MockAlert } from "__mocks__/Alerts";
import { AlertStore } from "Stores/AlertStore";
import {
SilenceFormStore,
SilenceFormStage,
NewEmptyMatcher,
MatcherValueToObject
} from "Stores/SilenceFormStore";
import { SilencePreview } from ".";
let alertStore;
let silenceFormStore;
beforeEach(() => {
fetch.resetMocks();
alertStore = new AlertStore([]);
const matcher = NewEmptyMatcher();
matcher.name = "foo";
matcher.values = [MatcherValueToObject("bar")];
silenceFormStore = new SilenceFormStore();
silenceFormStore.data.matchers = [matcher];
});
afterEach(() => {
jest.restoreAllMocks();
});
const MockAPIResponse = () => {
const response = EmptyAPIResponse();
response.groups = {
"1": MockAlertGroup(
{ alertname: "foo" },
[MockAlert([], { instance: "foo1" }, "active")],
[],
{ job: "foo" }
),
"2": MockAlertGroup(
{ alertname: "bar" },
[
MockAlert([], { instance: "bar1" }, "active"),
MockAlert([], { instance: "bar2" }, "active")
],
[],
{ job: "bar" }
)
};
return response;
};
const MountedSilencePreview = () => {
return mount(
<SilencePreview
alertStore={alertStore}
silenceFormStore={silenceFormStore}
/>
);
};
describe("<SilencePreview />", () => {
it("fetches matching alerts on mount", async () => {
fetch.mockResponse(JSON.stringify(MockAPIResponse()));
const tree = MountedSilencePreview();
await expect(tree.instance().matchedAlerts.fetch).resolves.toBeUndefined();
expect(fetch).toHaveBeenCalled();
});
it("matches snapshot", async () => {
fetch.mockResponse(JSON.stringify(MockAPIResponse()));
const tree = MountedSilencePreview();
await expect(tree.instance().matchedAlerts.fetch).resolves.toBeUndefined();
expect(toDiffableHtml(tree.html())).toMatchSnapshot();
});
it("renders FetchError on failed fetch", async () => {
const consoleSpy = jest
.spyOn(console, "trace")
.mockImplementation(() => {});
fetch.mockReject("Fetch error");
const tree = MountedSilencePreview();
await expect(tree.instance().matchedAlerts.fetch).resolves.toBeUndefined();
tree.update();
expect(tree.find("FetchError")).toHaveLength(1);
expect(consoleSpy).toHaveBeenCalled();
expect(tree.find("Preview")).toHaveLength(0);
});
it("renders Preview on successful fetch", async () => {
fetch.mockResponse(JSON.stringify(MockAPIResponse()));
const tree = MountedSilencePreview();
await expect(tree.instance().matchedAlerts.fetch).resolves.toBeUndefined();
tree.update();
expect(tree.find("FetchError")).toHaveLength(0);
expect(tree.find("Preview")).toHaveLength(1);
});
it("clicking on the submit button moves form to the 'Submit' stage", () => {
fetch.mockResponse(JSON.stringify(MockAPIResponse()));
const tree = MountedSilencePreview();
const button = tree.find(".btn-outline-primary");
button.simulate("click");
expect(silenceFormStore.data.currentStage).toBe(SilenceFormStage.Submit);
});
});

View File

@@ -5,6 +5,7 @@ import { shallow } from "enzyme";
import { AlertStore } from "Stores/AlertStore";
import {
SilenceFormStore,
SilenceFormStage,
MatcherValueToObject
} from "Stores/SilenceFormStore";
import { SilenceSubmitController } from "./SilenceSubmitController";
@@ -36,10 +37,10 @@ describe("<SilenceSubmitController />", () => {
});
it("resets the form on 'Back' button click", () => {
silenceFormStore.data.inProgress = true;
silenceFormStore.data.currentStage = SilenceFormStage.Submit;
const tree = ShallowSilenceSubmitController();
const button = tree.find("button");
button.simulate("click");
expect(silenceFormStore.data.inProgress).toBe(false);
expect(silenceFormStore.data.currentStage).toBe(SilenceFormStage.UserInput);
});
});

View File

@@ -4,7 +4,12 @@ import { mount } from "enzyme";
import { AlertStore } from "Stores/AlertStore";
import { Settings } from "Stores/Settings";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import {
SilenceFormStore,
SilenceFormStage,
NewEmptyMatcher,
MatcherValueToObject
} from "Stores/SilenceFormStore";
import { SilenceModal } from ".";
let alertStore;
@@ -100,12 +105,17 @@ describe("<SilenceModal />", () => {
expect(document.body.className.split(" ")).not.toContain("modal-open");
});
it("inProgress is set to false after modal is hidden", () => {
it("currentStage is set to 'UserInput' after modal is hidden", () => {
const matcher = NewEmptyMatcher();
matcher.name = "foo";
matcher.values = [MatcherValueToObject("bar")];
silenceFormStore.data.matchers = [matcher];
silenceFormStore.toggle.visible = true;
const tree = MountedSilenceModal();
silenceFormStore.data.inProgress = true;
silenceFormStore.data.currentStage = SilenceFormStage.Preview;
const toggle = tree.find("button.close");
toggle.simulate("click");
expect(silenceFormStore.data.inProgress).toBe(false);
expect(silenceFormStore.data.currentStage).toBe(SilenceFormStage.UserInput);
});
});

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,

View File

@@ -19,6 +19,12 @@ const NewEmptyMatcher = () => {
const MatcherValueToObject = value => ({ label: value, value: value });
const SilenceFormStage = Object.freeze({
UserInput: "form",
Preview: "preview",
Submit: "submit"
});
class SilenceFormStore {
// this is used to store modal visibility toggle
toggle = observable(
@@ -43,7 +49,7 @@ class SilenceFormStore {
// this form from that alert so user can easily silence that alert
data = observable(
{
inProgress: false,
currentStage: SilenceFormStage.UserInput,
wasValidated: false,
silenceID: null,
alertmanagers: [],
@@ -76,7 +82,7 @@ class SilenceFormStore {
},
resetProgress() {
this.inProgress = false;
this.currentStage = SilenceFormStage.UserInput;
this.wasValidated = false;
},
@@ -84,6 +90,10 @@ class SilenceFormStore {
this.silenceID = null;
},
setStageSubmit() {
this.currentStage = SilenceFormStage.Submit;
},
// append a new empty matcher to the list
addEmptyMatcher() {
let m = NewEmptyMatcher();
@@ -235,6 +245,7 @@ class SilenceFormStore {
resetStartEnd: action.bound,
resetProgress: action.bound,
resetSilenceID: action.bound,
setStageSubmit: action.bound,
addEmptyMatcher: action.bound,
deleteMatcher: action.bound,
fillMatchersFromGroup: action.bound,
@@ -252,4 +263,9 @@ class SilenceFormStore {
);
}
export { SilenceFormStore, NewEmptyMatcher, MatcherValueToObject };
export {
SilenceFormStore,
SilenceFormStage,
NewEmptyMatcher,
MatcherValueToObject
};

View File

@@ -6,7 +6,11 @@ import {
MockSilence,
MockAlertmanager
} from "__mocks__/Alerts.js";
import { SilenceFormStore, NewEmptyMatcher } from "./SilenceFormStore";
import {
SilenceFormStore,
SilenceFormStage,
NewEmptyMatcher
} from "./SilenceFormStore";
let store;
beforeEach(() => {
@@ -72,11 +76,10 @@ describe("SilenceFormStore.data", () => {
expect(store.data.endsAt.isSame([2000, 1, 1], "day")).toBe(false);
});
it("resetProgress() sets 'inProgress' to false", () => {
store.data.inProgress = true;
expect(store.data.inProgress).toBe(true);
it("resetProgress() sets currentStage to UserInput", () => {
store.data.currentStage = SilenceFormStage.Submit;
store.data.resetProgress();
expect(store.data.inProgress).toBe(false);
expect(store.data.currentStage).toBe(SilenceFormStage.UserInput);
});
it("resetProgress() sets 'wasValidated' to false", () => {