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:
Nathan Rodman 2021-07-26 12:05:49 -07:00 committed by GitHub
parent ffa0ef9b3d
commit 5f0bc252bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 279 additions and 19 deletions

View File

@ -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.
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.
### Group view
@ -25,9 +26,10 @@ State view shows alert rules grouped by state. Use this view to get an overview
![Alert rule state view](/static/img/docs/alerting/unified/rule-list-state-view-8-0.png 'Screenshot of alert rule state view')
## 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.
@ -39,13 +41,13 @@ A rule row shows the rule state, health, and summary annotation if the rule has
### Edit or delete rule
Grafana rules can only be edited or deleted by users with Edit permissions for the folder which contains the rule. Prometheus or Loki rules can be edited or deleted by users with Editor or Admin roles.
Grafana rules can only be edited or deleted by users with Edit permissions for the folder which contains the rule. Prometheus or Loki rules can be edited or deleted by users with Editor or Admin roles.
To edit or delete a rule:
1. Expand this rule to reveal rule controls.
1. Expand this rule to reveal rule controls.
1. Click **Edit** to go to the rule editing form. Make changes following [instructions listed here]({{< relref "./create-grafana-managed-rule.md" >}}).
1. Click **Delete"** to delete a rule.
1. Click **Delete"** to delete a rule.
## Opt-out a Loki or Prometheus data source

View File

@ -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;

View File

@ -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();
});
});

View File

@ -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};
`,
};
};

View File

@ -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;
}
}

View File

@ -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);
});
});
});

View File

@ -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;
});
});
}