diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 8d3cdcd6135..01c8c2beb2a 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -19,26 +19,27 @@ This page contains a list of available feature toggles. To learn how to turn on Some stable features are enabled by default. You can disable a stable feature by setting the feature flag to "false" in the configuration. -| Feature toggle name | Description | Enabled by default | -| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | -| `disableEnvelopeEncryption` | Disable envelope encryption (emergency only) | | -| `featureHighlights` | Highlight Grafana Enterprise features | | -| `exploreMixedDatasource` | Enable mixed datasource in Explore | Yes | -| `dataConnectionsConsole` | Enables a new top-level page called Connections. This page is an experiment that provides a better experience when you install and configure data sources and other plugins. | Yes | -| `internationalization` | Enables internationalization | Yes | -| `topnav` | Enables new top navigation and page layouts | Yes | -| `cloudWatchCrossAccountQuerying` | Enables cross-account querying in CloudWatch datasources | Yes | -| `newPanelChromeUI` | Show updated look and feel of grafana-ui PanelChrome: panel header, icons, and menu | Yes | -| `accessTokenExpirationCheck` | Enable OAuth access_token expiration check and token refresh using the refresh_token | | -| `emptyDashboardPage` | Enable the redesigned user interface of a dashboard page that includes no panels | Yes | -| `disablePrometheusExemplarSampling` | Disable Prometheus exemplar sampling | | -| `logsSampleInExplore` | Enables access to the logs sample feature in Explore | Yes | -| `logsContextDatasourceUi` | Allow datasource to provide custom UI for context view | Yes | -| `prometheusDataplane` | Changes responses to from Prometheus to be compliant with the dataplane specification. In particular it sets the numeric Field.Name from 'Value' to the value of the `__name__` label when present. | Yes | -| `lokiMetricDataplane` | Changes metric responses from Loki to be compliant with the dataplane specification. | Yes | -| `dataplaneFrontendFallback` | Support dataplane contract field name change for transformations and field name matchers where the name is different | Yes | -| `useCachingService` | When turned on, the new query and resource caching implementation using a wire service inject will be used in place of the previous middleware implementation | | -| `advancedDataSourcePicker` | Enable a new data source picker with contextual information, recently used order and advanced mode | Yes | +| Feature toggle name | Description | Enabled by default | +| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | +| `disableEnvelopeEncryption` | Disable envelope encryption (emergency only) | | +| `featureHighlights` | Highlight Grafana Enterprise features | | +| `exploreMixedDatasource` | Enable mixed datasource in Explore | Yes | +| `dataConnectionsConsole` | Enables a new top-level page called Connections. This page is an experiment that provides a better experience when you install and configure data sources and other plugins. | Yes | +| `internationalization` | Enables internationalization | Yes | +| `topnav` | Enables new top navigation and page layouts | Yes | +| `cloudWatchCrossAccountQuerying` | Enables cross-account querying in CloudWatch datasources | Yes | +| `newPanelChromeUI` | Show updated look and feel of grafana-ui PanelChrome: panel header, icons, and menu | Yes | +| `accessTokenExpirationCheck` | Enable OAuth access_token expiration check and token refresh using the refresh_token | | +| `emptyDashboardPage` | Enable the redesigned user interface of a dashboard page that includes no panels | Yes | +| `disablePrometheusExemplarSampling` | Disable Prometheus exemplar sampling | | +| `logsSampleInExplore` | Enables access to the logs sample feature in Explore | Yes | +| `logsContextDatasourceUi` | Allow datasource to provide custom UI for context view | Yes | +| `prometheusDataplane` | Changes responses to from Prometheus to be compliant with the dataplane specification. In particular it sets the numeric Field.Name from 'Value' to the value of the `__name__` label when present. | Yes | +| `lokiMetricDataplane` | Changes metric responses from Loki to be compliant with the dataplane specification. | Yes | +| `dataplaneFrontendFallback` | Support dataplane contract field name change for transformations and field name matchers where the name is different | Yes | +| `alertingNotificationsPoliciesMatchingInstances` | Enables the preview of matching instances for notification policies | Yes | +| `useCachingService` | When turned on, the new query and resource caching implementation using a wire service inject will be used in place of the previous middleware implementation | | +| `advancedDataSourcePicker` | Enable a new data source picker with contextual information, recently used order and advanced mode | Yes | ## Beta feature toggles diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 1747a05a567..2579b746ba3 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -82,6 +82,7 @@ export interface FeatureToggles { dataplaneFrontendFallback?: boolean; disableSSEDataplane?: boolean; alertStateHistoryLokiSecondary?: boolean; + alertingNotificationsPoliciesMatchingInstances?: boolean; alertStateHistoryLokiPrimary?: boolean; alertStateHistoryLokiOnly?: boolean; unifiedRequestLog?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 8c218e6102f..554494ada1c 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -435,6 +435,14 @@ var ( State: FeatureStateAlpha, Owner: grafanaAlertingSquad, }, + { + Name: "alertingNotificationsPoliciesMatchingInstances", + Description: "Enables the preview of matching instances for notification policies", + State: FeatureStateStable, + FrontendOnly: true, + Expression: "true", // enabled by default + Owner: grafanaAlertingSquad, + }, { Name: "alertStateHistoryLokiPrimary", Description: "Enable a remote Loki instance as the primary source for state history reads.", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index c27fa0001c6..37633d2b1f2 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -63,6 +63,7 @@ lokiMetricDataplane,stable,@grafana/observability-logs,false,false,false,false dataplaneFrontendFallback,stable,@grafana/observability-metrics,false,false,false,true disableSSEDataplane,alpha,@grafana/observability-metrics,false,false,false,false alertStateHistoryLokiSecondary,alpha,@grafana/alerting-squad,false,false,false,false +alertingNotificationsPoliciesMatchingInstances,stable,@grafana/alerting-squad,false,false,false,true alertStateHistoryLokiPrimary,alpha,@grafana/alerting-squad,false,false,false,false alertStateHistoryLokiOnly,alpha,@grafana/alerting-squad,false,false,false,false unifiedRequestLog,alpha,@grafana/backend-platform,false,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 58b8c4c23f1..3b37f31d16e 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -263,6 +263,10 @@ const ( // Enable Grafana to write alert state history to an external Loki instance in addition to Grafana annotations. FlagAlertStateHistoryLokiSecondary = "alertStateHistoryLokiSecondary" + // FlagAlertingNotificationsPoliciesMatchingInstances + // Enables the preview of matching instances for notification policies + FlagAlertingNotificationsPoliciesMatchingInstances = "alertingNotificationsPoliciesMatchingInstances" + // FlagAlertStateHistoryLokiPrimary // Enable a remote Loki instance as the primary source for state history reads. FlagAlertStateHistoryLokiPrimary = "alertStateHistoryLokiPrimary" diff --git a/public/app/features/alerting/unified/NotificationPolicies.test.tsx b/public/app/features/alerting/unified/NotificationPolicies.test.tsx index 3698e5abc9d..fc80a85b6da 100644 --- a/public/app/features/alerting/unified/NotificationPolicies.test.tsx +++ b/public/app/features/alerting/unified/NotificationPolicies.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor } from '@testing-library/react'; +import { render, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { TestProvider } from 'test/helpers/TestProvider'; @@ -20,6 +20,7 @@ import { AccessControlAction } from 'app/types'; import NotificationPolicies, { findRoutesMatchingFilters } from './NotificationPolicies'; import { fetchAlertManagerConfig, fetchStatus, updateAlertManagerConfig } from './api/alertmanager'; +import { alertmanagerApi } from './api/alertmanagerApi'; import { discoverAlertmanagerFeatures } from './api/buildInfo'; import * as grafanaApp from './components/receivers/grafanaAppReceivers/grafanaApp'; import { mockDataSource, MockDataSourceSrv, someCloudAlertManagerConfig, someCloudAlertManagerStatus } from './mocks'; @@ -32,6 +33,7 @@ jest.mock('./api/alertmanager'); jest.mock('./utils/config'); jest.mock('app/core/services/context_srv'); jest.mock('./api/buildInfo'); +jest.mock('./useRouteGroupsMatcher'); const mocks = { getAllDataSourcesMock: jest.mocked(getAllDataSources), @@ -388,6 +390,7 @@ describe('NotificationPolicies', () => { renderNotificationPolicies(); await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalledTimes(1)); + expect(ui.newPolicyButton.query()).not.toBeInTheDocument(); expect(ui.editButton.query()).not.toBeInTheDocument(); }); @@ -399,6 +402,12 @@ describe('NotificationPolicies', () => { message: "Alertmanager has exploded. it's gone. Forget about it.", }, }); + + jest.spyOn(alertmanagerApi, 'useGetAlertmanagerAlertGroupsQuery').mockImplementation(() => ({ + currentData: [], + refetch: jest.fn(), + })); + await renderNotificationPolicies(); await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalledTimes(1)); expect(await byText("Alertmanager has exploded. it's gone. Forget about it.").find()).toBeInTheDocument(); @@ -630,8 +639,18 @@ describe('NotificationPolicies', () => { }, }, }); + + jest.spyOn(alertmanagerApi, 'useGetAlertmanagerAlertGroupsQuery').mockImplementation(() => ({ + currentData: [], + refetch: jest.fn(), + })); + await renderNotificationPolicies(dataSources.promAlertManager.name); const rootRouteContainer = await ui.rootRouteContainer.find(); + await waitFor(() => + expect(within(rootRouteContainer).getByTestId('matching-instances')).toHaveTextContent('0instances') + ); + expect(ui.editButton.query(rootRouteContainer)).not.toBeInTheDocument(); expect(ui.newPolicyCTAButton.query()).not.toBeInTheDocument(); expect(mocks.api.fetchAlertManagerConfig).not.toHaveBeenCalled(); diff --git a/public/app/features/alerting/unified/NotificationPolicies.tsx b/public/app/features/alerting/unified/NotificationPolicies.tsx index d213921829d..dd84520d85a 100644 --- a/public/app/features/alerting/unified/NotificationPolicies.tsx +++ b/public/app/features/alerting/unified/NotificationPolicies.tsx @@ -1,6 +1,7 @@ import { css } from '@emotion/css'; import { intersectionBy, isEqual } from 'lodash'; import React, { useEffect, useMemo, useState } from 'react'; +import { useAsyncFn } from 'react-use'; import { GrafanaTheme2, UrlQueryMap } from '@grafana/data'; import { Stack } from '@grafana/experimental'; @@ -11,6 +12,7 @@ import { useDispatch } from 'app/types'; import { useCleanup } from '../../../core/hooks/useCleanup'; +import { alertmanagerApi } from './api/alertmanagerApi'; import { useGetContactPointsState } from './api/receiversApi'; import { AlertManagerPicker } from './components/AlertManagerPicker'; import { AlertingPageWrapper } from './components/AlertingPageWrapper'; @@ -33,10 +35,12 @@ import { Policy } from './components/notification-policies/Policy'; import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName'; import { useAlertManagersByPermission } from './hooks/useAlertManagerSources'; import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; -import { fetchAlertGroupsAction, fetchAlertManagerConfigAction, updateAlertManagerConfigAction } from './state/actions'; +import { fetchAlertManagerConfigAction, updateAlertManagerConfigAction } from './state/actions'; import { FormAmRoute } from './types/amroutes'; -import { addUniqueIdentifierToRoute, normalizeMatchers } from './utils/amroutes'; +import { useRouteGroupsMatcher } from './useRouteGroupsMatcher'; +import { addUniqueIdentifierToRoute } from './utils/amroutes'; import { isVanillaPrometheusAlertManagerDataSource } from './utils/datasource'; +import { normalizeMatchers } from './utils/matchers'; import { initialAsyncRequestState } from './utils/redux'; import { addRouteToParentRoute, mergePartialAmRouteWithRouteTree, omitRouteFromRouteTree } from './utils/routeTree'; @@ -49,6 +53,8 @@ const AmRoutes = () => { const dispatch = useDispatch(); const styles = useStyles2(getStyles); + const { useGetAlertmanagerAlertGroupsQuery } = alertmanagerApi; + const [queryParams, setQueryParams] = useQueryParams(); const { tab } = getActiveTabFromUrl(queryParams); @@ -57,6 +63,8 @@ const AmRoutes = () => { const [contactPointFilter, setContactPointFilter] = useState(); const [labelMatchersFilter, setLabelMatchersFilter] = useState([]); + const { getRouteGroupsMap } = useRouteGroupsMatcher(); + const alertManagers = useAlertManagersByPermission('notification'); const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName(alertManagers); @@ -69,6 +77,11 @@ const AmRoutes = () => { } }, [alertManagerSourceName, dispatch]); + const { currentData: alertGroups, refetch: refetchAlertGroups } = useGetAlertmanagerAlertGroupsQuery( + { amSourceName: alertManagerSourceName ?? '' }, + { skip: !alertManagerSourceName } + ); + const { result, loading: resultLoading, @@ -82,10 +95,22 @@ const AmRoutes = () => { if (config?.route) { return addUniqueIdentifierToRoute(config.route); } - return; }, [config?.route]); + // useAsync could also work but it's hard to wait until it's done in the tests + // Combining with useEffect gives more predictable results because the condition is in useEffect + const [{ value: routeAlertGroupsMap, error: instancesPreviewError }, triggerGetRouteGroupsMap] = useAsyncFn( + getRouteGroupsMap, + [getRouteGroupsMap] + ); + + useEffect(() => { + if (rootRoute && alertGroups) { + triggerGetRouteGroupsMap(rootRoute, alertGroups); + } + }, [rootRoute, alertGroups, triggerGetRouteGroupsMap]); + // these are computed from the contactPoint and labels matchers filter const routesMatchingFilters = useMemo(() => { if (!rootRoute) { @@ -96,9 +121,6 @@ const AmRoutes = () => { const isProvisioned = Boolean(config?.route?.provenance); - const alertGroups = useUnifiedAlertingSelector((state) => state.amAlertGroups); - const fetchAlertGroups = alertGroups[alertManagerSourceName || ''] ?? initialAsyncRequestState; - function handleSave(partialRoute: Partial) { if (!rootRoute) { return; @@ -149,7 +171,7 @@ const AmRoutes = () => { .unwrap() .then(() => { if (alertManagerSourceName) { - dispatch(fetchAlertGroupsAction(alertManagerSourceName)); + refetchAlertGroups(); } closeEditModal(); closeAddModal(); @@ -173,13 +195,6 @@ const AmRoutes = () => { useCleanup((state) => (state.unifiedAlerting.saveAMConfig = initialAsyncRequestState)); - // fetch AM instances grouping - useEffect(() => { - if (alertManagerSourceName) { - dispatch(fetchAlertGroupsAction(alertManagerSourceName)); - } - }, [alertManagerSourceName, dispatch]); - if (!alertManagerSourceName) { return ( @@ -201,7 +216,7 @@ const AmRoutes = () => { const policyTreeTabActive = activeTab === ActiveTab.NotificationPolicies; return ( - + <> { receivers={receivers} routeTree={rootRoute} currentRoute={rootRoute} - alertGroups={fetchAlertGroups.result} + alertGroups={alertGroups ?? []} contactPointsState={contactPointsState.receivers} readOnly={readOnlyPolicies} alertManagerSourceName={alertManagerSourceName} @@ -261,6 +276,7 @@ const AmRoutes = () => { onDeletePolicy={openDeleteModal} onShowAlertInstances={showAlertGroupsModal} routesMatchingFilters={routesMatchingFilters} + matchingInstancesPreview={{ groupsMap: routeAlertGroupsMap, enabled: !instancesPreviewError }} /> )} @@ -276,7 +292,7 @@ const AmRoutes = () => { )} - + ); }; @@ -340,4 +356,12 @@ function getActiveTabFromUrl(queryParams: UrlQueryMap): QueryParamValues { }; } -export default withErrorBoundary(AmRoutes, { style: 'page' }); +function NotificationPoliciesPage() { + return ( + + + + ); +} + +export default withErrorBoundary(NotificationPoliciesPage, { style: 'page' }); diff --git a/public/app/features/alerting/unified/__mocks__/createRouteGroupsMatcherWorker.ts b/public/app/features/alerting/unified/__mocks__/createRouteGroupsMatcherWorker.ts new file mode 100644 index 00000000000..9aaba6e6470 --- /dev/null +++ b/public/app/features/alerting/unified/__mocks__/createRouteGroupsMatcherWorker.ts @@ -0,0 +1 @@ +export const createWorker = jest.fn(); diff --git a/public/app/features/alerting/unified/__mocks__/useRouteGroupsMatcher.ts b/public/app/features/alerting/unified/__mocks__/useRouteGroupsMatcher.ts new file mode 100644 index 00000000000..94ac4ea7a2d --- /dev/null +++ b/public/app/features/alerting/unified/__mocks__/useRouteGroupsMatcher.ts @@ -0,0 +1,20 @@ +import { useCallback } from 'react'; + +import { AlertmanagerGroup, RouteWithID } from '../../../../plugins/datasource/alertmanager/types'; + +export function useRouteGroupsMatcher() { + const getRouteGroupsMap = useCallback(async (route: RouteWithID, __: AlertmanagerGroup[]) => { + const groupsMap = new Map(); + function addRoutes(route: RouteWithID) { + groupsMap.set(route.id, []); + + route.routes?.forEach((r) => addRoutes(r)); + } + + addRoutes(route); + + return groupsMap; + }, []); + + return { getRouteGroupsMap }; +} diff --git a/public/app/features/alerting/unified/api/alertmanagerApi.ts b/public/app/features/alerting/unified/api/alertmanagerApi.ts index a761134eaf9..bd83e5071bd 100644 --- a/public/app/features/alerting/unified/api/alertmanagerApi.ts +++ b/public/app/features/alerting/unified/api/alertmanagerApi.ts @@ -2,6 +2,7 @@ import { AlertmanagerAlert, AlertmanagerChoice, AlertManagerCortexConfig, + AlertmanagerGroup, ExternalAlertmanagerConfig, ExternalAlertmanagers, ExternalAlertmanagersResponse, @@ -61,6 +62,12 @@ export const alertmanagerApi = alertingApi.injectEndpoints({ }, }), + getAlertmanagerAlertGroups: build.query({ + query: ({ amSourceName }) => ({ + url: `/api/alertmanager/${getDatasourceAPIUid(amSourceName)}/api/v2/alerts/groups`, + }), + }), + getAlertmanagerChoiceStatus: build.query({ query: () => ({ url: '/api/v1/ngalert' }), providesTags: ['AlertmanagerChoice'], diff --git a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx index 094a80a1ba1..67c3bfa71ab 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx @@ -1,8 +1,7 @@ import { css } from '@emotion/css'; import { uniqueId, pick, groupBy, upperFirst, merge, reduce, sumBy } from 'lodash'; import pluralize from 'pluralize'; -import React, { FC, Fragment, ReactNode, useMemo } from 'react'; -import { useEnabled } from 'react-enable'; +import React, { FC, Fragment, ReactNode } from 'react'; import { Link } from 'react-router-dom'; import { GrafanaTheme2, IconName } from '@grafana/data'; @@ -18,11 +17,9 @@ import { } from 'app/plugins/datasource/alertmanager/types'; import { ReceiversState } from 'app/types'; -import { AlertingFeature } from '../../features'; import { getNotificationsPermissions } from '../../utils/access-control'; -import { normalizeMatchers } from '../../utils/amroutes'; +import { normalizeMatchers } from '../../utils/matchers'; import { createContactPointLink, createMuteTimingLink } from '../../utils/misc'; -import { findMatchingAlertGroups } from '../../utils/notification-policies'; import { HoverCard } from '../HoverCard'; import { Label } from '../Label'; import { MetaText } from '../MetaText'; @@ -44,6 +41,9 @@ interface PolicyComponentProps { readOnly?: boolean; inheritedProperties?: InhertitableProperties; routesMatchingFilters?: RouteWithID[]; + // routeAlertGroupsMap?: Map; + + matchingInstancesPreview?: { groupsMap?: Map; enabled: boolean }; routeTree: RouteWithID; currentRoute: RouteWithID; @@ -64,6 +64,7 @@ const Policy: FC = ({ routeTree, inheritedProperties, routesMatchingFilters = [], + matchingInstancesPreview = { enabled: false }, onEditPolicy, onAddPolicy, onDeletePolicy, @@ -71,7 +72,6 @@ const Policy: FC = ({ }) => { const styles = useStyles2(getStyles); const isDefaultPolicy = currentRoute === routeTree; - const showMatchingInstances = useEnabled(AlertingFeature.NotificationPoliciesV2MatchingInstances); const permissions = getNotificationsPermissions(alertManagerSourceName); const canEditRoutes = contextSrv.hasPermission(permissions.update); @@ -114,12 +114,12 @@ const Policy: FC = ({ const isEditable = canEditRoutes; const isDeletable = canDeleteRoutes && !isDefaultPolicy; - const matchingAlertGroups = useMemo(() => { - return showMatchingInstances ? findMatchingAlertGroups(routeTree, currentRoute, alertGroups) : []; - }, [alertGroups, currentRoute, routeTree, showMatchingInstances]); + const matchingAlertGroups = matchingInstancesPreview?.groupsMap?.get(currentRoute.id); // sum all alert instances for all groups we're handling - const numberOfAlertInstances = sumBy(matchingAlertGroups, (group) => group.alerts.length); + const numberOfAlertInstances = matchingAlertGroups + ? sumBy(matchingAlertGroups, (group) => group.alerts.length) + : undefined; // TODO dead branch detection, warnings for all sort of configs that won't work or will never be activated return ( @@ -196,15 +196,15 @@ const Policy: FC = ({ {/* Metadata row */}
- {showMatchingInstances && ( + {matchingInstancesPreview.enabled && ( { - onShowAlertInstances(matchingAlertGroups, matchers); + matchingAlertGroups && onShowAlertInstances(matchingAlertGroups, matchers); }} data-testid="matching-instances" > - {numberOfAlertInstances} + {numberOfAlertInstances ?? '-'} {pluralize('instance', numberOfAlertInstances)} )} @@ -298,6 +298,7 @@ const Policy: FC = ({ alertManagerSourceName={alertManagerSourceName} alertGroups={alertGroups} routesMatchingFilters={routesMatchingFilters} + matchingInstancesPreview={matchingInstancesPreview} /> ); })} diff --git a/public/app/features/alerting/unified/createRouteGroupsMatcherWorker.ts b/public/app/features/alerting/unified/createRouteGroupsMatcherWorker.ts new file mode 100644 index 00000000000..ecdfbc3fe00 --- /dev/null +++ b/public/app/features/alerting/unified/createRouteGroupsMatcherWorker.ts @@ -0,0 +1,5 @@ +import { CorsWorker as Worker } from 'app/core/utils/CorsWorker'; + +// CorsWorker is needed as a workaround for CORS issue caused +// by static assets served from an url different from origin +export const createWorker = () => new Worker(new URL('./routeGroupsMatcher.worker.ts', import.meta.url)); diff --git a/public/app/features/alerting/unified/features.ts b/public/app/features/alerting/unified/features.ts index d94f1848ee2..5fb013934da 100644 --- a/public/app/features/alerting/unified/features.ts +++ b/public/app/features/alerting/unified/features.ts @@ -1,5 +1,7 @@ import { FeatureDescription } from 'react-enable/dist/FeatureState'; +import { config } from '@grafana/runtime'; + export enum AlertingFeature { NotificationPoliciesV2MatchingInstances = 'notification-policies.v2.matching-instances', } @@ -7,8 +9,7 @@ export enum AlertingFeature { const FEATURES: FeatureDescription[] = [ { name: AlertingFeature.NotificationPoliciesV2MatchingInstances, - defaultValue: false, + defaultValue: config.featureToggles.alertingNotificationsPoliciesMatchingInstances, }, ]; - export default FEATURES; diff --git a/public/app/features/alerting/unified/hooks/useFilteredRules.ts b/public/app/features/alerting/unified/hooks/useFilteredRules.ts index aea4ba65c3e..228eea3dc78 100644 --- a/public/app/features/alerting/unified/hooks/useFilteredRules.ts +++ b/public/app/features/alerting/unified/hooks/useFilteredRules.ts @@ -9,8 +9,9 @@ import { CombinedRuleGroup, CombinedRuleNamespace, Rule } from 'app/types/unifie import { isPromAlertingRuleState, PromRuleType, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto'; import { applySearchFilterToQuery, getSearchFilterFromQuery, RulesFilter } from '../search/rulesSearchParser'; -import { labelsMatchMatchers, matcherToMatcherField, parseMatcher, parseMatchers } from '../utils/alertmanager'; +import { labelsMatchMatchers, matcherToMatcherField, parseMatchers } from '../utils/alertmanager'; import { isCloudRulesSource } from '../utils/datasource'; +import { parseMatcher } from '../utils/matchers'; import { getRuleHealth, isAlertingRule, isGrafanaRulerRule, isPromRuleType } from '../utils/rules'; import { calculateGroupTotals, calculateRuleFilteredTotals, calculateRuleTotals } from './useCombinedRuleNamespaces'; diff --git a/public/app/features/alerting/unified/routeGroupsMatcher.worker.ts b/public/app/features/alerting/unified/routeGroupsMatcher.worker.ts new file mode 100644 index 00000000000..f4eac245b3a --- /dev/null +++ b/public/app/features/alerting/unified/routeGroupsMatcher.worker.ts @@ -0,0 +1,27 @@ +import * as comlink from 'comlink'; + +import type { AlertmanagerGroup, RouteWithID } from '../../../plugins/datasource/alertmanager/types'; + +import { findMatchingAlertGroups, normalizeRoute } from './utils/notification-policies'; + +const routeGroupsMatcher = { + getRouteGroupsMap(rootRoute: RouteWithID, groups: AlertmanagerGroup[]): Map { + const normalizedRootRoute = normalizeRoute(rootRoute); + + function addRouteGroups(route: RouteWithID, acc: Map) { + const routeGroups = findMatchingAlertGroups(normalizedRootRoute, route, groups); + acc.set(route.id, routeGroups); + + route.routes?.forEach((r) => addRouteGroups(r, acc)); + } + + const routeGroupsMap = new Map(); + addRouteGroups(normalizedRootRoute, routeGroupsMap); + + return routeGroupsMap; + }, +}; + +export type RouteGroupsMatcher = typeof routeGroupsMatcher; + +comlink.expose(routeGroupsMatcher); diff --git a/public/app/features/alerting/unified/useRouteGroupsMatcher.test.tsx b/public/app/features/alerting/unified/useRouteGroupsMatcher.test.tsx new file mode 100644 index 00000000000..f3f05787308 --- /dev/null +++ b/public/app/features/alerting/unified/useRouteGroupsMatcher.test.tsx @@ -0,0 +1,70 @@ +import { renderHook } from '@testing-library/react'; +import * as comlink from 'comlink'; +import React from 'react'; +import { Features } from 'react-enable'; +import { FeatureDescription } from 'react-enable/dist/FeatureState'; + +import { createWorker } from './createRouteGroupsMatcherWorker'; +import { AlertingFeature } from './features'; +import { useRouteGroupsMatcher } from './useRouteGroupsMatcher'; + +jest.mock('./createRouteGroupsMatcherWorker'); +jest.mock('comlink'); + +const createWorkerMock = jest.mocked(createWorker); +const wrapMock = jest.mocked(comlink.wrap); + +beforeEach(() => { + createWorkerMock.mockReset(); + wrapMock.mockReset(); +}); + +describe('useRouteGroupsMatcher', () => { + it('should not load web worker if the feature flag is disabled', function () { + const featureFlag = getInstancePreviewFeature(false); + + const { result } = renderHook(() => useRouteGroupsMatcher(), { + wrapper: ({ children }) => {children}, + }); + + expect(createWorkerMock).not.toHaveBeenCalled(); + expect(wrapMock).not.toHaveBeenCalled(); + expect(result.current.getRouteGroupsMap).toBeDefined(); + }); + + it('should load web worker if the feature flag is enabled', function () { + const featureFlag = getInstancePreviewFeature(true); + + const { result } = renderHook(() => useRouteGroupsMatcher(), { + wrapper: ({ children }) => {children}, + }); + + expect(createWorkerMock).toHaveBeenCalledTimes(1); + expect(wrapMock).toHaveBeenCalledTimes(1); + expect(result.current.getRouteGroupsMap).toBeDefined(); + }); + + it('getMatchedRouteGroups should throw error if loading worker failed', async function () { + const featureFlag = getInstancePreviewFeature(true); + createWorkerMock.mockImplementation(() => { + throw new DOMException('Failed to load worker'); + }); + + const { result } = renderHook(() => useRouteGroupsMatcher(), { + wrapper: ({ children }) => {children}, + }); + + expect(createWorkerMock).toHaveBeenCalledTimes(1); + expect(wrapMock).toHaveBeenCalledTimes(0); // When loading worker failed we shouldn't call wrap + expect(async () => { + await result.current.getRouteGroupsMap({ id: '1' }, []); + }).rejects.toThrowError(Error); + }); +}); + +function getInstancePreviewFeature(enabled: boolean): FeatureDescription { + return { + name: AlertingFeature.NotificationPoliciesV2MatchingInstances, + defaultValue: enabled, + }; +} diff --git a/public/app/features/alerting/unified/useRouteGroupsMatcher.ts b/public/app/features/alerting/unified/useRouteGroupsMatcher.ts new file mode 100644 index 00000000000..324a5e0c238 --- /dev/null +++ b/public/app/features/alerting/unified/useRouteGroupsMatcher.ts @@ -0,0 +1,88 @@ +import * as comlink from 'comlink'; +import { useCallback, useEffect } from 'react'; +import { useEnabled } from 'react-enable'; + +import { logError } from '@grafana/runtime'; + +import { AlertmanagerGroup, RouteWithID } from '../../../plugins/datasource/alertmanager/types'; + +import { logInfo } from './Analytics'; +import { createWorker } from './createRouteGroupsMatcherWorker'; +import { AlertingFeature } from './features'; +import type { RouteGroupsMatcher } from './routeGroupsMatcher.worker'; + +let routeMatcher: comlink.Remote | undefined; + +// Load worker loads the worker if it's not loaded yet +// and returns a function to dispose of the worker +// We do it to enable feature toggling. If the feature is disabled we don't wont to load the worker code at all +// An alternative way would be to move all this code to the hook below, but it will create and terminate the worker much more often +function loadWorker() { + let worker: Worker | undefined; + + if (routeMatcher === undefined) { + try { + worker = createWorker(); + routeMatcher = comlink.wrap(worker); + } catch (e: unknown) { + if (e instanceof Error) { + logError(e); + } + } + } + + const disposeWorker = () => { + if (worker && routeMatcher) { + routeMatcher[comlink.releaseProxy](); + worker.terminate(); + + routeMatcher = undefined; + worker = undefined; + } + }; + + return { disposeWorker }; +} + +export function useRouteGroupsMatcher() { + const workerPreviewEnabled = useEnabled(AlertingFeature.NotificationPoliciesV2MatchingInstances); + + useEffect(() => { + if (workerPreviewEnabled) { + const { disposeWorker } = loadWorker(); + return disposeWorker; + } + + return () => null; + }, [workerPreviewEnabled]); + + const getRouteGroupsMap = useCallback( + async (rootRoute: RouteWithID, alertGroups: AlertmanagerGroup[]) => { + if (!workerPreviewEnabled) { + throw new Error('Matching routes preview is disabled'); + } + + if (!routeMatcher) { + throw new Error('Route Matcher has not been initialized'); + } + + const startTime = performance.now(); + + const result = await routeMatcher.getRouteGroupsMap(rootRoute, alertGroups); + + const timeSpent = performance.now() - startTime; + + logInfo(`Route Groups Matched in ${timeSpent} ms`, { + matchingTime: timeSpent.toString(), + alertGroupsCount: alertGroups.length.toString(), + // Counting all nested routes might be too time-consuming, so we only count the first level + topLevelRoutesCount: rootRoute.routes?.length.toString() ?? '0', + }); + + return result; + }, + [workerPreviewEnabled] + ); + + return { getRouteGroupsMap }; +} diff --git a/public/app/features/alerting/unified/utils/alertmanager.test.ts b/public/app/features/alerting/unified/utils/alertmanager.test.ts index 2fd657a6a1b..30954e9b35c 100644 --- a/public/app/features/alerting/unified/utils/alertmanager.test.ts +++ b/public/app/features/alerting/unified/utils/alertmanager.test.ts @@ -1,13 +1,8 @@ import { Matcher, MatcherOperator, Route } from 'app/plugins/datasource/alertmanager/types'; import { Labels } from 'app/types/unified-alerting-dto'; -import { - parseMatcher, - parseMatchers, - labelsMatchMatchers, - removeMuteTimingFromRoute, - matchersToString, -} from './alertmanager'; +import { parseMatchers, labelsMatchMatchers, removeMuteTimingFromRoute, matchersToString } from './alertmanager'; +import { parseMatcher } from './matchers'; describe('Alertmanager utils', () => { describe('parseMatcher', () => { diff --git a/public/app/features/alerting/unified/utils/alertmanager.ts b/public/app/features/alerting/unified/utils/alertmanager.ts index a2745a00bee..a3992f04551 100644 --- a/public/app/features/alerting/unified/utils/alertmanager.ts +++ b/public/app/features/alerting/unified/utils/alertmanager.ts @@ -126,41 +126,6 @@ export const matcherFieldOptions: SelectableValue[] = [ { label: MatcherOperator.notRegex, description: 'Does not match regex', value: MatcherOperator.notRegex }, ]; -const matcherOperators = [ - MatcherOperator.regex, - MatcherOperator.notRegex, - MatcherOperator.notEqual, - MatcherOperator.equal, -]; - -export function parseMatcher(matcher: string): Matcher { - const trimmed = matcher.trim(); - if (trimmed.startsWith('{') && trimmed.endsWith('}')) { - throw new Error(`PromQL matchers not supported yet, sorry! PromQL matcher found: ${trimmed}`); - } - const operatorsFound = matcherOperators - .map((op): [MatcherOperator, number] => [op, trimmed.indexOf(op)]) - .filter(([_, idx]) => idx > -1) - .sort((a, b) => a[1] - b[1]); - - if (!operatorsFound.length) { - throw new Error(`Invalid matcher: ${trimmed}`); - } - const [operator, idx] = operatorsFound[0]; - const name = trimmed.slice(0, idx).trim(); - const value = trimmed.slice(idx + operator.length).trim(); - if (!name) { - throw new Error(`Invalid matcher: ${trimmed}`); - } - - return { - name, - value, - isRegex: operator === MatcherOperator.regex || operator === MatcherOperator.notRegex, - isEqual: operator === MatcherOperator.equal || operator === MatcherOperator.regex, - }; -} - export function matcherToObjectMatcher(matcher: Matcher): ObjectMatcher { const operator = matcherToOperator(matcher); return [matcher.name, operator, matcher.value]; diff --git a/public/app/features/alerting/unified/utils/amroutes.test.ts b/public/app/features/alerting/unified/utils/amroutes.test.ts index 8895125b8b1..13538a666db 100644 --- a/public/app/features/alerting/unified/utils/amroutes.test.ts +++ b/public/app/features/alerting/unified/utils/amroutes.test.ts @@ -1,8 +1,8 @@ -import { MatcherOperator, Route } from 'app/plugins/datasource/alertmanager/types'; +import { Route } from 'app/plugins/datasource/alertmanager/types'; import { FormAmRoute } from '../types/amroutes'; -import { amRouteToFormAmRoute, emptyRoute, formAmRouteToAmRoute, normalizeMatchers } from './amroutes'; +import { amRouteToFormAmRoute, emptyRoute, formAmRouteToAmRoute } from './amroutes'; const emptyAmRoute: Route = { receiver: '', @@ -89,28 +89,3 @@ describe('amRouteToFormAmRoute', () => { }); }); }); - -describe('normalizeMatchers', () => { - const eq = MatcherOperator.equal; - - it('should work for object_matchers', () => { - const route: Route = { object_matchers: [['foo', eq, 'bar']] }; - expect(normalizeMatchers(route)).toEqual([['foo', eq, 'bar']]); - }); - it('should work for matchers', () => { - const route: Route = { matchers: ['foo=bar', 'foo!=bar', 'foo=~bar', 'foo!~bar'] }; - expect(normalizeMatchers(route)).toEqual([ - ['foo', MatcherOperator.equal, 'bar'], - ['foo', MatcherOperator.notEqual, 'bar'], - ['foo', MatcherOperator.regex, 'bar'], - ['foo', MatcherOperator.notRegex, 'bar'], - ]); - }); - it('should work for match and match_re', () => { - const route: Route = { match: { foo: 'bar' }, match_re: { foo: 'bar' } }; - expect(normalizeMatchers(route)).toEqual([ - ['foo', MatcherOperator.regex, 'bar'], - ['foo', MatcherOperator.equal, 'bar'], - ]); - }); -}); diff --git a/public/app/features/alerting/unified/utils/amroutes.ts b/public/app/features/alerting/unified/utils/amroutes.ts index 0c39d0d2139..7fbb401e007 100644 --- a/public/app/features/alerting/unified/utils/amroutes.ts +++ b/public/app/features/alerting/unified/utils/amroutes.ts @@ -6,8 +6,9 @@ import { MatcherOperator, ObjectMatcher, Route, RouteWithID } from 'app/plugins/ import { FormAmRoute } from '../types/amroutes'; import { MatcherFieldValue } from '../types/silence-form'; -import { matcherToMatcherField, parseMatcher } from './alertmanager'; +import { matcherToMatcherField } from './alertmanager'; import { GRAFANA_RULES_SOURCE_NAME } from './datasource'; +import { normalizeMatchers, parseMatcher } from './matchers'; import { findExistingRoute } from './routeTree'; import { isValidPrometheusDuration } from './time'; @@ -63,54 +64,6 @@ export const emptyRoute: FormAmRoute = { muteTimeIntervals: [], }; -/** - * We need to deal with multiple (deprecated) properties such as "match" and "match_re" - * this function will normalize all of the different ways to define matchers in to a single one. - */ -export const normalizeMatchers = (route: Route): ObjectMatcher[] => { - const matchers: ObjectMatcher[] = []; - - if (route.matchers) { - route.matchers.forEach((matcher) => { - const { name, value, isEqual, isRegex } = parseMatcher(matcher); - let operator = MatcherOperator.equal; - - if (isEqual && isRegex) { - operator = MatcherOperator.regex; - } - if (!isEqual && isRegex) { - operator = MatcherOperator.notRegex; - } - if (isEqual && !isRegex) { - operator = MatcherOperator.equal; - } - if (!isEqual && !isRegex) { - operator = MatcherOperator.notEqual; - } - - matchers.push([name, operator, value]); - }); - } - - if (route.object_matchers) { - matchers.push(...route.object_matchers); - } - - if (route.match_re) { - Object.entries(route.match_re).forEach(([label, value]) => { - matchers.push([label, MatcherOperator.regex, value]); - }); - } - - if (route.match) { - Object.entries(route.match).forEach(([label, value]) => { - matchers.push([label, MatcherOperator.equal, value]); - }); - } - - return matchers; -}; - // 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 { return { diff --git a/public/app/features/alerting/unified/utils/matchers.test.ts b/public/app/features/alerting/unified/utils/matchers.test.ts index ae5b64b105e..43b3cbc2320 100644 --- a/public/app/features/alerting/unified/utils/matchers.test.ts +++ b/public/app/features/alerting/unified/utils/matchers.test.ts @@ -1,4 +1,6 @@ -import { getMatcherQueryParams, parseQueryParamMatchers } from './matchers'; +import { MatcherOperator, Route } from '../../../../plugins/datasource/alertmanager/types'; + +import { getMatcherQueryParams, normalizeMatchers, parseQueryParamMatchers } from './matchers'; describe('Unified Alerting matchers', () => { describe('getMatcherQueryParams tests', () => { @@ -33,4 +35,29 @@ describe('Unified Alerting matchers', () => { expect(matchers[0].value).toBe('TestData 1'); }); }); + + describe('normalizeMatchers', () => { + const eq = MatcherOperator.equal; + + it('should work for object_matchers', () => { + const route: Route = { object_matchers: [['foo', eq, 'bar']] }; + expect(normalizeMatchers(route)).toEqual([['foo', eq, 'bar']]); + }); + it('should work for matchers', () => { + const route: Route = { matchers: ['foo=bar', 'foo!=bar', 'foo=~bar', 'foo!~bar'] }; + expect(normalizeMatchers(route)).toEqual([ + ['foo', MatcherOperator.equal, 'bar'], + ['foo', MatcherOperator.notEqual, 'bar'], + ['foo', MatcherOperator.regex, 'bar'], + ['foo', MatcherOperator.notRegex, 'bar'], + ]); + }); + it('should work for match and match_re', () => { + const route: Route = { match: { foo: 'bar' }, match_re: { foo: 'bar' } }; + expect(normalizeMatchers(route)).toEqual([ + ['foo', MatcherOperator.regex, 'bar'], + ['foo', MatcherOperator.equal, 'bar'], + ]); + }); + }); }); diff --git a/public/app/features/alerting/unified/utils/matchers.ts b/public/app/features/alerting/unified/utils/matchers.ts index 7ca37819719..76a98bc6eab 100644 --- a/public/app/features/alerting/unified/utils/matchers.ts +++ b/public/app/features/alerting/unified/utils/matchers.ts @@ -1,9 +1,43 @@ import { uniqBy } from 'lodash'; -import { Labels } from '@grafana/data'; -import { Matcher } from 'app/plugins/datasource/alertmanager/types'; +import { Matcher, MatcherOperator, ObjectMatcher, Route } from 'app/plugins/datasource/alertmanager/types'; -import { parseMatcher } from './alertmanager'; +import { Labels } from '../../../../types/unified-alerting-dto'; + +const matcherOperators = [ + MatcherOperator.regex, + MatcherOperator.notRegex, + MatcherOperator.notEqual, + MatcherOperator.equal, +]; + +export function parseMatcher(matcher: string): Matcher { + const trimmed = matcher.trim(); + if (trimmed.startsWith('{') && trimmed.endsWith('}')) { + throw new Error(`PromQL matchers not supported yet, sorry! PromQL matcher found: ${trimmed}`); + } + const operatorsFound = matcherOperators + .map((op): [MatcherOperator, number] => [op, trimmed.indexOf(op)]) + .filter(([_, idx]) => idx > -1) + .sort((a, b) => a[1] - b[1]); + + if (!operatorsFound.length) { + throw new Error(`Invalid matcher: ${trimmed}`); + } + const [operator, idx] = operatorsFound[0]; + const name = trimmed.slice(0, idx).trim(); + const value = trimmed.slice(idx + operator.length).trim(); + if (!name) { + throw new Error(`Invalid matcher: ${trimmed}`); + } + + return { + name, + value, + isRegex: operator === MatcherOperator.regex || operator === MatcherOperator.notRegex, + isEqual: operator === MatcherOperator.equal || operator === MatcherOperator.regex, + }; +} // Parses a list of entries like like "['foo=bar', 'baz=~bad*']" into SilenceMatcher[] export function parseQueryParamMatchers(matcherPairs: string[]): Matcher[] { @@ -26,3 +60,84 @@ export const getMatcherQueryParams = (labels: Labels) => { return matcherUrlParams; }; + +/** + * We need to deal with multiple (deprecated) properties such as "match" and "match_re" + * this function will normalize all of the different ways to define matchers in to a single one. + */ +export const normalizeMatchers = (route: Route): ObjectMatcher[] => { + const matchers: ObjectMatcher[] = []; + + if (route.matchers) { + route.matchers.forEach((matcher) => { + const { name, value, isEqual, isRegex } = parseMatcher(matcher); + let operator = MatcherOperator.equal; + + if (isEqual && isRegex) { + operator = MatcherOperator.regex; + } + if (!isEqual && isRegex) { + operator = MatcherOperator.notRegex; + } + if (isEqual && !isRegex) { + operator = MatcherOperator.equal; + } + if (!isEqual && !isRegex) { + operator = MatcherOperator.notEqual; + } + + matchers.push([name, operator, value]); + }); + } + + if (route.object_matchers) { + matchers.push(...route.object_matchers); + } + + if (route.match_re) { + Object.entries(route.match_re).forEach(([label, value]) => { + matchers.push([label, MatcherOperator.regex, value]); + }); + } + + if (route.match) { + Object.entries(route.match).forEach(([label, value]) => { + matchers.push([label, MatcherOperator.equal, value]); + }); + } + + return matchers; +}; + +export type Label = [string, string]; +type OperatorPredicate = (labelValue: string, matcherValue: string) => boolean; +const OperatorFunctions: Record = { + [MatcherOperator.equal]: (lv, mv) => lv === mv, + [MatcherOperator.notEqual]: (lv, mv) => lv !== mv, + [MatcherOperator.regex]: (lv, mv) => new RegExp(mv).test(lv), + [MatcherOperator.notRegex]: (lv, mv) => !new RegExp(mv).test(lv), +}; + +function isLabelMatch(matcher: ObjectMatcher, label: Label) { + const [labelKey, labelValue] = label; + const [matcherKey, operator, matcherValue] = matcher; + + // not interested, keys don't match + if (labelKey !== matcherKey) { + return false; + } + + const matchFunction = OperatorFunctions[operator]; + if (!matchFunction) { + throw new Error(`no such operator: ${operator}`); + } + + return matchFunction(labelValue, matcherValue); +} + +// check if every matcher returns "true" for the set of labels +export function labelsMatchObjectMatchers(matchers: ObjectMatcher[], labels: Label[]) { + return matchers.every((matcher) => { + return labels.some((label) => isLabelMatch(matcher, label)); + }); +} diff --git a/public/app/features/alerting/unified/utils/notification-policies.test.ts b/public/app/features/alerting/unified/utils/notification-policies.test.ts index 0029d3af795..03c48de027c 100644 --- a/public/app/features/alerting/unified/utils/notification-policies.test.ts +++ b/public/app/features/alerting/unified/utils/notification-policies.test.ts @@ -1,6 +1,8 @@ -import { MatcherOperator, Route } from 'app/plugins/datasource/alertmanager/types'; +import { MatcherOperator, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; -import { findMatchingRoutes } from './notification-policies'; +import { findMatchingRoutes, normalizeRoute } from './notification-policies'; + +import 'core-js/stable/structured-clone'; const CATCH_ALL_ROUTE: Route = { receiver: 'ALL', @@ -11,6 +13,7 @@ describe('findMatchingRoutes', () => { const policies: Route = { receiver: 'ROOT', group_by: ['grafana_folder'], + object_matchers: [], routes: [ { receiver: 'A', @@ -117,3 +120,40 @@ describe('findMatchingRoutes', () => { expect(matches[0]).toHaveProperty('receiver', 'PARENT'); }); }); + +describe('normalizeRoute', () => { + it('should map matchers property to object_matchers', function () { + const route: RouteWithID = { + id: '1', + matchers: ['foo=bar', 'foo=~ba.*'], + }; + + const normalized = normalizeRoute(route); + + expect(normalized.object_matchers).toHaveLength(2); + expect(normalized.object_matchers).toContainEqual(['foo', MatcherOperator.equal, 'bar']); + expect(normalized.object_matchers).toContainEqual(['foo', MatcherOperator.regex, 'ba.*']); + expect(normalized).not.toHaveProperty('matchers'); + }); + + it('should map match and match_re properties to object_matchers', function () { + const route: RouteWithID = { + id: '1', + match: { + foo: 'bar', + }, + match_re: { + team: 'op.*', + }, + }; + + const normalized = normalizeRoute(route); + + expect(normalized.object_matchers).toHaveLength(2); + expect(normalized.object_matchers).toContainEqual(['foo', MatcherOperator.equal, 'bar']); + expect(normalized.object_matchers).toContainEqual(['team', MatcherOperator.regex, 'op.*']); + + expect(normalized).not.toHaveProperty('match'); + expect(normalized).not.toHaveProperty('match_re'); + }); +}); diff --git a/public/app/features/alerting/unified/utils/notification-policies.ts b/public/app/features/alerting/unified/utils/notification-policies.ts index a2f9a35d535..ff87829736b 100644 --- a/public/app/features/alerting/unified/utils/notification-policies.ts +++ b/public/app/features/alerting/unified/utils/notification-policies.ts @@ -1,61 +1,25 @@ -import { AlertmanagerGroup, MatcherOperator, ObjectMatcher, Route } from 'app/plugins/datasource/alertmanager/types'; +import { AlertmanagerGroup, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; -import { normalizeMatchers } from './amroutes'; - -export type Label = [string, string]; -type OperatorPredicate = (labelValue: string, matcherValue: string) => boolean; - -const OperatorFunctions: Record = { - [MatcherOperator.equal]: (lv, mv) => lv === mv, - [MatcherOperator.notEqual]: (lv, mv) => lv !== mv, - [MatcherOperator.regex]: (lv, mv) => Boolean(lv.match(new RegExp(mv))), - [MatcherOperator.notRegex]: (lv, mv) => !Boolean(lv.match(new RegExp(mv))), -}; - -function isLabelMatch(matcher: ObjectMatcher, label: Label) { - const [labelKey, labelValue] = label; - const [matcherKey, operator, matcherValue] = matcher; - - // not interested, keys don't match - if (labelKey !== matcherKey) { - return false; - } - - const matchFunction = OperatorFunctions[operator]; - if (!matchFunction) { - throw new Error(`no such operator: ${operator}`); - } - - return matchFunction(labelValue, matcherValue); -} - -// check if every matcher returns "true" for the set of labels -function matchLabels(matchers: ObjectMatcher[], labels: Label[]) { - return matchers.every((matcher) => { - return labels.some((label) => isLabelMatch(matcher, label)); - }); -} +import { Label, normalizeMatchers, labelsMatchObjectMatchers } from './matchers'; // Match does a depth-first left-to-right search through the route tree // and returns the matching routing nodes. -function findMatchingRoutes(root: T, labels: Label[]): T[] { - let matches: T[] = []; +function findMatchingRoutes(root: Route, labels: Label[]): Route[] { + const matches: Route[] = []; // If the current node is not a match, return nothing - const normalizedMatchers = normalizeMatchers(root); - if (!matchLabels(normalizedMatchers, labels)) { + // const normalizedMatchers = normalizeMatchers(root); + // Normalization should have happened earlier in the code + if (!root.object_matchers || !labelsMatchObjectMatchers(root.object_matchers, labels)) { return []; } // If the current node matches, recurse through child nodes if (root.routes) { - for (let index = 0; index < root.routes.length; index++) { - let child = root.routes[index]; - let matchingChildren = findMatchingRoutes(child, labels); + for (const child of root.routes) { + const matchingChildren = findMatchingRoutes(child, labels); - // TODO how do I solve this typescript thingy? It looks correct to me /shrug - // @ts-ignore - matches = matches.concat(matchingChildren); + matches.push(...matchingChildren); // we have matching children and we don't want to continue, so break here if (matchingChildren.length && !child.continue) { @@ -72,6 +36,22 @@ function findMatchingRoutes(root: T, labels: Label[]): T[] { return matches; } +// This is a performance improvement to normalize matchers only once and use the normalized version later on +export function normalizeRoute(rootRoute: RouteWithID): RouteWithID { + function normalizeRoute(route: RouteWithID) { + route.object_matchers = normalizeMatchers(route); + delete route.matchers; + delete route.match; + delete route.match_re; + route.routes?.forEach(normalizeRoute); + } + + const normalizedRootRoute = structuredClone(rootRoute); + normalizeRoute(normalizedRootRoute); + + return normalizedRootRoute; +} + /** * find all of the groups that have instances that match the route, thay way we can find all instances * (and their grouping) for the given route @@ -102,4 +82,4 @@ function findMatchingAlertGroups( }, matchingGroups); } -export { findMatchingAlertGroups, findMatchingRoutes, matchLabels }; +export { findMatchingAlertGroups, findMatchingRoutes };