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
|
# View alert rules
|
||||||
|
|
||||||
To view alerts:
|
To view alerts:
|
||||||
|
|
||||||
1. In the Grafana menu hover your cursor over the Alerting (bell) icon.
|
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.
|
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
|
### Group view
|
||||||
|
|
||||||
@ -25,9 +26,10 @@ State view shows alert rules grouped by state. Use this view to get an overview
|
|||||||

|

|
||||||
|
|
||||||
## Filter alert rules
|
## Filter alert rules
|
||||||
|
|
||||||
You can use the following filters to view only alert rules that match specific criteria:
|
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 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.
|
- **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 {
|
export interface Labels {
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Column {
|
export interface Column {
|
||||||
text: string; // For a Column, the 'text' is the field name
|
text: string; // For a Column, the 'text' is the field name
|
||||||
filterable?: boolean;
|
filterable?: boolean;
|
||||||
|
@ -76,6 +76,7 @@ const ui = {
|
|||||||
rulesTable: byTestId('rules-table'),
|
rulesTable: byTestId('rules-table'),
|
||||||
ruleRow: byTestId('row'),
|
ruleRow: byTestId('row'),
|
||||||
expandedContent: byTestId('expanded-content'),
|
expandedContent: byTestId('expanded-content'),
|
||||||
|
rulesFilterInput: byTestId('search-query-input'),
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('RuleList', () => {
|
describe('RuleList', () => {
|
||||||
@ -299,4 +300,140 @@ describe('RuleList', () => {
|
|||||||
userEvent.click(ui.groupCollapseToggle.get(groups[1]));
|
userEvent.click(ui.groupCollapseToggle.get(groups[1]));
|
||||||
expect(ui.rulesTable.query()).not.toBeInTheDocument();
|
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 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 { DataSourceInstanceSettings, GrafanaTheme, SelectableValue } from '@grafana/data';
|
||||||
import { css, cx } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
@ -80,7 +80,19 @@ const RulesFilter = () => {
|
|||||||
<div className={cx(styles.flexRow, styles.spaceBetween)}>
|
<div className={cx(styles.flexRow, styles.spaceBetween)}>
|
||||||
<div className={styles.flexRow}>
|
<div className={styles.flexRow}>
|
||||||
<div className={styles.rowChild}>
|
<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
|
<Input
|
||||||
key={queryStringKey}
|
key={queryStringKey}
|
||||||
className={styles.inputWidth}
|
className={styles.inputWidth}
|
||||||
@ -88,6 +100,7 @@ const RulesFilter = () => {
|
|||||||
onChange={handleQueryStringChange}
|
onChange={handleQueryStringChange}
|
||||||
defaultValue={queryString}
|
defaultValue={queryString}
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
|
data-testid="search-query-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.rowChild}>
|
<div className={styles.rowChild}>
|
||||||
@ -105,7 +118,13 @@ const RulesFilter = () => {
|
|||||||
</div>
|
</div>
|
||||||
{(dataSource || alertState || queryString) && (
|
{(dataSource || alertState || queryString) && (
|
||||||
<div className={styles.flexRow}>
|
<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
|
Clear filters
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -145,8 +164,11 @@ const getStyles = (theme: GrafanaTheme) => {
|
|||||||
margin-right: ${theme.spacing.sm};
|
margin-right: ${theme.spacing.sm};
|
||||||
margin-top: ${theme.spacing.sm};
|
margin-top: ${theme.spacing.sm};
|
||||||
`,
|
`,
|
||||||
|
tooltip: css`
|
||||||
|
margin: 0 ${theme.spacing.xs};
|
||||||
|
`,
|
||||||
clearButton: css`
|
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 { isAlertingRule, isGrafanaRulerRule } from '../utils/rules';
|
||||||
import { getFiltersFromUrlParams } from '../utils/misc';
|
import { getFiltersFromUrlParams } from '../utils/misc';
|
||||||
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
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 { getDataSourceSrv } from '@grafana/runtime';
|
||||||
|
import { labelsMatchMatchers, parseMatchers } from '../utils/alertmanager';
|
||||||
|
|
||||||
export const useFilteredRules = (namespaces: CombinedRuleNamespace[]) => {
|
export const useFilteredRules = (namespaces: CombinedRuleNamespace[]) => {
|
||||||
const [queryParams] = useQueryParams();
|
const [queryParams] = useQueryParams();
|
||||||
@ -54,13 +55,16 @@ const reduceGroups = (filters: RuleFilterState) => {
|
|||||||
if (filters.queryString) {
|
if (filters.queryString) {
|
||||||
const normalizedQueryString = filters.queryString.toLocaleLowerCase();
|
const normalizedQueryString = filters.queryString.toLocaleLowerCase();
|
||||||
const doesNameContainsQueryString = rule.name?.toLocaleLowerCase().includes(normalizedQueryString);
|
const doesNameContainsQueryString = rule.name?.toLocaleLowerCase().includes(normalizedQueryString);
|
||||||
|
const matchers = parseMatchers(filters.queryString);
|
||||||
|
|
||||||
const doLabelsContainQueryString = Object.entries(rule.labels || {}).some(
|
const doRuleLabelsMatchQuery = labelsMatchMatchers(rule.labels, matchers);
|
||||||
([key, value]) =>
|
const doAlertsContainMatchingLabels =
|
||||||
key.toLocaleLowerCase().includes(normalizedQueryString) ||
|
rule.promRule &&
|
||||||
value.toLocaleLowerCase().includes(normalizedQueryString)
|
rule.promRule.type === PromRuleType.Alerting &&
|
||||||
);
|
rule.promRule.alerts &&
|
||||||
if (!(doesNameContainsQueryString || doLabelsContainQueryString)) {
|
rule.promRule.alerts.some((alert) => labelsMatchMatchers(alert.labels, matchers));
|
||||||
|
|
||||||
|
if (!(doesNameContainsQueryString || doRuleLabelsMatchQuery || doAlertsContainMatchingLabels)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { Matcher } from 'app/plugins/datasource/alertmanager/types';
|
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('Alertmanager utils', () => {
|
||||||
describe('parseMatcher', () => {
|
describe('parseMatcher', () => {
|
||||||
@ -66,4 +67,56 @@ describe('Alertmanager utils', () => {
|
|||||||
).toEqual('foo!~"boo=\\"bar\\""');
|
).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 { AlertManagerCortexConfig, MatcherOperator, Route, Matcher } from 'app/plugins/datasource/alertmanager/types';
|
||||||
|
import { Labels } from 'app/types/unified-alerting-dto';
|
||||||
|
|
||||||
export function addDefaultsToAlertmanagerConfig(config: AlertManagerCortexConfig): AlertManagerCortexConfig {
|
export function addDefaultsToAlertmanagerConfig(config: AlertManagerCortexConfig): AlertManagerCortexConfig {
|
||||||
// add default receiver if it does not exist
|
// 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,
|
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