mirror of
https://github.com/prymitive/karma
synced 2026-05-17 04:16:42 +00:00
Merge pull request #126 from prymitive/silence-preview
feat(ui): preview affected alerts before submitting silence
This commit is contained in:
@@ -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>
|
||||
"
|
||||
`;
|
||||
29
ui/src/Components/Labels/StaticLabel/index.js
Normal file
29
ui/src/Components/Labels/StaticLabel/index.js
Normal 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 };
|
||||
24
ui/src/Components/Labels/StaticLabel/index.test.js
Normal file
24
ui/src/Components/Labels/StaticLabel/index.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
34
ui/src/Components/SilenceModal/Matchers.js
Normal file
34
ui/src/Components/SilenceModal/Matchers.js
Normal 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 };
|
||||
@@ -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>
|
||||
":
|
||||
<span class=\\"json-string\\">
|
||||
"SilencePreview test"
|
||||
"PayloadPreview test"
|
||||
</span>
|
||||
}
|
||||
</pre>
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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 = () => {
|
||||
122
ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.js
Normal file
122
ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.js
Normal 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 };
|
||||
156
ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.test.js
Normal file
156
ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.test.js
Normal 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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
@@ -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>
|
||||
"
|
||||
`;
|
||||
@@ -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>
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
NewEmptyMatcher,
|
||||
MatcherValueToObject
|
||||
} from "Stores/SilenceFormStore";
|
||||
import { SilenceMatch } from "./SilenceMatch";
|
||||
import { SilenceMatch } from ".";
|
||||
|
||||
let silenceFormStore;
|
||||
let matcher;
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
"
|
||||
`;
|
||||
169
ui/src/Components/SilenceModal/SilencePreview/index.js
Normal file
169
ui/src/Components/SilenceModal/SilencePreview/index.js
Normal 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 };
|
||||
120
ui/src/Components/SilenceModal/SilencePreview/index.test.js
Normal file
120
ui/src/Components/SilenceModal/SilencePreview/index.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user