Alerting: Refactor rules filtering (#113898)

* Refactor rules filter to easily switch between frontend and backend filters

* Refactor getFilter functions to receive filters as an argument

* Move filter normalization to getFilter utils
This commit is contained in:
Konrad Lalik
2025-11-17 14:42:29 +01:00
committed by GitHub
parent 10a699fb6c
commit 703e93d103
7 changed files with 958 additions and 323 deletions
@@ -34,7 +34,7 @@ type PromRulesOptions = WithNotificationOptions<{
groupNextToken?: string;
}>;
type GrafanaPromRulesOptions = Omit<PromRulesOptions, 'ruleSource' | 'namespace' | 'excludeAlerts'> & {
export type GrafanaPromRulesOptions = Omit<PromRulesOptions, 'ruleSource' | 'namespace' | 'excludeAlerts'> & {
folderUid?: string;
dashboardUid?: string;
panelId?: number;
@@ -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,
@@ -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,
@@ -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);
});
});
@@ -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<RulesFilter, 'namespace' | 'groupName'>
) => boolean;
type RuleFilterConfig = Record<
Exclude<keyof RulesFilter, 'namespace' | 'groupName' | 'ruleSource'>,
RuleFilterHandler | null
>;
type GroupFilterConfig = Record<keyof Pick<RulesFilter, 'namespace' | 'groupName'>, 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<RulesFilter, 'namespace' | 'groupName'>
filterState: Pick<RulesFilter, 'namespace' | 'groupName'>,
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<RulesFilter, 'namespace' | 'groupName'>): boolean {
if (filterState.namespace && !fuzzyMatches(group.file, filterState.namespace)) {
return false;
}
return true;
}
function groupNameFilter(group: PromRuleGroupDTO, filterState: Pick<RulesFilter, 'namespace' | 'groupName'>): 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(),
};
}
@@ -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;
@@ -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<RuleWithOrigin> = 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<AsyncIterableX<RuleWithOrigin>> = externalRulesSourcesToFetchFrom.map(
(dataSourceIdentifier) => {
const promGroupsGenerator: AsyncIterableX<RuleWithOrigin> = 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<RuleWithOrigin>>): AsyncIterableX<RuleWithOrigin> {
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(),
};
}