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
<img width="1431" height="197" alt="image"
src="https://github.com/user-attachments/assets/17b2c4e1-06b7-4345-8ccc-008637edd4e0"
/>
- 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
<img width="1264" height="599" alt="image"
src="https://github.com/user-attachments/assets/a803347f-e44e-4325-9b59-8656bd6a5d9b"
/>
<img width="1253" height="523" alt="image"
src="https://github.com/user-attachments/assets/70db27bd-0027-4510-9cad-0354e016d2f2"
/>


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 <achubatiuk@victoriametrics.com>
Co-authored-by: Haley Wang <haley@victoriametrics.com>
Co-authored-by: Max Kotliar <mkotlyar@victoriametrics.com>
This commit is contained in:
Andrii Chubatiuk
2026-03-18 13:25:36 +02:00
committed by GitHub
parent 5ad7b645e6
commit 4516a58df9
27 changed files with 652 additions and 374 deletions

View File

@@ -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()

View File

@@ -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
}

View File

@@ -11,7 +11,7 @@
<path d="M224.163 175.27a1.9 1.9 0 0 0 2.8 0l6-5.9a2.1 2.1 0 0 0 .2-2.7 1.9 1.9 0 0 0-3-.2l-2.6 2.6v-5.2c0-1.54-1.667-2.502-3-1.732-.619.357-1 1.017-1 1.732v5.2l-2.6-2.6a1.9 1.9 0 0 0-3 .2 2.1 2.1 0 0 0 .2 2.7zm-16.459-23.297h36c1.54 0 2.502-1.667 1.732-3a2 2 0 0 0-1.732-1h-36c-1.54 0-2.502 1.667-1.732 3 .357.619 1.017 1 1.732 1m36 4h-36c-1.54 0-2.502 1.667-1.732 3 .357.619 1.017 1 1.732 1h36c1.54 0 2.502-1.667 1.732-3a2 2 0 0 0-1.732-1m-16.59-23.517a1.9 1.9 0 0 0-2.8 0l-6 5.9a2.1 2.1 0 0 0-.2 2.7 1.9 1.9 0 0 0 3 .2l2.6-2.6v5.2c0 1.54 1.667 2.502 3 1.732.619-.357 1-1.017 1-1.732v-5.2l2.6 2.6a1.9 1.9 0 0 0 3-.2 2.1 2.1 0 0 0-.2-2.7z"/>
</symbol>
<symbol id="filter" viewBox="-10 -10 320 310">
<symbol id="state" viewBox="-10 -10 320 310">
<path d="M288.953 0h-277c-5.522 0-10 4.478-10 10v49.531c0 5.522 4.478 10 10 10h12.372l91.378 107.397v113.978a10 10 0 0 0 15.547 8.32l49.5-33a10 10 0 0 0 4.453-8.32v-80.978l91.378-107.397h12.372c5.522 0 10-4.478 10-10V10c0-5.522-4.477-10-10-10M167.587 166.77a10 10 0 0 0-2.384 6.48v79.305l-29.5 19.666V173.25a10 10 0 0 0-2.384-6.48L50.585 69.531h199.736zM278.953 49.531h-257V20h257z"/>
</symbol>

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -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;
}

View File

@@ -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)
}

View File

