mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
844b194f5b
commit
c6e6e92a80
@ -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
|
||||
|
@ -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}
|
||||
|
@ -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]);
|
||||
});
|
||||
});
|
@ -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}
|
||||
|
@ -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)};
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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];
|
||||
}
|
||||
|
@ -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']);
|
||||
|
Loading…
Reference in New Issue
Block a user