Alerting: Rules filtering improvements (#114073)
* Reorganize filtering code structure * Remove unused imports * Simplify naming
This commit is contained in:
@@ -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 }
|
||||
);
|
||||
+78
-304
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user