diff --git a/public/app/features/alerting/unified/rule-list/hooks/datasourceFilter.test.ts b/public/app/features/alerting/unified/rule-list/hooks/datasourceFilter.test.ts new file mode 100644 index 00000000000..f15318642e8 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/hooks/datasourceFilter.test.ts @@ -0,0 +1,325 @@ +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 { getDatasourceAPIUid } from '../../utils/datasource'; +import { getFilter } from '../../utils/search'; + +import { getDatasourceFilter } from './datasourceFilter'; + +jest.mock('../../utils/datasource'); + +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); + }); + }); + + describe('ruleFilter', () => { + it('should filter by free form words in rule name', () => { + const rule = mockPromAlertingRule({ name: 'High CPU Usage' }); + + const { ruleMatches } = getDatasourceFilter(getFilter({ freeFormWords: ['cpu'] })); + expect(ruleMatches(rule)).toBe(true); + + const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ freeFormWords: ['memory'] })); + expect(ruleMatches2(rule)).toBe(false); + }); + + 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); + }); + }); +}); diff --git a/public/app/features/alerting/unified/rule-list/hooks/datasourceFilter.ts b/public/app/features/alerting/unified/rule-list/hooks/datasourceFilter.ts new file mode 100644 index 00000000000..e6aa3089e55 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/hooks/datasourceFilter.ts @@ -0,0 +1,56 @@ +import { PromRuleDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto'; + +import { RulesFilter } from '../../search/rulesSearchParser'; + +import { normalizeFilterState } from './filterNormalization'; +import { + GroupFilterConfig, + RuleFilterConfig, + contactPointFilter, + dashboardUidFilter, + dataSourceNamesFilter, + freeFormFilter, + groupMatches, + groupNameFilter, + labelsFilter, + namespaceFilter, + pluginsFilter, + ruleHealthFilter, + ruleMatches, + ruleNameFilter, + ruleStateFilter, + ruleTypeFilter, +} from './filterPredicates'; + +/** + * Builds filter configurations for data source-managed alert rules. + * + * Constructs filter objects for both rules and groups that are managed by data sources. + * All filters are applied on the client-side for data source rules. + */ +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), + }; +} diff --git a/public/app/features/alerting/unified/rule-list/hooks/filterNormalization.test.ts b/public/app/features/alerting/unified/rule-list/hooks/filterNormalization.test.ts new file mode 100644 index 00000000000..83a308c4563 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/hooks/filterNormalization.test.ts @@ -0,0 +1,95 @@ +import { PromRuleType } from 'app/types/unified-alerting-dto'; + +import { getFilter } from '../../utils/search'; + +import { buildTitleSearch, normalizeFilterState } from './filterNormalization'; + +describe('buildTitleSearch', () => { + it('should return undefined when no title filters are provided', () => { + expect(buildTitleSearch(getFilter({}))).toBeUndefined(); + }); + + it('should return ruleName when only ruleName is provided', () => { + expect(buildTitleSearch(getFilter({ ruleName: 'high cpu' }))).toBe('high cpu'); + }); + + it('should return freeFormWords when only freeFormWords are provided', () => { + expect(buildTitleSearch(getFilter({ freeFormWords: ['cpu', 'usage'] }))).toBe('cpu usage'); + }); + + it('should combine ruleName and freeFormWords', () => { + expect(buildTitleSearch(getFilter({ ruleName: 'alert', freeFormWords: ['cpu'] }))).toBe('alert cpu'); + }); + + it('should trim whitespace from inputs', () => { + expect(buildTitleSearch(getFilter({ ruleName: ' alert ', freeFormWords: [' cpu ', ' usage '] }))).toBe( + 'alert cpu usage' + ); + }); + + it('should filter out empty strings from freeFormWords', () => { + expect(buildTitleSearch(getFilter({ freeFormWords: ['cpu', '', ' ', 'usage'] }))).toBe('cpu usage'); + }); + + it('should return undefined when only empty strings are provided', () => { + expect(buildTitleSearch(getFilter({ ruleName: ' ', freeFormWords: ['', ' '] }))).toBeUndefined(); + }); +}); + +describe('normalizeFilterState', () => { + it('should lowercase freeFormWords', () => { + const result = normalizeFilterState(getFilter({ freeFormWords: ['CPU', 'Usage'] })); + expect(result.freeFormWords).toEqual(['cpu', 'usage']); + }); + + it('should lowercase ruleName', () => { + const result = normalizeFilterState(getFilter({ ruleName: 'High CPU' })); + expect(result.ruleName).toBe('high cpu'); + }); + + it('should lowercase groupName', () => { + const result = normalizeFilterState(getFilter({ groupName: 'Production Alerts' })); + expect(result.groupName).toBe('production alerts'); + }); + + it('should lowercase namespace', () => { + const result = normalizeFilterState(getFilter({ namespace: 'Production/Alerts' })); + expect(result.namespace).toBe('production/alerts'); + }); + + it('should handle undefined values', () => { + const result = normalizeFilterState(getFilter({})); + expect(result.ruleName).toBeUndefined(); + expect(result.groupName).toBeUndefined(); + expect(result.namespace).toBeUndefined(); + }); + + it('should preserve other filter properties', () => { + const result = normalizeFilterState( + getFilter({ + ruleName: 'Alert', + ruleType: PromRuleType.Alerting, + labels: ['severity=critical'], + dataSourceNames: ['prometheus'], + }) + ); + expect(result.ruleType).toBe(PromRuleType.Alerting); + expect(result.labels).toEqual(['severity=critical']); + expect(result.dataSourceNames).toEqual(['prometheus']); + }); + + it('should lowercase all relevant fields in a complex filter', () => { + const result = normalizeFilterState( + getFilter({ + freeFormWords: ['CPU', 'Memory'], + ruleName: 'High Usage', + groupName: 'System Alerts', + namespace: 'Production/Critical', + }) + ); + expect(result.freeFormWords).toEqual(['cpu', 'memory']); + expect(result.ruleName).toBe('high usage'); + expect(result.groupName).toBe('system alerts'); + expect(result.namespace).toBe('production/critical'); + }); +}); diff --git a/public/app/features/alerting/unified/rule-list/hooks/filterNormalization.ts b/public/app/features/alerting/unified/rule-list/hooks/filterNormalization.ts new file mode 100644 index 00000000000..d8594135463 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/hooks/filterNormalization.ts @@ -0,0 +1,43 @@ +import { RulesFilter } from '../../search/rulesSearchParser'; + +/** + * 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 + */ +export 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/filterPredicates.ts b/public/app/features/alerting/unified/rule-list/hooks/filterPredicates.ts new file mode 100644 index 00000000000..d268a336f08 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/hooks/filterPredicates.ts @@ -0,0 +1,267 @@ +import { attempt, compact, isString } from 'lodash'; +import memoize from 'micro-memoize'; + +import { Matcher } from 'app/plugins/datasource/alertmanager/types'; +import { PromRuleDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto'; + +import { RulesFilter } from '../../search/rulesSearchParser'; +import { labelsMatchMatchers } from '../../utils/alertmanager'; +import { Annotation } from '../../utils/constants'; +import { getDatasourceAPIUid } from '../../utils/datasource'; +import { fuzzyMatches } from '../../utils/fuzzySearch'; +import { parseMatcher } from '../../utils/matchers'; +import { isPluginProvidedRule, prometheusRuleType } from '../../utils/rules'; +import { normalizeHealth } from '../components/util'; + +export type RuleFilterHandler = (rule: PromRuleDTO, filterState: RulesFilter) => boolean; +export type GroupFilterHandler = ( + group: PromRuleGroupDTO, + filterState: Pick +) => boolean; + +export type RuleFilterConfig = Record< + Exclude, + RuleFilterHandler | null +>; + +export type GroupFilterConfig = Record, GroupFilterHandler | null>; + +/** + * @returns True if the group matches the filter, false otherwise. Keeps rules intact + */ +export function groupMatches( + group: PromRuleGroupDTO, + filterState: Pick, + filterConfig: GroupFilterConfig +): boolean { + if (filterConfig.namespace && filterConfig.namespace(group, filterState) === false) { + return false; + } + + if (filterConfig.groupName && filterConfig.groupName(group, filterState) === false) { + return false; + } + + return true; +} + +/** + * @returns True if the rule matches the filter, false otherwise + */ +export function ruleMatches(rule: PromRuleDTO, filterState: RulesFilter, filterConfig: RuleFilterConfig) { + if (filterConfig.freeFormWords && filterConfig.freeFormWords(rule, filterState) === false) { + return false; + } + + 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; +} + +export function namespaceFilter( + group: PromRuleGroupDTO, + filterState: Pick +): boolean { + if (filterState.namespace && !fuzzyMatches(group.file, filterState.namespace)) { + return false; + } + + return true; +} + +export function groupNameFilter( + group: PromRuleGroupDTO, + filterState: Pick +): boolean { + if (filterState.groupName && !fuzzyMatches(group.name, filterState.groupName)) { + return false; + } + + return true; +} + +export function freeFormFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { + if (filterState.freeFormWords.length > 0) { + const nameMatches = fuzzyMatches(rule.name, filterState.freeFormWords.join(' ')); + if (!nameMatches) { + return false; + } + } + + return true; +} + +export function ruleNameFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { + if (filterState.ruleName && !fuzzyMatches(rule.name, filterState.ruleName)) { + return false; + } + + return true; +} + +export 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(rule.labels || {}, matchers); + + // Also check alerts if they exist + const doAlertsContainMatchingLabels = + matchers.length > 0 && + prometheusRuleType.alertingRule(rule) && + rule.alerts && + rule.alerts.some((alert) => labelsMatchMatchers(alert.labels || {}, matchers)); + + if (!doRuleLabelsMatchQuery && !doAlertsContainMatchingLabels) { + return false; + } + } + + return true; +} + +export function ruleTypeFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { + if (filterState.ruleType && rule.type !== filterState.ruleType) { + return false; + } + + return true; +} + +export function ruleStateFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { + if (filterState.ruleState) { + if (!prometheusRuleType.alertingRule(rule)) { + return false; + } + if (rule.state !== filterState.ruleState) { + return false; + } + } + + return true; +} + +export function ruleHealthFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { + if (filterState.ruleHealth && normalizeHealth(rule.health) !== filterState.ruleHealth) { + return false; + } + + return true; +} + +export function contactPointFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { + if (filterState.contactPoint) { + if (!prometheusRuleType.grafana.alertingRule(rule)) { + return false; + } + + if (!rule.notificationSettings) { + return false; + } + + if (filterState.contactPoint !== rule.notificationSettings.receiver) { + return false; + } + } + + return true; +} + +export function dashboardUidFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { + if (filterState.dashboardUid) { + if (!prometheusRuleType.alertingRule(rule)) { + return false; + } + + const dashboardAnnotation = rule.annotations?.[Annotation.dashboardUID]; + if (dashboardAnnotation !== filterState.dashboardUid) { + return false; + } + } + + return true; +} + +export 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; +} + +export 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 + // - dataSourceNames filter + if (filterState.dataSourceNames.length > 0) { + const isGrafanaRule = prometheusRuleType.grafana.rule(rule); + if (isGrafanaRule) { + try { + const filterDatasourceUids = mapDataSourceNamesToUids(filterState.dataSourceNames); + const queriedDatasourceUids = rule.queriedDatasourceUIDs || []; + + const queryIncludesDataSource = queriedDatasourceUids.some((uid) => filterDatasourceUids.includes(uid)); + if (!queryIncludesDataSource) { + return false; + } + } catch (error) { + return false; + } + } + } + + return true; +} + +function looseParseMatcher(matcherQuery: string): Matcher | undefined { + try { + return parseMatcher(matcherQuery); + } catch { + // Try to createa a matcher than matches all values for a given key + return { name: matcherQuery, value: '', isRegex: true, isEqual: true }; + } +} + +// Memoize the function to avoid calling getDatasourceAPIUid for the filter values multiple times +const mapDataSourceNamesToUids = memoize( + (names: string[]): string[] => { + return names.map((name) => attempt(getDatasourceAPIUid, name)).filter(isString); + }, + { maxSize: 1 } +); diff --git a/public/app/features/alerting/unified/rule-list/hooks/filters.test.ts b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts similarity index 64% rename from public/app/features/alerting/unified/rule-list/hooks/filters.test.ts rename to public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts index af9d9011f89..3825c905a52 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/filters.test.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts @@ -2,13 +2,13 @@ import { testWithFeatureToggles } from 'test/test-utils'; import { PromAlertingRuleState, PromRuleGroupDTO, PromRuleType } from 'app/types/unified-alerting-dto'; -import { mockGrafanaPromAlertingRule, mockPromAlertingRule, mockPromRecordingRule } from '../../mocks'; +import { mockGrafanaPromAlertingRule, mockPromRecordingRule } from '../../mocks'; import { RuleHealth } from '../../search/rulesSearchParser'; import { Annotation } from '../../utils/constants'; import { getDatasourceAPIUid } from '../../utils/datasource'; import { getFilter } from '../../utils/search'; -import { getDatasourceFilter, getGrafanaFilter } from './filters'; +import { getGrafanaFilter, hasClientSideFilters } from './grafanaFilter'; jest.mock('../../utils/datasource'); @@ -24,308 +24,6 @@ getDatasourceAPIUidMock.mockImplementation((ruleSourceName) => { 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); - }); - }); - - describe('ruleFilter', () => { - it('should filter by free form words in rule name', () => { - const rule = mockPromAlertingRule({ name: 'High CPU Usage' }); - - const { ruleMatches } = getDatasourceFilter(getFilter({ freeFormWords: ['cpu'] })); - expect(ruleMatches(rule)).toBe(true); - - const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ freeFormWords: ['memory'] })); - expect(ruleMatches2(rule)).toBe(false); - }); - - 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('grafana-managed rules', () => { describe('groupFilter', () => { it('should filter by namespace (file path)', () => { @@ -787,4 +485,80 @@ describe('grafana-managed rules', () => { }); }); }); + + describe('hasClientSideFilters', () => { + describe('when alertingUIUseBackendFilters is disabled', () => { + testWithFeatureToggles({ disable: ['alertingUIUseBackendFilters'] }); + + it('should return false when no filters are applied', () => { + expect(hasClientSideFilters(getFilter({}))).toBe(false); + }); + + it('should return true for title-related filters (freeFormWords, ruleName)', () => { + expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true); + expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true); + }); + + it('should return true for ruleType filter', () => { + expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(true); + }); + + it('should return true for dashboardUid filter', () => { + expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true); + }); + + it('should return true for groupName filter', () => { + expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true); + }); + + it('should return true for client-side only filters', () => { + expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); + expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); + expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + }); + + it('should return false for backend-only filters (state, health, contactPoint)', () => { + 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); + }); + }); + + describe('when alertingUIUseBackendFilters is enabled', () => { + testWithFeatureToggles({ enable: ['alertingUIUseBackendFilters'] }); + + it('should return false when no filters are applied', () => { + expect(hasClientSideFilters(getFilter({}))).toBe(false); + }); + + it('should return false for title-related filters (handled by backend)', () => { + expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false); + expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(false); + }); + + it('should return false for ruleType filter (handled by backend)', () => { + expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); + }); + + it('should return false for dashboardUid filter (handled by backend)', () => { + expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); + }); + + it('should return false for groupName filter (handled by backend)', () => { + expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(false); + }); + + it('should return true for client-side only filters', () => { + expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); + expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); + expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + }); + + it('should return false for backend-only filters (state, health, contactPoint)', () => { + 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); + }); + }); + }); }); diff --git a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts new file mode 100644 index 00000000000..d7d224047d7 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts @@ -0,0 +1,117 @@ +import { PromRuleDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto'; + +import { GrafanaPromRulesOptions } from '../../api/prometheusApi'; +import { shouldUseBackendFilters } from '../../featureToggles'; +import { RulesFilter } from '../../search/rulesSearchParser'; + +import { buildTitleSearch, normalizeFilterState } from './filterNormalization'; +import { + GroupFilterConfig, + RuleFilterConfig, + dashboardUidFilter, + dataSourceNamesFilter, + freeFormFilter, + groupMatches, + groupNameFilter, + labelsFilter, + namespaceFilter, + pluginsFilter, + ruleMatches, + ruleNameFilter, + ruleTypeFilter, +} from './filterPredicates'; + +/** + * Determines if client-side filtering is needed for Grafana-managed rules. + */ +export function hasClientSideFilters(filterState: RulesFilter): boolean { + const { ruleFilterConfig, groupFilterConfig } = buildGrafanaFilterConfigs(); + + // Check each rule filter: if the config has a non-null handler AND the filter state has a value, we need client-side filtering + const hasActiveRuleFilters = + (ruleFilterConfig.freeFormWords !== null && filterState.freeFormWords.length > 0) || + (ruleFilterConfig.ruleName !== null && Boolean(filterState.ruleName)) || + (ruleFilterConfig.ruleState !== null && Boolean(filterState.ruleState)) || + (ruleFilterConfig.ruleType !== null && Boolean(filterState.ruleType)) || + (ruleFilterConfig.dataSourceNames !== null && filterState.dataSourceNames.length > 0) || + (ruleFilterConfig.labels !== null && filterState.labels.length > 0) || + (ruleFilterConfig.ruleHealth !== null && Boolean(filterState.ruleHealth)) || + (ruleFilterConfig.dashboardUid !== null && Boolean(filterState.dashboardUid)) || + (ruleFilterConfig.plugins !== null && Boolean(filterState.plugins)) || + (ruleFilterConfig.contactPoint !== null && Boolean(filterState.contactPoint)); + + // Check each group filter: if the config has a non-null handler AND the filter state has a value, we need client-side filtering + const hasActiveGroupFilters = + (groupFilterConfig.namespace !== null && Boolean(filterState.namespace)) || + (groupFilterConfig.groupName !== null && Boolean(filterState.groupName)); + + return hasActiveRuleFilters || hasActiveGroupFilters; +} + +/** + * Builds a combined filter configuration for Grafana-managed alert rules. + * + * Constructs both backend and frontend filter objects based on the provided filter state. + * The backend filter is used for server-side filtering when `shouldUseBackendFilters()` is enabled, + * while the frontend filter provides client-side matching functions for rules and groups. + */ +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, + searchGroupName: useBackendFilters ? normalizedFilterState.groupName : undefined, + }; + + const { ruleFilterConfig: grafanaFilterProcessingConfig, groupFilterConfig: grafanaGroupFilterConfig } = + buildGrafanaFilterConfigs(); + + return { + backendFilter, + frontendFilter: { + groupMatches: (group: PromRuleGroupDTO) => groupMatches(group, normalizedFilterState, grafanaGroupFilterConfig), + ruleMatches: (rule: PromRuleDTO) => ruleMatches(rule, normalizedFilterState, grafanaFilterProcessingConfig), + }, + }; +} + +/** + * Builds filter configurations for Grafana rules and groups. + * + * Determines which filters are applied on the backend vs. client-side based on + * the `shouldUseBackendFilters()` flag. When backend filtering is enabled, certain + * filters are set to null to prevent duplicate filtering. + */ +function buildGrafanaFilterConfigs() { + const useBackendFilters = shouldUseBackendFilters(); + + 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, + dataSourceNames: dataSourceNamesFilter, + labels: labelsFilter, + ruleHealth: null, + dashboardUid: useBackendFilters ? null : dashboardUidFilter, + plugins: pluginsFilter, + contactPoint: null, + }; + + const groupFilterConfig: GroupFilterConfig = { + namespace: namespaceFilter, + groupName: useBackendFilters ? null : groupNameFilter, + }; + + return { ruleFilterConfig, groupFilterConfig }; +} 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 592485e211c..1b226d50c5b 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,6 @@ import { PromRuleType } from 'app/types/unified-alerting-dto'; import { RuleSource } from '../../search/rulesSearchParser'; import { getFilter } from '../../utils/search'; -import { buildTitleSearch } from './filters'; import { hasClientSideFilters } from './useFilteredRulesIterator'; describe('hasClientSideFilters', () => { @@ -84,23 +83,3 @@ describe('hasClientSideFilters', () => { }); }); }); - -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' - ); - }); -});