grafana/public/app/features/alerting/unified/components/notification-policies/PoliciesTree.tsx
Matthew Jacobson 923239d48d
Alerting: Add support for multiple named notification policies (#116945)
This PR introduces multiple named notification policies to Grafana Alerting. In addition to the existing single "user-defined" routing tree, notification policies can now be created, managed, selected by alert rules, and provisioned individually.

The feature is gated behind an experimental feature flag alertingMultiplePolicies. When the flag is disabled, the system behaves exactly as before and continues to expose a single legacy routing tree in both frontend and backend APIs.
2026-02-03 16:05:13 -05:00

356 lines
13 KiB
TypeScript

import { defaults } from 'lodash';
import { useEffect, useMemo, useState } from 'react';
import { useAsyncFn } from 'react-use';
import { computeInheritedTree } from '@grafana/alerting';
import { Trans, t } from '@grafana/i18n';
import { Alert, Button, Stack } from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import { useContactPointsWithStatus } from 'app/features/alerting/unified/components/contact-points/useContactPoints';
import { AlertmanagerAction, useAlertmanagerAbility } from 'app/features/alerting/unified/hooks/useAbilities';
import { FormAmRoute } from 'app/features/alerting/unified/types/amroutes';
import { addUniqueIdentifierToRoute } from 'app/features/alerting/unified/utils/amroutes';
import { getErrorCode, stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
import { ObjectMatcher, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import { anyOfRequestState, isError } from '../../hooks/useAsync';
import { useAlertmanager } from '../../state/AlertmanagerContext';
import { ERROR_NEWER_CONFIGURATION } from '../../utils/k8s/errors';
import { routeAdapter } from '../../utils/routeAdapter';
import { alertmanagerApi } from './../../api/alertmanagerApi';
import { useGetContactPointsState } from './../../api/receiversApi';
import { useRouteGroupsMatcher } from './../../useRouteGroupsMatcher';
import { InsertPosition } from './../../utils/routeTree';
import { NotificationPoliciesFilter, findRoutesByMatchers, findRoutesMatchingPredicate } from './Filters';
import { useAddPolicyModal, useAlertGroupsModal, useDeletePolicyModal, useEditPolicyModal } from './Modals';
import { Policy } from './Policy';
import { TIMING_OPTIONS_DEFAULTS } from './timingOptions';
import {
useAddNotificationPolicy,
useDeleteNotificationPolicy,
useNotificationPolicyRoute,
useUpdateExistingNotificationPolicy,
} from './useNotificationPolicyRoute';
interface PoliciesTreeProps {
routeName?: string;
}
export const PoliciesTree = ({ routeName }: PoliciesTreeProps) => {
const appNotification = useAppNotification();
const [contactPointsSupported, canSeeContactPoints] = useAlertmanagerAbility(AlertmanagerAction.ViewContactPoint);
const [_, canSeeAlertGroups] = useAlertmanagerAbility(AlertmanagerAction.ViewAlertGroups);
const { useGetAlertmanagerAlertGroupsQuery } = alertmanagerApi;
const [contactPointFilter, setContactPointFilter] = useState<string | undefined>();
const [labelMatchersFilter, setLabelMatchersFilter] = useState<ObjectMatcher[]>([]);
const { selectedAlertmanager, hasConfigurationAPI, isGrafanaAlertmanager } = useAlertmanager();
const { getRouteGroupsMap } = useRouteGroupsMatcher();
const shouldFetchContactPoints = contactPointsSupported && canSeeContactPoints;
const contactPointsState = useGetContactPointsState(
// Workaround to not try and call this API when we don't have access to the policies tab
shouldFetchContactPoints ? (selectedAlertmanager ?? '') : ''
);
const {
currentData: defaultPolicy,
isLoading,
error: fetchPoliciesError,
refetch: refetchNotificationPolicyRoute,
} = useNotificationPolicyRoute({ alertmanager: selectedAlertmanager ?? '' }, routeName);
// deleting policies
const [deleteNotificationPolicy, deleteNotificationPolicyState] = useDeleteNotificationPolicy({
alertmanager: selectedAlertmanager ?? '',
});
// updating policies
const [updateExistingNotificationPolicy, updateExistingNotificationPolicyState] = useUpdateExistingNotificationPolicy(
{
alertmanager: selectedAlertmanager ?? '',
}
);
// adding new policies
const [addNotificationPolicy, addNotificationPolicyState] = useAddNotificationPolicy({
alertmanager: selectedAlertmanager ?? '',
});
const { currentData: alertGroups, refetch: refetchAlertGroups } = useGetAlertmanagerAlertGroupsQuery(
{ amSourceName: selectedAlertmanager ?? '' },
{ skip: !canSeeAlertGroups || !selectedAlertmanager }
);
const { contactPoints: receivers } = useContactPointsWithStatus({
alertmanager: selectedAlertmanager ?? '',
fetchPolicies: false,
fetchStatuses: true,
skip: !shouldFetchContactPoints,
});
const rootRoute = useMemo(() => {
if (defaultPolicy) {
return addUniqueIdentifierToRoute(defaultPolicy);
}
return;
}, [defaultPolicy]);
const routeProvenance = defaultPolicy?.provenance;
// useAsync could also work but it's hard to wait until it's done in the tests
// Combining with useEffect gives more predictable results because the condition is in useEffect
const [{ value: routeAlertGroupsMap, error: instancesPreviewError }, triggerGetRouteGroupsMap] = useAsyncFn(
getRouteGroupsMap,
[getRouteGroupsMap]
);
useEffect(() => {
if (rootRoute && alertGroups) {
triggerGetRouteGroupsMap(rootRoute, alertGroups, { unquoteMatchers: !isGrafanaAlertmanager });
}
}, [rootRoute, alertGroups, triggerGetRouteGroupsMap, isGrafanaAlertmanager]);
// these are computed from the contactPoint and labels matchers filter
const routesMatchingFilters = useMemo(() => {
if (!rootRoute) {
const emptyResult: RoutesMatchingFilters = {
filtersApplied: false,
matchedRoutesWithPath: new Map(),
};
return emptyResult;
}
return findRoutesMatchingFilters(rootRoute, { contactPointFilter, labelMatchersFilter });
}, [contactPointFilter, labelMatchersFilter, rootRoute]);
const refetchPolicies = () => {
refetchNotificationPolicyRoute();
updateExistingNotificationPolicy.reset();
deleteNotificationPolicy.reset();
addNotificationPolicy.reset();
};
async function handleUpdate(partialRoute: Partial<FormAmRoute>) {
await updateExistingNotificationPolicy.execute(partialRoute);
handleActionResult({ error: updateExistingNotificationPolicyState.error });
}
async function handleDelete(route: RouteWithID) {
await deleteNotificationPolicy.execute(route);
handleActionResult({ error: deleteNotificationPolicyState.error });
}
async function handleAdd(
partialRoute: Partial<FormAmRoute>,
referenceRoute: RouteWithID,
insertPosition: InsertPosition
) {
await addNotificationPolicy.execute({
partialRoute,
referenceRoute: referenceRoute,
insertPosition,
});
handleActionResult({ error: addNotificationPolicyState.error });
}
function handleActionResult({ error }: { error?: Error }) {
if (!error) {
appNotification.success('Updated notification policies');
}
if (selectedAlertmanager) {
refetchAlertGroups();
}
// close all modals
closeEditModal();
closeAddModal();
closeDeleteModal();
}
const updatingTree = anyOfRequestState(
updateExistingNotificationPolicyState,
deleteNotificationPolicyState,
addNotificationPolicyState
).loading;
// edit, add, delete modals
const [addModal, openAddModal, closeAddModal] = useAddPolicyModal(handleAdd, updatingTree);
const [editModal, openEditModal, closeEditModal] = useEditPolicyModal(
selectedAlertmanager ?? '',
handleUpdate,
updatingTree
);
const [deleteModal, openDeleteModal, closeDeleteModal] = useDeletePolicyModal(handleDelete, updatingTree);
const [alertInstancesModal, showAlertGroupsModal] = useAlertGroupsModal(selectedAlertmanager ?? '');
if (!selectedAlertmanager) {
return null;
}
const hasPoliciesData = rootRoute && !fetchPoliciesError && !isLoading;
const hasPoliciesError = Boolean(fetchPoliciesError) && !isLoading;
const hasConflictError = [
addNotificationPolicyState,
updateExistingNotificationPolicyState,
deleteNotificationPolicyState,
].some((state) => isError(state) && getErrorCode(state.error) === ERROR_NEWER_CONFIGURATION);
return (
<>
{hasPoliciesError && (
<Alert
severity="error"
title={t(
'alerting.notification-policies-list.title-error-loading-alertmanager-config',
'Error loading Alertmanager config'
)}
>
{stringifyErrorLike(fetchPoliciesError) || 'Unknown error.'}
</Alert>
)}
{/* show when there is an update error */}
{hasConflictError && (
<Alert
severity="info"
title={t(
'alerting.notification-policies-list.title-notification-policies-have-changed',
'Notification policies have changed'
)}
>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Trans i18nKey="alerting.policies.update-errors.conflict">
The notification policy tree has been updated by another user.
</Trans>
<Button onClick={refetchPolicies}>
<Trans i18nKey="alerting.policies.reload-policies">Reload policies</Trans>
</Button>
</Stack>
</Alert>
)}
{hasPoliciesData && (
<Stack direction="column" gap={1}>
<NotificationPoliciesFilter
onChangeMatchers={setLabelMatchersFilter}
onChangeReceiver={setContactPointFilter}
matchingCount={routesMatchingFilters.matchedRoutesWithPath.size}
/>
<Policy
receivers={receivers}
// add the timing defaults to the default policy to make sure child policies inherit properly
currentRoute={defaults(rootRoute, TIMING_OPTIONS_DEFAULTS)}
contactPointsState={contactPointsState.receivers}
readOnly={!hasConfigurationAPI}
provenance={routeProvenance}
alertManagerSourceName={selectedAlertmanager}
onAddPolicy={openAddModal}
onEditPolicy={openEditModal}
onDeletePolicy={openDeleteModal}
onShowAlertInstances={showAlertGroupsModal}
routesMatchingFilters={routesMatchingFilters}
matchingInstancesPreview={{
groupsMap: routeAlertGroupsMap,
enabled: Boolean(canSeeAlertGroups && !instancesPreviewError),
}}
isAutoGenerated={false}
isDefaultPolicy
/>
</Stack>
)}
{addModal}
{editModal}
{deleteModal}
{alertInstancesModal}
</>
);
};
type RouteFilters = {
contactPointFilter?: string;
labelMatchersFilter?: ObjectMatcher[];
};
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 { 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
const matchedRoutes: RouteWithID[][] = [];
// compute fully inherited tree so all policies have their inherited receiver
const adaptedRootRoute = routeAdapter.toPackage(rootRoute);
const adaptedFullTree = computeInheritedTree(adaptedRootRoute);
const fullRoute = routeAdapter.fromPackage(adaptedFullTree);
// find all routes for our contact point filter
const matchingRoutesForContactPoint = contactPointFilter
? findRoutesMatchingPredicate(fullRoute, (route) => route.receiver === contactPointFilter)
: new Map();
const routesMatchingContactPoint = Array.from(matchingRoutesForContactPoint.keys());
if (routesMatchingContactPoint) {
matchedRoutes.push(routesMatchingContactPoint);
}
// find all routes matching our label matchers
const matchingRoutesForLabelMatchers = labelMatchersFilter.length
? findRoutesMatchingPredicate(fullRoute, (route) => findRoutesByMatchers(route, labelMatchersFilter))
: new Map();
const routesMatchingLabelFilters = Array.from(matchingRoutesForLabelMatchers.keys());
if (matchingRoutesForLabelMatchers.size > 0) {
matchedRoutes.push(routesMatchingLabelFilters);
}
// 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;
}