Alerting: Matching instances preview for notification policies (#68882)

* Basic implementation in web worker

* Move instances discovery to the worker

* Remove filtering from the worker

* Use normalized routes, use rtk query for alert groups fetching

* Reorganize matchers utilities to be available for web workers

* Move object matchers to the machers util file, rename worker

* Move worker code to a separate hook, add perf logging

* Add a mock for the web worker code, fix tests

* Fix tests warnings

* Remove notification policy feature flag

* Add normalizeRoute tests, change the regex match to test for label matching

* Move worker init to the file scope

* Simplify useAsyncFn hook

* Use CorsWorker as a workaround for web workers loading from CDN

* Use a feature flag to enable/disable worker-based preview, add worker error handling

* Add POC for react-enable working with grafana feature toggles

* Code cleanup

* Remove console error, add useRouteGroupsMatcher tests

* Fix tests mock
pull/69247/head
Konrad Lalik 2 years ago committed by GitHub
parent 0d54a8858b
commit 2f0728ac67
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 41
      docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
  2. 1
      packages/grafana-data/src/types/featureToggles.gen.ts
  3. 8
      pkg/services/featuremgmt/registry.go
  4. 1
      pkg/services/featuremgmt/toggles_gen.csv
  5. 4
      pkg/services/featuremgmt/toggles_gen.go
  6. 21
      public/app/features/alerting/unified/NotificationPolicies.test.tsx
  7. 60
      public/app/features/alerting/unified/NotificationPolicies.tsx
  8. 1
      public/app/features/alerting/unified/__mocks__/createRouteGroupsMatcherWorker.ts
  9. 20
      public/app/features/alerting/unified/__mocks__/useRouteGroupsMatcher.ts
  10. 7
      public/app/features/alerting/unified/api/alertmanagerApi.ts
  11. 27
      public/app/features/alerting/unified/components/notification-policies/Policy.tsx
  12. 5
      public/app/features/alerting/unified/createRouteGroupsMatcherWorker.ts
  13. 5
      public/app/features/alerting/unified/features.ts
  14. 3
      public/app/features/alerting/unified/hooks/useFilteredRules.ts
  15. 27
      public/app/features/alerting/unified/routeGroupsMatcher.worker.ts
  16. 70
      public/app/features/alerting/unified/useRouteGroupsMatcher.test.tsx
  17. 88
      public/app/features/alerting/unified/useRouteGroupsMatcher.ts
  18. 9
      public/app/features/alerting/unified/utils/alertmanager.test.ts
  19. 35
      public/app/features/alerting/unified/utils/alertmanager.ts
  20. 29
      public/app/features/alerting/unified/utils/amroutes.test.ts
  21. 51
      public/app/features/alerting/unified/utils/amroutes.ts
  22. 29
      public/app/features/alerting/unified/utils/matchers.test.ts
  23. 121
      public/app/features/alerting/unified/utils/matchers.ts
  24. 44
      public/app/features/alerting/unified/utils/notification-policies.test.ts
  25. 74
      public/app/features/alerting/unified/utils/notification-policies.ts

@ -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

@ -82,6 +82,7 @@ export interface FeatureToggles {
dataplaneFrontendFallback?: boolean;
disableSSEDataplane?: boolean;
alertStateHistoryLokiSecondary?: boolean;
alertingNotificationsPoliciesMatchingInstances?: boolean;
alertStateHistoryLokiPrimary?: boolean;
alertStateHistoryLokiOnly?: boolean;
unifiedRequestLog?: boolean;

@ -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.",

@ -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

1 Name State Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
63 dataplaneFrontendFallback stable @grafana/observability-metrics false false false true
64 disableSSEDataplane alpha @grafana/observability-metrics false false false false
65 alertStateHistoryLokiSecondary alpha @grafana/alerting-squad false false false false
66 alertingNotificationsPoliciesMatchingInstances stable @grafana/alerting-squad false false false true
67 alertStateHistoryLokiPrimary alpha @grafana/alerting-squad false false false false
68 alertStateHistoryLokiOnly alpha @grafana/alerting-squad false false false false
69 unifiedRequestLog alpha @grafana/backend-platform false false false false

@ -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"

@ -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();

@ -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<string | undefined>();
const [labelMatchersFilter, setLabelMatchersFilter] = useState<ObjectMatcher[]>([]);
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<FormAmRoute>) {
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 (
<AlertingPageWrapper pageId="am-routes">
@ -201,7 +216,7 @@ const AmRoutes = () => {
const policyTreeTabActive = activeTab === ActiveTab.NotificationPolicies;
return (
<AlertingPageWrapper pageId="am-routes">
<>
<AlertManagerPicker
current={alertManagerSourceName}
onChange={setAlertManagerSourceName}
@ -252,7 +267,7 @@ const AmRoutes = () => {
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 }}
/>
)}
</Stack>
@ -276,7 +292,7 @@ const AmRoutes = () => {
</>
)}
</TabContent>
</AlertingPageWrapper>
</>
);
};
@ -340,4 +356,12 @@ function getActiveTabFromUrl(queryParams: UrlQueryMap): QueryParamValues {
};
}
export default withErrorBoundary(AmRoutes, { style: 'page' });
function NotificationPoliciesPage() {
return (
<AlertingPageWrapper pageId="am-routes">
<AmRoutes />
</AlertingPageWrapper>
);
}
export default withErrorBoundary(NotificationPoliciesPage, { style: 'page' });

