From da5af292181b24005ca7a918d14af537d1ea8127 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Mon, 17 Nov 2025 20:38:42 +0100 Subject: [PATCH] Alerting: Add search.rule_group to search rules by rule group name (#113680) Co-authored-by: Konrad Lalik --- .../ngalert/api/prometheus/api_prometheus.go | 32 +++++---- pkg/services/ngalert/models/alert_rule.go | 3 + pkg/services/ngalert/store/alert_rule.go | 12 ++++ pkg/services/ngalert/store/alert_rule_test.go | 69 +++++++++++++++++++ .../alerting/unified/api/prometheusApi.ts | 3 + .../rule-list/PaginatedGrafanaLoader.tsx | 12 +++- .../unified/rule-list/hooks/filters.test.ts | 46 +++++++++++++ .../unified/rule-list/hooks/filters.ts | 3 +- .../hooks/prometheusGroupsGenerator.ts | 1 + .../hooks/useFilteredRulesIterator.test.ts | 3 + .../hooks/useFilteredRulesIterator.ts | 5 +- 11 files changed, 169 insertions(+), 20 deletions(-) diff --git a/pkg/services/ngalert/api/prometheus/api_prometheus.go b/pkg/services/ngalert/api/prometheus/api_prometheus.go index 2cfedbc81c4..e7a6b7f57a4 100644 --- a/pkg/services/ngalert/api/prometheus/api_prometheus.go +++ b/pkg/services/ngalert/api/prometheus/api_prometheus.go @@ -480,6 +480,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt receiverName := opts.Query.Get("receiver_name") title := opts.Query.Get("search.rule_name") + searchRuleGroup := opts.Query.Get("search.rule_group") var ruleType ngmodels.RuleTypeFilter switch ngmodels.RuleType(opts.Query.Get("rule_type")) { @@ -500,13 +501,14 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt byGroupQuery := ngmodels.ListAlertRulesExtendedQuery{ ListAlertRulesQuery: ngmodels.ListAlertRulesQuery{ - OrgID: opts.OrgID, - NamespaceUIDs: namespaceUIDs, - DashboardUID: dashboardUID, - PanelID: panelID, - RuleGroups: ruleGroups, - ReceiverName: receiverName, - SearchTitle: title, + OrgID: opts.OrgID, + NamespaceUIDs: namespaceUIDs, + DashboardUID: dashboardUID, + PanelID: panelID, + RuleGroups: ruleGroups, + ReceiverName: receiverName, + SearchTitle: title, + SearchRuleGroup: searchRuleGroup, }, RuleType: ruleType, Limit: maxGroups, @@ -638,15 +640,17 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru receiverName := opts.Query.Get("receiver_name") title := opts.Query.Get("search.rule_name") + searchRuleGroup := opts.Query.Get("search.rule_group") alertRuleQuery := ngmodels.ListAlertRulesQuery{ - OrgID: opts.OrgID, - NamespaceUIDs: namespaceUIDs, - DashboardUID: dashboardUID, - PanelID: panelID, - RuleGroups: ruleGroups, - ReceiverName: receiverName, - SearchTitle: title, + OrgID: opts.OrgID, + NamespaceUIDs: namespaceUIDs, + DashboardUID: dashboardUID, + PanelID: panelID, + RuleGroups: ruleGroups, + ReceiverName: receiverName, + SearchTitle: title, + SearchRuleGroup: searchRuleGroup, } ruleList, err := store.ListAlertRules(opts.Ctx, &alertRuleQuery) if err != nil { diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index fc6b5fe104d..4823cd977fe 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -981,6 +981,9 @@ type ListAlertRulesQuery struct { // SearchTitle allows searching for alert rules that contain // the given string in their title (case insensitive) SearchTitle string + // SearchRuleGroup allows searching for alert rules in groups that contain + // the given string in their name (case insensitive) + SearchRuleGroup string HasPrometheusRuleDefinition *bool } diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 0b0f51f4dcc..393bfdabc91 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -865,6 +865,18 @@ func (st DBstore) buildListAlertRulesQuery(sess *db.Session, query *ngmodels.Lis } } + if query.SearchRuleGroup != "" { + normalizedInput := strings.ToLower(query.SearchRuleGroup) + words := strings.Fields(normalizedInput) + + if len(words) > 0 { + pattern := "%" + strings.Join(words, "%") + "%" + // In MySQL rule_group field has case-sensitive collation by default, + // so we need to use LOWER to perform case-insensitive search. + q = q.And("LOWER(rule_group) LIKE ?", pattern) + } + } + if query.HasPrometheusRuleDefinition != nil { q, err = st.filterWithPrometheusRuleDefinition(*query.HasPrometheusRuleDefinition, q) if err != nil { diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index 6fcc1375abe..0c49528f8d6 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -2141,6 +2141,75 @@ func TestIntegration_ListAlertRules(t *testing.T) { }) } }) + + t.Run("filter by SearchRuleGroup", func(t *testing.T) { + sqlStore := db.InitTestDB(t) + folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) + store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, b) + rule1 := createRule(t, store, ruleGen.With(models.RuleMuts.WithGroupName("database-alerts"))) + rule2 := createRule(t, store, ruleGen.With(models.RuleMuts.WithGroupName("application-alerts"))) + rule3 := createRule(t, store, ruleGen.With(models.RuleMuts.WithGroupName("network-alerts"))) + rule4 := createRule(t, store, ruleGen.With(models.RuleMuts.WithGroupName("critical-monitoring"))) + + tc := []struct { + name string + groupSearch string + expectedRules []*models.AlertRule + }{ + { + name: "should find rules", + groupSearch: "alerts", + expectedRules: []*models.AlertRule{rule1, rule2, rule3}, + }, + { + name: "should find rule with partial match", + groupSearch: "mOnItOrInG", + expectedRules: []*models.AlertRule{rule4}, + }, + { + name: "should return no rules when no match", + groupSearch: "nonexistent", + expectedRules: []*models.AlertRule{}, + }, + { + name: "should return all rules when empty", + groupSearch: "", + expectedRules: []*models.AlertRule{rule1, rule2, rule3, rule4}, + }, + { + name: "should not find rules when word order is reversed", + groupSearch: "alerts database", + expectedRules: []*models.AlertRule{}, + }, + { + name: "should find multiple rules matching sequential words", + groupSearch: "database alert", + expectedRules: []*models.AlertRule{rule1}, + }, + { + name: "should handle extra whitespace between words", + groupSearch: " network alerts ", + expectedRules: []*models.AlertRule{rule3}, + }, + { + name: "should handle multiple words with partial matches", + groupSearch: "crit mon", + expectedRules: []*models.AlertRule{rule4}, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + query := &models.ListAlertRulesQuery{ + OrgID: orgID, + SearchRuleGroup: tt.groupSearch, + } + result, err := store.ListAlertRules(context.Background(), query) + require.NoError(t, err) + require.ElementsMatch(t, tt.expectedRules, result) + }) + } + }) } func TestIntegration_ListAlertRulesPaginated(t *testing.T) { diff --git a/public/app/features/alerting/unified/api/prometheusApi.ts b/public/app/features/alerting/unified/api/prometheusApi.ts index a3d9eead62e..565a50e46ad 100644 --- a/public/app/features/alerting/unified/api/prometheusApi.ts +++ b/public/app/features/alerting/unified/api/prometheusApi.ts @@ -43,6 +43,7 @@ export type GrafanaPromRulesOptions = Omit ({ url: `api/prometheus/grafana/api/v1/rules`, @@ -110,6 +112,7 @@ export const prometheusApi = alertingApi.injectEndpoints({ group_limit: groupLimit?.toFixed(0), group_next_token: groupNextToken, 'search.rule_name': title, + 'search.rule_group': searchGroupName, dashboard_uid: dashboardUid, }, }), diff --git a/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx b/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx index dc8875f1cf8..d1835d29757 100644 --- a/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx @@ -7,6 +7,7 @@ import { GrafanaPromRuleGroupDTO, PromRuleGroupDTO } from 'app/types/unified-ale import { FolderActionsButton } from '../components/folder-actions/FolderActionsButton'; import { GrafanaNoRulesCTA } from '../components/rules/NoRulesCTA'; +import { shouldUseBackendFilters } from '../featureToggles'; import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; import { groups } from '../utils/navigation'; @@ -35,10 +36,13 @@ export function PaginatedGrafanaLoader({ groupFilter, namespaceFilter }: LoaderP } function PaginatedGroupsLoader({ groupFilter, namespaceFilter }: LoaderProps) { + const useBackendFilters = shouldUseBackendFilters(); + + // When backend filters are enabled, groupFilter is handled on the backend + const hasFilters = useBackendFilters ? Boolean(namespaceFilter) : Boolean(groupFilter || namespaceFilter); + // If there are filters, we don't want to populate the cache to avoid performance issues // Filtering may trigger multiple HTTP requests, which would populate the cache with a lot of groups hurting performance - const hasFilters = Boolean(groupFilter || namespaceFilter); - const grafanaGroupsGenerator = useGrafanaGroupsGenerator({ populateCache: hasFilters ? false : true, limitAlerts: 0, @@ -48,7 +52,9 @@ function PaginatedGroupsLoader({ groupFilter, namespaceFilter }: LoaderProps) { // However, if there are filters, we need to fetch more groups from the API to populate one frontend page const apiGroupPageSize = getApiGroupPageSize(hasFilters); - const groupsGenerator = useRef(toIndividualRuleGroups(grafanaGroupsGenerator(apiGroupPageSize))); + const searchGroupName = useBackendFilters ? groupFilter : undefined; + + const groupsGenerator = useRef(toIndividualRuleGroups(grafanaGroupsGenerator(apiGroupPageSize, { searchGroupName }))); useEffect(() => { const currentGenerator = groupsGenerator.current; diff --git a/public/app/features/alerting/unified/rule-list/hooks/filters.test.ts b/public/app/features/alerting/unified/rule-list/hooks/filters.test.ts index dc6e8670db4..af9d9011f89 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/filters.test.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/filters.test.ts @@ -650,6 +650,31 @@ describe('grafana-managed rules', () => { expect(frontendFilter.ruleMatches(ruleWithDashboard)).toBe(true); }); + it('should include searchGroupName in backend filter when provided', () => { + const { backendFilter } = getGrafanaFilter(getFilter({ groupName: 'my-group' })); + + expect(backendFilter.searchGroupName).toBe('my-group'); + }); + + it('should not include searchGroupName in backend filter when not provided', () => { + const { backendFilter } = getGrafanaFilter(getFilter({})); + + expect(backendFilter.searchGroupName).toBeUndefined(); + }); + + it('should skip groupName filtering on frontend when backend filtering is enabled', () => { + const group: PromRuleGroupDTO = { + name: 'CPU Usage Alerts', + file: 'production/alerts', + rules: [], + interval: 60, + }; + + const { frontendFilter } = getGrafanaFilter(getFilter({ groupName: 'memory' })); + // Should return true because groupName filter is null (handled by backend) + expect(frontendFilter.groupMatches(group)).toBe(true); + }); + it('should still apply other frontend filters', () => { const rule = mockGrafanaPromAlertingRule({ name: 'High CPU Usage', @@ -739,6 +764,27 @@ describe('grafana-managed rules', () => { expect(frontendFilter2.ruleMatches(ruleDashboardA)).toBe(false); expect(frontendFilter2.ruleMatches(ruleDashboardB)).toBe(true); }); + + it('should not include searchGroupName in backend filter', () => { + const { backendFilter } = getGrafanaFilter(getFilter({ groupName: 'my-group' })); + + expect(backendFilter.searchGroupName).toBeUndefined(); + }); + + it('should perform groupName filtering on frontend', () => { + const group: PromRuleGroupDTO = { + name: 'CPU Usage Alerts', + file: 'production/alerts', + rules: [], + interval: 60, + }; + + const { frontendFilter } = getGrafanaFilter(getFilter({ groupName: 'cpu' })); + expect(frontendFilter.groupMatches(group)).toBe(true); + + const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ groupName: 'memory' })); + expect(frontendFilter2.groupMatches(group)).toBe(false); + }); }); }); }); diff --git a/public/app/features/alerting/unified/rule-list/hooks/filters.ts b/public/app/features/alerting/unified/rule-list/hooks/filters.ts index 637fa636f62..6ac4d668e9d 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/filters.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/filters.ts @@ -42,6 +42,7 @@ export function getGrafanaFilter(filterState: RulesFilter) { title: useBackendFilters ? titleSearch : undefined, type: useBackendFilters ? normalizedFilterState.ruleType : undefined, dashboardUid: useBackendFilters ? normalizedFilterState.dashboardUid : undefined, + searchGroupName: useBackendFilters ? normalizedFilterState.groupName : undefined, }; const grafanaFilterProcessingConfig: RuleFilterConfig = { @@ -60,7 +61,7 @@ export function getGrafanaFilter(filterState: RulesFilter) { const grafanaGroupFilterConfig: GroupFilterConfig = { namespace: namespaceFilter, - groupName: groupNameFilter, + groupName: useBackendFilters ? null : groupNameFilter, }; return { diff --git a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts index e7a357c8498..db2d5df077d 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts @@ -47,6 +47,7 @@ interface GrafanaPromApiFilter { health?: RuleHealth[]; contactPoint?: string; title?: string; + searchGroupName?: string; type?: 'alerting' | 'recording'; dashboardUid?: string; } diff --git a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.test.ts b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.test.ts index a622d43b0c6..592485e211c 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.test.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.test.ts @@ -28,6 +28,7 @@ describe('hasClientSideFilters', () => { expect(hasClientSideFilters(getFilter({ ruleName: 'test' }))).toBe(false); expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); + expect(hasClientSideFilters(getFilter({ groupName: 'my-group' }))).toBe(false); }); it('should return true for client-side only filters', () => { @@ -52,6 +53,7 @@ describe('hasClientSideFilters', () => { expect(hasClientSideFilters(getFilter({ ruleName: 'test' }))).toBe(true); expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(true); expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true); + expect(hasClientSideFilters(getFilter({ groupName: 'my-group' }))).toBe(true); }); it('should return true for client-side only filters', () => { @@ -78,6 +80,7 @@ describe('hasClientSideFilters', () => { expect(hasClientSideFilters(getFilter({ ruleName: 'test' }))).toBe(true); expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(true); expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true); + expect(hasClientSideFilters(getFilter({ groupName: 'my-group' }))).toBe(true); }); }); }); diff --git a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts index 4bff8bde783..86f593661ea 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts @@ -153,12 +153,13 @@ export function hasClientSideFilters(filterState: RulesFilter): boolean { const useBackendFilters = shouldUseBackendFilters(); return ( - // When backend filters are disabled, title search, type filter, and dashboard filter need client-side filtering + // When backend filters are disabled, title search, type filter, dashboard filter, and group name filter need client-side filtering (!useBackendFilters && (filterState.freeFormWords.length > 0 || Boolean(filterState.ruleName) || Boolean(filterState.ruleType) || - Boolean(filterState.dashboardUid))) || + Boolean(filterState.dashboardUid) || + Boolean(filterState.groupName))) || // Client-side only filters: Boolean(filterState.namespace) || filterState.dataSourceNames.length > 0 ||