Alerting: Fix notification policies matcher filter (#64967) (#65031)

(cherry picked from commit bf3422740b)
This commit is contained in:
Konrad Lalik
2023-03-21 13:36:06 +01:00
committed by GitHub
parent 30ef59ec6c
commit fa21dba89e
6 changed files with 144 additions and 8 deletions
@@ -76,6 +76,23 @@ You can configure grouping to be `group_by: [alertname]` (take note that the `en
1. Make any changes using instructions in [Add new specific policy](#add-new-specific-policy).
1. Click **Save policy**.
## Searching for policies
Grafana allows you to search within the tree of policies by the following:
- **Label matchers**
- **Contact Points**
To search by contact point, simply enter a part or full name you are looking for.
To search by label matchers simply enter a valid matcher in the **Search by matchers** input field. Multiple matchers can be combined with a comma (`,`).
An example of a valid matchers search input is:
`severity=high, region=~EMEA|NASA`
> All matched policies will be **exact** matches, we currently do not support regex-style or partial matching.
## Example
An example of an alert configuration.
@@ -10,6 +10,7 @@ export const LogMessages = {
clickingAlertStateFilters: 'clicking alert state filters',
cancelSavingAlertRule: 'user canceled alert rule creation',
successSavingAlertRule: 'alert rule saved successfully',
filterPoliciesByMatchers: 'filtering notification policies by matchers',
};
// logInfo from '@grafana/runtime' should be used, but it doesn't handle Grafana JS Agent and Sentry correctly
@@ -46,7 +46,7 @@ describe('getFilteredRoutes', () => {
expect(filteredRoutes).toContain(routes[2]);
});
it('Should only return entries matching provided label query', () => {
it('Should only return entries matching provided matcher query', () => {
// Arrange
const routes: FormAmRoute[] = [
buildAmRoute({ id: '1' }),
@@ -62,6 +62,28 @@ describe('getFilteredRoutes', () => {
expect(filteredRoutes).toContain(routes[1]);
});
it('Should only return entries matching all provided matchers', () => {
// Arrange
const routes: FormAmRoute[] = [
buildAmRoute({ id: '1' }),
buildAmRoute({
id: '2',
object_matchers: [
buildMatcher('severity', 'critical', MatcherOperator.regex),
buildMatcher('cloud', 'aws', MatcherOperator.regex),
],
}),
buildAmRoute({ id: '3', object_matchers: [buildMatcher('severity', 'critical', MatcherOperator.regex)] }),
];
// Act
const filteredRoutes = getFilteredRoutes(routes, 'severity=~critical, cloud=~aws', undefined);
// Assert
expect(filteredRoutes).toHaveLength(1);
expect(filteredRoutes).toContain(routes[1]);
});
it('Should only return entries matching provided contact query', () => {
// Arrange
const routes: FormAmRoute[] = [
@@ -78,7 +100,7 @@ describe('getFilteredRoutes', () => {
expect(filteredRoutes).toContain(routes[1]);
});
it('Should only return entries matching provided label and contact query', () => {
it('Should only return entries matching provided matcher and contact query', () => {
// Arrange
const routes: FormAmRoute[] = [
buildAmRoute({ id: '1' }),
@@ -1,4 +1,4 @@
import { intersectionWith, isEqual } from 'lodash';
import { differenceWith, isEqual } from 'lodash';
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { Button, ConfirmModal, HorizontalGroup, IconButton } from '@grafana/ui';
@@ -30,14 +30,15 @@ type RouteTableColumnProps = DynamicTableColumnProps<FormAmRoute>;
type RouteTableItemProps = DynamicTableItemProps<FormAmRoute>;
export const getFilteredRoutes = (routes: FormAmRoute[], labelMatcherQuery?: string, contactPointQuery?: string) => {
const matchers = parseMatchers(labelMatcherQuery ?? '');
const filterMatchers = parseMatchers(labelMatcherQuery ?? '');
let filteredRoutes = routes;
if (matchers.length) {
if (filterMatchers.length) {
filteredRoutes = routes.filter((route) => {
const routeMatchers = route.object_matchers.map(matcherFieldToMatcher);
return intersectionWith(routeMatchers, matchers, isEqual).length > 0;
// Route matchers needs to include all filter matchers
return differenceWith(filterMatchers, routeMatchers, isEqual).length === 0;
});
}
@@ -14,9 +14,9 @@ import { emptyArrayFieldMatcher, emptyRoute } from '../../utils/amroutes';
import { getNotificationPoliciesFilters } from '../../utils/misc';
import { EmptyArea } from '../EmptyArea';
import { EmptyAreaWithCTA } from '../EmptyAreaWithCTA';
import { MatcherFilter } from '../alert-groups/MatcherFilter';
import { AmRoutesTable } from './AmRoutesTable';
import { LabelMatcherFilter } from './LabelMatcherFilter';
export interface AmSpecificRoutingProps {
alertManagerSourceName: string;
@@ -115,7 +115,7 @@ export const AmSpecificRouting: FC<AmSpecificRoutingProps> = ({
<div>
{!isAddMode && (
<div className={styles.searchContainer}>
<MatcherFilter
<LabelMatcherFilter
onFilterChange={(filter) =>
setFilters((currentFilters) => ({ ...currentFilters, queryString: filter }))
}
@@ -0,0 +1,95 @@
import { css } from '@emotion/css';
import { debounce } from 'lodash';
import React, { ChangeEvent, useEffect, useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { logInfo } from '@grafana/runtime';
import { Label, Input, Icon, useStyles2 } from '@grafana/ui';
import { LogMessages } from '../../Analytics';
import { HoverCard } from '../HoverCard';
interface Props {
className?: string;
defaultQueryString?: string;
onFilterChange: (filterString: string) => void;
}
export const LabelMatcherFilter = ({ className, onFilterChange, defaultQueryString }: Props) => {
const styles = useStyles2(getStyles);
const onSearchInputChanged = useMemo(
() =>
debounce((e: ChangeEvent<HTMLInputElement>) => {
logInfo(LogMessages.filterPoliciesByMatchers);
onFilterChange(e.target.value);
}, 600),
[onFilterChange]
);
useEffect(() => onSearchInputChanged.cancel(), [onSearchInputChanged]);
const searchIcon = <Icon name={'search'} />;
return (
<div className={className}>
<Label>
<Stack gap={0.5}>
<span>Search by matcher</span>
<HoverCard
content={
<div className={styles.hoverContent}>
Filter notification policies by <span className={styles.bold}>matchers</span>
<div className={styles.textBlock}>
Notification policies are characterized by labels matchers rathen than labels. <br />
Filtering by matchers means we compare if matchers are equal contrary to checking if a label matches a
matcher.
</div>
<div className={styles.textBlock}>
According to that, e.g. <code>severity=critical</code> equals only <code>severity=critical</code>
<br />
and <span className={styles.bold}>is not equal</span> <code>severity=~critical</code>
<br />
This is an important distinction from how filtering of alert rules works
</div>
<hr />
Filter policies using matchers querying, e.g.:
<pre>{`{severity="critical", instance=~"cluster-us-.+"}`}</pre>
</div>
}
>
<Icon className={styles.icon} name="info-circle" size="sm" />
</HoverCard>
</Stack>
</Label>
<Input
placeholder="Search"
defaultValue={defaultQueryString}
onChange={onSearchInputChanged}
data-testid="search-query-input"
prefix={searchIcon}
className={styles.inputWidth}
/>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
icon: css`
margin-right: ${theme.spacing(0.5)};
`,
inputWidth: css`
width: 340px;
flex-grow: 0;
`,
bold: css`
font-weight: ${theme.typography.fontWeightBold};
`,
textBlock: css`
padding: ${theme.spacing(1, 0)};
`,
hoverContent: css`
max-width: 600px;
`,
});