From c6e6e92a802fa3cc792cf6ed6f7f7f788666906a Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Fri, 28 Jan 2022 09:40:05 +0100 Subject: [PATCH] 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 --- .../alert-groups/AlertGroupFilter.tsx | 2 +- .../components/alert-groups/MatcherFilter.tsx | 6 +- .../components/amroutes/AmRoutesTable.test.ts | 112 ++++++++++++++ .../components/amroutes/AmRoutesTable.tsx | 56 ++++++- .../components/amroutes/AmSpecificRouting.tsx | 142 ++++++++++++++---- .../rules/RuleDetailsMatchingInstances.tsx | 2 +- .../components/silences/SilencesFilter.tsx | 8 +- .../unified/hooks/useURLSearchParams.ts | 15 +- .../features/alerting/unified/utils/misc.ts | 7 + 9 files changed, 301 insertions(+), 49 deletions(-) create mode 100644 public/app/features/alerting/unified/components/amroutes/AmRoutesTable.test.ts diff --git a/public/app/features/alerting/unified/components/alert-groups/AlertGroupFilter.tsx b/public/app/features/alerting/unified/components/alert-groups/AlertGroupFilter.tsx index eb13990c421..746f3897d9d 100644 --- a/public/app/features/alerting/unified/components/alert-groups/AlertGroupFilter.tsx +++ b/public/app/features/alerting/unified/components/alert-groups/AlertGroupFilter.tsx @@ -44,7 +44,7 @@ export const AlertGroupFilter = ({ groups }: Props) => { setQueryParams({ queryString: value ? value : null })} /> void; } -export const MatcherFilter = ({ className, onFilterChange, queryString }: Props) => { +export const MatcherFilter = ({ className, onFilterChange, defaultQueryString, queryString }: Props) => { const styles = useStyles2(getStyles); const handleSearchChange = (e: FormEvent) => { const target = e.target as HTMLInputElement; @@ -33,7 +34,8 @@ export const MatcherFilter = ({ className, onFilterChange, queryString }: Props) = {}): 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]); + }); +}); diff --git a/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx b/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx index a66ae209285..0d4bf235f04 100644 --- a/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx +++ b/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx @@ -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; type RouteTableItemProps = DynamicTableItemProps; +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 = ({ isAddMode, onCancelAdd, onChange, receivers, routes, + filters, readOnly = false, }) => { const [editMode, setEditMode] = useState(false); - const [expandedId, setExpandedId] = useState(); const expandItem = useCallback((item: RouteTableItemProps) => setExpandedId(item.id), []); - const collapseItem = useCallback(() => setExpandedId(undefined), []); const cols: RouteTableColumnProps[] = [ @@ -111,20 +134,37 @@ export const AmRoutesTable: FC = ({ ]), ]; - 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 ( + +

No policies found

+
+ ); + } return ( 'am-routes-row'} onCollapse={collapseItem} onExpand={expandItem} diff --git a/public/app/features/alerting/unified/components/amroutes/AmSpecificRouting.tsx b/public/app/features/alerting/unified/components/amroutes/AmSpecificRouting.tsx index d4d36a8a197..c2acff52421 100644 --- a/public/app/features/alerting/unified/components/amroutes/AmSpecificRouting.tsx +++ b/public/app/features/alerting/unified/components/amroutes/AmSpecificRouting.tsx @@ -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 = ({ onChange, onRootRouteEdit, @@ -23,15 +32,34 @@ export const AmSpecificRouting: FC = ({ 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({ 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 = ({ ]); }; + const onCancelAdd = () => { + setIsAddMode(false); + setActualRoutes([...routes.routes]); + }; + + const onTableRouteChange = (newRoutes: FormAmRoute[]): void => { + onChange({ + ...routes, + routes: newRoutes, + }); + + if (isAddMode) { + setIsAddMode(false); + } + }; return (
Specific routing
@@ -58,35 +101,52 @@ export const AmSpecificRouting: FC = ({ ) ) : actualRoutes.length > 0 ? ( <> - {!isAddMode && !readOnly && ( - - )} +
+ {!isAddMode && ( +
+ + setFilters((currentFilters) => ({ ...currentFilters, queryString: filter })) + } + queryString={filters.queryString ?? ''} + className={styles.filterInput} + /> +
+ + + setFilters((currentFilters) => ({ ...currentFilters, contactPoint: currentTarget.value })) + } + value={filters.contactPoint ?? ''} + placeholder="Search by contact point" + data-testid="search-query-input" + prefix={} + /> +
+ {(queryString || contactPoint) && ( + + )} +
+ )} + + {!isAddMode && !readOnly && ( +
+ +
+ )} +
{ - 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 = ({ 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)}; `, }; }; diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx index db039e769b7..80efbc627eb 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx @@ -48,7 +48,7 @@ export function RuleDetailsMatchingInstances(props: Props): JSX.Element | null { setQueryString(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; diff --git a/public/app/features/alerting/unified/hooks/useURLSearchParams.ts b/public/app/features/alerting/unified/hooks/useURLSearchParams.ts index 4a9ecf08225..9ede7b89937 100644 --- a/public/app/features/alerting/unified/hooks/useURLSearchParams.ts +++ b/public/app/features/alerting/unified/hooks/useURLSearchParams.ts @@ -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, replace?: boolean) => void +] { const { search } = useLocation(); const queryParams = useMemo(() => new URLSearchParams(search), [search]); - return [queryParams]; + + const update = useCallback((searchValues: Record, replace?: boolean) => { + locationService.partial(searchValues, replace); + }, []); + + return [queryParams, update]; } diff --git a/public/app/features/alerting/unified/utils/misc.ts b/public/app/features/alerting/unified/utils/misc.ts index 51615cf0e84..0b61bef8ad5 100644 --- a/public/app/features/alerting/unified/utils/misc.ts +++ b/public/app/features/alerting/unified/utils/misc.ts @@ -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']);