@@ -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) %}
<div class="btn-toolbar mb-3" role="toolbar">
<div class="d-flex gap-2 justify-content-between w-100">
<div class="d-flex gap-2 align-items-center">
@@ -28,10 +28,10 @@
<use href="{%s prefix %}static/icons/icons.svg#expand"/>
</svg>
</a>
{% if len(filters) > 0 %}
{% if len(states) > 0 %}
<span class="d-none d-md-inline-block">Filter by status:</span>
<svg class="d-md-none" width="20" height="20">
<use href="{%s prefix %}static/icons/icons.svg#filter">
<use href="{%s prefix %}static/icons/icons.svg#state">
</svg>
<div class="dropdown">
<button
@@ -46,10 +46,10 @@
</svg>
</button>
<ul class="dropdown-menu">
{% for key, title := range filters %}
{% for key, title := range states %}
{% if title != currentText %}
<li>
<a class="dropdown-item" onclick="groupFilter('{%s key %}')">
<a class="dropdown-item" onclick="groupForState('{%s key %}')">
<span class="d-none d-md-inline-block">{%s title %}</span>
<svg class="d-md-none" width="22" height="22">
<use href="{%s prefix %}static/icons/icons.svg#{%s icons[key] %}"/>
@@ -97,10 +97,10 @@
{%= tpl.Footer(r) %}
{% endfunc %}
{% func ListGroups(r *http.Request, groups []*rule.ApiGroup, filter string) %}
{% func ListGroups(r *http.Request, groups []*rule.ApiGroup, state string) %}
{%code
prefix := vmalertutil.Prefix(r.URL.Path)
filters := map[string]string{
states := map[string]string{
"": "All",
"unhealthy": "Unhealthy",
"nomatch": "No Match",
@@ -110,14 +110,14 @@
"unhealthy": "unhealthy",
"nomatch": "nomatch",
}
currentText := filters[filter]
currentIcon := icons[filter]
currentText := states[state]
currentIcon := icons[state]
%}
{%= tpl.Header(r, navItems, "Groups", getLastConfigError()) %}
{%= Controls(prefix, currentIcon, currentText, icons, filters, true) %}
{%= Controls(prefix, currentIcon, currentText, icons, states, true) %}
{% if len(groups) > 0 %}
{% for _, g := range groups %}
<div id="group-{%s g.ID %}" class="w-100 border-0 flex-column vm-group{% if g.Unhealthy > 0 %} alert-danger{% endif %}">
<div id="group-{%s g.ID %}" class="w-100 border-0 flex-column vm-group{% if g.States["unhealthy"] > 0 %} alert-danger{% endif %}">
<span class="d-flex justify-content-between">
<a
class="vm-group-search"
@@ -130,9 +130,9 @@
data-bs-target="#item-{%s g.ID %}"
>
<span class="d-flex gap-2">
{% if g.Unhealthy > 0 %}<span class="badge bg-danger" title="Number of rules with status Error">{%d g.Unhealthy %}</span> {% endif %}
{% if g.NoMatch > 0 %}<span class="badge bg-warning" title="Number of rules with status NoMatch">{%d g.NoMatch %}</span> {% endif %}
<span class="badge bg-success" title="Number of rules with status Ok">{%d g.Healthy %}</span>
{% if g.States["unhealthy"] > 0 %}<span class="badge bg-danger" title="Number of rules with status Error">{%d g.States["unhealthy"] %}</span> {% endif %}
{% if g.States["nomatch"] > 0 %}<span class="badge bg-warning" title="Number of rules with status NoMatch">{%d g.States["nomatch"] %}</span> {% endif %}
<span class="badge bg-success" title="Number of rules with status Ok">{%d g.States["ok"] %}</span>
</span>
</span>
</span>
@@ -189,7 +189,7 @@
<b>record:</b> {%s r.Name %}
{% endif %}
|
{%= seriesFetchedWarn(prefix, r) %}
{%= seriesFetchedWarn(prefix, &r) %}
<span><a target="_blank" href="{%s prefix+r.WebLink() %}">Details</a></span>
</div>
<div class="col-12">
@@ -476,7 +476,7 @@
{% endfunc %}
{% func RuleDetails(r *http.Request, rule rule.ApiRule) %}
{% func Rule(r *http.Request, rule rule.ApiRule) %}
{%code prefix := vmalertutil.Prefix(r.URL.Path) %}
{%= tpl.Header(r, navItems, "", getLastConfigError()) %}
{%code
@@ -661,8 +661,8 @@
<span class="badge bg-warning text-dark" title="This firing state is kept because of `keep_firing_for`">stabilizing</span>
{% endfunc %}
{% func seriesFetchedWarn(prefix string, r rule.ApiRule) %}
{% if isNoMatch(r) %}
{% func seriesFetchedWarn(prefix string, r *rule.ApiRule) %}
{% if r.IsNoMatch() %}
<svg
data-bs-toggle="tooltip"
title="No match! This rule's last evaluation hasn't selected any time series from the datasource.
@@ -673,9 +673,3 @@
</svg>
{% endif %}
{% endfunc %}
{%code
func isNoMatch (r rule.ApiRule) bool {
return r.LastSamples == 0 && r.LastSeriesFetched != nil && *r.LastSeriesFetched == 0
}
%}

View File

@@ -31,7 +31,7 @@ var (
)
//line app/vmalert/web.qtpl:15
func StreamControls(qw422016 *qt422016.Writer, prefix, currentIcon, currentText string, icons, filters map[string]string, search bool) {
func StreamControls(qw422016 *qt422016.Writer, prefix, currentIcon, currentText string, icons, states map[string]string, search bool) {
//line app/vmalert/web.qtpl:15
qw422016.N().S(`
<div class="btn-toolbar mb-3" role="toolbar">
@@ -59,7 +59,7 @@ func StreamControls(qw422016 *qt422016.Writer, prefix, currentIcon, currentText
</a>
`)
//line app/vmalert/web.qtpl:31
if len(filters) > 0 {
if len(states) > 0 {
//line app/vmalert/web.qtpl:31
qw422016.N().S(`
<span class="d-none d-md-inline-block">Filter by status:</span>
@@ -68,7 +68,7 @@ func StreamControls(qw422016 *qt422016.Writer, prefix, currentIcon, currentText
//line app/vmalert/web.qtpl:34
qw422016.E().S(prefix)
//line app/vmalert/web.qtpl:34
qw422016.N().S(`static/icons/icons.svg#filter">
qw422016.N().S(`static/icons/icons.svg#state">
</svg>
<div class="dropdown">
<button
@@ -97,7 +97,7 @@ func StreamControls(qw422016 *qt422016.Writer, prefix, currentIcon, currentText
<ul class="dropdown-menu">
`)
//line app/vmalert/web.qtpl:49
for key, title := range filters {
for key, title := range states {
//line app/vmalert/web.qtpl:49
qw422016.N().S(`
`)
@@ -106,7 +106,7 @@ func StreamControls(qw422016 *qt422016.Writer, prefix, currentIcon, currentText
//line app/vmalert/web.qtpl:50
qw422016.N().S(`
<li>
<a class="dropdown-item" onclick="groupFilter('`)
<a class="dropdown-item" onclick="groupForState('`)
//line app/vmalert/web.qtpl:52
qw422016.E().S(key)
//line app/vmalert/web.qtpl:52
@@ -176,22 +176,22 @@ func StreamControls(qw422016 *qt422016.Writer, prefix, currentIcon, currentText
}
//line app/vmalert/web.qtpl:77
func WriteControls(qq422016 qtio422016.Writer, prefix, currentIcon, currentText string, icons, filters map[string]string, search bool) {
func WriteControls(qq422016 qtio422016.Writer, prefix, currentIcon, currentText string, icons, states map[string]string, search bool) {
//line app/vmalert/web.qtpl:77
qw422016 := qt422016.AcquireWriter(qq422016)
//line app/vmalert/web.qtpl:77
StreamControls(qw422016, prefix, currentIcon, currentText, icons, filters, search)
StreamControls(qw422016, prefix, currentIcon, currentText, icons, states, search)
//line app/vmalert/web.qtpl:77
qt422016.ReleaseWriter(qw422016)
//line app/vmalert/web.qtpl:77
}
//line app/vmalert/web.qtpl:77
func Controls(prefix, currentIcon, currentText string, icons, filters map[string]string, search bool) string {
func Controls(prefix, currentIcon, currentText string, icons, states map[string]string, search bool) string {
//line app/vmalert/web.qtpl:77
qb422016 := qt422016.AcquireByteBuffer()
//line app/vmalert/web.qtpl:77
WriteControls(qb422016, prefix, currentIcon, currentText, icons, filters, search)
WriteControls(qb422016, prefix, currentIcon, currentText, icons, states, search)
//line app/vmalert/web.qtpl:77
qs422016 := string(qb422016.B)
//line app/vmalert/web.qtpl:77
@@ -324,13 +324,13 @@ func Welcome(r *http.Request) string {
}
//line app/vmalert/web.qtpl:100
func StreamListGroups(qw422016 *qt422016.Writer, r *http.Request, groups []*rule.ApiGroup, filter string) {
func StreamListGroups(qw422016 *qt422016.Writer, r *http.Request, groups []*rule.ApiGroup, state string) {
//line app/vmalert/web.qtpl:100
qw422016.N().S(`
`)
//line app/vmalert/web.qtpl:102
prefix := vmalertutil.Prefix(r.URL.Path)
filters := map[string]string{
states := map[string]string{
"": "All",
"unhealthy": "Unhealthy",
"nomatch": "No Match",
@@ -340,8 +340,8 @@ func StreamListGroups(qw422016 *qt422016.Writer, r *http.Request, groups []*rule
"unhealthy": "unhealthy",
"nomatch": "nomatch",
}
currentText := filters[filter]
currentIcon := icons[filter]
currentText := states[state]
currentIcon := icons[state]
//line app/vmalert/web.qtpl:115
qw422016.N().S(`
@@ -352,7 +352,7 @@ func StreamListGroups(qw422016 *qt422016.Writer, r *http.Request, groups []*rule
qw422016.N().S(`
`)
//line app/vmalert/web.qtpl:117
StreamControls(qw422016, prefix, currentIcon, currentText, icons, filters, true)
StreamControls(qw422016, prefix, currentIcon, currentText, icons, states, true)
//line app/vmalert/web.qtpl:117
qw422016.N().S(`
`)
@@ -371,7 +371,7 @@ func StreamListGroups(qw422016 *qt422016.Writer, r *http.Request, groups []*rule
//line app/vmalert/web.qtpl:120
qw422016.N().S(`" class="w-100 border-0 flex-column vm-group`)
//line app/vmalert/web.qtpl:120
if g.Unhealthy > 0 {
if g.States["unhealthy"] > 0 {
//line app/vmalert/web.qtpl:120
qw422016.N().S(` alert-danger`)
//line app/vmalert/web.qtpl:120
@@ -418,11 +418,11 @@ func StreamListGroups(qw422016 *qt422016.Writer, r *http.Request, groups []*rule
<span class="d-flex gap-2">
`)
//line app/vmalert/web.qtpl:133
if g.Unhealthy > 0 {
if g.States["unhealthy"] > 0 {
//line app/vmalert/web.qtpl:133
qw422016.N().S(`<span class="badge bg-danger" title="Number of rules with status Error">`)
//line app/vmalert/web.qtpl:133
qw422016.N().D(g.Unhealthy)
qw422016.N().D(g.States["unhealthy"])
//line app/vmalert/web.qtpl:133
qw422016.N().S(`</span> `)
//line app/vmalert/web.qtpl:133
@@ -431,11 +431,11 @@ func StreamListGroups(qw422016 *qt422016.Writer, r *http.Request, groups []*rule
qw422016.N().S(`
`)
//line app/vmalert/web.qtpl:134
if g.NoMatch > 0 {
if g.States["nomatch"] > 0 {
//line app/vmalert/web.qtpl:134
qw422016.N().S(`<span class="badge bg-warning" title="Number of rules with status NoMatch">`)
//line app/vmalert/web.qtpl:134
qw422016.N().D(g.NoMatch)
qw422016.N().D(g.States["nomatch"])
//line app/vmalert/web.qtpl:134
qw422016.N().S(`</span> `)
//line app/vmalert/web.qtpl:134
@@ -444,7 +444,7 @@ func StreamListGroups(qw422016 *qt422016.Writer, r *http.Request, groups []*rule
qw422016.N().S(`
<span class="badge bg-success" title="Number of rules with status Ok">`)
//line app/vmalert/web.qtpl:135
qw422016.N().D(g.Healthy)
qw422016.N().D(g.States["ok"])
//line app/vmalert/web.qtpl:135
qw422016.N().S(`</span>
</span>
@@ -617,7 +617,7 @@ func StreamListGroups(qw422016 *qt422016.Writer, r *http.Request, groups []*rule
|
`)
//line app/vmalert/web.qtpl:192
streamseriesFetchedWarn(qw422016, prefix, r)
streamseriesFetchedWarn(qw422016, prefix, &r)
//line app/vmalert/web.qtpl:192
qw422016.N().S(`
<span><a target="_blank" href="`)
@@ -750,22 +750,22 @@ func StreamListGroups(qw422016 *qt422016.Writer, r *http.Request, groups []*rule
}
//line app/vmalert/web.qtpl:234
func WriteListGroups(qq422016 qtio422016.Writer, r *http.Request, groups []*rule.ApiGroup, filter string) {
func WriteListGroups(qq422016 qtio422016.Writer, r *http.Request, groups []*rule.ApiGroup, state string) {
//line app/vmalert/web.qtpl:234
qw422016 := qt422016.AcquireWriter(qq422016)
//line app/vmalert/web.qtpl:234
StreamListGroups(qw422016, r, groups, filter)
StreamListGroups(qw422016, r, groups, state)
//line app/vmalert/web.qtpl:234
qt422016.ReleaseWriter(qw422016)
//line app/vmalert/web.qtpl:234
}
//line app/vmalert/web.qtpl:234
func ListGroups(r *http.Request, groups []*rule.ApiGroup, filter string) string {
func ListGroups(r *http.Request, groups []*rule.ApiGroup, state string) string {
//line app/vmalert/web.qtpl:234
qb422016 := qt422016.AcquireByteBuffer()
//line app/vmalert/web.qtpl:234
WriteListGroups(qb422016, r, groups, filter)
WriteListGroups(qb422016, r, groups, state)
//line app/vmalert/web.qtpl:234
qs422016 := string(qb422016.B)
//line app/vmalert/web.qtpl:234
@@ -1462,7 +1462,7 @@ func Alert(r *http.Request, alert *rule.ApiAlert) string {
}
//line app/vmalert/web.qtpl:479
func StreamRuleDetails(qw422016 *qt422016.Writer, r *http.Request, rule rule.ApiRule) {
func StreamRule(qw422016 *qt422016.Writer, r *http.Request, rule rule.ApiRule) {
//line app/vmalert/web.qtpl:479
qw422016.N().S(`
`)
@@ -1859,22 +1859,22 @@ func StreamRuleDetails(qw422016 *qt422016.Writer, r *http.Request, rule rule.Api
}
//line app/vmalert/web.qtpl:642
func WriteRuleDetails(qq422016 qtio422016.Writer, r *http.Request, rule rule.ApiRule) {
func WriteRule(qq422016 qtio422016.Writer, r *http.Request, rule rule.ApiRule) {
//line app/vmalert/web.qtpl:642
qw422016 := qt422016.AcquireWriter(qq422016)
//line app/vmalert/web.qtpl:642
StreamRuleDetails(qw422016, r, rule)
StreamRule(qw422016, r, rule)
//line app/vmalert/web.qtpl:642
qt422016.ReleaseWriter(qw422016)
//line app/vmalert/web.qtpl:642
}
//line app/vmalert/web.qtpl:642
func RuleDetails(r *http.Request, rule rule.ApiRule) string {
func Rule(r *http.Request, rule rule.ApiRule) string {
//line app/vmalert/web.qtpl:642
qb422016 := qt422016.AcquireByteBuffer()
//line app/vmalert/web.qtpl:642
WriteRuleDetails(qb422016, r, rule)
WriteRule(qb422016, r, rule)
//line app/vmalert/web.qtpl:642
qs422016 := string(qb422016.B)
//line app/vmalert/web.qtpl:642
@@ -2015,12 +2015,12 @@ func badgeStabilizing() string {
}
//line app/vmalert/web.qtpl:664
func streamseriesFetchedWarn(qw422016 *qt422016.Writer, prefix string, r rule.ApiRule) {
func streamseriesFetchedWarn(qw422016 *qt422016.Writer, prefix string, r *rule.ApiRule) {
//line app/vmalert/web.qtpl:664
qw422016.N().S(`
`)
//line app/vmalert/web.qtpl:665
if isNoMatch(r) {
if r.IsNoMatch() {
//line app/vmalert/web.qtpl:665
qw422016.N().S(`
<svg
@@ -2045,7 +2045,7 @@ func streamseriesFetchedWarn(qw422016 *qt422016.Writer, prefix string, r rule.Ap
}
//line app/vmalert/web.qtpl:675
func writeseriesFetchedWarn(qq422016 qtio422016.Writer, prefix string, r rule.ApiRule) {
func writeseriesFetchedWarn(qq422016 qtio422016.Writer, prefix string, r *rule.ApiRule) {
//line app/vmalert/web.qtpl:675
qw422016 := qt422016.AcquireWriter(qq422016)
//line app/vmalert/web.qtpl:675
@@ -2056,7 +2056,7 @@ func writeseriesFetchedWarn(qq422016 qtio422016.Writer, prefix string, r rule.Ap
}
//line app/vmalert/web.qtpl:675
func seriesFetchedWarn(prefix string, r rule.ApiRule) string {
func seriesFetchedWarn(prefix string, r *rule.ApiRule) string {
//line app/vmalert/web.qtpl:675
qb422016 := qt422016.AcquireByteBuffer()
//line app/vmalert/web.qtpl:675
@@ -2069,8 +2069,3 @@ func seriesFetchedWarn(prefix string, r rule.ApiRule) string {
return qs422016
//line app/vmalert/web.qtpl:675
}
//line app/vmalert/web.qtpl:678
func isNoMatch(r rule.ApiRule) bool {
return r.LastSamples == 0 && r.LastSeriesFetched != nil && *r.LastSeriesFetched == 0
}

View File

@@ -210,7 +210,7 @@ func TestHandler(t *testing.T) {
}
})
t.Run("/api/v1/rules&filters", func(t *testing.T) {
t.Run("/api/v1/rules&states", func(t *testing.T) {
check := func(url string, statusCode, expGroups, expRules int) {
t.Helper()
lr := listGroupsResponse{}
@@ -252,9 +252,15 @@ func TestHandler(t *testing.T) {
check("/api/v1/rules?rule_group[]=group&file[]=foo", 200, 0, 0)
check("/api/v1/rules?rule_group[]=group&file[]=rules.yaml", 200, 3, 6)
check("/api/v1/rules?rule_group[]=group&file[]=rules.yaml&rule_name[]=foo", 200, 3, 0)
check("/api/v1/rules?rule_group[]=group&file[]=rules.yaml&rule_name[]=foo", 200, 0, 0)
check("/api/v1/rules?rule_group[]=group&file[]=rules.yaml&rule_name[]=alert", 200, 3, 3)
check("/api/v1/rules?rule_group[]=group&file[]=rules.yaml&rule_name[]=alert&rule_name[]=record", 200, 3, 6)
check("/api/v1/rules?group_limit=1", 200, 1, 2)
check("/api/v1/rules?group_limit=1&type=alert", 200, 1, 1)
check("/api/v1/rules?group_limit=1&type=record", 200, 1, 1)
check("/api/v1/rules?group_limit=2", 200, 2, 4)
check(fmt.Sprintf("/api/v1/rules?group_limit=1&page_num=%d", 1), 200, 1, 2)
})
t.Run("/api/v1/rules&exclude_alerts=true", func(t *testing.T) {
// check if response returns active alerts by default

View File

@@ -1,5 +1,5 @@
export const getGroupsUrl = (server: string): string => {
return `${server}/vmalert/api/v1/rules?datasource_type=prometheus`;
export const getGroupsUrl = (server: string, search: string, type: string, states: string[], maxGroups: number): string => {
return `${server}/vmalert/api/v1/rules?datasource_type=prometheus&search=${encodeURIComponent(search)}&type=${encodeURIComponent(type)}&state=${states.map(encodeURIComponent).join(",")}&group_limit=${maxGroups}&extended_states=true`;
};
export const getItemUrl = (

View File

@@ -1,7 +1,7 @@
import "./style.scss";
import { ReactNode } from "react";
export type BadgeColor = "firing" | "inactive" | "pending" | "no-match" | "unhealthy" | "ok" | "passive";
export type BadgeColor = "firing" | "inactive" | "pending" | "nomatch" | "unhealthy" | "ok" | "passive";
interface BadgeItem {
value?: number | string;

View File

@@ -4,7 +4,7 @@ $badge-colors: (
"firing": $color-error,
"inactive": $color-success,
"pending": $color-warning,
"no-match": $color-notice,
"nomatch": $color-notice,
"unhealthy": $color-broken,
"ok": $color-info,
"passive": $color-passive,

View File

@@ -1,7 +1,8 @@
import { useMemo } from "preact/compat";
import "./style.scss";
import { Group as APIGroup } from "../../../types";
import { formatDuration, formatEventTime } from "../helpers";
import ItemHeader from "../ItemHeader";
import { getStates, formatDuration, formatEventTime } from "../helpers";
import Badges, { BadgeColor } from "../Badges";
interface BaseGroupProps {
@@ -117,6 +118,21 @@ const BaseGroup = ({ group }: BaseGroupProps) => {
)}
</tbody>
</table>
<div className="vm-explore-alerts-rule-item">
<span className="vm-alerts-title">Rules</span>
{group.rules.map((rule) => (
<ItemHeader
classes={["vm-badge-item", rule.state]}
key={rule.id}
entity="rule"
type={rule.type}
groupId={rule.group_id}
states={getStates(rule)}
id={rule.id}
name={rule.name}
/>
))}
</div>
</div>
);
};

View File

@@ -18,6 +18,7 @@ import {
import Button from "../../Main/Button/Button";
interface ItemHeaderControlsProps {
classes?: string[];
entity: string;
type?: string;
groupId: string;
@@ -27,12 +28,19 @@ interface ItemHeaderControlsProps {
onClose?: () => void;
}
const ItemHeader: FC<ItemHeaderControlsProps> = ({ name, id, groupId, entity, type, states, onClose }) => {
const ItemHeader: FC<ItemHeaderControlsProps> = ({ name, id, groupId, entity, type, states, onClose, classes }) => {
const { isMobile } = useDeviceDetect();
const { serverUrl } = useAppState();
const navigate = useNavigate();
const copyToClipboard = useCopyToClipboard();
const openGroupLink = () => {
navigate({
pathname: "/rules",
search: `group_id=${groupId}`,
});
};
const openItemLink = () => {
navigate({
pathname: "/rules",
@@ -49,7 +57,7 @@ const ItemHeader: FC<ItemHeaderControlsProps> = ({ name, id, groupId, entity, ty
const headerClasses = classNames({
"vm-explore-alerts-item-header": true,
"vm-explore-alerts-item-header_mobile": isMobile,
});
}, classes);
const renderIcon = () => {
switch(entity) {
@@ -105,16 +113,30 @@ const ItemHeader: FC<ItemHeaderControlsProps> = ({ name, id, groupId, entity, ty
items={badgesItems}
/>
{onClose ? (
<Button
className="vm-back-button"
size="small"
variant="outlined"
color="gray"
startIcon={<LinkIcon />}
onClick={copyLink}
>
<span className="vm-button-text">Copy Link</span>
</Button>
<>
{id && (
<Button
className="vm-back-button"
size="small"
variant="outlined"
color="gray"
startIcon={<GroupIcon />}
onClick={openGroupLink}
>
<span className="vm-button-text">Open Group</span>
</Button>
)}
<Button
className="vm-back-button"
size="small"
variant="outlined"
color="gray"
startIcon={<LinkIcon />}
onClick={copyLink}
>
<span className="vm-button-text">Copy Link</span>
</Button>
</>
) : (
<Button
className="vm-button-borderless"

View File

@@ -6,6 +6,10 @@
justify-content: space-between;
gap: $padding-global;
&:is(.vm-badge-item) {
padding: 6px 0 6px 6px;
}
.vm-button_small {
padding: 4px;
}

View File

@@ -0,0 +1,94 @@
import Button from "../../Main/Button/Button";
import { ArrowDownIcon } from "../../Main/Icons";
import "./style.scss";
import classNames from "classnames";
interface PaginationProps {
page: number;
totalPages: number;
totalRules: number;
totalGroups: number;
pageRules: number;
pageGroups: number;
onPageChange: (num: number) => () => void;
}
const getButtons = (page: number, totalPages: number) => {
const result: number[] = [];
if (totalPages < 2) return result;
result.push(1);
if (page > 3) result.push(0);
if (page > 2) result.push(page - 1);
if (page > 1 && page < totalPages) result.push(page);
if (page > 0 && page < totalPages - 1) result.push(page + 1);
if (totalPages - page > 2) result.push(0);
result.push(totalPages);
return result;
};
const Pagination = ({
page,
totalPages,
onPageChange,
totalGroups,
totalRules,
pageGroups,
pageRules,
}: PaginationProps) => {
const buttons = getButtons(page, totalPages);
return (
<>
<div
className="vm-pagination"
>
<span className="vm-pagination-stats">
<span>Page rules/groups:</span> <b>{pageRules}</b> / <b>{pageGroups}</b>
</span>
{!!buttons.length && (
<div className="vm-pagination-buttons">
<Button
className="vm-button-borderless vm-pagination-prev"
size="small"
color="gray"
disabled={page == 1}
variant="outlined"
startIcon={<ArrowDownIcon />}
onClick={onPageChange(page-1)}
/>
{buttons.map((button, index) => {
return button ? (
<Button
className={classNames({
"vm-button-borderless": page !== button,
})}
key={index}
size="small"
color="gray"
variant="outlined"
onClick={onPageChange(button)}
>{button}</Button>
) : (
<span className="vm-pagination-more">...</span>
);
})}
<Button
className="vm-button-borderless vm-pagination-next"
size="small"
color="gray"
disabled={page==totalPages}
variant="outlined"
startIcon={<ArrowDownIcon />}
onClick={onPageChange(page+1)}
/>
</div>
)}
<span className="vm-pagination-stats">
<span>Total rules/groups:</span> <b>{totalRules}</b> / <b>{totalGroups}</b>
</span>
</div>
</>
);
};
export default Pagination;

View File

@@ -0,0 +1,33 @@
@use "src/styles/variables" as *;
.vm-pagination {
display: flex;
min-height: 24px;
justify-content: space-between;
&-stats {
display: flex;
align-items: center;
color: var(--color-text-secondary);
column-gap: $padding-tiny;
}
&-buttons {
display: flex;
column-gap: $padding-small;
}
.vm-button-borderless {
border: 0;
}
&-more {
align-self: center;
}
&-prev {
svg {
transform: rotate(90deg);
}
}
&-next {
svg {
transform: rotate(-90deg);
}
}
}

View File

@@ -1,4 +1,4 @@
import { FC, useMemo } from "preact/compat";
import { useMemo } from "preact/compat";
import Select from "../../Main/Select/Select";
import { SearchIcon } from "../../Main/Icons";
import TextField from "../../Main/TextField/TextField";
@@ -8,25 +8,25 @@ import useDeviceDetect from "../../../hooks/useDeviceDetect";
interface RulesHeaderProps {
types: string[];
allTypes: string[];
allRuleTypes: string[];
allStates: string[];
states: string[];
search: string;
onChangeTypes: (input: string) => void;
onChangeRuleType: (input: string) => void;
onChangeStates: (input: string) => void;
onChangeSearch: (input: string) => void;
}
const RulesHeader: FC<RulesHeaderProps> = ({
const RulesHeader = ({
types,
allTypes,
allRuleTypes,
allStates,
states,
search,
onChangeTypes,
onChangeRuleType,
onChangeStates,
onChangeSearch,
}) => {
}: RulesHeaderProps) => {
const noStateText = useMemo(
() => (types.length ? "" : "No states. Please select rule states"),
[types],
@@ -46,10 +46,10 @@ const RulesHeader: FC<RulesHeaderProps> = ({
<div className="vm-explore-alerts-header__rule_type">
<Select
value={types}
list={allTypes}
label="Rules type"
list={allRuleTypes}
label="Rule type"
placeholder="Please select rule type"
onChange={onChangeTypes}
onChange={onChangeRuleType}
autofocus={!!types.length && !isMobile}
includeAll
searchable

View File

@@ -1,4 +1,5 @@
import dayjs from "dayjs";
import { Rule } from "../../types";
export const formatDuration = (raw: number) => {
const duration = dayjs.duration(Math.round(raw * 1000));
@@ -18,3 +19,13 @@ export const formatEventTime = (raw: string) => {
const t = dayjs(raw);
return t.year() <= 1 ? "Never" : t.format("DD MMM YYYY HH:mm:ss");
};
export const getStates = (rule: Rule) => {
if (!rule.alerts?.length) {
return { [rule.state]: 1 };
}
return rule.alerts.reduce((acc, alert) => {
acc[alert.state] = (acc[alert.state] ?? 0) + 1;
return acc;
}, {} as Record<string, number>);
};

View File

@@ -6,7 +6,7 @@ import { Rule as APIRule } from "../../types";
import ItemHeader from "../../components/ExploreAlerts/ItemHeader";
import BaseRule from "../../components/ExploreAlerts/BaseRule";
import Modal from "../../components/Main/Modal/Modal";
import { getStates } from "./helpers";
import { getStates } from "../../components/ExploreAlerts/helpers";
interface ExploreRuleProps {
groupId: string;

View File

@@ -7,30 +7,36 @@ import Accordion from "../../components/Main/Accordion/Accordion";
import { useFetchGroups } from "./hooks/useFetchGroups";
import "./style.scss";
import RulesHeader from "../../components/ExploreAlerts/RulesHeader";
import Pagination from "../../components/ExploreAlerts/Pagination";
import GroupHeader from "../../components/ExploreAlerts/GroupHeader";
import Rule from "../../components/ExploreAlerts/Rule";
import ExploreRule from "../../pages/ExploreAlerts/ExploreRule";
import ExploreAlert from "../../pages/ExploreAlerts/ExploreAlert";
import ExploreGroup from "../../pages/ExploreAlerts/ExploreGroup";
import { getQueryStringValue } from "../../utils/query-string";
import { getStates, getChanges, filterGroups } from "./helpers";
import { getChanges } from "./helpers";
import debounce from "lodash.debounce";
import { getStates } from "../../components/ExploreAlerts/helpers";
const defaultTypesStr = getQueryStringValue("types", "") as string;
const defaultTypes = defaultTypesStr.split("&").filter((rt) => rt) as string[];
const defaultRuleType = getQueryStringValue("type", "") as string;
const defaultStatesStr = getQueryStringValue("states", "") as string;
const defaultStates = defaultStatesStr.split("&").filter((s) => s) as string[];
const defaultSearchInput = getQueryStringValue("search", "") as string;
const TYPE_STATES: Record<string, string[]> = {
alert: ["inactive", "firing", "nomatch", "pending", "unhealthy"],
record: ["unhealthy", "nomatch", "ok"],
};
const ExploreRules: FC = () => {
const pageNum = getQueryStringValue("page_num", "1") as string;
const groupId = getQueryStringValue("group_id", "") as string;
const ruleId = getQueryStringValue("rule_id", "") as string;
const alertId = getQueryStringValue("alert_id", "") as string;
const [searchInput, setSearchInput] = useState(defaultSearchInput);
const [types, setTypes] = useState(defaultTypes);
const [ruleType, setRuleType] = useState(defaultRuleType);
const [states, setStates] = useState(defaultStates);
const [modalOpen, setModalOpen] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
useEffect(() => {
@@ -38,7 +44,7 @@ const ExploreRules: FC = () => {
}, [groupId]);
useSetQueryParams({
types: types.join("&"),
type: ruleType,
states: states.join("&"),
search: searchInput,
group_id: groupId,
@@ -47,12 +53,11 @@ const ExploreRules: FC = () => {
});
const handleChangeSearch = useCallback((input: string) => {
if (!input) {
setSearchInput("");
} else {
setSearchInput(input);
}
}, [searchInput]);
const newParams = new URLSearchParams(searchParams);
newParams.set("page_num", "1");
setSearchParams(newParams);
setSearchInput(input || "");
}, [searchInput, searchParams]);
const getModal = () => {
if (ruleId) {
@@ -94,55 +99,79 @@ const ExploreRules: FC = () => {
setModalOpen(false);
};
const onPageChange = (num: number) => {
return () => {
const newParams = new URLSearchParams(searchParams);
newParams.set("page_num", num.toString());
setSearchParams(newParams);
};
};
const allRuleTypes = Object.keys(TYPE_STATES);
const allStates = useMemo(
() => Array.from(ruleType === "" ? new Set(Object.values(TYPE_STATES).flat()) : TYPE_STATES[ruleType] || []),
[ruleType]
);
const selectedRuleTypes = [ruleType].filter(Boolean);
useEffect(() => {
if (!states.every(v => allStates.includes(v))) {
setStates([]);
}
}, [states, allStates]);
const pageNumInt: number = Math.max(1, parseInt(pageNum, 10) || 1);
const {
groups,
isLoading,
error,
} = useFetchGroups({ blockFetch: modalOpen });
const { filteredGroups, allTypes, allStates } = useMemo(
() => filterGroups(groups || [], types, states, searchInput),
[groups, types, states, searchInput]
);
if (!types.every(v => allTypes.has(v))) {
setTypes([]);
}
const selectedTypes = allTypes.size === types.length ? [] : types;
if (!states.every(v => allStates.has(v))) {
setStates([]);
}
const selectedStates = allStates.size === states.length ? [] : states;
pageInfo,
} = useFetchGroups({ blockFetch: modalOpen, search: searchInput, ruleType, states, pageNum: pageNumInt, onPageChange });
const handleChangeStates = useCallback((title: string) => {
setStates(getChanges(title, selectedStates));
}, [states]);
const newParams = new URLSearchParams(searchParams);
newParams.set("page_num", "1");
setSearchParams(newParams);
const changes = getChanges(title, states);
setStates(changes.length == allStates.length ? [] : changes);
}, [states, searchParams]);
const handleChangeTypes = useCallback((title: string) => {
setTypes(getChanges(title, selectedTypes));
}, [types]);
const handleChangeRuleType = useCallback((title: string) => {
const newParams = new URLSearchParams(searchParams);
newParams.set("page_num", "1");
setSearchParams(newParams);
const changes = getChanges(title, selectedRuleTypes);
setRuleType(changes.length && changes.length !== allRuleTypes.length ? changes[0] : "");
}, [ruleType, searchParams]);
return (
<>
{modalOpen && getModal()}
{(!modalOpen || !!allStates?.size) && (
{(!modalOpen || !!allStates?.length) && (
<div className="vm-explore-alerts">
<RulesHeader
types={selectedTypes}
allTypes={Array.from(allTypes)}
states={selectedStates}
allStates={Array.from(allStates)}
types={selectedRuleTypes}
allRuleTypes={allRuleTypes}
states={states}
allStates={allStates}
search={searchInput}
onChangeTypes={handleChangeTypes}
onChangeRuleType={handleChangeRuleType}
onChangeStates={handleChangeStates}
onChangeSearch={debounce(handleChangeSearch, 500)}
/>
<Pagination
page={pageInfo.page}
totalPages={pageInfo.total_pages}
pageRules={groups.reduce((total, g) => total + g?.rules.length, 0)}
pageGroups={groups.length}
totalRules={pageInfo.total_rules}
totalGroups={pageInfo.total_groups}
onPageChange={onPageChange}
/>
{(isLoading && <Spinner />) || (error && <Alert variant="error">{error}</Alert>) || (
!filteredGroups.length && <Alert variant="info">{noRuleFound}</Alert>
!groups.length && <Alert variant="info">{noRuleFound}</Alert>
) || (
<div className="vm-explore-alerts-body">
{filteredGroups.map((group) => (
{groups.map((group) => (
<div
key={group.id}
className="vm-explore-alert-group vm-block vm-block_empty-padding"

View File

@@ -1,5 +1,3 @@
import { Rule, Group } from "../../types";
export const getChanges = (title: string, prevValues: string[]): string[] => {
if (title === "All") return [];
@@ -12,77 +10,3 @@ export const getChanges = (title: string, prevValues: string[]): string[] => {
return Array.from(newValues);
};
export const getState = (rule: Rule) => {
let state = rule?.state || "ok";
if (rule?.health !== "ok") {
state = "unhealthy";
} else if (!rule?.lastSamples && !rule?.lastSeriesFetched) {
state = "no match";
}
return state;
};
export const getStates = (rule: Rule) => {
const output: Record<string, number> = {};
const alertsCount = rule?.alerts?.length || 0;
if (alertsCount > 0) {
rule.alerts.forEach((alert) => {
if (alert.state in output) {
output[alert.state] += 1;
} else {
output[alert.state] = 1;
}
});
} else {
output[getState(rule)] = 1;
}
return output;
};
export const filterGroups = (groups: Group[], types: string[], states: string[], searchInput: string) => {
const allTypes: Set<string> = new Set();
const allStates: Set<string> = new Set();
const filteredGroups: Group[] = [];
groups.forEach((group) => {
const filteredRules: Rule[] = [];
const statesPerGroup: Record<string, number> = {};
group.rules.forEach((rule) => {
const ruleType = rule.type.charAt(0).toUpperCase() + rule.type.slice(1);
allTypes.add(ruleType);
if (types?.length && !types.includes(ruleType)) return;
const state = getState(rule);
const stateName = state.charAt(0).toUpperCase() + state.slice(1);
allStates.add(stateName);
if (states?.length && !states.includes(stateName)) return;
if (
searchInput &&
!rule.name.toLowerCase().includes(searchInput.toLowerCase()) &&
!group.name.toLowerCase().includes(searchInput.toLowerCase()) &&
!group.file.toLowerCase().includes(searchInput.toLowerCase())
)
return;
filteredRules.push(rule);
if (state !== "no match" && state !== "unhealthy" && state !== "firing" && state !== "pending")
return;
const count = state === "firing" || state === "pending" ? rule?.alerts?.length : 1;
if (stateName in statesPerGroup) {
statesPerGroup[stateName] += count;
} else {
statesPerGroup[stateName] = count;
}
});
if (filteredRules.length) {
const g = Object.assign({}, group);
g.rules = filteredRules;
g.states = statesPerGroup;
filteredGroups.push(g);
}
});
return { filteredGroups, allTypes, allStates };
};

View File

@@ -1,46 +1,75 @@
import { useTimeState } from "../../../state/time/TimeStateContext";
import { useEffect, useMemo, useState } from "preact/compat";
import { useMemo, useEffect, useState } from "preact/compat";
import { getGroupsUrl } from "../../../api/explore-alerts";
import { useAppState } from "../../../state/common/StateContext";
import { ErrorTypes, Group } from "../../../types";
import { useTimeState } from "../../../state/time/TimeStateContext";
interface FetchGroupsReturn {
groups: Group[];
isLoading: boolean;
error?: ErrorTypes | string;
pageInfo: PageInfo;
}
interface FetchGroupsProps {
blockFetch: boolean
blockFetch: boolean;
search: string;
ruleType: string;
states: string[];
pageNum: number;
onPageChange: (num: number) => () => void;
}
export const useFetchGroups = ({ blockFetch }: FetchGroupsProps): FetchGroupsReturn => {
interface PageInfo {
page: number;
total_pages: number;
total_groups: number;
total_rules: number;
}
const MAX_GROUPS = 100;
export const useFetchGroups = ({ blockFetch, pageNum, search, ruleType, states, onPageChange }: FetchGroupsProps): FetchGroupsReturn => {
const { serverUrl } = useAppState();
const { period } = useTimeState();
const [groups, setGroups] = useState<Group[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [pageInfo, setPageInfo] = useState<PageInfo>({
page: pageNum,
total_pages: 1,
total_groups: 0,
total_rules: 0,
});
const [error, setError] = useState<ErrorTypes | string>();
const fetchUrl = useMemo(
() => getGroupsUrl(serverUrl),
[serverUrl],
() => getGroupsUrl(serverUrl, search, ruleType, states, MAX_GROUPS),
[serverUrl, search, ruleType, states],
);
const loaded = !!groups.length || !blockFetch;
useEffect(() => {
if (blockFetch) return;
const fetchData = async () => {
setIsLoading(true);
try {
const response = await fetch(fetchUrl);
const url = `${fetchUrl}&page_num=${pageNum}`;
const response = await fetch(url);
const resp = await response.json();
if (response.ok) {
const data = (resp.data.groups || []) as Group[];
setGroups(data.sort((a, b) => a.name.localeCompare(b.name)));
const loadedGroups = (resp.data.groups || []) as Group[];
setGroups(loadedGroups);
setPageInfo({
page: resp.page || 1,
total_pages: resp.total_pages || 1,
total_groups: resp.total_groups || 0,
total_rules: resp.total_rules || 0,
});
setError(undefined);
} else if (response.status === 400 && resp?.error?.includes("exceeds total amount of pages")) {
onPageChange(1)();
setError(`${resp.errorType}\r\n${resp?.error}`);
} else {
setError(`${resp.errorType}\r\n${resp?.error}`);
}
@@ -51,9 +80,8 @@ export const useFetchGroups = ({ blockFetch }: FetchGroupsProps): FetchGroupsRet
}
setIsLoading(false);
};
fetchData().catch(console.error);
}, [fetchUrl, period, loaded]);
}, [fetchUrl, period, loaded, pageNum]);
return { groups, isLoading, error };
return { groups, isLoading, error, pageInfo };
};

View File

@@ -3,7 +3,7 @@ import { compactObject } from "../../../utils/object";
import useSearchParamsFromObject from "../../../hooks/useSearchParamsFromObject";
interface rulesQueryProps {
types?: string;
type?: string;
states?: string;
search?: string;
rule_id: string;
@@ -12,7 +12,7 @@ interface rulesQueryProps {
}
export const useRulesSetQueryParams = ({
types,
type,
states,
search,
rule_id,
@@ -23,7 +23,7 @@ export const useRulesSetQueryParams = ({
const setSearchParamsFromState = () => {
const params = compactObject({
types,
type,
states,
search,
alert_id,
@@ -35,7 +35,7 @@ export const useRulesSetQueryParams = ({
};
useEffect(setSearchParamsFromState, [
types,
type,
states,
search,
rule_id,

View File

@@ -17,6 +17,19 @@
}
}
.vm-explore-alerts-load {
text-align: center;
color: var(--color-text-disabled);
button {
border: none;
}
&-before {
svg {
transform: rotate(180deg);
}
}
}
.vm-list-item-inner {
display: flex;
align-items: center;

View File

@@ -230,6 +230,7 @@ export interface Rule {
debug: boolean;
updates: RuleUpdate[];
max_updates_entries: number;
states: Record<string, number>;
}
interface RuleUpdate {

View File

@@ -28,6 +28,8 @@ See also [LTS releases](https://docs.victoriametrics.com/victoriametrics/lts-rel
* FEATURE: [vmui](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#vmui): show `seriesCountByMetricName` table when a label is in focus in the [Cardinality Explorer](https://docs.victoriametrics.com/victoriametrics/#cardinality-explorer). See [#10630](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10630). Thanks to @Roshan1299 for the contribution.
* FEATURE: [dashboards/unused-metrics](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/master/dashboards/unused-metrics.json): add a new dashboard for exploring stored metrics based on [Caridnality Explorer](https://docs.victoriametrics.com/victoriametrics/#cardinality-explorer) and [ingested metrics usage API](https://docs.victoriametrics.com/victoriametrics/#track-ingested-metrics-usage). The dashboard requires [Infinity Grafana plugin](https://grafana.com/grafana/plugins/yesoreyeram-infinity-datasource/) to be installed. See [#10617](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/10617) for details.
* FEATURE: [vmalert](https://docs.victoriametrics.com/victoriametrics/vmalert/): add `search` parameter and pagination support in `/api/v1/rules` API. See [#10046](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/10046).
* FEATURE: [vmui](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#vmui): add default pagination to improve the Alerting Rules page experience when vmalert loads thousands of rules. See [#10046](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/10046).
* BUGFIX: `vmselect` in [VictoriaMetrics cluster](https://docs.victoriametrics.com/victoriametrics/cluster-victoriametrics/): retry RPC by dialing a new connection instead of reusing a pooled one when the previous attempt fails with `io.EOF`, `broken pipe` or `reset by peer`. This reduces query failures caused by stale connections to restarted vmstorage nodes. See [#10314](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10314)
* BUGFIX: [vmui](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#vmui): fix autocomplete dropdown not closing on the Raw Query page. See [#10665](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/10665)

View File

@@ -750,7 +750,7 @@ or time series modification via [relabeling](https://docs.victoriametrics.com/vi
`vmalert` runs a web-server (`-httpListenAddr`) for serving metrics and alerts endpoints:
* `http://<vmalert-addr>` - UI;
* `http://<vmalert-addr>/api/v1/rules` - list of all loaded groups and rules. Supports additional [filtering](https://prometheus.io/docs/prometheus/latest/querying/api/#rules);
* `http://<vmalert-addr>/api/v1/rules` - list of all loaded groups and rules. Supports `search`, `group_limit`, and `page_num` parameters, as well as additional [filtering](https://prometheus.io/docs/prometheus/latest/querying/api/#rules);
* `http://<vmalert-addr>/api/v1/alerts` - list of all active alerts;
* `http://<vmalert-addr>/api/v1/notifiers` - list all available notifiers;
* `http://<vmalert-addr>/vmalert/api/v1/alert?group_id=<group_id>&alert_id=<alert_id>` - get alert status in JSON format.