Merge pull request #133 from prymitive/silence-delete-form

feat(ui): allow deleting silences (with preview)
This commit is contained in:
Łukasz Mierzwa
2018-10-09 07:08:26 +01:00
committed by GitHub
33 changed files with 1085 additions and 678 deletions

View File

@@ -85,10 +85,10 @@ func TestDedupAutocomplete(t *testing.T) {
ac := alertmanager.DedupAutocomplete()
// since we have alertmanager instance per mock adding new mocks will increase
// the number of hints, so we need to calculate the expected value here
// there should be 56 hints excluding @alertmanager ones, use that as our base
// there should be 156 hints excluding @alertmanager ones, use that as our base
// and add 2 hints per alertmanager instance (= and != hints)
mockCount := len(mock.ListAllMockURIs())
expected := 56 + mockCount*2
expected := 156 + mockCount*2
if len(ac) != expected {
t.Errorf("Expected %d autocomplete hints, got %d", expected, len(ac))
}

View File

@@ -11,6 +11,7 @@ import (
"time"
"github.com/prymitive/karma/internal/config"
"github.com/prymitive/karma/internal/filters"
"github.com/prymitive/karma/internal/mapper"
"github.com/prymitive/karma/internal/models"
"github.com/prymitive/karma/internal/transform"
@@ -270,7 +271,7 @@ func (am *Alertmanager) pullAlerts(version string) error {
alerts = append(alerts, alert)
}
for _, hint := range transform.BuildAutocomplete(alerts) {
for _, hint := range filters.BuildAutocomplete(alerts) {
autocompleteMap[hint.Value] = hint
}

View File

@@ -14,3 +14,21 @@ func makeAC(value string, tokens []string) models.Autocomplete {
acHint.Tokens = append(acHint.Tokens, value)
return acHint
}
// BuildAutocomplete takes an alert object and generates list of autocomplete
// strings for it
func BuildAutocomplete(alerts []models.Alert) []models.Autocomplete {
acHints := map[string]models.Autocomplete{}
for _, filterConfig := range AllFilters {
if filterConfig.Autocomplete != nil {
for _, hint := range filterConfig.Autocomplete(filterConfig.Label, filterConfig.SupportedOperators, alerts) {
acHints[hint.Value] = hint
}
}
}
acHintsSlice := []models.Autocomplete{}
for _, hint := range acHints {
acHintsSlice = append(acHintsSlice, hint)
}
return acHintsSlice
}

View File

@@ -1,12 +1,12 @@
package transform_test
package filters_test
import (
"encoding/json"
"sort"
"testing"
"github.com/prymitive/karma/internal/filters"
"github.com/prymitive/karma/internal/models"
"github.com/prymitive/karma/internal/transform"
"github.com/pmezard/go-difflib/difflib"
)
@@ -54,7 +54,7 @@ var acTests = []acTest{
func TestBuildAutocomplete(t *testing.T) {
for _, acTest := range acTests {
result := []string{}
for _, hint := range transform.BuildAutocomplete(acTest.Alerts) {
for _, hint := range filters.BuildAutocomplete(acTest.Alerts) {
result = append(result, hint.Value)
}

View File

@@ -43,7 +43,7 @@ func newSilenceAuthorFilter() FilterT {
return &f
}
func sinceAuthorAutocomplete(name string, operators []string, alerts []models.Alert) []models.Autocomplete {
func silenceAuthorAutocomplete(name string, operators []string, alerts []models.Alert) []models.Autocomplete {
tokens := map[string]models.Autocomplete{}
for _, alert := range alerts {
if alert.IsSilenced() {

View File

@@ -0,0 +1,63 @@
package filters
import (
"fmt"
"strings"
"github.com/prymitive/karma/internal/models"
)
type silenceIDFilter struct {
alertFilter
}
func (filter *silenceIDFilter) Match(alert *models.Alert, matches int) bool {
if filter.IsValid {
var isMatch bool
if alert.IsSilenced() {
for _, silenceID := range alert.SilencedBy {
m := filter.Matcher.Compare(silenceID, filter.Value)
if m {
isMatch = m
}
}
} else {
isMatch = filter.Matcher.Compare("", filter.Value)
}
if isMatch {
filter.Hits++
}
return isMatch
}
e := fmt.Sprintf("Match() called on invalid filter %#v", filter)
panic(e)
}
func newsilenceIDFilter() FilterT {
f := silenceIDFilter{}
return &f
}
func silenceIDAutocomplete(name string, operators []string, alerts []models.Alert) []models.Autocomplete {
tokens := map[string]models.Autocomplete{}
for _, alert := range alerts {
if alert.IsSilenced() {
for _, silenceID := range alert.SilencedBy {
for _, operator := range operators {
token := fmt.Sprintf("%s%s%s", name, operator, silenceID)
tokens[token] = makeAC(token, []string{
name,
strings.TrimPrefix(name, "@"),
fmt.Sprintf("%s%s", name, operator),
silenceID,
})
}
}
}
}
acData := []models.Autocomplete{}
for _, token := range tokens {
acData = append(acData, token)
}
return acData
}

View File

@@ -43,7 +43,7 @@ func newSilenceJiraFilter() FilterT {
return &f
}
func sinceJiraIDAutocomplete(name string, operators []string, alerts []models.Alert) []models.Autocomplete {
func silenceJiraIDAutocomplete(name string, operators []string, alerts []models.Alert) []models.Autocomplete {
tokens := map[string]models.Autocomplete{}
for _, alert := range alerts {
if alert.IsSilenced() {

View File

@@ -84,6 +84,67 @@ var tests = []filterTest{
IsMatch: false,
},
filterTest{
Expression: "@silence_id=abcdef",
IsValid: true,
Alert: models.Alert{State: "suppressed", SilencedBy: []string{"1"}},
IsMatch: false,
},
filterTest{
Expression: "@silence_id=abcdef",
IsValid: true,
Alert: models.Alert{State: "active"},
IsMatch: false,
},
filterTest{
Expression: "@silence_id=abcdef",
IsValid: true,
Alert: models.Alert{State: "active", SilencedBy: []string{"abcdef"}},
IsMatch: false,
},
filterTest{
Expression: "@silence_id=abcdef",
IsValid: true,
Alert: models.Alert{State: "unprocessed"},
IsMatch: false,
},
filterTest{
Expression: "@silence_id=abcdef",
IsValid: true,
Alert: models.Alert{State: "unprocessed", SilencedBy: []string{"abcdef"}},
IsMatch: false,
},
filterTest{
Expression: "@silence_id=abcdef",
IsValid: true,
Alert: models.Alert{State: "suppressed", SilencedBy: []string{"abcdef"}},
IsMatch: true,
},
filterTest{
Expression: "@silence_id!=abcdef",
IsValid: true,
Alert: models.Alert{State: "suppressed", SilencedBy: []string{"abcdef"}},
IsMatch: false,
},
filterTest{
Expression: "@silence_id!=abcdef",
IsValid: true,
Alert: models.Alert{State: "suppressed", SilencedBy: []string{"1"}},
IsMatch: true,
},
filterTest{
Expression: "@silence_id=~cde",
IsValid: false,
Alert: models.Alert{State: "suppressed", SilencedBy: []string{"abcdef"}},
IsMatch: false,
},
filterTest{
Expression: "@silence_id!~abc",
IsValid: false,
Alert: models.Alert{State: "suppressed", SilencedBy: []string{"zwd"}},
IsMatch: false,
},
filterTest{
Expression: "@silence_jira=1",
IsValid: true,

View File

@@ -68,19 +68,26 @@ var AllFilters = []filterConfig{
Factory: newAgeFilter,
Autocomplete: ageAutocomplete,
},
filterConfig{
Label: "@silence_id",
LabelRe: regexp.MustCompile("^@silence_id$"),
SupportedOperators: []string{equalOperator, notEqualOperator},
Factory: newsilenceIDFilter,
Autocomplete: silenceIDAutocomplete,
},
filterConfig{
Label: "@silence_jira",
LabelRe: regexp.MustCompile("^@silence_jira$"),
SupportedOperators: []string{regexpOperator, negativeRegexOperator, equalOperator, notEqualOperator},
Factory: newSilenceJiraFilter,
Autocomplete: sinceJiraIDAutocomplete,
Autocomplete: silenceJiraIDAutocomplete,
},
filterConfig{
Label: "@silence_author",
LabelRe: regexp.MustCompile("^@silence_author$"),
SupportedOperators: []string{regexpOperator, negativeRegexOperator, equalOperator, notEqualOperator},
Factory: newSilenceAuthorFilter,
Autocomplete: sinceAuthorAutocomplete,
Autocomplete: silenceAuthorAutocomplete,
},
filterConfig{
Label: "@limit",

View File

@@ -1,24 +0,0 @@
package transform
import (
"github.com/prymitive/karma/internal/filters"
"github.com/prymitive/karma/internal/models"
)
// BuildAutocomplete takes an alert object and generates list of autocomplete
// strings for it
func BuildAutocomplete(alerts []models.Alert) []models.Autocomplete {
acHints := map[string]models.Autocomplete{}
for _, filterConfig := range filters.AllFilters {
if filterConfig.Autocomplete != nil {
for _, hint := range filterConfig.Autocomplete(filterConfig.Label, filterConfig.SupportedOperators, alerts) {
acHints[hint.Value] = hint
}
}
}
acHintsSlice := []models.Autocomplete{}
for _, hint := range acHints {
acHintsSlice = append(acHintsSlice, hint)
}
return acHintsSlice
}

View File

@@ -7,7 +7,8 @@ const StaticLabels = Object.freeze({
AlertName: "alertname",
AlertManager: "@alertmanager",
Receiver: "@receiver",
State: "@state"
State: "@state",
SilenceID: "@silence_id"
});
function FormatQuery(name, operator, value) {

View File

@@ -0,0 +1,259 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import { observable, action } from "mobx";
import { observer } from "mobx-react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrash } from "@fortawesome/free-solid-svg-icons/faTrash";
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclamationCircle";
import { faCheckCircle } from "@fortawesome/free-solid-svg-icons/faCheckCircle";
import { APIAlertmanagerUpstream } from "Models/API";
import { AlertStore, FormatBackendURI, FormatAlertsQ } from "Stores/AlertStore";
import { FormatQuery, QueryOperators, StaticLabels } from "Common/Query";
import { Modal } from "Components/Modal";
import {
LabelSetList,
GroupListToUniqueLabelsList
} from "Components/LabelSetList";
const ErrorMessage = ({ message }) => (
<div className="text-center">
<FontAwesomeIcon
icon={faExclamationCircle}
className="text-danger display-1 mb-3"
/>
<p>{message}</p>
</div>
);
ErrorMessage.propTypes = {
message: PropTypes.node.isRequired
};
const SuccessMessage = () => (
<div className="text-center">
<FontAwesomeIcon
icon={faCheckCircle}
className="text-success display-1 mb-3"
/>
<p>
Silence deleted, it might take a few minutes for affected alerts to change
state
</p>
</div>
);
const DeleteSilenceModalContent = observer(
class DeleteSilenceModalContent extends Component {
static propTypes = {
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
alertmanager: APIAlertmanagerUpstream.isRequired,
silenceID: PropTypes.string.isRequired,
onHide: PropTypes.func.isRequired
};
previewState = observable(
{
fetch: null,
error: null,
alertLabels: [],
setError(err) {
this.error = err;
},
groupsToUniqueLabels(groups) {
this.alertLabels = GroupListToUniqueLabelsList(groups);
}
},
{
setError: action.bound,
groupsToUniqueLabels: action.bound
}
);
deleteState = observable(
{
fetch: null,
done: false,
error: null,
setDone() {
this.done = true;
},
setError(err) {
this.error = err;
}
},
{
setDone: action.bound,
setError: action.bound
}
);
parseAlertmanagerResponse = response => {
/*
{"status": "success"}
or
{
"status": "error",
"errorType": "bad_data",
"error": "silence 706959fd-4590-4e21-b983-859ba6ec0e1a already expired"
}
*/
if (response.status === "success") {
this.deleteState.setError(null);
} else if (response.status === "error" && response.error) {
this.deleteState.setError(response.error);
} else {
this.deleteState.setError(JSON.stringify(response));
}
this.deleteState.setDone();
};
onFetchPreview = () => {
const { silenceID } = this.props;
const alertsURI =
FormatBackendURI("alerts.json?") +
FormatAlertsQ([
FormatQuery(StaticLabels.SilenceID, QueryOperators.Equal, silenceID)
]);
this.previewState.fetch = fetch(alertsURI, { credentials: "include" })
.then(result => {
return result.json();
})
.then(result => {
this.previewState.groupsToUniqueLabels(Object.values(result.groups));
this.previewState.setError(null);
})
.catch(err => {
console.trace(err);
return this.previewState.setError(
`Request fetching affected alerts failed with: ${err.message}`
);
});
};
onDelete = () => {
const { alertmanager, silenceID } = this.props;
// if it's already deleted then do nothing
if (this.deleteState.done && this.deleteState.error === null) return;
const uri = `${alertmanager.publicURI}/api/v1/silence/${silenceID}`;
this.deleteState.fetch = fetch(uri, { method: "DELETE" })
.then(result => result.json())
.then(result => this.parseAlertmanagerResponse(result))
.catch(err => {
console.trace(err);
this.deleteState.setDone();
this.deleteState.setError(
`Delete request failed with: ${err.message}`
);
});
};
componentDidMount() {
this.onFetchPreview();
}
render() {
const { alertStore, onHide } = this.props;
const isDone = this.deleteState.done && this.deleteState.error === null;
return (
<React.Fragment>
<div className="modal-header">
<h5 className="modal-title">Delete silence</h5>
<button type="button" className="close" onClick={onHide}>
<span>&times;</span>
</button>
</div>
<div className="modal-body">
{this.deleteState.done ? (
this.deleteState.error !== null ? (
<ErrorMessage message={this.deleteState.error} />
) : (
<SuccessMessage />
)
) : this.previewState.error === null ? (
<div>
<p className="lead text-center">
Alerts affected by this silence
</p>
<LabelSetList
alertStore={alertStore}
labelsList={this.previewState.alertLabels}
/>
</div>
) : (
<ErrorMessage message={this.previewState.error} />
)}
{isDone ? null : (
<div className="d-flex flex-row-reverse">
<button
type="button"
className="btn btn-outline-danger mr-2"
onClick={this.onDelete}
>
<FontAwesomeIcon icon={faCheckCircle} className="mr-1" />
{this.deleteState.fetch !== null &&
this.deleteState.error !== null
? "Retry"
: "Confirm"}
</button>
</div>
)}
</div>
</React.Fragment>
);
}
}
);
const DeleteSilence = observer(
class DeleteSilence extends Component {
static propTypes = {
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
alertmanager: APIAlertmanagerUpstream.isRequired,
silenceID: PropTypes.string.isRequired
};
toggle = observable(
{
visible: false,
toggle() {
this.visible = !this.visible;
}
},
{ toggle: action.bound }
);
render() {
const { alertStore, alertmanager, silenceID } = this.props;
return (
<React.Fragment>
<span
className={`badge badge-danger text-nowrap text-truncate px-1 cursor-pointer components-label-with-hover`}
onClick={this.toggle.toggle}
>
<FontAwesomeIcon className="mr-1" icon={faTrash} />
Delete
</span>
<Modal isOpen={this.toggle.visible}>
<DeleteSilenceModalContent
alertStore={alertStore}
alertmanager={alertmanager}
silenceID={silenceID}
onHide={this.toggle.toggle}
/>
</Modal>
</React.Fragment>
);
}
}
);
export { DeleteSilence, DeleteSilenceModalContent };

View File

@@ -0,0 +1,178 @@
import React from "react";
import { mount } from "enzyme";
import { EmptyAPIResponse } from "__mocks__/Fetch";
import { MockAlertGroup, MockAlert, MockAlertmanager } from "__mocks__/Alerts";
import { AlertStore } from "Stores/AlertStore";
import { DeleteSilence, DeleteSilenceModalContent } from "./DeleteSilence";
let alertmanager;
let alertStore;
beforeEach(() => {
alertmanager = MockAlertmanager();
alertStore = new AlertStore([]);
fetch.mockResponseOnce(JSON.stringify(MockAPIResponse()));
jest.restoreAllMocks();
});
afterEach(() => {
jest.restoreAllMocks();
fetch.resetMocks();
});
const MockOnHide = jest.fn();
const MockAPIResponse = () => {
const response = EmptyAPIResponse();
response.groups = {
"1": MockAlertGroup(
{ alertname: "foo" },
[MockAlert([], { instance: "foo" }, "suppressed")],
[],
{ job: "foo" }
)
};
return response;
};
const MountedDeleteSilence = () => {
return mount(
<DeleteSilence
alertStore={alertStore}
alertmanager={alertmanager}
silenceID="123456789"
/>
);
};
const MountedDeleteSilenceModalContent = () => {
return mount(
<DeleteSilenceModalContent
alertStore={alertStore}
alertmanager={alertmanager}
silenceID="123456789"
onHide={MockOnHide}
/>
);
};
const VerifyResponse = async response => {
const tree = MountedDeleteSilenceModalContent();
await expect(tree.instance().previewState.fetch).resolves.toBeUndefined();
fetch.mockResponseOnce(JSON.stringify(response));
tree.find(".btn-outline-danger").simulate("click");
await expect(tree.instance().deleteState.fetch).resolves.toBeUndefined();
return tree;
};
describe("<DeleteSilence />", async () => {
it("label is 'Delete' by default", () => {
const tree = MountedDeleteSilence();
expect(tree.text()).toBe("Delete");
});
it("opens modal on click", async () => {
const tree = MountedDeleteSilence();
tree.simulate("click");
expect(tree.find(".modal-body")).toHaveLength(1);
});
});
describe("<DeleteSilenceModalContent />", () => {
it("renders LabelSetList on mount", async () => {
const tree = MountedDeleteSilenceModalContent();
expect(tree.find("LabelSetList")).toHaveLength(1);
});
it("fetches affected alerts on mount", async () => {
MountedDeleteSilenceModalContent();
expect(fetch).toHaveBeenCalled();
});
it("renders ErrorMessage on failed fetch", async () => {
jest.spyOn(console, "trace").mockImplementation(() => {});
fetch.resetMocks();
fetch.mockReject("Fetch error");
const tree = MountedDeleteSilenceModalContent();
await expect(tree.instance().previewState.fetch).resolves.toBeUndefined();
tree.update();
expect(tree.find("ErrorMessage")).toHaveLength(1);
});
it("renders ErrorMessage on fetch with non-JSON response", async () => {
fetch.mockResponseOnce("not json");
jest.spyOn(console, "trace").mockImplementation(() => {});
fetch.resetMocks();
fetch.mockReject("Fetch error");
const tree = MountedDeleteSilenceModalContent();
await expect(tree.instance().previewState.fetch).resolves.toBeUndefined();
tree.update();
expect(tree.find("ErrorMessage")).toHaveLength(1);
});
it("sends a DELETE request after clicking 'Confirm' button", async () => {
await VerifyResponse({ status: "success" });
expect(fetch.mock.calls[1][0]).toBe(
"http://am.example.com/api/v1/silence/123456789"
);
expect(fetch.mock.calls[1][1]).toMatchObject({ method: "DELETE" });
});
it("'Confirm' button is no-op after successful DELETE", async () => {
const tree = await VerifyResponse({ status: "success" });
expect(fetch.mock.calls[1][0]).toBe(
"http://am.example.com/api/v1/silence/123456789"
);
expect(fetch.mock.calls[1][1]).toMatchObject({ method: "DELETE" });
expect(fetch.mock.calls).toHaveLength(2);
tree.find(".btn-outline-danger").simulate("click");
expect(fetch.mock.calls).toHaveLength(2);
});
it("renders SuccessMessage on 'success' response status", async () => {
const tree = await VerifyResponse({ status: "success" });
tree.update();
expect(tree.find("SuccessMessage")).toHaveLength(1);
});
it("renders ErrorMessage on 'error' response status", async () => {
const tree = await VerifyResponse({ status: "error", error: "fake error" });
tree.update();
expect(tree.find("ErrorMessage")).toHaveLength(1);
});
it("renders ErrorMessage on unhandled response status", async () => {
const tree = await VerifyResponse({ status: "foo bar" });
tree.update();
expect(tree.find("ErrorMessage")).toHaveLength(1);
});
it("renders ErrorMessage on unhandled response body", async () => {
const tree = await VerifyResponse({ foo: "bar" });
tree.update();
expect(tree.find("ErrorMessage")).toHaveLength(1);
});
it("renders ErrorMessage on failed fetch request", async () => {
const tree = MountedDeleteSilenceModalContent();
await expect(tree.instance().previewState.fetch).resolves.toBeUndefined();
jest.spyOn(console, "trace").mockImplementation(() => {});
fetch.resetMocks();
fetch.mockReject("Fetch error");
tree.find(".btn-outline-danger").simulate("click");
await expect(tree.instance().deleteState.fetch).resolves.toBeUndefined();
tree.update();
expect(tree.find("ErrorMessage")).toHaveLength(1);
});
});

View File

@@ -172,7 +172,7 @@ exports[`<Silence /> matches snapshot with expaned details 1`] = `
in 5 hours
</time>
</span>
<span class=\\"badge badge-secondary text-nowrap text-truncate px-1 cursor-pointer components-label-with-hover\\">
<span class=\\"badge badge-secondary text-nowrap text-truncate px-1 cursor-pointer components-label-with-hover mr-1\\">
<svg aria-hidden=\\"true\\"
data-prefix=\\"fas\\"
data-icon=\\"edit\\"
@@ -188,6 +188,22 @@ exports[`<Silence /> matches snapshot with expaned details 1`] = `
</svg>
Edit
</span>
<span class=\\"badge badge-danger text-nowrap text-truncate px-1 cursor-pointer components-label-with-hover\\">
<svg aria-hidden=\\"true\\"
data-prefix=\\"fas\\"
data-icon=\\"trash\\"
class=\\"svg-inline--fa fa-trash fa-w-14 mr-1\\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 448 512\\"
>
<path fill=\\"currentColor\\"
d=\\"M0 84V56c0-13.3 10.7-24 24-24h112l9.4-18.7c4-8.2 12.3-13.3 21.4-13.3h114.3c9.1 0 17.4 5.1 21.5 13.3L312 32h112c13.3 0 24 10.7 24 24v28c0 6.6-5.4 12-12 12H12C5.4 96 0 90.6 0 84zm415.2 56.7L394.8 467c-1.6 25.3-22.6 45-47.9 45H101.1c-25.3 0-46.3-19.7-47.9-45L32.8 140.7c-.4-6.9 5.1-12.7 12-12.7h358.5c6.8 0 12.3 5.8 11.9 12.7z\\"
>
</path>
</svg>
Delete
</span>
</div>
<div>
<span class=\\"badge text-nowrap text-truncate px-1 mr-1\\">

View File

@@ -29,6 +29,7 @@ import { StaticLabels, QueryOperators } from "Common/Query";
import { FilteringLabel } from "Components/Labels/FilteringLabel";
import { TooltipWrapper } from "Components/TooltipWrapper";
import { RenderLinkAnnotation } from "../Annotation";
import { DeleteSilence } from "./DeleteSilence";
import "./index.css";
@@ -87,7 +88,12 @@ SilenceExpiryBadgeWithProgress.propTypes = {
progress: PropTypes.number.isRequired
};
const SilenceDetails = ({ alertmanager, silence, onEditSilence }) => {
const SilenceDetails = ({
alertStore,
alertmanager,
silence,
onEditSilence
}) => {
let expiresClass = "";
let expiresLabel = "Expires";
if (moment(silence.endsAt) < moment()) {
@@ -119,12 +125,17 @@ const SilenceDetails = ({ alertmanager, silence, onEditSilence }) => {
{expiresLabel} <Moment fromNow>{silence.endsAt}</Moment>
</span>
<span
className="badge badge-secondary text-nowrap text-truncate px-1 cursor-pointer components-label-with-hover"
className="badge badge-secondary text-nowrap text-truncate px-1 cursor-pointer components-label-with-hover mr-1"
onClick={onEditSilence}
>
<FontAwesomeIcon className="mr-1" icon={faEdit} />
Edit
</span>
<DeleteSilence
alertStore={alertStore}
alertmanager={alertmanager}
silenceID={silence.id}
/>
</div>
<div>
<span className="badge text-nowrap text-truncate px-1 mr-1">
@@ -277,7 +288,7 @@ const Silence = inject("alertStore")(
}
render() {
const { alertmanagerState, silenceID } = this.props;
const { alertStore, alertmanagerState, silenceID } = this.props;
const silence = this.getSilence();
if (!silence)
@@ -320,6 +331,7 @@ const Silence = inject("alertStore")(
</div>
{this.collapse.value ? null : (
<SilenceDetails
alertStore={alertStore}
alertmanager={alertmanager}
silence={silence}
onEditSilence={this.onEditSilence}

View File

@@ -100,6 +100,7 @@ const MountedSilenceDetails = onEditSilence => {
return mount(
<Provider alertStore={alertStore}>
<SilenceDetails
alertStore={alertStore}
alertmanager={alertStore.data.upstreams.instances[0]}
silence={silence}
onEditSilence={onEditSilence}

View File

@@ -0,0 +1,53 @@
import React from "react";
import PropTypes from "prop-types";
import hash from "object-hash";
import { AlertStore } from "Stores/AlertStore";
import { StaticLabel } from "Components/Labels/StaticLabel";
// take a list of groups and outputs a list of label sets, this ignores
// the receiver, so we'll end up with only unique alerts
const GroupListToUniqueLabelsList = 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;
}
}
return Object.values(alerts);
};
// used in new silence form preview stage and when deleting silences
const LabelSetList = ({ alertStore, labelsList }) =>
labelsList.length > 0 ? (
<ul className="list-group list-group-flush">
{labelsList.map(labels => (
<li key={hash(labels)} className="list-group-item px-0 pt-2 pb-1">
{Object.entries(labels).map(([name, value]) => (
<StaticLabel
key={name}
alertStore={alertStore}
name={name}
value={value}
/>
))}
</li>
))}
</ul>
) : (
<p className="text-muted text-center">No alerts matched</p>
);
LabelSetList.propTypes = {
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
labelsList: PropTypes.arrayOf(PropTypes.object).isRequired
};
export { LabelSetList, GroupListToUniqueLabelsList };

View File

@@ -8,7 +8,12 @@ const FilterOperatorHelp = ({ operator, description, children }) => (
</dt>
<dd className="mb-3">
<div>
Example: <code>key{operator}value</code>
Example:{" "}
<code>
key
{operator}
value
</code>
</div>
<div>{children}</div>
</dd>
@@ -179,6 +184,19 @@ const Help = () => (
</FilterExample>
</QueryHelp>
<QueryHelp
title="Match suppressed alerts based on the silence ID"
operators={["=", "!="]}
>
<FilterExample example="@silence_id=abc123456789">
Match alerts suppressed by silence <code>abc123456789</code>.
</FilterExample>
<FilterExample example="@silence_id!=abc123456789">
Match alerts suppressed by any silence except{" "}
<code>abc123456789</code>.
</FilterExample>
</QueryHelp>
<QueryHelp
title="Match alerts based on the author of silence"
operators={["=", "!=", "=~", "!~"]}

View File

@@ -1,16 +1,12 @@
import React, { Component } from "react";
import ReactDOM from "react-dom";
import PropTypes from "prop-types";
import { observer } from "mobx-react";
import { observable, action } from "mobx";
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
import { AlertStore } from "Stores/AlertStore";
import { Settings } from "Stores/Settings";
import { Configuration } from "./Configuration";
import { MountModalBackdrop } from "Components/Animations/MountModal";
import { Help } from "./Help";
const Tab = ({ title, active, onClick }) => (
@@ -39,8 +35,7 @@ const MainModalContent = observer(
static propTypes = {
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
settingsStore: PropTypes.instanceOf(Settings).isRequired,
onHide: PropTypes.func.isRequired,
isVisible: PropTypes.bool.isRequired
onHide: PropTypes.func.isRequired
};
tab = observable(
@@ -53,58 +48,40 @@ const MainModalContent = observer(
{ setTab: action.bound }
);
componentDidMount() {
disableBodyScroll(document.querySelector(".modal"));
}
componentWillUnmount() {
enableBodyScroll(document.querySelector(".modal"));
}
render() {
const { alertStore, settingsStore, onHide, isVisible } = this.props;
const { alertStore, settingsStore, onHide } = this.props;
return ReactDOM.createPortal(
return (
<React.Fragment>
<div className="modal d-block" role="dialog">
<div className="modal-dialog modal-lg" role="document">
<div className="modal-content">
<div className="modal-header py-2">
<nav className="nav nav-pills nav-justified w-100">
<Tab
title="Configuration"
active={this.tab.current === TabNames.Configuration}
onClick={() => this.tab.setTab(TabNames.Configuration)}
/>
<Tab
title="Help"
active={this.tab.current === TabNames.Help}
onClick={() => this.tab.setTab(TabNames.Help)}
/>
<button type="button" className="close" onClick={onHide}>
<span>&times;</span>
</button>
</nav>
</div>
<div className="modal-body">
{this.tab.current === TabNames.Help ? <Help /> : null}
{this.tab.current === TabNames.Configuration ? (
<Configuration settingsStore={settingsStore} />
) : null}
</div>
<div className="modal-footer">
<span className="text-muted">
Version: {alertStore.info.version}
</span>
</div>
</div>
</div>
<div className="modal-header py-2">
<nav className="nav nav-pills nav-justified w-100">
<Tab
title="Configuration"
active={this.tab.current === TabNames.Configuration}
onClick={() => this.tab.setTab(TabNames.Configuration)}
/>
<Tab
title="Help"
active={this.tab.current === TabNames.Help}
onClick={() => this.tab.setTab(TabNames.Help)}
/>
<button type="button" className="close" onClick={onHide}>
<span>&times;</span>
</button>
</nav>
</div>
<MountModalBackdrop in={isVisible} unmountOnExit>
<div className="modal-backdrop d-block" />
</MountModalBackdrop>
</React.Fragment>,
document.body
<div className="modal-body">
{this.tab.current === TabNames.Help ? <Help /> : null}
{this.tab.current === TabNames.Configuration ? (
<Configuration settingsStore={settingsStore} />
) : null}
</div>
<div className="modal-footer">
<span className="text-muted">
Version: {alertStore.info.version}
</span>
</div>
</React.Fragment>
);
}
}

View File

@@ -28,7 +28,6 @@ const FakeModal = () => {
alertStore={alertStore}
settingsStore={settingsStore}
onHide={onHide}
isVisible={true}
/>
);
};
@@ -46,7 +45,18 @@ const ValidateSetTab = (title, callArg) => {
describe("<MainModalContent />", () => {
it("matches snapshot", () => {
const tree = FakeModal();
// we have multiple fragments and enzyme only renders the first one
// in html() and text(), debug() would work but it's noisy
// https://github.com/airbnb/enzyme/issues/1213
const tree = mount(
<span>
<MainModalContent
alertStore={alertStore}
settingsStore={settingsStore}
onHide={onHide}
/>
</span>
);
expect(toDiffableHtml(tree.html())).toMatchSnapshot();
});

View File

@@ -474,6 +474,53 @@ exports[`<Help /> matches snapshot 1`] = `
</li>
</ul>
</dd>
<dt>
Match suppressed alerts based on the silence ID
</dt>
<dd class=\\"mb-5\\">
<div>
Supported operators:
<kbd class=\\"mr-1\\">
=
</kbd>
<kbd class=\\"mr-1\\">
!=
</kbd>
</div>
<div>
Examples:
</div>
<ul>
<li>
<div>
<span class=\\"badge badge-info\\">
@silence_id=abc123456789
</span>
</div>
<div>
Match alerts suppressed by silence
<code>
abc123456789
</code>
.
</div>
</li>
<li>
<div>
<span class=\\"badge badge-info\\">
@silence_id!=abc123456789
</span>
</div>
<div>
Match alerts suppressed by any silence except
<code>
abc123456789
</code>
.
</div>
</li>
</ul>
</dd>
<dt>
Match alerts based on the author of silence
</dt>

View File

@@ -2,129 +2,121 @@
exports[`<MainModalContent /> matches snapshot 1`] = `
"
<div class=\\"modal d-block\\"
role=\\"dialog\\"
>
<div class=\\"modal-dialog modal-lg\\"
role=\\"document\\"
>
<div class=\\"modal-content\\">
<div class=\\"modal-header py-2\\">
<nav class=\\"nav nav-pills nav-justified w-100\\">
<span class=\\"nav-item nav-link cursor-pointer active\\">
Configuration
</span>
<span class=\\"nav-item nav-link cursor-pointer text-primary\\">
Help
</span>
<button type=\\"button\\"
class=\\"close\\"
>
<span>
×
</span>
</button>
</nav>
</div>
<div class=\\"modal-body\\">
<form class=\\"px-3\\">
<div class=\\"form-group text-center\\">
<label class=\\"mb-4\\">
Refresh interval
</label>
<div aria-disabled=\\"false\\"
class=\\"input-range\\"
>
<span class=\\"input-range__label input-range__label--min\\">
<span class=\\"input-range__label-container\\">
10s
</span>
</span>
<div class=\\"input-range__track input-range__track--background\\">
<div style=\\"left: 0%; width: 18.181818181818183%;\\"
class=\\"input-range__track input-range__track--active\\"
>
</div>
<span class=\\"input-range__slider-container\\"
style=\\"position: absolute; left: 18.181818181818183%;\\"
>
<span class=\\"input-range__label input-range__label--value\\">
<span class=\\"input-range__label-container\\">
30s
</span>
</span>
<div aria-valuemax=\\"120\\"
aria-valuemin=\\"10\\"
aria-valuenow=\\"30\\"
class=\\"input-range__slider\\"
draggable=\\"false\\"
role=\\"slider\\"
tabindex=\\"0\\"
>
</div>
</span>
</div>
<span class=\\"input-range__label input-range__label--max\\">
<span class=\\"input-range__label-container\\">
120s
</span>
</span>
</div>
</div>
<div class=\\"mt-5\\">
</div>
<div class=\\"form-group text-center\\">
<label class=\\"mb-4\\">
Default number of alerts to show per group
</label>
<div aria-disabled=\\"false\\"
class=\\"input-range\\"
>
<span class=\\"input-range__label input-range__label--min\\">
<span class=\\"input-range__label-container\\">
1
</span>
</span>
<div class=\\"input-range__track input-range__track--background\\">
<div style=\\"left: 0%; width: 44.44444444444444%;\\"
class=\\"input-range__track input-range__track--active\\"
>
</div>
<span class=\\"input-range__slider-container\\"
style=\\"position: absolute; left: 44.44444444444444%;\\"
>
<span class=\\"input-range__label input-range__label--value\\">
<span class=\\"input-range__label-container\\">
5
</span>
</span>
<div aria-valuemax=\\"10\\"
aria-valuemin=\\"1\\"
aria-valuenow=\\"5\\"
class=\\"input-range__slider\\"
draggable=\\"false\\"
role=\\"slider\\"
tabindex=\\"0\\"
>
</div>
</span>
</div>
<span class=\\"input-range__label input-range__label--max\\">
<span class=\\"input-range__label-container\\">
10
</span>
</span>
</div>
</div>
</form>
</div>
<div class=\\"modal-footer\\">
<span class=\\"text-muted\\">
Version: unknown
<span>
<div class=\\"modal-header py-2\\">
<nav class=\\"nav nav-pills nav-justified w-100\\">
<span class=\\"nav-item nav-link cursor-pointer active\\">
Configuration
</span>
<span class=\\"nav-item nav-link cursor-pointer text-primary\\">
Help
</span>
<button type=\\"button\\"
class=\\"close\\"
>
<span>
×
</span>
</div>
</div>
</button>
</nav>
</div>
</div>
<div class=\\"modal-body\\">
<form class=\\"px-3\\">
<div class=\\"form-group text-center\\">
<label class=\\"mb-4\\">
Refresh interval
</label>
<div aria-disabled=\\"false\\"
class=\\"input-range\\"
>
<span class=\\"input-range__label input-range__label--min\\">
<span class=\\"input-range__label-container\\">
10s
</span>
</span>
<div class=\\"input-range__track input-range__track--background\\">
<div style=\\"left: 0%; width: 18.181818181818183%;\\"
class=\\"input-range__track input-range__track--active\\"
>
</div>
<span class=\\"input-range__slider-container\\"
style=\\"position: absolute; left: 18.181818181818183%;\\"
>
<span class=\\"input-range__label input-range__label--value\\">
<span class=\\"input-range__label-container\\">
30s
</span>
</span>
<div aria-valuemax=\\"120\\"
aria-valuemin=\\"10\\"
aria-valuenow=\\"30\\"
class=\\"input-range__slider\\"
draggable=\\"false\\"
role=\\"slider\\"
tabindex=\\"0\\"
>
</div>
</span>
</div>
<span class=\\"input-range__label input-range__label--max\\">
<span class=\\"input-range__label-container\\">
120s
</span>
</span>
</div>
</div>
<div class=\\"mt-5\\">
</div>
<div class=\\"form-group text-center\\">
<label class=\\"mb-4\\">
Default number of alerts to show per group
</label>
<div aria-disabled=\\"false\\"
class=\\"input-range\\"
>
<span class=\\"input-range__label input-range__label--min\\">
<span class=\\"input-range__label-container\\">
1
</span>
</span>
<div class=\\"input-range__track input-range__track--background\\">
<div style=\\"left: 0%; width: 44.44444444444444%;\\"
class=\\"input-range__track input-range__track--active\\"
>
</div>
<span class=\\"input-range__slider-container\\"
style=\\"position: absolute; left: 44.44444444444444%;\\"
>
<span class=\\"input-range__label input-range__label--value\\">
<span class=\\"input-range__label-container\\">
5
</span>
</span>
<div aria-valuemax=\\"10\\"
aria-valuemin=\\"1\\"
aria-valuenow=\\"5\\"
class=\\"input-range__slider\\"
draggable=\\"false\\"
role=\\"slider\\"
tabindex=\\"0\\"
>
</div>
</span>
</div>
<span class=\\"input-range__label input-range__label--max\\">
<span class=\\"input-range__label-container\\">
10
</span>
</span>
</div>
</div>
</form>
</div>
<div class=\\"modal-footer\\">
<span class=\\"text-muted\\">
Version: unknown
</span>
</div>
</span>
"
`;

View File

@@ -9,8 +9,8 @@ import { faCog } from "@fortawesome/free-solid-svg-icons/faCog";
import { AlertStore } from "Stores/AlertStore";
import { Settings } from "Stores/Settings";
import { MountModal } from "Components/Animations/MountModal";
import { TooltipWrapper } from "Components/TooltipWrapper";
import { Modal } from "Components/Modal";
import { MainModalContent } from "./MainModalContent";
const MainModal = observer(
@@ -33,14 +33,6 @@ const MainModal = observer(
{ toggle: action.bound, hide: action.bound }
);
componentDidUpdate() {
document.body.classList.toggle("modal-open", this.toggle.show);
}
componentWillUnmount() {
document.body.classList.remove("modal-open");
}
render() {
const { alertStore, settingsStore } = this.props;
@@ -56,14 +48,14 @@ const MainModal = observer(
</span>
</TooltipWrapper>
</li>
<MountModal in={this.toggle.show} unmountOnExit>
<Modal isOpen={this.toggle.show}>
<MainModalContent
alertStore={alertStore}
settingsStore={settingsStore}
onHide={this.toggle.hide}
isVisible={this.toggle.show}
/>
</MountModal>
</Modal>
</React.Fragment>
);
}

View File

@@ -0,0 +1,66 @@
import React, { Component } from "react";
import ReactDOM from "react-dom";
import PropTypes from "prop-types";
import { observer } from "mobx-react";
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
import {
MountModal,
MountModalBackdrop
} from "Components/Animations/MountModal";
const Modal = observer(
class Modal extends Component {
static propTypes = {
isOpen: PropTypes.bool.isRequired,
children: PropTypes.node.isRequired
};
toggleBodyClass = isOpen => {
document.body.classList.toggle("modal-open", isOpen);
if (isOpen) {
disableBodyScroll(document.querySelector(".modal"));
} else {
enableBodyScroll(document.querySelector(".modal"));
}
};
componentDidMount() {
const { isOpen } = this.props;
this.toggleBodyClass(isOpen);
}
componentDidUpdate() {
const { isOpen } = this.props;
this.toggleBodyClass(isOpen);
}
componentWillUnmount() {
this.toggleBodyClass(false);
}
render() {
const { isOpen, children } = this.props;
return ReactDOM.createPortal(
<React.Fragment>
<MountModal in={isOpen} unmountOnExit>
<div className="modal d-block" role="dialog">
<div className="modal-dialog modal-lg" role="document">
<div className="modal-content">{children}</div>
</div>
</div>
</MountModal>
<MountModalBackdrop in={isOpen} unmountOnExit>
<div className="modal-backdrop d-block" />
</MountModalBackdrop>
</React.Fragment>,
document.body
);
}
}
);
export { Modal };

View File

@@ -0,0 +1,31 @@
import React from "react";
import { mount } from "enzyme";
import { Modal } from ".";
const MountedModal = isOpen => {
return mount(
<Modal isOpen={isOpen}>
<div />
</Modal>
);
};
describe("<Modal />", () => {
it("'modal-open' class is appended to body node when modal is visible", () => {
MountedModal(true);
expect(document.body.className.split(" ")).toContain("modal-open");
});
it("'modal-open' class is removed from body node after modal is hidden", () => {
MountedModal(false);
expect(document.body.className.split(" ")).not.toContain("modal-open");
});
it("'modal-open' class is removed from body node after modal is unmounted", () => {
const tree = MountedModal(true);
tree.unmount();
expect(document.body.className.split(" ")).not.toContain("modal-open");
});
});

View File

@@ -1,283 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<NavBar /> matches snapshot with 0 alerts 1`] = `
"
<div class=\\"container visible\\">
<nav class=\\"navbar fixed-top navbar-expand navbar-dark p-1 bg-primary-transparent d-inline-block\\">
<div style=\\"position:absolute;width:0;height:0;visibility:hidden;display:none\\">
</div>
<span class=\\"navbar-brand my-0 mx-2 h1 d-none d-sm-block float-left\\">
0
<svg aria-hidden=\\"true\\"
data-prefix=\\"fas\\"
data-icon=\\"circle-notch\\"
class=\\"svg-inline--fa fa-circle-notch fa-w-16 fa-lg mx-1 text-muted\\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 512 512\\"
style=\\"opacity:0\\"
>
<path fill=\\"currentColor\\"
d=\\"M288 39.056v16.659c0 10.804 7.281 20.159 17.686 23.066C383.204 100.434 440 171.518 440 256c0 101.689-82.295 184-184 184-101.689 0-184-82.295-184-184 0-84.47 56.786-155.564 134.312-177.219C216.719 75.874 224 66.517 224 55.712V39.064c0-15.709-14.834-27.153-30.046-23.234C86.603 43.482 7.394 141.206 8.003 257.332c.72 137.052 111.477 246.956 248.531 246.667C393.255 503.711 504 392.788 504 256c0-115.633-79.14-212.779-186.211-240.236C302.678 11.889 288 23.456 288 39.056z\\"
>
</path>
</svg>
</span>
<ul class=\\"navbar-nav float-right d-flex flex-row\\">
<li class=\\"nav-item\\">
<div title=\\"Add new silence\\"
class
style=\\"display:inline\\"
>
<span class=\\"nav-link cursor-pointer\\">
<svg aria-hidden=\\"true\\"
data-prefix=\\"fas\\"
data-icon=\\"bell-slash\\"
class=\\"svg-inline--fa fa-bell-slash fa-w-20 \\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 640 512\\"
>
<path fill=\\"currentColor\\"
d=\\"M633.82 458.1l-90.62-70.05c.19-1.38.8-2.66.8-4.06.05-7.55-2.61-15.27-8.61-21.71-19.32-20.76-55.47-51.99-55.47-154.29 0-77.7-54.48-139.9-127.94-155.16V32c0-17.67-14.32-32-31.98-32s-31.98 14.33-31.98 32v20.84c-40.33 8.38-74.66 31.07-97.59 62.57L45.47 3.37C38.49-2.05 28.43-.8 23.01 6.18L3.37 31.45C-2.05 38.42-.8 48.47 6.18 53.9l588.35 454.73c6.98 5.43 17.03 4.17 22.46-2.81l19.64-25.27c5.42-6.97 4.17-17.02-2.81-22.45zM157.23 251.54c-8.61 67.96-36.41 93.33-52.62 110.75-6 6.45-8.66 14.16-8.61 21.71.11 16.4 12.98 32 32.1 32h241.92L157.23 251.54zM320 512c35.32 0 63.97-28.65 63.97-64H256.03c0 35.35 28.65 64 63.97 64z\\"
>
</path>
</svg>
</span>
</div>
</li>
<li class=\\"nav-item\\">
<div title=\\"Settings\\"
class
style=\\"display:inline\\"
>
<span class=\\"nav-link cursor-pointer\\">
<svg aria-hidden=\\"true\\"
data-prefix=\\"fas\\"
data-icon=\\"cog\\"
class=\\"svg-inline--fa fa-cog fa-w-16 \\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 512 512\\"
>
<path fill=\\"currentColor\\"
d=\\"M444.788 291.1l42.616 24.599c4.867 2.809 7.126 8.618 5.459 13.985-11.07 35.642-29.97 67.842-54.689 94.586a12.016 12.016 0 0 1-14.832 2.254l-42.584-24.595a191.577 191.577 0 0 1-60.759 35.13v49.182a12.01 12.01 0 0 1-9.377 11.718c-34.956 7.85-72.499 8.256-109.219.007-5.49-1.233-9.403-6.096-9.403-11.723v-49.184a191.555 191.555 0 0 1-60.759-35.13l-42.584 24.595a12.016 12.016 0 0 1-14.832-2.254c-24.718-26.744-43.619-58.944-54.689-94.586-1.667-5.366.592-11.175 5.459-13.985L67.212 291.1a193.48 193.48 0 0 1 0-70.199l-42.616-24.599c-4.867-2.809-7.126-8.618-5.459-13.985 11.07-35.642 29.97-67.842 54.689-94.586a12.016 12.016 0 0 1 14.832-2.254l42.584 24.595a191.577 191.577 0 0 1 60.759-35.13V25.759a12.01 12.01 0 0 1 9.377-11.718c34.956-7.85 72.499-8.256 109.219-.007 5.49 1.233 9.403 6.096 9.403 11.723v49.184a191.555 191.555 0 0 1 60.759 35.13l42.584-24.595a12.016 12.016 0 0 1 14.832 2.254c24.718 26.744 43.619 58.944 54.689 94.586 1.667 5.366-.592 11.175-5.459 13.985L444.788 220.9a193.485 193.485 0 0 1 0 70.2zM336 256c0-44.112-35.888-80-80-80s-80 35.888-80 80 35.888 80 80 80 80-35.888 80-80z\\"
>
</path>
</svg>
</span>
</div>
</li>
</ul>
<form class=\\"form-inline mw-100\\"
data-filters
>
<div role=\\"combobox\\"
aria-haspopup=\\"listbox\\"
aria-owns=\\"react-autowhatever-1\\"
aria-expanded=\\"false\\"
class=\\"autosuggest d-inline-block w-100\\"
>
<div class=\\"input-group mr-2\\">
<div class=\\"input-group-prepend\\">
<span class=\\"input-group-text px-2\\">
<svg aria-hidden=\\"true\\"
data-prefix=\\"fas\\"
data-icon=\\"search\\"
class=\\"svg-inline--fa fa-search fa-w-16 \\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 512 512\\"
>
<path fill=\\"currentColor\\"
d=\\"M505 442.7L405.3 343c-4.5-4.5-10.6-7-17-7H372c27.6-35.3 44-79.7 44-128C416 93.1 322.9 0 208 0S0 93.1 0 208s93.1 208 208 208c48.3 0 92.7-16.4 128-44v16.3c0 6.4 2.5 12.5 7 17l99.7 99.7c9.4 9.4 24.6 9.4 33.9 0l28.3-28.3c9.4-9.4 9.4-24.6.1-34zM208 336c-70.7 0-128-57.2-128-128 0-70.7 57.2-128 128-128 70.7 0 128 57.2 128 128 0 70.7-57.2 128-128 128z\\"
>
</path>
</svg>
</span>
</div>
<div class=\\"form-control p-1 components-filterinput\\">
<input type=\\"text\\"
class=\\"components-filterinput-wrapper\\"
placeholder
value
autocomplete=\\"off\\"
aria-autocomplete=\\"list\\"
aria-controls=\\"react-autowhatever-1\\"
>
</div>
<div class=\\"input-group-append\\">
<button class=\\"input-group-text rounded-right cursor-pointer components-navbar-history px-2\\"
type=\\"button\\"
data-toggle=\\"dropdown\\"
aria-haspopup=\\"true\\"
aria-expanded=\\"true\\"
>
<svg aria-hidden=\\"true\\"
data-prefix=\\"fas\\"
data-icon=\\"caret-down\\"
class=\\"svg-inline--fa fa-caret-down fa-w-10 \\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 320 512\\"
>
<path fill=\\"currentColor\\"
d=\\"M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z\\"
>
</path>
</svg>
</button>
</div>
</div>
<div id=\\"react-autowhatever-1\\"
role=\\"listbox\\"
class=\\"dropdown\\"
>
</div>
</div>
</form>
</nav>
</div>
"
`;
exports[`<NavBar /> matches snapshot with 5 alerts 1`] = `
"
<div class=\\"container visible\\">
<nav class=\\"navbar fixed-top navbar-expand navbar-dark p-1 bg-primary-transparent d-inline-block\\">
<div style=\\"position:absolute;width:0;height:0;visibility:hidden;display:none\\">
</div>
<span class=\\"navbar-brand my-0 mx-2 h1 d-none d-sm-block float-left\\">
5
<svg aria-hidden=\\"true\\"
data-prefix=\\"fas\\"
data-icon=\\"circle-notch\\"
class=\\"svg-inline--fa fa-circle-notch fa-w-16 fa-lg mx-1 text-muted\\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 512 512\\"
style=\\"opacity:0\\"
>
<path fill=\\"currentColor\\"
d=\\"M288 39.056v16.659c0 10.804 7.281 20.159 17.686 23.066C383.204 100.434 440 171.518 440 256c0 101.689-82.295 184-184 184-101.689 0-184-82.295-184-184 0-84.47 56.786-155.564 134.312-177.219C216.719 75.874 224 66.517 224 55.712V39.064c0-15.709-14.834-27.153-30.046-23.234C86.603 43.482 7.394 141.206 8.003 257.332c.72 137.052 111.477 246.956 248.531 246.667C393.255 503.711 504 392.788 504 256c0-115.633-79.14-212.779-186.211-240.236C302.678 11.889 288 23.456 288 39.056z\\"
>
</path>
</svg>
</span>
<ul class=\\"navbar-nav float-right d-flex flex-row\\">
<li class=\\"nav-item\\">
<div title=\\"Add new silence\\"
class
style=\\"display:inline\\"
>
<span class=\\"nav-link cursor-pointer\\">
<svg aria-hidden=\\"true\\"
data-prefix=\\"fas\\"
data-icon=\\"bell-slash\\"
class=\\"svg-inline--fa fa-bell-slash fa-w-20 \\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 640 512\\"
>
<path fill=\\"currentColor\\"
d=\\"M633.82 458.1l-90.62-70.05c.19-1.38.8-2.66.8-4.06.05-7.55-2.61-15.27-8.61-21.71-19.32-20.76-55.47-51.99-55.47-154.29 0-77.7-54.48-139.9-127.94-155.16V32c0-17.67-14.32-32-31.98-32s-31.98 14.33-31.98 32v20.84c-40.33 8.38-74.66 31.07-97.59 62.57L45.47 3.37C38.49-2.05 28.43-.8 23.01 6.18L3.37 31.45C-2.05 38.42-.8 48.47 6.18 53.9l588.35 454.73c6.98 5.43 17.03 4.17 22.46-2.81l19.64-25.27c5.42-6.97 4.17-17.02-2.81-22.45zM157.23 251.54c-8.61 67.96-36.41 93.33-52.62 110.75-6 6.45-8.66 14.16-8.61 21.71.11 16.4 12.98 32 32.1 32h241.92L157.23 251.54zM320 512c35.32 0 63.97-28.65 63.97-64H256.03c0 35.35 28.65 64 63.97 64z\\"
>
</path>
</svg>
</span>
</div>
</li>
<li class=\\"nav-item\\">
<div title=\\"Settings\\"
class
style=\\"display:inline\\"
>
<span class=\\"nav-link cursor-pointer\\">
<svg aria-hidden=\\"true\\"
data-prefix=\\"fas\\"
data-icon=\\"cog\\"
class=\\"svg-inline--fa fa-cog fa-w-16 \\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 512 512\\"
>
<path fill=\\"currentColor\\"
d=\\"M444.788 291.1l42.616 24.599c4.867 2.809 7.126 8.618 5.459 13.985-11.07 35.642-29.97 67.842-54.689 94.586a12.016 12.016 0 0 1-14.832 2.254l-42.584-24.595a191.577 191.577 0 0 1-60.759 35.13v49.182a12.01 12.01 0 0 1-9.377 11.718c-34.956 7.85-72.499 8.256-109.219.007-5.49-1.233-9.403-6.096-9.403-11.723v-49.184a191.555 191.555 0 0 1-60.759-35.13l-42.584 24.595a12.016 12.016 0 0 1-14.832-2.254c-24.718-26.744-43.619-58.944-54.689-94.586-1.667-5.366.592-11.175 5.459-13.985L67.212 291.1a193.48 193.48 0 0 1 0-70.199l-42.616-24.599c-4.867-2.809-7.126-8.618-5.459-13.985 11.07-35.642 29.97-67.842 54.689-94.586a12.016 12.016 0 0 1 14.832-2.254l42.584 24.595a191.577 191.577 0 0 1 60.759-35.13V25.759a12.01 12.01 0 0 1 9.377-11.718c34.956-7.85 72.499-8.256 109.219-.007 5.49 1.233 9.403 6.096 9.403 11.723v49.184a191.555 191.555 0 0 1 60.759 35.13l42.584-24.595a12.016 12.016 0 0 1 14.832 2.254c24.718 26.744 43.619 58.944 54.689 94.586 1.667 5.366-.592 11.175-5.459 13.985L444.788 220.9a193.485 193.485 0 0 1 0 70.2zM336 256c0-44.112-35.888-80-80-80s-80 35.888-80 80 35.888 80 80 80 80-35.888 80-80z\\"
>
</path>
</svg>
</span>
</div>
</li>
</ul>
<form class=\\"form-inline mw-100\\"
data-filters
>
<div role=\\"combobox\\"
aria-haspopup=\\"listbox\\"
aria-owns=\\"react-autowhatever-1\\"
aria-expanded=\\"false\\"
class=\\"autosuggest d-inline-block w-100\\"
>
<div class=\\"input-group mr-2\\">
<div class=\\"input-group-prepend\\">
<span class=\\"input-group-text px-2\\">
<svg aria-hidden=\\"true\\"
data-prefix=\\"fas\\"
data-icon=\\"search\\"
class=\\"svg-inline--fa fa-search fa-w-16 \\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 512 512\\"
>
<path fill=\\"currentColor\\"
d=\\"M505 442.7L405.3 343c-4.5-4.5-10.6-7-17-7H372c27.6-35.3 44-79.7 44-128C416 93.1 322.9 0 208 0S0 93.1 0 208s93.1 208 208 208c48.3 0 92.7-16.4 128-44v16.3c0 6.4 2.5 12.5 7 17l99.7 99.7c9.4 9.4 24.6 9.4 33.9 0l28.3-28.3c9.4-9.4 9.4-24.6.1-34zM208 336c-70.7 0-128-57.2-128-128 0-70.7 57.2-128 128-128 70.7 0 128 57.2 128 128 0 70.7-57.2 128-128 128z\\"
>
</path>
</svg>
</span>
</div>
<div class=\\"form-control p-1 components-filterinput\\">
<input type=\\"text\\"
class=\\"components-filterinput-wrapper\\"
placeholder
value
autocomplete=\\"off\\"
aria-autocomplete=\\"list\\"
aria-controls=\\"react-autowhatever-1\\"
>
</div>
<div class=\\"input-group-append\\">
<button class=\\"input-group-text rounded-right cursor-pointer components-navbar-history px-2\\"
type=\\"button\\"
data-toggle=\\"dropdown\\"
aria-haspopup=\\"true\\"
aria-expanded=\\"true\\"
>
<svg aria-hidden=\\"true\\"
data-prefix=\\"fas\\"
data-icon=\\"caret-down\\"
class=\\"svg-inline--fa fa-caret-down fa-w-10 \\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 320 512\\"
>
<path fill=\\"currentColor\\"
d=\\"M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z\\"
>
</path>
</svg>
</button>
</div>
</div>
<div id=\\"react-autowhatever-1\\"
role=\\"listbox\\"
class=\\"dropdown\\"
>
</div>
</div>
</form>
</nav>
</div>
"
`;

View File

@@ -54,17 +54,6 @@ const ValidateNavClass = (totalFilters, expectedClass) => {
};
describe("<NavBar />", () => {
it("matches snapshot with 0 alerts", () => {
const tree = RenderNavbar();
expect(toDiffableHtml(tree.html())).toMatchSnapshot();
});
it("matches snapshot with 5 alerts", () => {
alertStore.info.totalAlerts = 5;
const tree = RenderNavbar();
expect(toDiffableHtml(tree.html())).toMatchSnapshot();
});
it("navbar-brand shows 15 alerts with totalAlerts=15", () => {
alertStore.info.totalAlerts = 15;
const tree = RenderNavbar();

View File

@@ -1,15 +1,11 @@
import React, { Component } from "react";
import ReactDOM from "react-dom";
import PropTypes from "prop-types";
import { observer } from "mobx-react";
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
import { AlertStore } from "Stores/AlertStore";
import { SilenceFormStore, SilenceFormStage } from "Stores/SilenceFormStore";
import { Settings } from "Stores/Settings";
import { MountModalBackdrop } from "Components/Animations/MountModal";
import { SilenceForm } from "./SilenceForm";
import { SilencePreview } from "./SilencePreview";
import { SilenceSubmitController } from "./SilenceSubmit/SilenceSubmitController";
@@ -23,14 +19,6 @@ const SilenceModalContent = observer(
onHide: PropTypes.func.isRequired
};
componentDidMount() {
disableBodyScroll(document.querySelector(".modal"));
}
componentWillUnmount() {
enableBodyScroll(document.querySelector(".modal"));
}
render() {
const {
alertStore,
@@ -39,59 +27,46 @@ const SilenceModalContent = observer(
onHide
} = this.props;
return ReactDOM.createPortal(
return (
<React.Fragment>
<div className="modal d-block" role="dialog">
<div className="modal-dialog modal-lg" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">
{silenceFormStore.data.silenceID === null
? 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}>
<span className="align-middle">&times;</span>
</button>
</div>
<div className="modal-body">
{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>
</div>
<div className="modal-header">
<h5 className="modal-title">
{silenceFormStore.data.silenceID === null
? 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}>
<span className="align-middle">&times;</span>
</button>
</div>
<MountModalBackdrop
in={silenceFormStore.toggle.visible}
unmountOnExit
>
<div className="modal-backdrop d-block" />
</MountModalBackdrop>
</React.Fragment>,
document.body
<div className="modal-body">
{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>
</React.Fragment>
);
}
}

View File

@@ -4,8 +4,6 @@ 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";
@@ -13,7 +11,10 @@ import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclama
import { AlertStore, FormatBackendURI, FormatAlertsQ } from "Stores/AlertStore";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import { StaticLabel } from "Components/Labels/StaticLabel";
import {
LabelSetList,
GroupListToUniqueLabelsList
} from "Components/LabelSetList";
import { MatcherToFilter, AlertManagersToFilter } from "../Matchers";
const FetchError = ({ message }) => (
@@ -28,27 +29,6 @@ FetchError.propTypes = {
message: PropTypes.node.isRequired
};
const Preview = ({ alertStore, labelsList }) => (
<ul className="list-group list-group-flush">
{labelsList.map(labels => (
<li key={hash(labels)} className="list-group-item px-0 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 = {
@@ -64,20 +44,7 @@ const SilencePreview = observer(
// take a list of groups and outputs a list of label sets, this ignores
// the receiver, so we'll end up with only unique alerts
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);
this.alertLabels = GroupListToUniqueLabelsList(groups);
},
setError(value) {
this.error = value;
@@ -135,7 +102,7 @@ const SilencePreview = observer(
silence.
</p>
<div>
<Preview
<LabelSetList
alertStore={alertStore}
labelsList={this.matchedAlerts.alertLabels}
/>

View File

@@ -95,10 +95,10 @@ describe("<SilencePreview />", () => {
tree.update();
expect(tree.find("FetchError")).toHaveLength(1);
expect(consoleSpy).toHaveBeenCalled();
expect(tree.find("Preview")).toHaveLength(0);
expect(tree.find("LabelSetList")).toHaveLength(0);
});
it("renders Preview on successful fetch", async () => {
it("renders LabelSetList on successful fetch", async () => {
fetch.mockResponse(JSON.stringify(MockAPIResponse()));
const tree = MountedSilencePreview();
@@ -106,7 +106,7 @@ describe("<SilencePreview />", () => {
tree.update();
expect(tree.find("FetchError")).toHaveLength(0);
expect(tree.find("Preview")).toHaveLength(1);
expect(tree.find("LabelSetList")).toHaveLength(1);
});
it("clicking on the submit button moves form to the 'Submit' stage", () => {

View File

@@ -9,7 +9,7 @@ import { faBellSlash } from "@fortawesome/free-solid-svg-icons/faBellSlash";
import { AlertStore } from "Stores/AlertStore";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import { Settings } from "Stores/Settings";
import { MountModal } from "Components/Animations/MountModal";
import { Modal } from "Components/Modal";
import { TooltipWrapper } from "Components/TooltipWrapper";
import { SilenceModalContent } from "./SilenceModalContent";
@@ -35,19 +35,6 @@ const SilenceModal = observer(
}
};
componentDidUpdate() {
const { silenceFormStore } = this.props;
document.body.classList.toggle(
"modal-open",
silenceFormStore.toggle.visible
);
}
componentWillUnmount() {
document.body.classList.remove("modal-open");
}
render() {
const { alertStore, silenceFormStore, settingsStore } = this.props;
@@ -63,14 +50,14 @@ const SilenceModal = observer(
</span>
</TooltipWrapper>
</li>
<MountModal in={silenceFormStore.toggle.visible} unmountOnExit>
<Modal isOpen={silenceFormStore.toggle.visible}>
<SilenceModalContent
alertStore={alertStore}
silenceFormStore={silenceFormStore}
settingsStore={settingsStore}
onHide={this.toggleModal}
/>
</MountModal>
</Modal>
</React.Fragment>
);
}

View File

@@ -4,12 +4,7 @@ import { mount } from "enzyme";
import { AlertStore } from "Stores/AlertStore";
import { Settings } from "Stores/Settings";
import {
SilenceFormStore,
SilenceFormStage,
NewEmptyMatcher,
MatcherValueToObject
} from "Stores/SilenceFormStore";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import { SilenceModal } from ".";
let alertStore;
@@ -104,18 +99,4 @@ describe("<SilenceModal />", () => {
tree.unmount();
expect(document.body.className.split(" ")).not.toContain("modal-open");
});
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.currentStage = SilenceFormStage.Preview;
const toggle = tree.find("button.close");
toggle.simulate("click");
expect(silenceFormStore.data.currentStage).toBe(SilenceFormStage.UserInput);
});
});

View File

@@ -192,7 +192,7 @@ type acTestCase struct {
var acTests = []acTestCase{
acTestCase{
Term: "a",
Term: "ale",
Results: []string{
"alertname=Memory_Usage_Too_High",
"alertname=Host_Down",
@@ -204,10 +204,6 @@ var acTests = []acTestCase{
"alertname!=Free_Disk_Space_Too_Low",
"@alertmanager=default",
"@alertmanager!=default",
"@age>1h",
"@age>10m",
"@age<1h",
"@age<10m",
},
},
acTestCase{
@@ -266,30 +262,46 @@ var acTests = []acTestCase{
},
},
acTestCase{
Term: "@",
Term: "@st",
Results: []string{
"@state=suppressed",
"@state=active",
"@state!=suppressed",
"@state!=active",
"@silence_author=~john@example.com",
"@silence_author=john@example.com",
"@silence_author!~john@example.com",
"@silence_author!=john@example.com",
},
},
acTestCase{
Term: "@r",
Results: []string{
"@receiver=by-name",
"@receiver=by-cluster-service",
"@receiver!=by-name",
"@receiver!=by-cluster-service",
"@limit=50",
"@limit=10",
"@alertmanager=default",
"@alertmanager!=default",
},
},
acTestCase{
Term: "@age",
Results: []string{
"@age>1h",
"@age>10m",
"@age<1h",
"@age<10m",
},
},
acTestCase{
Term: "@limit",
Results: []string{
"@limit=50",
"@limit=10",
},
},
acTestCase{
Term: "@alertmanager",
Results: []string{
"@alertmanager=default",
"@alertmanager!=default",
},
},
acTestCase{
Term: "nod",
Results: []string{