mirror of https://github.com/grafana/grafana
Alerting: New alert list filter improvements (#103107)
* Move filtering code to generators for performance reasons Discarding rules and groups early in the iterable chain limits the number of promises we need to wait for which improves performance significantly * Add error handling for generators * Add support for data source filter for GMA rules * search WIP fix * Fix datasource filter * Move filtering back to filtered rules hook, use paged groups for improved performance * Add queriedDatasources field to grafana managed rules and update filtering logic to rely on it - Introduced a new field `queriedDatasources` in the AlertingRule struct to track data sources used in rules. - Updated the Prometheus API to populate `queriedDatasources` when creating alerting rules. - Modified filtering logic in the ruleFilter function to utilize the new `queriedDatasources` field for improved data source matching. - Adjusted related tests to reflect changes in rule structure and filtering behavior. * Add FilterView performance logging * Improve GMA Prometheus types, rename queried datasources property * Use custom generator helpers for flattening and filtering rule groups * Fix lint errors, add missing translations * Revert test condition * Refactor api prom changes * Fix lint errors * Update backend tests * Refactor rule list components to improve error handling and data source management - Enhanced error handling in FilterViewResults by logging errors before returning an empty iterable. - Simplified conditional rendering in GrafanaRuleLoader for better readability. - Updated data source handling in PaginatedDataSourceLoader and PaginatedGrafanaLoader to use new individual rule group generator. - Renamed toPageless function to toIndividualRuleGroups for clarity in prometheusGroupsGenerator. - Improved filtering logic in useFilteredRulesIterator to utilize a dedicated function for data source type validation. - Added isRulesDataSourceType utility function for better data source type checks. - Removed commented-out code in PromRuleDTOBase for cleaner interface definition. * Fix abort controller on FilterView * Improve generators filtering * fix abort controller * refactor cancelSearch * make states exclusive * Load full page in one loadResultPage call * Update tests, update translations * Refactor filter status into separate component * hoist hook * Use the new function for supported rules source type --------- Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>pull/103851/head
parent
1e669cbb45
commit
0a8dccc19a
@ -0,0 +1,41 @@ |
||||
import { Button, Card, Text } from '@grafana/ui'; |
||||
import { Trans, t } from 'app/core/internationalization'; |
||||
|
||||
export type FilterProgressState = 'searching' | 'done' | 'aborted'; |
||||
interface FilterStatusProps { |
||||
numberOfRules: number; |
||||
state: FilterProgressState; |
||||
onCancel: () => void; |
||||
} |
||||
|
||||
export function FilterStatus({ state, numberOfRules, onCancel }: FilterStatusProps) { |
||||
return ( |
||||
<Card> |
||||
<Text color="secondary"> |
||||
{/* done searching everything and found some results */} |
||||
{state === 'done' && ( |
||||
<Trans i18nKey="alerting.rule-list.filter-view.no-more-results"> |
||||
No more results – found {{ numberOfRules }} rules |
||||
</Trans> |
||||
)} |
||||
{/* user has cancelled the search */} |
||||
{state === 'aborted' && ( |
||||
<Trans i18nKey="alerting.rule-list.filter-view.results-with-cancellation"> |
||||
Search cancelled – found {{ numberOfRules }} rules |
||||
</Trans> |
||||
)} |
||||
{/* search is in progress */} |
||||
{state === 'searching' && ( |
||||
<Trans i18nKey="alerting.rule-list.filter-view.results-loading"> |
||||
Searching – found {{ numberOfRules }} rules |
||||
</Trans> |
||||
)} |
||||
</Text> |
||||
{state === 'searching' && ( |
||||
<Button variant="secondary" size="sm" onClick={() => onCancel()}> |
||||
{t('alerting.rule-list.filter-view.cancel-search', 'Cancel search')} |
||||
</Button> |
||||
)} |
||||
</Card> |
||||
); |
||||
} |
||||
@ -0,0 +1,257 @@ |
||||
import { PromAlertingRuleState, PromRuleGroupDTO, PromRuleType } from 'app/types/unified-alerting-dto'; |
||||
|
||||
import { mockGrafanaPromAlertingRule, mockPromAlertingRule, mockPromRecordingRule } from '../../mocks'; |
||||
import { RuleHealth } from '../../search/rulesSearchParser'; |
||||
import { Annotation } from '../../utils/constants'; |
||||
import * as datasourceUtils from '../../utils/datasource'; |
||||
import { getFilter } from '../../utils/search'; |
||||
|
||||
import { groupFilter, ruleFilter } from './filters'; |
||||
|
||||
describe('groupFilter', () => { |
||||
it('should filter by namespace (file path)', () => { |
||||
const group: PromRuleGroupDTO = { |
||||
name: 'Test Group', |
||||
file: 'production/alerts', |
||||
rules: [], |
||||
interval: 60, |
||||
}; |
||||
|
||||
expect(groupFilter(group, getFilter({ namespace: 'production' }))).toBe(true); |
||||
expect(groupFilter(group, getFilter({ namespace: 'staging' }))).toBe(false); |
||||
}); |
||||
|
||||
it('should filter by group name', () => { |
||||
const group: PromRuleGroupDTO = { |
||||
name: 'CPU Usage Alerts', |
||||
file: 'production/alerts', |
||||
rules: [], |
||||
interval: 60, |
||||
}; |
||||
|
||||
expect(groupFilter(group, getFilter({ groupName: 'cpu' }))).toBe(true); |
||||
expect(groupFilter(group, getFilter({ groupName: 'memory' }))).toBe(false); |
||||
}); |
||||
|
||||
it('should return true when no filters are applied', () => { |
||||
const group: PromRuleGroupDTO = { |
||||
name: 'Test Group', |
||||
file: 'production/alerts', |
||||
rules: [], |
||||
interval: 60, |
||||
}; |
||||
|
||||
expect(groupFilter(group, getFilter({}))).toBe(true); |
||||
}); |
||||
}); |
||||
|
||||
describe('ruleFilter', () => { |
||||
it('should filter by free form words in rule name', () => { |
||||
const rule = mockPromAlertingRule({ name: 'High CPU Usage' }); |
||||
|
||||
expect(ruleFilter(rule, getFilter({ freeFormWords: ['cpu'] }))).toBe(true); |
||||
expect(ruleFilter(rule, getFilter({ freeFormWords: ['memory'] }))).toBe(false); |
||||
}); |
||||
|
||||
it('should filter by rule name', () => { |
||||
const rule = mockPromAlertingRule({ name: 'High CPU Usage' }); |
||||
|
||||
expect(ruleFilter(rule, getFilter({ ruleName: 'cpu' }))).toBe(true); |
||||
expect(ruleFilter(rule, getFilter({ ruleName: 'memory' }))).toBe(false); |
||||
}); |
||||
|
||||
it('should filter by labels', () => { |
||||
const rule = mockPromAlertingRule({ |
||||
labels: { severity: 'critical', team: 'ops' }, |
||||
alerts: [], |
||||
}); |
||||
|
||||
expect(ruleFilter(rule, getFilter({ labels: ['severity=critical'] }))).toBe(true); |
||||
expect(ruleFilter(rule, getFilter({ labels: ['severity=warning'] }))).toBe(false); |
||||
expect(ruleFilter(rule, getFilter({ labels: ['team=ops'] }))).toBe(true); |
||||
}); |
||||
|
||||
it('should filter by alert instance labels', () => { |
||||
const rule = mockPromAlertingRule({ |
||||
labels: { severity: 'critical' }, |
||||
alerts: [ |
||||
{ |
||||
labels: { instance: 'server-1', env: 'production' }, |
||||
state: PromAlertingRuleState.Firing, |
||||
value: '100', |
||||
activeAt: '', |
||||
annotations: {}, |
||||
}, |
||||
], |
||||
}); |
||||
|
||||
expect(ruleFilter(rule, getFilter({ labels: ['instance=server-1'] }))).toBe(true); |
||||
expect(ruleFilter(rule, getFilter({ labels: ['env=production'] }))).toBe(true); |
||||
expect(ruleFilter(rule, getFilter({ labels: ['instance=server-2'] }))).toBe(false); |
||||
}); |
||||
|
||||
it('should filter by rule type', () => { |
||||
const alertingRule = mockPromAlertingRule({ name: 'Test Alert' }); |
||||
const recordingRule = mockPromRecordingRule({ name: 'Test Recording' }); |
||||
|
||||
expect(ruleFilter(alertingRule, getFilter({ ruleType: PromRuleType.Alerting }))).toBe(true); |
||||
expect(ruleFilter(alertingRule, getFilter({ ruleType: PromRuleType.Recording }))).toBe(false); |
||||
expect(ruleFilter(recordingRule, getFilter({ ruleType: PromRuleType.Recording }))).toBe(true); |
||||
expect(ruleFilter(recordingRule, getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); |
||||
}); |
||||
|
||||
it('should filter by rule state', () => { |
||||
const firingRule = mockPromAlertingRule({ |
||||
name: 'Firing Alert', |
||||
state: PromAlertingRuleState.Firing, |
||||
}); |
||||
|
||||
const pendingRule = mockPromAlertingRule({ |
||||
name: 'Pending Alert', |
||||
state: PromAlertingRuleState.Pending, |
||||
}); |
||||
|
||||
expect(ruleFilter(firingRule, getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(true); |
||||
expect(ruleFilter(firingRule, getFilter({ ruleState: PromAlertingRuleState.Pending }))).toBe(false); |
||||
expect(ruleFilter(pendingRule, getFilter({ ruleState: PromAlertingRuleState.Pending }))).toBe(true); |
||||
}); |
||||
|
||||
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
|
||||
expect(ruleFilter(recordingRule, getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); |
||||
expect(ruleFilter(recordingRule, getFilter({ ruleState: PromAlertingRuleState.Pending }))).toBe(false); |
||||
expect(ruleFilter(recordingRule, getFilter({ ruleState: PromAlertingRuleState.Inactive }))).toBe(false); |
||||
}); |
||||
|
||||
it('should filter by rule health', () => { |
||||
const healthyRule = mockPromAlertingRule({ |
||||
name: 'Healthy Rule', |
||||
health: RuleHealth.Ok, |
||||
}); |
||||
|
||||
const errorRule = mockPromAlertingRule({ |
||||
name: 'Error Rule', |
||||
health: RuleHealth.Error, |
||||
}); |
||||
|
||||
expect(ruleFilter(healthyRule, getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(true); |
||||
expect(ruleFilter(healthyRule, getFilter({ ruleHealth: RuleHealth.Error }))).toBe(false); |
||||
expect(ruleFilter(errorRule, getFilter({ ruleHealth: RuleHealth.Error }))).toBe(true); |
||||
}); |
||||
|
||||
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' }, |
||||
}); |
||||
|
||||
expect(ruleFilter(ruleDashboardA, getFilter({ dashboardUid: 'dashboard-a' }))).toBe(true); |
||||
expect(ruleFilter(ruleDashboardA, getFilter({ dashboardUid: 'dashboard-b' }))).toBe(false); |
||||
expect(ruleFilter(ruleDashboardB, getFilter({ dashboardUid: 'dashboard-b' }))).toBe(true); |
||||
}); |
||||
|
||||
it('should 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
|
||||
expect(ruleFilter(recordingRule, getFilter({ dashboardUid: 'any-dashboard' }))).toBe(false); |
||||
}); |
||||
|
||||
describe('dataSourceNames filter', () => { |
||||
let getDataSourceUIDSpy: jest.SpyInstance; |
||||
|
||||
beforeEach(() => { |
||||
getDataSourceUIDSpy = jest.spyOn(datasourceUtils, 'getDatasourceAPIUid').mockImplementation((ruleSourceName) => { |
||||
if (ruleSourceName === 'prometheus') { |
||||
return 'datasource-uid-1'; |
||||
} |
||||
if (ruleSourceName === 'loki') { |
||||
return 'datasource-uid-3'; |
||||
} |
||||
throw new Error(`Unknown datasource name: ${ruleSourceName}`); |
||||
}); |
||||
}); |
||||
|
||||
afterEach(() => { |
||||
// Clean up
|
||||
getDataSourceUIDSpy.mockRestore(); |
||||
}); |
||||
|
||||
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
|
||||
expect(ruleFilter(ruleWithMatchingDatasource, getFilter({ dataSourceNames: ['prometheus'] }))).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
|
||||
expect(ruleFilter(ruleWithoutMatchingDatasource, getFilter({ dataSourceNames: ['loki'] }))).toBe(false); |
||||
}); |
||||
|
||||
it('should return false when there is an error parsing the query', () => { |
||||
const ruleWithInvalidQuery = mockGrafanaPromAlertingRule({ |
||||
query: 'not-valid-json', |
||||
}); |
||||
|
||||
expect(ruleFilter(ruleWithInvalidQuery, getFilter({ dataSourceNames: ['prometheus'] }))).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, |
||||
}); |
||||
|
||||
expect(ruleFilter(rule, filter)).toBe(true); |
||||
}); |
||||
|
||||
it('should return false if any filter does not match', () => { |
||||
const rule = mockPromAlertingRule({ |
||||
name: 'High CPU Usage Production', |
||||
labels: { severity: 'critical', environment: 'production' }, |
||||
state: PromAlertingRuleState.Firing, |
||||
health: RuleHealth.Ok, |
||||
alerts: [], |
||||
}); |
||||
|
||||
const filter = getFilter({ |
||||
ruleName: 'cpu', |
||||
labels: ['severity=warning'], |
||||
ruleState: PromAlertingRuleState.Firing, |
||||
ruleHealth: RuleHealth.Ok, |
||||
}); |
||||
|
||||
expect(ruleFilter(rule, filter)).toBe(false); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,144 @@ |
||||
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 { parseMatcher } from '../../utils/matchers'; |
||||
import { isPluginProvidedRule, prometheusRuleType } from '../../utils/rules'; |
||||
|
||||
/** |
||||
* @returns True if the group matches the filter, false otherwise. Keeps rules intact |
||||
*/ |
||||
export function groupFilter(group: PromRuleGroupDTO, filterState: RulesFilter): boolean { |
||||
const { name, file } = group; |
||||
|
||||
// Add fuzzy search for namespace
|
||||
if (filterState.namespace && !file.toLowerCase().includes(filterState.namespace)) { |
||||
return false; |
||||
} |
||||
|
||||
// Add fuzzy search for group name
|
||||
if (filterState.groupName && !name.toLowerCase().includes(filterState.groupName)) { |
||||
return false; |
||||
} |
||||
|
||||
return true; |
||||
} |
||||
|
||||
/** |
||||
* @returns True if the rule matches the filter, false otherwise |
||||
*/ |
||||
export function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter) { |
||||
const { name, labels = {}, health, type } = rule; |
||||
|
||||
const nameLower = name.toLowerCase(); |
||||
|
||||
// Free form words filter (matches if any word is part of the rule name)
|
||||
if (filterState.freeFormWords.length > 0 && !filterState.freeFormWords.some((word) => nameLower.includes(word))) { |
||||
return false; |
||||
} |
||||
|
||||
// Rule name filter (exact match)
|
||||
if (filterState.ruleName && !nameLower.includes(filterState.ruleName)) { |
||||
return false; |
||||
} |
||||
|
||||
// Labels filter
|
||||
if (filterState.labels.length > 0) { |
||||
const matchers = compact(filterState.labels.map(looseParseMatcher)); |
||||
const doRuleLabelsMatchQuery = matchers.length > 0 && labelsMatchMatchers(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; |
||||
} |
||||
} |
||||
|
||||
// Rule type filter
|
||||
if (filterState.ruleType && type !== filterState.ruleType) { |
||||
return false; |
||||
} |
||||
|
||||
// Rule state filter (for alerting rules only)
|
||||
if (filterState.ruleState) { |
||||
if (!prometheusRuleType.alertingRule(rule)) { |
||||
return false; |
||||
} |
||||
if (rule.state !== filterState.ruleState) { |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
// Rule health filter
|
||||
if (filterState.ruleHealth && health !== filterState.ruleHealth) { |
||||
return false; |
||||
} |
||||
|
||||
// Dashboard UID filter
|
||||
if (filterState.dashboardUid) { |
||||
if (!prometheusRuleType.alertingRule(rule)) { |
||||
return false; |
||||
} |
||||
|
||||
const dashboardAnnotation = rule.annotations?.[Annotation.dashboardUID]; |
||||
if (dashboardAnnotation !== filterState.dashboardUid) { |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
// Plugins filter - hide plugin-provided rules when set to 'hide'
|
||||
if (filterState.plugins === 'hide' && isPluginProvidedRule(rule)) { |
||||
return false; |
||||
} |
||||
|
||||
// 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 } |
||||
); |
||||
Loading…
Reference in new issue