mirror of
https://github.com/prymitive/karma
synced 2026-05-07 03:26:52 +00:00
Merge pull request #133 from prymitive/silence-delete-form
feat(ui): allow deleting silences (with preview)
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
63
internal/filters/filter_silence_id.go
Normal file
63
internal/filters/filter_silence_id.go
Normal 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
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>×</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 };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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\\">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
53
ui/src/Components/LabelSetList/index.js
Normal file
53
ui/src/Components/LabelSetList/index.js
Normal 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 };
|
||||
@@ -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={["=", "!=", "=~", "!~"]}
|
||||
|
||||
@@ -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>×</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>×</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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
66
ui/src/Components/Modal/index.js
Normal file
66
ui/src/Components/Modal/index.js
Normal 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 };
|
||||
31
ui/src/Components/Modal/index.test.js
Normal file
31
ui/src/Components/Modal/index.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
"
|
||||
`;
|
||||
@@ -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();
|
||||
|
||||
@@ -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">×</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">×</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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user