mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: support label matcher syntax in alert rule list filter (#36408)
* Add filter parsing to rule list filters * Add unit tests for label parsing * Make label operators an enum * add example for parsing function * Update labels operator regex * Add tests to rule list for filtering * add additional test for testing alert instances filtering * Use tooltip for query syntax example * refactor to use Matchers for filtering * Update docs for label filtering on rules list * style fixes
This commit is contained in:
parent
ffa0ef9b3d
commit
5f0bc252bc
@ -8,9 +8,10 @@ weight = 400
|
||||
# View alert rules
|
||||
|
||||
To view alerts:
|
||||
|
||||
1. In the Grafana menu hover your cursor over the Alerting (bell) icon.
|
||||
1. Click **Alert Rules**. You can see all configured Grafana alert rules as well as any rules from Loki or Prometheus data sources.
|
||||
By default, the group view is shown. You can toggle between group or state views by clicking the relevant **View as** buttons in the options area at the top of the page.
|
||||
By default, the group view is shown. You can toggle between group or state views by clicking the relevant **View as** buttons in the options area at the top of the page.
|
||||
|
||||
### Group view
|
||||
|
||||
@ -25,9 +26,10 @@ State view shows alert rules grouped by state. Use this view to get an overview
|
||||

|
||||
|
||||
## Filter alert rules
|
||||
|
||||
You can use the following filters to view only alert rules that match specific criteria:
|
||||
|
||||
- **Filter alerts by name or label -** Type an alert name, label name or value in the **Search** input.
|
||||
- **Filter alerts by label -** Search by alert labels using label selectors in the **Search** input. eg: `environment=production,region=~US|EU,severity!=warning`
|
||||
- **Filter alerts by state -** In **States** Select which alert states you want to see. All others are hidden.
|
||||
- **Filter alerts by data source -** Click the **Select data source** and select an alerting data source. Only alert rules that query selected data source will be visible.
|
||||
|
||||
|
@ -128,7 +128,6 @@ export interface QueryResultBase {
|
||||
export interface Labels {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export interface Column {
|
||||
text: string; // For a Column, the 'text' is the field name
|
||||
filterable?: boolean;
|
||||
|
@ -76,6 +76,7 @@ const ui = {
|
||||
rulesTable: byTestId('rules-table'),
|
||||
ruleRow: byTestId('row'),
|
||||
expandedContent: byTestId('expanded-content'),
|
||||
rulesFilterInput: byTestId('search-query-input'),
|
||||
};
|
||||
|
||||
describe('RuleList', () => {
|
||||
@ -299,4 +300,140 @@ describe('RuleList', () => {
|
||||
userEvent.click(ui.groupCollapseToggle.get(groups[1]));
|
||||
expect(ui.rulesTable.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters rules and alerts by labels', async () => {
|
||||
mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]);
|
||||
setDataSourceSrv(new MockDataSourceSrv({ prom: dataSources.prom }));
|
||||
|
||||
mocks.api.fetchRules.mockImplementation((dataSourceName: string) => {
|
||||
if (dataSourceName === GRAFANA_RULES_SOURCE_NAME) {
|
||||
return Promise.resolve([]);
|
||||
} else {
|
||||
return Promise.resolve([
|
||||
mockPromRuleNamespace({
|
||||
groups: [
|
||||
mockPromRuleGroup({
|
||||
name: 'group-1',
|
||||
rules: [
|
||||
mockPromAlertingRule({
|
||||
name: 'alertingrule',
|
||||
labels: {
|
||||
severity: 'warning',
|
||||
foo: 'bar',
|
||||
},
|
||||
query: 'topk(5, foo)[5m]',
|
||||
annotations: {
|
||||
message: 'great alert',
|
||||
},
|
||||
alerts: [
|
||||
mockPromAlert({
|
||||
labels: {
|
||||
foo: 'bar',
|
||||
severity: 'warning',
|
||||
},
|
||||
value: '2e+10',
|
||||
annotations: {
|
||||
message: 'first alert message',
|
||||
},
|
||||
}),
|
||||
mockPromAlert({
|
||||
labels: {
|
||||
foo: 'baz',
|
||||
severity: 'error',
|
||||
},
|
||||
value: '3e+11',
|
||||
annotations: {
|
||||
message: 'first alert message',
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
mockPromRuleGroup({
|
||||
name: 'group-2',
|
||||
rules: [
|
||||
mockPromAlertingRule({
|
||||
name: 'alertingrule2',
|
||||
labels: {
|
||||
severity: 'error',
|
||||
foo: 'buzz',
|
||||
},
|
||||
query: 'topk(5, foo)[5m]',
|
||||
annotations: {
|
||||
message: 'great alert',
|
||||
},
|
||||
alerts: [
|
||||
mockPromAlert({
|
||||
labels: {
|
||||
foo: 'buzz',
|
||||
severity: 'error',
|
||||
region: 'EU',
|
||||
},
|
||||
value: '2e+10',
|
||||
annotations: {
|
||||
message: 'alert message',
|
||||
},
|
||||
}),
|
||||
mockPromAlert({
|
||||
labels: {
|
||||
foo: 'buzz',
|
||||
severity: 'error',
|
||||
region: 'US',
|
||||
},
|
||||
value: '3e+11',
|
||||
annotations: {
|
||||
message: 'alert message',
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
await renderRuleList();
|
||||
const groups = await ui.ruleGroup.findAll();
|
||||
expect(groups).toHaveLength(2);
|
||||
|
||||
const filterInput = ui.rulesFilterInput.get();
|
||||
userEvent.type(filterInput, '{foo="bar"}');
|
||||
|
||||
// Input is debounced so wait for it to be visible
|
||||
waitFor(() => expect(filterInput).toHaveTextContent('{foo="bar"}'));
|
||||
// Group doesn't contain matching labels
|
||||
waitFor(() => expect(groups[1]).not.toBeVisible());
|
||||
expect(groups[0]).toBeVisible();
|
||||
|
||||
userEvent.click(ui.groupCollapseToggle.get(groups[0]));
|
||||
|
||||
const ruleRows = ui.ruleRow.getAll(groups[0]);
|
||||
expect(ruleRows).toHaveLength(1);
|
||||
|
||||
userEvent.click(ui.ruleCollapseToggle.get(ruleRows[0]));
|
||||
const ruleDetails = ui.expandedContent.get(ruleRows[0]);
|
||||
|
||||
expect(ruleDetails).toHaveTextContent('Labelsseverity=warningfoo=bar');
|
||||
|
||||
// Check for different label matchers
|
||||
userEvent.type(filterInput, '{foo!="bar"}');
|
||||
waitFor(() => expect(filterInput).toHaveTextContent('{foo!="bar"}'));
|
||||
// Group doesn't contain matching labels
|
||||
waitFor(() => expect(groups[0]).not.toBeVisible());
|
||||
expect(groups[1]).toBeVisible();
|
||||
|
||||
userEvent.type(filterInput, '{foo=~"b.+"}');
|
||||
waitFor(() => expect(filterInput).toHaveTextContent('{foo=~"b.+"}'));
|
||||
expect(groups[0]).toBeVisible();
|
||||
expect(groups[1]).toBeVisible();
|
||||
|
||||
userEvent.type(filterInput, '{region="US"}');
|
||||
waitFor(() => expect(filterInput).toHaveTextContent('{region="US"}'));
|
||||
waitFor(() => expect(groups[0]).not.toBeVisible());
|
||||
expect(groups[1]).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React, { FormEvent, useState } from 'react';
|
||||
import { Button, Icon, Input, Label, RadioButtonGroup, useStyles } from '@grafana/ui';
|
||||
import { Button, Icon, Input, Label, RadioButtonGroup, Tooltip, useStyles } from '@grafana/ui';
|
||||
import { DataSourceInstanceSettings, GrafanaTheme, SelectableValue } from '@grafana/data';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { debounce } from 'lodash';
|
||||
@ -80,7 +80,19 @@ const RulesFilter = () => {
|
||||
<div className={cx(styles.flexRow, styles.spaceBetween)}>
|
||||
<div className={styles.flexRow}>
|
||||
<div className={styles.rowChild}>
|
||||
<Label>Search by name or label</Label>
|
||||
<Label>
|
||||
<Tooltip
|
||||
content={
|
||||
<div>
|
||||
Filter rules and alerts using label querying, ex:
|
||||
<pre>{`{severity="critical", instance=~"cluster-us-.+"}`}</pre>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Icon name="info-circle" className={styles.tooltip} />
|
||||
</Tooltip>
|
||||
Search by label
|
||||
</Label>
|
||||
<Input
|
||||
key={queryStringKey}
|
||||
className={styles.inputWidth}
|
||||
@ -88,6 +100,7 @@ const RulesFilter = () => {
|
||||
onChange={handleQueryStringChange}
|
||||
defaultValue={queryString}
|
||||
placeholder="Search"
|
||||
data-testid="search-query-input"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.rowChild}>
|
||||
@ -105,7 +118,13 @@ const RulesFilter = () => {
|
||||
</div>
|
||||
{(dataSource || alertState || queryString) && (
|
||||
<div className={styles.flexRow}>
|
||||
<Button fullWidth={false} icon="times" variant="secondary" onClick={handleClearFiltersClick}>
|
||||
<Button
|
||||
className={styles.clearButton}
|
||||
fullWidth={false}
|
||||
icon="times"
|
||||
variant="secondary"
|
||||
onClick={handleClearFiltersClick}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
@ -145,8 +164,11 @@ const getStyles = (theme: GrafanaTheme) => {
|
||||
margin-right: ${theme.spacing.sm};
|
||||
margin-top: ${theme.spacing.sm};
|
||||
`,
|
||||
tooltip: css`
|
||||
margin: 0 ${theme.spacing.xs};
|
||||
`,
|
||||
clearButton: css`
|
||||
align-self: flex-end;
|
||||
margin-top: ${theme.spacing.sm};
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
@ -5,8 +5,9 @@ import { isCloudRulesSource } from '../utils/datasource';
|
||||
import { isAlertingRule, isGrafanaRulerRule } from '../utils/rules';
|
||||
import { getFiltersFromUrlParams } from '../utils/misc';
|
||||
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||
import { RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
import { PromRuleType, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { labelsMatchMatchers, parseMatchers } from '../utils/alertmanager';
|
||||
|
||||
export const useFilteredRules = (namespaces: CombinedRuleNamespace[]) => {
|
||||
const [queryParams] = useQueryParams();
|
||||
@ -54,13 +55,16 @@ const reduceGroups = (filters: RuleFilterState) => {
|
||||
if (filters.queryString) {
|
||||
const normalizedQueryString = filters.queryString.toLocaleLowerCase();
|
||||
const doesNameContainsQueryString = rule.name?.toLocaleLowerCase().includes(normalizedQueryString);
|
||||
const matchers = parseMatchers(filters.queryString);
|
||||
|
||||
const doLabelsContainQueryString = Object.entries(rule.labels || {}).some(
|
||||
([key, value]) =>
|
||||
key.toLocaleLowerCase().includes(normalizedQueryString) ||
|
||||
value.toLocaleLowerCase().includes(normalizedQueryString)
|
||||
);
|
||||
if (!(doesNameContainsQueryString || doLabelsContainQueryString)) {
|
||||
const doRuleLabelsMatchQuery = labelsMatchMatchers(rule.labels, matchers);
|
||||
const doAlertsContainMatchingLabels =
|
||||
rule.promRule &&
|
||||
rule.promRule.type === PromRuleType.Alerting &&
|
||||
rule.promRule.alerts &&
|
||||
rule.promRule.alerts.some((alert) => labelsMatchMatchers(alert.labels, matchers));
|
||||
|
||||
if (!(doesNameContainsQueryString || doRuleLabelsMatchQuery || doAlertsContainMatchingLabels)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { Matcher } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { parseMatcher, stringifyMatcher } from './alertmanager';
|
||||
import { Labels } from 'app/types/unified-alerting-dto';
|
||||
import { parseMatcher, parseMatchers, stringifyMatcher, labelsMatchMatchers } from './alertmanager';
|
||||
|
||||
describe('Alertmanager utils', () => {
|
||||
describe('parseMatcher', () => {
|
||||
@ -66,4 +67,56 @@ describe('Alertmanager utils', () => {
|
||||
).toEqual('foo!~"boo=\\"bar\\""');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseMatchers', () => {
|
||||
it('should parse all operators', () => {
|
||||
expect(parseMatchers('foo=bar, bar=~ba.+, severity!=warning, email!~@grafana.com')).toEqual<Matcher[]>([
|
||||
{ name: 'foo', value: 'bar', isRegex: false, isEqual: true },
|
||||
{ name: 'bar', value: 'ba.+', isEqual: true, isRegex: true },
|
||||
{ name: 'severity', value: 'warning', isRegex: false, isEqual: false },
|
||||
{ name: 'email', value: '@grafana.com', isRegex: true, isEqual: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return nothing for invalid operator', () => {
|
||||
expect(parseMatchers('foo=!bar')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should parse matchers with or without quotes', () => {
|
||||
expect(parseMatchers('foo="bar",bar=bazz')).toEqual<Matcher[]>([
|
||||
{ name: 'foo', value: 'bar', isRegex: false, isEqual: true },
|
||||
{ name: 'bar', value: 'bazz', isEqual: true, isRegex: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('labelsMatchMatchers', () => {
|
||||
it('should return true for matching labels', () => {
|
||||
const labels: Labels = {
|
||||
foo: 'bar',
|
||||
bar: 'bazz',
|
||||
bazz: 'buzz',
|
||||
};
|
||||
|
||||
const matchers = parseMatchers('foo=bar,bar=bazz');
|
||||
expect(labelsMatchMatchers(labels, matchers)).toBe(true);
|
||||
});
|
||||
it('should return false for no matching labels', () => {
|
||||
const labels: Labels = {
|
||||
foo: 'bar',
|
||||
bar: 'bazz',
|
||||
};
|
||||
const matchers = parseMatchers('foo=buzz');
|
||||
expect(labelsMatchMatchers(labels, matchers)).toBe(false);
|
||||
});
|
||||
it('should match with different operators', () => {
|
||||
const labels: Labels = {
|
||||
foo: 'bar',
|
||||
bar: 'bazz',
|
||||
email: 'admin@grafana.com',
|
||||
};
|
||||
const matchers = parseMatchers('foo!=bazz,bar=~ba.+');
|
||||
expect(labelsMatchMatchers(labels, matchers)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { AlertManagerCortexConfig, MatcherOperator, Route, Matcher } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { Labels } from 'app/types/unified-alerting-dto';
|
||||
|
||||
export function addDefaultsToAlertmanagerConfig(config: AlertManagerCortexConfig): AlertManagerCortexConfig {
|
||||
// add default receiver if it does not exist
|
||||
@ -93,3 +94,45 @@ export function parseMatcher(matcher: string): Matcher {
|
||||
isEqual: operator === MatcherOperator.equal || operator === MatcherOperator.regex,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMatchers(matcherQueryString: string): Matcher[] {
|
||||
const matcherRegExp = /\b(\w+)(=~|!=|!~|=(?="?\w))"?([^"\n,]*)"?/g;
|
||||
const matchers: Matcher[] = [];
|
||||
|
||||
matcherQueryString.replace(matcherRegExp, (_, key, operator, value) => {
|
||||
const isEqual = operator === MatcherOperator.equal || operator === MatcherOperator.regex;
|
||||
const isRegex = operator === MatcherOperator.regex || operator === MatcherOperator.notRegex;
|
||||
matchers.push({
|
||||
name: key,
|
||||
value,
|
||||
isEqual,
|
||||
isRegex,
|
||||
});
|
||||
return '';
|
||||
});
|
||||
|
||||
return matchers;
|
||||
}
|
||||
|
||||
export function labelsMatchMatchers(labels: Labels, matchers: Matcher[]): boolean {
|
||||
return matchers.every(({ name, value, isRegex, isEqual }) => {
|
||||
return Object.entries(labels).some(([labelKey, labelValue]) => {
|
||||
const nameMatches = name === labelKey;
|
||||
let valueMatches;
|
||||
if (isEqual && !isRegex) {
|
||||
valueMatches = value === labelValue;
|
||||
}
|
||||
if (!isEqual && !isRegex) {
|
||||
valueMatches = value !== labelValue;
|
||||
}
|
||||
if (isEqual && isRegex) {
|
||||
valueMatches = new RegExp(value).test(labelValue);
|
||||
}
|
||||
if (!isEqual && isRegex) {
|
||||
valueMatches = !new RegExp(value).test(labelValue);
|
||||
}
|
||||
|
||||
return nameMatches && valueMatches;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user