From c0295d06a3ddfdc54567ab027e57e672d4f1e84e Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Tue, 16 Dec 2025 14:13:50 +0100 Subject: [PATCH] Alerting: Add rule_matcher filter to Prometheus rules API (#115297) **What is this feature?** Add `rule_matcher` filter to the Prometheus-compatible list rules API: `/api/prometheus/grafana/api/v1/rules`. It allows to filter rules by static labels (not by alert instance labels). **Special notes:** - Equality (`=`) and inequality (`!=`) matchers are pushed down to the database. Regex matchers (`=~`, `!~`) are applied in-memory at the API layer. - SQLite: Uses GLOB pattern matching - MySQL / PostgreSQL: Use JSON functions to compare label values --------- Co-authored-by: Konrad Lalik --- .../ngalert/api/api_prometheus_test.go | 183 +++++++++++++++++ .../ngalert/api/prometheus/api_prometheus.go | 143 ++++++++++---- .../ngalert/api/tooling/definitions/prom.go | 6 + pkg/services/ngalert/api/tooling/post.json | 9 + pkg/services/ngalert/api/tooling/spec.json | 9 + pkg/services/ngalert/models/alert_rule.go | 5 + pkg/services/ngalert/store/alert_rule.go | 39 ++++ .../ngalert/store/alert_rule_labels.go | 51 +++++ .../ngalert/store/alert_rule_labels_test.go | 136 +++++++++++++ pkg/services/ngalert/store/alert_rule_test.go | 152 ++++++++++++++ pkg/services/ngalert/store/json.go | 101 ++++++++++ pkg/services/ngalert/store/json_test.go | 185 ++++++++++++++++++ pkg/services/ngalert/tests/fakes/rules.go | 15 ++ pkg/tests/api/alerting/api_prometheus_test.go | 69 ++++++- .../alerting/unified/api/prometheusApi.ts | 3 + .../rule-list/hooks/grafanaFilter.test.ts | 67 ++++--- .../unified/rule-list/hooks/grafanaFilter.ts | 30 ++- .../rule-list/paginationLimits.test.ts | 5 +- 18 files changed, 1137 insertions(+), 71 deletions(-) create mode 100644 pkg/services/ngalert/store/alert_rule_labels.go create mode 100644 pkg/services/ngalert/store/alert_rule_labels_test.go create mode 100644 pkg/services/ngalert/store/json.go create mode 100644 pkg/services/ngalert/store/json_test.go diff --git a/pkg/services/ngalert/api/api_prometheus_test.go b/pkg/services/ngalert/api/api_prometheus_test.go index 25acf56f7d3..71e25d4c963 100644 --- a/pkg/services/ngalert/api/api_prometheus_test.go +++ b/pkg/services/ngalert/api/api_prometheus_test.go @@ -2886,6 +2886,189 @@ func TestRouteGetRuleStatuses(t *testing.T) { }) } }) + + t.Run("with rule_matcher filter", func(t *testing.T) { + fakeStore, fakeAIM, api := setupAPI(t) + + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule1"), gen.WithLabels(map[string]string{"team": "alerting", "severity": "critical"}), gen.WithNoNotificationSettings()) + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule2"), gen.WithLabels(map[string]string{"team": "Alerting", "severity": "warning"}), gen.WithNoNotificationSettings()) + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule3"), gen.WithLabels(map[string]string{"team": "platform", "severity": "critical"}), gen.WithNoNotificationSettings()) + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule4"), gen.WithLabels(map[string]string{"env": "production"}), gen.WithNoNotificationSettings()) + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule_special"), gen.WithLabels(map[string]string{"key": `value"with"quotes`}), gen.WithNoNotificationSettings()) + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule_empty"), gen.WithLabels(map[string]string{"empty": ""}), gen.WithNoNotificationSettings()) + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule_nonempty"), gen.WithLabels(map[string]string{"empty": "nonempty"}), gen.WithNoNotificationSettings()) + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule_multiline"), gen.WithLabels(map[string]string{"description": "line1\nline2\\end\"quote"}), gen.WithNoNotificationSettings()) + + testCases := []struct { + name string + matchers []string + expectedUIDs []string + }{ + { + name: "equality matcher filters by team=alerting", + matchers: []string{`{"name":"team","value":"alerting","isRegex":false,"isEqual":true}`}, + expectedUIDs: []string{"rule1"}, + }, + { + name: "inequality matcher filters severity!=warning", + matchers: []string{`{"name":"severity","value":"warning","isRegex":false,"isEqual":false}`}, + expectedUIDs: []string{"rule1", "rule3", "rule4", "rule_special", "rule_empty", "rule_nonempty", "rule_multiline"}, + }, + { + name: "regex matcher filters team=~plat.*", + matchers: []string{`{"name":"team","value":"plat.*","isRegex":true,"isEqual":true}`}, + expectedUIDs: []string{"rule3"}, + }, + { + name: "not-regex matcher filters severity!~warn.*", + matchers: []string{`{"name":"severity","value":"warn.*","isRegex":true,"isEqual":false}`}, + expectedUIDs: []string{"rule1", "rule3", "rule4", "rule_special", "rule_empty", "rule_nonempty", "rule_multiline"}, + }, + { + name: "multiple matchers are ANDed", + matchers: []string{ + `{"name":"team","value":"alerting","isRegex":false,"isEqual":true}`, + `{"name":"severity","value":"critical","isRegex":false,"isEqual":true}`, + }, + expectedUIDs: []string{"rule1"}, + }, + { + name: "matcher with non-existent label returns no rules", + matchers: []string{`{"name":"nonexistent","value":"value","isRegex":false,"isEqual":true}`}, + expectedUIDs: []string{}, + }, + { + name: "equality matcher is case-sensitive", + matchers: []string{`{"name":"team","value":"Alerting","isRegex":false,"isEqual":true}`}, + expectedUIDs: []string{"rule2"}, + }, + { + name: "quotes in label value are handled correctly", + matchers: []string{`{"name":"key","value":"value\"with\"quotes","isRegex":false,"isEqual":true}`}, + expectedUIDs: []string{"rule_special"}, + }, + { + name: "no matchers returns all rules", + matchers: []string{}, + expectedUIDs: []string{"rule1", "rule2", "rule3", "rule4", "rule_special", "rule_empty", "rule_nonempty", "rule_multiline"}, + }, + { + name: "empty string value matches correctly", + matchers: []string{`{"name":"empty","value":"","isRegex":false,"isEqual":true}`}, + expectedUIDs: []string{"rule1", "rule2", "rule3", "rule4", "rule_special", "rule_empty", "rule_multiline"}, + }, + { + name: "special characters in label value are handled correctly", + matchers: []string{`{"name":"description","value":"line1\nline2\\end\"quote","isRegex":false,"isEqual":true}`}, + expectedUIDs: []string{"rule_multiline"}, + }, + { + name: "inequality matcher on non-existent label matches all rules", + matchers: []string{`{"name":"nonexistent","value":"value","isRegex":false,"isEqual":false}`}, + expectedUIDs: []string{"rule1", "rule2", "rule3", "rule4", "rule_special", "rule_empty", "rule_nonempty", "rule_multiline"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + reqURL := "/api/v1/rules" + for i, matcher := range tc.matchers { + if i == 0 { + reqURL += "?rule_matcher=" + url.QueryEscape(matcher) + } else { + reqURL += "&rule_matcher=" + url.QueryEscape(matcher) + } + } + + req, err := http.NewRequest("GET", reqURL, nil) + require.NoError(t, err) + ctx := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{OrgID: orgID, Permissions: queryPermissions}, + } + + resp := api.RouteGetRuleStatuses(ctx) + require.Equal(t, http.StatusOK, resp.Status()) + + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + require.Equal(t, "success", res.Status) + + actualUIDs := []string{} + for _, group := range res.Data.RuleGroups { + for _, rule := range group.Rules { + actualUIDs = append(actualUIDs, rule.UID) + } + } + + require.ElementsMatch(t, tc.expectedUIDs, actualUIDs) + }) + } + }) + + t.Run("pagination with rule_matcher in-memory filtering", func(t *testing.T) { + fakeStore, fakeAIM, api := setupAPI(t) + + // Create 3 groups with 2 rules each: + // Group 1 & 2: team=backend (won't match filter) + // Group 3: team=frontend (will match filter) + // This tests that pagination continues fetching when early pages are filtered out + + group1Key := ngmodels.AlertRuleGroupKey{OrgID: orgID, NamespaceUID: "namespace1", RuleGroup: "group1"} + group2Key := ngmodels.AlertRuleGroupKey{OrgID: orgID, NamespaceUID: "namespace2", RuleGroup: "group2"} + group3Key := ngmodels.AlertRuleGroupKey{OrgID: orgID, NamespaceUID: "namespace3", RuleGroup: "group3"} + + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule1"), gen.WithLabels(map[string]string{"team": "security"}), gen.WithGroupKey(group1Key), gen.WithNoNotificationSettings()) + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule2"), gen.WithLabels(map[string]string{"team": "security"}), gen.WithGroupKey(group1Key), gen.WithNoNotificationSettings()) + + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule3"), gen.WithLabels(map[string]string{"team": "security"}), gen.WithGroupKey(group2Key), gen.WithNoNotificationSettings()) + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule4"), gen.WithLabels(map[string]string{"team": "security"}), gen.WithGroupKey(group2Key), gen.WithNoNotificationSettings()) + + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule5"), gen.WithLabels(map[string]string{"team": "alerting"}), gen.WithGroupKey(group3Key), gen.WithNoNotificationSettings()) + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule6"), gen.WithLabels(map[string]string{"team": "alerting"}), gen.WithGroupKey(group3Key), gen.WithNoNotificationSettings()) + + // Request with regex rule_matcher filter for team=~"alerting" and group_limit=1 to force pagination + matcher := `{"name":"team","value":"alerting","isRegex":true,"isEqual":true}` + reqURL := "/api/v1/rules?rule_matcher=" + url.QueryEscape(matcher) + "&group_limit=1" + + req, err := http.NewRequest("GET", reqURL, nil) + require.NoError(t, err) + ctx := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{OrgID: orgID, Permissions: queryPermissions}, + } + + resp := api.RouteGetRuleStatuses(ctx) + require.Equal(t, http.StatusOK, resp.Status()) + + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + require.Equal(t, "success", res.Status) + + actualUIDs := []string{} + for _, group := range res.Data.RuleGroups { + for _, rule := range group.Rules { + actualUIDs = append(actualUIDs, rule.UID) + } + } + + // Should return group3 rules (rule5, rule6), pagination should continue past filtered groups + require.ElementsMatch(t, []string{"rule5", "rule6"}, actualUIDs) + }) } func setupAPI(t *testing.T) (*fakes.RuleStore, *fakeAlertInstanceManager, PrometheusSrv) { diff --git a/pkg/services/ngalert/api/prometheus/api_prometheus.go b/pkg/services/ngalert/api/prometheus/api_prometheus.go index ddbd1d2af4c..934805d74f4 100644 --- a/pkg/services/ngalert/api/prometheus/api_prometheus.go +++ b/pkg/services/ngalert/api/prometheus/api_prometheus.go @@ -33,6 +33,12 @@ import ( "go.opentelemetry.io/otel/trace" ) +const ( + queryIncludeInternalLabels = "includeInternalLabels" + queryRuleMatcher = "rule_matcher" + queryInstanceMatcher = "matcher" +) + type RuleStoreReader interface { GetUserVisibleNamespaces(context.Context, int64, identity.Requester) (map[string]*folder.Folder, error) ListAlertRulesStoreV2 @@ -62,6 +68,20 @@ type PrometheusSrv struct { // Package-level OpenTelemetry tracer per Grafana instrumentation conventions. var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/ngalert/api/prometheus") +// badRequestError returns a Prometheus-compatible error response for bad request data. +func badRequestError(err error) apimodels.RuleResponse { + return apimodels.RuleResponse{ + DiscoveryBase: apimodels.DiscoveryBase{ + Status: "error", + Error: err.Error(), + ErrorType: apiv1.ErrBadData, + }, + Data: apimodels.RuleDiscovery{ + RuleGroups: []apimodels.RuleGroup{}, + }, + } +} + func NewPrometheusSrv(log log.Logger, manager state.AlertInstanceManager, status StatusReader, store RuleStoreReader, authz RuleGroupAccessControlService, provenanceStore ProvenanceStore) *PrometheusSrv { return &PrometheusSrv{ log, @@ -73,8 +93,6 @@ func NewPrometheusSrv(log log.Logger, manager state.AlertInstanceManager, status } } -const queryIncludeInternalLabels = "includeInternalLabels" - func getBoolWithDefault(vals url.Values, field string, d bool) bool { f := vals.Get(field) if f == "" { @@ -188,15 +206,15 @@ func getPanelIDFromQuery(v url.Values) (int64, error) { return 0, nil } -func getMatchersFromQuery(v url.Values) (labels.Matchers, error) { +func getMatchersFromQuery(v url.Values, paramName string) (labels.Matchers, error) { var matchers labels.Matchers - for _, s := range v["matcher"] { + for _, s := range v[paramName] { var m labels.Matcher if err := json.Unmarshal([]byte(s), &m); err != nil { return nil, err } if len(m.Name) == 0 { - return nil, errors.New("bad matcher: the name cannot be blank") + return nil, fmt.Errorf("bad %s: the name cannot be blank", paramName) } matchers = append(matchers, &m) } @@ -454,6 +472,7 @@ type paginationContext struct { stateFilterSet map[eval.State]struct{} healthFilterSet map[string]struct{} matchers labels.Matchers + ruleLabelMatchers labels.Matchers labelOptions []ngmodels.LabelOption limitAlertsPerRule int64 limitRulesPerGroup int64 @@ -476,6 +495,9 @@ func accumulateTotals(dest, source map[string]int64) { // fetchAndFilterPage fetches one page from the store and applies filters func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlertRulesStoreV2, span trace.Span, token string, remainingGroups, remainingRules int64) (pageResult, error) { + // Split matchers: only equality/inequality are supported by the store + storeMatchers := filterOutRegexMatchers(ctx.ruleLabelMatchers) + byGroupQuery := ngmodels.ListAlertRulesExtendedQuery{ ListAlertRulesQuery: ngmodels.ListAlertRulesQuery{ OrgID: ctx.opts.OrgID, @@ -488,6 +510,7 @@ func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlert DataSourceUIDs: ctx.dataSourceUIDs, SearchTitle: ctx.title, SearchRuleGroup: ctx.searchRuleGroup, + LabelMatchers: storeMatchers, }, RuleType: ctx.ruleType, Limit: remainingGroups, @@ -534,6 +557,8 @@ func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlert filterRulesByHealth(ruleGroup, ctx.healthFilterSet) } + filterRulesByLabelMatchers(ruleGroup, ctx.ruleLabelMatchers) + if ctx.limitRulesPerGroup > -1 && int64(len(ruleGroup.Rules)) > ctx.limitRulesPerGroup { ruleGroup.Rules = ruleGroup.Rules[0:ctx.limitRulesPerGroup] } @@ -546,6 +571,17 @@ func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlert return result, nil } +func filterOutRegexMatchers(matchers labels.Matchers) labels.Matchers { + var result labels.Matchers + for _, m := range matchers { + if m.Type == labels.MatchEqual || m.Type == labels.MatchNotEqual { + result = append(result, m) + } + } + + return result +} + // paginateRuleGroups fetches pages until limits are satisfied applying filters at each step func paginateRuleGroups(log log.Logger, store ListAlertRulesStoreV2, ctx *paginationContext, span trace.Span, maxGroups, maxRules int64, startToken string) ([]apimodels.RuleGroup, map[string]int64, string, error) { allGroups := []apimodels.RuleGroup{} @@ -644,21 +680,30 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt attribute.Int64("limit_rules", limitRulesPerGroup), attribute.Int64("limit_alerts", limitAlertsPerRule), ) - matchers, err := getMatchersFromQuery(opts.Query) + matchers, err := getMatchersFromQuery(opts.Query, queryInstanceMatcher) if err != nil { - ruleResponse.Status = "error" - ruleResponse.Error = err.Error() - ruleResponse.ErrorType = apiv1.ErrBadData - return ruleResponse + return badRequestError(err) } span.SetAttributes(attribute.Int("matcher_count", len(matchers))) + ruleLabelMatchers, err := getMatchersFromQuery(opts.Query, queryRuleMatcher) + if err != nil { + return badRequestError(err) + } + regexCount := 0 + for _, m := range ruleLabelMatchers { + if m.Type == labels.MatchRegexp || m.Type == labels.MatchNotRegexp { + regexCount++ + } + } + span.SetAttributes( + attribute.Int("rule_matcher_count", len(ruleLabelMatchers)), + attribute.Int("rule_matcher_regex_count", regexCount), + ) + stateFilterSet, err := GetStatesFromQuery(opts.Query) if err != nil { - ruleResponse.Status = "error" - ruleResponse.Error = err.Error() - ruleResponse.ErrorType = apiv1.ErrBadData - return ruleResponse + return badRequestError(err) } span.SetAttributes( attribute.Int("state_filter_count", len(stateFilterSet)), @@ -667,10 +712,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt healthFilterSet, err := GetHealthFromQuery(opts.Query) if err != nil { - ruleResponse.Status = "error" - ruleResponse.Error = err.Error() - ruleResponse.ErrorType = apiv1.ErrBadData - return ruleResponse + return badRequestError(err) } span.SetAttributes( attribute.Int("health_filter_count", len(healthFilterSet)), @@ -808,6 +850,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt stateFilterSet: stateFilterSet, healthFilterSet: healthFilterSet, matchers: matchers, + ruleLabelMatchers: ruleLabelMatchers, labelOptions: labelOptions, limitAlertsPerRule: limitAlertsPerRule, limitRulesPerGroup: limitRulesPerGroup, @@ -833,6 +876,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt return ruleResponse } +// nolint:gocyclo func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts RuleGroupStatusesOptions, ruleStatusMutator RuleStatusMutator, alertStateMutator RuleAlertStateMutator, provenanceRecords map[string]ngmodels.Provenance) apimodels.RuleResponse { ruleResponse := apimodels.RuleResponse{ DiscoveryBase: apimodels.DiscoveryBase{ @@ -846,41 +890,30 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru dashboardUID := opts.Query.Get("dashboard_uid") panelID, err := getPanelIDFromQuery(opts.Query) if err != nil { - ruleResponse.Status = "error" - ruleResponse.Error = fmt.Sprintf("invalid panel_id: %s", err.Error()) - ruleResponse.ErrorType = apiv1.ErrBadData - return ruleResponse + return badRequestError(fmt.Errorf("invalid panel_id: %w", err)) } if dashboardUID == "" && panelID != 0 { - ruleResponse.Status = "error" - ruleResponse.Error = "panel_id must be set with dashboard_uid" - ruleResponse.ErrorType = apiv1.ErrBadData - return ruleResponse + return badRequestError(errors.New("panel_id must be set with dashboard_uid")) } limitRulesPerGroup := getInt64WithDefault(opts.Query, "limit_rules", -1) limitAlertsPerRule := getInt64WithDefault(opts.Query, "limit_alerts", -1) - matchers, err := getMatchersFromQuery(opts.Query) + matchers, err := getMatchersFromQuery(opts.Query, queryInstanceMatcher) if err != nil { - ruleResponse.Status = "error" - ruleResponse.Error = err.Error() - ruleResponse.ErrorType = apiv1.ErrBadData - return ruleResponse + return badRequestError(err) + } + ruleLabelMatchers, err := getMatchersFromQuery(opts.Query, queryRuleMatcher) + if err != nil { + return badRequestError(err) } stateFilterSet, err := GetStatesFromQuery(opts.Query) if err != nil { - ruleResponse.Status = "error" - ruleResponse.Error = err.Error() - ruleResponse.ErrorType = apiv1.ErrBadData - return ruleResponse + return badRequestError(err) } healthFilterSet, err := GetHealthFromQuery(opts.Query) if err != nil { - ruleResponse.Status = "error" - ruleResponse.Error = err.Error() - ruleResponse.ErrorType = apiv1.ErrBadData - return ruleResponse + return badRequestError(err) } var labelOptions []ngmodels.LabelOption @@ -913,6 +946,9 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru dataSourceUIDs := opts.Query["datasource_uid"] searchRuleGroup := opts.Query.Get("search.rule_group") + // Split matchers: only equality/inequality are supported by the store + storeMatchers := filterOutRegexMatchers(ruleLabelMatchers) + alertRuleQuery := ngmodels.ListAlertRulesQuery{ OrgID: opts.OrgID, NamespaceUIDs: namespaceUIDs, @@ -924,6 +960,7 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru SearchTitle: title, SearchRuleGroup: searchRuleGroup, DataSourceUIDs: dataSourceUIDs, + LabelMatchers: storeMatchers, } ruleList, err := store.ListAlertRules(opts.Ctx, &alertRuleQuery) if err != nil { @@ -978,6 +1015,10 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru filterRulesByHealth(ruleGroup, healthFilterSet) } + if len(ruleLabelMatchers) > 0 { + filterRulesByLabelMatchers(ruleGroup, ruleLabelMatchers) + } + if limitRulesPerGroup > -1 && int64(len(ruleGroup.Rules)) > limitRulesPerGroup { ruleGroup.Rules = ruleGroup.Rules[0:limitRulesPerGroup] } @@ -1105,6 +1146,30 @@ func filterRulesByHealth(ruleGroup *apimodels.RuleGroup, withHealthFast map[stri ruleGroup.Rules = filteredRules } +func filterRulesByLabelMatchers(ruleGroup *apimodels.RuleGroup, matchers labels.Matchers) { + if len(matchers) == 0 { + return + } + + filteredRules := make([]apimodels.AlertingRule, 0, len(ruleGroup.Rules)) + + for _, rule := range ruleGroup.Rules { + ruleLabels := rule.Labels.Map() + matches := true + for _, m := range matchers { + if !m.Matches(ruleLabels[m.Name]) { + matches = false + break + } + } + if matches { + filteredRules = append(filteredRules, rule) + } + } + + ruleGroup.Rules = filteredRules +} + // This is the same as matchers.Matches but avoids the need to create a LabelSet func matchersMatch(matchers []*labels.Matcher, labels map[string]string) bool { for _, m := range matchers { diff --git a/pkg/services/ngalert/api/tooling/definitions/prom.go b/pkg/services/ngalert/api/tooling/definitions/prom.go index 128c69787c0..05fb62dc283 100644 --- a/pkg/services/ngalert/api/tooling/definitions/prom.go +++ b/pkg/services/ngalert/api/tooling/definitions/prom.go @@ -462,4 +462,10 @@ type GetGrafanaRuleStatusesParams struct { // in: query // required: false Matchers []string `json:"matcher"` + + // Filter rules by their static labels (not alert instance labels). Each value is a JSON-encoded Prometheus-like matcher (for example, {"type":0,"name":"severity","value":"critical"}). + // For equality matchers with empty string values (e.g., name=""), rules that have the label with an empty value OR rules without the label will match (standard Prometheus behavior). + // in: query + // required: false + RuleLabelMatchers []string `json:"rule_matcher"` } diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index d9d38ce03b0..1243007dfcf 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -7561,6 +7561,15 @@ }, "name": "matcher", "type": "array" + }, + { + "description": "Filter rules by their static labels (not alert instance labels). Each value is a JSON-encoded Prometheus-like matcher (for example, {\"type\":0,\"name\":\"severity\",\"value\":\"critical\"}).\nFor equality matchers with empty string values (e.g., name=\"\"), rules that have the label with an empty value OR rules without the label will match (standard Prometheus behavior).", + "in": "query", + "items": { + "type": "string" + }, + "name": "rule_matcher", + "type": "array" } ], "responses": { diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index adb65e92ad9..81e0aa894c9 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -1947,6 +1947,15 @@ "description": "Filter by label matchers encoded as JSON representations of Prometheus matchers (for example, {\"type\":0,\"name\":\"severity\",\"value\":\"critical\"}). Provide one matcher per query string value.", "name": "matcher", "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "description": "Filter rules by their static labels (not alert instance labels). Each value is a JSON-encoded Prometheus-like matcher (for example, {\"type\":0,\"name\":\"severity\",\"value\":\"critical\"}).\nFor equality matchers with empty string values (e.g., name=\"\"), rules that have the label with an empty value OR rules without the label will match (standard Prometheus behavior).", + "name": "rule_matcher", + "in": "query" } ], "responses": { diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index 14da686b32f..f7bb3d9fcfd 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -18,6 +18,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" + "github.com/prometheus/alertmanager/pkg/labels" prommodels "github.com/prometheus/common/model" "github.com/grafana/grafana-plugin-sdk-go/data" @@ -1012,6 +1013,10 @@ type ListAlertRulesQuery struct { SearchRuleGroup string HasPrometheusRuleDefinition *bool + + // LabelMatchers filters rules by their labels. + // Only equality and inequality matchers are supported, no regex operators. + LabelMatchers labels.Matchers } type ListAlertRulesExtendedQuery struct { diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 649af476741..4307778f9c6 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/google/uuid" + "github.com/prometheus/alertmanager/pkg/labels" "golang.org/x/exp/maps" "github.com/grafana/grafana/pkg/util/xorm" @@ -802,6 +803,15 @@ func (st DBstore) ListAlertRulesPaginated(ctx context.Context, query *ngmodels.L return result, nextToken, err } +func matchersMatchLabels(matchers labels.Matchers, lbls map[string]string) bool { + for _, m := range matchers { + if !m.Matches(lbls[m.Name]) { + return false + } + } + return true +} + // nolint:gocyclo func (st DBstore) buildListAlertRulesQuery(sess *db.Session, query *ngmodels.ListAlertRulesExtendedQuery) (q *xorm.Session, groupsSet map[string]struct{}, err error) { q = sess.Table("alert_rule") @@ -920,6 +930,13 @@ func (st DBstore) buildListAlertRulesQuery(sess *db.Session, query *ngmodels.Lis } } + if len(query.LabelMatchers) > 0 { + q, err = st.filterByLabelMatchers(query.LabelMatchers, q) + if err != nil { + return nil, groupsSet, err + } + } + // FIXME: record is nullable but we don't save it as null when it's nil switch query.RuleType { case ngmodels.RuleTypeFilterAlerting: @@ -967,6 +984,11 @@ func (st DBstore) handleRuleRow(rows *xorm.Rows, query *ngmodels.ListAlertRulesE return nil, false } } + if len(query.LabelMatchers) > 0 { // remove false-positive hits from the result + if !matchersMatchLabels(query.LabelMatchers, converted.Labels) { + return nil, false + } + } // MySQL (and potentially other databases) can use case-insensitive comparison. // This code makes sure we return groups that only exactly match the filter. if groupsSet != nil { @@ -1355,6 +1377,23 @@ func (st DBstore) filterWithPrometheusRuleDefinition(value bool, sess *xorm.Sess ), nil } +// filterByLabelMatchers adds filtering for equality and inequality label matchers. +// Returns error if regex matchers are passed. +func (st DBstore) filterByLabelMatchers(matchers labels.Matchers, sess *xorm.Session) (*xorm.Session, error) { + for _, m := range matchers { + if m.Type != labels.MatchEqual && m.Type != labels.MatchNotEqual { + return nil, fmt.Errorf("matcher %q %s %q is not supported", m.Name, m.Type, m.Value) + } + + sql, args, err := buildLabelMatcherCondition(st.SQLStore.GetDialect(), "labels", m) + if err != nil { + return nil, err + } + sess = sess.And(sql, args...) + } + return sess, nil +} + func (st DBstore) RenameReceiverInNotificationSettings(ctx context.Context, orgID int64, oldReceiver, newReceiver string, validateProvenance func(ngmodels.Provenance) bool, dryRun bool) ([]ngmodels.AlertRuleKey, []ngmodels.AlertRuleKey, error) { // fetch entire rules because Update method requires it because it copies rules to version table rules, err := st.ListAlertRules(ctx, &ngmodels.ListAlertRulesQuery{ diff --git a/pkg/services/ngalert/store/alert_rule_labels.go b/pkg/services/ngalert/store/alert_rule_labels.go new file mode 100644 index 00000000000..721071d5219 --- /dev/null +++ b/pkg/services/ngalert/store/alert_rule_labels.go @@ -0,0 +1,51 @@ +package store + +import ( + "fmt" + + "github.com/prometheus/alertmanager/pkg/labels" + + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +) + +// buildLabelMatcherCondition builds SQL for a label matcher with Prometheus semantics. +// For MySQL/PostgreSQL, it uses JSON functions, and +// for SQLite, it uses GLOB patterns to find matching labels. +func buildLabelMatcherCondition(dialect migrator.Dialect, column string, m *labels.Matcher) (string, []any, error) { + if dialect.DriverName() == migrator.SQLite { + return buildLabelMatcherGlob(column, m) + } + return buildLabelMatcherJSON(dialect, column, m) +} + +func buildLabelMatcherGlob(column string, m *labels.Matcher) (string, []any, error) { + switch { + case m.Type == labels.MatchEqual && m.Value == "": + eqSQL, eqArgs, _ := globEquals(column, m.Name, "") + missingSQL, missingArgs, _ := globKeyMissing(column, m.Name) + return "(" + eqSQL + " OR " + missingSQL + ")", append(eqArgs, missingArgs...), nil + case m.Type == labels.MatchEqual: + return globEquals(column, m.Name, m.Value) + case m.Type == labels.MatchNotEqual: + return globNotEquals(column, m.Name, m.Value) + default: + return "", nil, fmt.Errorf("unsupported matcher type: %v", m.Type) + } +} + +func buildLabelMatcherJSON(dialect migrator.Dialect, column string, m *labels.Matcher) (string, []any, error) { + switch { + case m.Type == labels.MatchEqual && m.Value == "": + eqSQL, eqArgs := jsonEquals(dialect, column, m.Name, "") + missingSQL, missingArgs := jsonKeyMissing(dialect, column, m.Name) + return "(" + eqSQL + " OR " + missingSQL + ")", append(eqArgs, missingArgs...), nil + case m.Type == labels.MatchEqual: + sql, args := jsonEquals(dialect, column, m.Name, m.Value) + return sql, args, nil + case m.Type == labels.MatchNotEqual: + sql, args := jsonNotEquals(dialect, column, m.Name, m.Value) + return sql, args, nil + default: + return "", nil, fmt.Errorf("unsupported matcher type: %v", m.Type) + } +} diff --git a/pkg/services/ngalert/store/alert_rule_labels_test.go b/pkg/services/ngalert/store/alert_rule_labels_test.go new file mode 100644 index 00000000000..9b72d8f00f9 --- /dev/null +++ b/pkg/services/ngalert/store/alert_rule_labels_test.go @@ -0,0 +1,136 @@ +package store + +import ( + "testing" + + "github.com/prometheus/alertmanager/pkg/labels" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +) + +func TestBuildLabelMatcherGlob(t *testing.T) { + tests := []struct { + name string + matcher *labels.Matcher + wantSQL string + wantArgs []any + wantErr bool + errContains string + }{ + { + name: "MatchEqual with non-empty value", + matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: "alerting"}, + wantSQL: "labels GLOB ?", + wantArgs: []any{`*"team":"alerting"*`}, + }, + { + name: "MatchEqual with empty value (Prometheus semantics)", + matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: ""}, + wantSQL: `(labels GLOB ? OR labels NOT GLOB ?)`, + wantArgs: []any{`*"team":""*`, `*"team":*`}, + }, + { + name: "MatchNotEqual", + matcher: &labels.Matcher{Type: labels.MatchNotEqual, Name: "team", Value: "alerting"}, + wantSQL: "labels NOT GLOB ?", + wantArgs: []any{`*"team":"alerting"*`}, + }, + { + name: "unsupported matcher type", + matcher: &labels.Matcher{Type: labels.MatchRegexp, Name: "team", Value: "alert.*"}, + wantErr: true, + errContains: "unsupported matcher type", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sql, args, err := buildLabelMatcherGlob("labels", tt.matcher) + if tt.wantErr { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errContains) + return + } + require.NoError(t, err) + require.Equal(t, tt.wantSQL, sql) + require.Equal(t, tt.wantArgs, args) + }) + } +} + +func TestBuildLabelMatcherJSON(t *testing.T) { + tests := []struct { + name string + dialect migrator.Dialect + matcher *labels.Matcher + wantSQL string + wantArgs []any + wantErr bool + errContains string + }{ + { + name: "MySQL MatchEqual with non-empty value", + dialect: migrator.NewMysqlDialect(), + matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: "alerting"}, + wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) = ?", + wantArgs: []any{"team", "alerting"}, + }, + { + name: "MySQL MatchEqual with empty value", + dialect: migrator.NewMysqlDialect(), + matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: ""}, + wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) = ? OR JSON_EXTRACT(labels, CONCAT('$.', ?)) IS NULL)", + wantArgs: []any{"team", "", "team"}, + }, + { + name: "MySQL MatchNotEqual", + dialect: migrator.NewMysqlDialect(), + matcher: &labels.Matcher{Type: labels.MatchNotEqual, Name: "team", Value: "alerting"}, + wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) != ?)", + wantArgs: []any{"team", "team", "alerting"}, + }, + { + name: "PostgreSQL MatchEqual with non-empty value", + dialect: migrator.NewPostgresDialect(), + matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: "alerting"}, + wantSQL: "jsonb_extract_path_text(labels::jsonb, ?) = ?", + wantArgs: []any{"team", "alerting"}, + }, + { + name: "PostgreSQL MatchEqual with empty value", + dialect: migrator.NewPostgresDialect(), + matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: ""}, + wantSQL: "(jsonb_extract_path_text(labels::jsonb, ?) = ? OR jsonb_extract_path_text(labels::jsonb, ?) IS NULL)", + wantArgs: []any{"team", "", "team"}, + }, + { + name: "PostgreSQL MatchNotEqual", + dialect: migrator.NewPostgresDialect(), + matcher: &labels.Matcher{Type: labels.MatchNotEqual, Name: "team", Value: "alerting"}, + wantSQL: "(jsonb_extract_path_text(labels::jsonb, ?) IS NULL OR jsonb_extract_path_text(labels::jsonb, ?) != ?)", + wantArgs: []any{"team", "team", "alerting"}, + }, + { + name: "unsupported matcher type", + dialect: migrator.NewMysqlDialect(), + matcher: &labels.Matcher{Type: labels.MatchRegexp, Name: "team", Value: "alert.*"}, + wantErr: true, + errContains: "unsupported matcher type", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sql, args, err := buildLabelMatcherJSON(tt.dialect, "labels", tt.matcher) + if tt.wantErr { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errContains) + return + } + require.NoError(t, err) + require.Equal(t, tt.wantSQL, sql) + require.Equal(t, tt.wantArgs, args) + }) + } +} diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index 93f6f9e57a4..91db9edc32a 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -13,6 +13,7 @@ import ( "github.com/benbjohnson/clock" "github.com/google/uuid" + "github.com/prometheus/alertmanager/pkg/labels" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -2385,6 +2386,157 @@ func TestIntegration_ListAlertRules(t *testing.T) { }) } }) + + t.Run("filter by LabelMatchers", func(t *testing.T) { + sqlStore := db.InitTestDB(t) + folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) + store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, b) + + ruleLower := createRule(t, store, ruleGen.With( + ruleGen.WithLabels(map[string]string{"team": "alerting", "severity": "warning"}), + ruleGen.WithTitle("rule_lowercase"))) + ruleUpper := createRule(t, store, ruleGen.With( + ruleGen.WithLabels(map[string]string{"team": "Alerting", "severity": "critical"}), + ruleGen.WithTitle("rule_uppercase"))) + ruleSpecial := createRule(t, store, ruleGen.With( + ruleGen.WithLabels(map[string]string{"key": `value"with"quotes`}), + ruleGen.WithTitle("rule_special"))) + ruleGlob := createRule(t, store, ruleGen.With( + ruleGen.WithLabels(map[string]string{"glob": "*[?]"}), + ruleGen.WithTitle("rule_glob"))) + ruleSpecialChars := createRule(t, store, ruleGen.With( + ruleGen.WithLabels(map[string]string{"json": "line1\nline2\\end\"quote"}), + ruleGen.WithTitle("rule_special_chars"))) + ruleEmpty := createRule(t, store, ruleGen.With( + ruleGen.WithLabels(map[string]string{"empty": ""}), + ruleGen.WithTitle("rule_empty"))) + ruleNonempty := createRule(t, store, ruleGen.With( + ruleGen.WithLabels(map[string]string{"empty": "nonempty"}), + ruleGen.WithTitle("rule_nonempty"))) + + tc := []struct { + name string + labelMatchers labels.Matchers + expectedRules []*models.AlertRule + }{ + { + name: "equality matcher is case-sensitive", + labelMatchers: labels.Matchers{ + func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "team", "alerting"); return m }(), + }, + expectedRules: []*models.AlertRule{ruleLower}, + }, + { + name: "equality matcher matches uppercase when specified", + labelMatchers: labels.Matchers{ + func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "team", "Alerting"); return m }(), + }, + expectedRules: []*models.AlertRule{ruleUpper}, + }, + { + name: "inequality matcher is case-sensitive", + labelMatchers: labels.Matchers{ + func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchNotEqual, "team", "alerting"); return m }(), + }, + expectedRules: []*models.AlertRule{ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNonempty}, + }, + { + name: "special characters in labels are handled correctly", + labelMatchers: labels.Matchers{ + func() *labels.Matcher { + m, _ := labels.NewMatcher(labels.MatchEqual, "key", `value"with"quotes`) + return m + }(), + }, + expectedRules: []*models.AlertRule{ruleSpecial}, + }, + { + name: "matcher with non-existent label returns no rules", + labelMatchers: labels.Matchers{ + func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "nonexistent", "value"); return m }(), + }, + expectedRules: []*models.AlertRule{}, + }, + { + name: "multiple matchers are ANDed", + labelMatchers: labels.Matchers{ + func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "team", "Alerting"); return m }(), + func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "severity", "critical"); return m }(), + }, + expectedRules: []*models.AlertRule{ruleUpper}, + }, + { + name: "GLOB special characters are escaped correctly", + labelMatchers: labels.Matchers{ + func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "glob", "*[?]"); return m }(), + }, + expectedRules: []*models.AlertRule{ruleGlob}, + }, + { + name: "JSON escape characters are handled correctly", + labelMatchers: labels.Matchers{ + func() *labels.Matcher { + m, _ := labels.NewMatcher(labels.MatchEqual, "json", "line1\nline2\\end\"quote") + return m + }(), + }, + expectedRules: []*models.AlertRule{ruleSpecialChars}, + }, + { + name: "empty string value matches correctly", + labelMatchers: labels.Matchers{ + func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "empty", ""); return m }(), + }, + expectedRules: []*models.AlertRule{ruleLower, ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty}, + }, + { + name: "inequality matcher on non-existent label matches all rules", + labelMatchers: labels.Matchers{ + func() *labels.Matcher { + m, _ := labels.NewMatcher(labels.MatchNotEqual, "nonexistent", "value") + return m + }(), + }, + expectedRules: []*models.AlertRule{ruleLower, ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNonempty}, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + query := &models.ListAlertRulesQuery{ + OrgID: orgID, + LabelMatchers: tt.labelMatchers, + } + result, err := store.ListAlertRules(context.Background(), query) + require.NoError(t, err) + require.ElementsMatch(t, tt.expectedRules, result) + }) + } + + t.Run("regex matcher returns error from store", func(t *testing.T) { + query := &models.ListAlertRulesQuery{ + OrgID: orgID, + LabelMatchers: labels.Matchers{ + func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchRegexp, "team", "alert.*"); return m }(), + }, + } + _, err := store.ListAlertRules(context.Background(), query) + require.Error(t, err) + require.ErrorContains(t, err, "is not supported") + }) + + t.Run("not-regex matcher returns error from store", func(t *testing.T) { + query := &models.ListAlertRulesQuery{ + OrgID: orgID, + LabelMatchers: labels.Matchers{ + func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchNotRegexp, "team", "alert.*"); return m }(), + }, + } + _, err := store.ListAlertRules(context.Background(), query) + require.Error(t, err) + require.ErrorContains(t, err, "is not supported") + }) + }) } func TestIntegration_ListAlertRulesPaginated(t *testing.T) { diff --git a/pkg/services/ngalert/store/json.go b/pkg/services/ngalert/store/json.go new file mode 100644 index 00000000000..7b634246951 --- /dev/null +++ b/pkg/services/ngalert/store/json.go @@ -0,0 +1,101 @@ +package store + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +) + +// JSON functions for MySQL/PostgreSQL + +func jsonEquals(dialect migrator.Dialect, column, key, value string) (string, []any) { + switch dialect.DriverName() { + case migrator.MySQL: + return fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(%s, CONCAT('$.', ?))) = ?", column), []any{key, value} + case migrator.Postgres: + return fmt.Sprintf("jsonb_extract_path_text(%s::jsonb, ?) = ?", column), []any{key, value} + default: + return "", nil + } +} + +func jsonNotEquals(dialect migrator.Dialect, column, key, value string) (string, []any) { + var jx string + switch dialect.DriverName() { + case migrator.MySQL: + jx = fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(%s, CONCAT('$.', ?)))", column) + case migrator.Postgres: + jx = fmt.Sprintf("jsonb_extract_path_text(%s::jsonb, ?)", column) + default: + return "", nil + } + return fmt.Sprintf("(%s IS NULL OR %s != ?)", jx, jx), []any{key, key, value} +} + +func jsonKeyMissing(dialect migrator.Dialect, column, key string) (string, []any) { + switch dialect.DriverName() { + case migrator.MySQL: + return fmt.Sprintf("JSON_EXTRACT(%s, CONCAT('$.', ?)) IS NULL", column), []any{key} + case migrator.Postgres: + return fmt.Sprintf("jsonb_extract_path_text(%s::jsonb, ?) IS NULL", column), []any{key} + default: + return "", nil + } +} + +// GLOB functions for SQLite + +func globEquals(column, key, value string) (string, []any, error) { + pattern, err := buildGlobPattern(key, value) + if err != nil { + return "", nil, err + } + return column + " GLOB ?", []any{"*" + pattern + "*"}, nil +} + +func globNotEquals(column, key, value string) (string, []any, error) { + pattern, err := buildGlobPattern(key, value) + if err != nil { + return "", nil, err + } + return column + " NOT GLOB ?", []any{"*" + pattern + "*"}, nil +} + +func globKeyMissing(column, key string) (string, []any, error) { + pattern, err := buildGlobKeyPattern(key) + if err != nil { + return "", nil, err + } + return column + " NOT GLOB ?", []any{"*" + pattern + "*"}, nil +} + +// Search for `"key":"value"` +func buildGlobPattern(key, value string) (string, error) { + keyJSON, err := json.Marshal(key) + if err != nil { + return "", fmt.Errorf("failed to marshal key: %w", err) + } + valueJSON, err := json.Marshal(value) + if err != nil { + return "", fmt.Errorf("failed to marshal value: %w", err) + } + return escapeGlobPattern(fmt.Sprintf(`%s:%s`, string(keyJSON), string(valueJSON))), nil +} + +// Search for `"key":` +func buildGlobKeyPattern(key string) (string, error) { + keyJSON, err := json.Marshal(key) + if err != nil { + return "", fmt.Errorf("failed to marshal key: %w", err) + } + return escapeGlobPattern(string(keyJSON) + ":"), nil +} + +func escapeGlobPattern(pattern string) string { + pattern = strings.ReplaceAll(pattern, "[", "[[]") + pattern = strings.ReplaceAll(pattern, "*", "[*]") + pattern = strings.ReplaceAll(pattern, "?", "[?]") + return pattern +} diff --git a/pkg/services/ngalert/store/json_test.go b/pkg/services/ngalert/store/json_test.go new file mode 100644 index 00000000000..89f85a027a6 --- /dev/null +++ b/pkg/services/ngalert/store/json_test.go @@ -0,0 +1,185 @@ +package store + +import ( + "testing" + + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/stretchr/testify/require" +) + +func TestJsonEquals(t *testing.T) { + tests := []struct { + name string + dialect migrator.Dialect + column string + key string + value string + wantSQL string + wantArgs []any + }{ + { + name: "MySQL", + dialect: migrator.NewMysqlDialect(), + column: "labels", + key: "team", + value: "alerting", + wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) = ?", + wantArgs: []any{"team", "alerting"}, + }, + { + name: "PostgreSQL", + dialect: migrator.NewPostgresDialect(), + column: "labels", + key: "team", + value: "alerting", + wantSQL: "jsonb_extract_path_text(labels::jsonb, ?) = ?", + wantArgs: []any{"team", "alerting"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sql, args := jsonEquals(tt.dialect, tt.column, tt.key, tt.value) + require.Equal(t, tt.wantSQL, sql) + require.Equal(t, tt.wantArgs, args) + }) + } +} + +func TestJsonNotEquals(t *testing.T) { + tests := []struct { + name string + dialect migrator.Dialect + column string + key string + value string + wantSQL string + wantArgs []any + }{ + { + name: "MySQL", + dialect: migrator.NewMysqlDialect(), + column: "labels", + key: "team", + value: "alerting", + wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) != ?)", + wantArgs: []any{"team", "team", "alerting"}, + }, + { + name: "PostgreSQL", + dialect: migrator.NewPostgresDialect(), + column: "labels", + key: "team", + value: "alerting", + wantSQL: "(jsonb_extract_path_text(labels::jsonb, ?) IS NULL OR jsonb_extract_path_text(labels::jsonb, ?) != ?)", + wantArgs: []any{"team", "team", "alerting"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sql, args := jsonNotEquals(tt.dialect, tt.column, tt.key, tt.value) + require.Equal(t, tt.wantSQL, sql) + require.Equal(t, tt.wantArgs, args) + }) + } +} + +func TestJsonKeyMissing(t *testing.T) { + tests := []struct { + name string + dialect migrator.Dialect + column string + key string + wantSQL string + wantArgs []any + }{ + { + name: "MySQL", + dialect: migrator.NewMysqlDialect(), + column: "labels", + key: "team", + wantSQL: "JSON_EXTRACT(labels, CONCAT('$.', ?)) IS NULL", + wantArgs: []any{"team"}, + }, + { + name: "PostgreSQL", + dialect: migrator.NewPostgresDialect(), + column: "labels", + key: "team", + wantSQL: "jsonb_extract_path_text(labels::jsonb, ?) IS NULL", + wantArgs: []any{"team"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sql, args := jsonKeyMissing(tt.dialect, tt.column, tt.key) + require.Equal(t, tt.wantSQL, sql) + require.Equal(t, tt.wantArgs, args) + }) + } +} + +func TestGlobEquals(t *testing.T) { + sql, args, err := globEquals("labels", "team", "alerting") + require.NoError(t, err) + require.Equal(t, "labels GLOB ?", sql) + require.Equal(t, []any{`*"team":"alerting"*`}, args) +} + +func TestGlobNotEquals(t *testing.T) { + sql, args, err := globNotEquals("labels", "team", "alerting") + require.NoError(t, err) + require.Equal(t, "labels NOT GLOB ?", sql) + require.Equal(t, []any{`*"team":"alerting"*`}, args) +} + +func TestGlobKeyMissing(t *testing.T) { + sql, args, err := globKeyMissing("labels", "team") + require.NoError(t, err) + require.Equal(t, "labels NOT GLOB ?", sql) + require.Equal(t, []any{`*"team":*`}, args) +} + +func TestBuildGlobPattern(t *testing.T) { + tests := []struct { + name string + key string + value string + expected string + }{ + { + name: "simple key-value", + key: "team", + value: "alerting", + expected: `"team":"alerting"`, + }, + { + name: "empty value", + key: "empty", + value: "", + expected: `"empty":""`, + }, + { + name: "special GLOB chars are escaped", + key: "key", + value: "*[?]", + expected: `"key":"[*][[][?]]"`, + }, + { + name: "special chars are escaped", + key: "key", + value: "line1\nline2\\end\"quote", + expected: `"key":"line1\nline2\\end\"quote"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pattern, err := buildGlobPattern(tt.key, tt.value) + require.NoError(t, err) + require.Equal(t, tt.expected, pattern) + }) + } +} diff --git a/pkg/services/ngalert/tests/fakes/rules.go b/pkg/services/ngalert/tests/fakes/rules.go index cfe790b9853..42e30ddb052 100644 --- a/pkg/services/ngalert/tests/fakes/rules.go +++ b/pkg/services/ngalert/tests/fakes/rules.go @@ -219,6 +219,7 @@ func (f *RuleStore) ListAlertRulesByGroup(_ context.Context, q *models.ListAlert RuleUIDs: q.RuleUIDs, ReceiverName: q.ReceiverName, HasPrometheusRuleDefinition: q.HasPrometheusRuleDefinition, + LabelMatchers: q.LabelMatchers, } ruleList, err := f.listAlertRules(query) @@ -355,6 +356,20 @@ func (f *RuleStore) listAlertRules(q *models.ListAlertRulesQuery) (models.RulesG if q.ReceiverName != "" && (len(r.NotificationSettings) < 1 || r.NotificationSettings[0].Receiver != q.ReceiverName) { continue } + + if len(q.LabelMatchers) > 0 { + matches := true + for _, m := range q.LabelMatchers { + if !m.Matches(r.Labels[m.Name]) { + matches = false + break + } + } + if !matches { + continue + } + } + copyR := models.CopyRule(r) ruleList = append(ruleList, copyR) } diff --git a/pkg/tests/api/alerting/api_prometheus_test.go b/pkg/tests/api/alerting/api_prometheus_test.go index 61bc3195857..067372a3470 100644 --- a/pkg/tests/api/alerting/api_prometheus_test.go +++ b/pkg/tests/api/alerting/api_prometheus_test.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/url" "sort" "testing" "time" @@ -364,8 +365,6 @@ func TestIntegrationPrometheusRules(t *testing.T) { func TestIntegrationPrometheusRulesPagination(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) - testinfra.SQLiteIntegrationTest(t) - dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, EnableUnifiedAlerting: true, @@ -388,23 +387,30 @@ func TestIntegrationPrometheusRulesPagination(t *testing.T) { require.NoError(t, err) // Create 3 rule groups with different numbers of rules - // Group 1: 5 rules, Group 2: 3 rules, Group 3: 2 rules (total: 10 rules) + // Group 1: 5 rules with team=backend + // Group 2: 3 rules with team=frontend + // Group 3: 2 rules with team=platform for groupIdx := 1; groupIdx <= 3; groupIdx++ { var rulesCount int + var team string switch groupIdx { case 1: rulesCount = 5 + team = "backend" case 2: rulesCount = 3 + team = "frontend" case 3: rulesCount = 2 + team = "platform" } rules := make([]apimodels.PostableExtendedRuleNode, rulesCount) for i := 0; i < rulesCount; i++ { rules[i] = apimodels.PostableExtendedRuleNode{ ApiRuleNode: &apimodels.ApiRuleNode{ - For: &interval, + For: &interval, + Labels: map[string]string{"team": team}, }, GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ Title: fmt.Sprintf("rule-%d-%d", groupIdx, i+1), @@ -514,6 +520,61 @@ func TestIntegrationPrometheusRulesPagination(t *testing.T) { require.Equal(t, http.StatusOK, resp.StatusCode) require.Len(t, result.Data.RuleGroups, 0, "should return no groups") }) + + t.Run("with rule_matcher filter returns only matching rules", func(t *testing.T) { + matcher := url.QueryEscape(`{"name":"team","value":"frontend","isRegex":false,"isEqual":true}`) + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?rule_matcher=%s", grafanaListedAddr, matcher) + // nolint:gosec + resp, err := http.Get(promRulesURL) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + + var result apimodels.RuleResponse + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Should only return group-2 (team=frontend, 3 rules) + foundGroups := []string{} + total := 0 + for _, group := range result.Data.RuleGroups { + foundGroups = append(foundGroups, group.Name) + total += len(group.Rules) + } + require.Equal(t, []string{"group-2"}, foundGroups) + require.Equal(t, 3, total) + }) + + t.Run("with rule_matcher regex filter", func(t *testing.T) { + // Filter with regex team=~plat.* (should match group-3 with team=platform) + matcher := url.QueryEscape(`{"name":"team","value":"plat.*","isRegex":true,"isEqual":true}`) + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?rule_matcher=%s", grafanaListedAddr, matcher) + // nolint:gosec + resp, err := http.Get(promRulesURL) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + + var result apimodels.RuleResponse + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Should only return group-3 (team=platform matches plat.*) + foundGroups := []string{} + total := 0 + for _, group := range result.Data.RuleGroups { + foundGroups = append(foundGroups, group.Name) + total += len(group.Rules) + } + require.Equal(t, []string{"group-3"}, foundGroups) + require.Equal(t, 2, total) + }) } func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) { diff --git a/public/app/features/alerting/unified/api/prometheusApi.ts b/public/app/features/alerting/unified/api/prometheusApi.ts index 3c4fe219dd0..12fa3aae223 100644 --- a/public/app/features/alerting/unified/api/prometheusApi.ts +++ b/public/app/features/alerting/unified/api/prometheusApi.ts @@ -47,6 +47,7 @@ export type GrafanaPromRulesOptions = Omit ({ url: `api/prometheus/grafana/api/v1/rules`, params: { @@ -120,6 +122,7 @@ export const prometheusApi = alertingApi.injectEndpoints({ 'search.rule_name': title, 'search.rule_group': searchGroupName, dashboard_uid: dashboardUid, + rule_matcher: ruleMatchers, }, }), providesTags: (_result, _error, { folderUid, groupName, ruleName }) => { diff --git a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts index 12a46a87bdd..ab9404d4e52 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts @@ -1,9 +1,12 @@ import { testWithFeatureToggles } from 'test/test-utils'; +import { config } from '@grafana/runtime'; import { PromAlertingRuleState, PromRuleGroupDTO, PromRuleType } from 'app/types/unified-alerting-dto'; import { mockGrafanaPromAlertingRule, mockPromRecordingRule } from '../../mocks'; import { RuleHealth } from '../../search/rulesSearchParser'; +import { pluginMeta, pluginMetaToPluginConfig } from '../../testSetup/plugins'; +import { SupportedPlugin } from '../../types/pluginBridges'; import { Annotation } from '../../utils/constants'; import { getDatasourceAPIUid } from '../../utils/datasource'; import { getFilter } from '../../utils/search'; @@ -416,19 +419,40 @@ describe('grafana-managed rules', () => { expect(frontendFilter.groupMatches(group)).toBe(true); }); + it('should include ruleMatchers in backend filter when labels are provided', () => { + const { backendFilter } = getGrafanaFilter(getFilter({ labels: ['severity=critical'] })); + + expect(backendFilter.ruleMatchers).toBeDefined(); + expect(backendFilter.ruleMatchers).toHaveLength(1); + expect(backendFilter.ruleMatchers).toEqual([ + '{"name":"severity","value":"critical","isRegex":false,"isEqual":true}', + ]); + }); + it('should still apply other frontend filters', () => { - const rule = mockGrafanaPromAlertingRule({ + // Set up test plugin as installed + config.apps[SupportedPlugin.Slo] = pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Slo]); + + const regularRule = mockGrafanaPromAlertingRule({ name: 'High CPU Usage', labels: { severity: 'critical', team: 'ops' }, alerts: [], }); - // Label filter should still work on frontend - const { frontendFilter } = getGrafanaFilter(getFilter({ labels: ['severity=warning'] })); - expect(frontendFilter.ruleMatches(rule)).toBe(false); + const pluginRule = mockGrafanaPromAlertingRule({ + name: 'Plugin Rule', + labels: { __grafana_origin: `plugin/${SupportedPlugin.Slo}` }, + alerts: [], + }); - const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ labels: ['severity=critical'] })); - expect(frontendFilter2.ruleMatches(rule)).toBe(true); + // Plugins filter should still work on frontend + const { frontendFilter } = getGrafanaFilter(getFilter({ plugins: 'hide' })); + + // Non-plugin rules should pass through + expect(frontendFilter.ruleMatches(regularRule)).toBe(true); + + // Plugin-provided rules should be filtered out + expect(frontendFilter.ruleMatches(pluginRule)).toBe(false); }); }); @@ -681,20 +705,7 @@ describe('grafana-managed rules', () => { expect(frontendFilter.groupMatches(group)).toBe(true); }); - it('should still apply always-frontend filters (labels, namespace)', () => { - const rule = mockGrafanaPromAlertingRule({ - name: 'High CPU Usage', - labels: { severity: 'critical' }, - alerts: [], - }); - - // Labels filter should still work - const { frontendFilter: labelFilter } = getGrafanaFilter(getFilter({ labels: ['severity=warning'] })); - expect(labelFilter.ruleMatches(rule)).toBe(false); - - const { frontendFilter: labelFilter2 } = getGrafanaFilter(getFilter({ labels: ['severity=critical'] })); - expect(labelFilter2.ruleMatches(rule)).toBe(true); - + it('should still apply always-frontend filters (namespace)', () => { // Namespace filter should still work const group: PromRuleGroupDTO = { name: 'Test Group', @@ -791,9 +802,13 @@ describe('grafana-managed rules', () => { expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(false); }); + it('should return false for labels (handled by backend when feature toggle is enabled)', () => { + expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(false); + }); + it('should return true for client-side only filters', () => { expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); - expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(true); }); it('should return false for backend-only filters (state, health, contactPoint)', () => { @@ -816,12 +831,13 @@ describe('grafana-managed rules', () => { expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); - // Should return true for: frontend-handled filters + // Should return true for: frontend-handled filters (labels, namespace, plugins) expect(hasGrafanaClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true); expect(hasGrafanaClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true); expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true); expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(true); }); }); @@ -840,12 +856,13 @@ describe('grafana-managed rules', () => { expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); - // Should return true for: always-frontend filters only (namespace, labels) + // Should return true for: always-frontend filters only (namespace, plugins) expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); - expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(true); - // Should return false for: backend-handled dataSourceNames when feature toggles are enabled + // Should return false for: backend-handled filters when both feature toggles are enabled expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(false); }); }); }); diff --git a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts index c0fd9fea4d8..96ee951ee37 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts @@ -1,8 +1,11 @@ +import { attempt, isError } from 'lodash'; + import { PromRuleDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto'; import { GrafanaPromRulesOptions } from '../../api/prometheusApi'; import { shouldUseBackendFilters, shouldUseFullyCompatibleBackendFilters } from '../../featureToggles'; import { RulesFilter } from '../../search/rulesSearchParser'; +import { parseMatcher } from '../../utils/matchers'; import { buildTitleSearch, normalizeFilterState } from './filterNormalization'; import { @@ -75,6 +78,12 @@ export function getGrafanaFilter(filterState: Partial) { hasInvalidDataSourceNames = datasourceUids.length === 0; } + // Convert labels to JSON-encoded matchers for backend filtering + const ruleMatchersBackendFilter: string[] | undefined = + ruleFilterConfig.labels || normalizedFilterState.labels.length === 0 + ? undefined + : labelMatchersToBackendFormat(normalizedFilterState.labels); + const backendFilter: GrafanaPromRulesOptions = { state: normalizedFilterState.ruleState ? [normalizedFilterState.ruleState] : [], health: normalizedFilterState.ruleHealth ? [normalizedFilterState.ruleHealth] : [], @@ -85,6 +94,7 @@ export function getGrafanaFilter(filterState: Partial) { dashboardUid: ruleFilterConfig.dashboardUid ? undefined : normalizedFilterState.dashboardUid, searchGroupName: groupFilterConfig.groupName ? undefined : normalizedFilterState.groupName, datasources: ruleFilterConfig.dataSourceNames ? undefined : datasourceUids, + ruleMatchers: ruleMatchersBackendFilter, }; return { @@ -115,7 +125,7 @@ function buildGrafanaFilterConfigs() { ruleState: null, ruleType: useBackendFilters || useFullyCompatibleBackendFilters ? null : ruleTypeFilter, dataSourceNames: useBackendFilters || useFullyCompatibleBackendFilters ? null : dataSourceNamesFilter, - labels: labelsFilter, + labels: useBackendFilters ? null : labelsFilter, ruleHealth: null, dashboardUid: useBackendFilters || useFullyCompatibleBackendFilters ? null : dashboardUidFilter, plugins: pluginsFilter, @@ -129,3 +139,21 @@ function buildGrafanaFilterConfigs() { return { ruleFilterConfig, groupFilterConfig }; } + +/** + * Converts label matchers to JSON-encoded strings for backend filtering. + * Invalid matchers are logged and filtered out. + */ +function labelMatchersToBackendFormat(labels: string[]): string[] { + return labels.reduce((acc, label) => { + const result = attempt(() => JSON.stringify(parseMatcher(label))); + + if (isError(result)) { + console.warn('Failed to parse label matcher:', label, result); + } else { + acc.push(result); + } + + return acc; + }, []); +} diff --git a/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts b/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts index 2e1db699883..5d6b7c97782 100644 --- a/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts +++ b/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts @@ -74,6 +74,7 @@ describe('paginationLimits', () => { { ruleHealth: RuleHealth.Ok }, { contactPoint: 'slack' }, { dataSourceNames: ['prometheus'] }, + { labels: ['severity=critical'] }, ])( 'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p', (filterState) => { @@ -86,7 +87,6 @@ describe('paginationLimits', () => { it.each>([ { namespace: 'production' }, - { labels: ['severity=critical'] }, { ruleState: PromAlertingRuleState.Firing, namespace: 'production' }, ])('should return large limits for both when frontend filters are used: %p', (filterState) => { const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); @@ -157,6 +157,7 @@ describe('paginationLimits', () => { { ruleHealth: RuleHealth.Ok }, { contactPoint: 'slack' }, { dataSourceNames: ['prometheus'] }, + { labels: ['severity=critical'] }, ])( 'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p', (filterState) => { @@ -167,7 +168,7 @@ describe('paginationLimits', () => { } ); - it.each>([{ namespace: 'production' }, { labels: ['severity=critical'] }])( + it.each>([{ namespace: 'production' }])( 'should return large limits for both when frontend filters are used: %p', (filterState) => { const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState));