diff --git a/.betterer.results b/.betterer.results index 8a5128772b0..9cf91ecd0f9 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"] diff --git a/.betterer.results.json b/.betterer.results.json index ca07d39f380..4dd7405a36d 100644 --- a/.betterer.results.json +++ b/.betterer.results.json @@ -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.", diff --git a/public/app/features/alerting/unified/NotificationPolicies.test.tsx b/public/app/features/alerting/unified/NotificationPolicies.test.tsx index 459ea8e4e67..258cab99483 100644 --- a/public/app/features/alerting/unified/NotificationPolicies.test.tsx +++ b/public/app/features/alerting/unified/NotificationPolicies.test.tsx @@ -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(); }); }); diff --git a/public/app/features/alerting/unified/NotificationPolicies.tsx b/public/app/features/alerting/unified/NotificationPolicies.tsx index 5309c537cfc..b1696dbb2a4 100644 --- a/public/app/features/alerting/unified/NotificationPolicies.tsx +++ b/public/app/features/alerting/unified/NotificationPolicies.tsx @@ -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; + +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(); + + // 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)}; diff --git a/public/app/features/alerting/unified/__snapshots__/NotificationPolicies.test.tsx.snap b/public/app/features/alerting/unified/__snapshots__/NotificationPolicies.test.tsx.snap new file mode 100644 index 00000000000..9011ec04694 --- /dev/null +++ b/public/app/features/alerting/unified/__snapshots__/NotificationPolicies.test.tsx.snap @@ -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, + }, + ], + }, + ], + }, +} +`; diff --git a/public/app/features/alerting/unified/components/notification-policies/Filters.tsx b/public/app/features/alerting/unified/components/notification-policies/Filters.tsx index 3a9e6cb41c5..66b2ee0cd30 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Filters.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Filters.tsx @@ -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(null); @@ -50,11 +53,11 @@ const NotificationPoliciesFilter = ({ const inputInvalid = queryString && queryString.length > 3 ? parseMatchers(queryString).length === 0 : false; return ( - + + } invalid={inputInvalid} error={inputInvalid ? 'Query must use valid matcher syntax' : null} @@ -99,9 +102,16 @@ const NotificationPoliciesFilter = ({ /> {hasFilters && ( - + + + + {matchingCount === 0 && 'No policies matching filters.'} + {matchingCount === 1 && `${matchingCount} policy matches the filters.`} + {matchingCount > 1 && `${matchingCount} policies match the filters.`} + + )} ); @@ -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 { + // 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(); + + 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 }; diff --git a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx index c2069f80cf7..98f19f597c0 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx @@ -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; - routesMatchingFilters?: RouteWithID[]; + routesMatchingFilters?: RoutesMatchingFilters; - matchingInstancesPreview?: { groupsMap?: Map; enabled: boolean }; + matchingInstancesPreview?: { + groupsMap?: Map; + enabled: boolean; + }; routeTree: RouteWithID; currentRoute: RouteWithID; @@ -84,7 +88,10 @@ const Policy = (props: PolicyComponentProps) => { currentRoute, routeTree, inheritedProperties, - routesMatchingFilters = [], + routesMatchingFilters = { + filtersApplied: false, + matchedRoutesWithPath: new Map(), + }, 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 ( <> @@ -261,7 +304,7 @@ const Policy = (props: PolicyComponentProps) => {
{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 && ( + + )} )}
@@ -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 };