diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 2f8cc13e8df..35ae1024368 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -345,6 +345,10 @@ export interface FeatureToggles { */ alertingProvenanceLockWrites?: boolean; /** + * Enables the UI to use certain backend-side filters + */ + alertingUIUseBackendFilters?: 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 fe4834db0ae..0f60b9743b3 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -575,6 +575,14 @@ var ( HideFromAdminPage: true, HideFromDocs: true, }, + { + Name: "alertingUIUseBackendFilters", + Description: "Enables the UI to use certain backend-side filters", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromAdminPage: true, + 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 e493b35ca1e..b3c83f2fac3 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -76,6 +76,7 @@ cachingOptimizeSerializationMemoryUsage,experimental,@grafana/grafana-operator-e addFieldFromCalculationStatFunctions,GA,@grafana/datapro,false,false,true alertmanagerRemoteSecondary,experimental,@grafana/alerting-squad,false,false,false alertingProvenanceLockWrites,experimental,@grafana/alerting-squad,false,false,false +alertingUIUseBackendFilters,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 b484694fef6..46c25420ac1 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -315,6 +315,10 @@ const ( // Enables a feature to avoid issues with concurrent writes to the alerting provenance table in MySQL FlagAlertingProvenanceLockWrites = "alertingProvenanceLockWrites" + // FlagAlertingUIUseBackendFilters + // Enables the UI to use certain backend-side filters + FlagAlertingUIUseBackendFilters = "alertingUIUseBackendFilters" + // 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 1e511160a07..0731f6f9eca 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -614,6 +614,20 @@ "expression": "true" } }, + { + "metadata": { + "name": "alertingUIUseBackendFilters", + "resourceVersion": "1762966218072", + "creationTimestamp": "2025-11-12T16:50:18Z" + }, + "spec": { + "description": "Enables the UI to use certain backend-side filters", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "hideFromAdminPage": true, + "hideFromDocs": true + } + }, { "metadata": { "name": "alertingUseNewSimplifiedRoutingHashAlgorithm", diff --git a/pkg/services/ngalert/api/prometheus/api_prometheus.go b/pkg/services/ngalert/api/prometheus/api_prometheus.go index b44f0f97651..a1884b56e98 100644 --- a/pkg/services/ngalert/api/prometheus/api_prometheus.go +++ b/pkg/services/ngalert/api/prometheus/api_prometheus.go @@ -479,6 +479,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt ruleGroups := opts.Query["rule_group"] receiverName := opts.Query.Get("receiver_name") + title := opts.Query.Get("search.rule_name") maxGroups := getInt64WithDefault(opts.Query, "group_limit", -1) nextToken := opts.Query.Get("group_next_token") @@ -495,6 +496,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt PanelID: panelID, RuleGroups: ruleGroups, ReceiverName: receiverName, + SearchTitle: title, }, Limit: maxGroups, ContinueToken: nextToken, @@ -624,6 +626,7 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru ruleGroups := opts.Query["rule_group"] receiverName := opts.Query.Get("receiver_name") + title := opts.Query.Get("search.rule_name") alertRuleQuery := ngmodels.ListAlertRulesQuery{ OrgID: opts.OrgID, @@ -632,6 +635,7 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru PanelID: panelID, RuleGroups: ruleGroups, ReceiverName: receiverName, + SearchTitle: title, } 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 c7350a91620..fc6b5fe104d 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -978,6 +978,9 @@ type ListAlertRulesQuery struct { ReceiverName string TimeIntervalName string + // SearchTitle allows searching for alert rules that contain + // the given string in their title (case insensitive) + SearchTitle string HasPrometheusRuleDefinition *bool } diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 4b3b9dbbfde..0b0f51f4dcc 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -855,6 +855,16 @@ func (st DBstore) buildListAlertRulesQuery(sess *db.Session, query *ngmodels.Lis } } + if query.SearchTitle != "" { + words := strings.Fields(query.SearchTitle) + if len(words) > 0 { + // Build sequential pattern: %word1%word2%word3% + pattern := strings.Join(words, "%") + sql, param := st.SQLStore.GetDialect().LikeOperator("title", true, pattern, true) + q = q.And(sql, param) + } + } + 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 d1ac3f59cd4..6fcc1375abe 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -2020,12 +2020,10 @@ func Benchmark_ListAlertRules(b *testing.B) { func TestIntegration_ListAlertRules(t *testing.T) { tutil.SkipIntegrationTestInShortMode(t) - sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{ BaseInterval: time.Duration(rand.Int64N(100)) * time.Second, } - folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) b := &fakeBus{} orgID := int64(1) ruleGen := models.RuleGen @@ -2034,6 +2032,8 @@ func TestIntegration_ListAlertRules(t *testing.T) { ruleGen.WithOrgID(orgID), ) t.Run("filter by HasPrometheusRuleDefinition", func(t *testing.T) { + sqlStore := db.InitTestDB(t) + folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, b) regularRule := createRule(t, store, ruleGen) importedRule := createRule(t, store, ruleGen.With( @@ -2072,6 +2072,75 @@ func TestIntegration_ListAlertRules(t *testing.T) { }) } }) + + t.Run("filter by SearchTitle", 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.WithTitle("CPU Usage Alert"))) + rule2 := createRule(t, store, ruleGen.With(models.RuleMuts.WithTitle("Memory Usage Alert"))) + rule3 := createRule(t, store, ruleGen.With(models.RuleMuts.WithTitle("Disk Space Alert"))) + rule4 := createRule(t, store, ruleGen.With(models.RuleMuts.WithTitle("Application Error Rate"))) + + tc := []struct { + name string + titleSearch string + expectedRules []*models.AlertRule + }{ + { + name: "should find rules", + titleSearch: "alert", + expectedRules: []*models.AlertRule{rule1, rule2, rule3}, + }, + { + name: "should find rule with partial match", + titleSearch: "aPpl", + expectedRules: []*models.AlertRule{rule4}, + }, + { + name: "should return no rules when no match", + titleSearch: "nonexistent", + expectedRules: []*models.AlertRule{}, + }, + { + name: "should return all rules when empty", + titleSearch: "", + expectedRules: []*models.AlertRule{rule1, rule2, rule3, rule4}, + }, + { + name: "should not find rules when word order is reversed", + titleSearch: "usage cpu", + expectedRules: []*models.AlertRule{}, + }, + { + name: "should find multiple rules matching sequential words", + titleSearch: "usage alert", + expectedRules: []*models.AlertRule{rule1, rule2}, + }, + { + name: "should handle extra whitespace between words", + titleSearch: " cpu usage ", + expectedRules: []*models.AlertRule{rule1}, + }, + { + name: "should handle multiple words with partial matches", + titleSearch: "aPp erR", + expectedRules: []*models.AlertRule{rule4}, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + query := &models.ListAlertRulesQuery{ + OrgID: orgID, + SearchTitle: tt.titleSearch, + } + 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 01e5fdaa1b9..8aafa13a8b3 100644 --- a/public/app/features/alerting/unified/api/prometheusApi.ts +++ b/public/app/features/alerting/unified/api/prometheusApi.ts @@ -42,6 +42,7 @@ type GrafanaPromRulesOptions = Omit ({ url: `api/prometheus/grafana/api/v1/rules`, params: { @@ -103,6 +105,7 @@ export const prometheusApi = alertingApi.injectEndpoints({ limit_alerts: limitAlerts, group_limit: groupLimit?.toFixed(0), group_next_token: groupNextToken, + 'search.rule_name': title, }, }), providesTags: (_result, _error, { folderUid, groupName, ruleName }) => { diff --git a/public/app/features/alerting/unified/featureToggles.ts b/public/app/features/alerting/unified/featureToggles.ts index d3b3d755249..46b202ac7f8 100644 --- a/public/app/features/alerting/unified/featureToggles.ts +++ b/public/app/features/alerting/unified/featureToggles.ts @@ -21,3 +21,5 @@ export const shouldAllowRecoveringDeletedRules = () => export const shouldAllowPermanentlyDeletingRules = () => (shouldAllowRecoveringDeletedRules() && config.featureToggles.alertingRulePermanentlyDelete) ?? false; + +export const shouldUseBackendFilters = () => config.featureToggles.alertingUIUseBackendFilters ?? false; diff --git a/public/app/features/alerting/unified/rule-list/FilterView.tsx b/public/app/features/alerting/unified/rule-list/FilterView.tsx index 5e1a651cdaf..8ac1e1a8c58 100644 --- a/public/app/features/alerting/unified/rule-list/FilterView.tsx +++ b/public/app/features/alerting/unified/rule-list/FilterView.tsx @@ -20,9 +20,10 @@ import { GrafanaRuleWithOrigin, PromRuleWithOrigin, RuleWithOrigin, + hasClientSideFilters, useFilteredRulesIteratorProvider, } from './hooks/useFilteredRulesIterator'; -import { FRONTEND_LIST_PAGE_SIZE, getApiGroupPageSize } from './paginationLimits'; +import { FRONTEND_LIST_PAGE_SIZE, getSearchApiGroupPageSize } from './paginationLimits'; interface FilterViewProps { filterState: RulesFilter; @@ -77,7 +78,10 @@ function FilterViewResults({ filterState }: FilterViewProps) { * ⚠️ Make sure we are returning / using a "iterator" and not an "iterable" since the iterable is only a blueprint * and the iterator will allow us to exhaust the iterable in a stateful way */ - const { iterable, abortController } = getFilteredRulesIterator(filterState, getApiGroupPageSize(true)); + const { iterable, abortController } = getFilteredRulesIterator( + filterState, + getSearchApiGroupPageSize(hasClientSideFilters(filterState)) + ); const rulesBatchIterator = iterable .pipe( bufferCountOrTime(FRONTEND_LIST_PAGE_SIZE, 1000), 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 729576da9f2..1dfdf735bf6 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 @@ -60,6 +60,34 @@ describe('ruleFilter', () => { expect(ruleFilter(rule, getFilter({ ruleName: 'memory' }))).toBe(false); }); + describe('backendFiltered parameter for backend filtering', () => { + it('should skip title filtering when backendFiltered is true', () => { + const rule = mockPromAlertingRule({ name: 'High CPU Usage' }); + + // When backendFiltered is true, title search should be skipped (already filtered by backend) + expect(ruleFilter(rule, getFilter({ freeFormWords: ['memory'] }), true)).toBe(true); + expect(ruleFilter(rule, getFilter({ ruleName: 'memory' }), true)).toBe(true); + }); + + it('should perform title filtering when backendFiltered is false', () => { + const rule = mockPromAlertingRule({ name: 'High CPU Usage' }); + + // When backendFiltered is false, title search should be performed client-side + expect(ruleFilter(rule, getFilter({ freeFormWords: ['cpu'] }), false)).toBe(true); + expect(ruleFilter(rule, getFilter({ freeFormWords: ['memory'] }), false)).toBe(false); + expect(ruleFilter(rule, getFilter({ ruleName: 'cpu' }), false)).toBe(true); + expect(ruleFilter(rule, getFilter({ ruleName: 'memory' }), false)).toBe(false); + }); + + it('should perform title filtering when backendFiltered is not specified (backward compatibility)', () => { + const rule = mockPromAlertingRule({ name: 'High CPU Usage' }); + + // When backendFiltered is not provided, should perform client-side filtering (default behavior) + expect(ruleFilter(rule, getFilter({ freeFormWords: ['cpu'] }))).toBe(true); + expect(ruleFilter(rule, getFilter({ freeFormWords: ['memory'] }))).toBe(false); + }); + }); + it('should filter by labels', () => { const rule = mockPromAlertingRule({ labels: { severity: 'critical', team: 'ops' }, 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 96db43ad35a..8db19b5a8dd 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/filters.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/filters.ts @@ -36,18 +36,20 @@ export function groupFilter( /** * @returns True if the rule matches the filter, false otherwise + * @param backendFiltered - If true, title search is skipped (already filtered by backend) */ -export function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter) { +export function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter, backendFiltered?: boolean) { const { name, labels = {}, health, type } = rule; - if (filterState.freeFormWords.length > 0) { + if (filterState.freeFormWords.length > 0 && !backendFiltered) { const nameMatches = fuzzyMatches(name, filterState.freeFormWords.join(' ')); if (!nameMatches) { return false; } } - if (filterState.ruleName && !fuzzyMatches(name, filterState.ruleName)) { + // Rule name search: Backend-supported for backend-filtered rules, client-side otherwise + if (filterState.ruleName && !backendFiltered && !fuzzyMatches(name, filterState.ruleName)) { return false; } 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 71ee5537465..47e6d534e47 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts @@ -46,6 +46,7 @@ interface GrafanaPromApiFilter { state?: PromAlertingRuleState[]; health?: RuleHealth[]; contactPoint?: string; + title?: string; } interface GrafanaFetchGroupsOptions extends FetchGroupsOptions { 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 new file mode 100644 index 00000000000..1f37be2be09 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.test.ts @@ -0,0 +1,96 @@ +import { config } from '@grafana/runtime'; + +import { RuleSource } from '../../search/rulesSearchParser'; +import { getFilter } from '../../utils/search'; + +import { buildTitleSearch, hasClientSideFilters } from './useFilteredRulesIterator'; + +describe('hasClientSideFilters', () => { + const originalFeatureToggles = config.featureToggles; + + beforeEach(() => { + config.featureToggles = { ...originalFeatureToggles }; + }); + + afterEach(() => { + config.featureToggles = originalFeatureToggles; + }); + + describe('when alertingUIUseBackendFilters is enabled', () => { + beforeEach(() => { + config.featureToggles.alertingUIUseBackendFilters = true; + }); + + it('should return false for title search filters (backend-supported)', () => { + expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false); + expect(hasClientSideFilters(getFilter({ ruleName: 'test' }))).toBe(false); + }); + + it('should return true for client-side only filters', () => { + expect(hasClientSideFilters(getFilter({ namespace: 'test' }))).toBe(true); + expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); + expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true); + expect(hasClientSideFilters(getFilter({ ruleSource: RuleSource.DataSource }))).toBe(true); + }); + + it('should return false when no filters are applied', () => { + expect(hasClientSideFilters(getFilter({}))).toBe(false); + }); + }); + + describe('when alertingUIUseBackendFilters is disabled', () => { + beforeEach(() => { + config.featureToggles.alertingUIUseBackendFilters = false; + }); + + it('should return true for title search filters (client-side fallback)', () => { + expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true); + expect(hasClientSideFilters(getFilter({ ruleName: 'test' }))).toBe(true); + }); + + it('should return true for client-side only filters', () => { + expect(hasClientSideFilters(getFilter({ namespace: 'test' }))).toBe(true); + expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); + expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true); + expect(hasClientSideFilters(getFilter({ ruleSource: RuleSource.DataSource }))).toBe(true); + }); + + it('should return false when no filters are applied', () => { + expect(hasClientSideFilters(getFilter({}))).toBe(false); + }); + }); + + describe('when alertingUIUseBackendFilters is undefined (default)', () => { + beforeEach(() => { + config.featureToggles.alertingUIUseBackendFilters = undefined; + }); + + it('should return true for title search filters (backward compatibility)', () => { + // Default behavior should be client-side filtering + expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true); + expect(hasClientSideFilters(getFilter({ ruleName: 'test' }))).toBe(true); + }); + }); +}); + +describe('buildTitleSearch', () => { + it('returns undefined when no search terms are provided', () => { + expect(buildTitleSearch(getFilter({}))).toBeUndefined(); + }); + + it('returns the rule name when only a rule search is provided', () => { + expect(buildTitleSearch(getFilter({ ruleName: 'cpu' }))).toBe('cpu'); + }); + + it('returns joined free-form words when only text search is provided', () => { + expect(buildTitleSearch(getFilter({ freeFormWords: ['cpu', 'memory'] }))).toBe('cpu memory'); + }); + + it('concatenates rule search and free-form text when both are provided', () => { + expect(buildTitleSearch(getFilter({ ruleName: 'cpu', freeFormWords: ['memory', 'latency'] }))).toBe( + 'cpu memory latency' + ); + }); +}); 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 b1611558604..12cbcfb7a73 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts @@ -15,6 +15,7 @@ import { PromRuleGroupDTO, } from 'app/types/unified-alerting-dto'; +import { shouldUseBackendFilters } from '../../featureToggles'; import { RuleSource, RulesFilter } from '../../search/rulesSearchParser'; import { getDataSourceByUid, @@ -79,12 +80,16 @@ export function useFilteredRulesIteratorProvider() { const normalizedFilterState = normalizeFilterState(filterState); const hasDataSourceFilterActive = Boolean(filterState.dataSourceNames.length); + const useBackendFilters = shouldUseBackendFilters(); + + const titleSearch = useBackendFilters ? buildTitleSearch(filterState) : undefined; const grafanaRulesGenerator: AsyncIterableX = from( grafanaGroupsGenerator(groupLimit, { contactPoint: filterState.contactPoint ?? undefined, health: filterState.ruleHealth ? [filterState.ruleHealth] : [], state: filterState.ruleState ? [filterState.ruleState] : [], + title: titleSearch, }) ).pipe( withAbort(abortController.signal), @@ -92,7 +97,7 @@ export function useFilteredRulesIteratorProvider() { groups .filter((group) => groupFilter(group, normalizedFilterState)) .flatMap((group) => group.rules.map((rule) => ({ group, rule }))) - .filter(({ rule }) => ruleFilter(rule, normalizedFilterState)) + .filter(({ rule }) => ruleFilter(rule, normalizedFilterState, useBackendFilters)) .map(({ group, rule }) => mapGrafanaRuleToRuleWithOrigin(group, rule)) ), catchError(() => empty()) @@ -117,7 +122,7 @@ export function useFilteredRulesIteratorProvider() { groups .filter((group) => groupFilter(group, normalizedFilterState)) .flatMap((group) => group.rules.map((rule, index) => ({ group, rule, index }))) - .filter(({ rule }) => ruleFilter(rule, normalizedFilterState)) + .filter(({ rule }) => ruleFilter(rule, normalizedFilterState, false)) .map(({ group, rule, index }) => mapRuleToRuleWithOrigin(dataSourceIdentifier, group, rule, index)) ), catchError(() => empty()) @@ -146,6 +151,48 @@ export function useFilteredRulesIteratorProvider() { return getFilteredRulesIterable; } +/** + * Determines if client-side filtering is needed for Grafana-managed rules. + */ +export function hasClientSideFilters(filterState: RulesFilter): boolean { + const useBackendFilters = shouldUseBackendFilters(); + + return ( + // When backend filters are disabled, title search needs client-side filtering + (!useBackendFilters && (filterState.freeFormWords.length > 0 || Boolean(filterState.ruleName))) || + // Client-side only filters: + Boolean(filterState.namespace) || + filterState.dataSourceNames.length > 0 || + filterState.labels.length > 0 || + Boolean(filterState.dashboardUid) || + filterState.ruleSource === RuleSource.DataSource + ); +} + +export function buildTitleSearch(filterState: RulesFilter): string | undefined { + const titleParts: string[] = []; + + const ruleName = filterState.ruleName?.trim(); + if (ruleName) { + titleParts.push(ruleName); + } + + const freeFormSegment = filterState.freeFormWords + .map((word) => word.trim()) + .filter(Boolean) + .join(' '); + + if (freeFormSegment) { + titleParts.push(freeFormSegment); + } + + if (titleParts.length === 0) { + return undefined; + } + + return titleParts.join(' '); +} + function mergeIterables(iterables: Array>): AsyncIterableX { if (iterables.length === 0) { return empty(); diff --git a/public/app/features/alerting/unified/rule-list/paginationLimits.ts b/public/app/features/alerting/unified/rule-list/paginationLimits.ts index 41c5293cbde..a8e03ac5a48 100644 --- a/public/app/features/alerting/unified/rule-list/paginationLimits.ts +++ b/public/app/features/alerting/unified/rule-list/paginationLimits.ts @@ -1,9 +1,15 @@ export const FRONTEND_LIST_PAGE_SIZE = 100; -export const FILTERED_GROUPS_API_PAGE_SIZE = 2000; +export const FILTERED_GROUPS_LARGE_API_PAGE_SIZE = 2000; +export const FILTERED_GROUPS_SMALL_API_PAGE_SIZE = 100; + export const DEFAULT_GROUPS_API_PAGE_SIZE = 40; export const FRONTED_GROUPED_PAGE_SIZE = DEFAULT_GROUPS_API_PAGE_SIZE; export function getApiGroupPageSize(hasFilters: boolean) { - return hasFilters ? FILTERED_GROUPS_API_PAGE_SIZE : DEFAULT_GROUPS_API_PAGE_SIZE; + return hasFilters ? FILTERED_GROUPS_LARGE_API_PAGE_SIZE : DEFAULT_GROUPS_API_PAGE_SIZE; +} + +export function getSearchApiGroupPageSize(hasFrontendFilters: boolean) { + return hasFrontendFilters ? FILTERED_GROUPS_LARGE_API_PAGE_SIZE : FILTERED_GROUPS_SMALL_API_PAGE_SIZE; }