@ -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<string, AlertmanagerGroup[]>();
function addRoutes(route: RouteWithID) {
groupsMap.set(route.id, []);
route.routes?.forEach((r) => addRoutes(r));
}
addRoutes(route);
return groupsMap;
}, []);
return { getRouteGroupsMap };
}

@ -2,6 +2,7 @@ import {
AlertmanagerAlert,
AlertmanagerChoice,
AlertManagerCortexConfig,
AlertmanagerGroup,
ExternalAlertmanagerConfig,
ExternalAlertmanagers,
ExternalAlertmanagersResponse,
@ -61,6 +62,12 @@ export const alertmanagerApi = alertingApi.injectEndpoints({
},
}),
getAlertmanagerAlertGroups: build.query<AlertmanagerGroup[], { amSourceName: string }>({
query: ({ amSourceName }) => ({
url: `/api/alertmanager/${getDatasourceAPIUid(amSourceName)}/api/v2/alerts/groups`,
}),
}),
getAlertmanagerChoiceStatus: build.query<AlertmanagersChoiceResponse, void>({
query: () => ({ url: '/api/v1/ngalert' }),
providesTags: ['AlertmanagerChoice'],

@ -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<string, AlertmanagerGroup[]>;
matchingInstancesPreview?: { groupsMap?: Map<string, AlertmanagerGroup[]>; enabled: boolean };
routeTree: RouteWithID;
currentRoute: RouteWithID;
@ -64,6 +64,7 @@ const Policy: FC<PolicyComponentProps> = ({
routeTree,
inheritedProperties,
routesMatchingFilters = [],
matchingInstancesPreview = { enabled: false },
onEditPolicy,
onAddPolicy,
onDeletePolicy,
@ -71,7 +72,6 @@ const Policy: FC<PolicyComponentProps> = ({
}) => {
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<PolicyComponentProps> = ({
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<PolicyComponentProps> = ({
{/* Metadata row */}
<div className={styles.metadataRow}>
<Stack direction="row" alignItems="center" gap={1}>
{showMatchingInstances && (
{matchingInstancesPreview.enabled && (
<MetaText
icon="layers-alt"
onClick={() => {
onShowAlertInstances(matchingAlertGroups, matchers);
matchingAlertGroups && onShowAlertInstances(matchingAlertGroups, matchers);
}}
data-testid="matching-instances"
>
<Strong>{numberOfAlertInstances}</Strong>
<Strong>{numberOfAlertInstances ?? '-'}</Strong>
<span>{pluralize('instance', numberOfAlertInstances)}</span>
</MetaText>
)}
@ -298,6 +298,7 @@ const Policy: FC<PolicyComponentProps> = ({
alertManagerSourceName={alertManagerSourceName}
alertGroups={alertGroups}
routesMatchingFilters={routesMatchingFilters}
matchingInstancesPreview={matchingInstancesPreview}
/>
);
})}

@ -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));

@ -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;

@ -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';

@ -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<string, AlertmanagerGroup[]> {
const normalizedRootRoute = normalizeRoute(rootRoute);
function addRouteGroups(route: RouteWithID, acc: Map<string, AlertmanagerGroup[]>) {
const routeGroups = findMatchingAlertGroups(normalizedRootRoute, route, groups);
acc.set(route.id, routeGroups);
route.routes?.forEach((r) => addRouteGroups(r, acc));
}
const routeGroupsMap = new Map<string, AlertmanagerGroup[]>();
addRouteGroups(normalizedRootRoute, routeGroupsMap);
return routeGroupsMap;
},
};
export type RouteGroupsMatcher = typeof routeGroupsMatcher;
comlink.expose(routeGroupsMatcher);

@ -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 }) => <Features features={[featureFlag]}>{children}</Features>,
});
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 }) => <Features features={[featureFlag]}>{children}</Features>,
});
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 }) => <Features features={[featureFlag]}>{children}</Features>,
});
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,
};
}

@ -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<RouteGroupsMatcher> | 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<RouteGroupsMatcher>(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 };
}

@ -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', () => {

@ -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];

@ -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'],
]);
});
});

@ -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 {

@ -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'],
]);
});
});
});

@ -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, OperatorPredicate> = {
[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));
});
}

@ -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');
});
});

@ -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, OperatorPredicate> = {
[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<T extends Route>(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<T extends Route>(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 };

Loading…
Cancel
Save