diff --git a/cmd/karma/main.go b/cmd/karma/main.go index b7bd9482e..2906cd84a 100644 --- a/cmd/karma/main.go +++ b/cmd/karma/main.go @@ -101,6 +101,7 @@ func setupRouter(router *gin.Engine) { router.GET(getViewURL("/autocomplete.json"), autocomplete) router.GET(getViewURL("/labelNames.json"), knownLabelNames) router.GET(getViewURL("/labelValues.json"), knownLabelValues) + router.GET(getViewURL("/silences.json"), silences) router.GET(getViewURL("/custom.css"), customCSS) router.GET(getViewURL("/custom.js"), customJS) diff --git a/cmd/karma/views.go b/cmd/karma/views.go index 12fa28b61..9592e73f0 100644 --- a/cmd/karma/views.go +++ b/cmd/karma/views.go @@ -409,3 +409,79 @@ func autocomplete(c *gin.Context) { c.Data(http.StatusOK, gin.MIMEJSON, data.([]byte)) logAlertsView(c, "MIS", time.Since(start)) } + +func silences(c *gin.Context) { + noCache(c) + + dedupedSilences := []models.ManagedSilence{} + + showExpired := false + showExpiredValue, found := c.GetQuery("showExpired") + if found && showExpiredValue == "1" { + showExpired = true + } + + searchTerm := "" + searchTermValue, found := c.GetQuery("searchTerm") + if found && searchTermValue != "" { + searchTerm = strings.ToLower(searchTermValue) + } + + for _, silence := range alertmanager.DedupSilences() { + if silence.IsExpired && !showExpired { + continue + } + if searchTerm != "" { + isMatch := false + if strings.Contains(strings.ToLower(silence.Silence.Comment), searchTerm) { + isMatch = true + } else if strings.Contains(strings.ToLower(silence.Silence.CreatedBy), searchTerm) { + isMatch = true + } else { + for _, match := range silence.Silence.Matchers { + eq := "=" + if match.IsRegex { + eq = "=~" + } + if searchTerm == fmt.Sprintf("%s%s\"%s\"", strings.ToLower(match.Name), eq, strings.ToLower(match.Value)) { + isMatch = true + } else if searchTerm == fmt.Sprintf("%s%s%s", match.Name, eq, match.Value) { + isMatch = true + } else if strings.Contains(strings.ToLower(match.Name), searchTerm) { + isMatch = true + } else if strings.Contains(strings.ToLower(match.Value), searchTerm) { + isMatch = true + } + } + } + if !isMatch { + continue + } + } + dedupedSilences = append(dedupedSilences, silence) + } + + recentFirst := true + sortReverse, found := c.GetQuery("sortReverse") + if found && sortReverse == "1" { + recentFirst = false + } + + sort.Slice(dedupedSilences, func(i int, j int) bool { + if dedupedSilences[i].Silence.EndsAt.Equal(dedupedSilences[j].Silence.EndsAt) { + if dedupedSilences[i].Silence.StartsAt.Equal(dedupedSilences[j].Silence.StartsAt) { + return dedupedSilences[i].Silence.ID < dedupedSilences[j].Silence.ID + } + return dedupedSilences[i].Silence.StartsAt.After(dedupedSilences[j].Silence.StartsAt) == recentFirst + } + return dedupedSilences[i].Silence.EndsAt.Before(dedupedSilences[j].Silence.EndsAt) == recentFirst + }) + + data, err := json.Marshal(dedupedSilences) + if err != nil { + log.Error(err.Error()) + panic(err) + } + + c.Data(http.StatusOK, gin.MIMEJSON, data) +} diff --git a/cmd/karma/views_test.go b/cmd/karma/views_test.go index 31500a324..ed0c4f22e 100644 --- a/cmd/karma/views_test.go +++ b/cmd/karma/views_test.go @@ -584,3 +584,27 @@ func TestValidateAuthorFromHeaders(t *testing.T) { } } } + +func TestSilences(t *testing.T) { + mockConfig() + for _, version := range mock.ListAllMocks() { + t.Logf("Validating silences.json response using mock files from Alertmanager %s", version) + mockAlerts(version) + r := ginTestEngine() + req := httptest.NewRequest("GET", "/silences.json?showExpired=1&sortReverse=1&searchTerm=a", nil) + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + if resp.Code != http.StatusOK { + t.Errorf("GET /silences.json returned status %d", resp.Code) + } + ur := []models.ManagedSilence{} + body := resp.Body.Bytes() + err := json.Unmarshal(body, &ur) + if err != nil { + t.Errorf("Failed to unmarshal response: %s", err) + } + if len(ur) != 3 { + t.Errorf("Incorrect number of silences: got %d, wanted 3", len(ur)) + } + } +} diff --git a/demo/generator.py b/demo/generator.py index 4ed5bb1e9..5a32ae377 100755 --- a/demo/generator.py +++ b/demo/generator.py @@ -263,7 +263,7 @@ def silences(self): now = datetime.datetime.utcnow().replace(microsecond=0) return [ ( - [newMatcher("alertname", SilencedAlert.name, False)], + [newMatcher("alertname", self.name, False)], "{}Z".format(now.isoformat()), "{}Z".format((now + datetime.timedelta( minutes=30)).isoformat()), @@ -300,7 +300,7 @@ def silences(self): now = datetime.datetime.utcnow().replace(microsecond=0) return [ ( - [newMatcher("alertname", MixedAlerts.name, False), + [newMatcher("alertname", self.name, False), newMatcher("instance", "server(1|3|5|7)", True)], "{}Z".format(now.isoformat()), "{}Z".format((now + datetime.timedelta( @@ -387,8 +387,7 @@ def silences(self): now = datetime.datetime.utcnow().replace(microsecond=0) return [ ( - [newMatcher( - "alertname", SilencedAlertWithJiraLink.name, False)], + [newMatcher("alertname", self.name, False)], "{}Z".format(now.isoformat()), "{}Z".format((now + datetime.timedelta( minutes=30)).isoformat()), @@ -412,6 +411,28 @@ def alerts(self): ) for i in xrange(0, 1000) ] + def silences(self): + now = datetime.datetime.utcnow().replace(microsecond=0) + return [ + ( + [ + newMatcher("alertname", self.name, False), + newMatcher("instance", "server{}".format(i), True) + ], + "{}Z".format(now.isoformat()), + "{}Z".format((now + datetime.timedelta( + minutes=10)).isoformat()), + "me@example.com", + "DEVOPS-123 Pagination Test alert silenced with a long text " + "to see if it gets truncated properly. It only matches first " + "20 alerts. " + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed " + "do eiusmod tempor incididunt ut labore et dolore magna aliqua." + " Ut enim ad minim veniam, quis nostrud exercitation ullamco " + "laboris nisi ut aliquip ex ea commodo consequat. " + ) for i in xrange(0, 20) + ] + if __name__ == "__main__": generators = [ diff --git a/internal/alertmanager/dedup.go b/internal/alertmanager/dedup.go index 42d306332..8c9affa76 100644 --- a/internal/alertmanager/dedup.go +++ b/internal/alertmanager/dedup.go @@ -2,6 +2,7 @@ package alertmanager import ( "sort" + "time" "github.com/prymitive/karma/internal/config" "github.com/prymitive/karma/internal/models" @@ -94,6 +95,40 @@ func DedupAlerts() []models.AlertGroup { return dedupedGroups } +// DedupKnownLabels returns a deduplicated slice of all known label names +func DedupSilences() []models.ManagedSilence { + silenceByCluster := map[string]map[string]models.Silence{} + upstreams := GetAlertmanagers() + + for _, am := range upstreams { + for id, silence := range am.Silences() { + cluster := am.ClusterID() + + if _, found := silenceByCluster[cluster]; !found { + silenceByCluster[cluster] = map[string]models.Silence{} + } + + if _, ok := silenceByCluster[cluster][id]; !ok { + silenceByCluster[cluster][id] = silence + } + } + } + + now := time.Now() + dedupedSilences := []models.ManagedSilence{} + for cluster, silenceMap := range silenceByCluster { + for _, silence := range silenceMap { + managedSilence := models.ManagedSilence{ + Cluster: cluster, + IsExpired: silence.EndsAt.Before(now), + Silence: silence, + } + dedupedSilences = append(dedupedSilences, managedSilence) + } + } + return dedupedSilences +} + // DedupColors returns a color map merged from all Alertmanager upstream color // maps func DedupColors() models.LabelsColorMap { diff --git a/internal/alertmanager/dedup_test.go b/internal/alertmanager/dedup_test.go index 77a9f470e..91145d49d 100644 --- a/internal/alertmanager/dedup_test.go +++ b/internal/alertmanager/dedup_test.go @@ -90,6 +90,19 @@ func TestDedupAlertsWithoutLabels(t *testing.T) { } } +func TestDedupSilences(t *testing.T) { + os.Setenv("ALERTMANAGER_URI", "http://localhost") + config.Config.Read() + if err := pullAlerts(); err != nil { + t.Error(err) + } + silences := alertmanager.DedupSilences() + expected := 72 + if len(silences) != expected { + t.Errorf("Expected %d silences keys, got %d", expected, len(silences)) + } +} + func TestDedupAutocomplete(t *testing.T) { if err := pullAlerts(); err != nil { t.Error(err) @@ -121,6 +134,32 @@ func TestDedupColors(t *testing.T) { } } +func TestDedupKnownLabels(t *testing.T) { + os.Setenv("ALERTMANAGER_URI", "http://localhost") + config.Config.Read() + if err := pullAlerts(); err != nil { + t.Error(err) + } + labels := alertmanager.DedupKnownLabels() + expected := 6 + if len(labels) != expected { + t.Errorf("Expected %d knownLabels keys, got %d", expected, len(labels)) + } +} + +func TestDedupKnownLabelValues(t *testing.T) { + os.Setenv("ALERTMANAGER_URI", "http://localhost") + config.Config.Read() + if err := pullAlerts(); err != nil { + t.Error(err) + } + values := alertmanager.DedupKnownLabelValues("alertname") + expected := 4 + if len(values) != expected { + t.Errorf("Expected %d knownLabelValues keys, got %d", expected, len(values)) + } +} + func TestStripReceivers(t *testing.T) { os.Setenv("RECEIVERS_STRIP", "by-name by-cluster-service") os.Setenv("ALERTMANAGER_URI", "http://localhost") diff --git a/internal/models/silence.go b/internal/models/silence.go index 4187d3dba..223507c6b 100644 --- a/internal/models/silence.go +++ b/internal/models/silence.go @@ -24,3 +24,10 @@ type Silence struct { JiraID string `json:"jiraID"` JiraURL string `json:"jiraURL"` } + +// ManagedSilence is a standalone silence detached from any alert +type ManagedSilence struct { + Cluster string `json:"cluster"` + IsExpired bool `json:"isExpired"` + Silence Silence `json:"silence"` +} diff --git a/ui/package-lock.json b/ui/package-lock.json index 8addab363..8dfd22ee1 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -8159,6 +8159,34 @@ "pend": "~1.2.0" } }, + "fetch-mock": { + "version": "8.0.0-alpha.5", + "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-8.0.0-alpha.5.tgz", + "integrity": "sha512-/FShpzvtDt/535q+9un5Ve+GGNsCKX3AW9CQN5htZ2eLY11E7yM///SrXoK8QteETSnKbT2N0vyHHIiRcVi7/A==", + "dev": true, + "requires": { + "babel-runtime": "^6.26.0", + "core-js": "^3.0.0", + "glob-to-regexp": "^0.4.0", + "lodash.isequal": "^4.5.0", + "path-to-regexp": "^2.2.1", + "whatwg-url": "^6.5.0" + }, + "dependencies": { + "glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, + "path-to-regexp": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.4.0.tgz", + "integrity": "sha512-G6zHoVqC6GGTQkZwF4lkuEyMbVOjoBKAEybQUypI1WTkqinCOrq2x6U2+phkJ1XsEMTy4LjtwPI7HW+NVrRR2w==", + "dev": true + } + } + }, "figgy-pudding": { "version": "3.5.1", "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.1.tgz", diff --git a/ui/package.json b/ui/package.json index a86082606..fb82e43c8 100644 --- a/ui/package.json +++ b/ui/package.json @@ -82,6 +82,7 @@ "enzyme": "3.10.0", "enzyme-adapter-react-16": "1.15.1", "eslint-plugin-prettier": "3.1.1", + "fetch-mock": "8.0.0-alpha.5", "jest-canvas-mock": "2.1.2", "jest-date-mock": "1.0.7", "jest-fetch-mock": "2.1.2", diff --git a/ui/src/App.scss b/ui/src/App.scss index 7d0ac387d..3d47c6ddb 100644 --- a/ui/src/App.scss +++ b/ui/src/App.scss @@ -1,6 +1,8 @@ // bundled font assets, so we don't need to talk to Google Fonts API @import "src/Fonts.scss"; +$lead-font-weight: 400; + // custom "dark" color, little less dark than flatly $blue: #455a64; // body background color should be same as navbar, so it blends into one @@ -17,6 +19,15 @@ $dark: #3b4247; // fix active tab color, for some reason it ends up with $primary as bg color $nav-tabs-link-active-bg: #fff; +// pagination tweaks +$pagination-color: #fff; +$pagination-bg: #b4bcc2; // gray-500 +$pagination-hover-color: #fff; +$pagination-hover-bg: #7b8a8b; // gray-700 +$pagination-active-bg: $pagination-hover-bg; +$pagination-disabled-color: #fff; +$pagination-disabled-bg: #dee2e6; // gray-300 + @import "~bootswatch/dist/flatly/variables"; @import "~bootstrap/scss/bootstrap"; @import "~bootswatch/dist/flatly/bootswatch"; diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.js index 9c9847b67..cd8e45c11 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.js @@ -14,8 +14,8 @@ import { StaticLabels } from "Common/Query"; import { FilteringLabel } from "Components/Labels/FilteringLabel"; import { TooltipWrapper } from "Components/TooltipWrapper"; import { RenderNonLinkAnnotation, RenderLinkAnnotation } from "../Annotation"; -import { Silence } from "../Silence"; import { AlertMenu } from "./AlertMenu"; +import { RenderSilence } from "../Silences"; import "./index.scss"; @@ -132,16 +132,16 @@ const Alert = observer( value={a.value} /> ))} - {Object.values(silences).map(clusterSilences => - clusterSilences.silences.map(silenceID => ( - - )) + {Object.entries(silences).map(([cluster, clusterSilences]) => + clusterSilences.silences.map(silenceID => + RenderSilence( + alertStore, + silenceFormStore, + afterUpdate, + cluster, + silenceID + ) + ) )} ); diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.test.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.test.js index f62772480..9e107b427 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.test.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.test.js @@ -10,7 +10,12 @@ import toDiffableHtml from "diffable-html"; import Moment from "react-moment"; -import { MockAlert, MockAnnotation, MockAlertGroup } from "__mocks__/Alerts.js"; +import { + MockAlert, + MockAnnotation, + MockAlertGroup, + MockSilence +} from "__mocks__/Alerts.js"; import { AlertStore } from "Stores/AlertStore"; import { SilenceFormStore } from "Stores/SilenceFormStore"; import { BorderClassMap } from "Common/Colors"; @@ -110,11 +115,46 @@ describe("", () => { it("renders a silence if alert is silenced", () => { const alert = MockedAlert(); alert.alertmanager[0].silencedBy = ["silence123456789"]; + alertStore.data.silences = { + default: { + silence123456789: MockSilence() + } + }; + const group = MockAlertGroup({}, [alert], [], {}, { default: [] }); + const tree = MountedAlert(alert, group, false, false); + const silence = tree.find("ManagedSilence"); + expect(silence).toHaveLength(1); + expect(silence.html()).toMatch(/Mocked Silence/); + }); + + it("renders a fallback silence if the silence is not found in alertStore", () => { + const alert = MockedAlert(); + alert.alertmanager[0].silencedBy = ["silence123456789"]; + alertStore.data.silences = { + default: { + "123": MockSilence() + } + }; const group = MockAlertGroup({}, [alert], [], {}, { default: [] }); const tree = MountedAlert(alert, group, false, false); - const silence = tree.find("Silence"); + const silence = tree.find("FallbackSilenceDesciption"); expect(silence).toHaveLength(1); - expect(silence.html()).toMatch(/silence123456789/); + expect(silence.html()).not.toMatch(/Mocked Silence/); + }); + + it("renders a fallback silence if the cluster is not found in alertStore", () => { + const alert = MockedAlert(); + alert.alertmanager[0].silencedBy = ["silence123456789"]; + alertStore.data.silences = { + foo: { + "123": MockSilence() + } + }; + const group = MockAlertGroup({}, [alert], [], {}, { default: [] }); + const tree = MountedAlert(alert, group, false, false); + const silence = tree.find("FallbackSilenceDesciption"); + expect(silence).toHaveLength(1); + expect(silence.html()).not.toMatch(/Mocked Silence/); }); it("renders only one silence for HA cluster", () => { @@ -139,11 +179,16 @@ describe("", () => { inhibitedBy: [] } ]; + alertStore.data.silences = { + ha: { + silence123456789: MockSilence() + } + }; const group = MockAlertGroup({}, [alert], [], {}, {}); const tree = MountedAlert(alert, group, false, false); - const silence = tree.find("Silence"); + const silence = tree.find("ManagedSilence"); expect(silence).toHaveLength(1); - expect(silence.html()).toMatch(/silence123456789/); + expect(silence.html()).toMatch(/Mocked Silence/); }); it("doesn't render shared silences", () => { @@ -157,7 +202,7 @@ describe("", () => { { default: ["silence123456789"] } ); const tree = MountedAlert(alert, group, false, false); - const silence = tree.find("Silence"); + const silence = tree.find("ManagedSilence"); expect(silence).toHaveLength(0); }); diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/__snapshots__/index.test.js.snap b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/__snapshots__/index.test.js.snap index a6f4d0a82..c96e4818a 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/__snapshots__/index.test.js.snap +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/__snapshots__/index.test.js.snap @@ -152,7 +152,7 @@ exports[` mathes snapshot when silence is rendered 1`] = `
@@ -183,7 +183,7 @@ exports[` mathes snapshot when silence is rendered 1`] = `
@@ -208,7 +208,7 @@ exports[` mathes snapshot when silence is rendered 1`] = `
@@ -223,7 +223,7 @@ exports[` mathes snapshot when silence is rendered 1`] = `
@@ -238,7 +238,7 @@ exports[` mathes snapshot when silence is rendered 1`] = `
@@ -253,7 +253,7 @@ exports[` mathes snapshot when silence is rendered 1`] = `
@@ -286,55 +286,74 @@ exports[` mathes snapshot when silence is rendered 1`] = ` link -
-
-
- - - - - - Mocked Silence - - - … - - - - -
- - +
+
+
+
+
+
+
+ + + + +
+
+ - - + + + + Mocked Silence + + + … + + + + + me@example.com + + + Expired + + + +
- - - me@example.com - - - Expired - - - - +
+
+
+ + + + +
+
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.js index f9e6e82d3..62288f65b 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.js @@ -5,10 +5,11 @@ import { observer } from "mobx-react"; import { APIGroup } from "Models/API"; import { StaticLabels } from "Common/Query"; +import { AlertStore } from "Stores/AlertStore"; import { SilenceFormStore } from "Stores/SilenceFormStore"; import { FilteringLabel } from "Components/Labels/FilteringLabel"; import { RenderNonLinkAnnotation, RenderLinkAnnotation } from "../Annotation"; -import { Silence } from "../Silence"; +import { RenderSilence } from "../Silences"; import "./index.css"; @@ -18,6 +19,7 @@ const GroupFooter = observer( group: APIGroup.isRequired, alertmanagers: PropTypes.arrayOf(PropTypes.string).isRequired, afterUpdate: PropTypes.func.isRequired, + alertStore: PropTypes.instanceOf(AlertStore).isRequired, silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired }; @@ -26,6 +28,7 @@ const GroupFooter = observer( group, alertmanagers, afterUpdate, + alertStore, silenceFormStore } = this.props; @@ -65,25 +68,18 @@ const GroupFooter = observer( /> ))} {Object.keys(group.shared.silences).length === 0 ? null : ( -
+
{Object.entries(group.shared.silences).map( ([cluster, silences]) => - silences.map(silenceID => ( - - a.alertmanager.filter( - am => am.cluster === cluster - )[0] - )[0] - } - silenceID={silenceID} - afterUpdate={afterUpdate} - /> - )) + silences.map(silenceID => + RenderSilence( + alertStore, + silenceFormStore, + afterUpdate, + cluster, + silenceID + ) + ) )}
)} diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.test.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.test.js index 0849fc0ea..8da6f29e3 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.test.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/index.test.js @@ -64,6 +64,7 @@ const MountedGroupFooter = () => { group={group} alertmanagers={["default"]} afterUpdate={MockAfterUpdate} + alertStore={alertStore} silenceFormStore={silenceFormStore} /> @@ -81,8 +82,40 @@ describe("", () => { group.alerts[id].alertmanager[0].silencedBy = ["123456789"]; } group.shared.silences = { default: ["123456789"] }; + alertStore.data.silences = { + default: { + "123456789": MockSilence() + } + }; + + const tree = MountedGroupFooter().find("GroupFooter"); + expect(tree.find("ManagedSilence")).toHaveLength(1); + }); + + it("render fallback silence if not found in alertStore", () => { + for (const id of Object.keys(group.alerts)) { + group.alerts[id].alertmanager[0].silencedBy = ["123456789"]; + } + group.shared.silences = { default: ["123456789"] }; + alertStore.data.silences = { + default: {} + }; + + const tree = MountedGroupFooter().find("GroupFooter"); + expect(tree.find("FallbackSilenceDesciption")).toHaveLength(1); + }); + + it("render fallback silence if cluster not found in alertStore", () => { + for (const id of Object.keys(group.alerts)) { + group.alerts[id].alertmanager[0].silencedBy = ["123456789"]; + } + group.shared.silences = { default: ["123456789"] }; + alertStore.data.silences = { + foo: {} + }; + const tree = MountedGroupFooter().find("GroupFooter"); - expect(tree.find("Silence")).toHaveLength(1); + expect(tree.find("FallbackSilenceDesciption")).toHaveLength(1); }); it("mathes snapshot when silence is rendered", () => { 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 deleted file mode 100644 index cf84ad9ab..000000000 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/__snapshots__/index.test.js.snap +++ /dev/null @@ -1,272 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` matches snapshot when data is not present in alertStore 1`] = ` -" -
- - Silenced by default/4cf5fd82-1edd-4169-99d1-ff8415e72179 - -
-" -`; - -exports[` matches snapshot when data is present in alertStore 1`] = ` -" -
-
- - - - - - Fake silence - - - … - - - - -
- - - - -
-
- - me@example.com - - - Expires - -
-
-
-
-
-
-
-
-
-" -`; - -exports[` matches snapshot with expaned details 1`] = ` -" -
-
- - - - - - Fake silence - - - … - - - - -
- - - - -
-
- - me@example.com - -
-
-
-
-
-
- - - @alertmanager: - - - default - - -
- - - - - - 4cf5fd82-1edd-4169-99d1-ff8415e72179 - -
-
- - - - - - Started - - - - - - - - Expires - - - - - - - - Edit - - - - - - - Delete - -
-
-
- - - - - - Matchers: - -
-
- - alertname=MockAlert - - - instance=~foo[0-9]+ - -
-
-
-
-" -`; diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.css b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.css deleted file mode 100644 index d88dc1c88..000000000 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.css +++ /dev/null @@ -1,8 +0,0 @@ -.progress.silence-progress { - height: 2px; - margin-top: 2px; -} - -.cite.components-grid-alertgroup-silences { - font-size: 100%; -} diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js deleted file mode 100644 index afae05196..000000000 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js +++ /dev/null @@ -1,370 +0,0 @@ -import React, { Component } from "react"; -import PropTypes from "prop-types"; - -import { observable, action } from "mobx"; -import { observer, inject } from "mobx-react"; - -import hash from "object-hash"; - -import moment from "moment"; -import Moment from "react-moment"; - -import Truncate from "react-truncate"; - -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons/faExternalLinkAlt"; -import { faChevronUp } from "@fortawesome/free-solid-svg-icons/faChevronUp"; -import { faChevronDown } from "@fortawesome/free-solid-svg-icons/faChevronDown"; -import { faEdit } from "@fortawesome/free-solid-svg-icons/faEdit"; -import { faCalendarCheck } from "@fortawesome/free-solid-svg-icons/faCalendarCheck"; -import { faCalendarTimes } from "@fortawesome/free-solid-svg-icons/faCalendarTimes"; -import { faFilter } from "@fortawesome/free-solid-svg-icons/faFilter"; - -import { - APIAlertAlertmanagerState, - APIAlertmanagerUpstream, - APISilence -} from "Models/API"; -import { AlertStore } from "Stores/AlertStore"; -import { SilenceFormStore } from "Stores/SilenceFormStore"; -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"; - -const SilenceComment = ({ silence, collapsed, afterUpdate }) => { - const showLines = 2; - if (silence.jiraURL) { - return ( - - - - {silence.comment} - - - ); - } - return ( - {silence.comment} - ); -}; -SilenceComment.propTypes = { - silence: APISilence.isRequired, - collapsed: PropTypes.bool.isRequired, - afterUpdate: PropTypes.func.isRequired -}; - -const SilenceExpiryBadgeWithProgress = ({ silence, progress }) => { - // if silence is expired we can skip progress value calculation - if (moment(silence.endsAt) < moment()) { - return ( - - Expired {silence.endsAt} - - ); - } - - let progressClass; - if (progress > 90) { - progressClass = "progress-bar bg-danger"; - } else if (progress > 75) { - progressClass = "progress-bar bg-warning"; - } else { - progressClass = "progress-bar bg-success"; - } - - return ( - - Expires {silence.endsAt} -
-
-
- - ); -}; -SilenceExpiryBadgeWithProgress.propTypes = { - silence: APISilence.isRequired, - progress: PropTypes.number.isRequired -}; - -const SilenceDetails = ({ - alertStore, - alertmanager, - silence, - onEditSilence -}) => { - let expiresClass = ""; - let expiresLabel = "Expires"; - if (moment(silence.endsAt) < moment()) { - expiresClass = "text-danger"; - expiresLabel = "Expired"; - } - - return ( -
-
- - -
-
- - - Started {silence.startsAt} - - - - {expiresLabel} {silence.endsAt} - - - - Edit - - -
-
-
- - - Matchers: - -
-
- {silence.matchers.map(matcher => ( - - {matcher.name} - {matcher.isRegex ? QueryOperators.Regex : QueryOperators.Equal} - {matcher.value} - - ))} -
-
-
- ); -}; -SilenceDetails.propTypes = { - alertmanager: APIAlertmanagerUpstream.isRequired, - silence: APISilence.isRequired, - onEditSilence: PropTypes.func.isRequired -}; - -// -const FallbackSilenceDesciption = ({ alertmanagerName, silenceID }) => { - return ( -
- - Silenced by {alertmanagerName}/{silenceID} - -
- ); -}; -FallbackSilenceDesciption.propTypes = { - alertmanagerName: PropTypes.string.isRequired, - silenceID: PropTypes.string.isRequired -}; - -const Silence = inject("alertStore")( - observer( - class Silence extends Component { - static propTypes = { - alertStore: PropTypes.instanceOf(AlertStore).isRequired, - silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired, - alertmanagerState: APIAlertAlertmanagerState.isRequired, - silenceID: PropTypes.string.isRequired, - afterUpdate: PropTypes.func.isRequired - }; - - // store collapse state, by default only silence comment is visible - // the rest of the silence is hidden until expanded by a click - collapse = observable( - { - value: true, - toggle() { - this.value = !this.value; - } - }, - { toggle: action.bound }, - { name: "Silence collpase toggle" } - ); - - progress = observable( - { - value: 0, - calculate(startsAt, endsAt) { - const durationDone = moment().unix() - moment(startsAt).unix(); - const durationTotal = - moment(endsAt).unix() - moment(startsAt).unix(); - const durationPercent = Math.floor( - (durationDone / durationTotal) * 100 - ); - if (this.value !== durationPercent) { - this.value = durationPercent; - } - } - }, - { - calculate: action.bound - } - ); - - constructor(props) { - super(props); - - this.recalculateProgress(); - this.progressTimer = setInterval(this.recalculateProgress, 30 * 1000); - } - - getAlertmanager = () => { - const { alertStore, alertmanagerState } = this.props; - - const alertmanager = alertStore.data.getAlertmanagerByName( - alertmanagerState.name - ); - - if (alertmanager) return alertmanager; - - return { - name: alertmanagerState.name - }; - }; - - getSilence = () => { - const { alertStore, alertmanagerState, silenceID } = this.props; - - // We pass alertmanager name and silence ID to Silence component - // and we need to lookup the actual silence data in the store. - // Data might be missing from the store so first check if we have - // anything for this alertmanager instance - const amSilences = alertStore.data.silences[alertmanagerState.cluster]; - if (!amSilences) return null; - - // next check if alertmanager has our silence ID - const silence = amSilences[silenceID]; - if (!silence) return null; - - return silence; - }; - - recalculateProgress = () => { - const silence = this.getSilence(); - if (silence !== null) { - this.progress.calculate(silence.startsAt, silence.endsAt); - } - }; - - onEditSilence = () => { - const { silenceFormStore } = this.props; - - const silence = this.getSilence(); - const alertmanager = this.getAlertmanager(); - - silenceFormStore.data.fillFormFromSilence(alertmanager, silence); - silenceFormStore.data.resetProgress(); - silenceFormStore.toggle.show(); - }; - - componentDidUpdate() { - const { afterUpdate } = this.props; - afterUpdate(); - } - - componentWillUnmount() { - clearInterval(this.progressTimer); - this.progressTimer = null; - } - - render() { - const { - alertStore, - alertmanagerState, - silenceID, - afterUpdate - } = this.props; - - const silence = this.getSilence(); - if (!silence) - return ( - - ); - - const alertmanager = this.getAlertmanager(); - - return ( -
-
- - - - - - - - - - {silence.createdBy} - - {this.collapse.value ? ( - - ) : null} - - -
- {this.collapse.value ? null : ( - - )} -
- ); - } - } - ) -); - -export { Silence, SilenceDetails, SilenceExpiryBadgeWithProgress }; diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.test.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.test.js deleted file mode 100644 index d877deeb2..000000000 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.test.js +++ /dev/null @@ -1,306 +0,0 @@ -import React from "react"; - -import { toJS } from "mobx"; -import { Provider } from "mobx-react"; - -import { mount } from "enzyme"; - -import toDiffableHtml from "diffable-html"; - -import moment from "moment"; -import { advanceTo, clear } from "jest-date-mock"; - -import { AlertStore } from "Stores/AlertStore"; -import { SilenceFormStore } from "Stores/SilenceFormStore"; -import { Silence, SilenceDetails } from "."; - -const mockAfterUpdate = jest.fn(); - -const alertmanager = { - name: "default", - cluster: "default", - state: "suppressed", - startsAt: "2000-01-01T10:00:00Z", - source: "localhost/prometheus", - silencedBy: ["4cf5fd82-1edd-4169-99d1-ff8415e72179"], - inhibitedBy: [] -}; - -const silence = { - id: "4cf5fd82-1edd-4169-99d1-ff8415e72179", - matchers: [ - { - name: "alertname", - value: "MockAlert", - isRegex: false - }, - { - name: "instance", - value: "foo[0-9]+", - isRegex: true - } - ], - startsAt: "2000-01-01T10:00:00Z", - endsAt: "2000-01-01T20:00:00Z", - createdAt: "0001-01-01T00:00:00Z", - createdBy: "me@example.com", - comment: "Fake silence", - jiraID: "", - jiraURL: "" -}; - -let alertStore; -let silenceFormStore; - -beforeEach(() => { - advanceTo(moment.utc([2000, 0, 1, 15, 0, 0])); - alertStore = new AlertStore([]); - alertStore.data.upstreams = { - counters: { - total: 1, - healthy: 1, - failed: 0 - }, - instances: [ - { - name: "default", - cluster: "default", - uri: "file:///mock", - publicURI: "http://example.com", - headers: {}, - error: "", - version: "0.15.0", - clusterMembers: ["default"] - } - ], - clusters: { default: ["default"] } - }; - alertStore.data.silences = { - default: { - "4cf5fd82-1edd-4169-99d1-ff8415e72179": silence - } - }; - silenceFormStore = new SilenceFormStore(); -}); - -afterEach(() => { - jest.restoreAllMocks(); - // reset Date() to current time - clear(); -}); - -const MountedSilence = alertmanagerState => { - return mount( - - - - ); -}; - -const MountedSilenceDetails = onEditSilence => { - return mount( - - - - ).find("SilenceDetails"); -}; - -describe("", () => { - it("matches snapshot when data is present in alertStore", () => { - const tree = MountedSilence(alertmanager).find("Silence"); - expect(toDiffableHtml(tree.html())).toMatchSnapshot(); - }); - - it("renders full silence when data is present in alertStore", () => { - const tree = MountedSilence(alertmanager).find("Silence"); - const fallback = tree.find("FallbackSilenceDesciption"); - expect(fallback).toHaveLength(0); - }); - - it("matches snapshot when data is not present in alertStore", () => { - alertStore.data.silences = {}; - const tree = MountedSilence(alertmanager).find("Silence"); - expect(toDiffableHtml(tree.html())).toMatchSnapshot(); - }); - - it("renders FallbackSilenceDesciption when Alertmanager data is not present in alertStore", () => { - alertStore.data.silences = {}; - const tree = MountedSilence(alertmanager); - const fallback = tree.find("FallbackSilenceDesciption"); - expect(fallback).toHaveLength(1); - expect(tree.text()).toBe( - "Silenced by default/4cf5fd82-1edd-4169-99d1-ff8415e72179" - ); - }); - - it("renders FallbackSilenceDesciption when silence data is not present in alertStore", () => { - alertStore.data.silences.default = {}; - const tree = MountedSilence(alertmanager); - const fallback = tree.find("FallbackSilenceDesciption"); - expect(fallback).toHaveLength(1); - expect(tree.text()).toBe( - "Silenced by default/4cf5fd82-1edd-4169-99d1-ff8415e72179" - ); - }); - - it("clicking on expand toggle shows silence details", () => { - const tree = MountedSilence(alertmanager); - const toggle = tree.find(".float-right.cursor-pointer"); - toggle.simulate("click"); - const details = tree.find("SilenceDetails"); - expect(details).toHaveLength(1); - }); - - it("matches snapshot with expaned details", () => { - const tree = MountedSilence(alertmanager).find("Silence"); - tree.instance().collapse.toggle(); - expect(toDiffableHtml(tree.html())).toMatchSnapshot(); - }); - - it("renders comment as link when jiraURL is set and silence is collapsed", () => { - alertStore.data.silences.default[silence.id].jiraURL = - "http://jira.example.com"; - const tree = MountedSilence(alertmanager).find("Silence"); - const link = tree.find("a[href='http://jira.example.com']"); - expect(link).toHaveLength(1); - expect(link.text()).toBe("Fake silence…"); - }); - - it("renders comment as link when jiraURL is set and silence is expaned", () => { - alertStore.data.silences.default[silence.id].jiraURL = - "http://jira.example.com"; - const tree = MountedSilence(alertmanager).find("Silence"); - tree.instance().collapse.toggle(); - const link = tree.find("a[href='http://jira.example.com']"); - expect(link).toHaveLength(1); - expect(link.text()).toBe("Fake silence…"); - }); - - it("clears progress timer on unmount", () => { - const tree = MountedSilence(alertmanager).find("Silence"); - const instance = tree.instance(); - expect(instance.progressTimer).toBeTruthy(); - instance.componentWillUnmount(); - expect(instance.progressTimer).toBeNull(); - }); - - it("getAlertmanager() returns alertmanager object from alertStore.data.upstreams.instances", () => { - const tree = MountedSilence(alertmanager).find("Silence"); - const instance = tree.instance(); - const am = instance.getAlertmanager(); - expect(am).toEqual({ - name: "default", - cluster: "default", - uri: "file:///mock", - publicURI: "http://example.com", - headers: {}, - error: "", - version: "0.15.0", - clusterMembers: ["default"] - }); - }); - - it("getAlertmanager() return object with only name if given name is not in alertStore", () => { - const missingAlertmanager = { ...alertmanager, name: "notDefault" }; - const tree = MountedSilence(missingAlertmanager).find("Silence"); - const instance = tree.instance(); - const am = instance.getAlertmanager(); - expect(am).toEqual({ - name: "notDefault" - }); - }); - - it("clicking on silence edit button calls silenceFormStore.data.fillFormFromSilence", () => { - const fillSpy = jest.spyOn(silenceFormStore.data, "fillFormFromSilence"); - const tree = MountedSilence(alertmanager); - - // expand silence - tree.find(".float-right.cursor-pointer").simulate("click"); - - const button = tree.find(".badge-secondary.components-label-with-hover"); - expect(button.text()).toBe("Edit"); - button.simulate("click"); - expect(fillSpy).toHaveBeenCalled(); - }); - - it("clicking on silence edit button opens the silence form", () => { - const tree = MountedSilence(alertmanager); - - // expand silence - tree.find(".float-right.cursor-pointer").simulate("click"); - - const button = tree.find(".badge-secondary.components-label-with-hover"); - expect(button.text()).toBe("Edit"); - button.simulate("click"); - expect(silenceFormStore.toggle.visible).toBe(true); - }); -}); - -describe("", () => { - it("unexpired silence endsAt label doesn't use 'danger' class", () => { - const tree = MountedSilenceDetails(jest.fn()); - const endsAt = tree.find("span.badge").at(1); - expect(endsAt.html()).not.toMatch(/text-danger/); - }); - - it("expired silence endsAt label uses 'danger' class", () => { - advanceTo(moment.utc([2000, 0, 1, 23, 0, 0])); - const tree = MountedSilenceDetails(jest.fn()); - const endsAt = tree.find("span.badge").at(2); - expect(endsAt.html()).toMatch(/text-danger/); - }); - - it("id links to Alertmanager silence view via alertmanager.publicURI", () => { - const tree = MountedSilenceDetails(jest.fn()); - const link = tree.find("a"); - expect(link.props().href).toBe( - "http://example.com/#/silences/4cf5fd82-1edd-4169-99d1-ff8415e72179" - ); - }); -}); - -describe("", () => { - it("renders with class 'danger' and no progressbar when expired", () => { - advanceTo(moment.utc([2001, 0, 1, 23, 0, 0])); - const tree = MountedSilence(alertmanager); - expect(tree.html()).toMatch(/badge-danger/); - expect(tree.text()).toMatch(/Expired a year ago/); - }); - - it("progressbar uses class 'danger' when > 90%", () => { - advanceTo(moment.utc([2000, 0, 1, 19, 30, 0])); - const tree = MountedSilence(alertmanager); - expect(tree.html()).toMatch(/progress-bar bg-danger/); - }); - - it("progressbar uses class 'danger' when > 75%", () => { - advanceTo(moment.utc([2000, 0, 1, 17, 45, 0])); - const tree = MountedSilence(alertmanager); - expect(tree.html()).toMatch(/progress-bar bg-warning/); - }); - - it("calling calculate() on progress multiple times in a row doesn't change the value", () => { - const startsAt = moment.utc([2000, 0, 1, 10, 0, 0]); - const endsAt = moment.utc([2000, 0, 1, 20, 0, 0]); - - const tree = MountedSilence(alertmanager).find("Silence"); - const instance = tree.instance(); - - const value = toJS(instance.progress.value); - instance.progress.calculate(startsAt, endsAt); - instance.progress.calculate(startsAt, endsAt); - instance.progress.calculate(startsAt, endsAt); - expect(toJS(instance.progress.value)).toBe(value); - }); -}); diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silences.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silences.js new file mode 100644 index 000000000..e14db7cdb --- /dev/null +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silences.js @@ -0,0 +1,57 @@ +import React from "react"; +import PropTypes from "prop-types"; + +import { ManagedSilence } from "Components/ManagedSilence"; +const FallbackSilenceDesciption = ({ silenceID }) => { + return ( +
+ Silenced by {silenceID} +
+ ); +}; +FallbackSilenceDesciption.propTypes = { + silenceID: PropTypes.string.isRequired +}; + +const GetSilenceFromStore = (alertStore, cluster, silenceID) => { + const amSilences = alertStore.data.silences[cluster]; + if (!amSilences) return null; + + // next check if alertmanager has our silence ID + const silence = amSilences[silenceID]; + if (!silence) return null; + + return silence; +}; + +const RenderSilence = ( + alertStore, + silenceFormStore, + afterUpdate, + cluster, + silenceID +) => { + const silence = GetSilenceFromStore(alertStore, cluster, silenceID); + + if (silence === null) { + return ( + + ); + } + + return ( + + ); +}; + +export { RenderSilence }; diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/index.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/index.js index 3e0cc34a1..5441f759e 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/index.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/index.js @@ -273,6 +273,7 @@ const AlertGroup = observer( group={group} alertmanagers={footerAlertmanagers} afterUpdate={afterUpdate} + alertStore={alertStore} silenceFormStore={silenceFormStore} /> ) : null} diff --git a/ui/src/Components/Grid/FatalError/__snapshots__/index.test.js.snap b/ui/src/Components/Grid/FatalError/__snapshots__/index.test.js.snap index 27f4bb56a..634c3c8c4 100644 --- a/ui/src/Components/Grid/FatalError/__snapshots__/index.test.js.snap +++ b/ui/src/Components/Grid/FatalError/__snapshots__/index.test.js.snap @@ -20,7 +20,7 @@ exports[` matches snapshot 1`] = ` -

+

foo bar

diff --git a/ui/src/Components/Grid/FatalError/index.js b/ui/src/Components/Grid/FatalError/index.js index 22524f145..177cef44a 100644 --- a/ui/src/Components/Grid/FatalError/index.js +++ b/ui/src/Components/Grid/FatalError/index.js @@ -17,7 +17,7 @@ class FatalError extends Component {

-

+

{message}

diff --git a/ui/src/Components/Grid/index.stories.js b/ui/src/Components/Grid/index.stories.js index fd25ba40c..e5d488106 100644 --- a/ui/src/Components/Grid/index.stories.js +++ b/ui/src/Components/Grid/index.stories.js @@ -73,7 +73,9 @@ const MockGroup = (groupName, alertCount, active, suppressed, unprocessed) => { storiesOf("Grid", module) .add("FatalError", () => { - return ; + return ( + + ); }) .add("UpgradeNeeded", () => { return ; @@ -162,7 +164,8 @@ storiesOf("Grid", module) { name: "failed", uri: "https://am.example.com", - error: "Failed to connect to https://am.example.com" + error: + "Failed to connect to https://am.example.com veryLongStringToTestTextWrappingveryLongStringToTestTextWrappingveryLongStringToTestTextWrappingveryLongStringToTestTextWrappingveryLongStringToTestTextWrappingveryLongStringToTestTextWrapping" } ], clusters: { am: ["am1", "am2"], failed: ["failed"] } diff --git a/ui/src/Components/LabelSetList/index.js b/ui/src/Components/LabelSetList/index.js index efb69ed22..f9f5de6c3 100644 --- a/ui/src/Components/LabelSetList/index.js +++ b/ui/src/Components/LabelSetList/index.js @@ -7,8 +7,10 @@ import { observable, action } from "mobx"; import hash from "object-hash"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faChevronLeft } from "@fortawesome/free-solid-svg-icons/faChevronLeft"; -import { faChevronRight } from "@fortawesome/free-solid-svg-icons/faChevronRight"; +import { faAngleLeft } from "@fortawesome/free-solid-svg-icons/faAngleLeft"; +import { faAngleRight } from "@fortawesome/free-solid-svg-icons/faAngleRight"; +import { faAngleDoubleLeft } from "@fortawesome/free-solid-svg-icons/faAngleDoubleLeft"; +import { faAngleDoubleRight } from "@fortawesome/free-solid-svg-icons/faAngleDoubleRight"; import Pagination from "react-js-pagination"; @@ -93,12 +95,14 @@ const LabelSetList = observer( totalItemsCount={labelsList.length} pageRangeDisplayed={5} onChange={this.pagination.onPageChange} - hideFirstLastPages + hideFirstLastPages={labelsList.length / this.maxPerPage < 10} innerClass="pagination justify-content-center" itemClass="page-item" linkClass="page-link" - prevPageText={} - nextPageText={} + prevPageText={} + nextPageText={} + firstPageText={} + lastPageText={} />
) : null} diff --git a/ui/src/Components/MainModal/MainModalContent.js b/ui/src/Components/MainModal/MainModalContent.js index 55c27c08e..66d44c340 100644 --- a/ui/src/Components/MainModal/MainModalContent.js +++ b/ui/src/Components/MainModal/MainModalContent.js @@ -6,25 +6,10 @@ import { observable, action } from "mobx"; import { AlertStore } from "Stores/AlertStore"; import { Settings } from "Stores/Settings"; +import { Tab } from "Components/Modal/Tab"; import { Configuration } from "./Configuration"; import { Help } from "./Help"; -const Tab = ({ title, active, onClick }) => ( - - {title} - -); -Tab.propTypes = { - title: PropTypes.string.isRequired, - active: PropTypes.bool, - onClick: PropTypes.func.isRequired -}; - const TabNames = Object.freeze({ Configuration: "configuration", Help: "help" diff --git a/ui/src/Components/MainModal/__snapshots__/MainModalContent.test.js.snap b/ui/src/Components/MainModal/__snapshots__/MainModalContent.test.js.snap index 7fb667a8e..493000f4c 100644 --- a/ui/src/Components/MainModal/__snapshots__/MainModalContent.test.js.snap +++ b/ui/src/Components/MainModal/__snapshots__/MainModalContent.test.js.snap @@ -5,10 +5,10 @@ exports[` matches snapshot 1`] = `
+
+
+ {silenceFormStore.tab.current === SilenceTabNames.Editor ? ( + silenceFormStore.data.currentStage === + SilenceFormStage.UserInput ? ( + + ) : silenceFormStore.data.currentStage === + SilenceFormStage.Preview ? ( + + ) : ( + + ) + ) : null} + {silenceFormStore.tab.current === SilenceTabNames.Browser ? ( + - )} + ) : null}
); diff --git a/ui/src/Components/SilenceModal/SilenceModalContent.test.js b/ui/src/Components/SilenceModal/SilenceModalContent.test.js index edb033e25..352b54cf1 100644 --- a/ui/src/Components/SilenceModal/SilenceModalContent.test.js +++ b/ui/src/Components/SilenceModal/SilenceModalContent.test.js @@ -4,7 +4,11 @@ import { shallow } from "enzyme"; import { AlertStore } from "Stores/AlertStore"; import { Settings } from "Stores/Settings"; -import { SilenceFormStore, SilenceFormStage } from "Stores/SilenceFormStore"; +import { + SilenceFormStore, + SilenceFormStage, + SilenceTabNames +} from "Stores/SilenceFormStore"; import { SilenceModalContent } from "./SilenceModalContent"; let alertStore; @@ -15,6 +19,8 @@ beforeEach(() => { alertStore = new AlertStore([]); settingsStore = new Settings(); silenceFormStore = new SilenceFormStore(); + + silenceFormStore.tab.current = SilenceTabNames.Editor; }); const MockOnHide = jest.fn(); @@ -26,11 +32,72 @@ const ShallowSilenceModalContent = () => { settingsStore={settingsStore} silenceFormStore={silenceFormStore} onHide={MockOnHide} + onDeleteModalClose={jest.fn()} /> ); }; describe("", () => { + it("Clicking on the Browser tab changes content", () => { + const tree = ShallowSilenceModalContent(); + const tabs = tree.find("Tab"); + tabs.at(1).simulate("click"); + const form = tree.find("Browser"); + expect(form).toHaveLength(1); + }); + + it("Clicking on the Editor tab changes content", () => { + silenceFormStore.tab.current = SilenceTabNames.Browser; + const tree = ShallowSilenceModalContent(); + const tabs = tree.find("Tab"); + tabs.at(0).simulate("click"); + const form = tree.find("SilenceForm"); + expect(form).toHaveLength(1); + }); + + it("Content is not blurred when silenceFormStore.toggle.blurred is false", () => { + silenceFormStore.toggle.blurred = false; + const tree = ShallowSilenceModalContent(); + expect(tree.find("div.modal-body.modal-content-blur")).toHaveLength(0); + }); + + it("Content is blurred when silenceFormStore.toggle.blurred is true", () => { + silenceFormStore.toggle.blurred = true; + const tree = ShallowSilenceModalContent(); + expect(tree.find("div.modal-body.modal-content-blur")).toHaveLength(1); + }); +}); + +describe(" Editor", () => { + it("title is 'New silence' when creating new silence", () => { + silenceFormStore.data.currentStage = SilenceFormStage.UserInput; + silenceFormStore.data.silenceID = null; + const tree = ShallowSilenceModalContent(); + const tab = tree.find("Tab").at(0); + expect(tab.props().title).toBe("New silence"); + }); + it("title is 'Editing silence' when editing exiting silence", () => { + silenceFormStore.data.currentStage = SilenceFormStage.UserInput; + silenceFormStore.data.silenceID = "1234"; + const tree = ShallowSilenceModalContent(); + const tab = tree.find("Tab").at(0); + expect(tab.props().title).toBe("Editing silence"); + }); + it("title is 'Preview silenced alerts' when previewing silenced alerts", () => { + silenceFormStore.data.currentStage = SilenceFormStage.Preview; + silenceFormStore.data.silenceID = "1234"; + const tree = ShallowSilenceModalContent(); + const tab = tree.find("Tab").at(0); + expect(tab.props().title).toBe("Preview silenced alerts"); + }); + it("title is 'Silence submitted' after sending silence to Alertmanager", () => { + silenceFormStore.data.currentStage = SilenceFormStage.Submit; + silenceFormStore.data.silenceID = "1234"; + const tree = ShallowSilenceModalContent(); + const tab = tree.find("Tab").at(0); + expect(tab.props().title).toBe("Silence submitted"); + }); + it("renders SilenceForm when silenceFormStore.data.currentStage is 'UserInput'", () => { silenceFormStore.data.currentStage = SilenceFormStage.UserInput; const tree = ShallowSilenceModalContent(); @@ -51,18 +118,13 @@ describe("", () => { const ctrl = tree.find("SilenceSubmitController"); expect(ctrl).toHaveLength(1); }); +}); - it("title is 'Add new silence' when silenceFormStore.data.silenceID is null", () => { - silenceFormStore.data.silenceID = null; - const tree = ShallowSilenceModalContent(); - const title = tree.find(".modal-title"); - expect(title.text()).toBe("Add new silence"); - }); - - it("title is 'Editing silence 12345' when silenceFormStore.data.silenceID is '12345'", () => { - silenceFormStore.data.silenceID = "12345"; +describe(" Browser", () => { + it("renders silence browser when tab is set to Browser", () => { + silenceFormStore.tab.current = SilenceTabNames.Browser; const tree = ShallowSilenceModalContent(); - const title = tree.find(".modal-title"); - expect(title.text()).toBe("Editing silence 12345"); + const form = tree.find("Browser"); + expect(form).toHaveLength(1); }); }); diff --git a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js index 1de13971e..bd19778e9 100644 --- a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js +++ b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js @@ -181,7 +181,7 @@ const SilenceSubmitProgress = observer( {cluster}
diff --git a/ui/src/Components/SilenceModal/index.js b/ui/src/Components/SilenceModal/index.js index 4397a729a..85914336e 100644 --- a/ui/src/Components/SilenceModal/index.js +++ b/ui/src/Components/SilenceModal/index.js @@ -28,6 +28,16 @@ const SilenceModal = observer( settingsStore: PropTypes.instanceOf(Settings).isRequired }; + constructor(props) { + super(props); + + this.modalRef = React.createRef(); + } + + remountModal = () => { + this.modalRef.current.toggleBodyClass(true); + }; + render() { const { alertStore, silenceFormStore, settingsStore } = this.props; @@ -38,7 +48,7 @@ const SilenceModal = observer( silenceFormStore.toggle.visible ? "border-bottom border-info" : "" }`} > - + diff --git a/ui/src/Components/SilenceModal/index.stories.js b/ui/src/Components/SilenceModal/index.stories.js index e2dafdd1a..a7dfd557c 100644 --- a/ui/src/Components/SilenceModal/index.stories.js +++ b/ui/src/Components/SilenceModal/index.stories.js @@ -1,13 +1,17 @@ import React from "react"; +import fetchMock from "fetch-mock"; + import { storiesOf } from "@storybook/react"; +import { MockSilence } from "__mocks__/Alerts"; import { AlertStore } from "Stores/AlertStore"; import { Settings } from "Stores/Settings"; import { SilenceFormStore, NewEmptyMatcher, - MatcherValueToObject + MatcherValueToObject, + SilenceTabNames } from "Stores/SilenceFormStore"; import { SilenceModalContent } from "./SilenceModalContent"; @@ -52,6 +56,8 @@ storiesOf("SilenceModal", module) silenceFormStore.data.comment = "fake silence"; silenceFormStore.data.resetStartEnd(); + silenceFormStore.tab.current = SilenceTabNames.Editor; + return ( {}} previewOpen={true} + onDeleteModalClose={() => {}} + /> + ); + }) + .add("Browser", () => { + const alertStore = new AlertStore([]); + const settingsStore = new Settings(); + const silenceFormStore = new SilenceFormStore(); + + silenceFormStore.tab.current = SilenceTabNames.Browser; + + alertStore.data.upstreams = { + instances: [ + { + name: "am1", + cluster: "am", + clusterMembers: ["am1"], + uri: "http://localhost:9093", + publicURI: "http://example.com", + error: "", + version: "0.15.3", + headers: {} + } + ], + clusters: { am: ["am1"] } + }; + + let silences = []; + for (var index = 1; index <= 18; index++) { + const silence = MockSilence(); + silence.startsAt = "2018-08-14T16:00:00Z"; + silence.endsAt = `2018-08-14T18:${index < 10 ? "0" + index : index}:00Z`; + silence.matchers.push({ + name: "thisIsAveryLongNameToTestMatcherWrapping", + value: "valueIsAlsoAbitLong", + isRegex: false + }); + silence.matchers.push({ + name: "alertname", + value: "(foo1|foo2|foo3|foo4)", + isRegex: true + }); + silence.id = `silence${index}`; + silences.push({ + cluster: "am", + silence: silence + }); + } + + fetchMock.restore().mock("*", silences); + + return ( + {}} + onDeleteModalClose={() => {}} /> ); }); diff --git a/ui/src/Components/SilenceModal/index.test.js b/ui/src/Components/SilenceModal/index.test.js index 8e39ad585..a1209e68d 100644 --- a/ui/src/Components/SilenceModal/index.test.js +++ b/ui/src/Components/SilenceModal/index.test.js @@ -127,4 +127,11 @@ describe("", () => { tree.unmount(); expect(document.body.className.split(" ")).not.toContain("modal-open"); }); + + it("'modal-open' class is preserved on body node after remountModal is called", () => { + silenceFormStore.toggle.visible = true; + const tree = MountedSilenceModal(); + tree.instance().remountModal(); + expect(document.body.className.split(" ")).toContain("modal-open"); + }); }); diff --git a/ui/src/Stores/AlertStore.js b/ui/src/Stores/AlertStore.js index 64ecc7935..d19c1e1ad 100644 --- a/ui/src/Stores/AlertStore.js +++ b/ui/src/Stores/AlertStore.js @@ -209,7 +209,6 @@ class AlertStore { }, setFetching() { this.value = AlertStoreStatuses.Fetching; - this.error = null; }, setProcessing() { this.value = AlertStoreStatuses.Processing; diff --git a/ui/src/Stores/SilenceFormStore.js b/ui/src/Stores/SilenceFormStore.js index 3f41736dd..b7223e6b7 100644 --- a/ui/src/Stores/SilenceFormStore.js +++ b/ui/src/Stores/SilenceFormStore.js @@ -31,11 +31,17 @@ const SilenceFormStage = Object.freeze({ Submit: "submit" }); +const SilenceTabNames = Object.freeze({ + Editor: "editor", + Browser: "browser" +}); + class SilenceFormStore { // this is used to store modal visibility toggle toggle = observable( { visible: false, + blurred: false, toggle() { this.visible = !this.visible; }, @@ -44,9 +50,29 @@ class SilenceFormStore { }, show() { this.visible = true; + }, + setBlur(val) { + this.blurred = val; + } + }, + { + toggle: action.bound, + hide: action.bound, + show: action.bound, + setBlur: action.bound + } + ); + + tab = observable( + { + current: SilenceTabNames.Editor, + setTab(value) { + this.current = value; } }, - { toggle: action.bound, hide: action.bound, show: action.bound } + { + setTab: action.bound + } ); // form data is stored here, it's global (rather than attached to the form) @@ -279,5 +305,6 @@ export { SilenceFormStage, NewEmptyMatcher, MatcherValueToObject, - AlertmanagerClustersToOption + AlertmanagerClustersToOption, + SilenceTabNames }; diff --git a/ui/src/Stores/SilenceFormStore.test.js b/ui/src/Stores/SilenceFormStore.test.js index f25ec17e2..2dcfee6c9 100644 --- a/ui/src/Stores/SilenceFormStore.test.js +++ b/ui/src/Stores/SilenceFormStore.test.js @@ -9,7 +9,8 @@ import { import { SilenceFormStore, SilenceFormStage, - NewEmptyMatcher + NewEmptyMatcher, + SilenceTabNames } from "./SilenceFormStore"; let store; @@ -448,3 +449,15 @@ describe("SilenceFormStore.data startsAt & endsAt validation", () => { expect(diffMS).toBe(-1 * 60 * 1000); }); }); + +describe("SilenceFormStore.tab", () => { + it("current tab is Editor by default", () => { + expect(store.tab.current).toBe(SilenceTabNames.Editor); + }); + + it("setTab() sets the current tab", () => { + expect(store.tab.current).toBe(SilenceTabNames.Editor); + store.tab.setTab(SilenceTabNames.Browser); + expect(store.tab.current).toBe(SilenceTabNames.Browser); + }); +});