mirror of
https://github.com/grafana/grafana.git
synced 2026-02-03 20:49:50 -05:00
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.
356 lines
13 KiB
TypeScript
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;
|
|
}
|