From 01a4a5409a058596ff371a6e97aec5633c8c27a8 Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Mon, 24 Nov 2025 12:42:09 +0100 Subject: [PATCH] Alerting: Add alertingUIUseFullyCompatBackendFilters feature toggle (#114153) * Add alertingUIUseFullyCompatBackendFilters for fully compatible backend filters * Remove hide from admin flag --- .../src/types/featureToggles.gen.ts | 4 + pkg/services/featuremgmt/registry.go | 7 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 29 +++ .../alerting/unified/featureToggles.ts | 3 + .../rule-list/hooks/grafanaFilter.test.ts | 228 ++++++++++++++++++ .../unified/rule-list/hooks/grafanaFilter.ts | 26 +- 8 files changed, 289 insertions(+), 13 deletions(-) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 35d876518b9..b02be15bfc2 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -329,6 +329,10 @@ export interface FeatureToggles { */ alertingUIUseBackendFilters?: boolean; /** + * Enables the UI to use rules backend-side filters 100% compatible with the frontend filters + */ + alertingUIUseFullyCompatBackendFilters?: 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 ba7abae5a6d..de024e8f763 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -527,6 +527,13 @@ var ( Owner: grafanaAlertingSquad, HideFromDocs: true, }, + { + Name: "alertingUIUseFullyCompatBackendFilters", + Description: "Enables the UI to use rules backend-side filters 100% compatible with the frontend filters", + 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 cd3222235fa..076256f039d 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -73,6 +73,7 @@ cachingOptimizeSerializationMemoryUsage,experimental,@grafana/grafana-operator-e alertmanagerRemoteSecondary,experimental,@grafana/alerting-squad,false,false,false alertingProvenanceLockWrites,experimental,@grafana/alerting-squad,false,false,false alertingUIUseBackendFilters,experimental,@grafana/alerting-squad,false,false,false +alertingUIUseFullyCompatBackendFilters,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 284ce16e739..8d940cda9f1 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -243,6 +243,10 @@ const ( // Enables the UI to use certain backend-side filters FlagAlertingUIUseBackendFilters = "alertingUIUseBackendFilters" + // FlagAlertingUIUseFullyCompatBackendFilters + // Enables the UI to use rules backend-side filters 100% compatible with the frontend filters + FlagAlertingUIUseFullyCompatBackendFilters = "alertingUIUseFullyCompatBackendFilters" + // 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 e791d3bd89b..8561f371fe0 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -540,6 +540,35 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "alertingUIUseCompatibleBackendFilters", + "resourceVersion": "1763478911684", + "creationTimestamp": "2025-11-18T15:15:11Z", + "deletionTimestamp": "2025-11-18T15:17:12Z" + }, + "spec": { + "description": "Enables the UI to use rules backend-side filters 100% compatible with the frontend filters", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "hideFromAdminPage": true, + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "alertingUIUseFullyCompatBackendFilters", + "resourceVersion": "1763479032988", + "creationTimestamp": "2025-11-18T15:17:12Z" + }, + "spec": { + "description": "Enables the UI to use rules backend-side filters 100% compatible with the frontend filters", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "hideFromAdminPage": true, + "hideFromDocs": true + } + }, { "metadata": { "name": "alertingUseNewSimplifiedRoutingHashAlgorithm", diff --git a/public/app/features/alerting/unified/featureToggles.ts b/public/app/features/alerting/unified/featureToggles.ts index 46b202ac7f8..7ca1747a8c5 100644 --- a/public/app/features/alerting/unified/featureToggles.ts +++ b/public/app/features/alerting/unified/featureToggles.ts @@ -23,3 +23,6 @@ export const shouldAllowPermanentlyDeletingRules = () => (shouldAllowRecoveringDeletedRules() && config.featureToggles.alertingRulePermanentlyDelete) ?? false; export const shouldUseBackendFilters = () => config.featureToggles.alertingUIUseBackendFilters ?? false; + +export const shouldUseFullyCompatibleBackendFilters = () => + config.featureToggles.alertingUIUseFullyCompatBackendFilters ?? false; 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 3825c905a52..56be25ee248 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 @@ -484,6 +484,190 @@ describe('grafana-managed rules', () => { expect(frontendFilter2.groupMatches(group)).toBe(false); }); }); + + describe('when alertingUIUseFullyCompatBackendFilters is enabled', () => { + testWithFeatureToggles({ enable: ['alertingUIUseFullyCompatBackendFilters'] }); + + it('should populate backend filters correctly (ruleType, dashboardUid)', () => { + // Fully compatible filters should be in backend + const { backendFilter } = getGrafanaFilter( + getFilter({ + ruleType: PromRuleType.Alerting, + dashboardUid: 'dashboard-123', + freeFormWords: ['cpu'], + groupName: 'my-group', + }) + ); + + expect(backendFilter.type).toBe(PromRuleType.Alerting); + expect(backendFilter.dashboardUid).toBe('dashboard-123'); + + // Non-compatible filters should NOT be in backend + expect(backendFilter.title).toBeUndefined(); + expect(backendFilter.searchGroupName).toBeUndefined(); + + // Empty state + const { backendFilter: emptyFilter } = getGrafanaFilter(getFilter({})); + expect(emptyFilter.type).toBeUndefined(); + expect(emptyFilter.dashboardUid).toBeUndefined(); + }); + + it('should apply frontend filters correctly', () => { + const alertingRule = mockGrafanaPromAlertingRule({ + name: 'High CPU Usage', + labels: { severity: 'critical' }, + queriedDatasourceUIDs: ['datasource-uid-1'], + annotations: { [Annotation.dashboardUID]: 'dashboard-a' }, + alerts: [], + }); + const recordingRule = mockPromRecordingRule({ name: 'Test Recording' }); + + // Backend-handled filters (ruleType, dashboardUid) should skip frontend filtering + const { frontendFilter: backendHandledFilter } = getGrafanaFilter( + getFilter({ ruleType: PromRuleType.Recording, dashboardUid: 'dashboard-b' }) + ); + expect(backendHandledFilter.ruleMatches(alertingRule)).toBe(true); + expect(backendHandledFilter.ruleMatches(recordingRule)).toBe(true); + + // Frontend-handled filters (freeFormWords, ruleName) should work + const { frontendFilter: freeFormMatch } = getGrafanaFilter(getFilter({ freeFormWords: ['cpu'] })); + expect(freeFormMatch.ruleMatches(alertingRule)).toBe(true); + + const { frontendFilter: freeFormNoMatch } = getGrafanaFilter(getFilter({ freeFormWords: ['memory'] })); + expect(freeFormNoMatch.ruleMatches(alertingRule)).toBe(false); + + const { frontendFilter: ruleNameMatch } = getGrafanaFilter(getFilter({ ruleName: 'cpu' })); + expect(ruleNameMatch.ruleMatches(alertingRule)).toBe(true); + + const { frontendFilter: ruleNameNoMatch } = getGrafanaFilter(getFilter({ ruleName: 'memory' })); + expect(ruleNameNoMatch.ruleMatches(alertingRule)).toBe(false); + + // Group name filtering + const group: PromRuleGroupDTO = { + name: 'CPU Usage Alerts', + file: 'production/alerts', + rules: [], + interval: 60, + }; + + const { frontendFilter: groupMatch } = getGrafanaFilter(getFilter({ groupName: 'cpu' })); + expect(groupMatch.groupMatches(group)).toBe(true); + + const { frontendFilter: groupNoMatch } = getGrafanaFilter(getFilter({ groupName: 'memory' })); + expect(groupNoMatch.groupMatches(group)).toBe(false); + + // Always-frontend filters (labels, dataSourceNames, namespace) should work + const { frontendFilter: labelsMatch } = getGrafanaFilter(getFilter({ labels: ['severity=critical'] })); + expect(labelsMatch.ruleMatches(alertingRule)).toBe(true); + + const { frontendFilter: labelsNoMatch } = getGrafanaFilter(getFilter({ labels: ['severity=warning'] })); + expect(labelsNoMatch.ruleMatches(alertingRule)).toBe(false); + + const { frontendFilter: dsMatch } = getGrafanaFilter(getFilter({ dataSourceNames: ['prometheus'] })); + expect(dsMatch.ruleMatches(alertingRule)).toBe(true); + + const { frontendFilter: dsNoMatch } = getGrafanaFilter(getFilter({ dataSourceNames: ['loki'] })); + expect(dsNoMatch.ruleMatches(alertingRule)).toBe(false); + + const { frontendFilter: nsMatch } = getGrafanaFilter(getFilter({ namespace: 'production' })); + expect(nsMatch.groupMatches(group)).toBe(true); + + const { frontendFilter: nsNoMatch } = getGrafanaFilter(getFilter({ namespace: 'staging' })); + expect(nsNoMatch.groupMatches(group)).toBe(false); + }); + }); + + describe('when both alertingUIUseBackendFilters and alertingUIUseFullyCompatBackendFilters are enabled', () => { + testWithFeatureToggles({ enable: ['alertingUIUseBackendFilters', 'alertingUIUseFullyCompatBackendFilters'] }); + + it('should include all backend filters (title, ruleType, dashboardUid, searchGroupName)', () => { + const { backendFilter } = getGrafanaFilter( + getFilter({ + freeFormWords: ['cpu'], + ruleName: 'alert', + ruleType: PromRuleType.Alerting, + dashboardUid: 'dashboard-123', + groupName: 'my-group', + }) + ); + + expect(backendFilter.title).toBe('alert cpu'); + expect(backendFilter.type).toBe(PromRuleType.Alerting); + expect(backendFilter.dashboardUid).toBe('dashboard-123'); + expect(backendFilter.searchGroupName).toBe('my-group'); + }); + + it('should skip all backend-handled filters on frontend', () => { + const alertingRule = mockGrafanaPromAlertingRule({ + name: 'High CPU Usage', + annotations: { [Annotation.dashboardUID]: 'dashboard-a' }, + }); + const recordingRule = mockPromRecordingRule({ name: 'Test Recording' }); + + const { frontendFilter } = getGrafanaFilter( + getFilter({ + freeFormWords: ['memory'], + ruleName: 'memory', + ruleType: PromRuleType.Recording, + dashboardUid: 'dashboard-b', + }) + ); + + // All these filters are handled by backend, so frontend should return true + expect(frontendFilter.ruleMatches(alertingRule)).toBe(true); + expect(frontendFilter.ruleMatches(recordingRule)).toBe(true); + }); + + it('should skip groupName filtering on frontend', () => { + 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 always-frontend filters (labels, dataSourceNames, namespace)', () => { + const rule = mockGrafanaPromAlertingRule({ + name: 'High CPU Usage', + labels: { severity: 'critical' }, + queriedDatasourceUIDs: ['datasource-uid-1'], + 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); + + // DataSourceNames filter should still work + const { frontendFilter: dsFilter } = getGrafanaFilter(getFilter({ dataSourceNames: ['prometheus'] })); + expect(dsFilter.ruleMatches(rule)).toBe(true); + + const { frontendFilter: dsFilter2 } = getGrafanaFilter(getFilter({ dataSourceNames: ['loki'] })); + expect(dsFilter2.ruleMatches(rule)).toBe(false); + + // Namespace filter should still work + const group: PromRuleGroupDTO = { + name: 'Test Group', + file: 'production/alerts', + rules: [], + interval: 60, + }; + + const { frontendFilter: nsFilter } = getGrafanaFilter(getFilter({ namespace: 'production' })); + expect(nsFilter.groupMatches(group)).toBe(true); + + const { frontendFilter: nsFilter2 } = getGrafanaFilter(getFilter({ namespace: 'staging' })); + expect(nsFilter2.groupMatches(group)).toBe(false); + }); + }); }); describe('hasClientSideFilters', () => { @@ -560,5 +744,49 @@ describe('grafana-managed rules', () => { expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); }); }); + + describe('when alertingUIUseFullyCompatBackendFilters is enabled', () => { + testWithFeatureToggles({ enable: ['alertingUIUseFullyCompatBackendFilters'] }); + + it('should return correct values for all filter types', () => { + // Should return false for: empty, backend-handled (ruleType, dashboardUid), and backend-only filters + expect(hasClientSideFilters(getFilter({}))).toBe(false); + expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); + expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); + expect(hasClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); + expect(hasClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); + expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); + + // Should return true for: frontend-handled filters + expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true); + expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true); + expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true); + expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); + expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); + expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + }); + }); + + describe('when both alertingUIUseBackendFilters and alertingUIUseFullyCompatBackendFilters are enabled', () => { + testWithFeatureToggles({ enable: ['alertingUIUseBackendFilters', 'alertingUIUseFullyCompatBackendFilters'] }); + + it('should return correct values for all filter types', () => { + // Should return false for: empty, all backend-handled filters, and backend-only filters + expect(hasClientSideFilters(getFilter({}))).toBe(false); + expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false); + expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(false); + expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); + expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); + expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(false); + expect(hasClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); + expect(hasClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); + expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); + + // Should return true for: always-frontend filters only + expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); + expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); + expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + }); + }); }); }); 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 d7d224047d7..5b5ddd81f9b 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts @@ -1,7 +1,7 @@ import { PromRuleDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto'; import { GrafanaPromRulesOptions } from '../../api/prometheusApi'; -import { shouldUseBackendFilters } from '../../featureToggles'; +import { shouldUseBackendFilters, shouldUseFullyCompatibleBackendFilters } from '../../featureToggles'; import { RulesFilter } from '../../search/rulesSearchParser'; import { buildTitleSearch, normalizeFilterState } from './filterNormalization'; @@ -57,7 +57,8 @@ export function hasClientSideFilters(filterState: RulesFilter): boolean { */ export function getGrafanaFilter(filterState: RulesFilter) { const normalizedFilterState = normalizeFilterState(filterState); - const useBackendFilters = shouldUseBackendFilters(); + + const { ruleFilterConfig, groupFilterConfig } = buildGrafanaFilterConfigs(); // Build title search for backend filtering const titleSearch = buildTitleSearch(normalizedFilterState); @@ -66,20 +67,18 @@ export function getGrafanaFilter(filterState: RulesFilter) { state: normalizedFilterState.ruleState ? [normalizedFilterState.ruleState] : [], health: normalizedFilterState.ruleHealth ? [normalizedFilterState.ruleHealth] : [], contactPoint: normalizedFilterState.contactPoint ?? undefined, - title: useBackendFilters ? titleSearch : undefined, - type: useBackendFilters ? normalizedFilterState.ruleType : undefined, - dashboardUid: useBackendFilters ? normalizedFilterState.dashboardUid : undefined, - searchGroupName: useBackendFilters ? normalizedFilterState.groupName : undefined, + // If FE filter is defined, don't include the backend filter + title: ruleFilterConfig.ruleName ? undefined : titleSearch, + type: ruleFilterConfig.ruleType ? undefined : normalizedFilterState.ruleType, + dashboardUid: ruleFilterConfig.dashboardUid ? undefined : normalizedFilterState.dashboardUid, + searchGroupName: groupFilterConfig.groupName ? undefined : normalizedFilterState.groupName, }; - const { ruleFilterConfig: grafanaFilterProcessingConfig, groupFilterConfig: grafanaGroupFilterConfig } = - buildGrafanaFilterConfigs(); - return { backendFilter, frontendFilter: { - groupMatches: (group: PromRuleGroupDTO) => groupMatches(group, normalizedFilterState, grafanaGroupFilterConfig), - ruleMatches: (rule: PromRuleDTO) => ruleMatches(rule, normalizedFilterState, grafanaFilterProcessingConfig), + groupMatches: (group: PromRuleGroupDTO) => groupMatches(group, normalizedFilterState, groupFilterConfig), + ruleMatches: (rule: PromRuleDTO) => ruleMatches(rule, normalizedFilterState, ruleFilterConfig), }, }; } @@ -93,17 +92,18 @@ export function getGrafanaFilter(filterState: RulesFilter) { */ function buildGrafanaFilterConfigs() { const useBackendFilters = shouldUseBackendFilters(); + const useFullyCompatibleBackendFilters = shouldUseFullyCompatibleBackendFilters(); const ruleFilterConfig: RuleFilterConfig = { // When backend filtering is enabled, these filters are handled by the backend freeFormWords: useBackendFilters ? null : freeFormFilter, ruleName: useBackendFilters ? null : ruleNameFilter, ruleState: null, - ruleType: useBackendFilters ? null : ruleTypeFilter, + ruleType: useBackendFilters || useFullyCompatibleBackendFilters ? null : ruleTypeFilter, dataSourceNames: dataSourceNamesFilter, labels: labelsFilter, ruleHealth: null, - dashboardUid: useBackendFilters ? null : dashboardUidFilter, + dashboardUid: useBackendFilters || useFullyCompatibleBackendFilters ? null : dashboardUidFilter, plugins: pluginsFilter, contactPoint: null, };