mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
parent
0d54a8858b
commit
2f0728ac67
@ -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
|
||||
|
|
@ -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 @@
|
||||
export const createWorker = jest.fn();
|
@ -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…
Reference in New Issue
Block a user