mirror of
https://github.com/grafana/grafana.git
synced 2025-08-02 04:09:50 +08:00
Alerting: Use useProduceNewAlertmanagerConfiguration for notification policies (#98615)
Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
@ -1,11 +0,0 @@
|
||||
import { generatedRoutesApi } from 'app/features/alerting/unified/openapi/routesApi.gen';
|
||||
|
||||
export const routingTreeApi = generatedRoutesApi.enhanceEndpoints({
|
||||
endpoints: {
|
||||
replaceNamespacedRoutingTree: {
|
||||
// Stop a failed mutation from invalidating the cache, as otherwise the notification policies
|
||||
// components will re-attach IDs to the routes, and then the user can't update the route anyway
|
||||
invalidatesTags: (_, error) => (error ? [] : ['RoutingTree']),
|
||||
},
|
||||
},
|
||||
});
|
@ -7,7 +7,6 @@ import { AlertState, AlertmanagerGroup, ObjectMatcher, RouteWithID } from 'app/p
|
||||
|
||||
import { FormAmRoute } from '../../types/amroutes';
|
||||
import { MatcherFormatter } from '../../utils/matchers';
|
||||
import { stringifyErrorLike } from '../../utils/misc';
|
||||
import { InsertPosition } from '../../utils/routeTree';
|
||||
import { AlertGroup } from '../alert-groups/AlertGroup';
|
||||
|
||||
@ -20,9 +19,8 @@ import { NotificationPoliciesErrorAlert } from './PolicyUpdateErrorAlert';
|
||||
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 = (
|
||||
handleAdd: (route: Partial<FormAmRoute>, referenceRoute: RouteWithID, position: InsertPosition) => void,
|
||||
handleAdd: (route: Partial<FormAmRoute>, referenceRoute: RouteWithID, position: InsertPosition) => Promise<void>,
|
||||
loading: boolean
|
||||
): AddModalHook<RouteWithID> => {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
@ -32,6 +30,7 @@ const useAddPolicyModal = (
|
||||
const handleDismiss = useCallback(() => {
|
||||
setReferenceRoute(undefined);
|
||||
setInsertPosition(undefined);
|
||||
setError(undefined);
|
||||
setShowModal(false);
|
||||
}, []);
|
||||
|
||||
@ -41,6 +40,8 @@ const useAddPolicyModal = (
|
||||
setShowModal(true);
|
||||
}, []);
|
||||
|
||||
const [error, setError] = useState<Error | undefined>(undefined);
|
||||
|
||||
const modalElement = useMemo(
|
||||
() =>
|
||||
loading ? (
|
||||
@ -53,13 +54,14 @@ const useAddPolicyModal = (
|
||||
closeOnEscape={true}
|
||||
title="Add notification policy"
|
||||
>
|
||||
{error && <NotificationPoliciesErrorAlert error={error} />}
|
||||
<AmRoutesExpandedForm
|
||||
defaults={{
|
||||
groupBy: referenceRoute?.group_by,
|
||||
}}
|
||||
onSubmit={(newRoute) => {
|
||||
if (referenceRoute && insertPosition) {
|
||||
handleAdd(newRoute, referenceRoute, insertPosition);
|
||||
handleAdd(newRoute, referenceRoute, insertPosition).catch(setError);
|
||||
}
|
||||
}}
|
||||
actionButtons={
|
||||
@ -75,7 +77,7 @@ const useAddPolicyModal = (
|
||||
/>
|
||||
</Modal>
|
||||
),
|
||||
[handleAdd, handleDismiss, insertPosition, loading, referenceRoute, showModal]
|
||||
[error, handleAdd, handleDismiss, insertPosition, loading, referenceRoute, showModal, setError]
|
||||
);
|
||||
|
||||
return [modalElement, handleShow, handleDismiss];
|
||||
@ -83,17 +85,19 @@ const useAddPolicyModal = (
|
||||
|
||||
const useEditPolicyModal = (
|
||||
alertManagerSourceName: string,
|
||||
handleSave: (route: Partial<FormAmRoute>) => void,
|
||||
loading: boolean,
|
||||
error?: Error
|
||||
handleUpdate: (route: Partial<FormAmRoute>) => Promise<void>,
|
||||
loading: boolean
|
||||
): EditModalHook => {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [isDefaultPolicy, setIsDefaultPolicy] = useState(false);
|
||||
const [route, setRoute] = useState<RouteWithID>();
|
||||
|
||||
const [error, setError] = useState<Error | undefined>(undefined);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setRoute(undefined);
|
||||
setShowModal(false);
|
||||
setError(undefined);
|
||||
}, []);
|
||||
|
||||
const handleShow = useCallback((route: RouteWithID, isDefaultPolicy?: boolean) => {
|
||||
@ -114,13 +118,13 @@ const useEditPolicyModal = (
|
||||
closeOnEscape={true}
|
||||
title="Edit notification policy"
|
||||
>
|
||||
{error && <NotificationPoliciesErrorAlert error={stringifyErrorLike(error)} />}
|
||||
{error && <NotificationPoliciesErrorAlert error={error} />}
|
||||
{isDefaultPolicy && route && (
|
||||
<AmRootRouteForm
|
||||
// TODO *sigh* this alertmanagersourcename should come from context or something
|
||||
// passing it down all the way here is a code smell
|
||||
alertManagerSourceName={alertManagerSourceName}
|
||||
onSubmit={handleSave}
|
||||
onSubmit={(values) => handleUpdate(values).catch(setError)}
|
||||
route={route}
|
||||
actionButtons={
|
||||
<Modal.ButtonRow>
|
||||
@ -137,7 +141,7 @@ const useEditPolicyModal = (
|
||||
{!isDefaultPolicy && (
|
||||
<AmRoutesExpandedForm
|
||||
route={route}
|
||||
onSubmit={handleSave}
|
||||
onSubmit={(values) => handleUpdate(values).catch(setError)}
|
||||
actionButtons={
|
||||
<Modal.ButtonRow>
|
||||
<Button type="button" variant="secondary" onClick={handleDismiss} fill="outline">
|
||||
@ -152,19 +156,24 @@ const useEditPolicyModal = (
|
||||
)}
|
||||
</Modal>
|
||||
),
|
||||
[alertManagerSourceName, error, handleDismiss, handleSave, isDefaultPolicy, loading, route, showModal]
|
||||
[loading, showModal, handleDismiss, error, isDefaultPolicy, route, alertManagerSourceName, handleUpdate, setError]
|
||||
);
|
||||
|
||||
return [modalElement, handleShow, handleDismiss];
|
||||
};
|
||||
|
||||
const useDeletePolicyModal = (handleDelete: (route: RouteWithID) => void, loading: boolean): ModalHook<RouteWithID> => {
|
||||
const useDeletePolicyModal = (
|
||||
handleDelete: (route: RouteWithID) => Promise<void>,
|
||||
loading: boolean
|
||||
): ModalHook<RouteWithID> => {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [route, setRoute] = useState<RouteWithID>();
|
||||
const [error, setError] = useState<Error | undefined>(undefined);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setRoute(undefined);
|
||||
setShowModal(false);
|
||||
setError(undefined);
|
||||
}, [setRoute]);
|
||||
|
||||
const handleShow = useCallback((route: RouteWithID) => {
|
||||
@ -172,12 +181,6 @@ const useDeletePolicyModal = (handleDelete: (route: RouteWithID) => void, loadin
|
||||
setShowModal(true);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (route) {
|
||||
handleDelete(route);
|
||||
}
|
||||
}, [handleDelete, route]);
|
||||
|
||||
const modalElement = useMemo(
|
||||
() =>
|
||||
loading ? (
|
||||
@ -190,13 +193,13 @@ const useDeletePolicyModal = (handleDelete: (route: RouteWithID) => void, loadin
|
||||
closeOnEscape={true}
|
||||
title="Delete notification policy"
|
||||
>
|
||||
{error && <NotificationPoliciesErrorAlert error={error} />}
|
||||
<Trans i18nKey="alerting.policies.delete.warning-1">
|
||||
Deleting this notification policy will permanently remove it.
|
||||
</Trans>
|
||||
</Trans>{' '}
|
||||
<Trans i18nKey="alerting.policies.delete.warning-2">Are you sure you want to delete this policy?</Trans>
|
||||
|
||||
<Modal.ButtonRow>
|
||||
<Button type="button" variant="destructive" onClick={handleSubmit}>
|
||||
<Button type="button" variant="destructive" onClick={() => route && handleDelete(route).catch(setError)}>
|
||||
<Trans i18nKey="alerting.policies.delete.confirm">Yes, delete policy</Trans>
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={handleDismiss}>
|
||||
@ -205,7 +208,7 @@ const useDeletePolicyModal = (handleDelete: (route: RouteWithID) => void, loadin
|
||||
</Modal.ButtonRow>
|
||||
</Modal>
|
||||
),
|
||||
[handleDismiss, handleSubmit, loading, showModal]
|
||||
[handleDismiss, loading, showModal, error, route, handleDelete]
|
||||
);
|
||||
|
||||
return [modalElement, handleShow, handleDismiss];
|
||||
|
@ -8,28 +8,27 @@ import { useContactPointsWithStatus } from 'app/features/alerting/unified/compon
|
||||
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 { ERROR_NEWER_CONFIGURATION } from 'app/features/alerting/unified/utils/k8s/errors';
|
||||
import { isErrorMatchingCode, stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
|
||||
import { getErrorCode, stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
|
||||
import { computeInheritedTree } from 'app/features/alerting/unified/utils/notification-policies';
|
||||
import { ObjectMatcher, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { ObjectMatcher, ROUTES_META_SYMBOL, 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 { alertmanagerApi } from './../../api/alertmanagerApi';
|
||||
import { useGetContactPointsState } from './../../api/receiversApi';
|
||||
import { isLoading as isPending, useAsync } from './../../hooks/useAsync';
|
||||
import { useRouteGroupsMatcher } from './../../useRouteGroupsMatcher';
|
||||
import {
|
||||
InsertPosition,
|
||||
addRouteToReferenceRoute,
|
||||
cleanRouteIDs,
|
||||
mergePartialAmRouteWithRouteTree,
|
||||
omitRouteFromRouteTree,
|
||||
} from './../../utils/routeTree';
|
||||
import { InsertPosition } from './../../utils/routeTree';
|
||||
import { NotificationPoliciesFilter, findRoutesByMatchers, findRoutesMatchingPredicate } from './Filters';
|
||||
import { useAddPolicyModal, useAlertGroupsModal, useDeletePolicyModal, useEditPolicyModal } from './Modals';
|
||||
import { Policy } from './Policy';
|
||||
import { useNotificationPolicyRoute, useUpdateNotificationPolicyRoute } from './useNotificationPolicyRoute';
|
||||
import {
|
||||
useAddNotificationPolicy,
|
||||
useDeleteNotificationPolicy,
|
||||
useNotificationPolicyRoute,
|
||||
useUpdateExistingNotificationPolicy,
|
||||
} from './useNotificationPolicyRoute';
|
||||
|
||||
export const NotificationPoliciesList = () => {
|
||||
const appNotification = useAppNotification();
|
||||
@ -53,7 +52,7 @@ export const NotificationPoliciesList = () => {
|
||||
const {
|
||||
currentData,
|
||||
isLoading,
|
||||
error: resultError,
|
||||
error: fetchPoliciesError,
|
||||
refetch: refetchNotificationPolicyRoute,
|
||||
} = useNotificationPolicyRoute({ alertmanager: selectedAlertmanager ?? '' });
|
||||
|
||||
@ -62,7 +61,22 @@ export const NotificationPoliciesList = () => {
|
||||
// TODO in the future: Generalise the component to support any number of "root" policies
|
||||
const [defaultPolicy] = currentData ?? [];
|
||||
|
||||
const updateNotificationPolicyRoute = useUpdateNotificationPolicyRoute(selectedAlertmanager ?? '');
|
||||
// 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 ?? '' },
|
||||
@ -112,56 +126,38 @@ export const NotificationPoliciesList = () => {
|
||||
|
||||
const refetchPolicies = () => {
|
||||
refetchNotificationPolicyRoute();
|
||||
updateRouteTree.reset();
|
||||
updateExistingNotificationPolicy.reset();
|
||||
deleteNotificationPolicy.reset();
|
||||
addNotificationPolicy.reset();
|
||||
};
|
||||
|
||||
function handleSave(partialRoute: Partial<FormAmRoute>) {
|
||||
if (!rootRoute) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newRouteTree = mergePartialAmRouteWithRouteTree(selectedAlertmanager ?? '', partialRoute, rootRoute);
|
||||
updateRouteTree.execute(newRouteTree);
|
||||
async function handleUpdate(partialRoute: Partial<FormAmRoute>) {
|
||||
await updateExistingNotificationPolicy.execute(partialRoute);
|
||||
handleActionResult({ error: updateExistingNotificationPolicyState.error });
|
||||
}
|
||||
|
||||
function handleDelete(route: RouteWithID) {
|
||||
if (!rootRoute) {
|
||||
return;
|
||||
}
|
||||
const newRouteTree = omitRouteFromRouteTree(route, rootRoute);
|
||||
updateRouteTree.execute(newRouteTree);
|
||||
async function handleDelete(route: RouteWithID) {
|
||||
await deleteNotificationPolicy.execute(route.id);
|
||||
handleActionResult({ error: deleteNotificationPolicyState.error });
|
||||
}
|
||||
|
||||
function handleAdd(partialRoute: Partial<FormAmRoute>, referenceRoute: RouteWithID, insertPosition: InsertPosition) {
|
||||
if (!rootRoute) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newRouteTree = addRouteToReferenceRoute(
|
||||
selectedAlertmanager ?? '',
|
||||
async function handleAdd(
|
||||
partialRoute: Partial<FormAmRoute>,
|
||||
referenceRoute: RouteWithID,
|
||||
insertPosition: InsertPosition
|
||||
) {
|
||||
await addNotificationPolicy.execute({
|
||||
partialRoute,
|
||||
referenceRoute,
|
||||
rootRoute,
|
||||
insertPosition
|
||||
);
|
||||
updateRouteTree.execute(newRouteTree);
|
||||
referenceRouteIdentifier: referenceRoute.id,
|
||||
insertPosition,
|
||||
});
|
||||
handleActionResult({ error: addNotificationPolicyState.error });
|
||||
}
|
||||
|
||||
// this function will make the HTTP request and tracks the state of the request
|
||||
const [updateRouteTree, updateRouteTreeState] = useAsync(async (routeTree: Route | RouteWithID) => {
|
||||
if (!rootRoute) {
|
||||
return;
|
||||
function handleActionResult({ error }: { error?: Error }) {
|
||||
if (!error) {
|
||||
appNotification.success('Updated notification policies');
|
||||
}
|
||||
|
||||
// make sure we omit all IDs from our routes
|
||||
const newRouteTree = cleanRouteIDs(routeTree);
|
||||
|
||||
const newTree = await updateNotificationPolicyRoute({
|
||||
newRoute: newRouteTree,
|
||||
oldRoute: defaultPolicy,
|
||||
});
|
||||
|
||||
appNotification.success('Updated notification policies');
|
||||
if (selectedAlertmanager) {
|
||||
refetchAlertGroups();
|
||||
}
|
||||
@ -170,20 +166,20 @@ export const NotificationPoliciesList = () => {
|
||||
closeEditModal();
|
||||
closeAddModal();
|
||||
closeDeleteModal();
|
||||
}
|
||||
|
||||
return newTree;
|
||||
});
|
||||
|
||||
const updatingTree = isPending(updateRouteTreeState);
|
||||
const updateError = updateRouteTreeState.error;
|
||||
const updatingTree = anyOfRequestState(
|
||||
updateExistingNotificationPolicyState,
|
||||
deleteNotificationPolicyState,
|
||||
addNotificationPolicyState
|
||||
).loading;
|
||||
|
||||
// edit, add, delete modals
|
||||
const [addModal, openAddModal, closeAddModal] = useAddPolicyModal(handleAdd, updatingTree);
|
||||
const [editModal, openEditModal, closeEditModal] = useEditPolicyModal(
|
||||
selectedAlertmanager ?? '',
|
||||
handleSave,
|
||||
updatingTree,
|
||||
updateError
|
||||
handleUpdate,
|
||||
updatingTree
|
||||
);
|
||||
const [deleteModal, openDeleteModal, closeDeleteModal] = useDeletePolicyModal(handleDelete, updatingTree);
|
||||
const [alertInstancesModal, showAlertGroupsModal] = useAlertGroupsModal(selectedAlertmanager ?? '');
|
||||
@ -192,18 +188,23 @@ export const NotificationPoliciesList = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasPoliciesData = rootRoute && !resultError && !isLoading;
|
||||
const hasPoliciesError = !!resultError && !isLoading;
|
||||
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="Error loading Alertmanager config">
|
||||
{stringifyErrorLike(resultError) || 'Unknown error.'}
|
||||
{stringifyErrorLike(fetchPoliciesError) || 'Unknown error.'}
|
||||
</Alert>
|
||||
)}
|
||||
{/* show when there is an update error */}
|
||||
{isErrorMatchingCode(updateError, ERROR_NEWER_CONFIGURATION) && (
|
||||
{hasConflictError && (
|
||||
<Alert severity="info" title="Notification policies have changed">
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Trans i18nKey="alerting.policies.update-errors.conflict">
|
||||
@ -230,7 +231,7 @@ export const NotificationPoliciesList = () => {
|
||||
currentRoute={rootRoute}
|
||||
contactPointsState={contactPointsState.receivers}
|
||||
readOnly={!hasConfigurationAPI}
|
||||
provisioned={rootRoute._metadata?.provisioned}
|
||||
provisioned={rootRoute[ROUTES_META_SYMBOL]?.provisioned}
|
||||
alertManagerSourceName={selectedAlertmanager}
|
||||
onAddPolicy={openAddModal}
|
||||
onEditPolicy={openEditModal}
|
||||
|
@ -0,0 +1,35 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`createKubernetesRoutingTreeSpec 1`] = `
|
||||
{
|
||||
"metadata": {
|
||||
"name": "user-defined",
|
||||
"resourceVersion": "abc123",
|
||||
},
|
||||
"spec": {
|
||||
"defaults": {
|
||||
"group_by": [
|
||||
"alertname",
|
||||
],
|
||||
"receiver": "default-receiver",
|
||||
"repeat_interval": "4h",
|
||||
},
|
||||
"routes": [
|
||||
{
|
||||
"continue": false,
|
||||
"group_interval": "5m",
|
||||
"group_wait": "30s",
|
||||
"matchers": [
|
||||
{
|
||||
"label": "team",
|
||||
"type": "=",
|
||||
"value": "frontend",
|
||||
},
|
||||
],
|
||||
"receiver": "nested-receiver",
|
||||
"routes": undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
`;
|
@ -0,0 +1,117 @@
|
||||
import { MatcherOperator, ROUTES_META_SYMBOL, Route } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route } from '../../openapi/routesApi.gen';
|
||||
import { ROOT_ROUTE_NAME } from '../../utils/k8s/constants';
|
||||
|
||||
import { createKubernetesRoutingTreeSpec, k8sSubRouteToRoute, routeToK8sSubRoute } from './useNotificationPolicyRoute';
|
||||
|
||||
test('k8sSubRouteToRoute', () => {
|
||||
const input: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route = {
|
||||
continue: false,
|
||||
group_by: ['label1'],
|
||||
group_interval: '5m',
|
||||
group_wait: '30s',
|
||||
matchers: [{ label: 'label1', type: '=', value: 'value1' }],
|
||||
mute_time_intervals: ['mt-1'],
|
||||
receiver: 'my-receiver',
|
||||
repeat_interval: '4h',
|
||||
routes: [
|
||||
{
|
||||
receiver: 'receiver2',
|
||||
matchers: [{ label: 'label2', type: '!=', value: 'value2' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const expected: Route = {
|
||||
continue: false,
|
||||
group_by: ['label1'],
|
||||
group_interval: '5m',
|
||||
group_wait: '30s',
|
||||
matchers: undefined, // matchers -> object_matchers
|
||||
object_matchers: [['label1', MatcherOperator.equal, 'value1']],
|
||||
mute_time_intervals: ['mt-1'],
|
||||
receiver: 'my-receiver',
|
||||
repeat_interval: '4h',
|
||||
routes: [
|
||||
{
|
||||
receiver: 'receiver2',
|
||||
matchers: undefined,
|
||||
object_matchers: [['label2', MatcherOperator.notEqual, 'value2']],
|
||||
routes: undefined,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(k8sSubRouteToRoute(input)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
test('routeToK8sSubRoute', () => {
|
||||
const input: Route = {
|
||||
continue: false,
|
||||
group_by: ['label1'],
|
||||
group_interval: '5m',
|
||||
group_wait: '30s',
|
||||
matchers: undefined, // matchers -> object_matchers
|
||||
object_matchers: [['label1', MatcherOperator.equal, 'value1']],
|
||||
mute_time_intervals: ['mt-1'],
|
||||
receiver: 'my-receiver',
|
||||
repeat_interval: '4h',
|
||||
routes: [
|
||||
{
|
||||
receiver: 'receiver2',
|
||||
matchers: undefined,
|
||||
object_matchers: [['label2', MatcherOperator.notEqual, 'value2']],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const expected: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route = {
|
||||
continue: false,
|
||||
group_by: ['label1'],
|
||||
group_interval: '5m',
|
||||
group_wait: '30s',
|
||||
matchers: [{ label: 'label1', type: '=', value: 'value1' }],
|
||||
mute_time_intervals: ['mt-1'],
|
||||
receiver: 'my-receiver',
|
||||
repeat_interval: '4h',
|
||||
routes: [
|
||||
{
|
||||
receiver: 'receiver2',
|
||||
matchers: [{ label: 'label2', type: '!=', value: 'value2' }],
|
||||
routes: undefined,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(routeToK8sSubRoute(input)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
test('createKubernetesRoutingTreeSpec', () => {
|
||||
const route: Route = {
|
||||
continue: true,
|
||||
group_by: ['alertname'],
|
||||
matchers: undefined,
|
||||
object_matchers: [['severity', MatcherOperator.equal, 'critical']],
|
||||
mute_time_intervals: ['interval-1'],
|
||||
receiver: 'default-receiver',
|
||||
repeat_interval: '4h',
|
||||
routes: [
|
||||
{
|
||||
continue: false,
|
||||
receiver: 'nested-receiver',
|
||||
object_matchers: [['team', MatcherOperator.equal, 'frontend']],
|
||||
group_wait: '30s',
|
||||
group_interval: '5m',
|
||||
},
|
||||
],
|
||||
[ROUTES_META_SYMBOL]: {
|
||||
resourceVersion: 'abc123',
|
||||
},
|
||||
};
|
||||
|
||||
const tree = createKubernetesRoutingTreeSpec(route);
|
||||
|
||||
expect(tree.metadata.name).toBe(ROOT_ROUTE_NAME);
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
@ -1,27 +1,45 @@
|
||||
import { pick } from 'lodash';
|
||||
import memoize from 'micro-memoize';
|
||||
|
||||
import { routingTreeApi } from 'app/features/alerting/unified/api/notificationPoliciesApi';
|
||||
import { BaseAlertmanagerArgs, Skippable } from 'app/features/alerting/unified/types/hooks';
|
||||
import { MatcherOperator, Route } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { MatcherOperator, ROUTES_META_SYMBOL, Route } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import { alertmanagerApi } from '../../api/alertmanagerApi';
|
||||
import { useAsync } from '../../hooks/useAsync';
|
||||
import { useProduceNewAlertmanagerConfiguration } from '../../hooks/useProduceNewAlertmanagerConfig';
|
||||
import {
|
||||
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route,
|
||||
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RouteDefaults,
|
||||
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree,
|
||||
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTreeSpec,
|
||||
generatedRoutesApi as routingTreeApi,
|
||||
} from '../../openapi/routesApi.gen';
|
||||
import {
|
||||
addRouteAction,
|
||||
deleteRouteAction,
|
||||
updateRouteAction,
|
||||
} from '../../reducers/alertmanager/notificationPolicyRoutes';
|
||||
import { FormAmRoute } from '../../types/amroutes';
|
||||
import { addUniqueIdentifierToRoute } from '../../utils/amroutes';
|
||||
import { PROVENANCE_NONE, ROOT_ROUTE_NAME } from '../../utils/k8s/constants';
|
||||
import { ERROR_NEWER_CONFIGURATION } from '../../utils/k8s/errors';
|
||||
import { getK8sNamespace, isK8sEntityProvisioned, shouldUseK8sApi } from '../../utils/k8s/utils';
|
||||
import { INHERITABLE_KEYS, InheritableProperties } from '../../utils/notification-policies';
|
||||
import {
|
||||
InsertPosition,
|
||||
addRouteToReferenceRoute,
|
||||
cleanKubernetesRouteIDs,
|
||||
mergePartialAmRouteWithRouteTree,
|
||||
omitRouteFromRouteTree,
|
||||
} from '../../utils/routeTree';
|
||||
|
||||
const k8sRoutesToRoutesMemoized = memoize(k8sRoutesToRoutes, { maxSize: 1 });
|
||||
|
||||
const { useListNamespacedRoutingTreeQuery, useReplaceNamespacedRoutingTreeMutation } = routingTreeApi;
|
||||
|
||||
const {
|
||||
useUpdateAlertmanagerConfigurationMutation,
|
||||
useLazyGetAlertmanagerConfigurationQuery,
|
||||
useGetAlertmanagerConfigurationQuery,
|
||||
} = alertmanagerApi;
|
||||
useListNamespacedRoutingTreeQuery,
|
||||
useReplaceNamespacedRoutingTreeMutation,
|
||||
useLazyListNamespacedRoutingTreeQuery,
|
||||
} = routingTreeApi;
|
||||
|
||||
const { useGetAlertmanagerConfigurationQuery } = alertmanagerApi;
|
||||
|
||||
export const useNotificationPolicyRoute = ({ alertmanager }: BaseAlertmanagerArgs, { skip }: Skippable = {}) => {
|
||||
const k8sApiSupported = shouldUseK8sApi(alertmanager);
|
||||
@ -61,73 +79,149 @@ export const useNotificationPolicyRoute = ({ alertmanager }: BaseAlertmanagerArg
|
||||
const parseAmConfigRoute = memoize((route: Route): Route => {
|
||||
return {
|
||||
...route,
|
||||
_metadata: { provisioned: Boolean(route.provenance && route.provenance !== PROVENANCE_NONE) },
|
||||
[ROUTES_META_SYMBOL]: {
|
||||
provisioned: Boolean(route.provenance && route.provenance !== PROVENANCE_NONE),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export function useUpdateNotificationPolicyRoute(selectedAlertmanager: string) {
|
||||
const [getAlertmanagerConfiguration] = useLazyGetAlertmanagerConfigurationQuery();
|
||||
const [updateAlertmanagerConfiguration] = useUpdateAlertmanagerConfigurationMutation();
|
||||
|
||||
export function useUpdateExistingNotificationPolicy({ alertmanager }: BaseAlertmanagerArgs) {
|
||||
const k8sApiSupported = shouldUseK8sApi(alertmanager);
|
||||
const [updatedNamespacedRoute] = useReplaceNamespacedRoutingTreeMutation();
|
||||
const [produceNewAlertmanagerConfiguration] = useProduceNewAlertmanagerConfiguration();
|
||||
const [listNamespacedRoutingTree] = useLazyListNamespacedRoutingTreeQuery();
|
||||
|
||||
const k8sApiSupported = shouldUseK8sApi(selectedAlertmanager);
|
||||
|
||||
async function updateUsingK8sApi({ newRoute }: { newRoute: Route }) {
|
||||
const updateUsingK8sApi = useAsync(async (update: Partial<FormAmRoute>) => {
|
||||
const namespace = getK8sNamespace();
|
||||
const { routes, _metadata, ...defaults } = newRoute;
|
||||
// Remove provenance so we don't send it to API
|
||||
// Convert Route to K8s compatible format
|
||||
const k8sRoute: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTreeSpec = {
|
||||
defaults: {
|
||||
...defaults,
|
||||
// TODO: Fix types in k8s API? Fix our types to not allow empty receiver? TBC
|
||||
receiver: defaults.receiver || '',
|
||||
},
|
||||
routes: newRoute.routes?.map(routeToK8sSubRoute) || [],
|
||||
};
|
||||
const result = await listNamespacedRoutingTree({ namespace });
|
||||
|
||||
const [rootTree] = result.data ? k8sRoutesToRoutesMemoized(result.data.items) : [];
|
||||
if (!rootTree) {
|
||||
throw new Error(`no root route found for namespace ${namespace}`);
|
||||
}
|
||||
|
||||
const rootRouteWithIdentifiers = addUniqueIdentifierToRoute(rootTree);
|
||||
const newRouteTree = mergePartialAmRouteWithRouteTree(alertmanager, update, rootRouteWithIdentifiers);
|
||||
|
||||
// Create the K8s route object
|
||||
const routeObject: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree = {
|
||||
spec: k8sRoute,
|
||||
metadata: { name: ROOT_ROUTE_NAME, resourceVersion: _metadata?.resourceVersion },
|
||||
};
|
||||
const routeObject = createKubernetesRoutingTreeSpec(newRouteTree);
|
||||
|
||||
return updatedNamespacedRoute({
|
||||
name: ROOT_ROUTE_NAME,
|
||||
namespace,
|
||||
comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree: cleanKubernetesRouteIDs(routeObject),
|
||||
}).unwrap();
|
||||
});
|
||||
|
||||
const updateFromAlertmanagerConfiguration = useAsync(async (update: Partial<FormAmRoute>) => {
|
||||
const action = updateRouteAction({ update, alertmanager });
|
||||
return produceNewAlertmanagerConfiguration(action);
|
||||
});
|
||||
|
||||
return k8sApiSupported ? updateUsingK8sApi : updateFromAlertmanagerConfiguration;
|
||||
}
|
||||
|
||||
export function useDeleteNotificationPolicy({ alertmanager }: BaseAlertmanagerArgs) {
|
||||
const k8sApiSupported = shouldUseK8sApi(alertmanager);
|
||||
const [produceNewAlertmanagerConfiguration] = useProduceNewAlertmanagerConfiguration();
|
||||
const [listNamespacedRoutingTree] = useLazyListNamespacedRoutingTreeQuery();
|
||||
const [updatedNamespacedRoute] = useReplaceNamespacedRoutingTreeMutation();
|
||||
|
||||
const deleteFromK8sApi = useAsync(async (id: string) => {
|
||||
const namespace = getK8sNamespace();
|
||||
const result = await listNamespacedRoutingTree({ namespace });
|
||||
|
||||
const [rootTree] = result.data ? k8sRoutesToRoutesMemoized(result.data.items) : [];
|
||||
if (!rootTree) {
|
||||
throw new Error(`no root route found for namespace ${namespace}`);
|
||||
}
|
||||
|
||||
const rootRouteWithIdentifiers = addUniqueIdentifierToRoute(rootTree);
|
||||
const newRouteTree = omitRouteFromRouteTree(id, rootRouteWithIdentifiers);
|
||||
|
||||
// Create the K8s route object
|
||||
const routeObject = createKubernetesRoutingTreeSpec(newRouteTree);
|
||||
|
||||
return updatedNamespacedRoute({
|
||||
name: ROOT_ROUTE_NAME,
|
||||
namespace,
|
||||
comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree: routeObject,
|
||||
}).unwrap();
|
||||
}
|
||||
});
|
||||
|
||||
async function updateUsingConfigFileApi({ newRoute, oldRoute }: { newRoute: Route; oldRoute: Route }) {
|
||||
const { _metadata, ...oldRouteStripped } = oldRoute;
|
||||
const { _metadata: newMetadata, ...newRouteStripped } = newRoute;
|
||||
const lastConfig = await getAlertmanagerConfiguration(selectedAlertmanager).unwrap();
|
||||
const latestRouteFromConfig = lastConfig.alertmanager_config.route;
|
||||
const deleteFromAlertmanagerConfiguration = useAsync(async (id: string) => {
|
||||
const action = deleteRouteAction({ id });
|
||||
return produceNewAlertmanagerConfiguration(action);
|
||||
});
|
||||
|
||||
const configChangedInMeantime = JSON.stringify(oldRouteStripped) !== JSON.stringify(latestRouteFromConfig);
|
||||
return k8sApiSupported ? deleteFromK8sApi : deleteFromAlertmanagerConfiguration;
|
||||
}
|
||||
|
||||
if (configChangedInMeantime) {
|
||||
throw new Error('configuration modification conflict', { cause: ERROR_NEWER_CONFIGURATION });
|
||||
export function useAddNotificationPolicy({ alertmanager }: BaseAlertmanagerArgs) {
|
||||
const k8sApiSupported = shouldUseK8sApi(alertmanager);
|
||||
const [produceNewAlertmanagerConfiguration] = useProduceNewAlertmanagerConfiguration();
|
||||
const [listNamespacedRoutingTree] = useLazyListNamespacedRoutingTreeQuery();
|
||||
const [updatedNamespacedRoute] = useReplaceNamespacedRoutingTreeMutation();
|
||||
|
||||
const addToK8sApi = useAsync(
|
||||
async ({
|
||||
partialRoute,
|
||||
referenceRouteIdentifier,
|
||||
insertPosition,
|
||||
}: {
|
||||
partialRoute: Partial<FormAmRoute>;
|
||||
referenceRouteIdentifier: string;
|
||||
insertPosition: InsertPosition;
|
||||
}) => {
|
||||
const namespace = getK8sNamespace();
|
||||
const result = await listNamespacedRoutingTree({ namespace });
|
||||
|
||||
const [rootTree] = result.data ? k8sRoutesToRoutesMemoized(result.data.items) : [];
|
||||
if (!rootTree) {
|
||||
throw new Error(`no root route found for namespace ${namespace}`);
|
||||
}
|
||||
|
||||
const rootRouteWithIdentifiers = addUniqueIdentifierToRoute(rootTree);
|
||||
const newRouteTree = addRouteToReferenceRoute(
|
||||
alertmanager ?? '',
|
||||
partialRoute,
|
||||
referenceRouteIdentifier,
|
||||
rootRouteWithIdentifiers,
|
||||
insertPosition
|
||||
);
|
||||
|
||||
// Create the K8s route object
|
||||
const routeObject = createKubernetesRoutingTreeSpec(newRouteTree);
|
||||
|
||||
return updatedNamespacedRoute({
|
||||
name: ROOT_ROUTE_NAME,
|
||||
namespace,
|
||||
comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree: cleanKubernetesRouteIDs(routeObject),
|
||||
}).unwrap();
|
||||
}
|
||||
);
|
||||
|
||||
const newConfig = {
|
||||
...lastConfig,
|
||||
alertmanager_config: {
|
||||
...lastConfig.alertmanager_config,
|
||||
route: newRouteStripped,
|
||||
},
|
||||
};
|
||||
const addToAlertmanagerConfiguration = useAsync(
|
||||
async ({
|
||||
partialRoute,
|
||||
referenceRouteIdentifier,
|
||||
insertPosition,
|
||||
}: {
|
||||
partialRoute: Partial<FormAmRoute>;
|
||||
referenceRouteIdentifier: string;
|
||||
insertPosition: InsertPosition;
|
||||
}) => {
|
||||
const action = addRouteAction({
|
||||
partialRoute,
|
||||
referenceRouteIdentifier,
|
||||
insertPosition,
|
||||
alertmanager,
|
||||
});
|
||||
return produceNewAlertmanagerConfiguration(action);
|
||||
}
|
||||
);
|
||||
|
||||
// TODO This needs to properly handle lazy AM initialization
|
||||
return updateAlertmanagerConfiguration({
|
||||
selectedAlertmanager,
|
||||
config: newConfig,
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
return k8sApiSupported ? updateUsingK8sApi : updateUsingConfigFileApi;
|
||||
return k8sApiSupported ? addToK8sApi : addToAlertmanagerConfiguration;
|
||||
}
|
||||
|
||||
function k8sRoutesToRoutes(routes: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree[]): Route[] {
|
||||
@ -135,7 +229,7 @@ function k8sRoutesToRoutes(routes: ComGithubGrafanaGrafanaPkgApisAlertingNotific
|
||||
return {
|
||||
...route.spec.defaults,
|
||||
routes: route.spec.routes?.map(k8sSubRouteToRoute),
|
||||
_metadata: {
|
||||
[ROUTES_META_SYMBOL]: {
|
||||
provisioned: isK8sEntityProvisioned(route),
|
||||
resourceVersion: route.metadata.resourceVersion,
|
||||
name: route.metadata.name,
|
||||
@ -149,7 +243,7 @@ function isValidMatcherOperator(type: string): type is MatcherOperator {
|
||||
return Object.values<string>(MatcherOperator).includes(type);
|
||||
}
|
||||
|
||||
function k8sSubRouteToRoute(route: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route): Route {
|
||||
export function k8sSubRouteToRoute(route: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route): Route {
|
||||
return {
|
||||
...route,
|
||||
routes: route.routes?.map(k8sSubRouteToRoute),
|
||||
@ -163,7 +257,7 @@ function k8sSubRouteToRoute(route: ComGithubGrafanaGrafanaPkgApisAlertingNotific
|
||||
};
|
||||
}
|
||||
|
||||
function routeToK8sSubRoute(route: Route): ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route {
|
||||
export function routeToK8sSubRoute(route: Route): ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route {
|
||||
const { object_matchers, ...rest } = route;
|
||||
return {
|
||||
...rest,
|
||||
@ -176,3 +270,34 @@ function routeToK8sSubRoute(route: Route): ComGithubGrafanaGrafanaPkgApisAlertin
|
||||
routes: route.routes?.map(routeToK8sSubRoute),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Route to K8s compatible format. Make sure we aren't sending any additional properties the API doesn't recognize
|
||||
* because it will reply with excess properties in the HTTP headers
|
||||
*/
|
||||
export function createKubernetesRoutingTreeSpec(
|
||||
rootRoute: Route
|
||||
): ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree {
|
||||
const inheritableDefaultProperties: InheritableProperties = pick(rootRoute, INHERITABLE_KEYS);
|
||||
|
||||
const defaults: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RouteDefaults = {
|
||||
...inheritableDefaultProperties,
|
||||
// TODO: Fix types in k8s API? Fix our types to not allow empty receiver? TBC
|
||||
receiver: rootRoute.receiver ?? '',
|
||||
};
|
||||
|
||||
const routes = rootRoute.routes?.map(routeToK8sSubRoute) ?? [];
|
||||
|
||||
const spec = {
|
||||
defaults,
|
||||
routes,
|
||||
};
|
||||
|
||||
return {
|
||||
spec: spec,
|
||||
metadata: {
|
||||
name: ROOT_ROUTE_NAME,
|
||||
resourceVersion: rootRoute[ROUTES_META_SYMBOL]?.resourceVersion,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/ty
|
||||
|
||||
import { alertmanagerApi } from '../api/alertmanagerApi';
|
||||
import { muteTimingsReducer } from '../reducers/alertmanager/muteTimings';
|
||||
import { routesReducer } from '../reducers/alertmanager/notificationPolicyRoutes';
|
||||
import { notificationTemplatesReducer } from '../reducers/alertmanager/notificationTemplates';
|
||||
import { receiversReducer } from '../reducers/alertmanager/receivers';
|
||||
import { useAlertmanager } from '../state/AlertmanagerContext';
|
||||
@ -31,7 +32,8 @@ const configurationReducer = reduceReducers(
|
||||
initialAlertmanagerConfiguration,
|
||||
muteTimingsReducer,
|
||||
receiversReducer,
|
||||
notificationTemplatesReducer
|
||||
notificationTemplatesReducer,
|
||||
routesReducer
|
||||
);
|
||||
|
||||
/**
|
||||
|
@ -0,0 +1,182 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`routes Should add a new route with receiver E as a child of default route 1`] = `
|
||||
{
|
||||
"alertmanager_config": {
|
||||
"mute_time_intervals": [],
|
||||
"route": {
|
||||
"group_by": [
|
||||
"grafana_folder",
|
||||
],
|
||||
"group_interval": "1m",
|
||||
"group_wait": "10s",
|
||||
"object_matchers": [],
|
||||
"receiver": "ROOT",
|
||||
"routes": [
|
||||
{
|
||||
"object_matchers": [
|
||||
[
|
||||
"team",
|
||||
"=",
|
||||
"operations",
|
||||
],
|
||||
],
|
||||
"receiver": "A",
|
||||
"routes": [
|
||||
{
|
||||
"object_matchers": [
|
||||
[
|
||||
"region",
|
||||
"=",
|
||||
"europe",
|
||||
],
|
||||
],
|
||||
"receiver": "B1",
|
||||
"routes": [],
|
||||
},
|
||||
{
|
||||
"object_matchers": [
|
||||
[
|
||||
"region",
|
||||
"=",
|
||||
"nasa",
|
||||
],
|
||||
],
|
||||
"receiver": "B2",
|
||||
"routes": [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"object_matchers": [
|
||||
[
|
||||
"foo",
|
||||
"=",
|
||||
"bar",
|
||||
],
|
||||
],
|
||||
"receiver": "C",
|
||||
"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": "E",
|
||||
"repeat_interval": undefined,
|
||||
"routes": undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
"time_intervals": [],
|
||||
},
|
||||
"template_files": {},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`routes Should delete route 1`] = `
|
||||
{
|
||||
"alertmanager_config": {
|
||||
"mute_time_intervals": [],
|
||||
"route": {
|
||||
"group_by": [
|
||||
"grafana_folder",
|
||||
],
|
||||
"group_interval": "1m",
|
||||
"group_wait": "10s",
|
||||
"object_matchers": [],
|
||||
"receiver": "ROOT",
|
||||
"routes": [
|
||||
{
|
||||
"object_matchers": [
|
||||
[
|
||||
"foo",
|
||||
"=",
|
||||
"bar",
|
||||
],
|
||||
],
|
||||
"receiver": "C",
|
||||
"routes": [],
|
||||
},
|
||||
],
|
||||
},
|
||||
"time_intervals": [],
|
||||
},
|
||||
"template_files": {},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`routes Should update an existing route A with receiver B 1`] = `
|
||||
{
|
||||
"alertmanager_config": {
|
||||
"mute_time_intervals": [],
|
||||
"route": {
|
||||
"group_by": [
|
||||
"grafana_folder",
|
||||
],
|
||||
"group_interval": "1m",
|
||||
"group_wait": "10s",
|
||||
"object_matchers": [],
|
||||
"receiver": "ROOT",
|
||||
"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": "B",
|
||||
"repeat_interval": undefined,
|
||||
"routes": [
|
||||
{
|
||||
"object_matchers": [
|
||||
[
|
||||
"region",
|
||||
"=",
|
||||
"europe",
|
||||
],
|
||||
],
|
||||
"receiver": "B1",
|
||||
"routes": [],
|
||||
},
|
||||
{
|
||||
"object_matchers": [
|
||||
[
|
||||
"region",
|
||||
"=",
|
||||
"nasa",
|
||||
],
|
||||
],
|
||||
"receiver": "B2",
|
||||
"routes": [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"object_matchers": [
|
||||
[
|
||||
"foo",
|
||||
"=",
|
||||
"bar",
|
||||
],
|
||||
],
|
||||
"receiver": "C",
|
||||
"routes": [],
|
||||
},
|
||||
],
|
||||
},
|
||||
"time_intervals": [],
|
||||
},
|
||||
"template_files": {},
|
||||
}
|
||||
`;
|
@ -0,0 +1,144 @@
|
||||
import { produce } from 'immer';
|
||||
|
||||
import { AlertManagerCortexConfig, MatcherOperator, Route } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import { FormAmRoute } from '../../types/amroutes';
|
||||
import { addUniqueIdentifierToRoute } from '../../utils/amroutes';
|
||||
|
||||
import { addRouteAction, deleteRouteAction, routesReducer, updateRouteAction } from './notificationPolicyRoutes';
|
||||
|
||||
describe('routes', () => {
|
||||
const defaultRoute: Route = {
|
||||
receiver: 'ROOT',
|
||||
group_by: ['grafana_folder'],
|
||||
object_matchers: [],
|
||||
|
||||
routes: [
|
||||
{
|
||||
receiver: 'A',
|
||||
|
||||
object_matchers: [['team', MatcherOperator.equal, 'operations']],
|
||||
routes: [
|
||||
{
|
||||
receiver: 'B1',
|
||||
object_matchers: [['region', MatcherOperator.equal, 'europe']],
|
||||
},
|
||||
{
|
||||
receiver: 'B2',
|
||||
object_matchers: [['region', MatcherOperator.equal, 'nasa']],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
receiver: 'C',
|
||||
object_matchers: [['foo', MatcherOperator.equal, 'bar']],
|
||||
},
|
||||
],
|
||||
group_wait: '10s',
|
||||
group_interval: '1m',
|
||||
};
|
||||
const rootRouteWithIdentifiers = addUniqueIdentifierToRoute(defaultRoute);
|
||||
const ROOT_identifier = rootRouteWithIdentifiers.id;
|
||||
const A_identifier = rootRouteWithIdentifiers.routes![0].id;
|
||||
|
||||
const initialConfig: AlertManagerCortexConfig = {
|
||||
alertmanager_config: {
|
||||
time_intervals: [],
|
||||
mute_time_intervals: [],
|
||||
route: defaultRoute,
|
||||
},
|
||||
template_files: {},
|
||||
};
|
||||
it('Should add a new route with receiver E as a child of default route', () => {
|
||||
const routeWithID = addUniqueIdentifierToRoute({
|
||||
receiver: 'E',
|
||||
});
|
||||
const newFormRoute: Partial<FormAmRoute> = {
|
||||
receiver: 'E',
|
||||
id: routeWithID.id,
|
||||
};
|
||||
const action = addRouteAction({
|
||||
alertmanager: 'alertmanager',
|
||||
partialRoute: newFormRoute,
|
||||
referenceRouteIdentifier: ROOT_identifier,
|
||||
insertPosition: 'child',
|
||||
});
|
||||
|
||||
const result = routesReducer(initialConfig, action);
|
||||
|
||||
expect(result.alertmanager_config.route?.routes).toHaveLength(
|
||||
(initialConfig.alertmanager_config.route?.routes?.length ?? 0) + 1
|
||||
);
|
||||
|
||||
// the last route if the root route should have receiver E
|
||||
expect(result.alertmanager_config.route?.routes?.at(-1)).toMatchObject({
|
||||
receiver: 'E',
|
||||
});
|
||||
|
||||
// assert the rest of the configuration
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('Should not add a new route with receiver E as a child of route with receiver A, if data has been updated by another user', () => {
|
||||
const routeWithID = addUniqueIdentifierToRoute({
|
||||
receiver: 'E',
|
||||
});
|
||||
const newFormRoute: Partial<FormAmRoute> = {
|
||||
receiver: 'E',
|
||||
id: routeWithID.id,
|
||||
};
|
||||
const action = addRouteAction({
|
||||
alertmanager: 'alertmanager',
|
||||
partialRoute: newFormRoute,
|
||||
referenceRouteIdentifier: A_identifier,
|
||||
insertPosition: 'child',
|
||||
});
|
||||
const modifiedConfig = produce(initialConfig, (draft) => {
|
||||
draft.alertmanager_config.route!.routes = [];
|
||||
});
|
||||
const rootRouteWithIdentifiers = addUniqueIdentifierToRoute(modifiedConfig.alertmanager_config.route!);
|
||||
const modifiedConfigWithIdentifiers = produce(modifiedConfig, (draft) => {
|
||||
draft.alertmanager_config.route = rootRouteWithIdentifiers;
|
||||
});
|
||||
expect(() => routesReducer(modifiedConfigWithIdentifiers, action)).toThrow();
|
||||
});
|
||||
|
||||
it('Should update an existing route A with receiver B', () => {
|
||||
const newRoute: Partial<FormAmRoute> = {
|
||||
receiver: 'B',
|
||||
id: A_identifier,
|
||||
};
|
||||
const action = updateRouteAction({
|
||||
alertmanager: 'alertmanager',
|
||||
update: newRoute,
|
||||
});
|
||||
|
||||
expect(routesReducer(initialConfig, action)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('Should not update route if config has been updated by another user', () => {
|
||||
const newRoute: Partial<FormAmRoute> = {
|
||||
receiver: 'B',
|
||||
id: A_identifier,
|
||||
};
|
||||
const action = updateRouteAction({
|
||||
alertmanager: 'alertmanager',
|
||||
update: newRoute,
|
||||
});
|
||||
const modifiedConfig = produce(initialConfig, (draft) => {
|
||||
draft.alertmanager_config.route!.routes = [];
|
||||
});
|
||||
const rootRouteWithIdentifiers = addUniqueIdentifierToRoute(modifiedConfig.alertmanager_config.route!);
|
||||
const modifiedConfigWithIdentifiers = produce(modifiedConfig, (draft) => {
|
||||
draft.alertmanager_config.route = rootRouteWithIdentifiers;
|
||||
});
|
||||
expect(() => routesReducer(modifiedConfigWithIdentifiers, action)).toThrow();
|
||||
});
|
||||
|
||||
it('Should delete route', () => {
|
||||
const action = deleteRouteAction({
|
||||
id: A_identifier,
|
||||
});
|
||||
expect(routesReducer(initialConfig, action)).toMatchSnapshot();
|
||||
});
|
||||
});
|
@ -0,0 +1,82 @@
|
||||
import { createAction, createReducer } from '@reduxjs/toolkit';
|
||||
|
||||
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import { FormAmRoute } from '../../types/amroutes';
|
||||
import { addUniqueIdentifierToRoute } from '../../utils/amroutes';
|
||||
import {
|
||||
InsertPosition,
|
||||
addRouteToReferenceRoute,
|
||||
cleanRouteIDs,
|
||||
mergePartialAmRouteWithRouteTree,
|
||||
omitRouteFromRouteTree,
|
||||
} from '../../utils/routeTree';
|
||||
|
||||
export const updateRouteAction = createAction<{ update: Partial<FormAmRoute>; alertmanager: string }>('routes/update');
|
||||
export const deleteRouteAction = createAction<{ id: string }>('routes/delete');
|
||||
export const addRouteAction = createAction<{
|
||||
alertmanager: string;
|
||||
partialRoute: Partial<FormAmRoute>;
|
||||
referenceRouteIdentifier: string;
|
||||
insertPosition: InsertPosition;
|
||||
}>('routes/add');
|
||||
|
||||
const initialState: AlertManagerCortexConfig = {
|
||||
alertmanager_config: {},
|
||||
template_files: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* This reducer will manage action related to routes and make sure all operations on the alertmanager
|
||||
* configuration happen immutably and only mutate what they need.
|
||||
*/
|
||||
export const routesReducer = createReducer(initialState, (builder) => {
|
||||
builder
|
||||
// update an existing route
|
||||
.addCase(updateRouteAction, (draft, { payload }) => {
|
||||
const { update, alertmanager } = payload;
|
||||
const { alertmanager_config } = draft;
|
||||
|
||||
if (!alertmanager_config.route) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rootRouteWithIdentifiers = addUniqueIdentifierToRoute(alertmanager_config.route);
|
||||
const newRouteTree = mergePartialAmRouteWithRouteTree(alertmanager, update, rootRouteWithIdentifiers);
|
||||
alertmanager_config.route = cleanRouteIDs(newRouteTree);
|
||||
})
|
||||
// delete a route
|
||||
.addCase(deleteRouteAction, (draft, { payload }) => {
|
||||
const { id } = payload;
|
||||
const { alertmanager_config } = draft;
|
||||
|
||||
// if we don't even have a root route, we bail
|
||||
if (!alertmanager_config.route) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rootRouteWithIdentifiers = addUniqueIdentifierToRoute(alertmanager_config.route);
|
||||
const updatedPolicyTree = omitRouteFromRouteTree(id, rootRouteWithIdentifiers);
|
||||
draft.alertmanager_config.route = cleanRouteIDs(updatedPolicyTree);
|
||||
})
|
||||
// add a new route to given position
|
||||
.addCase(addRouteAction, (draft, { payload }) => {
|
||||
const { partialRoute, referenceRouteIdentifier, insertPosition, alertmanager } = payload;
|
||||
const { alertmanager_config } = draft;
|
||||
|
||||
if (!alertmanager_config.route) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rootRouteWithIdentifiers = addUniqueIdentifierToRoute(alertmanager_config.route);
|
||||
const updatedPolicyTree = addRouteToReferenceRoute(
|
||||
alertmanager,
|
||||
partialRoute,
|
||||
referenceRouteIdentifier,
|
||||
rootRouteWithIdentifiers,
|
||||
insertPosition
|
||||
);
|
||||
|
||||
draft.alertmanager_config.route = cleanRouteIDs(updatedPolicyTree);
|
||||
});
|
||||
});
|
@ -1,11 +1,14 @@
|
||||
import { MatcherOperator, Route } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { uniqueId } from 'lodash';
|
||||
|
||||
import { MatcherOperator, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import { FormAmRoute } from '../types/amroutes';
|
||||
|
||||
import { amRouteToFormAmRoute, emptyRoute, formAmRouteToAmRoute } from './amroutes';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
|
||||
|
||||
const emptyAmRoute: Route = {
|
||||
const emptyAmRoute: RouteWithID = {
|
||||
id: uniqueId(),
|
||||
receiver: '',
|
||||
group_by: [],
|
||||
continue: false,
|
||||
@ -20,8 +23,8 @@ const emptyAmRoute: Route = {
|
||||
mute_time_intervals: [],
|
||||
};
|
||||
|
||||
const buildAmRoute = (override: Partial<Route> = {}): Route => {
|
||||
return { ...emptyAmRoute, ...override };
|
||||
const buildAmRouteWithID = (override: Partial<RouteWithID> = {}): RouteWithID => {
|
||||
return { ...emptyAmRoute, ...override, id: uniqueId() };
|
||||
};
|
||||
|
||||
const buildFormAmRoute = (override: Partial<FormAmRoute> = {}): FormAmRoute => {
|
||||
@ -138,7 +141,7 @@ describe('amRouteToFormAmRoute', () => {
|
||||
describe('when called with empty group_by array', () => {
|
||||
it('should set overrideGrouping true and groupBy empty', () => {
|
||||
// Arrange
|
||||
const amRoute = buildAmRoute({ group_by: [] });
|
||||
const amRoute = buildAmRouteWithID({ group_by: [] });
|
||||
|
||||
// Act
|
||||
const formRoute = amRouteToFormAmRoute(amRoute);
|
||||
@ -156,7 +159,7 @@ describe('amRouteToFormAmRoute', () => {
|
||||
${undefined}
|
||||
`("when group_by is '$group_by', should set overrideGrouping false", ({ group_by }) => {
|
||||
// Arrange
|
||||
const amRoute = buildAmRoute({ group_by: group_by });
|
||||
const amRoute = buildAmRouteWithID({ group_by: group_by });
|
||||
|
||||
// Act
|
||||
const formRoute = amRouteToFormAmRoute(amRoute);
|
||||
@ -170,7 +173,7 @@ describe('amRouteToFormAmRoute', () => {
|
||||
describe('when called with non-empty group_by', () => {
|
||||
it('Should set overrideGrouping true and groupBy', () => {
|
||||
// Arrange
|
||||
const amRoute = buildAmRoute({ group_by: ['SHOULD BE SET'] });
|
||||
const amRoute = buildAmRouteWithID({ group_by: ['SHOULD BE SET'] });
|
||||
|
||||
// Act
|
||||
const formRoute = amRouteToFormAmRoute(amRoute);
|
||||
@ -183,7 +186,7 @@ describe('amRouteToFormAmRoute', () => {
|
||||
|
||||
it('should unquote and unescape matchers values', () => {
|
||||
// Arrange
|
||||
const amRoute = buildAmRoute({
|
||||
const amRoute = buildAmRouteWithID({
|
||||
matchers: ['foo=bar', 'foo="bar"', 'foo="bar"baz"', 'foo="bar\\\\baz"', 'foo="\\\\bar\\\\baz"\\\\"'],
|
||||
});
|
||||
|
||||
@ -202,7 +205,7 @@ describe('amRouteToFormAmRoute', () => {
|
||||
|
||||
it('should unquote and unescape matcher names', () => {
|
||||
// Arrange
|
||||
const amRoute = buildAmRoute({
|
||||
const amRoute = buildAmRouteWithID({
|
||||
matchers: ['"foo"=bar', '"foo with spaces"=bar', '"foo\\\\slash"=bar', '"foo"quote"=bar', '"fo\\\\o"="ba\\\\r"'],
|
||||
});
|
||||
|
||||
|
@ -1,5 +1,3 @@
|
||||
import { uniqueId } from 'lodash';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { MatcherOperator, ObjectMatcher, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
@ -9,7 +7,7 @@ import { MatcherFieldValue } from '../types/silence-form';
|
||||
import { matcherToMatcherField } from './alertmanager';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
|
||||
import { encodeMatcher, normalizeMatchers, parseMatcherToArray, unquoteWithUnescape } from './matchers';
|
||||
import { findExistingRoute } from './routeTree';
|
||||
import { findExistingRoute, hashRoute } from './routeTree';
|
||||
import { isValidPrometheusDuration, safeParsePrometheusDuration } from './time';
|
||||
|
||||
const matchersToArrayFieldMatchers = (
|
||||
@ -65,21 +63,25 @@ export const emptyRoute: FormAmRoute = {
|
||||
};
|
||||
|
||||
// add unique identifiers to each route in the route tree, that way we can figure out what route we've edited / deleted
|
||||
export function addUniqueIdentifierToRoute(route: Route): RouteWithID {
|
||||
// ⚠️ make sure this function uses _stable_ identifiers!
|
||||
export function addUniqueIdentifierToRoute(route: Route, position = '0'): RouteWithID {
|
||||
const routeHash = hashRoute(route);
|
||||
const routes = route.routes ?? [];
|
||||
|
||||
return {
|
||||
id: uniqueId('route-'),
|
||||
id: `${position}-${routeHash}`,
|
||||
...route,
|
||||
routes: (route.routes ?? []).map(addUniqueIdentifierToRoute),
|
||||
routes: routes.map((route, index) => addUniqueIdentifierToRoute(route, `${position}-${index}`)),
|
||||
};
|
||||
}
|
||||
|
||||
//returns route, and a record mapping id to existing route
|
||||
export const amRouteToFormAmRoute = (route: RouteWithID | Route | undefined): FormAmRoute => {
|
||||
// returns route, and a record mapping id to existing route
|
||||
export const amRouteToFormAmRoute = (route: RouteWithID | undefined): FormAmRoute => {
|
||||
if (!route) {
|
||||
return emptyRoute;
|
||||
}
|
||||
|
||||
const id = 'id' in route ? route.id : uniqueId('route-');
|
||||
const id = route.id;
|
||||
|
||||
if (Object.keys(route).length === 0) {
|
||||
const formAmRoute = { ...emptyRoute, id };
|
||||
|
@ -189,6 +189,7 @@ export function unquoteIfRequired(input: string) {
|
||||
|
||||
export const encodeMatcher = ({ name, operator, value }: MatcherFieldValue) => {
|
||||
const encodedLabelName = quoteWithEscapeIfRequired(name);
|
||||
// @TODO why not use quoteWithEscapeIfRequired?
|
||||
const encodedLabelValue = quoteWithEscape(value);
|
||||
|
||||
return `${encodedLabelName}${operator}${encodedLabelValue}`;
|
||||
|
@ -1,9 +1,16 @@
|
||||
import { RouteWithID } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { MatcherOperator, ROUTES_META_SYMBOL, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import { FormAmRoute } from '../types/amroutes';
|
||||
|
||||
import { GRAFANA_DATASOURCE_NAME } from './datasource';
|
||||
import { addRouteToReferenceRoute, cleanRouteIDs, findRouteInTree, omitRouteFromRouteTree } from './routeTree';
|
||||
import {
|
||||
addRouteToReferenceRoute,
|
||||
cleanRouteIDs,
|
||||
findRouteInTree,
|
||||
hashRoute,
|
||||
omitRouteFromRouteTree,
|
||||
stabilizeRoute,
|
||||
} from './routeTree';
|
||||
|
||||
describe('findRouteInTree', () => {
|
||||
it('should find the correct route', () => {
|
||||
@ -14,7 +21,7 @@ describe('findRouteInTree', () => {
|
||||
routes: [{ id: 'route-1' }, needle, { id: 'route-3', routes: [{ id: 'route-4' }] }],
|
||||
};
|
||||
|
||||
expect(findRouteInTree(root, { id: 'route-2' })).toStrictEqual([needle, root, 1]);
|
||||
expect(findRouteInTree(root, 'route-2')).toStrictEqual([needle, root, 1]);
|
||||
});
|
||||
|
||||
it('should return undefined for unknown route', () => {
|
||||
@ -23,15 +30,15 @@ describe('findRouteInTree', () => {
|
||||
routes: [{ id: 'route-1' }],
|
||||
};
|
||||
|
||||
expect(findRouteInTree(root, { id: 'none' })).toStrictEqual([undefined, undefined, undefined]);
|
||||
expect(findRouteInTree(root, 'none')).toStrictEqual([undefined, undefined, undefined]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addRouteToReferenceRoute', () => {
|
||||
const targetRoute = { id: 'route-3' };
|
||||
const targetRouteIdentifier = 'route-3';
|
||||
const root: RouteWithID = {
|
||||
id: 'route-1',
|
||||
routes: [{ id: 'route-2' }, targetRoute],
|
||||
routes: [{ id: 'route-2' }, { id: targetRouteIdentifier }],
|
||||
};
|
||||
|
||||
const newRoute: Partial<FormAmRoute> = {
|
||||
@ -40,21 +47,25 @@ describe('addRouteToReferenceRoute', () => {
|
||||
};
|
||||
|
||||
it('should be able to add above', () => {
|
||||
expect(addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, targetRoute, root, 'above')).toMatchSnapshot();
|
||||
expect(
|
||||
addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, targetRouteIdentifier, root, 'above')
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should be able to add below', () => {
|
||||
expect(addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, targetRoute, root, 'below')).toMatchSnapshot();
|
||||
expect(
|
||||
addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, targetRouteIdentifier, root, 'below')
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should be able to add as child', () => {
|
||||
expect(addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, targetRoute, root, 'child')).toMatchSnapshot();
|
||||
expect(
|
||||
addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, targetRouteIdentifier, root, 'child')
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should throw if target route does not exist', () => {
|
||||
expect(() =>
|
||||
addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, { id: 'unknown' }, root, 'child')
|
||||
).toThrow();
|
||||
expect(() => addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, 'unknown', root, 'child')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@ -69,7 +80,7 @@ describe('omitRouteFromRouteTree', () => {
|
||||
],
|
||||
};
|
||||
|
||||
expect(omitRouteFromRouteTree({ id: 'route-4' }, tree)).toEqual({
|
||||
expect(omitRouteFromRouteTree('route-4', tree)).toEqual({
|
||||
id: 'route-1',
|
||||
receiver: 'root',
|
||||
routes: [
|
||||
@ -85,7 +96,7 @@ describe('omitRouteFromRouteTree', () => {
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
omitRouteFromRouteTree(tree, { id: 'route-1' });
|
||||
omitRouteFromRouteTree(tree.id, { id: 'route-1' });
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
@ -108,3 +119,57 @@ describe('cleanRouteIDs', () => {
|
||||
expect(cleanRouteIDs({ receiver: 'test' })).toEqual({ receiver: 'test' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashRoute and stabilizeRoute', () => {
|
||||
it('should sort the correct route properties', () => {
|
||||
const route: Route = {
|
||||
receiver: 'foo',
|
||||
group_by: ['g2', 'g1'],
|
||||
object_matchers: [
|
||||
['name2', MatcherOperator.equal, 'value2'],
|
||||
['name1', MatcherOperator.equal, 'value1'],
|
||||
],
|
||||
routes: [{ receiver: 'b' }, { receiver: 'a' }],
|
||||
match: {
|
||||
b: 'b',
|
||||
a: 'a',
|
||||
},
|
||||
};
|
||||
|
||||
const expected: Route = {
|
||||
active_time_intervals: [],
|
||||
continue: false,
|
||||
group_interval: '',
|
||||
group_wait: '',
|
||||
group_by: ['g1', 'g2'],
|
||||
match: {
|
||||
a: 'a',
|
||||
b: 'b',
|
||||
},
|
||||
match_re: {},
|
||||
matchers: [],
|
||||
mute_time_intervals: [],
|
||||
object_matchers: [
|
||||
['name1', MatcherOperator.equal, 'value1'],
|
||||
['name2', MatcherOperator.equal, 'value2'],
|
||||
],
|
||||
provenance: '',
|
||||
receiver: 'foo',
|
||||
repeat_interval: '',
|
||||
routes: [{ receiver: 'b' }, { receiver: 'a' }],
|
||||
[ROUTES_META_SYMBOL]: {},
|
||||
};
|
||||
|
||||
// the stabilizedRoute should match what we expect
|
||||
expect(stabilizeRoute(route)).toEqual(expected);
|
||||
|
||||
// the hash of the route should be stable (so we assert is twice)
|
||||
expect(hashRoute(route)).toBe('-1tfmmx');
|
||||
expect(hashRoute(route)).toBe('-1tfmmx');
|
||||
expect(hashRoute(expected)).toBe('-1tfmmx');
|
||||
|
||||
// the hash of the unstabilized route should be the same as the stabilized route
|
||||
// because the hash function will stabilize the inputs
|
||||
expect(hashRoute(route)).toBe(hashRoute(expected));
|
||||
});
|
||||
});
|
||||
|
@ -6,11 +6,16 @@ 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 { ROUTES_META_SYMBOL, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import {
|
||||
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route,
|
||||
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree,
|
||||
} from '../openapi/routesApi.gen';
|
||||
import { FormAmRoute } from '../types/amroutes';
|
||||
|
||||
import { formAmRouteToAmRoute } from './amroutes';
|
||||
import { ERROR_NEWER_CONFIGURATION } from './k8s/errors';
|
||||
|
||||
// add a form submission to the route tree
|
||||
export const mergePartialAmRouteWithRouteTree = (
|
||||
@ -20,7 +25,12 @@ export const mergePartialAmRouteWithRouteTree = (
|
||||
): Route => {
|
||||
const existing = findExistingRoute(partialFormRoute.id ?? '', routeTree);
|
||||
if (!existing) {
|
||||
throw new Error(`No such route with ID '${partialFormRoute.id}'`);
|
||||
throw new Error(`No such route with ID '${partialFormRoute.id}'`, {
|
||||
// this allows any error handling (when using stringifyErrorLike) to identify and translate this exception
|
||||
// we do, however, make the assumption that this exception is the result of the policy tree having been updating by the user
|
||||
// and this not being a programmer error.
|
||||
cause: ERROR_NEWER_CONFIGURATION,
|
||||
});
|
||||
}
|
||||
|
||||
function findAndReplace(currentRoute: RouteWithID): Route {
|
||||
@ -45,8 +55,8 @@ export const mergePartialAmRouteWithRouteTree = (
|
||||
|
||||
// remove a route from the policy tree, returns a new tree
|
||||
// make sure to omit the "id" because Prometheus / Loki / Mimir will reject the payload
|
||||
export const omitRouteFromRouteTree = (findRoute: RouteWithID, routeTree: RouteWithID): RouteWithID => {
|
||||
if (findRoute.id === routeTree.id) {
|
||||
export const omitRouteFromRouteTree = (id: string, routeTree: RouteWithID): RouteWithID => {
|
||||
if (id === routeTree.id) {
|
||||
throw new Error('You cant remove the root policy');
|
||||
}
|
||||
|
||||
@ -54,7 +64,7 @@ export const omitRouteFromRouteTree = (findRoute: RouteWithID, routeTree: RouteW
|
||||
return {
|
||||
...currentRoute,
|
||||
routes: currentRoute.routes?.reduce((acc: RouteWithID[] = [], route) => {
|
||||
if (route.id === findRoute.id) {
|
||||
if (route.id === id) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
@ -73,17 +83,19 @@ export type InsertPosition = 'above' | 'below' | 'child';
|
||||
export const addRouteToReferenceRoute = (
|
||||
alertManagerSourceName: string,
|
||||
partialFormRoute: Partial<FormAmRoute>,
|
||||
referenceRoute: RouteWithID,
|
||||
referenceRouteIdentifier: string,
|
||||
routeTree: RouteWithID,
|
||||
position: InsertPosition
|
||||
): RouteWithID => {
|
||||
const newRoute = formAmRouteToAmRoute(alertManagerSourceName, partialFormRoute, routeTree);
|
||||
|
||||
return produce(routeTree, (draftTree) => {
|
||||
const [routeInTree, parentRoute, positionInParent] = findRouteInTree(draftTree, referenceRoute);
|
||||
const [routeInTree, parentRoute, positionInParent] = findRouteInTree(draftTree, referenceRouteIdentifier);
|
||||
|
||||
if (routeInTree === undefined || parentRoute === undefined || positionInParent === undefined) {
|
||||
throw new Error(`could not find reference route "${referenceRoute.id}" in tree`);
|
||||
throw new Error(`could not find reference route "${referenceRouteIdentifier}" in tree`, {
|
||||
cause: ERROR_NEWER_CONFIGURATION,
|
||||
});
|
||||
}
|
||||
|
||||
// if user wants to insert new child policy, append to the bottom of children
|
||||
@ -111,7 +123,7 @@ type RouteMatch = Route | undefined;
|
||||
|
||||
export function findRouteInTree(
|
||||
routeTree: RouteWithID,
|
||||
referenceRoute: RouteWithID
|
||||
referenceRouteIdentifier: string
|
||||
): [matchingRoute: RouteMatch, parentRoute: RouteMatch, positionInParent: number | undefined] {
|
||||
let matchingRoute: RouteMatch;
|
||||
let matchingRouteParent: RouteMatch;
|
||||
@ -123,7 +135,7 @@ export function findRouteInTree(
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentRoute.id === referenceRoute.id) {
|
||||
if (currentRoute.id === referenceRouteIdentifier) {
|
||||
matchingRoute = currentRoute;
|
||||
matchingRouteParent = parentRoute;
|
||||
matchingRoutePositionInParent = index;
|
||||
@ -139,7 +151,9 @@ export function findRouteInTree(
|
||||
return [matchingRoute, matchingRouteParent, matchingRoutePositionInParent];
|
||||
}
|
||||
|
||||
export function cleanRouteIDs(route: Route | RouteWithID): Route {
|
||||
export function cleanRouteIDs<
|
||||
T extends RouteWithID | Route | ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route,
|
||||
>(route: T): Omit<T, 'id'> {
|
||||
return omit(
|
||||
{
|
||||
...route,
|
||||
@ -149,6 +163,70 @@ export function cleanRouteIDs(route: Route | RouteWithID): Route {
|
||||
);
|
||||
}
|
||||
|
||||
// remove IDs from the Kubernetes routes
|
||||
export function cleanKubernetesRouteIDs(
|
||||
routingTree: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree
|
||||
): ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree {
|
||||
return produce(routingTree, (draft) => {
|
||||
draft.spec.routes = draft.spec.routes.map(cleanRouteIDs);
|
||||
});
|
||||
}
|
||||
|
||||
export function findExistingRoute(id: string, routeTree: RouteWithID): RouteWithID | undefined {
|
||||
return routeTree.id === id ? routeTree : routeTree.routes?.find((route) => findExistingRoute(id, route));
|
||||
}
|
||||
|
||||
/**
|
||||
* This function converts an object into a unique hash by sorting the keys and applying a simple integer hash
|
||||
*/
|
||||
export function hashRoute(route: Route): string {
|
||||
const jsonString = JSON.stringify(stabilizeRoute(route));
|
||||
|
||||
// Simple hash function - convert to a number-based hash
|
||||
let hash = 0;
|
||||
for (let i = 0; i < jsonString.length; i++) {
|
||||
const char = jsonString.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
|
||||
return hash.toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* This function will sort the route's values and set the keys in a deterministic order
|
||||
*/
|
||||
export function stabilizeRoute(route: Route): Required<Route> {
|
||||
const result: Required<Route> = {
|
||||
receiver: route.receiver ?? '',
|
||||
group_by: route.group_by ? [...route.group_by].sort() : [],
|
||||
continue: route.continue ?? false,
|
||||
object_matchers: route.object_matchers ? [...route.object_matchers].sort() : [],
|
||||
matchers: route.matchers ? [...route.matchers].sort() : [],
|
||||
match: route.match ? sortRecord(route.match) : {},
|
||||
match_re: route.match_re ? sortRecord(route.match_re) : {},
|
||||
group_wait: route.group_wait ?? '',
|
||||
group_interval: route.group_interval ?? '',
|
||||
repeat_interval: route.repeat_interval ?? '',
|
||||
routes: route.routes ?? [], // routes are not sorted as if the order of the routes has changed, the hash should be different
|
||||
mute_time_intervals: route.mute_time_intervals ? [...route.mute_time_intervals].sort() : [],
|
||||
active_time_intervals: route.active_time_intervals ? [...route.active_time_intervals].sort() : [],
|
||||
provenance: route.provenance ?? '',
|
||||
[ROUTES_META_SYMBOL]: {},
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// function to sort the keys of a record
|
||||
function sortRecord(record: Record<string, string>): Record<string, string> {
|
||||
const sorted: Record<string, string> = {};
|
||||
|
||||
Object.keys(record)
|
||||
.sort()
|
||||
.forEach((key) => {
|
||||
sorted[key] = record[key];
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
@ -245,7 +245,7 @@ export function stringifyIdentifier(identifier: RuleIdentifier): string {
|
||||
.join('$');
|
||||
}
|
||||
|
||||
function hash(value: string): number {
|
||||
export function hash(value: string): number {
|
||||
let hash = 0;
|
||||
if (value.length === 0) {
|
||||
return hash;
|
||||
|
@ -2,6 +2,8 @@
|
||||
import { DataSourceJsonData, WithAccessControlMetadata } from '@grafana/data';
|
||||
import { IoK8SApimachineryPkgApisMetaV1ObjectMeta } from 'app/features/alerting/unified/openapi/receiversApi.gen';
|
||||
|
||||
export const ROUTES_META_SYMBOL = Symbol('routes_metadata');
|
||||
|
||||
export type AlertManagerCortexConfig = {
|
||||
template_files: Record<string, string>;
|
||||
alertmanager_config: AlertmanagerConfig;
|
||||
@ -133,7 +135,8 @@ export type Route = {
|
||||
active_time_intervals?: string[];
|
||||
/** only the root policy might have a provenance field defined */
|
||||
provenance?: string;
|
||||
_metadata?: {
|
||||
/** this is used to add additional metadata to the routes without interfering with original route definition (symbols aren't iterable) */
|
||||
[ROUTES_META_SYMBOL]?: {
|
||||
provisioned?: boolean;
|
||||
resourceVersion?: string;
|
||||
name?: string;
|
||||
|
Reference in New Issue
Block a user