Alerting: Add pagination and improved search for notification policies (#81535)
This commit is contained in:
@@ -1816,9 +1816,6 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Styles should be written using objects.", "2"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "3"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/notification-policies/Filters.tsx:5381": [
|
||||
[0, 0, 0, "Styles should be written using objects.", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/notification-policies/Matchers.tsx:5381": [
|
||||
[0, 0, 0, "Styles should be written using objects.", "0"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "1"]
|
||||
|
||||
@@ -2084,12 +2084,6 @@
|
||||
"count": 4
|
||||
}
|
||||
],
|
||||
"/public/app/features/alerting/unified/components/notification-policies/Filters.tsx": [
|
||||
{
|
||||
"message": "Styles should be written using objects.",
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"/public/app/features/alerting/unified/components/notification-policies/Matchers.tsx": [
|
||||
{
|
||||
"message": "Styles should be written using objects.",
|
||||
|
||||
@@ -23,7 +23,7 @@ import { fetchAlertManagerConfig, fetchStatus, updateAlertManagerConfig } from '
|
||||
import { alertmanagerApi } from './api/alertmanagerApi';
|
||||
import { discoverAlertmanagerFeatures } from './api/buildInfo';
|
||||
import * as grafanaApp from './components/receivers/grafanaAppReceivers/grafanaApp';
|
||||
import { mockDataSource, MockDataSourceSrv, someCloudAlertManagerConfig, someCloudAlertManagerStatus } from './mocks';
|
||||
import { MockDataSourceSrv, mockDataSource, someCloudAlertManagerConfig, someCloudAlertManagerStatus } from './mocks';
|
||||
import { defaultGroupBy } from './utils/amroutes';
|
||||
import { getAllDataSources } from './utils/config';
|
||||
import { ALERTMANAGER_NAME_QUERY_KEY } from './utils/constants';
|
||||
@@ -759,18 +759,22 @@ describe('findRoutesMatchingFilters', () => {
|
||||
],
|
||||
};
|
||||
|
||||
it('should not filter when we do not have any valid filters', () => {
|
||||
expect(findRoutesMatchingFilters(simpleRouteTree, {})).toHaveProperty('filtersApplied', false);
|
||||
});
|
||||
|
||||
it('should not match non-existing', () => {
|
||||
expect(
|
||||
findRoutesMatchingFilters(simpleRouteTree, {
|
||||
labelMatchersFilter: [['foo', MatcherOperator.equal, 'bar']],
|
||||
})
|
||||
).toHaveLength(0);
|
||||
}).matchedRoutesWithPath.size
|
||||
).toBe(0);
|
||||
|
||||
expect(
|
||||
findRoutesMatchingFilters(simpleRouteTree, {
|
||||
contactPointFilter: 'does-not-exist',
|
||||
})
|
||||
).toHaveLength(0);
|
||||
const matchingRoutes = findRoutesMatchingFilters(simpleRouteTree, {
|
||||
contactPointFilter: 'does-not-exist',
|
||||
});
|
||||
|
||||
expect(matchingRoutes).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should work with only label matchers', () => {
|
||||
@@ -778,8 +782,7 @@ describe('findRoutesMatchingFilters', () => {
|
||||
labelMatchersFilter: [['hello', MatcherOperator.equal, 'world']],
|
||||
});
|
||||
|
||||
expect(matchingRoutes).toHaveLength(1);
|
||||
expect(matchingRoutes[0]).toHaveProperty('id', '1');
|
||||
expect(matchingRoutes).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should work with only contact point and inheritance', () => {
|
||||
@@ -787,9 +790,7 @@ describe('findRoutesMatchingFilters', () => {
|
||||
contactPointFilter: 'simple-receiver',
|
||||
});
|
||||
|
||||
expect(matchingRoutes).toHaveLength(2);
|
||||
expect(matchingRoutes[0]).toHaveProperty('id', '1');
|
||||
expect(matchingRoutes[1]).toHaveProperty('id', '2');
|
||||
expect(matchingRoutes).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should work with non-intersecting filters', () => {
|
||||
@@ -798,7 +799,7 @@ describe('findRoutesMatchingFilters', () => {
|
||||
contactPointFilter: 'does-not-exist',
|
||||
});
|
||||
|
||||
expect(matchingRoutes).toHaveLength(0);
|
||||
expect(matchingRoutes).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should work with all filters', () => {
|
||||
@@ -807,8 +808,7 @@ describe('findRoutesMatchingFilters', () => {
|
||||
contactPointFilter: 'simple-receiver',
|
||||
});
|
||||
|
||||
expect(matchingRoutes).toHaveLength(1);
|
||||
expect(matchingRoutes[0]).toHaveProperty('id', '1');
|
||||
expect(matchingRoutes).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { intersectionBy, isEqual } from 'lodash';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useAsyncFn } from 'react-use';
|
||||
|
||||
@@ -16,7 +15,11 @@ import { useGetContactPointsState } from './api/receiversApi';
|
||||
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
|
||||
import { GrafanaAlertmanagerDeliveryWarning } from './components/GrafanaAlertmanagerDeliveryWarning';
|
||||
import { MuteTimingsTable } from './components/mute-timings/MuteTimingsTable';
|
||||
import { NotificationPoliciesFilter, findRoutesMatchingPredicate } from './components/notification-policies/Filters';
|
||||
import {
|
||||
NotificationPoliciesFilter,
|
||||
findRoutesByMatchers,
|
||||
findRoutesMatchingPredicate,
|
||||
} from './components/notification-policies/Filters';
|
||||
import {
|
||||
useAddPolicyModal,
|
||||
useAlertGroupsModal,
|
||||
@@ -30,7 +33,6 @@ import { updateAlertManagerConfigAction } from './state/actions';
|
||||
import { FormAmRoute } from './types/amroutes';
|
||||
import { useRouteGroupsMatcher } from './useRouteGroupsMatcher';
|
||||
import { addUniqueIdentifierToRoute } from './utils/amroutes';
|
||||
import { normalizeMatchers } from './utils/matchers';
|
||||
import { computeInheritedTree } from './utils/notification-policies';
|
||||
import { initialAsyncRequestState } from './utils/redux';
|
||||
import { addRouteToParentRoute, mergePartialAmRouteWithRouteTree, omitRouteFromRouteTree } from './utils/routeTree';
|
||||
@@ -100,8 +102,14 @@ const AmRoutes = () => {
|
||||
// these are computed from the contactPoint and labels matchers filter
|
||||
const routesMatchingFilters = useMemo(() => {
|
||||
if (!rootRoute) {
|
||||
return [];
|
||||
const emptyResult: RoutesMatchingFilters = {
|
||||
filtersApplied: false,
|
||||
matchedRoutesWithPath: new Map(),
|
||||
};
|
||||
|
||||
return emptyResult;
|
||||
}
|
||||
|
||||
return findRoutesMatchingFilters(rootRoute, { contactPointFilter, labelMatchersFilter });
|
||||
}, [contactPointFilter, labelMatchersFilter, rootRoute]);
|
||||
|
||||
@@ -231,6 +239,7 @@ const AmRoutes = () => {
|
||||
receivers={receivers}
|
||||
onChangeMatchers={setLabelMatchersFilter}
|
||||
onChangeReceiver={setContactPointFilter}
|
||||
matchingCount={routesMatchingFilters.matchedRoutesWithPath.size}
|
||||
/>
|
||||
)}
|
||||
{rootRoute && (
|
||||
@@ -274,41 +283,85 @@ type RouteFilters = {
|
||||
labelMatchersFilter?: ObjectMatcher[];
|
||||
};
|
||||
|
||||
export const findRoutesMatchingFilters = (rootRoute: RouteWithID, filters: RouteFilters): RouteWithID[] => {
|
||||
type FilterResult = Map<RouteWithID, RouteWithID[]>;
|
||||
|
||||
export interface RoutesMatchingFilters {
|
||||
filtersApplied: boolean;
|
||||
matchedRoutesWithPath: FilterResult;
|
||||
}
|
||||
|
||||
export const findRoutesMatchingFilters = (rootRoute: RouteWithID, filters: RouteFilters): RoutesMatchingFilters => {
|
||||
const { contactPointFilter, labelMatchersFilter = [] } = filters;
|
||||
const hasFilter = contactPointFilter || labelMatchersFilter.length > 0;
|
||||
const havebothFilters = Boolean(contactPointFilter) && labelMatchersFilter.length > 0;
|
||||
|
||||
// if filters are empty we short-circuit this function
|
||||
if (!hasFilter) {
|
||||
return [];
|
||||
return { filtersApplied: false, matchedRoutesWithPath: new Map() };
|
||||
}
|
||||
|
||||
// we'll collect all of the routes matching the filters
|
||||
// we track an array of matching routes, each item in the array is for 1 type of filter
|
||||
//
|
||||
// [contactPointMatches, labelMatcherMatches] -> [[{ a: [], b: [] }], [{ a: [], c: [] }]]
|
||||
// later we'll use intersection to find results in all sets of filter matchers
|
||||
let matchedRoutes: RouteWithID[][] = [];
|
||||
|
||||
// compute fully inherited tree so all policies have their inherited receiver
|
||||
const fullRoute = computeInheritedTree(rootRoute);
|
||||
|
||||
const routesMatchingContactPoint = contactPointFilter
|
||||
// find all routes for our contact point filter
|
||||
const matchingRoutesForContactPoint = contactPointFilter
|
||||
? findRoutesMatchingPredicate(fullRoute, (route) => route.receiver === contactPointFilter)
|
||||
: undefined;
|
||||
: new Map();
|
||||
|
||||
const routesMatchingContactPoint = Array.from(matchingRoutesForContactPoint.keys());
|
||||
if (routesMatchingContactPoint) {
|
||||
matchedRoutes.push(routesMatchingContactPoint);
|
||||
}
|
||||
|
||||
const routesMatchingLabelMatchers = labelMatchersFilter.length
|
||||
? findRoutesMatchingPredicate(fullRoute, (route) => {
|
||||
const routeMatchers = normalizeMatchers(route);
|
||||
return labelMatchersFilter.every((filter) => routeMatchers.some((matcher) => isEqual(filter, matcher)));
|
||||
})
|
||||
: undefined;
|
||||
// find all routes matching our label matchers
|
||||
const matchingRoutesForLabelMatchers = labelMatchersFilter.length
|
||||
? findRoutesMatchingPredicate(fullRoute, (route) => findRoutesByMatchers(route, labelMatchersFilter))
|
||||
: new Map();
|
||||
|
||||
if (routesMatchingLabelMatchers) {
|
||||
matchedRoutes.push(routesMatchingLabelMatchers);
|
||||
const routesMatchingLabelFilters = Array.from(matchingRoutesForLabelMatchers.keys());
|
||||
if (matchingRoutesForLabelMatchers.size > 0) {
|
||||
matchedRoutes.push(routesMatchingLabelFilters);
|
||||
}
|
||||
|
||||
return intersectionBy(...matchedRoutes, 'id');
|
||||
// now that we have our maps for all filters, we just need to find the intersection of all maps by route if we have both filters
|
||||
const routesForAllFilterResults = havebothFilters
|
||||
? findMapIntersection(matchingRoutesForLabelMatchers, matchingRoutesForContactPoint)
|
||||
: new Map([...matchingRoutesForLabelMatchers, ...matchingRoutesForContactPoint]);
|
||||
|
||||
return {
|
||||
filtersApplied: true,
|
||||
matchedRoutesWithPath: routesForAllFilterResults,
|
||||
};
|
||||
};
|
||||
|
||||
// this function takes multiple maps and creates a new map with routes that exist in all maps
|
||||
//
|
||||
// map 1: { a: [], b: [] }
|
||||
// map 2: { a: [], c: [] }
|
||||
// return: { a: [] }
|
||||
function findMapIntersection(...matchingRoutes: FilterResult[]): FilterResult {
|
||||
const result = new Map<RouteWithID, RouteWithID[]>();
|
||||
|
||||
// Iterate through the keys of the first map'
|
||||
for (const key of matchingRoutes[0].keys()) {
|
||||
// Check if the key exists in all other maps
|
||||
if (matchingRoutes.every((map) => map.has(key))) {
|
||||
// If yes, add the key to the result map
|
||||
// @ts-ignore
|
||||
result.set(key, matchingRoutes[0].get(key));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
tabContent: css`
|
||||
margin-top: ${theme.spacing(2)};
|
||||
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`findRoutesMatchingFilters should not match non-existing 1`] = `
|
||||
{
|
||||
"filtersApplied": true,
|
||||
"matchedRoutesWithPath": Map {},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`findRoutesMatchingFilters should work with all filters 1`] = `
|
||||
{
|
||||
"filtersApplied": true,
|
||||
"matchedRoutesWithPath": Map {
|
||||
{
|
||||
"id": "1",
|
||||
"matchers": [
|
||||
"hello=world",
|
||||
"foo!=bar",
|
||||
],
|
||||
"receiver": "simple-receiver",
|
||||
"routes": [
|
||||
{
|
||||
"id": "2",
|
||||
"matchers": [
|
||||
"bar=baz",
|
||||
],
|
||||
"receiver": "simple-receiver",
|
||||
"routes": undefined,
|
||||
},
|
||||
],
|
||||
} => [
|
||||
{
|
||||
"id": "0",
|
||||
"receiver": "default-receiver",
|
||||
"routes": [
|
||||
{
|
||||
"id": "1",
|
||||
"matchers": [
|
||||
"hello=world",
|
||||
"foo!=bar",
|
||||
],
|
||||
"receiver": "simple-receiver",
|
||||
"routes": [
|
||||
{
|
||||
"id": "2",
|
||||
"matchers": [
|
||||
"bar=baz",
|
||||
],
|
||||
"receiver": "simple-receiver",
|
||||
"routes": undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"matchers": [
|
||||
"hello=world",
|
||||
"foo!=bar",
|
||||
],
|
||||
"receiver": "simple-receiver",
|
||||
"routes": [
|
||||
{
|
||||
"id": "2",
|
||||
"matchers": [
|
||||
"bar=baz",
|
||||
],
|
||||
"receiver": "simple-receiver",
|
||||
"routes": undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`findRoutesMatchingFilters should work with non-intersecting filters 1`] = `
|
||||
{
|
||||
"filtersApplied": true,
|
||||
"matchedRoutesWithPath": Map {},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`findRoutesMatchingFilters should work with only contact point and inheritance 1`] = `
|
||||
{
|
||||
"filtersApplied": true,
|
||||
"matchedRoutesWithPath": Map {
|
||||
{
|
||||
"id": "1",
|
||||
"matchers": [
|
||||
"hello=world",
|
||||
"foo!=bar",
|
||||
],
|
||||
"receiver": "simple-receiver",
|
||||
"routes": [
|
||||
{
|
||||
"id": "2",
|
||||
"matchers": [
|
||||
"bar=baz",
|
||||
],
|
||||
"receiver": "simple-receiver",
|
||||
"routes": undefined,
|
||||
},
|
||||
],
|
||||
} => [
|
||||
{
|
||||
"id": "0",
|
||||
"receiver": "default-receiver",
|
||||
"routes": [
|
||||
{
|
||||
"id": "1",
|
||||
"matchers": [
|
||||
"hello=world",
|
||||
"foo!=bar",
|
||||
],
|
||||
"receiver": "simple-receiver",
|
||||
"routes": [
|
||||
{
|
||||
"id": "2",
|
||||
"matchers": [
|
||||
"bar=baz",
|
||||
],
|
||||
"receiver": "simple-receiver",
|
||||
"routes": undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"matchers": [
|
||||
"hello=world",
|
||||
"foo!=bar",
|
||||
],
|
||||
"receiver": "simple-receiver",
|
||||
"routes": [
|
||||
{
|
||||
"id": "2",
|
||||
"matchers": [
|
||||
"bar=baz",
|
||||
],
|
||||
"receiver": "simple-receiver",
|
||||
"routes": undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
{
|
||||
"id": "2",
|
||||
"matchers": [
|
||||
"bar=baz",
|
||||
],
|
||||
"receiver": "simple-receiver",
|
||||
"routes": undefined,
|
||||
} => [
|
||||
{
|
||||
"id": "0",
|
||||
"receiver": "default-receiver",
|
||||
"routes": [
|
||||
{
|
||||
"id": "1",
|
||||
"matchers": [
|
||||
"hello=world",
|
||||
"foo!=bar",
|
||||
],
|
||||
"receiver": "simple-receiver",
|
||||
"routes": [
|
||||
{
|
||||
"id": "2",
|
||||
"matchers": [
|
||||
"bar=baz",
|
||||
],
|
||||
"receiver": "simple-receiver",
|
||||
"routes": undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"matchers": [
|
||||
"hello=world",
|
||||
"foo!=bar",
|
||||
],
|
||||
"receiver": "simple-receiver",
|
||||
"routes": [
|
||||
{
|
||||
"id": "2",
|
||||
"matchers": [
|
||||
"bar=baz",
|
||||
],
|
||||
"receiver": "simple-receiver",
|
||||
"routes": undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"matchers": [
|
||||
"bar=baz",
|
||||
],
|
||||
"receiver": "simple-receiver",
|
||||
"routes": undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`findRoutesMatchingFilters should work with only label matchers 1`] = `
|
||||
{
|
||||
"filtersApplied": true,
|
||||
"matchedRoutesWithPath": Map {
|
||||
{
|
||||
"id": "1",
|
||||
"matchers": [
|
||||
"hello=world",
|
||||
"foo!=bar",
|
||||
],
|
||||
"receiver": "simple-receiver",
|
||||
"routes": [
|
||||
{
|
||||
"id": "2",
|
||||
"matchers": [
|
||||
"bar=baz",
|
||||
],
|
||||
"receiver": "simple-receiver",
|
||||
"routes": undefined,
|
||||
},
|
||||
],
|
||||
} => [
|
||||
{
|
||||
"id": "0",
|
||||
"receiver": "default-receiver",
|
||||
"routes": [
|
||||
{
|
||||
"id": "1",
|
||||
"matchers": [
|
||||
"hello=world",
|
||||
"foo!=bar",
|
||||
],
|
||||
"receiver": "simple-receiver",
|
||||
"routes": [
|
||||
{
|
||||
"id": "2",
|
||||
"matchers": [
|
||||
"bar=baz",
|
||||
],
|
||||
"receiver": "simple-receiver",
|
||||
"routes": undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"matchers": [
|
||||
"hello=world",
|
||||
"foo!=bar",
|
||||
],
|
||||
"receiver": "simple-receiver",
|
||||
"routes": [
|
||||
{
|
||||
"id": "2",
|
||||
"matchers": [
|
||||
"bar=baz",
|
||||
],
|
||||
"receiver": "simple-receiver",
|
||||
"routes": undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
`;
|
||||
@@ -1,24 +1,27 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { debounce } from 'lodash';
|
||||
import { debounce, isEqual } from 'lodash';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { Button, Field, Icon, Input, Label as LabelElement, Select, Tooltip, useStyles2, Stack } from '@grafana/ui';
|
||||
import { Button, Field, Icon, Input, Label, Select, Stack, Text, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { ObjectMatcher, Receiver, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import { useURLSearchParams } from '../../hooks/useURLSearchParams';
|
||||
import { matcherToObjectMatcher, parseMatchers } from '../../utils/alertmanager';
|
||||
import { normalizeMatchers } from '../../utils/matchers';
|
||||
|
||||
interface NotificationPoliciesFilterProps {
|
||||
receivers: Receiver[];
|
||||
onChangeMatchers: (labels: ObjectMatcher[]) => void;
|
||||
onChangeReceiver: (receiver: string | undefined) => void;
|
||||
matchingCount: number;
|
||||
}
|
||||
|
||||
const NotificationPoliciesFilter = ({
|
||||
receivers,
|
||||
onChangeReceiver,
|
||||
onChangeMatchers,
|
||||
matchingCount,
|
||||
}: NotificationPoliciesFilterProps) => {
|
||||
const [searchParams, setSearchParams] = useURLSearchParams();
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -50,11 +53,11 @@ const NotificationPoliciesFilter = ({
|
||||
const inputInvalid = queryString && queryString.length > 3 ? parseMatchers(queryString).length === 0 : false;
|
||||
|
||||
return (
|
||||
<Stack direction="row" alignItems="flex-start" gap={0.5}>
|
||||
<Stack direction="row" alignItems="flex-end" gap={1}>
|
||||
<Field
|
||||
className={styles.noBottom}
|
||||
label={
|
||||
<LabelElement>
|
||||
<Label>
|
||||
<Stack gap={0.5}>
|
||||
<span>Search by matchers</span>
|
||||
<Tooltip
|
||||
@@ -68,7 +71,7 @@ const NotificationPoliciesFilter = ({
|
||||
<Icon name="info-circle" size="sm" />
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</LabelElement>
|
||||
</Label>
|
||||
}
|
||||
invalid={inputInvalid}
|
||||
error={inputInvalid ? 'Query must use valid matcher syntax' : null}
|
||||
@@ -99,9 +102,16 @@ const NotificationPoliciesFilter = ({
|
||||
/>
|
||||
</Field>
|
||||
{hasFilters && (
|
||||
<Button variant="secondary" icon="times" onClick={clearFilters} style={{ marginTop: 19 }}>
|
||||
Clear filters
|
||||
</Button>
|
||||
<Stack alignItems="center">
|
||||
<Button variant="secondary" icon="times" onClick={clearFilters}>
|
||||
Clear filters
|
||||
</Button>
|
||||
<Text variant="bodySmall" color="secondary">
|
||||
{matchingCount === 0 && 'No policies matching filters.'}
|
||||
{matchingCount === 1 && `${matchingCount} policy matches the filters.`}
|
||||
{matchingCount > 1 && `${matchingCount} policies match the filters.`}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
@@ -112,19 +122,46 @@ const NotificationPoliciesFilter = ({
|
||||
*/
|
||||
type FilterPredicate = (route: RouteWithID) => boolean;
|
||||
|
||||
export function findRoutesMatchingPredicate(routeTree: RouteWithID, predicateFn: FilterPredicate): RouteWithID[] {
|
||||
const matches: RouteWithID[] = [];
|
||||
/**
|
||||
* Find routes int the tree that match the given predicate function
|
||||
* @param routeTree the route tree to search
|
||||
* @param predicateFn the predicate function to match routes
|
||||
* @returns
|
||||
* - matches: list of routes that match the predicate
|
||||
* - matchingRouteIdsWithPath: map with routeids that are part of the path of a matching route
|
||||
* key is the route id, value is an array of route ids that are part of its path
|
||||
*/
|
||||
export function findRoutesMatchingPredicate(
|
||||
routeTree: RouteWithID,
|
||||
predicateFn: FilterPredicate
|
||||
): Map<RouteWithID, RouteWithID[]> {
|
||||
// map with routids that are part of the path of a matching route
|
||||
// key is the route id, value is an array of route ids that are part of the path
|
||||
const matchingRouteIdsWithPath = new Map<RouteWithID, RouteWithID[]>();
|
||||
|
||||
function findMatch(route: RouteWithID, path: RouteWithID[]) {
|
||||
const newPath = [...path, route];
|
||||
|
||||
function findMatch(route: RouteWithID) {
|
||||
if (predicateFn(route)) {
|
||||
matches.push(route);
|
||||
// if the route matches the predicate, we need to add the path to the map of matching routes
|
||||
const previousPath = matchingRouteIdsWithPath.get(route) ?? [];
|
||||
// add the current route id to the map with its path
|
||||
matchingRouteIdsWithPath.set(route, [...previousPath, ...newPath]);
|
||||
}
|
||||
|
||||
route.routes?.forEach(findMatch);
|
||||
// if the route has subroutes, call findMatch recursively
|
||||
route.routes?.forEach((route) => findMatch(route, newPath));
|
||||
}
|
||||
|
||||
findMatch(routeTree);
|
||||
return matches;
|
||||
findMatch(routeTree, []);
|
||||
|
||||
return matchingRouteIdsWithPath;
|
||||
}
|
||||
|
||||
export function findRoutesByMatchers(route: RouteWithID, labelMatchersFilter: ObjectMatcher[]): boolean {
|
||||
const routeMatchers = normalizeMatchers(route);
|
||||
|
||||
return labelMatchersFilter.every((filter) => routeMatchers.some((matcher) => isEqual(filter, matcher)));
|
||||
}
|
||||
|
||||
const toOption = (receiver: Receiver) => ({
|
||||
@@ -138,9 +175,9 @@ const getNotificationPoliciesFilters = (searchParams: URLSearchParams) => ({
|
||||
});
|
||||
|
||||
const getStyles = () => ({
|
||||
noBottom: css`
|
||||
margin-bottom: 0;
|
||||
`,
|
||||
noBottom: css({
|
||||
marginBottom: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
export { NotificationPoliciesFilter };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { defaults, groupBy, isArray, sumBy, uniqueId, upperFirst } from 'lodash';
|
||||
import pluralize from 'pluralize';
|
||||
import React, { FC, Fragment, ReactNode } from 'react';
|
||||
import React, { FC, Fragment, ReactNode, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useToggle } from 'react-use';
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from 'app/plugins/datasource/alertmanager/types';
|
||||
import { ReceiversState } from 'app/types';
|
||||
|
||||
import { RoutesMatchingFilters } from '../../NotificationPolicies';
|
||||
import { AlertmanagerAction, useAlertmanagerAbilities, useAlertmanagerAbility } from '../../hooks/useAbilities';
|
||||
import { INTEGRATION_ICONS } from '../../types/contact-points';
|
||||
import { getAmMatcherFormatter } from '../../utils/alertmanager';
|
||||
@@ -55,9 +56,12 @@ interface PolicyComponentProps {
|
||||
readOnly?: boolean;
|
||||
provisioned?: boolean;
|
||||
inheritedProperties?: Partial<InheritableProperties>;
|
||||
routesMatchingFilters?: RouteWithID[];
|
||||
routesMatchingFilters?: RoutesMatchingFilters;
|
||||
|
||||
matchingInstancesPreview?: { groupsMap?: Map<string, AlertmanagerGroup[]>; enabled: boolean };
|
||||
matchingInstancesPreview?: {
|
||||
groupsMap?: Map<string, AlertmanagerGroup[]>;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
routeTree: RouteWithID;
|
||||
currentRoute: RouteWithID;
|
||||
@@ -84,7 +88,10 @@ const Policy = (props: PolicyComponentProps) => {
|
||||
currentRoute,
|
||||
routeTree,
|
||||
inheritedProperties,
|
||||
routesMatchingFilters = [],
|
||||
routesMatchingFilters = {
|
||||
filtersApplied: false,
|
||||
matchedRoutesWithPath: new Map<RouteWithID, RouteWithID[]>(),
|
||||
},
|
||||
matchingInstancesPreview = { enabled: false },
|
||||
onEditPolicy,
|
||||
onAddPolicy,
|
||||
@@ -102,7 +109,16 @@ const Policy = (props: PolicyComponentProps) => {
|
||||
|
||||
const matchers = normalizeMatchers(currentRoute);
|
||||
const hasMatchers = Boolean(matchers && matchers.length);
|
||||
const hasFocus = routesMatchingFilters.some((route) => route.id === currentRoute.id);
|
||||
|
||||
const { filtersApplied, matchedRoutesWithPath } = routesMatchingFilters;
|
||||
const matchedRoutes = Array.from(matchedRoutesWithPath.keys());
|
||||
|
||||
// check if this route matches the filters
|
||||
const hasFocus = filtersApplied && matchedRoutes.some((route) => route.id === currentRoute.id);
|
||||
|
||||
// check if this route belongs to a path that matches the filters
|
||||
const routesPath = Array.from(matchedRoutesWithPath.values()).flat();
|
||||
const belongsToMatchPath = routesPath.some((route: RouteWithID) => route.id === currentRoute.id);
|
||||
|
||||
// gather errors here
|
||||
const errors: ReactNode[] = [];
|
||||
@@ -115,9 +131,17 @@ const Policy = (props: PolicyComponentProps) => {
|
||||
const actualContactPoint = contactPoint ?? inheritedProperties?.receiver ?? '';
|
||||
const contactPointErrors = contactPointsState ? getContactPointErrors(actualContactPoint, contactPointsState) : [];
|
||||
|
||||
const childPolicies = currentRoute.routes ?? [];
|
||||
const allChildPolicies = currentRoute.routes ?? [];
|
||||
|
||||
// filter chld policies that match
|
||||
const childPolicies = filtersApplied
|
||||
? // filter by the ones that belong to the path that matches the filters
|
||||
allChildPolicies.filter((policy) => routesPath.some((route: RouteWithID) => route.id === policy.id))
|
||||
: allChildPolicies;
|
||||
|
||||
const [showExportDrawer, toggleShowExportDrawer] = useToggle(false);
|
||||
const matchingAlertGroups = matchingInstancesPreview?.groupsMap?.get(currentRoute.id);
|
||||
|
||||
// sum all alert instances for all groups we're handling
|
||||
const numberOfAlertInstances = matchingAlertGroups
|
||||
? sumBy(matchingAlertGroups, (group) => group.alerts.length)
|
||||
@@ -127,6 +151,7 @@ const Policy = (props: PolicyComponentProps) => {
|
||||
const [isSupportedToSeeAutogeneratedChunk, isAllowedToSeeAutogeneratedChunk] = useAlertmanagerAbility(
|
||||
AlertmanagerAction.ViewAutogeneratedPolicyTree
|
||||
);
|
||||
|
||||
// collapsible policies variables
|
||||
const isThisPolicyCollapsible = useShouldPolicyBeCollapsible(currentRoute);
|
||||
const [isBranchOpen, toggleBranchOpen] = useToggle(false);
|
||||
@@ -145,6 +170,10 @@ const Policy = (props: PolicyComponentProps) => {
|
||||
errors.push(error);
|
||||
});
|
||||
|
||||
const POLICIES_PER_PAGE = 20;
|
||||
|
||||
const [visibleChildPolicies, setVisibleChildPolicies] = useState(POLICIES_PER_PAGE);
|
||||
|
||||
const isAutogeneratedPolicyRoot = isAutoGeneratedRootAndSimplifiedEnabled(currentRoute);
|
||||
|
||||
// build the menu actions for our policy
|
||||
@@ -162,12 +191,26 @@ const Policy = (props: PolicyComponentProps) => {
|
||||
// policies then we should not show it. Same if the user is not supported to see autogenerated policies.
|
||||
const hideCurrentPolicy =
|
||||
isAutoGenerated && (!isAllowedToSeeAutogeneratedChunk || !isSupportedToSeeAutogeneratedChunk);
|
||||
const hideCurrentPolicyForFilters = filtersApplied && !belongsToMatchPath;
|
||||
|
||||
if (hideCurrentPolicy) {
|
||||
if (hideCurrentPolicy || hideCurrentPolicyForFilters) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isImmutablePolicy = isDefaultPolicy || isAutogeneratedPolicyRoot;
|
||||
// TODO dead branch detection, warnings for all sort of configs that won't work or will never be activated
|
||||
|
||||
const childPoliciesBelongingToMatchPath = childPolicies.filter((child) =>
|
||||
routesPath.some((route: RouteWithID) => route.id === child.id)
|
||||
);
|
||||
|
||||
// child policies to render are the ones that belong to the path that matches the filters
|
||||
const childPoliciesToRender = filtersApplied ? childPoliciesBelongingToMatchPath : childPolicies;
|
||||
const pageOfChildren = childPoliciesToRender.slice(0, visibleChildPolicies);
|
||||
|
||||
const moreCount = childPoliciesToRender.length - pageOfChildren.length;
|
||||
const showMore = moreCount > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack direction="column" gap={1.5}>
|
||||
@@ -261,7 +304,7 @@ const Policy = (props: PolicyComponentProps) => {
|
||||
<div className={styles.childPolicies}>
|
||||
{renderChildPolicies && (
|
||||
<>
|
||||
{childPolicies.map((child) => {
|
||||
{pageOfChildren.map((child) => {
|
||||
const childInheritedProperties = getInheritedProperties(currentRoute, child, inheritedProperties);
|
||||
// This child is autogenerated if it's the autogenerated root or if it's a child of an autogenerated policy.
|
||||
const isThisChildAutoGenerated = isAutoGeneratedRootAndSimplifiedEnabled(child) || isAutoGenerated;
|
||||
@@ -291,6 +334,17 @@ const Policy = (props: PolicyComponentProps) => {
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{showMore && (
|
||||
<Button
|
||||
size="sm"
|
||||
icon="angle-down"
|
||||
variant="secondary"
|
||||
className={styles.moreButtons}
|
||||
onClick={() => setVisibleChildPolicies(visibleChildPolicies + POLICIES_PER_PAGE)}
|
||||
>
|
||||
{moreCount} additional {pluralize('policy', moreCount)}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -308,12 +362,14 @@ function useShouldPolicyBeCollapsible(route: RouteWithID): boolean {
|
||||
const [isSupportedToSeeAutogeneratedChunk, isAllowedToSeeAutogeneratedChunk] = useAlertmanagerAbility(
|
||||
AlertmanagerAction.ViewAutogeneratedPolicyTree
|
||||
);
|
||||
return (
|
||||
const isAutoGeneratedRoot =
|
||||
childrenCount > 0 &&
|
||||
isSupportedToSeeAutogeneratedChunk &&
|
||||
isAllowedToSeeAutogeneratedChunk &&
|
||||
isAutoGeneratedRootAndSimplifiedEnabled(route)
|
||||
);
|
||||
isAutoGeneratedRootAndSimplifiedEnabled(route);
|
||||
// let's add here more conditions for policies that should be collapsible
|
||||
|
||||
return isAutoGeneratedRoot;
|
||||
}
|
||||
|
||||
interface MetadataRowProps {
|
||||
@@ -836,7 +892,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
padding: theme.spacing(1.5),
|
||||
}),
|
||||
metadataRow: css({
|
||||
background: theme.colors.background.secondary,
|
||||
borderBottomLeftRadius: theme.shape.borderRadius(2),
|
||||
borderBottomRightRadius: theme.shape.borderRadius(2),
|
||||
}),
|
||||
@@ -847,7 +902,10 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
background: theme.colors.background.secondary,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
border: `solid 1px ${theme.colors.border.weak}`,
|
||||
...(hasFocus && { borderColor: theme.colors.primary.border }),
|
||||
...(hasFocus && {
|
||||
borderColor: theme.colors.primary.border,
|
||||
background: theme.colors.primary.transparent,
|
||||
}),
|
||||
}),
|
||||
metadata: css({
|
||||
color: theme.colors.text.secondary,
|
||||
@@ -873,6 +931,10 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
borderRadius: theme.shape.radius.default,
|
||||
padding: 0,
|
||||
}),
|
||||
moreButtons: css({
|
||||
marginTop: theme.spacing(0.5),
|
||||
marginBottom: theme.spacing(1.5),
|
||||
}),
|
||||
});
|
||||
|
||||
export { Policy };
|
||||
|
||||
Reference in New Issue
Block a user