From 04379fc3cca1ccd6c3a4f05b83238edee622aedd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Mon, 28 Jun 2021 11:05:30 +0100 Subject: [PATCH] feat(ui): paginate api --- CHANGELOG.md | 4 + cmd/karma/autocomplete.go | 23 +++ .../testscript/059_log_full_config_env.txt | 2 + .../testscript/060_log_full_config_file.txt | 2 + .../testscript/065_proxy-with-readonly.txt | 1 + cmd/karma/tests/testscript/066_proxy.txt | 1 + cmd/karma/tests/testscript/067_readonly.txt | 1 + cmd/karma/tests/testscript/068_sentry.txt | 1 + .../tests/testscript/070_upper_case_keys.txt | 1 + .../tests/testscript/097_proxy_url_config.txt | 1 + cmd/karma/views.go | 30 +++- cmd/karma/views_test.go | 150 ++++++++++++++++++ docs/CONFIGURATION.md | 4 + internal/config/config.go | 3 + internal/config/config_test.go | 1 + internal/config/models.go | 1 + internal/models/api.go | 2 + ui/src/Components/Fetcher/index.test.tsx | 48 ++++-- ui/src/Components/Fetcher/index.tsx | 21 ++- ui/src/Components/Grid/AlertGrid/Grid.tsx | 25 ++- .../Grid/AlertGrid/GridLabelSelect.test.tsx | 1 + .../Components/Grid/AlertGrid/index.test.tsx | 81 +++++++++- .../SilencePreview/index.test.tsx | 1 + ui/src/Models/APITypes.ts | 2 + ui/src/Stores/AlertStore.test.ts | 88 +++++++--- ui/src/Stores/AlertStore.ts | 18 ++- ui/src/__fixtures__/Fetch.ts | 2 + ui/src/__fixtures__/Stories.ts | 2 + 28 files changed, 442 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32fb5fd3f..317cfb1fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - Use [uber-go/automaxprocs](https://github.com/uber-go/automaxprocs) to automatically adjust `GOMAXPROCS` to match Linux container CPU quota. Runtime value of `GOMAXPROCS` is now exported as a `go_max_procs` metric. +- API will now return only first 40 alert groups by default, the rest can + be loaded when user clicks on the `Load more` button. + `grid:groupLimit` config option was added to customise how many groups + are returned and displayed in the UI by default. ## v0.86 diff --git a/cmd/karma/autocomplete.go b/cmd/karma/autocomplete.go index 0038d8d55..47df20ec1 100644 --- a/cmd/karma/autocomplete.go +++ b/cmd/karma/autocomplete.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "sort" + "strconv" "strings" "github.com/prymitive/karma/internal/alertmanager" @@ -23,6 +24,28 @@ func lookupQueryStringSlice(r *http.Request, key string) ([]string, bool) { return vals, found } +func lookupLoadLimits(r *http.Request, key string) map[string]int { + limits := map[string]int{} + vals, found := lookupQueryStringSlice(r, key) + if !found { + return limits + } + var limit int + var err error + for _, val := range vals { + valSlice := strings.SplitN(val, "=", 2) + if len(valSlice) != 2 { + continue + } + limit, err = strconv.Atoi(valSlice[1]) + if err != nil { + continue + } + limits[valSlice[0]] = limit + } + return limits +} + // knownLabelNames allows querying known label names func knownLabelNames(w http.ResponseWriter, r *http.Request) { noCache(w) diff --git a/cmd/karma/tests/testscript/059_log_full_config_env.txt b/cmd/karma/tests/testscript/059_log_full_config_env.txt index c876bfdff..8a75d5ec5 100644 --- a/cmd/karma/tests/testscript/059_log_full_config_env.txt +++ b/cmd/karma/tests/testscript/059_log_full_config_env.txt @@ -34,6 +34,7 @@ env GRID_SORTING_REVERSE=false env GRID_SORTING_LABEL=severity env GRID_AUTO_IGNORE="region instance" env GRID_AUTO_ORDER="severity cluster" +env GRID_GROUPLIMIT=1 env KARMA_NAME=karma-demo @@ -153,6 +154,7 @@ level=info msg=" ignore:" level=info msg=" - '\"region'" level=info msg=" order:" level=info msg=" - '\"severity'" +level=info msg=" groupLimit: 1" level=info msg="history:" level=info msg=" enabled: true" level=info msg=" workers: 30" diff --git a/cmd/karma/tests/testscript/060_log_full_config_file.txt b/cmd/karma/tests/testscript/060_log_full_config_file.txt index ca8b968f4..916648c10 100644 --- a/cmd/karma/tests/testscript/060_log_full_config_file.txt +++ b/cmd/karma/tests/testscript/060_log_full_config_file.txt @@ -167,6 +167,7 @@ level=info msg=" - instance" level=info msg=" order:" level=info msg=" - severity" level=info msg=" - cluster" +level=info msg=" groupLimit: 1" level=info msg="history:" level=info msg=" enabled: false" level=info msg=" workers: 123" @@ -367,6 +368,7 @@ grid: order: - severity - cluster + groupLimit: 1 history: enabled: false workers: 123 diff --git a/cmd/karma/tests/testscript/065_proxy-with-readonly.txt b/cmd/karma/tests/testscript/065_proxy-with-readonly.txt index 886c00b43..b0ede4ed2 100644 --- a/cmd/karma/tests/testscript/065_proxy-with-readonly.txt +++ b/cmd/karma/tests/testscript/065_proxy-with-readonly.txt @@ -70,6 +70,7 @@ level=info msg=" labels: {}" level=info msg=" auto:" level=info msg=" ignore: []" level=info msg=" order: []" +level=info msg=" groupLimit: 40" level=info msg="history:" level=info msg=" enabled: true" level=info msg=" workers: 30" diff --git a/cmd/karma/tests/testscript/066_proxy.txt b/cmd/karma/tests/testscript/066_proxy.txt index 01ff6046d..1db08b02e 100644 --- a/cmd/karma/tests/testscript/066_proxy.txt +++ b/cmd/karma/tests/testscript/066_proxy.txt @@ -70,6 +70,7 @@ level=info msg=" labels: {}" level=info msg=" auto:" level=info msg=" ignore: []" level=info msg=" order: []" +level=info msg=" groupLimit: 40" level=info msg="history:" level=info msg=" enabled: true" level=info msg=" workers: 30" diff --git a/cmd/karma/tests/testscript/067_readonly.txt b/cmd/karma/tests/testscript/067_readonly.txt index b57bee8a4..ba7127d4a 100644 --- a/cmd/karma/tests/testscript/067_readonly.txt +++ b/cmd/karma/tests/testscript/067_readonly.txt @@ -70,6 +70,7 @@ level=info msg=" labels: {}" level=info msg=" auto:" level=info msg=" ignore: []" level=info msg=" order: []" +level=info msg=" groupLimit: 40" level=info msg="history:" level=info msg=" enabled: true" level=info msg=" workers: 30" diff --git a/cmd/karma/tests/testscript/068_sentry.txt b/cmd/karma/tests/testscript/068_sentry.txt index 5943b2da0..6ec91f2c2 100644 --- a/cmd/karma/tests/testscript/068_sentry.txt +++ b/cmd/karma/tests/testscript/068_sentry.txt @@ -72,6 +72,7 @@ level=info msg=" labels: {}" level=info msg=" auto:" level=info msg=" ignore: []" level=info msg=" order: []" +level=info msg=" groupLimit: 40" level=info msg="history:" level=info msg=" enabled: true" level=info msg=" workers: 30" diff --git a/cmd/karma/tests/testscript/070_upper_case_keys.txt b/cmd/karma/tests/testscript/070_upper_case_keys.txt index 3fa392590..f5068534e 100644 --- a/cmd/karma/tests/testscript/070_upper_case_keys.txt +++ b/cmd/karma/tests/testscript/070_upper_case_keys.txt @@ -70,6 +70,7 @@ level=info msg=" labels: {}" level=info msg=" auto:" level=info msg=" ignore: []" level=info msg=" order: []" +level=info msg=" groupLimit: 40" level=info msg="history:" level=info msg=" enabled: true" level=info msg=" workers: 30" diff --git a/cmd/karma/tests/testscript/097_proxy_url_config.txt b/cmd/karma/tests/testscript/097_proxy_url_config.txt index 04a745f84..92dec58c1 100644 --- a/cmd/karma/tests/testscript/097_proxy_url_config.txt +++ b/cmd/karma/tests/testscript/097_proxy_url_config.txt @@ -70,6 +70,7 @@ level=info msg=" labels: {}" level=info msg=" auto:" level=info msg=" ignore: []" level=info msg=" order: []" +level=info msg=" groupLimit: 40" level=info msg="history:" level=info msg=" enabled: true" level=info msg=" workers: 30" diff --git a/cmd/karma/views.go b/cmd/karma/views.go index 6da1801d5..cbf0b9be4 100644 --- a/cmd/karma/views.go +++ b/cmd/karma/views.go @@ -182,6 +182,7 @@ func alerts(w http.ResponseWriter, r *http.Request) { Comment: config.Config.AlertAcknowledgement.Comment, }, HistoryEnabled: config.Config.History.Enabled, + GridGroupLimit: config.Config.Grid.GroupLimit, } resp.Authentication = models.AuthenticationInfo{ Enabled: config.Config.Authentication.Enabled, @@ -215,6 +216,7 @@ func alerts(w http.ResponseWriter, r *http.Request) { gridLabel, _ := lookupQueryString(r, "gridLabel") q, _ := lookupQueryStringSlice(r, "q") matchFilters := getFiltersFromQuery(q) + limits := lookupLoadLimits(r, "limit") grids := map[string]models.APIGrid{} colors := models.LabelsColorMap{} @@ -438,16 +440,34 @@ func alerts(w http.ResponseWriter, r *http.Request) { } } + //resp.AlertGroups = sortAlertGroups(c, alerts) + v, _ := lookupQueryString(r, "gridSortReverse") + gridSortReverse := v == "1" + sortedGrids := sortGrids(r, gridLabel, grids, gridSortReverse) + for i := 0; i < len(sortedGrids); i++ { + sortedGrids[i].TotalGroups = len(sortedGrids[i].AlertGroups) + + limit, found := limits[sortedGrids[i].LabelValue] + if !found { + limit = config.Config.Grid.GroupLimit + } + + l := sortedGrids[i].TotalGroups + if limit < l { + l = limit + } + if l < 1 { + l = 1 + } + sortedGrids[i].AlertGroups = sortedGrids[i].AlertGroups[:l] + } + for _, filter := range matchFilters { if filter.GetValue() != "" && filter.GetMatcher() == "=" { transform.ColorLabel(colors, filter.GetName(), filter.GetValue()) } } - //resp.AlertGroups = sortAlertGroups(c, alerts) - v, _ := lookupQueryString(r, "gridSortReverse") - gridSortReverse := v == "1" - receivers := []string{} for k := range allReceivers { k := k @@ -455,7 +475,7 @@ func alerts(w http.ResponseWriter, r *http.Request) { } sort.Strings(receivers) - resp.Grids = sortGrids(r, gridLabel, grids, gridSortReverse) + resp.Grids = sortedGrids resp.Silences = silences resp.Colors = colors resp.Counters = countersToLabelStats(counters) diff --git a/cmd/karma/views_test.go b/cmd/karma/views_test.go index 1df4b6543..0ee5a99bd 100644 --- a/cmd/karma/views_test.go +++ b/cmd/karma/views_test.go @@ -965,6 +965,7 @@ func TestEmptySettings(t *testing.T) { Comment: "ACK! This alert was acknowledged using karma on %NOW%", }, HistoryEnabled: true, + GridGroupLimit: 40, } if diff := cmp.Diff(expectedSettings, ur.Settings); diff != "" { @@ -2845,3 +2846,152 @@ func TestAutoGrid(t *testing.T) { } } } + +func TestGridLimit(t *testing.T) { + type testCaseT struct { + groupLimit int + q string + groups map[string][]int + } + + testCases := []testCaseT{ + { + q: "", + groups: map[string][]int{ + "": {10, 10}, + }, + }, + { + groupLimit: 5, + q: "", + groups: map[string][]int{ + "": {10, 5}, + }, + }, + { + groupLimit: 15, + q: "", + groups: map[string][]int{ + "": {10, 10}, + }, + }, + { + q: "limit==1", + groups: map[string][]int{ + "": {10, 1}, + }, + }, + { + q: "limit", + groups: map[string][]int{ + "": {10, 10}, + }, + }, + { + q: "limit=", + groups: map[string][]int{ + "": {10, 10}, + }, + }, + { + q: "gridLabel=job&limit=node_exporter=1", + groups: map[string][]int{ + "node_exporter": {6, 1}, + "node_ping": {4, 4}, + }, + }, + { + q: "gridLabel=job&limit=node_exporter=a&limit=node_ping=1", + groups: map[string][]int{ + "node_exporter": {6, 6}, + "node_ping": {4, 1}, + }, + }, + { + q: "gridLabel=job&limit=node_exporter=0&limit=node_ping=2", + groups: map[string][]int{ + "node_exporter": {6, 1}, + "node_ping": {4, 2}, + }, + }, + { + q: "gridLabel=job&limit=node_exporter=0&limit=node_ping=20", + groups: map[string][]int{ + "node_exporter": {6, 1}, + "node_ping": {4, 4}, + }, + }, + } + + defer func() { + config.Config.Grid.GroupLimit = 50 + }() + + mockConfig() + for _, tc := range testCases { + if tc.groupLimit > 0 { + config.Config.Grid.GroupLimit = tc.groupLimit + } else { + config.Config.Grid.GroupLimit = 50 + } + for _, version := range mock.ListAllMocks() { + t.Logf("Testing grids using mock files from Alertmanager %s", version) + mockAlerts(version) + r := testRouter() + setupRouter(r, nil) + // re-run a few times to test the cache + for i := 1; i <= 3; i++ { + req := httptest.NewRequest("GET", fmt.Sprintf("/alerts.json?%s", tc.q), nil) + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + if resp.Code != http.StatusOK { + t.Errorf("GET /alerts.json returned status %d", resp.Code) + } + + ur := models.AlertsResponse{} + err := json.Unmarshal(resp.Body.Bytes(), &ur) + if err != nil { + t.Errorf("Failed to unmarshal response: %s", err) + } + if len(ur.Grids) == 0 { + t.Errorf("[%s] Got empty grid list", tc.q) + } + for _, grid := range ur.Grids { + if grid.TotalGroups == 0 { + t.Errorf("[%s] got empty grid for %s=%s", tc.q, grid.LabelName, grid.LabelValue) + } + found := false + for labelValue := range tc.groups { + if grid.LabelValue == labelValue { + found = true + break + } + } + if !found { + t.Errorf("[%s] got extra grid %s=%s", tc.q, grid.LabelName, grid.LabelValue) + } + } + for labelValue, totals := range tc.groups { + totalGroups := totals[0] + presentGroups := totals[1] + found := false + for _, grid := range ur.Grids { + if grid.LabelValue == labelValue { + found = true + if grid.TotalGroups != totalGroups { + t.Errorf("[%s] grid for label %s=%s returned totalGroups=%d, expected %d", tc.q, grid.LabelName, grid.LabelValue, grid.TotalGroups, totalGroups) + } + if len(grid.AlertGroups) != presentGroups { + t.Errorf("[%s] grid for label %s=%s returned %d alert groups, expected %d", tc.q, grid.LabelName, grid.LabelValue, len(grid.AlertGroups), presentGroups) + } + break + } + } + if !found { + t.Errorf("[%s] grid with label value %s missing", tc.q, labelValue) + } + } + } + } + } +} diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 1f8ca3444..ef54140ef 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -648,6 +648,7 @@ grid: auto: ignore: list of strings order: list of strings + groupLimit: integer ``` - `sorting:order` - default sort order for alert grid, valid values are: @@ -678,6 +679,8 @@ grid: source label when multi-grid is configured to `Automatic selection` in the UI or when `ui:multiGridLabel` is set to `@auto`. If a label name is not present in this list labels with equal weight will be picked in alphabetic order. +- `groupLimit` - default number of alert groups to show in the UI, loading more + will require user to click on `Load more` button. Defaults: @@ -692,6 +695,7 @@ grid: auto: ignore: [] order: [] + groupLimit: 40 ``` Example with sorting using `severity` label and value mappings for it: diff --git a/internal/config/config.go b/internal/config/config.go index 5238930d6..a027ab135 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -97,6 +97,7 @@ func SetupFlags(f *pflag.FlagSet) { f.String("grid.sorting.label", "alertname", "Label name to use when sorting alert grid by label") f.StringSlice("grid.auto.ignore", []string{}, "List of label names not allowed for automatic multi-grid") f.StringSlice("grid.auto.order", []string{}, "Order of preference for selecting label names for automatic multi-grid") + f.Int("grid.groupLimit", 40, "Default number of groups to show for each grid") f.Bool("history.enabled", true, "Enable alert history queries") f.Duration("history.timeout", time.Second*20, "Timeout for history queries against source Prometheus servers") @@ -211,6 +212,8 @@ func readEnvVariables(k *koanf.Koanf) { return "annotations.enableInsecureHTML" case "AUTHENTICATION_HEADER_VALUE_RE": return "authentication.header.value_re" + case "GRID_GROUPLIMIT": + return "grid.groupLimit" case "SILENCEFORM_STRIP_LABELS": return "silenceForm.strip.labels" case "UI_HIDEFILTERSWHENIDLE": diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 32ab40675..6d666b21f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -88,6 +88,7 @@ grid: auto: ignore: [] order: [] + groupLimit: 40 history: enabled: true workers: 30 diff --git a/internal/config/models.go b/internal/config/models.go index dbfffe7dd..c79952185 100644 --- a/internal/config/models.go +++ b/internal/config/models.go @@ -133,6 +133,7 @@ type configSchema struct { Ignore []string Order []string } + GroupLimit int `yaml:"groupLimit"` } `yaml:"grid"` History struct { Enabled bool diff --git a/internal/models/api.go b/internal/models/api.go index 20ae06cf1..3a5b165e3 100644 --- a/internal/models/api.go +++ b/internal/models/api.go @@ -324,6 +324,7 @@ type Settings struct { SilenceForm SilenceFormSettings `json:"silenceForm"` AlertAcknowledgement AlertAcknowledgementSettings `json:"alertAcknowledgement"` HistoryEnabled bool `json:"historyEnabled"` + GridGroupLimit int `json:"gridGroupLimit"` } type AuthenticationInfo struct { @@ -335,6 +336,7 @@ type APIGrid struct { LabelName string `json:"labelName"` LabelValue string `json:"labelValue"` AlertGroups []APIAlertGroup `json:"alertGroups"` + TotalGroups int `json:"totalGroups"` StateCount map[string]int `json:"stateCount"` } diff --git a/ui/src/Components/Fetcher/index.test.tsx b/ui/src/Components/Fetcher/index.test.tsx index 8b2fe1152..d0c2fa3a6 100644 --- a/ui/src/Components/Fetcher/index.test.tsx +++ b/ui/src/Components/Fetcher/index.test.tsx @@ -149,7 +149,7 @@ describe("", () => { const fetchSpy = jest.spyOn(alertStore, "fetchWithThrottle"); settingsStore.gridConfig.setSortOrder("default"); mount(); - expect(fetchSpy).toHaveBeenCalledWith("", false, "", "", ""); + expect(fetchSpy).toHaveBeenCalledWith("", false, "", "", "", {}); }); it("calls alertStore.fetchWithThrottle with correct sort arguments when sortOrder=disabled reverseSort=false", () => { @@ -158,7 +158,7 @@ describe("", () => { settingsStore.gridConfig.setSortOrder("disabled"); settingsStore.gridConfig.setSortReverse(false); mount(); - expect(fetchSpy).toHaveBeenCalledWith("", false, "disabled", "", ""); + expect(fetchSpy).toHaveBeenCalledWith("", false, "disabled", "", "", {}); }); it("calls alertStore.fetchWithThrottle with correct sort arguments when sortOrder=disabled reverseSort=true", () => { @@ -167,7 +167,7 @@ describe("", () => { settingsStore.gridConfig.setSortOrder("disabled"); settingsStore.gridConfig.setSortReverse(true); mount(); - expect(fetchSpy).toHaveBeenCalledWith("", false, "disabled", "", ""); + expect(fetchSpy).toHaveBeenCalledWith("", false, "disabled", "", "", {}); }); it("calls alertStore.fetchWithThrottle with correct sort arguments when sortOrder=startsAt reverseSort=false", () => { @@ -176,7 +176,7 @@ describe("", () => { settingsStore.gridConfig.setSortOrder("startsAt"); settingsStore.gridConfig.setSortReverse(false); mount(); - expect(fetchSpy).toHaveBeenCalledWith("", false, "startsAt", "", "0"); + expect(fetchSpy).toHaveBeenCalledWith("", false, "startsAt", "", "0", {}); }); it("calls alertStore.fetchWithThrottle with correct sort arguments when sortOrder=startsAt reverseSort=true", () => { @@ -185,7 +185,7 @@ describe("", () => { settingsStore.gridConfig.setSortOrder("startsAt"); settingsStore.gridConfig.setSortReverse(true); mount(); - expect(fetchSpy).toHaveBeenCalledWith("", false, "startsAt", "", "1"); + expect(fetchSpy).toHaveBeenCalledWith("", false, "startsAt", "", "1", {}); }); it("calls alertStore.fetchWithThrottle with correct sort arguments when sortOrder=label sortLabel=cluster reverseSort=false", () => { @@ -195,7 +195,14 @@ describe("", () => { settingsStore.gridConfig.setSortLabel("cluster"); settingsStore.gridConfig.setSortReverse(false); mount(); - expect(fetchSpy).toHaveBeenCalledWith("", false, "label", "cluster", "0"); + expect(fetchSpy).toHaveBeenCalledWith( + "", + false, + "label", + "cluster", + "0", + {} + ); }); it("calls alertStore.fetchWithThrottle with correct sort arguments when sortOrder=label sortLabel=job reverseSort=true", () => { @@ -205,7 +212,7 @@ describe("", () => { settingsStore.gridConfig.setSortLabel("job"); settingsStore.gridConfig.setSortReverse(true); mount(); - expect(fetchSpy).toHaveBeenCalledWith("", false, "label", "job", "1"); + expect(fetchSpy).toHaveBeenCalledWith("", false, "label", "job", "1", {}); }); it("calls alertStore.fetchWithThrottle with correct sort arguments when sortOrder=label sortLabel=instance reverseSort=null", () => { @@ -215,7 +222,14 @@ describe("", () => { settingsStore.gridConfig.setSortLabel("instance"); settingsStore.gridConfig.setSortReverse(null); mount(); - expect(fetchSpy).toHaveBeenCalledWith("", false, "label", "instance", ""); + expect(fetchSpy).toHaveBeenCalledWith( + "", + false, + "label", + "instance", + "", + {} + ); }); it("calls alertStore.fetchWithThrottle with gridLabel=cluster gridSortReverse=false", () => { @@ -225,7 +239,7 @@ describe("", () => { settingsStore.multiGridConfig.setGridLabel("cluster"); settingsStore.multiGridConfig.setGridSortReverse(false); mount(); - expect(fetchSpy).toHaveBeenCalledWith("cluster", false, "", "", ""); + expect(fetchSpy).toHaveBeenCalledWith("cluster", false, "", "", "", {}); }); it("calls alertStore.fetchWithThrottle with gridLabel=cluster gridSortReverse=true", () => { @@ -235,7 +249,7 @@ describe("", () => { settingsStore.multiGridConfig.setGridLabel("cluster"); settingsStore.multiGridConfig.setGridSortReverse(true); mount(); - expect(fetchSpy).toHaveBeenCalledWith("cluster", true, "", "", ""); + expect(fetchSpy).toHaveBeenCalledWith("cluster", true, "", "", "", {}); }); it("calls alertStore.fetchWithThrottle with gridLabel= gridSortReverse=true", () => { @@ -245,7 +259,19 @@ describe("", () => { settingsStore.multiGridConfig.setGridLabel(""); settingsStore.multiGridConfig.setGridSortReverse(true); mount(); - expect(fetchSpy).toHaveBeenCalledWith("", true, "", "", ""); + expect(fetchSpy).toHaveBeenCalledWith("", true, "", "", "", {}); + }); + + it("calls alertStore.fetchWithThrottle with limits set", () => { + MockEmptyAPIResponseWithoutFilters(); + const fetchSpy = jest.spyOn(alertStore, "fetchWithThrottle"); + settingsStore.gridConfig.setSortOrder("default"); + settingsStore.multiGridConfig.setGridLabel(""); + settingsStore.multiGridConfig.setGridSortReverse(false); + alertStore.ui.setLimit("old", "bar", 10); + alertStore.ui.setLimit("foo", "bar", 5); + mount(); + expect(fetchSpy).toHaveBeenCalledWith("", false, "", "", "", { bar: 5 }); }); it("internal timer is null after unmount", () => { diff --git a/ui/src/Components/Fetcher/index.tsx b/ui/src/Components/Fetcher/index.tsx index 48ca4daa6..642c9bc87 100644 --- a/ui/src/Components/Fetcher/index.tsx +++ b/ui/src/Components/Fetcher/index.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState, FC } from "react"; -import { reaction } from "mobx"; +import { reaction, toJS } from "mobx"; import { observer } from "mobx-react-lite"; import addSeconds from "date-fns/addSeconds"; @@ -175,7 +175,10 @@ const Fetcher: FC<{ settingsStore.multiGridConfig.config.gridSortReverse, sortSettings.sortOrder, sortSettings.sortLabel, - sortSettings.sortReverse + sortSettings.sortReverse, + Object.values(alertStore.ui.limits).length > 0 + ? toJS(Object.values(alertStore.ui.limits)[0]) + : {} ); }; @@ -192,14 +195,16 @@ const Fetcher: FC<{ .map((f: { raw: string }) => f.raw) .join(" "), grid: { - sortOrder: settingsStore.gridConfig.config.sortOrder, - sortLabel: settingsStore.gridConfig.config.sortLabel, + sortOrder: toJS(settingsStore.gridConfig.config.sortOrder), + sortLabel: toJS(settingsStore.gridConfig.config.sortLabel), + limits: toJS(alertStore.ui.limits), }, multigrid: { - gridLabel: settingsStore.multiGridConfig.config.gridLabel, - gridSortReverse: - settingsStore.multiGridConfig.config.gridSortReverse, - reverseSort: settingsStore.gridConfig.config.reverseSort, + gridLabel: toJS(settingsStore.multiGridConfig.config.gridLabel), + gridSortReverse: toJS( + settingsStore.multiGridConfig.config.gridSortReverse + ), + reverseSort: toJS(settingsStore.gridConfig.config.reverseSort), }, }), () => { diff --git a/ui/src/Components/Grid/AlertGrid/Grid.tsx b/ui/src/Components/Grid/AlertGrid/Grid.tsx index f58658c13..ed319cff2 100644 --- a/ui/src/Components/Grid/AlertGrid/Grid.tsx +++ b/ui/src/Components/Grid/AlertGrid/Grid.tsx @@ -52,8 +52,6 @@ const Grid: FC<{ const { ref, repack } = useGrid(gridSizesConfig); const debouncedRepack = useMemo(() => debounce(() => repack(), 10), [repack]); - const [groupsToRender, setGroupsToRender] = useState(50); - const [isExpanded, setIsExpanded] = useState( !DefaultDetailsCollapseValue(settingsStore) ); @@ -105,12 +103,6 @@ const Grid: FC<{ }; }, [debouncedRepack, onAlertGridCollapseEvent]); - useEffect(() => { - if (groupsToRender > grid.alertGroups.length) { - setGroupsToRender(Math.max(50, grid.alertGroups.length)); - } - }, [grid.alertGroups.length, groupsToRender]); - useEffect(() => { repack(); }); @@ -144,7 +136,7 @@ const Grid: FC<{ > {isExpanded || grid.labelName === "" - ? grid.alertGroups.slice(0, groupsToRender).map((group) => ( + ? grid.alertGroups.map((group) => ( - {isExpanded && grid.alertGroups.length > groupsToRender && ( + {isExpanded && grid.totalGroups > grid.alertGroups.length && ( - setGroupsToRender( - Math.min(groupsToRender + 30, grid.alertGroups.length) - ) - } + onClick={() => { + alertStore.ui.setLimit( + grid.labelName, + grid.labelValue, + grid.alertGroups.length + + alertStore.settings.values.gridGroupLimit + ); + }} > Load more diff --git a/ui/src/Components/Grid/AlertGrid/GridLabelSelect.test.tsx b/ui/src/Components/Grid/AlertGrid/GridLabelSelect.test.tsx index b3888bded..ec0a0401b 100644 --- a/ui/src/Components/Grid/AlertGrid/GridLabelSelect.test.tsx +++ b/ui/src/Components/Grid/AlertGrid/GridLabelSelect.test.tsx @@ -26,6 +26,7 @@ beforeEach(() => { labelName: "foo", labelValue: "bar", alertGroups: [], + totalGroups: 0, stateCount: { active: 0, suppressed: 0, diff --git a/ui/src/Components/Grid/AlertGrid/index.test.tsx b/ui/src/Components/Grid/AlertGrid/index.test.tsx index aeb5a927d..5fa909df5 100644 --- a/ui/src/Components/Grid/AlertGrid/index.test.tsx +++ b/ui/src/Components/Grid/AlertGrid/index.test.tsx @@ -94,6 +94,9 @@ const MockGrid = () => ({ alertGroups: alertStore.data.grids.length ? alertStore.data.grids[0].alertGroups : [], + totalGroups: alertStore.data.grids.length + ? alertStore.data.grids[0].alertGroups.length + : 0, stateCount: { unprocessed: 1, suppressed: 2, @@ -148,7 +151,11 @@ const MockGroup = (groupName: string, alertCount: number) => { return group; }; -const MockGroupList = (count: number, alertPerGroup: number) => { +const MockGroupList = ( + count: number, + alertPerGroup: number, + totalGroups?: number +) => { const groups = []; for (let i = 1; i <= count; i++) { const id = `id${i}`; @@ -179,6 +186,7 @@ const MockGroupList = (count: number, alertPerGroup: number) => { labelName: "", labelValue: "", alertGroups: groups, + totalGroups: totalGroups ? totalGroups : groups.length, stateCount: { unprocessed: 1, suppressed: 2, @@ -208,19 +216,69 @@ describe("", () => { ).not.toMatch(/animate components-animation-fade-appear/); }); - it("renders only first 50 alert groups", () => { + it("renders all alert groups", () => { MockGroupList(55, 5); const tree = MountedGrid(); const alertGroups = tree.find("AlertGroup"); - expect(alertGroups).toHaveLength(50); + expect(alertGroups).toHaveLength(55); }); - it("appends 30 groups after clicking 'Load More' button", () => { - MockGroupList(85, 5); - const tree = MountedGrid(); + it("appends more groups after clicking 'Load More' button", () => { + MockGroupList(40, 5, 70); + const tree = mount( + , + { + wrappingComponent: ThemeContext.Provider, + wrappingComponentProps: { value: MockThemeContext }, + } + ); tree.find("button").simulate("click"); - const alertGroups = tree.find("AlertGroup"); - expect(alertGroups).toHaveLength(80); + expect(alertStore.ui.limits).toStrictEqual({ + "": { "": 40 + alertStore.settings.values.gridGroupLimit }, + }); + }); + + it("sets correct limits after clicking 'Load More' button", () => { + MockGroupList(50, 5, 60); + alertStore.settings.setValues({ + ...alertStore.settings.values, + gridGroupLimit: 20, + }); + alertStore.data.setGrids([ + { + ...alertStore.data.grids[0], + labelName: "foo", + labelValue: "bar", + totalGroups: 69, + }, + ]); + const tree = mount( + , + { + wrappingComponent: ThemeContext.Provider, + wrappingComponentProps: { value: MockThemeContext }, + } + ); + tree.find("button").simulate("click"); + expect(alertStore.ui.limits).toStrictEqual({ + foo: { bar: 70 }, + }); }); it("doesn't sort groups when sorting is set to 'disabled'", () => { @@ -338,6 +396,7 @@ describe("", () => { labelName: "foo", labelValue: "bar", alertGroups: groups.slice(0, 10), + totalGroups: groups.slice(0, 10).length, stateCount: { unprocessed: 1, suppressed: 2, @@ -348,6 +407,7 @@ describe("", () => { labelName: "foo", labelValue: "", alertGroups: groups.slice(10, 20), + totalGroups: groups.slice(10, 20).length, stateCount: { unprocessed: 1, suppressed: 2, @@ -591,6 +651,7 @@ describe("", () => { labelName: "foo", labelValue: "bar", alertGroups: groups, + totalGroups: groups.length, stateCount: { unprocessed: 1, suppressed: 2, @@ -601,6 +662,7 @@ describe("", () => { labelName: "foo", labelValue: "", alertGroups: groups, + totalGroups: groups.length, stateCount: { unprocessed: 1, suppressed: 2, @@ -629,6 +691,7 @@ describe("", () => { labelName: "foo", labelValue: "bar", alertGroups: groups, + totalGroups: groups.length, stateCount: { unprocessed: 0, suppressed: 0, @@ -639,6 +702,7 @@ describe("", () => { labelName: "foo", labelValue: "", alertGroups: groups, + totalGroups: groups.length, stateCount: { unprocessed: 0, suppressed: 0, @@ -672,6 +736,7 @@ describe("", () => { labelName: "", labelValue: "", alertGroups: groups, + totalGroups: groups.length, stateCount: { unprocessed: 0, suppressed: 0, diff --git a/ui/src/Components/SilenceModal/SilencePreview/index.test.tsx b/ui/src/Components/SilenceModal/SilencePreview/index.test.tsx index 5c64c138a..8c675ed19 100644 --- a/ui/src/Components/SilenceModal/SilencePreview/index.test.tsx +++ b/ui/src/Components/SilenceModal/SilencePreview/index.test.tsx @@ -56,6 +56,7 @@ const MockAPIResponse = () => { {} ), ], + totalGroups: 2, stateCount: { unprocessed: 1, suppressed: 2, diff --git a/ui/src/Models/APITypes.ts b/ui/src/Models/APITypes.ts index a4cfa5f52..723dbeba7 100644 --- a/ui/src/Models/APITypes.ts +++ b/ui/src/Models/APITypes.ts @@ -91,6 +91,7 @@ export interface APIGridT { labelName: string; labelValue: string; alertGroups: APIAlertGroupT[]; + totalGroups: number; stateCount: StateCountT; } @@ -189,6 +190,7 @@ export interface APISettingsT { comment: string; }; historyEnabled: boolean; + gridGroupLimit: number; } export interface APIAlertsResponseT { diff --git a/ui/src/Stores/AlertStore.test.ts b/ui/src/Stores/AlertStore.test.ts index b5f3f1f76..2d80c72fb 100644 --- a/ui/src/Stores/AlertStore.test.ts +++ b/ui/src/Stores/AlertStore.test.ts @@ -544,7 +544,9 @@ describe("AlertStore.fetch", () => { }); const store = new AlertStore(["label=value"]); - await expect(store.fetch("", false, "", "", "")).resolves.toBeUndefined(); + await expect( + store.fetch("", false, "", "", "", {}) + ).resolves.toBeUndefined(); expect(fetchMock.calls()).toHaveLength(1); expect(store.status.value).toEqual(AlertStoreStatuses.Idle); @@ -558,7 +560,9 @@ describe("AlertStore.fetch", () => { }); const store = new AlertStore([]); - await expect(store.fetch("", false, "", "", "")).resolves.toBeUndefined(); + await expect( + store.fetch("", false, "", "", "", {}) + ).resolves.toBeUndefined(); expect(fetchMock.calls()).toHaveLength(1); expect(store.status.value).toEqual(AlertStoreStatuses.Failure); @@ -575,9 +579,9 @@ describe("AlertStore.fetch", () => { }); const store = new AlertStore([]); - await expect(store.fetch("", false, "", "", "")).resolves.toHaveProperty( - "error" - ); + await expect( + store.fetch("", false, "", "", "", {}) + ).resolves.toHaveProperty("error"); expect(fetchMock.calls()).toHaveLength(10); expect(store.status.value).toEqual(AlertStoreStatuses.Failure); @@ -595,9 +599,9 @@ describe("AlertStore.fetch", () => { throws: new Error("fetch error"), }); - await expect(store.fetch("", false, "", "", "")).resolves.toHaveProperty( - "error" - ); + await expect( + store.fetch("", false, "", "", "", {}) + ).resolves.toHaveProperty("error"); expect(fetchMock.calls()).toHaveLength(10); }); @@ -610,9 +614,9 @@ describe("AlertStore.fetch", () => { throws: new Error("fetch error"), }); - await expect(store.fetch("", false, "", "", "")).resolves.toHaveProperty( - "error" - ); + await expect( + store.fetch("", false, "", "", "", {}) + ).resolves.toHaveProperty("error"); expect(fetchMock.calls()).toHaveLength(10); const response = EmptyAPIResponse(); @@ -621,7 +625,9 @@ describe("AlertStore.fetch", () => { body: JSON.stringify(response), }); - await expect(store.fetch("", false, "", "", "")).resolves.toBeUndefined(); + await expect( + store.fetch("", false, "", "", "", {}) + ).resolves.toBeUndefined(); expect(fetchMock.calls()).toHaveLength(1); fetchMock.reset(); @@ -629,9 +635,9 @@ describe("AlertStore.fetch", () => { throws: new Error("fetch error"), }); - await expect(store.fetch("", false, "", "", "")).resolves.toHaveProperty( - "error" - ); + await expect( + store.fetch("", false, "", "", "", {}) + ).resolves.toHaveProperty("error"); expect(fetchMock.calls()).toHaveLength(10); }); @@ -649,7 +655,9 @@ describe("AlertStore.fetch", () => { }) as any ); - await expect(store.fetch("", false, "", "", "")).resolves.toBeUndefined(); + await expect( + store.fetch("", false, "", "", "", {}) + ).resolves.toBeUndefined(); expect(store.info.reloadNeeded).toBe(true); }); @@ -664,9 +672,9 @@ describe("AlertStore.fetch", () => { throws: new Error("fetch error"), }); - await expect(store.fetch("", false, "", "", "")).resolves.toHaveProperty( - "error" - ); + await expect( + store.fetch("", false, "", "", "", {}) + ).resolves.toHaveProperty("error"); expect(store.filters.values[0].applied).toBe(true); }); @@ -681,7 +689,9 @@ describe("AlertStore.fetch", () => { // initial fetch, should update settings store.settings.setValues({ foo: "bar" } as any); - await expect(store.fetch("", false, "", "", "")).resolves.toBeUndefined(); + await expect( + store.fetch("", false, "", "", "", {}) + ).resolves.toBeUndefined(); expect(store.settings.values).toMatchObject({ staticColorLabels: ["job"], annotationsDefaultHidden: false, @@ -691,7 +701,9 @@ describe("AlertStore.fetch", () => { }); // second fetch, should keep same settings - await expect(store.fetch("", false, "", "", "")).resolves.toBeUndefined(); + await expect( + store.fetch("", false, "", "", "", {}) + ).resolves.toBeUndefined(); expect(store.settings.values).toMatchObject({ staticColorLabels: ["job"], annotationsDefaultHidden: false, @@ -708,7 +720,9 @@ describe("AlertStore.fetch", () => { body: JSON.stringify(response), }); const store = new AlertStore(["label=value"]); - await expect(store.fetch("", false, "", "", "")).resolves.toBeUndefined(); + await expect( + store.fetch("", false, "", "", "", {}) + ).resolves.toBeUndefined(); expect(store.info.upgradeReady).toBe(false); response.version = "newFakeVersion"; @@ -716,7 +730,9 @@ describe("AlertStore.fetch", () => { fetchMock.mock("*", { body: JSON.stringify(response), }); - await expect(store.fetch("", false, "", "", "")).resolves.toBeUndefined(); + await expect( + store.fetch("", false, "", "", "", {}) + ).resolves.toBeUndefined(); expect(store.info.upgradeReady).toBe(true); }); @@ -729,6 +745,7 @@ describe("AlertStore.fetch", () => { labelName: "", labelValue: "", alertGroups: [g1, g2], + totalGroups: 2, stateCount: { unprocessed: 0, active: 2, suppressed: 0 }, }, ]; @@ -749,6 +766,7 @@ describe("AlertStore.fetch", () => { labelName: "", labelValue: "", alertGroups: [g1, g2, g3], + totalGroups: 3, stateCount: { unprocessed: 0, active: 3, suppressed: 0 }, }, ]); @@ -761,6 +779,7 @@ describe("AlertStore.fetch", () => { labelName: "", labelValue: "", alertGroups: [g1, g3], + totalGroups: 2, stateCount: { unprocessed: 0, active: 2, suppressed: 0 }, }, ]; @@ -778,7 +797,7 @@ describe("AlertStore.fetch", () => { }); const store = new AlertStore(["label=value"]); await expect( - store.fetch("", false, "sortOrder", "sortLabel", "sortReverse") + store.fetch("", false, "sortOrder", "sortLabel", "sortReverse", {}) ).resolves.toBeUndefined(); expect(fetchMock.calls().length).toEqual(1); expect(fetchMock.calls()[0][0]).toBe( @@ -794,11 +813,30 @@ describe("AlertStore.fetch", () => { }); const store = new AlertStore(["label=value"]); await expect( - store.fetch("cluster", true, "sortOrder", "sortLabel", "sortReverse") + store.fetch("cluster", true, "sortOrder", "sortLabel", "sortReverse", {}) ).resolves.toBeUndefined(); expect(fetchMock.calls().length).toEqual(1); expect(fetchMock.calls()[0][0]).toBe( "/alerts.json?&gridLabel=cluster&gridSortReverse=1&sortOrder=sortOrder&sortLabel=sortLabel&sortReverse=sortReverse&q=label%3Dvalue" ); }); + + it("uses correct query args with limits", async () => { + const response = EmptyAPIResponse(); + fetchMock.reset(); + fetchMock.mock("*", { + body: JSON.stringify(response), + }); + const store = new AlertStore(["label=value"]); + store.ui.setLimit("foo", "bar", 7); + await expect( + store.fetch("cluster", true, "sortOrder", "sortLabel", "sortReverse", { + bar: 7, + }) + ).resolves.toBeUndefined(); + expect(fetchMock.calls().length).toEqual(1); + expect(fetchMock.calls()[0][0]).toBe( + "/alerts.json?&gridLabel=cluster&gridSortReverse=1&sortOrder=sortOrder&sortLabel=sortLabel&sortReverse=sortReverse&limit=bar=7&q=label%3Dvalue" + ); + }); }); diff --git a/ui/src/Stores/AlertStore.ts b/ui/src/Stores/AlertStore.ts index 93c13683e..9d87169d3 100644 --- a/ui/src/Stores/AlertStore.ts +++ b/ui/src/Stores/AlertStore.ts @@ -207,6 +207,8 @@ interface AlertStoreStatusT { interface AlertStoreUIT { isIdle: boolean; setIsIdle: (val: boolean) => void; + limits: { [key: string]: { [val: string]: number } }; + setLimit: (key: string, val: string, limit: number) => void; } class AlertStore { @@ -474,6 +476,7 @@ class AlertStore { comment: "ACK! This alert was acknowledged using karma", }, historyEnabled: true, + gridGroupLimit: 40, } as APISettingsT, setValues(v: APISettingsT) { this.values = v; @@ -548,9 +551,14 @@ class AlertStore { setIsIdle(val: boolean) { this.isIdle = val; }, + limits: {} as { [key: string]: { [val: string]: number } }, + setLimit(key: string, val: string, limit: number) { + this.limits = { [key]: { ...this.limits[key], [val]: limit } }; + }, }, { setIsIdle: action.bound, + setLimit: action.bound, } ); @@ -563,17 +571,21 @@ class AlertStore { gridSortReverse: boolean, sortOrder: string, sortLabel: string, - sortReverse: string + sortReverse: string, + limits: { [key: string]: number } ) => { this.status.setFetching(); - const args = [ + const args: string[] = [ `gridLabel=${gridLabel}`, `gridSortReverse=${gridSortReverse ? "1" : "0"}`, `sortOrder=${sortOrder}`, `sortLabel=${sortLabel}`, `sortReverse=${sortReverse}`, - ]; + Object.entries(limits) + .map(([key, value]) => `limit=${key}=${value}`) + .join("&"), + ].filter((arg) => arg !== ""); const alertsURI = FormatBackendURI(`alerts.json?&${args.join("&")}&`) + diff --git a/ui/src/__fixtures__/Fetch.ts b/ui/src/__fixtures__/Fetch.ts index 0a8efb772..bff357396 100644 --- a/ui/src/__fixtures__/Fetch.ts +++ b/ui/src/__fixtures__/Fetch.ts @@ -81,6 +81,7 @@ const EmptyAPIResponse = (): APIAlertsResponseT => ({ annotationsVisible: [], annotationsEnableHTML: false, historyEnabled: true, + gridGroupLimit: 40, }, authentication: { username: "", @@ -103,6 +104,7 @@ const MockAPIResponse = (): APIAlertsResponseT => { {} ), ], + totalGroups: 1, stateCount: { unprocessed: 1, suppressed: 2, diff --git a/ui/src/__fixtures__/Stories.ts b/ui/src/__fixtures__/Stories.ts index 51642437f..6688d9b0e 100644 --- a/ui/src/__fixtures__/Stories.ts +++ b/ui/src/__fixtures__/Stories.ts @@ -301,6 +301,7 @@ const MockGrid = (alertStore: AlertStore): void => { labelName: "cluster", labelValue: "prod", alertGroups: groups.slice(0, 7), + totalGroups: groups.slice(0, 7).length, stateCount: { unprocessed: 1, suppressed: 2, @@ -311,6 +312,7 @@ const MockGrid = (alertStore: AlertStore): void => { labelName: "cluster", labelValue: "", alertGroups: groups.slice(7, 11), + totalGroups: groups.slice(7, 11).length, stateCount: { unprocessed: 0, suppressed: 10,