Compare commits

...

1 Commits

Author SHA1 Message Date
Alexander Akhmetov
285a051c49 Alerting: Return empty rule groups when limit_rules=0 2026-01-02 17:53:04 +01:00
13 changed files with 173 additions and 15 deletions

View File

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

View File

@@ -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.",

View File

@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
73 alertingProvenanceLockWrites experimental @grafana/alerting-squad false false false
74 alertingUIUseBackendFilters experimental @grafana/alerting-squad false false false
75 alertingUIUseFullyCompatBackendFilters experimental @grafana/alerting-squad false false false
76 alertingUIUseLimitRules experimental @grafana/alerting-squad false false false
77 alertmanagerRemotePrimary experimental @grafana/alerting-squad false false false
78 annotationPermissionUpdate GA @grafana/identity-access-team false false false
79 dashboardSceneForViewers GA @grafana/dashboards-squad false false true

View File

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

View File

@@ -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",

View File

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

View File

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

View File

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

View File

@@ -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",

View File

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

View File

@@ -40,6 +40,7 @@ export type GrafanaPromRulesOptions = Omit<PromRulesOptions, 'ruleSource' | 'nam
datasources?: string[];
panelId?: number;
limitAlerts?: number;
limitRules?: number;
ruleLimit?: number;
contactPoint?: string;
health?: RuleHealth[];
@@ -98,6 +99,7 @@ export const prometheusApi = alertingApi.injectEndpoints({
groupLimit,
ruleLimit,
limitAlerts,
limitRules,
groupNextToken,
title,
datasources,
@@ -115,6 +117,7 @@ export const prometheusApi = alertingApi.injectEndpoints({
state: state,
rule_type: type,
limit_alerts: limitAlerts,
limit_rules: limitRules?.toFixed(0),
rule_limit: ruleLimit?.toFixed(0),
group_limit: groupLimit?.toFixed(0),
group_next_token: groupNextToken,

View File

@@ -18,6 +18,12 @@ jest.mock('../../../api/prometheusApi', () => ({
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: () =>

View File

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