(cherry picked from commit bf3422740b)
This commit is contained in:
@@ -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. Make any changes using instructions in [Add new specific policy](#add-new-specific-policy).
|
||||||
1. Click **Save 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
|
## Example
|
||||||
|
|
||||||
An example of an alert configuration.
|
An example of an alert configuration.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const LogMessages = {
|
|||||||
clickingAlertStateFilters: 'clicking alert state filters',
|
clickingAlertStateFilters: 'clicking alert state filters',
|
||||||
cancelSavingAlertRule: 'user canceled alert rule creation',
|
cancelSavingAlertRule: 'user canceled alert rule creation',
|
||||||
successSavingAlertRule: 'alert rule saved successfully',
|
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
|
// 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]);
|
expect(filteredRoutes).toContain(routes[2]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Should only return entries matching provided label query', () => {
|
it('Should only return entries matching provided matcher query', () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const routes: FormAmRoute[] = [
|
const routes: FormAmRoute[] = [
|
||||||
buildAmRoute({ id: '1' }),
|
buildAmRoute({ id: '1' }),
|
||||||
@@ -62,6 +62,28 @@ describe('getFilteredRoutes', () => {
|
|||||||
expect(filteredRoutes).toContain(routes[1]);
|
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', () => {
|
it('Should only return entries matching provided contact query', () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const routes: FormAmRoute[] = [
|
const routes: FormAmRoute[] = [
|
||||||
@@ -78,7 +100,7 @@ describe('getFilteredRoutes', () => {
|
|||||||
expect(filteredRoutes).toContain(routes[1]);
|
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
|
// Arrange
|
||||||
const routes: FormAmRoute[] = [
|
const routes: FormAmRoute[] = [
|
||||||
buildAmRoute({ id: '1' }),
|
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 React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Button, ConfirmModal, HorizontalGroup, IconButton } from '@grafana/ui';
|
import { Button, ConfirmModal, HorizontalGroup, IconButton } from '@grafana/ui';
|
||||||
@@ -30,14 +30,15 @@ type RouteTableColumnProps = DynamicTableColumnProps<FormAmRoute>;
|
|||||||
type RouteTableItemProps = DynamicTableItemProps<FormAmRoute>;
|
type RouteTableItemProps = DynamicTableItemProps<FormAmRoute>;
|
||||||
|
|
||||||
export const getFilteredRoutes = (routes: FormAmRoute[], labelMatcherQuery?: string, contactPointQuery?: string) => {
|
export const getFilteredRoutes = (routes: FormAmRoute[], labelMatcherQuery?: string, contactPointQuery?: string) => {
|
||||||
const matchers = parseMatchers(labelMatcherQuery ?? '');
|
const filterMatchers = parseMatchers(labelMatcherQuery ?? '');
|
||||||
|
|
||||||
let filteredRoutes = routes;
|
let filteredRoutes = routes;
|
||||||
|
|
||||||
if (matchers.length) {
|
if (filterMatchers.length) {
|
||||||
filteredRoutes = routes.filter((route) => {
|
filteredRoutes = routes.filter((route) => {
|
||||||
const routeMatchers = route.object_matchers.map(matcherFieldToMatcher);
|
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 { getNotificationPoliciesFilters } from '../../utils/misc';
|
||||||
import { EmptyArea } from '../EmptyArea';
|
import { EmptyArea } from '../EmptyArea';
|
||||||
import { EmptyAreaWithCTA } from '../EmptyAreaWithCTA';
|
import { EmptyAreaWithCTA } from '../EmptyAreaWithCTA';
|
||||||
import { MatcherFilter } from '../alert-groups/MatcherFilter';
|
|
||||||
|
|
||||||
import { AmRoutesTable } from './AmRoutesTable';
|
import { AmRoutesTable } from './AmRoutesTable';
|
||||||
|
import { LabelMatcherFilter } from './LabelMatcherFilter';
|
||||||
|
|
||||||
export interface AmSpecificRoutingProps {
|
export interface AmSpecificRoutingProps {
|
||||||
alertManagerSourceName: string;
|
alertManagerSourceName: string;
|
||||||
@@ -115,7 +115,7 @@ export const AmSpecificRouting: FC<AmSpecificRoutingProps> = ({
|
|||||||
<div>
|
<div>
|
||||||
{!isAddMode && (
|
{!isAddMode && (
|
||||||
<div className={styles.searchContainer}>
|
<div className={styles.searchContainer}>
|
||||||
<MatcherFilter
|
<LabelMatcherFilter
|
||||||
onFilterChange={(filter) =>
|
onFilterChange={(filter) =>
|
||||||
setFilters((currentFilters) => ({ ...currentFilters, queryString: 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;
|
||||||
|
`,
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user