diff --git a/internal/alertmanager/dedup_test.go b/internal/alertmanager/dedup_test.go
index 2310fc0c9..3a7d8589a 100644
--- a/internal/alertmanager/dedup_test.go
+++ b/internal/alertmanager/dedup_test.go
@@ -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))
}
diff --git a/internal/alertmanager/models.go b/internal/alertmanager/models.go
index ae1509251..21f5d8a1d 100644
--- a/internal/alertmanager/models.go
+++ b/internal/alertmanager/models.go
@@ -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
}
diff --git a/internal/filters/autocomplete.go b/internal/filters/autocomplete.go
index 2018d0a79..c4c2097aa 100644
--- a/internal/filters/autocomplete.go
+++ b/internal/filters/autocomplete.go
@@ -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
+}
diff --git a/internal/transform/autocomplete_test.go b/internal/filters/autocomplete_test.go
similarity index 91%
rename from internal/transform/autocomplete_test.go
rename to internal/filters/autocomplete_test.go
index fa151a486..1bad262be 100644
--- a/internal/transform/autocomplete_test.go
+++ b/internal/filters/autocomplete_test.go
@@ -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)
}
diff --git a/internal/filters/filter_silence_author.go b/internal/filters/filter_silence_author.go
index d33a65d3f..0ae7f6dd3 100644
--- a/internal/filters/filter_silence_author.go
+++ b/internal/filters/filter_silence_author.go
@@ -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() {
diff --git a/internal/filters/filter_silence_id.go b/internal/filters/filter_silence_id.go
new file mode 100644
index 000000000..2c8b037b3
--- /dev/null
+++ b/internal/filters/filter_silence_id.go
@@ -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
+}
diff --git a/internal/filters/filter_silence_jira.go b/internal/filters/filter_silence_jira.go
index b71f71a54..6c584b421 100644
--- a/internal/filters/filter_silence_jira.go
+++ b/internal/filters/filter_silence_jira.go
@@ -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() {
diff --git a/internal/filters/filter_test.go b/internal/filters/filter_test.go
index 559865c73..9a1f78eb6 100644
--- a/internal/filters/filter_test.go
+++ b/internal/filters/filter_test.go
@@ -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,
diff --git a/internal/filters/registry.go b/internal/filters/registry.go
index a30fca9a9..94a1c7653 100644
--- a/internal/filters/registry.go
+++ b/internal/filters/registry.go
@@ -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",
diff --git a/internal/transform/autocomplete.go b/internal/transform/autocomplete.go
deleted file mode 100644
index 472d24806..000000000
--- a/internal/transform/autocomplete.go
+++ /dev/null
@@ -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
-}
diff --git a/ui/src/Common/Query.js b/ui/src/Common/Query.js
index a57c299cc..9bbba6ae2 100644
--- a/ui/src/Common/Query.js
+++ b/ui/src/Common/Query.js
@@ -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) {
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.js
new file mode 100644
index 000000000..673f588d4
--- /dev/null
+++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.js
@@ -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 }) => (
+
+);
+ErrorMessage.propTypes = {
+ message: PropTypes.node.isRequired
+};
+
+const SuccessMessage = () => (
+
+
+
+ Silence deleted, it might take a few minutes for affected alerts to change
+ state
+
+
+);
+
+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 (
+
+
+
Delete silence
+
+
+
+ {this.deleteState.done ? (
+ this.deleteState.error !== null ? (
+
+ ) : (
+
+ )
+ ) : this.previewState.error === null ? (
+
+
+ Alerts affected by this silence
+
+
+
+ ) : (
+
+ )}
+ {isDone ? null : (
+
+
+
+ )}
+
+
+ );
+ }
+ }
+);
+
+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 (
+
+
+
+ Delete
+
+
+
+
+
+ );
+ }
+ }
+);
+
+export { DeleteSilence, DeleteSilenceModalContent };
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.test.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.test.js
new file mode 100644
index 000000000..68a22b0df
--- /dev/null
+++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.test.js
@@ -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(
+
+ );
+};
+
+const MountedDeleteSilenceModalContent = () => {
+ return mount(
+
+ );
+};
+
+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("", 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("", () => {
+ 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);
+ });
+});
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/__snapshots__/index.test.js.snap b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/__snapshots__/index.test.js.snap
index a50c3fed5..33a2ff890 100644
--- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/__snapshots__/index.test.js.snap
+++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/__snapshots__/index.test.js.snap
@@ -172,7 +172,7 @@ exports[` matches snapshot with expaned details 1`] = `
in 5 hours
-
+
matches snapshot with expaned details 1`] = `
Edit
+
+
+ Delete
+
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js
index 38d5f34af..4e26a0e13 100644
--- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js
+++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js
@@ -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} {silence.endsAt}
Edit
+
@@ -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")(
{this.collapse.value ? null : (
{
return mount(
{
+ 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 ? (
+
+ {labelsList.map(labels => (
+ -
+ {Object.entries(labels).map(([name, value]) => (
+
+ ))}
+
+ ))}
+
+ ) : (
+ No alerts matched
+ );
+LabelSetList.propTypes = {
+ alertStore: PropTypes.instanceOf(AlertStore).isRequired,
+ labelsList: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export { LabelSetList, GroupListToUniqueLabelsList };
diff --git a/ui/src/Components/MainModal/Help.js b/ui/src/Components/MainModal/Help.js
index 55a5150be..db7aa6b95 100644
--- a/ui/src/Components/MainModal/Help.js
+++ b/ui/src/Components/MainModal/Help.js
@@ -8,7 +8,12 @@ const FilterOperatorHelp = ({ operator, description, children }) => (
- Example: key{operator}value
+ Example:{" "}
+
+ key
+ {operator}
+ value
+
{children}
@@ -179,6 +184,19 @@ const Help = () => (
+
+
+ Match alerts suppressed by silence abc123456789.
+
+
+ Match alerts suppressed by any silence except{" "}
+ abc123456789.
+
+
+
(
@@ -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 (
-
-
-
-
-
-
-
- {this.tab.current === TabNames.Help ? : null}
- {this.tab.current === TabNames.Configuration ? (
-
- ) : null}
-
-
-
- Version: {alertStore.info.version}
-
-
-
-
+
+
-
-
-
- ,
- document.body
+
+ {this.tab.current === TabNames.Help ? : null}
+ {this.tab.current === TabNames.Configuration ? (
+
+ ) : null}
+
+
+
+ Version: {alertStore.info.version}
+
+
+
);
}
}
diff --git a/ui/src/Components/MainModal/MainModalContent.test.js b/ui/src/Components/MainModal/MainModalContent.test.js
index f9b033f4a..495f8a683 100644
--- a/ui/src/Components/MainModal/MainModalContent.test.js
+++ b/ui/src/Components/MainModal/MainModalContent.test.js
@@ -28,7 +28,6 @@ const FakeModal = () => {
alertStore={alertStore}
settingsStore={settingsStore}
onHide={onHide}
- isVisible={true}
/>
);
};
@@ -46,7 +45,18 @@ const ValidateSetTab = (title, callArg) => {
describe("
", () => {
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(
+
+
+
+ );
expect(toDiffableHtml(tree.html())).toMatchSnapshot();
});
diff --git a/ui/src/Components/MainModal/__snapshots__/Help.test.js.snap b/ui/src/Components/MainModal/__snapshots__/Help.test.js.snap
index 3fc4afc41..6121c16e5 100644
--- a/ui/src/Components/MainModal/__snapshots__/Help.test.js.snap
+++ b/ui/src/Components/MainModal/__snapshots__/Help.test.js.snap
@@ -474,6 +474,53 @@ exports[`
matches snapshot 1`] = `
+
+ Match suppressed alerts based on the silence ID
+
+
+
+ Supported operators:
+
+ =
+
+
+ !=
+
+
+
+ Examples:
+
+
+ -
+
+
+ @silence_id=abc123456789
+
+
+
+ Match alerts suppressed by silence
+
+ abc123456789
+
+ .
+
+
+ -
+
+
+ @silence_id!=abc123456789
+
+
+
+ Match alerts suppressed by any silence except
+
+ abc123456789
+
+ .
+
+
+
+
Match alerts based on the author of silence
diff --git a/ui/src/Components/MainModal/__snapshots__/MainModalContent.test.js.snap b/ui/src/Components/MainModal/__snapshots__/MainModalContent.test.js.snap
index eff9a7e04..2a6a1d42c 100644
--- a/ui/src/Components/MainModal/__snapshots__/MainModalContent.test.js.snap
+++ b/ui/src/Components/MainModal/__snapshots__/MainModalContent.test.js.snap
@@ -2,129 +2,121 @@
exports[`
matches snapshot 1`] = `
"
-
-
+
+
+
"
`;
diff --git a/ui/src/Components/MainModal/index.js b/ui/src/Components/MainModal/index.js
index 5f8387743..66948d4ed 100644
--- a/ui/src/Components/MainModal/index.js
+++ b/ui/src/Components/MainModal/index.js
@@ -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(
-
+
-
+
);
}
diff --git a/ui/src/Components/Modal/index.js b/ui/src/Components/Modal/index.js
new file mode 100644
index 000000000..7fe8274c2
--- /dev/null
+++ b/ui/src/Components/Modal/index.js
@@ -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(
+
+
+
+
+
+
+
+ ,
+ document.body
+ );
+ }
+ }
+);
+
+export { Modal };
diff --git a/ui/src/Components/Modal/index.test.js b/ui/src/Components/Modal/index.test.js
new file mode 100644
index 000000000..758373ee0
--- /dev/null
+++ b/ui/src/Components/Modal/index.test.js
@@ -0,0 +1,31 @@
+import React from "react";
+
+import { mount } from "enzyme";
+
+import { Modal } from ".";
+
+const MountedModal = isOpen => {
+ return mount(
+
+
+
+ );
+};
+
+describe("
", () => {
+ 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");
+ });
+});
diff --git a/ui/src/Components/NavBar/__snapshots__/index.test.js.snap b/ui/src/Components/NavBar/__snapshots__/index.test.js.snap
deleted file mode 100644
index 918b93382..000000000
--- a/ui/src/Components/NavBar/__snapshots__/index.test.js.snap
+++ /dev/null
@@ -1,283 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`
matches snapshot with 0 alerts 1`] = `
-"
-
-
-
-"
-`;
-
-exports[`
matches snapshot with 5 alerts 1`] = `
-"
-
-
-
-"
-`;
diff --git a/ui/src/Components/NavBar/index.test.js b/ui/src/Components/NavBar/index.test.js
index c416621cf..1beb2bd81 100644
--- a/ui/src/Components/NavBar/index.test.js
+++ b/ui/src/Components/NavBar/index.test.js
@@ -54,17 +54,6 @@ const ValidateNavClass = (totalFilters, expectedClass) => {
};
describe("
", () => {
- 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();
diff --git a/ui/src/Components/SilenceModal/SilenceModalContent.js b/ui/src/Components/SilenceModal/SilenceModalContent.js
index 33243f048..7703e2c2f 100644
--- a/ui/src/Components/SilenceModal/SilenceModalContent.js
+++ b/ui/src/Components/SilenceModal/SilenceModalContent.js
@@ -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 (
-
-
-
-
-
- {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}`}
-
-
-
-
- {silenceFormStore.data.currentStage ===
- SilenceFormStage.UserInput ? (
-
- ) : silenceFormStore.data.currentStage ===
- SilenceFormStage.Preview ? (
-
- ) : (
-
- )}
-
-
-
+
+
+ {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}`}
+
+
-
-
-
- ,
- document.body
+
+ {silenceFormStore.data.currentStage ===
+ SilenceFormStage.UserInput ? (
+
+ ) : silenceFormStore.data.currentStage ===
+ SilenceFormStage.Preview ? (
+
+ ) : (
+
+ )}
+
+
);
}
}
diff --git a/ui/src/Components/SilenceModal/SilencePreview/index.js b/ui/src/Components/SilenceModal/SilencePreview/index.js
index 6f3144736..46fff071d 100644
--- a/ui/src/Components/SilenceModal/SilencePreview/index.js
+++ b/ui/src/Components/SilenceModal/SilencePreview/index.js
@@ -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 }) => (
-
- {labelsList.map(labels => (
- -
- {Object.entries(labels).map(([name, value]) => (
-
- ))}
-
- ))}
-
-);
-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.
-
diff --git a/ui/src/Components/SilenceModal/SilencePreview/index.test.js b/ui/src/Components/SilenceModal/SilencePreview/index.test.js
index 91b10d70a..9288082bc 100644
--- a/ui/src/Components/SilenceModal/SilencePreview/index.test.js
+++ b/ui/src/Components/SilenceModal/SilencePreview/index.test.js
@@ -95,10 +95,10 @@ describe("
", () => {
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("
", () => {
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", () => {
diff --git a/ui/src/Components/SilenceModal/index.js b/ui/src/Components/SilenceModal/index.js
index 9d58e8a2e..b70f13809 100644
--- a/ui/src/Components/SilenceModal/index.js
+++ b/ui/src/Components/SilenceModal/index.js
@@ -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(
-
+
-
+
);
}
diff --git a/ui/src/Components/SilenceModal/index.test.js b/ui/src/Components/SilenceModal/index.test.js
index f31aaf94a..c2aaef799 100644
--- a/ui/src/Components/SilenceModal/index.test.js
+++ b/ui/src/Components/SilenceModal/index.test.js
@@ -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("
", () => {
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);
- });
});
diff --git a/views_test.go b/views_test.go
index 60c8366a6..e36d45ebf 100644
--- a/views_test.go
+++ b/views_test.go
@@ -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{