Alerting: Filtering for notification policies (#44363)

* Add filtering by matching label

* Add label and contact based filters to Notification policies

* Improve filters UI, add clear filters option

* Add clearing of filters before switching to adding mode

* Move filtering code to the AmRoutesTable component

* Fix the clearing of silences filter

* Remove key-based input resetting

* Use uniqueId for input key generation

* Add tests for notification policies filtering
This commit is contained in:
Konrad Lalik 2022-01-28 09:40:05 +01:00 committed by GitHub
parent 844b194f5b
commit c6e6e92a80
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 301 additions and 49 deletions

View File

@ -44,7 +44,7 @@ export const AlertGroupFilter = ({ groups }: Props) => {
<MatcherFilter
className={styles.filterInput}
key={matcherFilterKey}
queryString={queryString}
defaultQueryString={queryString}
onFilterChange={(value) => setQueryParams({ queryString: value ? value : null })}
/>
<GroupBy

View File

@ -6,10 +6,11 @@ import { css } from '@emotion/css';
interface Props {
className?: string;
queryString?: string;
defaultQueryString?: string;
onFilterChange: (filterString: string) => void;
}
export const MatcherFilter = ({ className, onFilterChange, queryString }: Props) => {
export const MatcherFilter = ({ className, onFilterChange, defaultQueryString, queryString }: Props) => {
const styles = useStyles2(getStyles);
const handleSearchChange = (e: FormEvent<HTMLInputElement>) => {
const target = e.target as HTMLInputElement;
@ -33,7 +34,8 @@ export const MatcherFilter = ({ className, onFilterChange, queryString }: Props)
</Label>
<Input
placeholder="Search"
defaultValue={queryString}
defaultValue={defaultQueryString}
value={queryString}
onChange={handleSearchChange}
data-testid="search-query-input"
prefix={searchIcon}

View File

@ -0,0 +1,112 @@
import { MatcherOperator } from 'app/plugins/datasource/alertmanager/types';
import { FormAmRoute } from '../../types/amroutes';
import { MatcherFieldValue } from '../../types/silence-form';
import { getFilteredRoutes } from './AmRoutesTable';
const defaultAmRoute: FormAmRoute = {
id: '',
object_matchers: [],
continue: false,
receiver: '',
groupBy: [],
groupWaitValue: '',
groupWaitValueType: '',
groupIntervalValue: '',
groupIntervalValueType: '',
repeatIntervalValue: '',
repeatIntervalValueType: '',
muteTimeIntervals: [],
routes: [],
};
const buildAmRoute = (override: Partial<FormAmRoute> = {}): FormAmRoute => {
return { ...defaultAmRoute, ...override };
};
const buildMatcher = (name: string, value: string, operator: MatcherOperator): MatcherFieldValue => {
return { name, value, operator };
};
describe('getFilteredRoutes', () => {
it('Shoult return all entries when filters are empty', () => {
// Arrange
const routes: FormAmRoute[] = [buildAmRoute({ id: '1' }), buildAmRoute({ id: '2' }), buildAmRoute({ id: '3' })];
// Act
const filteredRoutes = getFilteredRoutes(routes, undefined, undefined);
// Assert
expect(filteredRoutes).toHaveLength(3);
expect(filteredRoutes).toContain(routes[0]);
expect(filteredRoutes).toContain(routes[1]);
expect(filteredRoutes).toContain(routes[2]);
});
it('Should only return entries matching provided label query', () => {
// Arrange
const routes: FormAmRoute[] = [
buildAmRoute({ id: '1' }),
buildAmRoute({ id: '2', object_matchers: [buildMatcher('severity', 'critical', MatcherOperator.equal)] }),
buildAmRoute({ id: '3' }),
];
// Act
const filteredRoutes = getFilteredRoutes(routes, 'severity=critical', undefined);
// Assert
expect(filteredRoutes).toHaveLength(1);
expect(filteredRoutes).toContain(routes[1]);
});
it('Should only return entries matching provided contact query', () => {
// Arrange
const routes: FormAmRoute[] = [
buildAmRoute({ id: '1' }),
buildAmRoute({ id: '2', receiver: 'TestContactPoint' }),
buildAmRoute({ id: '3' }),
];
// Act
const filteredRoutes = getFilteredRoutes(routes, undefined, 'contact');
// Assert
expect(filteredRoutes).toHaveLength(1);
expect(filteredRoutes).toContain(routes[1]);
});
it('Should only return entries matching provided label and contact query', () => {
// Arrange
const routes: FormAmRoute[] = [
buildAmRoute({ id: '1' }),
buildAmRoute({
id: '2',
receiver: 'TestContactPoint',
object_matchers: [buildMatcher('severity', 'critical', MatcherOperator.equal)],
}),
buildAmRoute({ id: '3' }),
];
// Act
const filteredRoutes = getFilteredRoutes(routes, 'severity=critical', 'contact');
// Assert
expect(filteredRoutes).toHaveLength(1);
expect(filteredRoutes).toContain(routes[1]);
});
it('Should return entries matching regex matcher configuration without regex evaluation', () => {
// Arrange
const routes: FormAmRoute[] = [
buildAmRoute({ id: '1' }),
buildAmRoute({ id: '2', object_matchers: [buildMatcher('severity', 'critical', MatcherOperator.equal)] }),
buildAmRoute({ id: '3', object_matchers: [buildMatcher('severity', 'crit', MatcherOperator.regex)] }),
];
// Act
const filteredRoutes = getFilteredRoutes(routes, 'severity=~crit', undefined);
// Assert
expect(filteredRoutes).toHaveLength(1);
expect(filteredRoutes).toContain(routes[2]);
});
});

View File

@ -6,7 +6,9 @@ import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '..
import { AmRoutesExpandedForm } from './AmRoutesExpandedForm';
import { AmRoutesExpandedRead } from './AmRoutesExpandedRead';
import { Matchers } from '../silences/Matchers';
import { matcherFieldToMatcher } from '../../utils/alertmanager';
import { matcherFieldToMatcher, parseMatchers } from '../../utils/alertmanager';
import { intersectionWith, isEqual } from 'lodash';
import { EmptyArea } from '../EmptyArea';
export interface AmRoutesTableProps {
isAddMode: boolean;
@ -14,26 +16,47 @@ export interface AmRoutesTableProps {
onCancelAdd: () => void;
receivers: AmRouteReceiver[];
routes: FormAmRoute[];
filters?: { queryString?: string; contactPoint?: string };
readOnly?: boolean;
}
type RouteTableColumnProps = DynamicTableColumnProps<FormAmRoute>;
type RouteTableItemProps = DynamicTableItemProps<FormAmRoute>;
export const getFilteredRoutes = (routes: FormAmRoute[], labelMatcherQuery?: string, contactPointQuery?: string) => {
const matchers = parseMatchers(labelMatcherQuery ?? '');
let filteredRoutes = routes;
if (matchers.length) {
filteredRoutes = routes.filter((route) => {
const routeMatchers = route.object_matchers.map(matcherFieldToMatcher);
return intersectionWith(routeMatchers, matchers, isEqual).length > 0;
});
}
if (contactPointQuery && contactPointQuery.length > 0) {
filteredRoutes = filteredRoutes.filter((route) =>
route.receiver.toLowerCase().includes(contactPointQuery.toLowerCase())
);
}
return filteredRoutes;
};
export const AmRoutesTable: FC<AmRoutesTableProps> = ({
isAddMode,
onCancelAdd,
onChange,
receivers,
routes,
filters,
readOnly = false,
}) => {
const [editMode, setEditMode] = useState(false);
const [expandedId, setExpandedId] = useState<string | number>();
const expandItem = useCallback((item: RouteTableItemProps) => setExpandedId(item.id), []);
const collapseItem = useCallback(() => setExpandedId(undefined), []);
const cols: RouteTableColumnProps[] = [
@ -111,20 +134,37 @@ export const AmRoutesTable: FC<AmRoutesTableProps> = ({
]),
];
const items = useMemo(() => prepareItems(routes), [routes]);
const filteredRoutes = useMemo(() => getFilteredRoutes(routes, filters?.queryString, filters?.contactPoint), [
routes,
filters,
]);
const dynamicTableRoutes = useMemo(() => prepareItems(isAddMode ? routes : filteredRoutes), [
isAddMode,
routes,
filteredRoutes,
]);
// expand the last item when adding
useEffect(() => {
if (isAddMode && items.length) {
setExpandedId(items[items.length - 1].id);
if (isAddMode && dynamicTableRoutes.length) {
setExpandedId(dynamicTableRoutes[dynamicTableRoutes.length - 1].id);
}
}, [isAddMode, items]);
}, [isAddMode, dynamicTableRoutes]);
if (routes.length > 0 && filteredRoutes.length === 0) {
return (
<EmptyArea>
<p>No policies found</p>
</EmptyArea>
);
}
return (
<DynamicTable
cols={cols}
isExpandable={true}
items={items}
items={dynamicTableRoutes}
testIdGenerator={() => 'am-routes-row'}
onCollapse={collapseItem}
onExpand={expandItem}

View File

@ -1,12 +1,16 @@
import React, { FC, useState } from 'react';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, useStyles2 } from '@grafana/ui';
import { Button, Icon, Input, Label, useStyles2 } from '@grafana/ui';
import React, { FC, useState } from 'react';
import { useDebounce } from 'react-use';
import { useURLSearchParams } from '../../hooks/useURLSearchParams';
import { AmRouteReceiver, FormAmRoute } from '../../types/amroutes';
import { emptyArrayFieldMatcher, emptyRoute } from '../../utils/amroutes';
import { getNotificationPoliciesFilters } from '../../utils/misc';
import { MatcherFilter } from '../alert-groups/MatcherFilter';
import { EmptyArea } from '../EmptyArea';
import { AmRoutesTable } from './AmRoutesTable';
import { EmptyAreaWithCTA } from '../EmptyAreaWithCTA';
import { AmRoutesTable } from './AmRoutesTable';
export interface AmSpecificRoutingProps {
onChange: (routes: FormAmRoute) => void;
@ -16,6 +20,11 @@ export interface AmSpecificRoutingProps {
readOnly?: boolean;
}
interface Filters {
queryString?: string;
contactPoint?: string;
}
export const AmSpecificRouting: FC<AmSpecificRoutingProps> = ({
onChange,
onRootRouteEdit,
@ -23,15 +32,34 @@ export const AmSpecificRouting: FC<AmSpecificRoutingProps> = ({
routes,
readOnly = false,
}) => {
const [actualRoutes, setActualRoutes] = useState(routes.routes);
const [actualRoutes, setActualRoutes] = useState([...routes.routes]);
const [isAddMode, setIsAddMode] = useState(false);
const [searchParams, setSearchParams] = useURLSearchParams();
const { queryString, contactPoint } = getNotificationPoliciesFilters(searchParams);
const [filters, setFilters] = useState<Filters>({ queryString, contactPoint });
useDebounce(
() => {
setSearchParams({ queryString: filters.queryString, contactPoint: filters.contactPoint });
},
400,
[filters]
);
const styles = useStyles2(getStyles);
const clearFilters = () => {
setFilters({ queryString: undefined, contactPoint: undefined });
setSearchParams({ queryString: undefined, contactPoint: undefined });
};
const addNewRoute = () => {
clearFilters();
setIsAddMode(true);
setActualRoutes((actualRoutes) => [
...actualRoutes,
setActualRoutes(() => [
...routes.routes,
{
...emptyRoute,
matchers: [emptyArrayFieldMatcher],
@ -39,6 +67,21 @@ export const AmSpecificRouting: FC<AmSpecificRoutingProps> = ({
]);
};
const onCancelAdd = () => {
setIsAddMode(false);
setActualRoutes([...routes.routes]);
};
const onTableRouteChange = (newRoutes: FormAmRoute[]): void => {
onChange({
...routes,
routes: newRoutes,
});
if (isAddMode) {
setIsAddMode(false);
}
};
return (
<div className={styles.container}>
<h5>Specific routing</h5>
@ -58,35 +101,52 @@ export const AmSpecificRouting: FC<AmSpecificRoutingProps> = ({
)
) : actualRoutes.length > 0 ? (
<>
{!isAddMode && !readOnly && (
<Button className={styles.addMatcherBtn} icon="plus" onClick={addNewRoute} type="button">
New policy
</Button>
)}
<div>
{!isAddMode && (
<div className={styles.searchContainer}>
<MatcherFilter
onFilterChange={(filter) =>
setFilters((currentFilters) => ({ ...currentFilters, queryString: filter }))
}
queryString={filters.queryString ?? ''}
className={styles.filterInput}
/>
<div className={styles.filterInput}>
<Label>Search by contact point</Label>
<Input
onChange={({ currentTarget }) =>
setFilters((currentFilters) => ({ ...currentFilters, contactPoint: currentTarget.value }))
}
value={filters.contactPoint ?? ''}
placeholder="Search by contact point"
data-testid="search-query-input"
prefix={<Icon name={'search'} />}
/>
</div>
{(queryString || contactPoint) && (
<Button variant="secondary" icon="times" onClick={clearFilters} className={styles.clearFilterBtn}>
Clear filters
</Button>
)}
</div>
)}
{!isAddMode && !readOnly && (
<div className={styles.addMatcherBtnRow}>
<Button className={styles.addMatcherBtn} icon="plus" onClick={addNewRoute} type="button">
New policy
</Button>
</div>
)}
</div>
<AmRoutesTable
isAddMode={isAddMode}
readOnly={readOnly}
onCancelAdd={() => {
setIsAddMode(false);
setActualRoutes((actualRoutes) => {
const newRoutes = [...actualRoutes];
newRoutes.pop();
return newRoutes;
});
}}
onChange={(newRoutes) => {
onChange({
...routes,
routes: newRoutes,
});
if (isAddMode) {
setIsAddMode(false);
}
}}
onCancelAdd={onCancelAdd}
onChange={onTableRouteChange}
receivers={receivers}
routes={actualRoutes}
filters={{ queryString, contactPoint }}
/>
</>
) : readOnly ? (
@ -108,12 +168,32 @@ export const AmSpecificRouting: FC<AmSpecificRoutingProps> = ({
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css`
display: flex;
flex-flow: column wrap;
`,
searchContainer: css`
display: flex;
flex-flow: row nowrap;
padding-bottom: ${theme.spacing(2)};
border-bottom: 1px solid ${theme.colors.border.strong};
`,
clearFilterBtn: css`
align-self: flex-end;
margin-left: ${theme.spacing(1)};
`,
filterInput: css`
width: 340px;
& + & {
margin-left: ${theme.spacing(1)};
}
`,
addMatcherBtnRow: css`
display: flex;
flex-flow: column nowrap;
padding: ${theme.spacing(2)} 0;
`,
addMatcherBtn: css`
align-self: flex-end;
margin-bottom: ${theme.spacing(3.5)};
`,
};
};

View File

@ -48,7 +48,7 @@ export function RuleDetailsMatchingInstances(props: Props): JSX.Element | null {
<MatcherFilter
className={styles.rowChild}
key={queryStringKey}
queryString={queryString}
defaultQueryString={queryString}
onFilterChange={(value) => setQueryString(value)}
/>
<AlertInstanceStateFilter

View File

@ -6,15 +6,17 @@ import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { getSilenceFiltersFromUrlParams } from '../../utils/misc';
import { SilenceState } from 'app/plugins/datasource/alertmanager/types';
import { parseMatchers } from '../../utils/alertmanager';
import { debounce } from 'lodash';
import { debounce, uniqueId } from 'lodash';
const stateOptions: SelectableValue[] = Object.entries(SilenceState).map(([key, value]) => ({
label: key,
value,
}));
const getQueryStringKey = () => uniqueId('query-string-');
export const SilencesFilter = () => {
const [queryStringKey, setQueryStringKey] = useState(`queryString-${Math.random() * 100}`);
const [queryStringKey, setQueryStringKey] = useState(getQueryStringKey());
const [queryParams, setQueryParams] = useQueryParams();
const { queryString, silenceState } = getSilenceFiltersFromUrlParams(queryParams);
const styles = useStyles2(getStyles);
@ -33,7 +35,7 @@ export const SilencesFilter = () => {
queryString: null,
silenceState: null,
});
setTimeout(() => setQueryStringKey(''));
setTimeout(() => setQueryStringKey(getQueryStringKey()));
};
const inputInvalid = queryString && queryString.length > 3 ? parseMatchers(queryString).length === 0 : false;

View File

@ -1,8 +1,17 @@
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { locationService } from '@grafana/runtime';
export function useURLSearchParams(): [URLSearchParams] {
export function useURLSearchParams(): [
URLSearchParams,
(searchValues: Record<string, string | string[] | undefined>, replace?: boolean) => void
] {
const { search } = useLocation();
const queryParams = useMemo(() => new URLSearchParams(search), [search]);
return [queryParams];
const update = useCallback((searchValues: Record<string, string | string[] | undefined>, replace?: boolean) => {
locationService.partial(searchValues, replace);
}, []);
return [queryParams, update];
}

View File

@ -47,6 +47,13 @@ export const getFiltersFromUrlParams = (queryParams: UrlQueryMap): FilterState =
return { queryString, alertState, dataSource, groupBy, ruleType };
};
export const getNotificationPoliciesFilters = (searchParams: URLSearchParams) => {
return {
queryString: searchParams.get('queryString') ?? undefined,
contactPoint: searchParams.get('contactPoint') ?? undefined,
};
};
export const getSilenceFiltersFromUrlParams = (queryParams: UrlQueryMap): SilenceFilterState => {
const queryString = queryParams['queryString'] === undefined ? undefined : String(queryParams['queryString']);
const silenceState = queryParams['silenceState'] === undefined ? undefined : String(queryParams['silenceState']);