diff --git a/public/app/features/alerting/unified/api/prometheusApi.ts b/public/app/features/alerting/unified/api/prometheusApi.ts index a64f87296f2..a3d9eead62e 100644 --- a/public/app/features/alerting/unified/api/prometheusApi.ts +++ b/public/app/features/alerting/unified/api/prometheusApi.ts @@ -34,7 +34,7 @@ type PromRulesOptions = WithNotificationOptions<{ groupNextToken?: string; }>; -type GrafanaPromRulesOptions = Omit & { +export type GrafanaPromRulesOptions = Omit & { folderUid?: string; dashboardUid?: string; panelId?: number; diff --git a/public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx b/public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx index 49477571ce3..2e2c2fda607 100644 --- a/public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx @@ -14,7 +14,7 @@ import { ListGroup } from './components/ListGroup'; import { ListSection } from './components/ListSection'; import { LoadMoreButton } from './components/LoadMoreButton'; import { NoRulesFound } from './components/NoRulesFound'; -import { groupFilter as groupFilterFn } from './hooks/filters'; +import { getDatasourceFilter } from './hooks/filters'; import { toIndividualRuleGroups, usePrometheusGroupsGenerator } from './hooks/prometheusGroupsGenerator'; import { useLazyLoadPrometheusGroups } from './hooks/useLazyLoadPrometheusGroups'; import { FRONTED_GROUPED_PAGE_SIZE, getApiGroupPageSize } from './paginationLimits'; @@ -68,14 +68,24 @@ function PaginatedGroupsLoader({ rulesSourceIdentifier, application, groupFilter }; }, []); - const filterFn = useMemo( - () => (group: PromRuleGroupDTO) => - groupFilterFn(group, { - namespace: namespaceFilter, - groupName: groupFilter, - }), - [namespaceFilter, groupFilter] - ); + const filterFn = useMemo(() => { + const { groupMatches } = getDatasourceFilter({ + namespace: namespaceFilter, + groupName: groupFilter, + freeFormWords: [], + ruleName: '', + labels: [], + ruleType: undefined, + ruleState: undefined, + ruleHealth: undefined, + dashboardUid: undefined, + dataSourceNames: [], + plugins: undefined, + contactPoint: undefined, + ruleSource: undefined, + }); + return (group: PromRuleGroupDTO) => groupMatches(group); + }, [namespaceFilter, groupFilter]); const { isLoading, groups, hasMoreGroups, fetchMoreGroups, error } = useLazyLoadPrometheusGroups( groupsGenerator.current, diff --git a/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx b/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx index 296535b881a..dc8875f1cf8 100644 --- a/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx @@ -17,7 +17,7 @@ import { ListGroup } from './components/ListGroup'; import { ListSection } from './components/ListSection'; import { LoadMoreButton } from './components/LoadMoreButton'; import { NoRulesFound } from './components/NoRulesFound'; -import { groupFilter as groupFilterFn } from './hooks/filters'; +import { getGrafanaFilter } from './hooks/filters'; import { toIndividualRuleGroups, useGrafanaGroupsGenerator } from './hooks/prometheusGroupsGenerator'; import { useLazyLoadPrometheusGroups } from './hooks/useLazyLoadPrometheusGroups'; import { FRONTED_GROUPED_PAGE_SIZE, getApiGroupPageSize } from './paginationLimits'; @@ -57,14 +57,24 @@ function PaginatedGroupsLoader({ groupFilter, namespaceFilter }: LoaderProps) { }; }, []); - const filterFn = useMemo( - () => (group: PromRuleGroupDTO) => - groupFilterFn(group, { - namespace: namespaceFilter, - groupName: groupFilter, - }), - [namespaceFilter, groupFilter] - ); + const filterFn = useMemo(() => { + const { frontendFilter } = getGrafanaFilter({ + namespace: namespaceFilter, + groupName: groupFilter, + freeFormWords: [], + ruleName: '', + labels: [], + ruleType: undefined, + ruleState: undefined, + ruleHealth: undefined, + dashboardUid: undefined, + dataSourceNames: [], + plugins: undefined, + contactPoint: undefined, + ruleSource: undefined, + }); + return (group: PromRuleGroupDTO) => frontendFilter.groupMatches(group); + }, [namespaceFilter, groupFilter]); const { isLoading, groups, hasMoreGroups, fetchMoreGroups, error } = useLazyLoadPrometheusGroups( 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 1dfdf735bf6..dc6e8670db4 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 @@ -1,302 +1,744 @@ +import { testWithFeatureToggles } from 'test/test-utils'; + import { PromAlertingRuleState, PromRuleGroupDTO, PromRuleType } from 'app/types/unified-alerting-dto'; import { mockGrafanaPromAlertingRule, mockPromAlertingRule, mockPromRecordingRule } from '../../mocks'; import { RuleHealth } from '../../search/rulesSearchParser'; import { Annotation } from '../../utils/constants'; -import * as datasourceUtils from '../../utils/datasource'; +import { getDatasourceAPIUid } from '../../utils/datasource'; import { getFilter } from '../../utils/search'; -import { groupFilter, ruleFilter } from './filters'; +import { getDatasourceFilter, getGrafanaFilter } from './filters'; -describe('groupFilter', () => { - it('should filter by namespace (file path)', () => { - const group: PromRuleGroupDTO = { - name: 'Test Group', - file: 'production/alerts', - rules: [], - interval: 60, - }; +jest.mock('../../utils/datasource'); - expect(groupFilter(group, getFilter({ namespace: 'production' }))).toBe(true); - expect(groupFilter(group, getFilter({ namespace: 'staging' }))).toBe(false); +const getDatasourceAPIUidMock = jest.mocked(getDatasourceAPIUid); + +getDatasourceAPIUidMock.mockImplementation((ruleSourceName) => { + if (ruleSourceName === 'prometheus') { + return 'datasource-uid-1'; + } + if (ruleSourceName === 'loki') { + return 'datasource-uid-3'; + } + throw new Error(`Unknown datasource name: ${ruleSourceName}`); +}); + +describe('datasource-managed rules', () => { + describe('groupFilter', () => { + it('should filter by namespace (file path)', () => { + const group: PromRuleGroupDTO = { + name: 'Test Group', + file: 'production/alerts', + rules: [], + interval: 60, + }; + + const { groupMatches } = getDatasourceFilter(getFilter({ namespace: 'production' })); + expect(groupMatches(group)).toBe(true); + + const { groupMatches: groupMatches2 } = getDatasourceFilter(getFilter({ namespace: 'staging' })); + expect(groupMatches2(group)).toBe(false); + }); + + it('should filter by group name', () => { + const group: PromRuleGroupDTO = { + name: 'CPU Usage Alerts', + file: 'production/alerts', + rules: [], + interval: 60, + }; + + const { groupMatches } = getDatasourceFilter(getFilter({ groupName: 'cpu' })); + expect(groupMatches(group)).toBe(true); + + const { groupMatches: groupMatches2 } = getDatasourceFilter(getFilter({ groupName: 'memory' })); + expect(groupMatches2(group)).toBe(false); + }); + + it('should return true when no filters are applied', () => { + const group: PromRuleGroupDTO = { + name: 'Test Group', + file: 'production/alerts', + rules: [], + interval: 60, + }; + + const { groupMatches } = getDatasourceFilter(getFilter({})); + expect(groupMatches(group)).toBe(true); + }); }); - it('should filter by group name', () => { - const group: PromRuleGroupDTO = { - name: 'CPU Usage Alerts', - file: 'production/alerts', - rules: [], - interval: 60, - }; + describe('ruleFilter', () => { + it('should filter by free form words in rule name', () => { + const rule = mockPromAlertingRule({ name: 'High CPU Usage' }); - expect(groupFilter(group, getFilter({ groupName: 'cpu' }))).toBe(true); - expect(groupFilter(group, getFilter({ groupName: 'memory' }))).toBe(false); - }); + const { ruleMatches } = getDatasourceFilter(getFilter({ freeFormWords: ['cpu'] })); + expect(ruleMatches(rule)).toBe(true); - it('should return true when no filters are applied', () => { - const group: PromRuleGroupDTO = { - name: 'Test Group', - file: 'production/alerts', - rules: [], - interval: 60, - }; + const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ freeFormWords: ['memory'] })); + expect(ruleMatches2(rule)).toBe(false); + }); - expect(groupFilter(group, getFilter({}))).toBe(true); + it('should filter by rule name', () => { + const rule = mockPromAlertingRule({ name: 'High CPU Usage' }); + + const { ruleMatches } = getDatasourceFilter(getFilter({ ruleName: 'cpu' })); + expect(ruleMatches(rule)).toBe(true); + + const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ ruleName: 'memory' })); + expect(ruleMatches2(rule)).toBe(false); + }); + + it('should filter by labels', () => { + const rule = mockPromAlertingRule({ + labels: { severity: 'critical', team: 'ops' }, + alerts: [], + }); + + const { ruleMatches } = getDatasourceFilter(getFilter({ labels: ['severity=critical'] })); + expect(ruleMatches(rule)).toBe(true); + + const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ labels: ['severity=warning'] })); + expect(ruleMatches2(rule)).toBe(false); + + const { ruleMatches: ruleMatches3 } = getDatasourceFilter(getFilter({ labels: ['team=ops'] })); + expect(ruleMatches3(rule)).toBe(true); + }); + + it('should filter by alert instance labels', () => { + const rule = mockPromAlertingRule({ + labels: { severity: 'critical' }, + alerts: [ + { + labels: { instance: 'server-1', env: 'production' }, + state: PromAlertingRuleState.Firing, + value: '100', + activeAt: '', + annotations: {}, + }, + ], + }); + + const { ruleMatches } = getDatasourceFilter(getFilter({ labels: ['instance=server-1'] })); + expect(ruleMatches(rule)).toBe(true); + + const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ labels: ['env=production'] })); + expect(ruleMatches2(rule)).toBe(true); + + const { ruleMatches: ruleMatches3 } = getDatasourceFilter(getFilter({ labels: ['instance=server-2'] })); + expect(ruleMatches3(rule)).toBe(false); + }); + + it('should filter by rule type', () => { + const alertingRule = mockPromAlertingRule({ name: 'Test Alert' }); + const recordingRule = mockPromRecordingRule({ name: 'Test Recording' }); + + const { ruleMatches } = getDatasourceFilter(getFilter({ ruleType: PromRuleType.Alerting })); + expect(ruleMatches(alertingRule)).toBe(true); + expect(ruleMatches(recordingRule)).toBe(false); + + const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ ruleType: PromRuleType.Recording })); + expect(ruleMatches2(alertingRule)).toBe(false); + expect(ruleMatches2(recordingRule)).toBe(true); + }); + + it('should filter by rule state', () => { + const firingRule = mockPromAlertingRule({ + name: 'Firing Alert', + state: PromAlertingRuleState.Firing, + }); + + const pendingRule = mockPromAlertingRule({ + name: 'Pending Alert', + state: PromAlertingRuleState.Pending, + }); + + const { ruleMatches } = getDatasourceFilter(getFilter({ ruleState: PromAlertingRuleState.Firing })); + expect(ruleMatches(firingRule)).toBe(true); + expect(ruleMatches(pendingRule)).toBe(false); + + const { ruleMatches: ruleMatches2 } = getDatasourceFilter( + getFilter({ ruleState: PromAlertingRuleState.Pending }) + ); + expect(ruleMatches2(firingRule)).toBe(false); + expect(ruleMatches2(pendingRule)).toBe(true); + }); + + it('should filter out recording rules when filtering by rule state', () => { + const recordingRule = mockPromRecordingRule({ + name: 'Recording Rule', + }); + + // Recording rules should always be filtered out when any rule state filter is applied as they don't have a state + const { ruleMatches } = getDatasourceFilter(getFilter({ ruleState: PromAlertingRuleState.Firing })); + expect(ruleMatches(recordingRule)).toBe(false); + + const { ruleMatches: ruleMatches2 } = getDatasourceFilter( + getFilter({ ruleState: PromAlertingRuleState.Pending }) + ); + expect(ruleMatches2(recordingRule)).toBe(false); + + const { ruleMatches: ruleMatches3 } = getDatasourceFilter( + getFilter({ ruleState: PromAlertingRuleState.Inactive }) + ); + expect(ruleMatches3(recordingRule)).toBe(false); + }); + + it('should filter by rule health', () => { + const healthyRule = mockPromAlertingRule({ + name: 'Healthy Rule', + health: RuleHealth.Ok, + }); + + const errorRule = mockPromAlertingRule({ + name: 'Error Rule', + health: RuleHealth.Error, + }); + + const prometheusErrorRule = mockPromAlertingRule({ + name: 'Error Rule', + health: 'err', + }); + + const { ruleMatches } = getDatasourceFilter(getFilter({ ruleHealth: RuleHealth.Ok })); + expect(ruleMatches(healthyRule)).toBe(true); + expect(ruleMatches(errorRule)).toBe(false); + + const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ ruleHealth: RuleHealth.Error })); + expect(ruleMatches2(healthyRule)).toBe(false); + expect(ruleMatches2(errorRule)).toBe(true); + expect(ruleMatches2(prometheusErrorRule)).toBe(true); + }); + + it('should normalize health values when filtering', () => { + // Legacy Prometheus health value 'err' should be normalized to 'error' + const legacyErrorRule = mockPromAlertingRule({ + name: 'Legacy Error Rule', + health: 'err', + }); + + // When filtering for 'error', it should match rules with health 'err' (legacy) or 'error' + const { ruleMatches } = getDatasourceFilter(getFilter({ ruleHealth: RuleHealth.Error })); + expect(ruleMatches(legacyErrorRule)).toBe(true); + }); + + it('should filter by dashboard UID', () => { + const ruleDashboardA = mockPromAlertingRule({ + name: 'Dashboard A Rule', + annotations: { [Annotation.dashboardUID]: 'dashboard-a' }, + }); + + const ruleDashboardB = mockPromAlertingRule({ + name: 'Dashboard B Rule', + annotations: { [Annotation.dashboardUID]: 'dashboard-b' }, + }); + + const { ruleMatches } = getDatasourceFilter(getFilter({ dashboardUid: 'dashboard-a' })); + expect(ruleMatches(ruleDashboardA)).toBe(true); + expect(ruleMatches(ruleDashboardB)).toBe(false); + + const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ dashboardUid: 'dashboard-b' })); + expect(ruleMatches2(ruleDashboardA)).toBe(false); + expect(ruleMatches2(ruleDashboardB)).toBe(true); + }); + + it('should filter out recording rules when filtering by dashboard UID', () => { + const recordingRule = mockPromRecordingRule({ + name: 'Recording Rule', + // Recording rules cannot have dashboard UIDs because they don't have annotations + }); + + // Dashboard UID filter should filter out recording rules + const { ruleMatches } = getDatasourceFilter(getFilter({ dashboardUid: 'any-dashboard' })); + expect(ruleMatches(recordingRule)).toBe(false); + }); + + describe('dataSourceNames filter', () => { + it('should match rules that use the filtered datasource', () => { + // Create a Grafana rule with matching datasource + const ruleWithMatchingDatasource = mockGrafanaPromAlertingRule({ + queriedDatasourceUIDs: ['datasource-uid-1'], + }); + + // 'prometheus' resolves to 'datasource-uid-1' which is in the rule + const { ruleMatches } = getDatasourceFilter(getFilter({ dataSourceNames: ['prometheus'] })); + expect(ruleMatches(ruleWithMatchingDatasource)).toBe(true); + }); + + it("should filter out rules that don't use the filtered datasource", () => { + // Create a Grafana rule without the target datasource + const ruleWithoutMatchingDatasource = mockGrafanaPromAlertingRule({ + queriedDatasourceUIDs: ['datasource-uid-1', 'datasource-uid-2'], + }); + + // 'loki' resolves to 'datasource-uid-3' which is not in the rule + const { ruleMatches } = getDatasourceFilter(getFilter({ dataSourceNames: ['loki'] })); + expect(ruleMatches(ruleWithoutMatchingDatasource)).toBe(false); + }); + + it('should return false when there is an error parsing the query', () => { + const ruleWithInvalidQuery = mockGrafanaPromAlertingRule({ + query: 'not-valid-json', + }); + + const { ruleMatches } = getDatasourceFilter(getFilter({ dataSourceNames: ['prometheus'] })); + expect(ruleMatches(ruleWithInvalidQuery)).toBe(false); + }); + }); + + it('should combine multiple filters with AND logic', () => { + const rule = mockPromAlertingRule({ + name: 'High CPU Usage Production', + labels: { severity: 'critical', environment: 'production' }, + state: PromAlertingRuleState.Firing, + health: RuleHealth.Ok, + }); + + const filter = getFilter({ + ruleName: 'cpu', + labels: ['severity=critical', 'environment=production'], + ruleState: PromAlertingRuleState.Firing, + ruleHealth: RuleHealth.Ok, + }); + const { ruleMatches } = getDatasourceFilter(filter); + expect(ruleMatches(rule)).toBe(true); + }); + + it('should return false if any filter does not match', () => { + const rule = mockPromAlertingRule({ + name: 'High CPU Usage Production', + labels: { severity: 'critical', environment: 'production' }, + state: PromAlertingRuleState.Firing, + health: RuleHealth.Ok, + alerts: [], + }); + + const filter = getFilter({ + ruleName: 'cpu', + labels: ['severity=warning'], + ruleState: PromAlertingRuleState.Firing, + ruleHealth: RuleHealth.Ok, + }); + const { ruleMatches } = getDatasourceFilter(filter); + expect(ruleMatches(rule)).toBe(false); + }); }); }); -describe('ruleFilter', () => { - it('should filter by free form words in rule name', () => { - const rule = mockPromAlertingRule({ name: 'High CPU Usage' }); +describe('grafana-managed rules', () => { + describe('groupFilter', () => { + it('should filter by namespace (file path)', () => { + const group: PromRuleGroupDTO = { + name: 'Test Group', + file: 'production/alerts', + rules: [], + interval: 60, + }; - expect(ruleFilter(rule, getFilter({ freeFormWords: ['cpu'] }))).toBe(true); - expect(ruleFilter(rule, getFilter({ freeFormWords: ['memory'] }))).toBe(false); - }); + const { frontendFilter } = getGrafanaFilter(getFilter({ namespace: 'production' })); + expect(frontendFilter.groupMatches(group)).toBe(true); - it('should filter by rule name', () => { - const rule = mockPromAlertingRule({ name: 'High CPU Usage' }); - - expect(ruleFilter(rule, getFilter({ ruleName: 'cpu' }))).toBe(true); - 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); + const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ namespace: 'staging' })); + expect(frontendFilter2.groupMatches(group)).toBe(false); }); - it('should perform title filtering when backendFiltered is false', () => { - const rule = mockPromAlertingRule({ name: 'High CPU Usage' }); + it('should filter by group name', () => { + const group: PromRuleGroupDTO = { + name: 'CPU Usage Alerts', + file: 'production/alerts', + rules: [], + interval: 60, + }; - // 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); + 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); }); - it('should perform title filtering when backendFiltered is not specified (backward compatibility)', () => { - const rule = mockPromAlertingRule({ name: 'High CPU Usage' }); + it('should return true when no filters are applied', () => { + const group: PromRuleGroupDTO = { + name: 'Test Group', + file: 'production/alerts', + rules: [], + interval: 60, + }; - // 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); + const { frontendFilter } = getGrafanaFilter(getFilter({})); + expect(frontendFilter.groupMatches(group)).toBe(true); }); }); - it('should filter by labels', () => { - const rule = mockPromAlertingRule({ - labels: { severity: 'critical', team: 'ops' }, - alerts: [], + describe('ruleFilter - frontend filters', () => { + it('should filter by free form words in rule name', () => { + const rule = mockGrafanaPromAlertingRule({ name: 'High CPU Usage' }); + + const { frontendFilter } = getGrafanaFilter(getFilter({ freeFormWords: ['cpu'] })); + expect(frontendFilter.ruleMatches(rule)).toBe(true); + + const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ freeFormWords: ['memory'] })); + expect(frontendFilter2.ruleMatches(rule)).toBe(false); }); - expect(ruleFilter(rule, getFilter({ labels: ['severity=critical'] }))).toBe(true); - expect(ruleFilter(rule, getFilter({ labels: ['severity=warning'] }))).toBe(false); - expect(ruleFilter(rule, getFilter({ labels: ['team=ops'] }))).toBe(true); + it('should filter by rule name', () => { + const rule = mockGrafanaPromAlertingRule({ name: 'High CPU Usage' }); + + const { frontendFilter } = getGrafanaFilter(getFilter({ ruleName: 'cpu' })); + expect(frontendFilter.ruleMatches(rule)).toBe(true); + + const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ ruleName: 'memory' })); + expect(frontendFilter2.ruleMatches(rule)).toBe(false); + }); + + it('should filter by labels', () => { + const rule = mockGrafanaPromAlertingRule({ + labels: { severity: 'critical', team: 'ops' }, + alerts: [], + }); + + const { frontendFilter } = getGrafanaFilter(getFilter({ labels: ['severity=critical'] })); + expect(frontendFilter.ruleMatches(rule)).toBe(true); + + const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ labels: ['severity=warning'] })); + expect(frontendFilter2.ruleMatches(rule)).toBe(false); + + const { frontendFilter: frontendFilter3 } = getGrafanaFilter(getFilter({ labels: ['team=ops'] })); + expect(frontendFilter3.ruleMatches(rule)).toBe(true); + }); + + it('should filter by rule type', () => { + const alertingRule = mockGrafanaPromAlertingRule({ name: 'Test Alert' }); + const recordingRule = mockPromRecordingRule({ name: 'Test Recording' }); + + const { frontendFilter } = getGrafanaFilter(getFilter({ ruleType: PromRuleType.Alerting })); + expect(frontendFilter.ruleMatches(alertingRule)).toBe(true); + expect(frontendFilter.ruleMatches(recordingRule)).toBe(false); + + const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ ruleType: PromRuleType.Recording })); + expect(frontendFilter2.ruleMatches(alertingRule)).toBe(false); + expect(frontendFilter2.ruleMatches(recordingRule)).toBe(true); + }); + + it('should filter by dashboard UID', () => { + const ruleDashboardA = mockGrafanaPromAlertingRule({ + name: 'Dashboard A Rule', + annotations: { [Annotation.dashboardUID]: 'dashboard-a' }, + }); + + const ruleDashboardB = mockGrafanaPromAlertingRule({ + name: 'Dashboard B Rule', + annotations: { [Annotation.dashboardUID]: 'dashboard-b' }, + }); + + const { frontendFilter } = getGrafanaFilter(getFilter({ dashboardUid: 'dashboard-a' })); + expect(frontendFilter.ruleMatches(ruleDashboardA)).toBe(true); + expect(frontendFilter.ruleMatches(ruleDashboardB)).toBe(false); + + const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ dashboardUid: 'dashboard-b' })); + expect(frontendFilter2.ruleMatches(ruleDashboardA)).toBe(false); + expect(frontendFilter2.ruleMatches(ruleDashboardB)).toBe(true); + }); + + describe('dataSourceNames filter', () => { + it('should match rules that use the filtered datasource', () => { + const ruleWithMatchingDatasource = mockGrafanaPromAlertingRule({ + queriedDatasourceUIDs: ['datasource-uid-1'], + }); + + const { frontendFilter } = getGrafanaFilter(getFilter({ dataSourceNames: ['prometheus'] })); + expect(frontendFilter.ruleMatches(ruleWithMatchingDatasource)).toBe(true); + }); + + it("should filter out rules that don't use the filtered datasource", () => { + const ruleWithoutMatchingDatasource = mockGrafanaPromAlertingRule({ + queriedDatasourceUIDs: ['datasource-uid-1', 'datasource-uid-2'], + }); + + const { frontendFilter } = getGrafanaFilter(getFilter({ dataSourceNames: ['loki'] })); + expect(frontendFilter.ruleMatches(ruleWithoutMatchingDatasource)).toBe(false); + }); + }); }); - it('should filter by alert instance labels', () => { - const rule = mockPromAlertingRule({ - labels: { severity: 'critical' }, - alerts: [ - { - labels: { instance: 'server-1', env: 'production' }, - state: PromAlertingRuleState.Firing, - value: '100', - activeAt: '', - annotations: {}, + describe('ruleFilter - backend filters (should NOT be applied in frontend)', () => { + it('should NOT filter by rule state in frontend (returns true regardless)', () => { + const firingRule = mockGrafanaPromAlertingRule({ + name: 'Firing Alert', + state: PromAlertingRuleState.Firing, + }); + + const pendingRule = mockGrafanaPromAlertingRule({ + name: 'Pending Alert', + state: PromAlertingRuleState.Pending, + }); + + // Frontend filter should return true for all states since backend handles this + const { frontendFilter } = getGrafanaFilter(getFilter({ ruleState: PromAlertingRuleState.Firing })); + expect(frontendFilter.ruleMatches(firingRule)).toBe(true); + expect(frontendFilter.ruleMatches(pendingRule)).toBe(true); + + const { frontendFilter: frontendFilter2 } = getGrafanaFilter( + getFilter({ ruleState: PromAlertingRuleState.Pending }) + ); + expect(frontendFilter2.ruleMatches(firingRule)).toBe(true); + expect(frontendFilter2.ruleMatches(pendingRule)).toBe(true); + }); + + it('should NOT filter by rule health in frontend (returns true regardless)', () => { + const healthyRule = mockGrafanaPromAlertingRule({ + name: 'Healthy Rule', + health: RuleHealth.Ok, + }); + + const errorRule = mockGrafanaPromAlertingRule({ + name: 'Error Rule', + health: RuleHealth.Error, + }); + + // Frontend filter should return true for all health states since backend handles this + const { frontendFilter } = getGrafanaFilter(getFilter({ ruleHealth: RuleHealth.Ok })); + expect(frontendFilter.ruleMatches(healthyRule)).toBe(true); + expect(frontendFilter.ruleMatches(errorRule)).toBe(true); + + const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ ruleHealth: RuleHealth.Error })); + expect(frontendFilter2.ruleMatches(healthyRule)).toBe(true); + expect(frontendFilter2.ruleMatches(errorRule)).toBe(true); + }); + + it('should NOT filter by contact point in frontend (returns true regardless)', () => { + const ruleWithContactPoint = mockGrafanaPromAlertingRule({ + name: 'Rule with Contact Point', + notificationSettings: { + receiver: 'contact-point-1', }, - ], - }); + }); - expect(ruleFilter(rule, getFilter({ labels: ['instance=server-1'] }))).toBe(true); - expect(ruleFilter(rule, getFilter({ labels: ['env=production'] }))).toBe(true); - expect(ruleFilter(rule, getFilter({ labels: ['instance=server-2'] }))).toBe(false); + const ruleWithDifferentContactPoint = mockGrafanaPromAlertingRule({ + name: 'Rule with Different Contact Point', + notificationSettings: { + receiver: 'contact-point-2', + }, + }); + + // Frontend filter should return true for all contact points since backend handles this + const { frontendFilter } = getGrafanaFilter(getFilter({ contactPoint: 'contact-point-1' })); + expect(frontendFilter.ruleMatches(ruleWithContactPoint)).toBe(true); + expect(frontendFilter.ruleMatches(ruleWithDifferentContactPoint)).toBe(true); + + const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ contactPoint: 'contact-point-2' })); + expect(frontendFilter2.ruleMatches(ruleWithContactPoint)).toBe(true); + expect(frontendFilter2.ruleMatches(ruleWithDifferentContactPoint)).toBe(true); + }); }); - it('should filter by rule type', () => { - const alertingRule = mockPromAlertingRule({ name: 'Test Alert' }); - const recordingRule = mockPromRecordingRule({ name: 'Test Recording' }); + describe('backendFilter', () => { + it('should include ruleState in backend filter', () => { + const { backendFilter } = getGrafanaFilter(getFilter({ ruleState: PromAlertingRuleState.Firing })); - expect(ruleFilter(alertingRule, getFilter({ ruleType: PromRuleType.Alerting }))).toBe(true); - expect(ruleFilter(alertingRule, getFilter({ ruleType: PromRuleType.Recording }))).toBe(false); - expect(ruleFilter(recordingRule, getFilter({ ruleType: PromRuleType.Recording }))).toBe(true); - expect(ruleFilter(recordingRule, getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); + expect(backendFilter.state).toEqual([PromAlertingRuleState.Firing]); + }); + + it('should include ruleHealth in backend filter', () => { + const { backendFilter } = getGrafanaFilter(getFilter({ ruleHealth: RuleHealth.Error })); + + expect(backendFilter.health).toEqual([RuleHealth.Error]); + }); + + it('should include contactPoint in backend filter', () => { + const { backendFilter } = getGrafanaFilter(getFilter({ contactPoint: 'my-contact-point' })); + + expect(backendFilter.contactPoint).toBe('my-contact-point'); + }); + + it('should handle empty backend filters', () => { + const { backendFilter } = getGrafanaFilter(getFilter({})); + + expect(backendFilter.state).toEqual([]); + expect(backendFilter.health).toEqual([]); + expect(backendFilter.contactPoint).toBeUndefined(); + }); }); - it('should filter by rule state', () => { - const firingRule = mockPromAlertingRule({ - name: 'Firing Alert', - state: PromAlertingRuleState.Firing, - }); + describe('backend filtering with alertingUIUseBackendFilters feature toggle', () => { + describe('when alertingUIUseBackendFilters is enabled', () => { + testWithFeatureToggles({ enable: ['alertingUIUseBackendFilters'] }); - const pendingRule = mockPromAlertingRule({ - name: 'Pending Alert', - state: PromAlertingRuleState.Pending, - }); + it('should include title in backend filter when freeFormWords are provided', () => { + const { backendFilter } = getGrafanaFilter(getFilter({ freeFormWords: ['cpu', 'usage'] })); - expect(ruleFilter(firingRule, getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(true); - expect(ruleFilter(firingRule, getFilter({ ruleState: PromAlertingRuleState.Pending }))).toBe(false); - expect(ruleFilter(pendingRule, getFilter({ ruleState: PromAlertingRuleState.Pending }))).toBe(true); - }); + expect(backendFilter.title).toBe('cpu usage'); + }); - it('should filter out recording rules when filtering by rule state', () => { - const recordingRule = mockPromRecordingRule({ - name: 'Recording Rule', - }); + it('should include title in backend filter when ruleName is provided', () => { + const { backendFilter } = getGrafanaFilter(getFilter({ ruleName: 'high cpu' })); - // Recording rules should always be filtered out when any rule state filter is applied as they don't have a state - expect(ruleFilter(recordingRule, getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); - expect(ruleFilter(recordingRule, getFilter({ ruleState: PromAlertingRuleState.Pending }))).toBe(false); - expect(ruleFilter(recordingRule, getFilter({ ruleState: PromAlertingRuleState.Inactive }))).toBe(false); - }); + expect(backendFilter.title).toBe('high cpu'); + }); - it('should filter by rule health', () => { - const healthyRule = mockPromAlertingRule({ - name: 'Healthy Rule', - health: RuleHealth.Ok, - }); + it('should combine ruleName and freeFormWords in title', () => { + const { backendFilter } = getGrafanaFilter(getFilter({ ruleName: 'alert', freeFormWords: ['cpu'] })); - const errorRule = mockPromAlertingRule({ - name: 'Error Rule', - health: RuleHealth.Error, - }); + expect(backendFilter.title).toBe('alert cpu'); + }); - const prometheusErrorRule = mockPromAlertingRule({ - name: 'Error Rule', - health: 'err', - }); + it('should not include title when no title filters are provided', () => { + const { backendFilter } = getGrafanaFilter(getFilter({ ruleState: PromAlertingRuleState.Firing })); - expect(ruleFilter(healthyRule, getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(true); - expect(ruleFilter(healthyRule, getFilter({ ruleHealth: RuleHealth.Error }))).toBe(false); - expect(ruleFilter(errorRule, getFilter({ ruleHealth: RuleHealth.Error }))).toBe(true); - expect(ruleFilter(prometheusErrorRule, getFilter({ ruleHealth: RuleHealth.Error }))).toBe(true); - }); + expect(backendFilter.title).toBeUndefined(); + }); - it('should normalize health values when filtering', () => { - // Legacy Prometheus health value 'err' should be normalized to 'error' - const legacyErrorRule = mockPromAlertingRule({ - name: 'Legacy Error Rule', - health: 'err', - }); + it('should skip freeFormWords filtering on frontend when backend filtering is enabled', () => { + const rule = mockGrafanaPromAlertingRule({ name: 'High CPU Usage' }); - // When filtering for 'error', it should match rules with health 'err' (legacy) or 'error' - expect(ruleFilter(legacyErrorRule, getFilter({ ruleHealth: RuleHealth.Error }))).toBe(true); - }); + const { frontendFilter } = getGrafanaFilter(getFilter({ freeFormWords: ['memory'] })); + // Should return true because freeFormWords filter is null (handled by backend) + expect(frontendFilter.ruleMatches(rule)).toBe(true); + }); - it('should filter by dashboard UID', () => { - const ruleDashboardA = mockPromAlertingRule({ - name: 'Dashboard A Rule', - annotations: { [Annotation.dashboardUID]: 'dashboard-a' }, - }); + it('should skip ruleName filtering on frontend when backend filtering is enabled', () => { + const rule = mockGrafanaPromAlertingRule({ name: 'High CPU Usage' }); - const ruleDashboardB = mockPromAlertingRule({ - name: 'Dashboard B Rule', - annotations: { [Annotation.dashboardUID]: 'dashboard-b' }, - }); + const { frontendFilter } = getGrafanaFilter(getFilter({ ruleName: 'memory' })); + // Should return true because ruleName filter is null (handled by backend) + expect(frontendFilter.ruleMatches(rule)).toBe(true); + }); - expect(ruleFilter(ruleDashboardA, getFilter({ dashboardUid: 'dashboard-a' }))).toBe(true); - expect(ruleFilter(ruleDashboardA, getFilter({ dashboardUid: 'dashboard-b' }))).toBe(false); - expect(ruleFilter(ruleDashboardB, getFilter({ dashboardUid: 'dashboard-b' }))).toBe(true); - }); + it('should include ruleType in backend filter when provided', () => { + const { backendFilter } = getGrafanaFilter(getFilter({ ruleType: PromRuleType.Alerting })); - it('should filter out recording rules when filtering by dashboard UID', () => { - const recordingRule = mockPromRecordingRule({ - name: 'Recording Rule', - // Recording rules cannot have dashboard UIDs because they don't have annotations - }); + expect(backendFilter.type).toBe(PromRuleType.Alerting); + }); - // Dashboard UID filter should filter out recording rules - expect(ruleFilter(recordingRule, getFilter({ dashboardUid: 'any-dashboard' }))).toBe(false); - }); + it('should not include ruleType in backend filter when not provided', () => { + const { backendFilter } = getGrafanaFilter(getFilter({})); - describe('dataSourceNames filter', () => { - let getDataSourceUIDSpy: jest.SpyInstance; + expect(backendFilter.type).toBeUndefined(); + }); - beforeEach(() => { - getDataSourceUIDSpy = jest.spyOn(datasourceUtils, 'getDatasourceAPIUid').mockImplementation((ruleSourceName) => { - if (ruleSourceName === 'prometheus') { - return 'datasource-uid-1'; - } - if (ruleSourceName === 'loki') { - return 'datasource-uid-3'; - } - throw new Error(`Unknown datasource name: ${ruleSourceName}`); + it('should skip ruleType filtering on frontend when backend filtering is enabled', () => { + const alertingRule = mockGrafanaPromAlertingRule({ name: 'Test Alert' }); + const recordingRule = mockPromRecordingRule({ name: 'Test Recording' }); + + const { frontendFilter } = getGrafanaFilter(getFilter({ ruleType: PromRuleType.Alerting })); + // Should return true for both because ruleType filter is null (handled by backend) + expect(frontendFilter.ruleMatches(alertingRule)).toBe(true); + expect(frontendFilter.ruleMatches(recordingRule)).toBe(true); + }); + + it('should include dashboardUid in backend filter when provided', () => { + const { backendFilter } = getGrafanaFilter(getFilter({ dashboardUid: 'dashboard-123' })); + + expect(backendFilter.dashboardUid).toBe('dashboard-123'); + }); + + it('should not include dashboardUid in backend filter when not provided', () => { + const { backendFilter } = getGrafanaFilter(getFilter({})); + + expect(backendFilter.dashboardUid).toBeUndefined(); + }); + + it('should skip dashboardUid filtering on frontend when backend filtering is enabled', () => { + const ruleWithDashboard = mockGrafanaPromAlertingRule({ + name: 'Dashboard Rule', + annotations: { [Annotation.dashboardUID]: 'dashboard-a' }, + }); + + const { frontendFilter } = getGrafanaFilter(getFilter({ dashboardUid: 'dashboard-b' })); + // Should return true because dashboardUid filter is null (handled by backend) + expect(frontendFilter.ruleMatches(ruleWithDashboard)).toBe(true); + }); + + it('should still apply other frontend filters', () => { + const rule = mockGrafanaPromAlertingRule({ + name: 'High CPU Usage', + labels: { severity: 'critical', team: 'ops' }, + alerts: [], + }); + + // Label filter should still work on frontend + const { frontendFilter } = getGrafanaFilter(getFilter({ labels: ['severity=warning'] })); + expect(frontendFilter.ruleMatches(rule)).toBe(false); + + const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ labels: ['severity=critical'] })); + expect(frontendFilter2.ruleMatches(rule)).toBe(true); }); }); - afterEach(() => { - // Clean up - getDataSourceUIDSpy.mockRestore(); - }); + describe('when alertingUIUseBackendFilters is disabled', () => { + testWithFeatureToggles({ disable: ['alertingUIUseBackendFilters'] }); - it('should match rules that use the filtered datasource', () => { - // Create a Grafana rule with matching datasource - const ruleWithMatchingDatasource = mockGrafanaPromAlertingRule({ - queriedDatasourceUIDs: ['datasource-uid-1'], + it('should not include title in backend filter', () => { + const { backendFilter } = getGrafanaFilter(getFilter({ freeFormWords: ['cpu'] })); + + expect(backendFilter.title).toBeUndefined(); }); - // 'prometheus' resolves to 'datasource-uid-1' which is in the rule - expect(ruleFilter(ruleWithMatchingDatasource, getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); - }); + it('should perform freeFormWords filtering on frontend', () => { + const rule = mockGrafanaPromAlertingRule({ name: 'High CPU Usage' }); - it("should filter out rules that don't use the filtered datasource", () => { - // Create a Grafana rule without the target datasource - const ruleWithoutMatchingDatasource = mockGrafanaPromAlertingRule({ - queriedDatasourceUIDs: ['datasource-uid-1', 'datasource-uid-2'], + const { frontendFilter } = getGrafanaFilter(getFilter({ freeFormWords: ['cpu'] })); + expect(frontendFilter.ruleMatches(rule)).toBe(true); + + const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ freeFormWords: ['memory'] })); + expect(frontendFilter2.ruleMatches(rule)).toBe(false); }); - // 'loki' resolves to 'datasource-uid-3' which is not in the rule - expect(ruleFilter(ruleWithoutMatchingDatasource, getFilter({ dataSourceNames: ['loki'] }))).toBe(false); - }); + it('should perform ruleName filtering on frontend', () => { + const rule = mockGrafanaPromAlertingRule({ name: 'High CPU Usage' }); - it('should return false when there is an error parsing the query', () => { - const ruleWithInvalidQuery = mockGrafanaPromAlertingRule({ - query: 'not-valid-json', + const { frontendFilter } = getGrafanaFilter(getFilter({ ruleName: 'cpu' })); + expect(frontendFilter.ruleMatches(rule)).toBe(true); + + const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ ruleName: 'memory' })); + expect(frontendFilter2.ruleMatches(rule)).toBe(false); }); - expect(ruleFilter(ruleWithInvalidQuery, getFilter({ dataSourceNames: ['prometheus'] }))).toBe(false); + it('should not include ruleType in backend filter', () => { + const { backendFilter } = getGrafanaFilter(getFilter({ ruleType: PromRuleType.Alerting })); + + expect(backendFilter.type).toBeUndefined(); + }); + + it('should perform ruleType filtering on frontend', () => { + const alertingRule = mockGrafanaPromAlertingRule({ name: 'Test Alert' }); + const recordingRule = mockPromRecordingRule({ name: 'Test Recording' }); + + const { frontendFilter } = getGrafanaFilter(getFilter({ ruleType: PromRuleType.Alerting })); + expect(frontendFilter.ruleMatches(alertingRule)).toBe(true); + expect(frontendFilter.ruleMatches(recordingRule)).toBe(false); + + const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ ruleType: PromRuleType.Recording })); + expect(frontendFilter2.ruleMatches(alertingRule)).toBe(false); + expect(frontendFilter2.ruleMatches(recordingRule)).toBe(true); + }); + + it('should not include dashboardUid in backend filter', () => { + const { backendFilter } = getGrafanaFilter(getFilter({ dashboardUid: 'dashboard-123' })); + + expect(backendFilter.dashboardUid).toBeUndefined(); + }); + + it('should perform dashboardUid filtering on frontend', () => { + const ruleDashboardA = mockGrafanaPromAlertingRule({ + name: 'Dashboard A Rule', + annotations: { [Annotation.dashboardUID]: 'dashboard-a' }, + }); + + const ruleDashboardB = mockGrafanaPromAlertingRule({ + name: 'Dashboard B Rule', + annotations: { [Annotation.dashboardUID]: 'dashboard-b' }, + }); + + const { frontendFilter } = getGrafanaFilter(getFilter({ dashboardUid: 'dashboard-a' })); + expect(frontendFilter.ruleMatches(ruleDashboardA)).toBe(true); + expect(frontendFilter.ruleMatches(ruleDashboardB)).toBe(false); + + const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ dashboardUid: 'dashboard-b' })); + expect(frontendFilter2.ruleMatches(ruleDashboardA)).toBe(false); + expect(frontendFilter2.ruleMatches(ruleDashboardB)).toBe(true); + }); }); }); - - it('should combine multiple filters with AND logic', () => { - const rule = mockPromAlertingRule({ - name: 'High CPU Usage Production', - labels: { severity: 'critical', environment: 'production' }, - state: PromAlertingRuleState.Firing, - health: RuleHealth.Ok, - }); - - const filter = getFilter({ - ruleName: 'cpu', - labels: ['severity=critical', 'environment=production'], - ruleState: PromAlertingRuleState.Firing, - ruleHealth: RuleHealth.Ok, - }); - - expect(ruleFilter(rule, filter)).toBe(true); - }); - - it('should return false if any filter does not match', () => { - const rule = mockPromAlertingRule({ - name: 'High CPU Usage Production', - labels: { severity: 'critical', environment: 'production' }, - state: PromAlertingRuleState.Firing, - health: RuleHealth.Ok, - alerts: [], - }); - - const filter = getFilter({ - ruleName: 'cpu', - labels: ['severity=warning'], - ruleState: PromAlertingRuleState.Firing, - ruleHealth: RuleHealth.Ok, - }); - - expect(ruleFilter(rule, filter)).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 8db19b5a8dd..637fa636f62 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/filters.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/filters.ts @@ -4,6 +4,8 @@ import memoize from 'micro-memoize'; import { Matcher } from 'app/plugins/datasource/alertmanager/types'; import { PromRuleDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto'; +import { GrafanaPromRulesOptions } from '../../api/prometheusApi'; +import { shouldUseBackendFilters } from '../../featureToggles'; import { RulesFilter } from '../../search/rulesSearchParser'; import { labelsMatchMatchers } from '../../utils/alertmanager'; import { Annotation } from '../../utils/constants'; @@ -13,21 +15,119 @@ import { parseMatcher } from '../../utils/matchers'; import { isPluginProvidedRule, prometheusRuleType } from '../../utils/rules'; import { normalizeHealth } from '../components/util'; +type RuleFilterHandler = (rule: PromRuleDTO, filterState: RulesFilter) => boolean; +type GroupFilterHandler = ( + group: PromRuleGroupDTO, + filterState: Pick +) => boolean; + +type RuleFilterConfig = Record< + Exclude, + RuleFilterHandler | null +>; + +type GroupFilterConfig = Record, GroupFilterHandler | null>; + +export function getGrafanaFilter(filterState: RulesFilter) { + const normalizedFilterState = normalizeFilterState(filterState); + const useBackendFilters = shouldUseBackendFilters(); + + // Build title search for backend filtering + const titleSearch = buildTitleSearch(normalizedFilterState); + + const backendFilter: GrafanaPromRulesOptions = { + 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, + }; + + const grafanaFilterProcessingConfig: 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, + dataSourceNames: dataSourceNamesFilter, + labels: labelsFilter, + ruleHealth: null, + dashboardUid: useBackendFilters ? null : dashboardUidFilter, + plugins: pluginsFilter, + contactPoint: null, + }; + + const grafanaGroupFilterConfig: GroupFilterConfig = { + namespace: namespaceFilter, + groupName: groupNameFilter, + }; + + return { + backendFilter, + frontendFilter: { + groupMatches: (group: PromRuleGroupDTO) => groupMatches(group, normalizedFilterState, grafanaGroupFilterConfig), + ruleMatches: (rule: PromRuleDTO) => ruleMatches(rule, normalizedFilterState, grafanaFilterProcessingConfig), + }, + }; +} + +export function getDatasourceFilter(filterState: RulesFilter) { + const normalizedFilterState = normalizeFilterState(filterState); + + const dsRuleFilterConfig: RuleFilterConfig = { + freeFormWords: freeFormFilter, + ruleName: ruleNameFilter, + ruleState: ruleStateFilter, + ruleType: ruleTypeFilter, + dataSourceNames: dataSourceNamesFilter, + labels: labelsFilter, + ruleHealth: ruleHealthFilter, + dashboardUid: dashboardUidFilter, + plugins: pluginsFilter, + contactPoint: contactPointFilter, + }; + + const dsGroupFilterConfig: GroupFilterConfig = { + namespace: namespaceFilter, + groupName: groupNameFilter, + }; + + return { + groupMatches: (group: PromRuleGroupDTO) => groupMatches(group, normalizedFilterState, dsGroupFilterConfig), + ruleMatches: (rule: PromRuleDTO) => ruleMatches(rule, normalizedFilterState, dsRuleFilterConfig), + }; +} + /** * @returns True if the group matches the filter, false otherwise. Keeps rules intact */ -export function groupFilter( +function groupMatches( group: PromRuleGroupDTO, - filterState: Pick + filterState: Pick, + filterConfig: GroupFilterConfig ): boolean { - const { name, file } = group; - const { namespace, groupName } = filterState; - - if (namespace && !fuzzyMatches(file, namespace)) { + if (filterConfig.namespace && filterConfig.namespace(group, filterState) === false) { return false; } - if (groupName && !fuzzyMatches(name, groupName)) { + if (filterConfig.groupName && filterConfig.groupName(group, filterState) === false) { + return false; + } + + return true; +} + +function namespaceFilter(group: PromRuleGroupDTO, filterState: Pick): boolean { + if (filterState.namespace && !fuzzyMatches(group.file, filterState.namespace)) { + return false; + } + + return true; +} + +function groupNameFilter(group: PromRuleGroupDTO, filterState: Pick): boolean { + if (filterState.groupName && !fuzzyMatches(group.name, filterState.groupName)) { return false; } @@ -36,26 +136,74 @@ 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, backendFiltered?: boolean) { - const { name, labels = {}, health, type } = rule; +function ruleMatches(rule: PromRuleDTO, filterState: RulesFilter, filterConfig: RuleFilterConfig) { + if (filterConfig.freeFormWords && filterConfig.freeFormWords(rule, filterState) === false) { + return false; + } - if (filterState.freeFormWords.length > 0 && !backendFiltered) { - const nameMatches = fuzzyMatches(name, filterState.freeFormWords.join(' ')); + if (filterConfig.ruleName && filterConfig.ruleName(rule, filterState) === false) { + return false; + } + + if (filterConfig.labels && filterConfig.labels(rule, filterState) === false) { + return false; + } + + if (filterConfig.ruleType && filterConfig.ruleType(rule, filterState) === false) { + return false; + } + + if (filterConfig.ruleState && filterConfig.ruleState(rule, filterState) === false) { + return false; + } + + if (filterConfig.ruleHealth && filterConfig.ruleHealth(rule, filterState) === false) { + return false; + } + + if (filterConfig.contactPoint && filterConfig.contactPoint(rule, filterState) === false) { + return false; + } + + if (filterConfig.dashboardUid && filterConfig.dashboardUid(rule, filterState) === false) { + return false; + } + + if (filterConfig.plugins && filterConfig.plugins(rule, filterState) === false) { + return false; + } + + if (filterConfig.dataSourceNames && filterConfig.dataSourceNames(rule, filterState) === false) { + return false; + } + + return true; +} + +function freeFormFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { + if (filterState.freeFormWords.length > 0) { + const nameMatches = fuzzyMatches(rule.name, filterState.freeFormWords.join(' ')); if (!nameMatches) { return false; } } - // Rule name search: Backend-supported for backend-filtered rules, client-side otherwise - if (filterState.ruleName && !backendFiltered && !fuzzyMatches(name, filterState.ruleName)) { + return true; +} + +function ruleNameFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { + if (filterState.ruleName && !fuzzyMatches(rule.name, filterState.ruleName)) { return false; } + return true; +} + +function labelsFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { if (filterState.labels.length > 0) { const matchers = compact(filterState.labels.map(looseParseMatcher)); - const doRuleLabelsMatchQuery = matchers.length > 0 && labelsMatchMatchers(labels, matchers); + const doRuleLabelsMatchQuery = matchers.length > 0 && labelsMatchMatchers(rule.labels || {}, matchers); // Also check alerts if they exist const doAlertsContainMatchingLabels = @@ -69,10 +217,18 @@ export function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter, backendF } } - if (filterState.ruleType && type !== filterState.ruleType) { + return true; +} + +function ruleTypeFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { + if (filterState.ruleType && rule.type !== filterState.ruleType) { return false; } + return true; +} + +function ruleStateFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { if (filterState.ruleState) { if (!prometheusRuleType.alertingRule(rule)) { return false; @@ -82,10 +238,18 @@ export function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter, backendF } } - if (filterState.ruleHealth && normalizeHealth(health) !== filterState.ruleHealth) { + return true; +} + +function ruleHealthFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { + if (filterState.ruleHealth && normalizeHealth(rule.health) !== filterState.ruleHealth) { return false; } + return true; +} + +function contactPointFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { if (filterState.contactPoint) { if (!prometheusRuleType.grafana.alertingRule(rule)) { return false; @@ -100,6 +264,10 @@ export function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter, backendF } } + return true; +} + +function dashboardUidFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { if (filterState.dashboardUid) { if (!prometheusRuleType.alertingRule(rule)) { return false; @@ -111,11 +279,19 @@ export function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter, backendF } } + return true; +} + +function pluginsFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { // Plugins filter - hide plugin-provided rules when set to 'hide' if (filterState.plugins === 'hide' && isPluginProvidedRule(rule)) { return false; } + return true; +} + +function dataSourceNamesFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { // Note: We can't implement these filters from reduceGroups because they rely on rulerRule property // which is not available in PromRuleDTO: // - contactPoint filter @@ -156,3 +332,45 @@ const mapDataSourceNamesToUids = memoize( }, { maxSize: 1 } ); + +/** + * Build title search parameter for backend filtering + * Combines ruleName and freeFormWords into a single search string + */ +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(' '); +} + +/** + * Normalize filter state for case-insensitive matching + * Lowercase free form words, rule name, group name and namespace + */ +function normalizeFilterState(filterState: RulesFilter): RulesFilter { + return { + ...filterState, + freeFormWords: filterState.freeFormWords.map((word) => word.toLowerCase()), + ruleName: filterState.ruleName?.toLowerCase(), + groupName: filterState.groupName?.toLowerCase(), + namespace: filterState.namespace?.toLowerCase(), + }; +} 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 9f597922867..a622d43b0c6 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 @@ -4,7 +4,8 @@ import { PromRuleType } from 'app/types/unified-alerting-dto'; import { RuleSource } from '../../search/rulesSearchParser'; import { getFilter } from '../../utils/search'; -import { buildTitleSearch, hasClientSideFilters } from './useFilteredRulesIterator'; +import { buildTitleSearch } from './filters'; +import { hasClientSideFilters } from './useFilteredRulesIterator'; describe('hasClientSideFilters', () => { const originalFeatureToggles = config.featureToggles; 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 d2a577a1d23..4bff8bde783 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts @@ -25,7 +25,7 @@ import { } from '../../utils/datasource'; import { RulePositionHash, createRulePositionHash } from '../rulePositionHash'; -import { groupFilter, ruleFilter } from './filters'; +import { getDatasourceFilter, getGrafanaFilter } from './filters'; import { useGrafanaGroupsGenerator, usePrometheusGroupsGenerator } from './prometheusGroupsGenerator'; export type RuleWithOrigin = PromRuleWithOrigin | GrafanaRuleWithOrigin; @@ -78,30 +78,19 @@ export function useFilteredRulesIteratorProvider() { /* this is the abort controller that allows us to stop an AsyncIterable */ const abortController = new AbortController(); - const normalizedFilterState = normalizeFilterState(filterState); const hasDataSourceFilterActive = Boolean(filterState.dataSourceNames.length); - const useBackendFilters = shouldUseBackendFilters(); - const titleSearch = useBackendFilters ? buildTitleSearch(filterState) : undefined; - const ruleType = useBackendFilters ? filterState.ruleType : undefined; - const dashboardUid = useBackendFilters ? filterState.dashboardUid : undefined; + const { backendFilter, frontendFilter } = getGrafanaFilter(filterState); const grafanaRulesGenerator: AsyncIterableX = from( - grafanaGroupsGenerator(groupLimit, { - contactPoint: filterState.contactPoint ?? undefined, - health: filterState.ruleHealth ? [filterState.ruleHealth] : [], - state: filterState.ruleState ? [filterState.ruleState] : [], - title: titleSearch, - type: ruleType, - dashboardUid, - }) + grafanaGroupsGenerator(groupLimit, backendFilter) ).pipe( withAbort(abortController.signal), concatMap((groups) => groups - .filter((group) => groupFilter(group, normalizedFilterState)) + .filter((group) => frontendFilter.groupMatches(group)) .flatMap((group) => group.rules.map((rule) => ({ group, rule }))) - .filter(({ rule }) => ruleFilter(rule, normalizedFilterState, useBackendFilters)) + .filter(({ rule }) => frontendFilter.ruleMatches(rule)) .map(({ group, rule }) => mapGrafanaRuleToRuleWithOrigin(group, rule)) ), catchError(() => empty()) @@ -116,6 +105,8 @@ export function useFilteredRulesIteratorProvider() { return { iterable: grafanaRulesGenerator, abortController }; } + const { groupMatches, ruleMatches } = getDatasourceFilter(filterState); + const dataSourceGenerators: Array> = externalRulesSourcesToFetchFrom.map( (dataSourceIdentifier) => { const promGroupsGenerator: AsyncIterableX = from( @@ -124,9 +115,9 @@ export function useFilteredRulesIteratorProvider() { withAbort(abortController.signal), concatMap((groups) => groups - .filter((group) => groupFilter(group, normalizedFilterState)) + .filter((group) => groupMatches(group)) .flatMap((group) => group.rules.map((rule, index) => ({ group, rule, index }))) - .filter(({ rule }) => ruleFilter(rule, normalizedFilterState, false)) + .filter(({ rule }) => ruleMatches(rule)) .map(({ group, rule, index }) => mapRuleToRuleWithOrigin(dataSourceIdentifier, group, rule, index)) ), catchError(() => empty()) @@ -176,30 +167,6 @@ export function hasClientSideFilters(filterState: RulesFilter): boolean { ); } -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(); @@ -268,16 +235,3 @@ function mapGrafanaRuleToRuleWithOrigin( origin: 'grafana', }; } - -/** - * Lowercase free form words, rule name, group name and namespace - */ -function normalizeFilterState(filterState: RulesFilter): RulesFilter { - return { - ...filterState, - freeFormWords: filterState.freeFormWords.map((word) => word.toLowerCase()), - ruleName: filterState.ruleName?.toLowerCase(), - groupName: filterState.groupName?.toLowerCase(), - namespace: filterState.namespace?.toLowerCase(), - }; -}