233 lines
7.8 KiB
TypeScript
233 lines
7.8 KiB
TypeScript
/**
|
|
* Various helper functions to modify (immutably) the route tree, aka "notification policies"
|
|
*/
|
|
|
|
import { produce } from 'immer';
|
|
import { omit } from 'lodash';
|
|
|
|
import { insertAfterImmutably, insertBeforeImmutably } from '@grafana/data/src/utils/arrayUtils';
|
|
import { 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 = (
|
|
alertManagerSourceName: string,
|
|
partialFormRoute: Partial<FormAmRoute>,
|
|
routeTree: RouteWithID
|
|
): Route => {
|
|
const existing = findExistingRoute(partialFormRoute.id ?? '', routeTree);
|
|
if (!existing) {
|
|
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 {
|
|
let updatedRoute: Route = currentRoute;
|
|
|
|
if (currentRoute.id === partialFormRoute.id) {
|
|
const newRoute = formAmRouteToAmRoute(alertManagerSourceName, partialFormRoute, routeTree);
|
|
updatedRoute = {
|
|
...currentRoute,
|
|
...newRoute,
|
|
};
|
|
}
|
|
|
|
return {
|
|
...updatedRoute,
|
|
routes: currentRoute.routes?.map(findAndReplace),
|
|
};
|
|
}
|
|
|
|
return findAndReplace(routeTree);
|
|
};
|
|
|
|
// 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 = (id: string, routeTree: RouteWithID): RouteWithID => {
|
|
if (id === routeTree.id) {
|
|
throw new Error('You cant remove the root policy');
|
|
}
|
|
|
|
function findAndOmit(currentRoute: RouteWithID): RouteWithID {
|
|
return {
|
|
...currentRoute,
|
|
routes: currentRoute.routes?.reduce((acc: RouteWithID[] = [], route) => {
|
|
if (route.id === id) {
|
|
return acc;
|
|
}
|
|
|
|
acc.push(findAndOmit(route));
|
|
return acc;
|
|
}, []),
|
|
};
|
|
}
|
|
|
|
return findAndOmit(routeTree);
|
|
};
|
|
|
|
export type InsertPosition = 'above' | 'below' | 'child';
|
|
|
|
// add a new route to a parent route
|
|
export const addRouteToReferenceRoute = (
|
|
alertManagerSourceName: string,
|
|
partialFormRoute: Partial<FormAmRoute>,
|
|
referenceRouteIdentifier: string,
|
|
routeTree: RouteWithID,
|
|
position: InsertPosition
|
|
): RouteWithID => {
|
|
const newRoute = formAmRouteToAmRoute(alertManagerSourceName, partialFormRoute, routeTree);
|
|
|
|
return produce(routeTree, (draftTree) => {
|
|
const [routeInTree, parentRoute, positionInParent] = findRouteInTree(draftTree, referenceRouteIdentifier);
|
|
|
|
if (routeInTree === undefined || parentRoute === undefined || positionInParent === undefined) {
|
|
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 (position === 'child') {
|
|
if (routeInTree.routes) {
|
|
routeInTree.routes.push(newRoute);
|
|
} else {
|
|
routeInTree.routes = [newRoute];
|
|
}
|
|
}
|
|
|
|
// insert new policy before / above the referenceRoute
|
|
if (position === 'above') {
|
|
parentRoute.routes = insertBeforeImmutably(parentRoute.routes ?? [], newRoute, positionInParent);
|
|
}
|
|
|
|
// insert new policy after / below the referenceRoute
|
|
if (position === 'below') {
|
|
parentRoute.routes = insertAfterImmutably(parentRoute.routes ?? [], newRoute, positionInParent);
|
|
}
|
|
});
|
|
};
|
|
|
|
type RouteMatch = Route | undefined;
|
|
|
|
export function findRouteInTree(
|
|
routeTree: RouteWithID,
|
|
referenceRouteIdentifier: string
|
|
): [matchingRoute: RouteMatch, parentRoute: RouteMatch, positionInParent: number | undefined] {
|
|
let matchingRoute: RouteMatch;
|
|
let matchingRouteParent: RouteMatch;
|
|
let matchingRoutePositionInParent: number | undefined;
|
|
|
|
// recurse through the tree to find the matching route, its parent and the position of the route in the parent
|
|
function findRouteInTree(currentRoute: RouteWithID, index: number, parentRoute: RouteWithID) {
|
|
if (matchingRoute) {
|
|
return;
|
|
}
|
|
|
|
if (currentRoute.id === referenceRouteIdentifier) {
|
|
matchingRoute = currentRoute;
|
|
matchingRouteParent = parentRoute;
|
|
matchingRoutePositionInParent = index;
|
|
}
|
|
|
|
if (currentRoute.routes) {
|
|
currentRoute.routes.forEach((route, index) => findRouteInTree(route, index, currentRoute));
|
|
}
|
|
}
|
|
|
|
findRouteInTree(routeTree, 0, routeTree);
|
|
|
|
return [matchingRoute, matchingRouteParent, matchingRoutePositionInParent];
|
|
}
|
|
|
|
export function cleanRouteIDs<
|
|
T extends RouteWithID | Route | ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route,
|
|
>(route: T): Omit<T, 'id'> {
|
|
return omit(
|
|
{
|
|
...route,
|
|
routes: route.routes?.map((route) => cleanRouteIDs(route)),
|
|
},
|
|
'id'
|
|
);
|
|
}
|
|
|
|
// 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;
|
|
}
|