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.
|
||||
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
|
||||

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