diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index aebbab8c6f9..a9af24eb32b 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -328,6 +328,10 @@ export interface FeatureToggles { */ alertingUIUseFullyCompatBackendFilters?: boolean; /** + * Enables the UI to use limit_rules=0 when calling the API and rules information is not needed + */ + alertingUIUseLimitRules?: boolean; + /** * Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager. */ alertmanagerRemotePrimary?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 3748db8e6b4..122e56e71cf 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -527,6 +527,13 @@ var ( Owner: grafanaAlertingSquad, HideFromDocs: true, }, + { + Name: "alertingUIUseLimitRules", + Description: "Enables the UI to use limit_rules=0 when calling the API and rules information is not needed", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromDocs: true, + }, { Name: "alertmanagerRemotePrimary", Description: "Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager.", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 0c85021cff8..2115c9902c3 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -73,6 +73,7 @@ alertmanagerRemoteSecondary,experimental,@grafana/alerting-squad,false,false,fal alertingProvenanceLockWrites,experimental,@grafana/alerting-squad,false,false,false alertingUIUseBackendFilters,experimental,@grafana/alerting-squad,false,false,false alertingUIUseFullyCompatBackendFilters,experimental,@grafana/alerting-squad,false,false,false +alertingUIUseLimitRules,experimental,@grafana/alerting-squad,false,false,false alertmanagerRemotePrimary,experimental,@grafana/alerting-squad,false,false,false annotationPermissionUpdate,GA,@grafana/identity-access-team,false,false,false dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 5de71954e2f..9e2c1aafebd 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -247,6 +247,10 @@ const ( // Enables the UI to use rules backend-side filters 100% compatible with the frontend filters FlagAlertingUIUseFullyCompatBackendFilters = "alertingUIUseFullyCompatBackendFilters" + // FlagAlertingUIUseLimitRules + // Enables the UI to use limit_rules=0 when calling the API and rules information is not needed + FlagAlertingUIUseLimitRules = "alertingUIUseLimitRules" + // FlagAlertmanagerRemotePrimary // Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager. FlagAlertmanagerRemotePrimary = "alertmanagerRemotePrimary" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 6d55a6ca617..76ee9d2fbb4 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -566,6 +566,19 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "alertingUIUseLimitRules", + "resourceVersion": "1765891213465", + "creationTimestamp": "2025-12-16T13:20:13Z" + }, + "spec": { + "description": "Enables the UI to use limit_rules=0 when calling the API and rules information is not needed", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "hideFromDocs": true + } + }, { "metadata": { "name": "alertingUseNewSimplifiedRoutingHashAlgorithm", diff --git a/pkg/services/ngalert/api/api_prometheus_test.go b/pkg/services/ngalert/api/api_prometheus_test.go index dc0f6d1f13d..de7561e2f47 100644 --- a/pkg/services/ngalert/api/api_prometheus_test.go +++ b/pkg/services/ngalert/api/api_prometheus_test.go @@ -1518,6 +1518,81 @@ func TestRouteGetRuleStatuses(t *testing.T) { require.Len(t, res.Data.RuleGroups[0].Rules, 1) require.Len(t, res.Data.RuleGroups[1].Rules, 1) }) + + t.Run("with limit_rules=0 returns empty rule groups", func(t *testing.T) { + r, err := http.NewRequest("GET", "/api/v1/rules?limit_rules=0", nil) + require.NoError(t, err) + c := &contextmodel.ReqContext{ + Context: &web.Context{Req: r}, + SignedInUser: &user.SignedInUser{ + OrgID: orgID, + Permissions: queryPermissions, + }, + } + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + + // Should return rule groups but with no rules + require.Len(t, res.Data.RuleGroups, 2) + require.Len(t, res.Data.RuleGroups[0].Rules, 0) + require.Len(t, res.Data.RuleGroups[1].Rules, 0) + }) + + t.Run("with limit_rules=0 and state filter returns error", func(t *testing.T) { + r, err := http.NewRequest("GET", "/api/v1/rules?limit_rules=0&state=firing", nil) + require.NoError(t, err) + c := &contextmodel.ReqContext{ + Context: &web.Context{Req: r}, + SignedInUser: &user.SignedInUser{ + OrgID: orgID, + Permissions: queryPermissions, + }, + } + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusBadRequest, resp.Status()) + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + require.Equal(t, "error", res.Status) + require.Contains(t, res.Error, "limit_rules=0 cannot be used together with state or health filters") + }) + + t.Run("with limit_rules=0 and health filter returns error", func(t *testing.T) { + r, err := http.NewRequest("GET", "/api/v1/rules?limit_rules=0&health=ok", nil) + require.NoError(t, err) + c := &contextmodel.ReqContext{ + Context: &web.Context{Req: r}, + SignedInUser: &user.SignedInUser{ + OrgID: orgID, + Permissions: queryPermissions, + }, + } + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusBadRequest, resp.Status()) + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + require.Equal(t, "error", res.Status) + require.Contains(t, res.Error, "limit_rules=0 cannot be used together with state or health filters") + }) + + t.Run("then with limit_rules=0 and both state and health filters returns error", func(t *testing.T) { + r, err := http.NewRequest("GET", "/api/v1/rules?limit_rules=0&state=firing&health=ok", nil) + require.NoError(t, err) + c := &contextmodel.ReqContext{ + Context: &web.Context{Req: r}, + SignedInUser: &user.SignedInUser{ + OrgID: orgID, + Permissions: queryPermissions, + }, + } + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusBadRequest, resp.Status()) + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + require.Equal(t, "error", res.Status) + require.Contains(t, res.Error, "limit_rules=0 cannot be used together with state or health filters") + }) }) t.Run("test with limit alerts", func(t *testing.T) { diff --git a/pkg/services/ngalert/api/prometheus/api_prometheus.go b/pkg/services/ngalert/api/prometheus/api_prometheus.go index 077761d0caf..0ee890c8cde 100644 --- a/pkg/services/ngalert/api/prometheus/api_prometheus.go +++ b/pkg/services/ngalert/api/prometheus/api_prometheus.go @@ -573,8 +573,11 @@ func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlert ctx.stateFilterSet, ctx.matchers, ctx.labelOptions, ctx.ruleStatusMutator, ctx.alertStateMutator, ctx.compact, ) - ruleGroup.Totals = totals - accumulateTotals(result.totalsDelta, totals) + // Only set totals if limit_rules is not 0 + if ctx.limitRulesPerGroup != 0 { + ruleGroup.Totals = totals + accumulateTotals(result.totalsDelta, totals) + } if len(ctx.stateFilterSet) > 0 { filterRulesByState(ruleGroup, ctx.stateFilterSet) @@ -590,7 +593,7 @@ func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlert ruleGroup.Rules = ruleGroup.Rules[0:ctx.limitRulesPerGroup] } - if len(ruleGroup.Rules) > 0 { + if len(ruleGroup.Rules) > 0 || ctx.limitRulesPerGroup == 0 { result.groups = append(result.groups, *ruleGroup) } } @@ -668,6 +671,7 @@ func paginateRuleGroups(log log.Logger, store ListAlertRulesStoreV2, ctx *pagina return allGroups, rulesTotals, continueToken, nil } +// nolint:gocyclo func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opts RuleGroupStatusesOptions, ruleStatusMutator RuleStatusMutator, alertStateMutator RuleAlertStateMutator, provenanceStore ProvenanceStore) apimodels.RuleResponse { ctx, span := tracer.Start(opts.Ctx, "api.prometheus.PrepareRuleGroupStatusesV2") defer span.End() @@ -746,6 +750,13 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt attribute.StringSlice("health_filter", slices.Collect(maps.Keys(healthFilterSet))), ) + if limitRulesPerGroup == 0 && (len(stateFilterSet) > 0 || len(healthFilterSet) > 0) { + ruleResponse.Status = "error" + ruleResponse.Error = "limit_rules=0 cannot be used together with state or health filters" + ruleResponse.ErrorType = apiv1.ErrBadData + return ruleResponse + } + var labelOptions []ngmodels.LabelOption if !getBoolWithDefault(opts.Query, queryIncludeInternalLabels, false) { labelOptions = append(labelOptions, ngmodels.WithoutInternalLabels()) @@ -896,8 +907,8 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt ruleResponse.Data.RuleGroups = groups ruleResponse.Data.NextToken = continueToken - // Only return Totals if there is no pagination - if maxGroups == -1 && maxRules == -1 { + // Only return Totals if there is no pagination and limit_rules is not 0 + if maxGroups == -1 && maxRules == -1 && limitRulesPerGroup != 0 { ruleResponse.Data.Totals = rulesTotals } @@ -944,6 +955,10 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru return badRequestError(err) } + if limitRulesPerGroup == 0 && (len(stateFilterSet) > 0 || len(healthFilterSet) > 0) { + return badRequestError(errors.New("limit_rules=0 cannot be used together with state or health filters")) + } + var labelOptions []ngmodels.LabelOption if !getBoolWithDefault(opts.Query, queryIncludeInternalLabels, false) { labelOptions = append(labelOptions, ngmodels.WithoutInternalLabels()) @@ -1030,9 +1045,12 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru } ruleGroup, totals := toRuleGroup(log, rg.GroupKey, rg.Folder, rg.Rules, provenanceRecords, limitAlertsPerRule, stateFilterSet, matchers, labelOptions, ruleStatusMutator, alertStateMutator, false) - ruleGroup.Totals = totals - for k, v := range totals { - rulesTotals[k] += v + // Only set totals if limit_rules is not 0 + if limitRulesPerGroup != 0 { + ruleGroup.Totals = totals + for k, v := range totals { + rulesTotals[k] += v + } } if len(stateFilterSet) > 0 { @@ -1051,15 +1069,15 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru ruleGroup.Rules = ruleGroup.Rules[0:limitRulesPerGroup] } - if len(ruleGroup.Rules) > 0 { + if len(ruleGroup.Rules) > 0 || limitRulesPerGroup == 0 { ruleResponse.Data.RuleGroups = append(ruleResponse.Data.RuleGroups, *ruleGroup) } } ruleResponse.Data.NextToken = newToken - // Only return Totals if there is no pagination - if maxGroups == -1 { + // Only return Totals if there is no pagination and limit_rules is not 0 + if maxGroups == -1 && limitRulesPerGroup != 0 { ruleResponse.Data.Totals = rulesTotals } diff --git a/pkg/services/ngalert/api/tooling/definitions/prom.go b/pkg/services/ngalert/api/tooling/definitions/prom.go index 05fb62dc283..fa77f3ee656 100644 --- a/pkg/services/ngalert/api/tooling/definitions/prom.go +++ b/pkg/services/ngalert/api/tooling/definitions/prom.go @@ -435,7 +435,8 @@ type GetGrafanaRuleStatusesParams struct { // default: -1 LimitAlerts int64 `json:"limit_alerts"` - // Limit the number of rules per group. + // Limit the number of rules per group. When set to 0, returns empty rule groups (only group metadata without rules). + // limit_rules=0 cannot be used together with state or health filters, and returns an empty response. // in: query // required: false // default: -1 diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index 1243007dfcf..37a1a9fcd48 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -7525,7 +7525,7 @@ }, { "default": -1, - "description": "Limit the number of rules per group.", + "description": "Limit the number of rules per group. When set to 0, returns empty rule groups (only group metadata without rules).\nlimit_rules=0 cannot be used together with state or health filters, and returns an empty response.", "format": "int64", "in": "query", "name": "limit_rules", diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 81e0aa894c9..2a11c67d998 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -1913,7 +1913,7 @@ "type": "integer", "format": "int64", "default": -1, - "description": "Limit the number of rules per group.", + "description": "Limit the number of rules per group. When set to 0, returns empty rule groups (only group metadata without rules).\nlimit_rules=0 cannot be used together with state or health filters, and returns an empty response.", "name": "limit_rules", "in": "query" }, diff --git a/public/app/features/alerting/unified/api/prometheusApi.ts b/public/app/features/alerting/unified/api/prometheusApi.ts index 12fa3aae223..2fa4f0bc6a1 100644 --- a/public/app/features/alerting/unified/api/prometheusApi.ts +++ b/public/app/features/alerting/unified/api/prometheusApi.ts @@ -40,6 +40,7 @@ export type GrafanaPromRulesOptions = Omit ({ jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), + config: { + featureToggles: { + alertingUIUseLimitRules: false, + }, + apps: {}, + }, getDataSourceSrv: () => ({ getList: jest.fn().mockReturnValue([]), }), @@ -71,6 +77,7 @@ describe('useNamespaceAndGroupOptions', () => { expect(mockFetchGrafanaGroups).toHaveBeenCalledWith({ limitAlerts: 0, + limitRules: undefined, searchGroupName: 'cpu', groupLimit: 100, }); @@ -80,6 +87,27 @@ describe('useNamespaceAndGroupOptions', () => { expect(options[1]).toEqual({ label: 'cpu-usage', value: 'cpu-usage' }); }); + it('should pass limitRules: 0 when alertingUIUseLimitRules is enabled', async () => { + const { config } = require('@grafana/runtime'); + config.featureToggles.alertingUIUseLimitRules = true; + + mockFetchGrafanaGroups.mockReturnValue({ + unwrap: () => Promise.resolve({ data: { groups: [] } }), + }); + + const { result } = renderHook(() => useNamespaceAndGroupOptions()); + await result.current.groupOptions('cpu'); + + expect(mockFetchGrafanaGroups).toHaveBeenCalledWith({ + limitAlerts: 0, + limitRules: 0, + searchGroupName: 'cpu', + groupLimit: 100, + }); + + config.featureToggles.alertingUIUseLimitRules = false; + }); + it('should show message when no groups match search', async () => { mockFetchGrafanaGroups.mockReturnValue({ unwrap: () => diff --git a/public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.ts b/public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.ts index 67b3591eae0..b7c53f8d92d 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.ts +++ b/public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.ts @@ -3,7 +3,7 @@ import { useCallback } from 'react'; import { DataSourceInstanceSettings } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { getDataSourceSrv } from '@grafana/runtime'; +import { config, getDataSourceSrv } from '@grafana/runtime'; import { ComboboxOption } from '@grafana/ui'; import { GrafanaPromRuleGroupDTO } from 'app/types/unified-alerting-dto'; @@ -63,8 +63,10 @@ export function useNamespaceAndGroupOptions(): { const namespaceOptions = useCallback( async (inputValue: string) => { // Grafana namespaces - fetch with limit to check threshold + const useLimitRules = config.featureToggles.alertingUIUseLimitRules; const grafanaResponse = await fetchGrafanaGroups({ limitAlerts: 0, + limitRules: useLimitRules ? 0 : undefined, groupLimit: NAMESPACE_THRESHOLD_LIMIT + 1, }).unwrap(); const grafanaFolderNames = Array.from( @@ -135,8 +137,10 @@ export function useNamespaceAndGroupOptions(): { try { // Use the backend search with lightweight response + const useLimitRules = config.featureToggles.alertingUIUseLimitRules; const grafanaResponse = await fetchGrafanaGroups({ limitAlerts: 0, // Lightweight - no alert data + limitRules: useLimitRules ? 0 : undefined, searchGroupName: trimmedInput, // Backend filtering via search.rule_group parameter groupLimit: GROUP_SEARCH_LIMIT, // Reasonable limit for dropdown results }).unwrap();