Alerting: Rules filtering improvements (#114073)

* Reorganize filtering code structure

* Remove unused imports

* Simplify naming
This commit is contained in:
Konrad Lalik
2025-11-18 14:21:55 +01:00
committed by GitHub
parent 083c7f31f7
commit a6952b554f
8 changed files with 981 additions and 325 deletions
@@ -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);
});
});
});
@@ -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),
};
}
@@ -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');
});
});
@@ -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(),
};
}
@@ -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<RulesFilter, 'namespace' | 'groupName'>
) => boolean;
export type RuleFilterConfig = Record<
Exclude<keyof RulesFilter, 'namespace' | 'groupName' | 'ruleSource'>,
RuleFilterHandler | null
>;
export type GroupFilterConfig = Record<keyof Pick<RulesFilter, 'namespace' | 'groupName'>, GroupFilterHandler | null>;
/**
* @returns True if the group matches the filter, false otherwise. Keeps rules intact
*/
export function groupMatches(
group: PromRuleGroupDTO,
filterState: Pick<RulesFilter, 'namespace' | 'groupName'>,
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<RulesFilter, 'namespace' | 'groupName'>
): boolean {
if (filterState.namespace && !fuzzyMatches(group.file, filterState.namespace)) {
return false;
}
return true;
}
export function groupNameFilter(
group: PromRuleGroupDTO,
filterState: Pick<RulesFilter, 'namespace' | 'groupName'>
): 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 }
);
@@ -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);
});
});
});
});
@@ -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 };
}
@@ -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'
);
});
});