Alerting: Allow inserting before or after existing policy (#83704)

This commit is contained in:
Gilles De Mey 2024-03-12 13:28:37 +01:00 committed by GitHub
parent 78478dc235
commit 388e0c27f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 400 additions and 81 deletions

View File

@ -1,6 +1,6 @@
import { SortOrder } from '@grafana/schema';
import { sortValues } from './arrayUtils';
import { insertAfterImmutably, insertBeforeImmutably, sortValues } from './arrayUtils';
describe('arrayUtils', () => {
describe('sortValues', () => {
@ -30,4 +30,52 @@ describe('arrayUtils', () => {
expect(sorted).toEqual(expected);
});
});
describe('insertBeforeImmutably', () => {
const original = [1, 2, 3];
it.each`
item | index | expected
${4} | ${1} | ${[1, 4, 2, 3]}
${4} | ${2} | ${[1, 2, 4, 3]}
${0} | ${0} | ${[0, 1, 2, 3]}
`('add $item before $index', ({ item, index, expected }) => {
const output = insertBeforeImmutably(original, item, index);
expect(output).toStrictEqual(expected);
});
it('should throw when out of bounds', () => {
expect(() => {
insertBeforeImmutably([], 1, -1);
}).toThrow();
expect(() => {
insertBeforeImmutably([], 1, 3);
}).toThrow();
});
});
describe('insertAfterImmutably', () => {
const original = [1, 2, 3];
it.each`
item | index | expected
${4} | ${1} | ${[1, 2, 4, 3]}
${4} | ${0} | ${[1, 4, 2, 3]}
${4} | ${2} | ${[1, 2, 3, 4]}
`('add $item after $index', ({ item, index, expected }) => {
const output = insertAfterImmutably(original, item, index);
expect(output).toStrictEqual(expected);
});
it('should throw when out of bounds', () => {
expect(() => {
insertAfterImmutably([], 1, -1);
}).toThrow();
expect(() => {
insertAfterImmutably([], 1, 3);
}).toThrow();
});
});
});

View File

@ -7,6 +7,30 @@ export function moveItemImmutably<T>(arr: T[], from: number, to: number) {
return clone;
}
/** @internal */
export function insertBeforeImmutably<T>(array: T[], item: T, index: number): T[] {
if (index < 0 || index > array.length) {
throw new Error('Index out of bounds');
}
const clone = [...array];
clone.splice(index, 0, item);
return clone;
}
/** @internal */
export function insertAfterImmutably<T>(array: T[], item: T, index: number): T[] {
if (index < 0 || index > array.length) {
throw new Error('Index out of bounds');
}
const clone = [...array];
clone.splice(index + 1, 0, item);
return clone;
}
/**
* Given a sort order and a value, return a function that can be used to sort values
* Null/undefined/empty string values are always sorted to the end regardless of the sort order provided

View File

@ -36,7 +36,12 @@ import { useRouteGroupsMatcher } from './useRouteGroupsMatcher';
import { addUniqueIdentifierToRoute } from './utils/amroutes';
import { computeInheritedTree } from './utils/notification-policies';
import { initialAsyncRequestState } from './utils/redux';
import { addRouteToParentRoute, mergePartialAmRouteWithRouteTree, omitRouteFromRouteTree } from './utils/routeTree';
import {
InsertPosition,
addRouteToReferenceRoute,
mergePartialAmRouteWithRouteTree,
omitRouteFromRouteTree,
} from './utils/routeTree';
enum ActiveTab {
NotificationPolicies = 'notification_policies',
@ -132,12 +137,18 @@ const AmRoutes = () => {
updateRouteTree(newRouteTree);
}
function handleAdd(partialRoute: Partial<FormAmRoute>, parentRoute: RouteWithID) {
function handleAdd(partialRoute: Partial<FormAmRoute>, referenceRoute: RouteWithID, insertPosition: InsertPosition) {
if (!rootRoute) {
return;
}
const newRouteTree = addRouteToParentRoute(selectedAlertmanager ?? '', partialRoute, parentRoute, rootRoute);
const newRouteTree = addRouteToReferenceRoute(
selectedAlertmanager ?? '',
partialRoute,
referenceRoute,
rootRoute,
insertPosition
);
updateRouteTree(newRouteTree);
}

View File

@ -12,6 +12,7 @@ import {
import { FormAmRoute } from '../../types/amroutes';
import { MatcherFormatter } from '../../utils/matchers';
import { InsertPosition } from '../../utils/routeTree';
import { AlertGroup } from '../alert-groups/AlertGroup';
import { useGetAmRouteReceiverWithGrafanaAppTypes } from '../receivers/grafanaAppReceivers/grafanaApp';
@ -21,24 +22,28 @@ import { AmRoutesExpandedForm } from './EditNotificationPolicyForm';
import { Matchers } from './Matchers';
type ModalHook<T = undefined> = [JSX.Element, (item: T) => void, () => void];
type AddModalHook<T = undefined> = [JSX.Element, (item: T, position: InsertPosition) => void, () => void];
type EditModalHook = [JSX.Element, (item: RouteWithID, isDefaultRoute?: boolean) => void, () => void];
const useAddPolicyModal = (
receivers: Receiver[] = [],
handleAdd: (route: Partial<FormAmRoute>, parentRoute: RouteWithID) => void,
handleAdd: (route: Partial<FormAmRoute>, referenceRoute: RouteWithID, position: InsertPosition) => void,
loading: boolean
): ModalHook<RouteWithID> => {
): AddModalHook<RouteWithID> => {
const [showModal, setShowModal] = useState(false);
const [parentRoute, setParentRoute] = useState<RouteWithID>();
const [insertPosition, setInsertPosition] = useState<InsertPosition | undefined>(undefined);
const [referenceRoute, setReferenceRoute] = useState<RouteWithID>();
const AmRouteReceivers = useGetAmRouteReceiverWithGrafanaAppTypes(receivers);
const handleDismiss = useCallback(() => {
setParentRoute(undefined);
setReferenceRoute(undefined);
setInsertPosition(undefined);
setShowModal(false);
}, []);
const handleShow = useCallback((parentRoute: RouteWithID) => {
setParentRoute(parentRoute);
const handleShow = useCallback((referenceRoute: RouteWithID, position: InsertPosition) => {
setReferenceRoute(referenceRoute);
setInsertPosition(position);
setShowModal(true);
}, []);
@ -57,9 +62,13 @@ const useAddPolicyModal = (
<AmRoutesExpandedForm
receivers={AmRouteReceivers}
defaults={{
groupBy: parentRoute?.group_by,
groupBy: referenceRoute?.group_by,
}}
onSubmit={(newRoute) => {
if (referenceRoute && insertPosition) {
handleAdd(newRoute, referenceRoute, insertPosition);
}
}}
onSubmit={(newRoute) => parentRoute && handleAdd(newRoute, parentRoute)}
actionButtons={
<Modal.ButtonRow>
<Button type="button" variant="secondary" onClick={handleDismiss} fill="outline">
@ -71,7 +80,7 @@ const useAddPolicyModal = (
/>
</Modal>
),
[AmRouteReceivers, handleAdd, handleDismiss, loading, parentRoute, showModal]
[AmRouteReceivers, handleAdd, handleDismiss, insertPosition, loading, referenceRoute, showModal]
);
return [modalElement, handleShow, handleDismiss];

View File

@ -37,6 +37,7 @@ import { getAmMatcherFormatter } from '../../utils/alertmanager';
import { MatcherFormatter, normalizeMatchers } from '../../utils/matchers';
import { createContactPointLink, createMuteTimingLink } from '../../utils/misc';
import { InheritableProperties, getInheritedProperties } from '../../utils/notification-policies';
import { InsertPosition } from '../../utils/routeTree';
import { Authorize } from '../Authorize';
import { HoverCard } from '../HoverCard';
import { Label } from '../Label';
@ -67,7 +68,7 @@ interface PolicyComponentProps {
currentRoute: RouteWithID;
alertManagerSourceName: string;
onEditPolicy: (route: RouteWithID, isDefault?: boolean, isAutogenerated?: boolean) => void;
onAddPolicy: (route: RouteWithID) => void;
onAddPolicy: (route: RouteWithID, position: InsertPosition) => void;
onDeletePolicy: (route: RouteWithID) => void;
onShowAlertInstances: (
alertGroups: AlertmanagerGroup[],
@ -133,12 +134,14 @@ const Policy = (props: PolicyComponentProps) => {
const allChildPolicies = currentRoute.routes ?? [];
// filter chld policies that match
// filter child 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 hasChildPolicies = childPolicies.length > 0;
const [showExportDrawer, toggleShowExportDrawer] = useToggle(false);
const matchingAlertGroups = matchingInstancesPreview?.groupsMap?.get(currentRoute.id);
@ -152,10 +155,9 @@ const Policy = (props: PolicyComponentProps) => {
AlertmanagerAction.ViewAutogeneratedPolicyTree
);
// collapsible policies variables
const isThisPolicyCollapsible = useShouldPolicyBeCollapsible(currentRoute);
const [isBranchOpen, toggleBranchOpen] = useToggle(false);
const renderChildPolicies = (isThisPolicyCollapsible && isBranchOpen) || !isThisPolicyCollapsible;
// we collapse the auto-generated policies by default
const isAutogeneratedPolicyRoot = isAutoGeneratedRootAndSimplifiedEnabled(currentRoute);
const [showPolicyChildren, togglePolicyChildren] = useToggle(isAutogeneratedPolicyRoot ? false : true);
const groupBy = currentRoute.group_by;
const muteTimings = currentRoute.mute_time_intervals ?? [];
@ -174,8 +176,6 @@ const Policy = (props: PolicyComponentProps) => {
const [visibleChildPolicies, setVisibleChildPolicies] = useState(POLICIES_PER_PAGE);
const isAutogeneratedPolicyRoot = isAutoGeneratedRootAndSimplifiedEnabled(currentRoute);
// build the menu actions for our policy
const dropdownMenuActions: JSX.Element[] = useCreateDropdownMenuActions(
isAutoGenerated,
@ -227,13 +227,13 @@ const Policy = (props: PolicyComponentProps) => {
{/* Matchers and actions */}
<div>
<Stack direction="row" alignItems="center" gap={1}>
{isThisPolicyCollapsible && (
{hasChildPolicies ? (
<IconButton
name={isBranchOpen ? 'angle-down' : 'angle-right'}
onClick={toggleBranchOpen}
aria-label={isBranchOpen ? 'Collapse' : 'Expand'}
name={showPolicyChildren ? 'angle-down' : 'angle-right'}
onClick={togglePolicyChildren}
aria-label={showPolicyChildren ? 'Collapse' : 'Expand'}
/>
)}
) : null}
{isImmutablePolicy ? (
isAutogeneratedPolicyRoot ? (
<AutogeneratedRootIndicator />
@ -253,16 +253,51 @@ const Policy = (props: PolicyComponentProps) => {
{!isAutoGenerated && !readOnly && (
<Authorize actions={[AlertmanagerAction.CreateNotificationPolicy]}>
<ConditionalWrap shouldWrap={provisioned} wrap={ProvisionedTooltip}>
<Button
variant="secondary"
icon="plus"
size="sm"
onClick={() => onAddPolicy(currentRoute)}
disabled={provisioned}
type="button"
>
New nested policy
</Button>
{isDefaultPolicy ? (
<Button
variant="secondary"
icon="plus"
size="sm"
disabled={provisioned}
type="button"
onClick={() => onAddPolicy(currentRoute, 'child')}
>
New child policy
</Button>
) : (
<Dropdown
overlay={
<Menu>
<Menu.Item
label="Insert above"
icon="arrow-up"
onClick={() => onAddPolicy(currentRoute, 'above')}
/>
<Menu.Item
label="Insert below"
icon="arrow-down"
onClick={() => onAddPolicy(currentRoute, 'below')}
/>
<Menu.Divider />
<Menu.Item
label="New child policy"
icon="plus"
onClick={() => onAddPolicy(currentRoute, 'child')}
/>
</Menu>
}
>
<Button
size="sm"
variant="secondary"
disabled={provisioned}
icon="angle-down"
type="button"
>
Add new policy
</Button>
</Dropdown>
)}
</ConditionalWrap>
</Authorize>
)}
@ -302,7 +337,7 @@ const Policy = (props: PolicyComponentProps) => {
</div>
</div>
<div className={styles.childPolicies}>
{renderChildPolicies && (
{showPolicyChildren && (
<>
{pageOfChildren.map((child) => {
const childInheritedProperties = getInheritedProperties(currentRoute, child, inheritedProperties);
@ -353,24 +388,6 @@ const Policy = (props: PolicyComponentProps) => {
</>
);
};
/**
* This function returns if the policy should be collapsible or not.
* Add here conditions for policies that should be collapsible.
*/
function useShouldPolicyBeCollapsible(route: RouteWithID): boolean {
const childrenCount = route.routes?.length ?? 0;
const [isSupportedToSeeAutogeneratedChunk, isAllowedToSeeAutogeneratedChunk] = useAlertmanagerAbility(
AlertmanagerAction.ViewAutogeneratedPolicyTree
);
const isAutoGeneratedRoot =
childrenCount > 0 &&
isSupportedToSeeAutogeneratedChunk &&
isAllowedToSeeAutogeneratedChunk &&
isAutoGeneratedRootAndSimplifiedEnabled(route);
// let's add here more conditions for policies that should be collapsible
return isAutoGeneratedRoot;
}
interface MetadataRowProps {
matchingInstancesPreview: { groupsMap?: Map<string, AlertmanagerGroup[]>; enabled: boolean };

View File

@ -0,0 +1,87 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`addRouteToReferenceRoute should be able to add above 1`] = `
{
"id": "route-1",
"routes": [
{
"id": "route-2",
},
{
"continue": undefined,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"match": undefined,
"match_re": undefined,
"matchers": undefined,
"mute_time_intervals": undefined,
"object_matchers": undefined,
"receiver": "new-route",
"repeat_interval": undefined,
"routes": undefined,
},
{
"id": "route-3",
},
],
}
`;
exports[`addRouteToReferenceRoute should be able to add as child 1`] = `
{
"id": "route-1",
"routes": [
{
"id": "route-2",
},
{
"id": "route-3",
"routes": [
{
"continue": undefined,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"match": undefined,
"match_re": undefined,
"matchers": undefined,
"mute_time_intervals": undefined,
"object_matchers": undefined,
"receiver": "new-route",
"repeat_interval": undefined,
"routes": undefined,
},
],
},
],
}
`;
exports[`addRouteToReferenceRoute should be able to add below 1`] = `
{
"id": "route-1",
"routes": [
{
"id": "route-2",
},
{
"id": "route-3",
},
{
"continue": undefined,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"match": undefined,
"match_re": undefined,
"matchers": undefined,
"mute_time_intervals": undefined,
"object_matchers": undefined,
"receiver": "new-route",
"repeat_interval": undefined,
"routes": undefined,
},
],
}
`;

View File

@ -0,0 +1,87 @@
import { RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import { FormAmRoute } from '../types/amroutes';
import { GRAFANA_DATASOURCE_NAME } from './datasource';
import { addRouteToReferenceRoute, findRouteInTree, omitRouteFromRouteTree } from './routeTree';
describe('findRouteInTree', () => {
it('should find the correct route', () => {
const needle: RouteWithID = { id: 'route-2' };
const root: RouteWithID = {
id: 'route-0',
routes: [{ id: 'route-1' }, needle, { id: 'route-3', routes: [{ id: 'route-4' }] }],
};
expect(findRouteInTree(root, { id: 'route-2' })).toStrictEqual([needle, root, 1]);
});
it('should return undefined for unknown route', () => {
const root: RouteWithID = {
id: 'route-0',
routes: [{ id: 'route-1' }],
};
expect(findRouteInTree(root, { id: 'none' })).toStrictEqual([undefined, undefined, undefined]);
});
});
describe('addRouteToReferenceRoute', () => {
const targetRoute = { id: 'route-3' };
const root: RouteWithID = {
id: 'route-1',
routes: [{ id: 'route-2' }, targetRoute],
};
const newRoute: Partial<FormAmRoute> = {
id: 'new-route',
receiver: 'new-route',
};
it('should be able to add above', () => {
expect(addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, targetRoute, root, 'above')).toMatchSnapshot();
});
it('should be able to add below', () => {
expect(addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, targetRoute, root, 'below')).toMatchSnapshot();
});
it('should be able to add as child', () => {
expect(addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, targetRoute, root, 'child')).toMatchSnapshot();
});
it('should throw if target route does not exist', () => {
expect(() =>
addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, { id: 'unknown' }, root, 'child')
).toThrow();
});
});
describe('omitRouteFromRouteTree', () => {
it('should omit route from tree', () => {
const tree: RouteWithID = {
id: 'route-1',
receiver: 'root',
routes: [
{ id: 'route-2', receiver: 'receiver-2' },
{ id: 'route-3', receiver: 'receiver-3' },
],
};
expect(omitRouteFromRouteTree({ id: 'route-2' }, tree)).toStrictEqual({
receiver: 'root',
routes: [{ receiver: 'receiver-3', routes: undefined }],
});
});
it('should throw when removing root route from tree', () => {
const tree: RouteWithID = {
id: 'route-1',
};
expect(() => {
omitRouteFromRouteTree(tree, { id: 'route-1' });
}).toThrow();
});
});

View File

@ -2,8 +2,10 @@
* Various helper functions to modify (immutably) the route tree, aka "notification policies"
*/
import { produce } from 'immer';
import { omit } from 'lodash';
import { insertAfterImmutably, insertBeforeImmutably } from '@grafana/data/src/utils/arrayUtils';
import { Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import { FormAmRoute } from '../types/amroutes';
@ -74,44 +76,78 @@ export const omitRouteFromRouteTree = (findRoute: RouteWithID, routeTree: RouteW
return findAndOmit(routeTree);
};
export type InsertPosition = 'above' | 'below' | 'child';
// add a new route to a parent route
export const addRouteToParentRoute = (
export const addRouteToReferenceRoute = (
alertManagerSourceName: string,
partialFormRoute: Partial<FormAmRoute>,
parentRoute: RouteWithID,
routeTree: RouteWithID
referenceRoute: RouteWithID,
routeTree: RouteWithID,
position: InsertPosition
): Route => {
const newRoute = formAmRouteToAmRoute(alertManagerSourceName, partialFormRoute, routeTree);
function findAndAdd(currentRoute: RouteWithID): RouteWithID {
if (currentRoute.id === parentRoute.id) {
return {
...currentRoute,
// TODO fix this typescript exception, it's... complicated
// @ts-ignore
routes: currentRoute.routes?.concat(newRoute),
};
return produce(routeTree, (draftTree) => {
const [routeInTree, parentRoute, positionInParent] = findRouteInTree(draftTree, referenceRoute);
if (routeInTree === undefined || parentRoute === undefined || positionInParent === undefined) {
throw new Error(`could not find reference route "${referenceRoute.id}" in tree`);
}
return {
...currentRoute,
routes: currentRoute.routes?.map(findAndAdd),
};
}
// if user wants to insert new child policy, append to the bottom of children
if (position === 'child') {
if (routeInTree.routes) {
routeInTree.routes.push(newRoute);
} else {
routeInTree.routes = [newRoute];
}
}
function findAndOmitId(currentRoute: RouteWithID): Route {
return omit(
{
...currentRoute,
routes: currentRoute.routes?.map(findAndOmitId),
},
'id'
);
}
// insert new policy before / above the referenceRoute
if (position === 'above') {
parentRoute.routes = insertBeforeImmutably(parentRoute.routes ?? [], newRoute, positionInParent);
}
return findAndOmitId(findAndAdd(routeTree));
// insert new policy after / below the referenceRoute
if (position === 'below') {
parentRoute.routes = insertAfterImmutably(parentRoute.routes ?? [], newRoute, positionInParent);
}
});
};
type RouteMatch = Route | undefined;
export function findRouteInTree(
routeTree: RouteWithID,
referenceRoute: RouteWithID
): [matchingRoute: RouteMatch, parentRoute: RouteMatch, positionInParent: number | undefined] {
let matchingRoute: RouteMatch;
let matchingRouteParent: RouteMatch;
let matchingRoutePositionInParent: number | undefined;
// recurse through the tree to find the matching route, its parent and the position of the route in the parent
function findRouteInTree(currentRoute: RouteWithID, index: number, parentRoute: RouteWithID) {
if (matchingRoute) {
return;
}
if (currentRoute.id === referenceRoute.id) {
matchingRoute = currentRoute;
matchingRouteParent = parentRoute;
matchingRoutePositionInParent = index;
}
if (currentRoute.routes) {
currentRoute.routes.forEach((route, index) => findRouteInTree(route, index, currentRoute));
}
}
findRouteInTree(routeTree, 0, routeTree);
return [matchingRoute, matchingRouteParent, matchingRoutePositionInParent];
}
export function findExistingRoute(id: string, routeTree: RouteWithID): RouteWithID | undefined {
return routeTree.id === id ? routeTree : routeTree.routes?.find((route) => findExistingRoute(id, route));
}