Alerting: Use useProduceNewAlertmanagerConfiguration for notification policies (#98615)
Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
co-authored by
Gilles De Mey
parent
59184628e1
commit
798b32138d
@@ -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 { FormAmRoute } from '../../types/amroutes';
|
||||||
import { MatcherFormatter } from '../../utils/matchers';
|
import { MatcherFormatter } from '../../utils/matchers';
|
||||||
import { stringifyErrorLike } from '../../utils/misc';
|
|
||||||
import { InsertPosition } from '../../utils/routeTree';
|
import { InsertPosition } from '../../utils/routeTree';
|
||||||
import { AlertGroup } from '../alert-groups/AlertGroup';
|
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 ModalHook<T = undefined> = [JSX.Element, (item: T) => void, () => void];
|
||||||
type AddModalHook<T = undefined> = [JSX.Element, (item: T, position: InsertPosition) => void, () => void];
|
type AddModalHook<T = undefined> = [JSX.Element, (item: T, position: InsertPosition) => void, () => void];
|
||||||
type EditModalHook = [JSX.Element, (item: RouteWithID, isDefaultRoute?: boolean) => void, () => void];
|
type EditModalHook = [JSX.Element, (item: RouteWithID, isDefaultRoute?: boolean) => void, () => void];
|
||||||
|
|
||||||
const useAddPolicyModal = (
|
const useAddPolicyModal = (
|
||||||
handleAdd: (route: Partial<FormAmRoute>, referenceRoute: RouteWithID, position: InsertPosition) => void,
|
handleAdd: (route: Partial<FormAmRoute>, referenceRoute: RouteWithID, position: InsertPosition) => Promise<void>,
|
||||||
loading: boolean
|
loading: boolean
|
||||||
): AddModalHook<RouteWithID> => {
|
): AddModalHook<RouteWithID> => {
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
@@ -32,6 +30,7 @@ const useAddPolicyModal = (
|
|||||||
const handleDismiss = useCallback(() => {
|
const handleDismiss = useCallback(() => {
|
||||||
setReferenceRoute(undefined);
|
setReferenceRoute(undefined);
|
||||||
setInsertPosition(undefined);
|
setInsertPosition(undefined);
|
||||||
|
setError(undefined);
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -41,6 +40,8 @@ const useAddPolicyModal = (
|
|||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const [error, setError] = useState<Error | undefined>(undefined);
|
||||||
|
|
||||||
const modalElement = useMemo(
|
const modalElement = useMemo(
|
||||||
() =>
|
() =>
|
||||||
loading ? (
|
loading ? (
|
||||||
@@ -53,13 +54,14 @@ const useAddPolicyModal = (
|
|||||||
closeOnEscape={true}
|
closeOnEscape={true}
|
||||||
title="Add notification policy"
|
title="Add notification policy"
|
||||||
>
|
>
|
||||||
|
{error && <NotificationPoliciesErrorAlert error={error} />}
|
||||||
<AmRoutesExpandedForm
|
<AmRoutesExpandedForm
|
||||||
defaults={{
|
defaults={{
|
||||||
groupBy: referenceRoute?.group_by,
|
groupBy: referenceRoute?.group_by,
|
||||||
}}
|
}}
|
||||||
onSubmit={(newRoute) => {
|
onSubmit={(newRoute) => {
|
||||||
if (referenceRoute && insertPosition) {
|
if (referenceRoute && insertPosition) {
|
||||||
handleAdd(newRoute, referenceRoute, insertPosition);
|
handleAdd(newRoute, referenceRoute, insertPosition).catch(setError);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
actionButtons={
|
actionButtons={
|
||||||
@@ -75,7 +77,7 @@ const useAddPolicyModal = (
|
|||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
),
|
),
|
||||||
[handleAdd, handleDismiss, insertPosition, loading, referenceRoute, showModal]
|
[error, handleAdd, handleDismiss, insertPosition, loading, referenceRoute, showModal, setError]
|
||||||
);
|
);
|
||||||
|
|
||||||
return [modalElement, handleShow, handleDismiss];
|
return [modalElement, handleShow, handleDismiss];
|
||||||
@@ -83,17 +85,19 @@ const useAddPolicyModal = (
|
|||||||
|
|
||||||
const useEditPolicyModal = (
|
const useEditPolicyModal = (
|
||||||
alertManagerSourceName: string,
|
alertManagerSourceName: string,
|
||||||
handleSave: (route: Partial<FormAmRoute>) => void,
|
handleUpdate: (route: Partial<FormAmRoute>) => Promise<void>,
|
||||||
loading: boolean,
|
loading: boolean
|
||||||
error?: Error
|
|
||||||
): EditModalHook => {
|
): EditModalHook => {
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [isDefaultPolicy, setIsDefaultPolicy] = useState(false);
|
const [isDefaultPolicy, setIsDefaultPolicy] = useState(false);
|
||||||
const [route, setRoute] = useState<RouteWithID>();
|
const [route, setRoute] = useState<RouteWithID>();
|
||||||
|
|
||||||
|
const [error, setError] = useState<Error | undefined>(undefined);
|
||||||
|
|
||||||
const handleDismiss = useCallback(() => {
|
const handleDismiss = useCallback(() => {
|
||||||
setRoute(undefined);
|
setRoute(undefined);
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
|
setError(undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleShow = useCallback((route: RouteWithID, isDefaultPolicy?: boolean) => {
|
const handleShow = useCallback((route: RouteWithID, isDefaultPolicy?: boolean) => {
|
||||||
@@ -114,13 +118,13 @@ const useEditPolicyModal = (
|
|||||||
closeOnEscape={true}
|
closeOnEscape={true}
|
||||||
title="Edit notification policy"
|
title="Edit notification policy"
|
||||||
>
|
>
|
||||||
{error && <NotificationPoliciesErrorAlert error={stringifyErrorLike(error)} />}
|
{error && <NotificationPoliciesErrorAlert error={error} />}
|
||||||
{isDefaultPolicy && route && (
|
{isDefaultPolicy && route && (
|
||||||
<AmRootRouteForm
|
<AmRootRouteForm
|
||||||
// TODO *sigh* this alertmanagersourcename should come from context or something
|
// TODO *sigh* this alertmanagersourcename should come from context or something
|
||||||
// passing it down all the way here is a code smell
|
// passing it down all the way here is a code smell
|
||||||
alertManagerSourceName={alertManagerSourceName}
|
alertManagerSourceName={alertManagerSourceName}
|
||||||
onSubmit={handleSave}
|
onSubmit={(values) => handleUpdate(values).catch(setError)}
|
||||||
route={route}
|
route={route}
|
||||||
actionButtons={
|
actionButtons={
|
||||||
<Modal.ButtonRow>
|
<Modal.ButtonRow>
|
||||||
@@ -137,7 +141,7 @@ const useEditPolicyModal = (
|
|||||||
{!isDefaultPolicy && (
|
{!isDefaultPolicy && (
|
||||||
<AmRoutesExpandedForm
|
<AmRoutesExpandedForm
|
||||||
route={route}
|
route={route}
|
||||||
onSubmit={handleSave}
|
onSubmit={(values) => handleUpdate(values).catch(setError)}
|
||||||
actionButtons={
|
actionButtons={
|
||||||
<Modal.ButtonRow>
|
<Modal.ButtonRow>
|
||||||
<Button type="button" variant="secondary" onClick={handleDismiss} fill="outline">
|
<Button type="button" variant="secondary" onClick={handleDismiss} fill="outline">
|
||||||
@@ -152,19 +156,24 @@ const useEditPolicyModal = (
|
|||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
),
|
),
|
||||||
[alertManagerSourceName, error, handleDismiss, handleSave, isDefaultPolicy, loading, route, showModal]
|
[loading, showModal, handleDismiss, error, isDefaultPolicy, route, alertManagerSourceName, handleUpdate, setError]
|
||||||
);
|
);
|
||||||
|
|
||||||
return [modalElement, handleShow, handleDismiss];
|
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 [showModal, setShowModal] = useState(false);
|
||||||
const [route, setRoute] = useState<RouteWithID>();
|
const [route, setRoute] = useState<RouteWithID>();
|
||||||
|
const [error, setError] = useState<Error | undefined>(undefined);
|
||||||
|
|
||||||
const handleDismiss = useCallback(() => {
|
const handleDismiss = useCallback(() => {
|
||||||
setRoute(undefined);
|
setRoute(undefined);
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
|
setError(undefined);
|
||||||
}, [setRoute]);
|
}, [setRoute]);
|
||||||
|
|
||||||
const handleShow = useCallback((route: RouteWithID) => {
|
const handleShow = useCallback((route: RouteWithID) => {
|
||||||
@@ -172,12 +181,6 @@ const useDeletePolicyModal = (handleDelete: (route: RouteWithID) => void, loadin
|
|||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
|
||||||
if (route) {
|
|
||||||
handleDelete(route);
|
|
||||||
}
|
|
||||||
}, [handleDelete, route]);
|
|
||||||
|
|
||||||
const modalElement = useMemo(
|
const modalElement = useMemo(
|
||||||
() =>
|
() =>
|
||||||
loading ? (
|
loading ? (
|
||||||
@@ -190,13 +193,13 @@ const useDeletePolicyModal = (handleDelete: (route: RouteWithID) => void, loadin
|
|||||||
closeOnEscape={true}
|
closeOnEscape={true}
|
||||||
title="Delete notification policy"
|
title="Delete notification policy"
|
||||||
>
|
>
|
||||||
|
{error && <NotificationPoliciesErrorAlert error={error} />}
|
||||||
<Trans i18nKey="alerting.policies.delete.warning-1">
|
<Trans i18nKey="alerting.policies.delete.warning-1">
|
||||||
Deleting this notification policy will permanently remove it.
|
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>
|
<Trans i18nKey="alerting.policies.delete.warning-2">Are you sure you want to delete this policy?</Trans>
|
||||||
|
|
||||||
<Modal.ButtonRow>
|
<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>
|
<Trans i18nKey="alerting.policies.delete.confirm">Yes, delete policy</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="secondary" onClick={handleDismiss}>
|
<Button type="button" variant="secondary" onClick={handleDismiss}>
|
||||||
@@ -205,7 +208,7 @@ const useDeletePolicyModal = (handleDelete: (route: RouteWithID) => void, loadin
|
|||||||
</Modal.ButtonRow>
|
</Modal.ButtonRow>
|
||||||
</Modal>
|
</Modal>
|
||||||
),
|
),
|
||||||
[handleDismiss, handleSubmit, loading, showModal]
|
[handleDismiss, loading, showModal, error, route, handleDelete]
|
||||||
);
|
);
|
||||||
|
|
||||||
return [modalElement, handleShow, handleDismiss];
|
return [modalElement, handleShow, handleDismiss];
|
||||||
|
|||||||
+68
-67
@@ -8,28 +8,27 @@ import { useContactPointsWithStatus } from 'app/features/alerting/unified/compon
|
|||||||
import { AlertmanagerAction, useAlertmanagerAbility } from 'app/features/alerting/unified/hooks/useAbilities';
|
import { AlertmanagerAction, useAlertmanagerAbility } from 'app/features/alerting/unified/hooks/useAbilities';
|
||||||
import { FormAmRoute } from 'app/features/alerting/unified/types/amroutes';
|
import { FormAmRoute } from 'app/features/alerting/unified/types/amroutes';
|
||||||
import { addUniqueIdentifierToRoute } from 'app/features/alerting/unified/utils/amroutes';
|
import { addUniqueIdentifierToRoute } from 'app/features/alerting/unified/utils/amroutes';
|
||||||
import { ERROR_NEWER_CONFIGURATION } from 'app/features/alerting/unified/utils/k8s/errors';
|
import { getErrorCode, stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
|
||||||
import { isErrorMatchingCode, stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
|
|
||||||
import { computeInheritedTree } from 'app/features/alerting/unified/utils/notification-policies';
|
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 { useAlertmanager } from '../../state/AlertmanagerContext';
|
||||||
|
import { ERROR_NEWER_CONFIGURATION } from '../../utils/k8s/errors';
|
||||||
|
|
||||||
import { alertmanagerApi } from './../../api/alertmanagerApi';
|
import { alertmanagerApi } from './../../api/alertmanagerApi';
|
||||||
import { useGetContactPointsState } from './../../api/receiversApi';
|
import { useGetContactPointsState } from './../../api/receiversApi';
|
||||||
import { isLoading as isPending, useAsync } from './../../hooks/useAsync';
|
|
||||||
import { useRouteGroupsMatcher } from './../../useRouteGroupsMatcher';
|
import { useRouteGroupsMatcher } from './../../useRouteGroupsMatcher';
|
||||||
import {
|
import { InsertPosition } from './../../utils/routeTree';
|
||||||
InsertPosition,
|
|
||||||
addRouteToReferenceRoute,
|
|
||||||
cleanRouteIDs,
|
|
||||||
mergePartialAmRouteWithRouteTree,
|
|
||||||
omitRouteFromRouteTree,
|
|
||||||
} from './../../utils/routeTree';
|
|
||||||
import { NotificationPoliciesFilter, findRoutesByMatchers, findRoutesMatchingPredicate } from './Filters';
|
import { NotificationPoliciesFilter, findRoutesByMatchers, findRoutesMatchingPredicate } from './Filters';
|
||||||
import { useAddPolicyModal, useAlertGroupsModal, useDeletePolicyModal, useEditPolicyModal } from './Modals';
|
import { useAddPolicyModal, useAlertGroupsModal, useDeletePolicyModal, useEditPolicyModal } from './Modals';
|
||||||
import { Policy } from './Policy';
|
import { Policy } from './Policy';
|
||||||
import { useNotificationPolicyRoute, useUpdateNotificationPolicyRoute } from './useNotificationPolicyRoute';
|
import {
|
||||||
|
useAddNotificationPolicy,
|
||||||
|
useDeleteNotificationPolicy,
|
||||||
|
useNotificationPolicyRoute,
|
||||||
|
useUpdateExistingNotificationPolicy,
|
||||||
|
} from './useNotificationPolicyRoute';
|
||||||
|
|
||||||
export const NotificationPoliciesList = () => {
|
export const NotificationPoliciesList = () => {
|
||||||
const appNotification = useAppNotification();
|
const appNotification = useAppNotification();
|
||||||
@@ -53,7 +52,7 @@ export const NotificationPoliciesList = () => {
|
|||||||
const {
|
const {
|
||||||
currentData,
|
currentData,
|
||||||
isLoading,
|
isLoading,
|
||||||
error: resultError,
|
error: fetchPoliciesError,
|
||||||
refetch: refetchNotificationPolicyRoute,
|
refetch: refetchNotificationPolicyRoute,
|
||||||
} = useNotificationPolicyRoute({ alertmanager: selectedAlertmanager ?? '' });
|
} = useNotificationPolicyRoute({ alertmanager: selectedAlertmanager ?? '' });
|
||||||
|
|
||||||
@@ -62,7 +61,22 @@ export const NotificationPoliciesList = () => {
|
|||||||
// TODO in the future: Generalise the component to support any number of "root" policies
|
// TODO in the future: Generalise the component to support any number of "root" policies
|
||||||
const [defaultPolicy] = currentData ?? [];
|
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(
|
const { currentData: alertGroups, refetch: refetchAlertGroups } = useGetAlertmanagerAlertGroupsQuery(
|
||||||
{ amSourceName: selectedAlertmanager ?? '' },
|
{ amSourceName: selectedAlertmanager ?? '' },
|
||||||
@@ -112,56 +126,38 @@ export const NotificationPoliciesList = () => {
|
|||||||
|
|
||||||
const refetchPolicies = () => {
|
const refetchPolicies = () => {
|
||||||
refetchNotificationPolicyRoute();
|
refetchNotificationPolicyRoute();
|
||||||
updateRouteTree.reset();
|
updateExistingNotificationPolicy.reset();
|
||||||
|
deleteNotificationPolicy.reset();
|
||||||
|
addNotificationPolicy.reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleSave(partialRoute: Partial<FormAmRoute>) {
|
async function handleUpdate(partialRoute: Partial<FormAmRoute>) {
|
||||||
if (!rootRoute) {
|
await updateExistingNotificationPolicy.execute(partialRoute);
|
||||||
return;
|
handleActionResult({ error: updateExistingNotificationPolicyState.error });
|
||||||
}
|
|
||||||
|
|
||||||
const newRouteTree = mergePartialAmRouteWithRouteTree(selectedAlertmanager ?? '', partialRoute, rootRoute);
|
|
||||||
updateRouteTree.execute(newRouteTree);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDelete(route: RouteWithID) {
|
async function handleDelete(route: RouteWithID) {
|
||||||
if (!rootRoute) {
|
await deleteNotificationPolicy.execute(route.id);
|
||||||
return;
|
handleActionResult({ error: deleteNotificationPolicyState.error });
|
||||||
}
|
|
||||||
const newRouteTree = omitRouteFromRouteTree(route, rootRoute);
|
|
||||||
updateRouteTree.execute(newRouteTree);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAdd(partialRoute: Partial<FormAmRoute>, referenceRoute: RouteWithID, insertPosition: InsertPosition) {
|
async function handleAdd(
|
||||||
if (!rootRoute) {
|
partialRoute: Partial<FormAmRoute>,
|
||||||
return;
|
referenceRoute: RouteWithID,
|
||||||
}
|
insertPosition: InsertPosition
|
||||||
|
) {
|
||||||
const newRouteTree = addRouteToReferenceRoute(
|
await addNotificationPolicy.execute({
|
||||||
selectedAlertmanager ?? '',
|
|
||||||
partialRoute,
|
partialRoute,
|
||||||
referenceRoute,
|
referenceRouteIdentifier: referenceRoute.id,
|
||||||
rootRoute,
|
insertPosition,
|
||||||
insertPosition
|
});
|
||||||
);
|
handleActionResult({ error: addNotificationPolicyState.error });
|
||||||
updateRouteTree.execute(newRouteTree);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// this function will make the HTTP request and tracks the state of the request
|
function handleActionResult({ error }: { error?: Error }) {
|
||||||
const [updateRouteTree, updateRouteTreeState] = useAsync(async (routeTree: Route | RouteWithID) => {
|
if (!error) {
|
||||||
if (!rootRoute) {
|
appNotification.success('Updated notification policies');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
if (selectedAlertmanager) {
|
||||||
refetchAlertGroups();
|
refetchAlertGroups();
|
||||||
}
|
}
|
||||||
@@ -170,20 +166,20 @@ export const NotificationPoliciesList = () => {
|
|||||||
closeEditModal();
|
closeEditModal();
|
||||||
closeAddModal();
|
closeAddModal();
|
||||||
closeDeleteModal();
|
closeDeleteModal();
|
||||||
|
}
|
||||||
|
|
||||||
return newTree;
|
const updatingTree = anyOfRequestState(
|
||||||
});
|
updateExistingNotificationPolicyState,
|
||||||
|
deleteNotificationPolicyState,
|
||||||
const updatingTree = isPending(updateRouteTreeState);
|
addNotificationPolicyState
|
||||||
const updateError = updateRouteTreeState.error;
|
).loading;
|
||||||
|
|
||||||
// edit, add, delete modals
|
// edit, add, delete modals
|
||||||
const [addModal, openAddModal, closeAddModal] = useAddPolicyModal(handleAdd, updatingTree);
|
const [addModal, openAddModal, closeAddModal] = useAddPolicyModal(handleAdd, updatingTree);
|
||||||
const [editModal, openEditModal, closeEditModal] = useEditPolicyModal(
|
const [editModal, openEditModal, closeEditModal] = useEditPolicyModal(
|
||||||
selectedAlertmanager ?? '',
|
selectedAlertmanager ?? '',
|
||||||
handleSave,
|
handleUpdate,
|
||||||
updatingTree,
|
updatingTree
|
||||||
updateError
|
|
||||||
);
|
);
|
||||||
const [deleteModal, openDeleteModal, closeDeleteModal] = useDeletePolicyModal(handleDelete, updatingTree);
|
const [deleteModal, openDeleteModal, closeDeleteModal] = useDeletePolicyModal(handleDelete, updatingTree);
|
||||||
const [alertInstancesModal, showAlertGroupsModal] = useAlertGroupsModal(selectedAlertmanager ?? '');
|
const [alertInstancesModal, showAlertGroupsModal] = useAlertGroupsModal(selectedAlertmanager ?? '');
|
||||||
@@ -192,18 +188,23 @@ export const NotificationPoliciesList = () => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasPoliciesData = rootRoute && !resultError && !isLoading;
|
const hasPoliciesData = rootRoute && !fetchPoliciesError && !isLoading;
|
||||||
const hasPoliciesError = !!resultError && !isLoading;
|
const hasPoliciesError = Boolean(fetchPoliciesError) && !isLoading;
|
||||||
|
const hasConflictError = [
|
||||||
|
addNotificationPolicyState,
|
||||||
|
updateExistingNotificationPolicyState,
|
||||||
|
deleteNotificationPolicyState,
|
||||||
|
].some((state) => isError(state) && getErrorCode(state.error) === ERROR_NEWER_CONFIGURATION);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{hasPoliciesError && (
|
{hasPoliciesError && (
|
||||||
<Alert severity="error" title="Error loading Alertmanager config">
|
<Alert severity="error" title="Error loading Alertmanager config">
|
||||||
{stringifyErrorLike(resultError) || 'Unknown error.'}
|
{stringifyErrorLike(fetchPoliciesError) || 'Unknown error.'}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{/* show when there is an update error */}
|
{/* show when there is an update error */}
|
||||||
{isErrorMatchingCode(updateError, ERROR_NEWER_CONFIGURATION) && (
|
{hasConflictError && (
|
||||||
<Alert severity="info" title="Notification policies have changed">
|
<Alert severity="info" title="Notification policies have changed">
|
||||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||||
<Trans i18nKey="alerting.policies.update-errors.conflict">
|
<Trans i18nKey="alerting.policies.update-errors.conflict">
|
||||||
@@ -230,7 +231,7 @@ export const NotificationPoliciesList = () => {
|
|||||||
currentRoute={rootRoute}
|
currentRoute={rootRoute}
|
||||||
contactPointsState={contactPointsState.receivers}
|
contactPointsState={contactPointsState.receivers}
|
||||||
readOnly={!hasConfigurationAPI}
|
readOnly={!hasConfigurationAPI}
|
||||||
provisioned={rootRoute._metadata?.provisioned}
|
provisioned={rootRoute[ROUTES_META_SYMBOL]?.provisioned}
|
||||||
alertManagerSourceName={selectedAlertmanager}
|
alertManagerSourceName={selectedAlertmanager}
|
||||||
onAddPolicy={openAddModal}
|
onAddPolicy={openAddModal}
|
||||||
onEditPolicy={openEditModal}
|
onEditPolicy={openEditModal}
|
||||||
|
|||||||
+35
@@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
+117
@@ -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();
|
||||||
|
});
|
||||||
+185
-60
@@ -1,27 +1,45 @@
|
|||||||
|
import { pick } from 'lodash';
|
||||||
import memoize from 'micro-memoize';
|
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 { 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 { alertmanagerApi } from '../../api/alertmanagerApi';
|
||||||
|
import { useAsync } from '../../hooks/useAsync';
|
||||||
|
import { useProduceNewAlertmanagerConfiguration } from '../../hooks/useProduceNewAlertmanagerConfig';
|
||||||
import {
|
import {
|
||||||
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route,
|
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route,
|
||||||
|
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RouteDefaults,
|
||||||
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree,
|
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree,
|
||||||
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTreeSpec,
|
generatedRoutesApi as routingTreeApi,
|
||||||
} from '../../openapi/routesApi.gen';
|
} 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 { 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 { 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 k8sRoutesToRoutesMemoized = memoize(k8sRoutesToRoutes, { maxSize: 1 });
|
||||||
|
|
||||||
const { useListNamespacedRoutingTreeQuery, useReplaceNamespacedRoutingTreeMutation } = routingTreeApi;
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
useUpdateAlertmanagerConfigurationMutation,
|
useListNamespacedRoutingTreeQuery,
|
||||||
useLazyGetAlertmanagerConfigurationQuery,
|
useReplaceNamespacedRoutingTreeMutation,
|
||||||
useGetAlertmanagerConfigurationQuery,
|
useLazyListNamespacedRoutingTreeQuery,
|
||||||
} = alertmanagerApi;
|
} = routingTreeApi;
|
||||||
|
|
||||||
|
const { useGetAlertmanagerConfigurationQuery } = alertmanagerApi;
|
||||||
|
|
||||||
export const useNotificationPolicyRoute = ({ alertmanager }: BaseAlertmanagerArgs, { skip }: Skippable = {}) => {
|
export const useNotificationPolicyRoute = ({ alertmanager }: BaseAlertmanagerArgs, { skip }: Skippable = {}) => {
|
||||||
const k8sApiSupported = shouldUseK8sApi(alertmanager);
|
const k8sApiSupported = shouldUseK8sApi(alertmanager);
|
||||||
@@ -61,73 +79,149 @@ export const useNotificationPolicyRoute = ({ alertmanager }: BaseAlertmanagerArg
|
|||||||
const parseAmConfigRoute = memoize((route: Route): Route => {
|
const parseAmConfigRoute = memoize((route: Route): Route => {
|
||||||
return {
|
return {
|
||||||
...route,
|
...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) {
|
export function useUpdateExistingNotificationPolicy({ alertmanager }: BaseAlertmanagerArgs) {
|
||||||
const [getAlertmanagerConfiguration] = useLazyGetAlertmanagerConfigurationQuery();
|
const k8sApiSupported = shouldUseK8sApi(alertmanager);
|
||||||
const [updateAlertmanagerConfiguration] = useUpdateAlertmanagerConfigurationMutation();
|
|
||||||
|
|
||||||
const [updatedNamespacedRoute] = useReplaceNamespacedRoutingTreeMutation();
|
const [updatedNamespacedRoute] = useReplaceNamespacedRoutingTreeMutation();
|
||||||
|
const [produceNewAlertmanagerConfiguration] = useProduceNewAlertmanagerConfiguration();
|
||||||
|
const [listNamespacedRoutingTree] = useLazyListNamespacedRoutingTreeQuery();
|
||||||
|
|
||||||
const k8sApiSupported = shouldUseK8sApi(selectedAlertmanager);
|
const updateUsingK8sApi = useAsync(async (update: Partial<FormAmRoute>) => {
|
||||||
|
|
||||||
async function updateUsingK8sApi({ newRoute }: { newRoute: Route }) {
|
|
||||||
const namespace = getK8sNamespace();
|
const namespace = getK8sNamespace();
|
||||||
const { routes, _metadata, ...defaults } = newRoute;
|
const result = await listNamespacedRoutingTree({ namespace });
|
||||||
// Remove provenance so we don't send it to API
|
|
||||||
// Convert Route to K8s compatible format
|
const [rootTree] = result.data ? k8sRoutesToRoutesMemoized(result.data.items) : [];
|
||||||
const k8sRoute: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTreeSpec = {
|
if (!rootTree) {
|
||||||
defaults: {
|
throw new Error(`no root route found for namespace ${namespace}`);
|
||||||
...defaults,
|
}
|
||||||
// TODO: Fix types in k8s API? Fix our types to not allow empty receiver? TBC
|
|
||||||
receiver: defaults.receiver || '',
|
const rootRouteWithIdentifiers = addUniqueIdentifierToRoute(rootTree);
|
||||||
},
|
const newRouteTree = mergePartialAmRouteWithRouteTree(alertmanager, update, rootRouteWithIdentifiers);
|
||||||
routes: newRoute.routes?.map(routeToK8sSubRoute) || [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create the K8s route object
|
// Create the K8s route object
|
||||||
const routeObject: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree = {
|
const routeObject = createKubernetesRoutingTreeSpec(newRouteTree);
|
||||||
spec: k8sRoute,
|
|
||||||
metadata: { name: ROOT_ROUTE_NAME, resourceVersion: _metadata?.resourceVersion },
|
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({
|
return updatedNamespacedRoute({
|
||||||
name: ROOT_ROUTE_NAME,
|
name: ROOT_ROUTE_NAME,
|
||||||
namespace,
|
namespace,
|
||||||
comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree: routeObject,
|
comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree: routeObject,
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
}
|
});
|
||||||
|
|
||||||
async function updateUsingConfigFileApi({ newRoute, oldRoute }: { newRoute: Route; oldRoute: Route }) {
|
const deleteFromAlertmanagerConfiguration = useAsync(async (id: string) => {
|
||||||
const { _metadata, ...oldRouteStripped } = oldRoute;
|
const action = deleteRouteAction({ id });
|
||||||
const { _metadata: newMetadata, ...newRouteStripped } = newRoute;
|
return produceNewAlertmanagerConfiguration(action);
|
||||||
const lastConfig = await getAlertmanagerConfiguration(selectedAlertmanager).unwrap();
|
});
|
||||||
const latestRouteFromConfig = lastConfig.alertmanager_config.route;
|
|
||||||
|
|
||||||
const configChangedInMeantime = JSON.stringify(oldRouteStripped) !== JSON.stringify(latestRouteFromConfig);
|
return k8sApiSupported ? deleteFromK8sApi : deleteFromAlertmanagerConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
if (configChangedInMeantime) {
|
export function useAddNotificationPolicy({ alertmanager }: BaseAlertmanagerArgs) {
|
||||||
throw new Error('configuration modification conflict', { cause: ERROR_NEWER_CONFIGURATION });
|
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 = {
|
const addToAlertmanagerConfiguration = useAsync(
|
||||||
...lastConfig,
|
async ({
|
||||||
alertmanager_config: {
|
partialRoute,
|
||||||
...lastConfig.alertmanager_config,
|
referenceRouteIdentifier,
|
||||||
route: newRouteStripped,
|
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 k8sApiSupported ? addToK8sApi : addToAlertmanagerConfiguration;
|
||||||
return updateAlertmanagerConfiguration({
|
|
||||||
selectedAlertmanager,
|
|
||||||
config: newConfig,
|
|
||||||
}).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
return k8sApiSupported ? updateUsingK8sApi : updateUsingConfigFileApi;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function k8sRoutesToRoutes(routes: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree[]): Route[] {
|
function k8sRoutesToRoutes(routes: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree[]): Route[] {
|
||||||
@@ -135,7 +229,7 @@ function k8sRoutesToRoutes(routes: ComGithubGrafanaGrafanaPkgApisAlertingNotific
|
|||||||
return {
|
return {
|
||||||
...route.spec.defaults,
|
...route.spec.defaults,
|
||||||
routes: route.spec.routes?.map(k8sSubRouteToRoute),
|
routes: route.spec.routes?.map(k8sSubRouteToRoute),
|
||||||
_metadata: {
|
[ROUTES_META_SYMBOL]: {
|
||||||
provisioned: isK8sEntityProvisioned(route),
|
provisioned: isK8sEntityProvisioned(route),
|
||||||
resourceVersion: route.metadata.resourceVersion,
|
resourceVersion: route.metadata.resourceVersion,
|
||||||
name: route.metadata.name,
|
name: route.metadata.name,
|
||||||
@@ -149,7 +243,7 @@ function isValidMatcherOperator(type: string): type is MatcherOperator {
|
|||||||
return Object.values<string>(MatcherOperator).includes(type);
|
return Object.values<string>(MatcherOperator).includes(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
function k8sSubRouteToRoute(route: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route): Route {
|
export function k8sSubRouteToRoute(route: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route): Route {
|
||||||
return {
|
return {
|
||||||
...route,
|
...route,
|
||||||
routes: route.routes?.map(k8sSubRouteToRoute),
|
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;
|
const { object_matchers, ...rest } = route;
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
@@ -176,3 +270,34 @@ function routeToK8sSubRoute(route: Route): ComGithubGrafanaGrafanaPkgApisAlertin
|
|||||||
routes: route.routes?.map(routeToK8sSubRoute),
|
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 { alertmanagerApi } from '../api/alertmanagerApi';
|
||||||
import { muteTimingsReducer } from '../reducers/alertmanager/muteTimings';
|
import { muteTimingsReducer } from '../reducers/alertmanager/muteTimings';
|
||||||
|
import { routesReducer } from '../reducers/alertmanager/notificationPolicyRoutes';
|
||||||
import { notificationTemplatesReducer } from '../reducers/alertmanager/notificationTemplates';
|
import { notificationTemplatesReducer } from '../reducers/alertmanager/notificationTemplates';
|
||||||
import { receiversReducer } from '../reducers/alertmanager/receivers';
|
import { receiversReducer } from '../reducers/alertmanager/receivers';
|
||||||
import { useAlertmanager } from '../state/AlertmanagerContext';
|
import { useAlertmanager } from '../state/AlertmanagerContext';
|
||||||
@@ -31,7 +32,8 @@ const configurationReducer = reduceReducers(
|
|||||||
initialAlertmanagerConfiguration,
|
initialAlertmanagerConfiguration,
|
||||||
muteTimingsReducer,
|
muteTimingsReducer,
|
||||||
receiversReducer,
|
receiversReducer,
|
||||||
notificationTemplatesReducer
|
notificationTemplatesReducer,
|
||||||
|
routesReducer
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+182
@@ -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": {},
|
||||||
|
}
|
||||||
|
`;
|
||||||
+144
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+82
@@ -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 { FormAmRoute } from '../types/amroutes';
|
||||||
|
|
||||||
import { amRouteToFormAmRoute, emptyRoute, formAmRouteToAmRoute } from './amroutes';
|
import { amRouteToFormAmRoute, emptyRoute, formAmRouteToAmRoute } from './amroutes';
|
||||||
import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
|
import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
|
||||||
|
|
||||||
const emptyAmRoute: Route = {
|
const emptyAmRoute: RouteWithID = {
|
||||||
|
id: uniqueId(),
|
||||||
receiver: '',
|
receiver: '',
|
||||||
group_by: [],
|
group_by: [],
|
||||||
continue: false,
|
continue: false,
|
||||||
@@ -20,8 +23,8 @@ const emptyAmRoute: Route = {
|
|||||||
mute_time_intervals: [],
|
mute_time_intervals: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildAmRoute = (override: Partial<Route> = {}): Route => {
|
const buildAmRouteWithID = (override: Partial<RouteWithID> = {}): RouteWithID => {
|
||||||
return { ...emptyAmRoute, ...override };
|
return { ...emptyAmRoute, ...override, id: uniqueId() };
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildFormAmRoute = (override: Partial<FormAmRoute> = {}): FormAmRoute => {
|
const buildFormAmRoute = (override: Partial<FormAmRoute> = {}): FormAmRoute => {
|
||||||
@@ -138,7 +141,7 @@ describe('amRouteToFormAmRoute', () => {
|
|||||||
describe('when called with empty group_by array', () => {
|
describe('when called with empty group_by array', () => {
|
||||||
it('should set overrideGrouping true and groupBy empty', () => {
|
it('should set overrideGrouping true and groupBy empty', () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const amRoute = buildAmRoute({ group_by: [] });
|
const amRoute = buildAmRouteWithID({ group_by: [] });
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const formRoute = amRouteToFormAmRoute(amRoute);
|
const formRoute = amRouteToFormAmRoute(amRoute);
|
||||||
@@ -156,7 +159,7 @@ describe('amRouteToFormAmRoute', () => {
|
|||||||
${undefined}
|
${undefined}
|
||||||
`("when group_by is '$group_by', should set overrideGrouping false", ({ group_by }) => {
|
`("when group_by is '$group_by', should set overrideGrouping false", ({ group_by }) => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const amRoute = buildAmRoute({ group_by: group_by });
|
const amRoute = buildAmRouteWithID({ group_by: group_by });
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const formRoute = amRouteToFormAmRoute(amRoute);
|
const formRoute = amRouteToFormAmRoute(amRoute);
|
||||||
@@ -170,7 +173,7 @@ describe('amRouteToFormAmRoute', () => {
|
|||||||
describe('when called with non-empty group_by', () => {
|
describe('when called with non-empty group_by', () => {
|
||||||
it('Should set overrideGrouping true and groupBy', () => {
|
it('Should set overrideGrouping true and groupBy', () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const amRoute = buildAmRoute({ group_by: ['SHOULD BE SET'] });
|
const amRoute = buildAmRouteWithID({ group_by: ['SHOULD BE SET'] });
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const formRoute = amRouteToFormAmRoute(amRoute);
|
const formRoute = amRouteToFormAmRoute(amRoute);
|
||||||
@@ -183,7 +186,7 @@ describe('amRouteToFormAmRoute', () => {
|
|||||||
|
|
||||||
it('should unquote and unescape matchers values', () => {
|
it('should unquote and unescape matchers values', () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const amRoute = buildAmRoute({
|
const amRoute = buildAmRouteWithID({
|
||||||
matchers: ['foo=bar', 'foo="bar"', 'foo="bar"baz"', 'foo="bar\\\\baz"', 'foo="\\\\bar\\\\baz"\\\\"'],
|
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', () => {
|
it('should unquote and unescape matcher names', () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const amRoute = buildAmRoute({
|
const amRoute = buildAmRouteWithID({
|
||||||
matchers: ['"foo"=bar', '"foo with spaces"=bar', '"foo\\\\slash"=bar', '"foo"quote"=bar', '"fo\\\\o"="ba\\\\r"'],
|
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 { SelectableValue } from '@grafana/data';
|
||||||
import { MatcherOperator, ObjectMatcher, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
|
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 { matcherToMatcherField } from './alertmanager';
|
||||||
import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
|
import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
|
||||||
import { encodeMatcher, normalizeMatchers, parseMatcherToArray, unquoteWithUnescape } from './matchers';
|
import { encodeMatcher, normalizeMatchers, parseMatcherToArray, unquoteWithUnescape } from './matchers';
|
||||||
import { findExistingRoute } from './routeTree';
|
import { findExistingRoute, hashRoute } from './routeTree';
|
||||||
import { isValidPrometheusDuration, safeParsePrometheusDuration } from './time';
|
import { isValidPrometheusDuration, safeParsePrometheusDuration } from './time';
|
||||||
|
|
||||||
const matchersToArrayFieldMatchers = (
|
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
|
// 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 {
|
return {
|
||||||
id: uniqueId('route-'),
|
id: `${position}-${routeHash}`,
|
||||||
...route,
|
...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
|
// returns route, and a record mapping id to existing route
|
||||||
export const amRouteToFormAmRoute = (route: RouteWithID | Route | undefined): FormAmRoute => {
|
export const amRouteToFormAmRoute = (route: RouteWithID | undefined): FormAmRoute => {
|
||||||
if (!route) {
|
if (!route) {
|
||||||
return emptyRoute;
|
return emptyRoute;
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = 'id' in route ? route.id : uniqueId('route-');
|
const id = route.id;
|
||||||
|
|
||||||
if (Object.keys(route).length === 0) {
|
if (Object.keys(route).length === 0) {
|
||||||
const formAmRoute = { ...emptyRoute, id };
|
const formAmRoute = { ...emptyRoute, id };
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ export function unquoteIfRequired(input: string) {
|
|||||||
|
|
||||||
export const encodeMatcher = ({ name, operator, value }: MatcherFieldValue) => {
|
export const encodeMatcher = ({ name, operator, value }: MatcherFieldValue) => {
|
||||||
const encodedLabelName = quoteWithEscapeIfRequired(name);
|
const encodedLabelName = quoteWithEscapeIfRequired(name);
|
||||||
|
// @TODO why not use quoteWithEscapeIfRequired?
|
||||||
const encodedLabelValue = quoteWithEscape(value);
|
const encodedLabelValue = quoteWithEscape(value);
|
||||||
|
|
||||||
return `${encodedLabelName}${operator}${encodedLabelValue}`;
|
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 { FormAmRoute } from '../types/amroutes';
|
||||||
|
|
||||||
import { GRAFANA_DATASOURCE_NAME } from './datasource';
|
import { GRAFANA_DATASOURCE_NAME } from './datasource';
|
||||||
import { addRouteToReferenceRoute, cleanRouteIDs, findRouteInTree, omitRouteFromRouteTree } from './routeTree';
|
import {
|
||||||
|
addRouteToReferenceRoute,
|
||||||
|
cleanRouteIDs,
|
||||||
|
findRouteInTree,
|
||||||
|
hashRoute,
|
||||||
|
omitRouteFromRouteTree,
|
||||||
|
stabilizeRoute,
|
||||||
|
} from './routeTree';
|
||||||
|
|
||||||
describe('findRouteInTree', () => {
|
describe('findRouteInTree', () => {
|
||||||
it('should find the correct route', () => {
|
it('should find the correct route', () => {
|
||||||
@@ -14,7 +21,7 @@ describe('findRouteInTree', () => {
|
|||||||
routes: [{ id: 'route-1' }, needle, { id: 'route-3', routes: [{ id: 'route-4' }] }],
|
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', () => {
|
it('should return undefined for unknown route', () => {
|
||||||
@@ -23,15 +30,15 @@ describe('findRouteInTree', () => {
|
|||||||
routes: [{ id: 'route-1' }],
|
routes: [{ id: 'route-1' }],
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(findRouteInTree(root, { id: 'none' })).toStrictEqual([undefined, undefined, undefined]);
|
expect(findRouteInTree(root, 'none')).toStrictEqual([undefined, undefined, undefined]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('addRouteToReferenceRoute', () => {
|
describe('addRouteToReferenceRoute', () => {
|
||||||
const targetRoute = { id: 'route-3' };
|
const targetRouteIdentifier = 'route-3';
|
||||||
const root: RouteWithID = {
|
const root: RouteWithID = {
|
||||||
id: 'route-1',
|
id: 'route-1',
|
||||||
routes: [{ id: 'route-2' }, targetRoute],
|
routes: [{ id: 'route-2' }, { id: targetRouteIdentifier }],
|
||||||
};
|
};
|
||||||
|
|
||||||
const newRoute: Partial<FormAmRoute> = {
|
const newRoute: Partial<FormAmRoute> = {
|
||||||
@@ -40,21 +47,25 @@ describe('addRouteToReferenceRoute', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
it('should be able to add above', () => {
|
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', () => {
|
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', () => {
|
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', () => {
|
it('should throw if target route does not exist', () => {
|
||||||
expect(() =>
|
expect(() => addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, 'unknown', root, 'child')).toThrow();
|
||||||
addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, { id: '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',
|
id: 'route-1',
|
||||||
receiver: 'root',
|
receiver: 'root',
|
||||||
routes: [
|
routes: [
|
||||||
@@ -85,7 +96,7 @@ describe('omitRouteFromRouteTree', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
omitRouteFromRouteTree(tree, { id: 'route-1' });
|
omitRouteFromRouteTree(tree.id, { id: 'route-1' });
|
||||||
}).toThrow();
|
}).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -108,3 +119,57 @@ describe('cleanRouteIDs', () => {
|
|||||||
expect(cleanRouteIDs({ receiver: 'test' })).toEqual({ receiver: 'test' });
|
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 { omit } from 'lodash';
|
||||||
|
|
||||||
import { insertAfterImmutably, insertBeforeImmutably } from '@grafana/data/src/utils/arrayUtils';
|
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 { FormAmRoute } from '../types/amroutes';
|
||||||
|
|
||||||
import { formAmRouteToAmRoute } from './amroutes';
|
import { formAmRouteToAmRoute } from './amroutes';
|
||||||
|
import { ERROR_NEWER_CONFIGURATION } from './k8s/errors';
|
||||||
|
|
||||||
// add a form submission to the route tree
|
// add a form submission to the route tree
|
||||||
export const mergePartialAmRouteWithRouteTree = (
|
export const mergePartialAmRouteWithRouteTree = (
|
||||||
@@ -20,7 +25,12 @@ export const mergePartialAmRouteWithRouteTree = (
|
|||||||
): Route => {
|
): Route => {
|
||||||
const existing = findExistingRoute(partialFormRoute.id ?? '', routeTree);
|
const existing = findExistingRoute(partialFormRoute.id ?? '', routeTree);
|
||||||
if (!existing) {
|
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 {
|
function findAndReplace(currentRoute: RouteWithID): Route {
|
||||||
@@ -45,8 +55,8 @@ export const mergePartialAmRouteWithRouteTree = (
|
|||||||
|
|
||||||
// remove a route from the policy tree, returns a new tree
|
// 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
|
// make sure to omit the "id" because Prometheus / Loki / Mimir will reject the payload
|
||||||
export const omitRouteFromRouteTree = (findRoute: RouteWithID, routeTree: RouteWithID): RouteWithID => {
|
export const omitRouteFromRouteTree = (id: string, routeTree: RouteWithID): RouteWithID => {
|
||||||
if (findRoute.id === routeTree.id) {
|
if (id === routeTree.id) {
|
||||||
throw new Error('You cant remove the root policy');
|
throw new Error('You cant remove the root policy');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +64,7 @@ export const omitRouteFromRouteTree = (findRoute: RouteWithID, routeTree: RouteW
|
|||||||
return {
|
return {
|
||||||
...currentRoute,
|
...currentRoute,
|
||||||
routes: currentRoute.routes?.reduce((acc: RouteWithID[] = [], route) => {
|
routes: currentRoute.routes?.reduce((acc: RouteWithID[] = [], route) => {
|
||||||
if (route.id === findRoute.id) {
|
if (route.id === id) {
|
||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,17 +83,19 @@ export type InsertPosition = 'above' | 'below' | 'child';
|
|||||||
export const addRouteToReferenceRoute = (
|
export const addRouteToReferenceRoute = (
|
||||||
alertManagerSourceName: string,
|
alertManagerSourceName: string,
|
||||||
partialFormRoute: Partial<FormAmRoute>,
|
partialFormRoute: Partial<FormAmRoute>,
|
||||||
referenceRoute: RouteWithID,
|
referenceRouteIdentifier: string,
|
||||||
routeTree: RouteWithID,
|
routeTree: RouteWithID,
|
||||||
position: InsertPosition
|
position: InsertPosition
|
||||||
): RouteWithID => {
|
): RouteWithID => {
|
||||||
const newRoute = formAmRouteToAmRoute(alertManagerSourceName, partialFormRoute, routeTree);
|
const newRoute = formAmRouteToAmRoute(alertManagerSourceName, partialFormRoute, routeTree);
|
||||||
|
|
||||||
return produce(routeTree, (draftTree) => {
|
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) {
|
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
|
// 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(
|
export function findRouteInTree(
|
||||||
routeTree: RouteWithID,
|
routeTree: RouteWithID,
|
||||||
referenceRoute: RouteWithID
|
referenceRouteIdentifier: string
|
||||||
): [matchingRoute: RouteMatch, parentRoute: RouteMatch, positionInParent: number | undefined] {
|
): [matchingRoute: RouteMatch, parentRoute: RouteMatch, positionInParent: number | undefined] {
|
||||||
let matchingRoute: RouteMatch;
|
let matchingRoute: RouteMatch;
|
||||||
let matchingRouteParent: RouteMatch;
|
let matchingRouteParent: RouteMatch;
|
||||||
@@ -123,7 +135,7 @@ export function findRouteInTree(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentRoute.id === referenceRoute.id) {
|
if (currentRoute.id === referenceRouteIdentifier) {
|
||||||
matchingRoute = currentRoute;
|
matchingRoute = currentRoute;
|
||||||
matchingRouteParent = parentRoute;
|
matchingRouteParent = parentRoute;
|
||||||
matchingRoutePositionInParent = index;
|
matchingRoutePositionInParent = index;
|
||||||
@@ -139,7 +151,9 @@ export function findRouteInTree(
|
|||||||
return [matchingRoute, matchingRouteParent, matchingRoutePositionInParent];
|
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(
|
return omit(
|
||||||
{
|
{
|
||||||
...route,
|
...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 {
|
export function findExistingRoute(id: string, routeTree: RouteWithID): RouteWithID | undefined {
|
||||||
return routeTree.id === id ? routeTree : routeTree.routes?.find((route) => findExistingRoute(id, route));
|
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('$');
|
.join('$');
|
||||||
}
|
}
|
||||||
|
|
||||||
function hash(value: string): number {
|
export function hash(value: string): number {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
if (value.length === 0) {
|
if (value.length === 0) {
|
||||||
return hash;
|
return hash;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { DataSourceJsonData, WithAccessControlMetadata } from '@grafana/data';
|
import { DataSourceJsonData, WithAccessControlMetadata } from '@grafana/data';
|
||||||
import { IoK8SApimachineryPkgApisMetaV1ObjectMeta } from 'app/features/alerting/unified/openapi/receiversApi.gen';
|
import { IoK8SApimachineryPkgApisMetaV1ObjectMeta } from 'app/features/alerting/unified/openapi/receiversApi.gen';
|
||||||
|
|
||||||
|
export const ROUTES_META_SYMBOL = Symbol('routes_metadata');
|
||||||
|
|
||||||
export type AlertManagerCortexConfig = {
|
export type AlertManagerCortexConfig = {
|
||||||
template_files: Record<string, string>;
|
template_files: Record<string, string>;
|
||||||
alertmanager_config: AlertmanagerConfig;
|
alertmanager_config: AlertmanagerConfig;
|
||||||
@@ -133,7 +135,8 @@ export type Route = {
|
|||||||
active_time_intervals?: string[];
|
active_time_intervals?: string[];
|
||||||
/** only the root policy might have a provenance field defined */
|
/** only the root policy might have a provenance field defined */
|
||||||
provenance?: string;
|
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;
|
provisioned?: boolean;
|
||||||
resourceVersion?: string;
|
resourceVersion?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user