From 4516a58df91e5f3fd2cb496b5330c3b03db0fa06 Mon Sep 17 00:00:00 2001 From: Andrii Chubatiuk Date: Wed, 18 Mar 2026 13:25:36 +0200 Subject: [PATCH] app/vmalert: add `group_limit` and `page_num` for pagination and `search` for search at /api/v1/rules (#10046) ### Describe Your Changes Fixes https://github.com/VictoriaMetrics/VictoriaMetrics/issues/9580 inspired by https://github.com/VictoriaMetrics/VictoriaMetrics/pull/9057 improve https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10005 added changes to support pagination in VMUI alerting tab: - added pagination panel image - added navigation from group modal to rule and from child modals to group as a replacement for anchors navigation, which became impossible after introduction of pagination image image PR is rebased against [this change](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/10068) ### Checklist The following checks are **mandatory**: - [ ] My change adheres to [VictoriaMetrics contributing guidelines](https://docs.victoriametrics.com/victoriametrics/contributing/#pull-request-checklist). - [ ] My change adheres to [VictoriaMetrics development goals](https://docs.victoriametrics.com/victoriametrics/goals/). --------- Signed-off-by: Andrii Chubatiuk Co-authored-by: Haley Wang Co-authored-by: Max Kotliar --- app/vmalert/manager.go | 10 +- app/vmalert/rule/web.go | 33 +- app/vmalert/static/icons/icons.svg | 2 +- app/vmalert/static/js/custom.js | 4 +- app/vmalert/web.go | 347 +++++++++++------- app/vmalert/web.qtpl | 42 +-- app/vmalert/web.qtpl.go | 73 ++-- app/vmalert/web_test.go | 10 +- .../packages/vmui/src/api/explore-alerts.ts | 4 +- .../components/ExploreAlerts/Badges/index.tsx | 2 +- .../ExploreAlerts/Badges/style.scss | 2 +- .../ExploreAlerts/BaseGroup/index.tsx | 18 +- .../ExploreAlerts/ItemHeader/index.tsx | 46 ++- .../ExploreAlerts/ItemHeader/style.scss | 4 + .../ExploreAlerts/Pagination/index.tsx | 94 +++++ .../ExploreAlerts/Pagination/style.scss | 33 ++ .../ExploreAlerts/RulesHeader/index.tsx | 20 +- .../src/components/ExploreAlerts/helpers.ts | 11 + .../src/pages/ExploreAlerts/ExploreRule.tsx | 2 +- .../src/pages/ExploreAlerts/ExploreRules.tsx | 111 +++--- .../vmui/src/pages/ExploreAlerts/helpers.ts | 76 ---- .../ExploreAlerts/hooks/useFetchGroups.ts | 56 ++- .../ExploreAlerts/hooks/useSetQueryParams.ts | 8 +- .../vmui/src/pages/ExploreAlerts/style.scss | 13 + app/vmui/packages/vmui/src/types/index.ts | 1 + docs/victoriametrics/changelog/CHANGELOG.md | 2 + docs/victoriametrics/vmalert.md | 2 +- 27 files changed, 652 insertions(+), 374 deletions(-) create mode 100644 app/vmui/packages/vmui/src/components/ExploreAlerts/Pagination/index.tsx create mode 100644 app/vmui/packages/vmui/src/components/ExploreAlerts/Pagination/style.scss diff --git a/app/vmalert/manager.go b/app/vmalert/manager.go index 3ded7fc7ae..2c0dfc29ad 100644 --- a/app/vmalert/manager.go +++ b/app/vmalert/manager.go @@ -98,7 +98,7 @@ func (m *manager) close() { m.wg.Wait() } -func (m *manager) startGroup(ctx context.Context, g *rule.Group, restore bool) error { +func (m *manager) startGroup(ctx context.Context, g *rule.Group, restore bool) { id := g.GetID() g.Init() m.wg.Go(func() { @@ -110,7 +110,6 @@ func (m *manager) startGroup(ctx context.Context, g *rule.Group, restore bool) e }) m.groups[id] = g - return nil } func (m *manager) update(ctx context.Context, groupsCfg []config.Group, restore bool) error { @@ -119,7 +118,7 @@ func (m *manager) update(ctx context.Context, groupsCfg []config.Group, restore for _, cfg := range groupsCfg { for _, r := range cfg.Rules { if rrPresent && arPresent { - continue + break } if r.Record != "" { rrPresent = true @@ -162,10 +161,7 @@ func (m *manager) update(ctx context.Context, groupsCfg []config.Group, restore } } for _, ng := range groupsRegistry { - if err := m.startGroup(ctx, ng, restore); err != nil { - m.groupsMu.Unlock() - return err - } + m.startGroup(ctx, ng, restore) } m.groupsMu.Unlock() diff --git a/app/vmalert/rule/web.go b/app/vmalert/rule/web.go index e9ed4fb67f..80fb080897 100644 --- a/app/vmalert/rule/web.go +++ b/app/vmalert/rule/web.go @@ -57,12 +57,8 @@ type ApiGroup struct { EvalOffset float64 `json:"eval_offset,omitempty"` // EvalDelay will adjust the `time` parameter of rule evaluation requests to compensate intentional query delay from datasource. EvalDelay float64 `json:"eval_delay,omitempty"` - // Unhealthy unhealthy rules count - Unhealthy int - // Healthy passing rules count - Healthy int - // NoMatch not matching rules count - NoMatch int + // States represents counts per each rule state + States map[string]int `json:"states"` } // APILink returns a link to the group's JSON representation. @@ -134,6 +130,11 @@ type ApiRule struct { Updates []StateEntry `json:"-"` } +// IsNoMatch returns true if rule is in nomatch state +func (r *ApiRule) IsNoMatch() bool { + return r.LastSamples == 0 && r.LastSeriesFetched != nil && *r.LastSeriesFetched == 0 +} + // ApiAlert represents a notifier.AlertingRule state // for WEB view // https://github.com/prometheus/compliance/blob/main/alert_generator/specification.md#get-apiv1rules @@ -235,6 +236,20 @@ func NewAlertAPI(ar *AlertingRule, a *notifier.Alert) *ApiAlert { return aa } +func (r *ApiRule) ExtendState() { + if len(r.Alerts) > 0 { + return + } + if r.State == "" { + r.State = "ok" + } + if r.Health != "ok" { + r.State = "unhealthy" + } else if r.IsNoMatch() { + r.State = "nomatch" + } +} + // ToAPI returns ApiGroup representation of g func (g *Group) ToAPI() *ApiGroup { g.mu.RLock() @@ -252,6 +267,7 @@ func (g *Group) ToAPI() *ApiGroup { Headers: headersToStrings(g.Headers), NotifierHeaders: headersToStrings(g.NotifierHeaders), Labels: g.Labels, + States: make(map[string]int), } if g.EvalOffset != nil { ag.EvalOffset = g.EvalOffset.Seconds() @@ -259,9 +275,10 @@ func (g *Group) ToAPI() *ApiGroup { if g.EvalDelay != nil { ag.EvalDelay = g.EvalDelay.Seconds() } - ag.Rules = make([]ApiRule, 0) + ag.Rules = make([]ApiRule, 0, len(g.Rules)) for _, r := range g.Rules { - ag.Rules = append(ag.Rules, r.ToAPI()) + ar := r.ToAPI() + ag.Rules = append(ag.Rules, ar) } return &ag } diff --git a/app/vmalert/static/icons/icons.svg b/app/vmalert/static/icons/icons.svg index 4f673a7d9c..5f62a3cecd 100644 --- a/app/vmalert/static/icons/icons.svg +++ b/app/vmalert/static/icons/icons.svg @@ -11,7 +11,7 @@ - + diff --git a/app/vmalert/static/js/custom.js b/app/vmalert/static/js/custom.js index 30bb519e07..03470fc0a3 100644 --- a/app/vmalert/static/js/custom.js +++ b/app/vmalert/static/js/custom.js @@ -8,9 +8,9 @@ function actionAll(isCollapse) { }); } -function groupFilter(key) { +function groupForState(key) { if (key) { - location.href = `?filter=${key}`; + location.href = `?state=${key}`; } else { window.location = window.location.pathname; } diff --git a/app/vmalert/web.go b/app/vmalert/web.go index de6bd7deea..6e873d15a7 100644 --- a/app/vmalert/web.go +++ b/app/vmalert/web.go @@ -1,9 +1,11 @@ package main import ( + "cmp" "embed" "encoding/json" "fmt" + "math" "net/http" "slices" "strconv" @@ -50,6 +52,7 @@ var ( "alert": rule.TypeAlerting, "record": rule.TypeRecording, } + ruleStates = []string{"ok", "nomatch", "inactive", "firing", "pending", "unhealthy"} ) type requestHandler struct { @@ -63,6 +66,14 @@ var ( staticServer = http.StripPrefix("/vmalert", staticHandler) ) +func marshalJson(v any, kind string) ([]byte, *httpserver.ErrorWithStatusCode) { + data, err := json.Marshal(v) + if err != nil { + return nil, errResponse(fmt.Errorf("failed to marshal %s: %s", kind, err), http.StatusInternalServerError) + } + return data, nil +} + func (rh *requestHandler) handler(w http.ResponseWriter, r *http.Request) bool { if strings.HasPrefix(r.URL.Path, "/vmalert/static") { staticServer.ServeHTTP(w, r) @@ -94,40 +105,32 @@ func (rh *requestHandler) handler(w http.ResponseWriter, r *http.Request) bool { httpserver.Errorf(w, r, "%s", err) return true } - WriteRuleDetails(w, r, rule) + WriteRule(w, r, rule) return true - case "/vmalert/groups": + // current used by old vmalert UI and Grafana Alerts + case "/vmalert/groups", "/rules": rf, err := newRulesFilter(r) if err != nil { httpserver.Errorf(w, r, "%s", err) return true } - data := rh.groups(rf) - WriteListGroups(w, r, data, rf.filter) + // only support filtering by a single state + state := "" + if len(rf.states) > 0 { + state = rf.states[0] + rf.states = rf.states[:1] + } + lr := rh.groups(rf) + WriteListGroups(w, r, lr.Data.Groups, state) return true case "/vmalert/notifiers": WriteListTargets(w, r, notifier.GetTargets()) return true - // special cases for Grafana requests, - // served without `vmalert` prefix: - case "/rules": - // Grafana makes an extra request to `/rules` - // handler in addition to `/api/v1/rules` calls in alerts UI - var data []*rule.ApiGroup - rf, err := newRulesFilter(r) - if err != nil { - httpserver.Errorf(w, r, "%s", err) - return true - } - data = rh.groups(rf) - WriteListGroups(w, r, data, rf.filter) - return true - case "/vmalert/api/v1/notifiers", "/api/v1/notifiers": data, err := rh.listNotifiers() if err != nil { - httpserver.Errorf(w, r, "%s", err) + errJson(w, r, err) return true } w.Header().Set("Content-Type", "application/json") @@ -135,15 +138,14 @@ func (rh *requestHandler) handler(w http.ResponseWriter, r *http.Request) bool { return true case "/vmalert/api/v1/rules", "/api/v1/rules": // path used by Grafana for ng alerting - var data []byte rf, err := newRulesFilter(r) if err != nil { - httpserver.Errorf(w, r, "%s", err) + errJson(w, r, err) return true } - data, err = rh.listGroups(rf) + data, err := rh.listGroups(rf) if err != nil { - httpserver.Errorf(w, r, "%s", err) + errJson(w, r, err) return true } w.Header().Set("Content-Type", "application/json") @@ -152,14 +154,14 @@ func (rh *requestHandler) handler(w http.ResponseWriter, r *http.Request) bool { case "/vmalert/api/v1/alerts", "/api/v1/alerts": // path used by Grafana for ng alerting - rf, err := newRulesFilter(r) + gf, err := newGroupsFilter(r) if err != nil { - httpserver.Errorf(w, r, "%s", err) + errJson(w, r, err) return true } - data, err := rh.listAlerts(rf) + data, err := rh.listAlerts(gf) if err != nil { - httpserver.Errorf(w, r, "%s", err) + errJson(w, r, err) return true } w.Header().Set("Content-Type", "application/json") @@ -168,12 +170,12 @@ func (rh *requestHandler) handler(w http.ResponseWriter, r *http.Request) bool { case "/vmalert/api/v1/alert", "/api/v1/alert": alert, err := rh.getAlert(r) if err != nil { - httpserver.Errorf(w, r, "%s", err) + errJson(w, r, err) return true } - data, err := json.Marshal(alert) + data, err := marshalJson(alert, "alert") if err != nil { - httpserver.Errorf(w, r, "failed to marshal alert: %s", err) + errJson(w, r, err) return true } w.Header().Set("Content-Type", "application/json") @@ -182,16 +184,16 @@ func (rh *requestHandler) handler(w http.ResponseWriter, r *http.Request) bool { case "/vmalert/api/v1/rule", "/api/v1/rule": apiRule, err := rh.getRule(r) if err != nil { - httpserver.Errorf(w, r, "%s", err) + errJson(w, r, err) return true } rwu := rule.ApiRuleWithUpdates{ ApiRule: apiRule, StateUpdates: apiRule.Updates, } - data, err := json.Marshal(rwu) + data, err := marshalJson(rwu, "rule") if err != nil { - httpserver.Errorf(w, r, "failed to marshal rule: %s", err) + errJson(w, r, err) return true } w.Header().Set("Content-Type", "application/json") @@ -200,12 +202,12 @@ func (rh *requestHandler) handler(w http.ResponseWriter, r *http.Request) bool { case "/vmalert/api/v1/group", "/api/v1/group": group, err := rh.getGroup(r) if err != nil { - httpserver.Errorf(w, r, "%s", err) + errJson(w, r, err) return true } - data, err := json.Marshal(group) + data, err := marshalJson(group, "group") if err != nil { - httpserver.Errorf(w, r, "failed to marshal group: %s", err) + errJson(w, r, err) return true } w.Header().Set("Content-Type", "application/json") @@ -225,10 +227,10 @@ func (rh *requestHandler) handler(w http.ResponseWriter, r *http.Request) bool { } } -func (rh *requestHandler) getGroup(r *http.Request) (*rule.ApiGroup, error) { +func (rh *requestHandler) getGroup(r *http.Request) (*rule.ApiGroup, *httpserver.ErrorWithStatusCode) { groupID, err := strconv.ParseUint(r.FormValue(rule.ParamGroupID), 10, 64) if err != nil { - return nil, fmt.Errorf("failed to read %q param: %w", rule.ParamGroupID, err) + return nil, errResponse(fmt.Errorf("failed to read %q param: %w", rule.ParamGroupID, err), http.StatusBadRequest) } obj, err := rh.m.groupAPI(groupID) if err != nil { @@ -237,14 +239,14 @@ func (rh *requestHandler) getGroup(r *http.Request) (*rule.ApiGroup, error) { return obj, nil } -func (rh *requestHandler) getRule(r *http.Request) (rule.ApiRule, error) { +func (rh *requestHandler) getRule(r *http.Request) (rule.ApiRule, *httpserver.ErrorWithStatusCode) { groupID, err := strconv.ParseUint(r.FormValue(rule.ParamGroupID), 10, 64) if err != nil { - return rule.ApiRule{}, fmt.Errorf("failed to read %q param: %w", rule.ParamGroupID, err) + return rule.ApiRule{}, errResponse(fmt.Errorf("failed to read %q param: %w", rule.ParamGroupID, err), http.StatusBadRequest) } ruleID, err := strconv.ParseUint(r.FormValue(rule.ParamRuleID), 10, 64) if err != nil { - return rule.ApiRule{}, fmt.Errorf("failed to read %q param: %w", rule.ParamRuleID, err) + return rule.ApiRule{}, errResponse(fmt.Errorf("failed to read %q param: %w", rule.ParamRuleID, err), http.StatusBadRequest) } obj, err := rh.m.ruleAPI(groupID, ruleID) if err != nil { @@ -253,14 +255,14 @@ func (rh *requestHandler) getRule(r *http.Request) (rule.ApiRule, error) { return obj, nil } -func (rh *requestHandler) getAlert(r *http.Request) (*rule.ApiAlert, error) { +func (rh *requestHandler) getAlert(r *http.Request) (*rule.ApiAlert, *httpserver.ErrorWithStatusCode) { groupID, err := strconv.ParseUint(r.FormValue(rule.ParamGroupID), 10, 64) if err != nil { - return nil, fmt.Errorf("failed to read %q param: %w", rule.ParamGroupID, err) + return nil, errResponse(fmt.Errorf("failed to read %q param: %w", rule.ParamGroupID, err), http.StatusBadRequest) } alertID, err := strconv.ParseUint(r.FormValue(rule.ParamAlertID), 10, 64) if err != nil { - return nil, fmt.Errorf("failed to read %q param: %w", rule.ParamAlertID, err) + return nil, errResponse(fmt.Errorf("failed to read %q param: %w", rule.ParamAlertID, err), http.StatusBadRequest) } a, err := rh.m.alertAPI(groupID, alertID) if err != nil { @@ -270,28 +272,76 @@ func (rh *requestHandler) getAlert(r *http.Request) (*rule.ApiAlert, error) { } type listGroupsResponse struct { - Status string `json:"status"` - Data struct { + Status string `json:"status"` + Page int `json:"page,omitempty"` + TotalPages int `json:"total_pages,omitempty"` + TotalGroups int `json:"total_groups,omitempty"` + TotalRules int `json:"total_rules,omitempty"` + Data struct { Groups []*rule.ApiGroup `json:"groups"` } `json:"data"` } -// see https://prometheus.io/docs/prometheus/latest/querying/api/#rules -type rulesFilter struct { - files []string - groupNames []string - ruleNames []string - ruleType string - excludeAlerts bool - filter string - dsType config.Type +type groupsFilter struct { + groupNames []string + files []string + dsType config.Type } -func newRulesFilter(r *http.Request) (*rulesFilter, error) { - rf := &rulesFilter{} - query := r.URL.Query() +func newGroupsFilter(r *http.Request) (*groupsFilter, *httpserver.ErrorWithStatusCode) { + _ = r.ParseForm() + vs := r.Form + gf := &groupsFilter{ + groupNames: vs["rule_group[]"], + files: vs["file[]"], + } + dsType := vs.Get("datasource_type") + if len(dsType) > 0 { + if config.SupportedType(dsType) { + gf.dsType = config.NewRawType(dsType) + } else { + return nil, errResponse(fmt.Errorf(`invalid parameter "datasource_type": not supported value %q`, dsType), http.StatusBadRequest) + } + } + return gf, nil +} - ruleTypeParam := query.Get("type") +func (gf *groupsFilter) matches(group *rule.Group) bool { + if len(gf.groupNames) > 0 && !slices.Contains(gf.groupNames, group.Name) { + return false + } + if len(gf.files) > 0 && !slices.Contains(gf.files, group.File) { + return false + } + if len(gf.dsType.Name) > 0 && gf.dsType.String() != group.Type.String() { + return false + } + return true +} + +// see https://prometheus.io/docs/prometheus/latest/querying/api/#rules +type rulesFilter struct { + gf *groupsFilter + ruleNames []string + ruleType string + excludeAlerts bool + states []string + maxGroups int + pageNum int + search string + extendedStates bool +} + +func newRulesFilter(r *http.Request) (*rulesFilter, *httpserver.ErrorWithStatusCode) { + gf, err := newGroupsFilter(r) + if err != nil { + return nil, err + } + + var rf rulesFilter + rf.gf = gf + vs := r.Form + ruleTypeParam := vs.Get("type") if len(ruleTypeParam) > 0 { if ruleType, ok := ruleTypeMap[ruleTypeParam]; ok { rf.ruleType = ruleType @@ -300,102 +350,146 @@ func newRulesFilter(r *http.Request) (*rulesFilter, error) { } } - dsType := query.Get("datasource_type") - if len(dsType) > 0 { - if config.SupportedType(dsType) { - rf.dsType = config.NewRawType(dsType) - } else { - return nil, errResponse(fmt.Errorf(`invalid parameter "datasource_type": not supported value %q`, dsType), http.StatusBadRequest) - } + states := vs["state"] + if len(states) == 0 { + states = vs["filter"] } - - filter := strings.ToLower(query.Get("filter")) - if len(filter) > 0 { - if filter == "nomatch" || filter == "unhealthy" { - rf.filter = filter - } else { - return nil, errResponse(fmt.Errorf(`invalid parameter "filter": not supported value %q`, filter), http.StatusBadRequest) + for _, s := range states { + values := strings.Split(s, ",") + for _, v := range values { + if len(v) == 0 { + continue + } + if !slices.Contains(ruleStates, v) { + return nil, errResponse(fmt.Errorf(`invalid parameter "state": contains not supported value %q`, v), http.StatusBadRequest) + } + rf.states = append(rf.states, v) } } rf.excludeAlerts = httputil.GetBool(r, "exclude_alerts") - rf.ruleNames = append([]string{}, r.Form["rule_name[]"]...) - rf.groupNames = append([]string{}, r.Form["rule_group[]"]...) - rf.files = append([]string{}, r.Form["file[]"]...) - return rf, nil + rf.extendedStates = httputil.GetBool(r, "extended_states") + rf.ruleNames = append([]string{}, vs["rule_name[]"]...) + rf.search = strings.ToLower(vs.Get("search")) + + pageNum := vs.Get("page_num") + maxGroups := vs.Get("group_limit") + if pageNum != "" { + if maxGroups == "" { + return nil, errResponse(fmt.Errorf(`"group_limit" needs to be present in order to paginate over the groups`), http.StatusBadRequest) + } + v, err := strconv.Atoi(pageNum) + if err != nil || v <= 0 { + return nil, errResponse(fmt.Errorf(`"page_num" is expected to be a positive number, found %q`, pageNum), http.StatusBadRequest) + } + rf.pageNum = v + } + if maxGroups != "" { + v, err := strconv.Atoi(maxGroups) + if err != nil || v <= 0 { + return nil, errResponse(fmt.Errorf(`"group_limit" is expected to be a positive number, found %q`, maxGroups), http.StatusBadRequest) + } + rf.maxGroups = v + } + return &rf, nil } -func (rf *rulesFilter) matchesGroup(group *rule.Group) bool { - if len(rf.groupNames) > 0 && !slices.Contains(rf.groupNames, group.Name) { +func (rf *rulesFilter) matchesRule(r *rule.ApiRule) bool { + if rf.ruleType != "" && rf.ruleType != r.Type { return false } - if len(rf.files) > 0 && !slices.Contains(rf.files, group.File) { + if len(rf.ruleNames) > 0 && !slices.Contains(rf.ruleNames, r.Name) { return false } - if len(rf.dsType.Name) > 0 && rf.dsType.String() != group.Type.String() { - return false + if len(rf.states) == 0 { + return true } - return true + return slices.Contains(rf.states, r.State) } -func (rh *requestHandler) groups(rf *rulesFilter) []*rule.ApiGroup { +func (rh *requestHandler) groups(rf *rulesFilter) *listGroupsResponse { rh.m.groupsMu.RLock() defer rh.m.groupsMu.RUnlock() - groups := make([]*rule.ApiGroup, 0) + skipGroups := (rf.pageNum - 1) * rf.maxGroups + lr := &listGroupsResponse{ + Status: "success", + } + lr.Data.Groups = make([]*rule.ApiGroup, 0) + if skipGroups >= len(rh.m.groups) { + return lr + } + // sort list of groups for deterministic output + groups := make([]*rule.Group, 0, len(rh.m.groups)) for _, group := range rh.m.groups { - if !rf.matchesGroup(group) { + groups = append(groups, group) + } + + slices.SortFunc(groups, func(a, b *rule.Group) int { + nameCmp := cmp.Compare(a.Name, b.Name) + if nameCmp != 0 { + return nameCmp + } + return cmp.Compare(a.File, b.File) + }) + for _, group := range groups { + if !rf.gf.matches(group) { continue } + groupFound := len(rf.search) == 0 || strings.Contains(strings.ToLower(group.Name), rf.search) || strings.Contains(strings.ToLower(group.File), rf.search) g := group.ToAPI() // the returned list should always be non-nil // https://github.com/VictoriaMetrics/VictoriaMetrics/issues/4221 filteredRules := make([]rule.ApiRule, 0) for _, rule := range g.Rules { - if rf.ruleType != "" && rf.ruleType != rule.Type { + if !groupFound && !strings.Contains(strings.ToLower(rule.Name), rf.search) { continue } - if len(rf.ruleNames) > 0 && !slices.Contains(rf.ruleNames, rule.Name) { - continue + if rf.extendedStates { + rule.ExtendState() } - if (rule.LastError == "" && rf.filter == "unhealthy") || (!isNoMatch(rule) && rf.filter == "nomatch") { + if !rf.matchesRule(&rule) { continue } if rf.excludeAlerts { rule.Alerts = nil } - if rule.LastError != "" { - g.Unhealthy++ - } else { - g.Healthy++ - } - if isNoMatch(rule) { - g.NoMatch++ - } + g.States[rule.State]++ filteredRules = append(filteredRules, rule) } - g.Rules = filteredRules - groups = append(groups, g) - } - // sort list of groups for deterministic output - slices.SortFunc(groups, func(a, b *rule.ApiGroup) int { - if a.Name != b.Name { - return strings.Compare(a.Name, b.Name) + if len(g.Rules) == 0 || len(filteredRules) > 0 { + if rf.maxGroups > 0 { + lr.TotalGroups++ + lr.TotalRules += len(filteredRules) + } + if skipGroups > 0 { + skipGroups-- + continue + } + if rf.maxGroups == 0 || len(lr.Data.Groups) < rf.maxGroups { + g.Rules = filteredRules + lr.Data.Groups = append(lr.Data.Groups, g) + } } - return strings.Compare(a.File, b.File) - }) - return groups + } + if rf.maxGroups > 0 { + lr.Page = rf.pageNum + lr.TotalPages = max(int(math.Ceil(float64(lr.TotalGroups)/float64(rf.maxGroups))), 1) + } + return lr } -func (rh *requestHandler) listGroups(rf *rulesFilter) ([]byte, error) { - lr := listGroupsResponse{Status: "success"} - lr.Data.Groups = rh.groups(rf) +func (rh *requestHandler) listGroups(rf *rulesFilter) ([]byte, *httpserver.ErrorWithStatusCode) { + lr := rh.groups(rf) + if rf.pageNum > 1 && len(lr.Data.Groups) == 0 { + return nil, errResponse(fmt.Errorf(`page_num exceeds total amount of pages`), http.StatusBadRequest) + } + if lr.Page > lr.TotalPages { + return nil, errResponse(fmt.Errorf(`page_num=%d exceeds total amount of pages in result=%d`, lr.Page, lr.TotalPages), http.StatusBadRequest) + } b, err := json.Marshal(lr) if err != nil { - return nil, &httpserver.ErrorWithStatusCode{ - Err: fmt.Errorf(`error encoding list of active alerts: %w`, err), - StatusCode: http.StatusInternalServerError, - } + return nil, errResponse(fmt.Errorf(`error encoding list of groups: %w`, err), http.StatusInternalServerError) } return b, nil } @@ -434,14 +528,14 @@ func (rh *requestHandler) groupAlerts() []rule.GroupAlerts { return gAlerts } -func (rh *requestHandler) listAlerts(rf *rulesFilter) ([]byte, error) { +func (rh *requestHandler) listAlerts(gf *groupsFilter) ([]byte, *httpserver.ErrorWithStatusCode) { rh.m.groupsMu.RLock() defer rh.m.groupsMu.RUnlock() lr := listAlertsResponse{Status: "success"} lr.Data.Alerts = make([]*rule.ApiAlert, 0) for _, group := range rh.m.groups { - if !rf.matchesGroup(group) { + if !gf.matches(group) { continue } g := group.ToAPI() @@ -460,10 +554,7 @@ func (rh *requestHandler) listAlerts(rf *rulesFilter) ([]byte, error) { b, err := json.Marshal(lr) if err != nil { - return nil, &httpserver.ErrorWithStatusCode{ - Err: fmt.Errorf(`error encoding list of active alerts: %w`, err), - StatusCode: http.StatusInternalServerError, - } + return nil, errResponse(fmt.Errorf(`error encoding list of active alerts: %w`, err), http.StatusInternalServerError) } return b, nil } @@ -475,7 +566,7 @@ type listNotifiersResponse struct { } `json:"data"` } -func (rh *requestHandler) listNotifiers() ([]byte, error) { +func (rh *requestHandler) listNotifiers() ([]byte, *httpserver.ErrorWithStatusCode) { targets := notifier.GetTargets() lr := listNotifiersResponse{Status: "success"} @@ -497,10 +588,7 @@ func (rh *requestHandler) listNotifiers() ([]byte, error) { b, err := json.Marshal(lr) if err != nil { - return nil, &httpserver.ErrorWithStatusCode{ - Err: fmt.Errorf(`error encoding list of notifiers: %w`, err), - StatusCode: http.StatusInternalServerError, - } + return nil, errResponse(fmt.Errorf(`error encoding list of notifiers: %w`, err), http.StatusInternalServerError) } return b, nil } @@ -511,3 +599,8 @@ func errResponse(err error, sc int) *httpserver.ErrorWithStatusCode { StatusCode: sc, } } + +func errJson(w http.ResponseWriter, r *http.Request, err *httpserver.ErrorWithStatusCode) { + w.Header().Set("Content-Type", "application/json") + httpserver.Errorf(w, r, `{"error":%q,"errorType":%d}`, err, err.StatusCode) +} diff --git a/app/vmalert/web.qtpl b/app/vmalert/web.qtpl index a2eb84b7c0..fe366cb954 100644 --- a/app/vmalert/web.qtpl +++ b/app/vmalert/web.qtpl @@ -12,7 +12,7 @@ "github.com/VictoriaMetrics/VictoriaMetrics/lib/buildinfo" ) %} -{% func Controls(prefix, currentIcon, currentText string, icons, filters map[string]string, search bool) %} +{% func Controls(prefix, currentIcon, currentText string, icons, states map[string]string, search bool) %}