diff --git a/public/app/features/alerting/unified/api/routesApi.test.ts b/public/app/features/alerting/unified/api/routesApi.test.ts new file mode 100644 index 00000000000..dbc91cce4c6 --- /dev/null +++ b/public/app/features/alerting/unified/api/routesApi.test.ts @@ -0,0 +1,89 @@ +import { MatcherOperator, Route } from 'app/plugins/datasource/alertmanager/types'; + +import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route } from '../openapi/routesApi.gen'; + +import { k8sSubRouteToRoute, routeToK8sSubRoute } from './routesApi'; + +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 = { + name: 'test-name', + 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: [ + { + name: 'test-name', + receiver: 'receiver2', + matchers: undefined, + object_matchers: [['label2', MatcherOperator.notEqual, 'value2']], + routes: undefined, + }, + ], + }; + + expect(k8sSubRouteToRoute(input, 'test-name')).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); +}); diff --git a/public/app/features/alerting/unified/api/routesApi.ts b/public/app/features/alerting/unified/api/routesApi.ts new file mode 100644 index 00000000000..a4e0918f721 --- /dev/null +++ b/public/app/features/alerting/unified/api/routesApi.ts @@ -0,0 +1,101 @@ +import { + ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree, + ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route, + generatedRoutesApi, + ReadNamespacedRoutingTreeApiResponse, + ListNamespacedRoutingTreeApiResponse, +} from 'app/features/alerting/unified/openapi/routesApi.gen'; +import { MatcherOperator, ROUTES_META_SYMBOL, Route } from 'app/plugins/datasource/alertmanager/types'; +import { ROOT_ROUTE_NAME } from '../utils/k8s/constants'; +import { isK8sEntityProvisioned } from '../utils/k8s/utils'; +import { DefinitionsFromApi, OverrideResultType, TagTypesFromApi } from '@reduxjs/toolkit/query'; + +type Definitions = DefinitionsFromApi; +type TagTypes = TagTypesFromApi; + +type UpdatedDefinitions = Omit & { + readNamespacedRoutingTree: OverrideResultType; + listNamespacedRoutingTree: OverrideResultType; +}; + +export const routesApi = generatedRoutesApi.enhanceEndpoints({ + endpoints: { + readNamespacedRoutingTree: (endpoint) => { + // We transform the response here instead of in `selectFromResult` so that memoization of the transformed Route + // is automatically handled. + endpoint.transformResponse = (response: ReadNamespacedRoutingTreeApiResponse): Route => { + return k8sRouteToRoute(response); + }; + }, + listNamespacedRoutingTree: (endpoint) => { + endpoint.transformResponse = (response: ListNamespacedRoutingTreeApiResponse): Route[] => { + return k8sRoutesToRoutes(response.items); + }; + }, + }, +}); + +export const NAMED_ROOT_LABEL_NAME = '__grafana_managed_route__'; + +function k8sRouteToRoute(route: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree): Route { + return { + ...route.spec.defaults, + name: route.metadata.name, + routes: route.spec.routes?.map((subroute) => k8sSubRouteToRoute(subroute, route.metadata.name)), + // This assumes if a `NAMED_ROOT_LABEL_NAME` label exists, it will NOT go to the default route, which is a fair but + // not perfect assumption since we don't yet protect the label. + object_matchers: + route.metadata.name == ROOT_ROUTE_NAME || !route.metadata.name + ? [[NAMED_ROOT_LABEL_NAME, MatcherOperator.equal, '']] + : [[NAMED_ROOT_LABEL_NAME, MatcherOperator.equal, route.metadata.name]], + [ROUTES_META_SYMBOL]: { + provisioned: isK8sEntityProvisioned(route), + resourceVersion: route.metadata.resourceVersion, + name: route.metadata.name, + metadata: route.metadata, + }, + }; +} + +function k8sRoutesToRoutes(routes: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree[]): Route[] { + return routes?.map((route) => { + return k8sRouteToRoute(route); + }); +} + +/** Helper to provide type safety for matcher operators from API */ +function isValidMatcherOperator(type: string): type is MatcherOperator { + return Object.values(MatcherOperator).includes(type); +} + +export function k8sSubRouteToRoute( + route: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route, + rootName?: string +): Route { + return { + ...route, + name: rootName, + routes: route.routes?.map((subroute) => k8sSubRouteToRoute(subroute, rootName)), + matchers: undefined, + object_matchers: route.matchers?.map(({ label, type, value }) => { + if (!isValidMatcherOperator(type)) { + throw new Error(`Invalid matcher operator from API: ${type}`); + } + return [label, type, value]; + }), + }; +} + +export function routeToK8sSubRoute(route: Route): ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route { + const { object_matchers, ...rest } = route; + return { + ...rest, + receiver: route.receiver ?? undefined, + matchers: object_matchers?.map(([label, type, value]) => ({ + label, + type, + value, + })), + routes: route.routes?.map(routeToK8sSubRoute), + }; +} diff --git a/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.test.tsx b/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.test.tsx index 512f8f2ac66..25a5ca0cafd 100644 --- a/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.test.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.test.tsx @@ -1,91 +1,8 @@ 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); -}); +import { createKubernetesRoutingTreeSpec } from './useNotificationPolicyRoute'; test('createKubernetesRoutingTreeSpec', () => { const route: Route = { diff --git a/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.ts b/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.ts index 067a0768dac..f6627088fb8 100644 --- a/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.ts +++ b/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.ts @@ -3,18 +3,17 @@ import memoize from 'micro-memoize'; import { INHERITABLE_KEYS, type InheritableProperties } from '@grafana/alerting/internal'; import { BaseAlertmanagerArgs, Skippable } from 'app/features/alerting/unified/types/hooks'; -import { MatcherOperator, ROUTES_META_SYMBOL, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; +import { ROUTES_META_SYMBOL, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; import { getAPINamespace } from '../../../../../api/utils'; import { alertmanagerApi } from '../../api/alertmanagerApi'; import { useAsync } from '../../hooks/useAsync'; import { useProduceNewAlertmanagerConfiguration } from '../../hooks/useProduceNewAlertmanagerConfig'; import { - ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route, ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RouteDefaults, ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree, - generatedRoutesApi as routingTreeApi, } from '../../openapi/routesApi.gen'; +import { routesApi as routingTreeApi, routeToK8sSubRoute } from '../../api/routesApi'; import { addRouteAction, deleteRouteAction, @@ -23,7 +22,7 @@ import { import { FormAmRoute } from '../../types/amroutes'; import { addUniqueIdentifierToRoute } from '../../utils/amroutes'; import { PROVENANCE_NONE, ROOT_ROUTE_NAME } from '../../utils/k8s/constants'; -import { isK8sEntityProvisioned, shouldUseK8sApi } from '../../utils/k8s/utils'; +import { shouldUseK8sApi } from '../../utils/k8s/utils'; import { routeAdapter } from '../../utils/routeAdapter'; import { InsertPosition, @@ -35,8 +34,6 @@ import { import uFuzzy from '@leeoniya/ufuzzy'; import { useMemo } from 'react'; -const k8sRoutesToRoutesMemoized = memoize(k8sRoutesToRoutes, { maxSize: 1 }); - const { useDeleteNamespacedRoutingTreeMutation, useListNamespacedRoutingTreeQuery, @@ -47,7 +44,11 @@ const { const { useGetAlertmanagerConfigurationQuery } = alertmanagerApi; -export const useNotificationPolicyRoute = ({ alertmanager }: BaseAlertmanagerArgs, routeName: string = ROOT_ROUTE_NAME, { skip }: Skippable = {}) => { +export const useNotificationPolicyRoute = ( + { alertmanager }: BaseAlertmanagerArgs, + routeName: string = ROOT_ROUTE_NAME, + { skip }: Skippable = {} +) => { const k8sApiSupported = shouldUseK8sApi(alertmanager); const k8sRouteQuery = useReadNamespacedRoutingTreeQuery( @@ -57,8 +58,8 @@ export const useNotificationPolicyRoute = ({ alertmanager }: BaseAlertmanagerArg selectFromResult: (result) => { return { ...result, - currentData: result.currentData ? k8sRoutesToRoutesMemoized([result.currentData])[0] : undefined, - data: result.data ? k8sRoutesToRoutesMemoized([result.data])[0] : undefined, + currentData: result.currentData, + data: result.data, }; }, } @@ -92,8 +93,8 @@ export const useListNotificationPolicyRoutes = ({ alertmanager }: BaseAlertmanag selectFromResult: (result) => { return { ...result, - currentData: result.currentData ? k8sRoutesToRoutesMemoized(result.currentData.items) : undefined, - data: result.data ? k8sRoutesToRoutesMemoized(result.data.items) : undefined, + currentData: result.currentData, + data: result.data, }; }, } @@ -118,9 +119,9 @@ export function useUpdateExistingNotificationPolicy({ alertmanager }: BaseAlertm const updateUsingK8sApi = useAsync(async (update: Partial) => { const namespace = getAPINamespace(); const name = update.name ?? ROOT_ROUTE_NAME; - const result = await readNamespacedRoutingTree({ namespace, name: name }) + const result = await readNamespacedRoutingTree({ namespace, name: name }); - const [rootTree] = result.data ? k8sRoutesToRoutesMemoized([result.data]) : []; + const rootTree = result.data; if (!rootTree) { throw new Error(`no root route found for namespace ${namespace} and name ${name}`); } @@ -155,9 +156,9 @@ export function useDeleteNotificationPolicy({ alertmanager }: BaseAlertmanagerAr const deleteFromK8sApi = useAsync(async (route: RouteWithID) => { const namespace = getAPINamespace(); const name = route.name ?? ROOT_ROUTE_NAME; - const result = await readNamespacedRoutingTree({ namespace, name: name }) + const result = await readNamespacedRoutingTree({ namespace, name: name }); - const [rootTree] = result.data ? k8sRoutesToRoutesMemoized([result.data]) : []; + const rootTree = result.data; if (!rootTree) { throw new Error(`no root route found for namespace ${namespace}`); } @@ -201,9 +202,9 @@ export function useAddNotificationPolicy({ alertmanager }: BaseAlertmanagerArgs) }) => { const namespace = getAPINamespace(); const name = referenceRoute.name ?? ROOT_ROUTE_NAME; - const result = await readNamespacedRoutingTree({ namespace, name: name }) + const result = await readNamespacedRoutingTree({ namespace, name: name }); - const [rootTree] = result.data ? k8sRoutesToRoutesMemoized([result.data]) : []; + const rootTree = result.data; if (!rootTree) { throw new Error(`no root route found for namespace ${namespace}`); } @@ -274,10 +275,7 @@ const fuzzyFinder = new uFuzzy({ intraTrn: 1, }); -export const useRootRouteSearch = ( - policies: Route[], - search?: string | null -): Route[] => { +export const useRootRouteSearch = (policies: Route[], search?: string | null): Route[] => { const nameHaystack = useMemo(() => { return policies.map((policy) => policy.name ?? ''); }, [policies]); @@ -298,56 +296,6 @@ export const useRootRouteSearch = ( return uniq(hits).map((id) => policies[id]) ?? []; }; -function k8sRoutesToRoutes(routes: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree[]): Route[] { - return routes?.map((route) => { - return { - ...route.spec.defaults, - name: route.metadata.name, - routes: route.spec.routes?.map((subroute) => (k8sSubRouteToRoute(subroute, route.metadata.name))), - [ROUTES_META_SYMBOL]: { - provisioned: isK8sEntityProvisioned(route), - resourceVersion: route.metadata.resourceVersion, - name: route.metadata.name, - metadata: route.metadata, - }, - }; - }); -} - -/** Helper to provide type safety for matcher operators from API */ -function isValidMatcherOperator(type: string): type is MatcherOperator { - return Object.values(MatcherOperator).includes(type); -} - -export function k8sSubRouteToRoute(route: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route, rootName?: string): Route { - return { - ...route, - name: rootName, - routes: route.routes?.map((subroute) => (k8sSubRouteToRoute(subroute, rootName))), - matchers: undefined, - object_matchers: route.matchers?.map(({ label, type, value }) => { - if (!isValidMatcherOperator(type)) { - throw new Error(`Invalid matcher operator from API: ${type}`); - } - return [label, type, value]; - }), - }; -} - -export function routeToK8sSubRoute(route: Route): ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route { - const { object_matchers, ...rest } = route; - return { - ...rest, - receiver: route.receiver ?? undefined, - matchers: object_matchers?.map(([label, type, value]) => ({ - label, - type, - value, - })), - 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