Alerting: Enable alerts preview on notification policies page (#68291)

* 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
pull/68482/head
Konrad Lalik 2 years ago committed by GitHub
parent 55622615de
commit f7b8a666f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 21
      public/app/features/alerting/unified/NotificationPolicies.test.tsx
  2. 42
      public/app/features/alerting/unified/NotificationPolicies.tsx
  3. 20
      public/app/features/alerting/unified/__mocks__/useRouteGroupsMatcher.ts
  4. 7
      public/app/features/alerting/unified/api/alertmanagerApi.ts
  5. 41
      public/app/features/alerting/unified/components/notification-policies/Policy.tsx
  6. 11
      public/app/features/alerting/unified/features.ts
  7. 3
      public/app/features/alerting/unified/hooks/useFilteredRules.ts
  8. 27
      public/app/features/alerting/unified/routeGroupsMatcher.worker.ts
  9. 31
      public/app/features/alerting/unified/useRouteGroupsMatcher.ts
  10. 9
      public/app/features/alerting/unified/utils/alertmanager.test.ts
  11. 35
      public/app/features/alerting/unified/utils/alertmanager.ts
  12. 29
      public/app/features/alerting/unified/utils/amroutes.test.ts
  13. 51
      public/app/features/alerting/unified/utils/amroutes.ts
  14. 29
      public/app/features/alerting/unified/utils/matchers.test.ts
  15. 121
      public/app/features/alerting/unified/utils/matchers.ts
  16. 44
      public/app/features/alerting/unified/utils/notification-policies.test.ts
  17. 74
      public/app/features/alerting/unified/utils/notification-policies.ts

@ -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';
@ -48,6 +52,7 @@ enum ActiveTab {
const AmRoutes = () => {
const dispatch = useDispatch();
const styles = useStyles2(getStyles);
const { useGetAlertmanagerAlertGroupsQuery } = alertmanagerApi;
const [queryParams, setQueryParams] = useQueryParams();
const { tab } = getActiveTabFromUrl(queryParams);
@ -57,6 +62,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 +76,11 @@ const AmRoutes = () => {
}
}, [alertManagerSourceName, dispatch]);
const { currentData: alertGroups, refetch: refetchAlertGroups } = useGetAlertmanagerAlertGroupsQuery(
{ amSourceName: alertManagerSourceName ?? '' },
{ skip: !alertManagerSourceName }
);
const {
result,
loading: resultLoading,
@ -82,10 +94,19 @@ 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 }, triggerGetRouteGroupsMap] = useAsyncFn(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 +117,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 +167,7 @@ const AmRoutes = () => {
.unwrap()
.then(() => {
if (alertManagerSourceName) {
dispatch(fetchAlertGroupsAction(alertManagerSourceName));
refetchAlertGroups();
}
closeEditModal();
closeAddModal();
@ -173,13 +191,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">
@ -252,7 +263,7 @@ const AmRoutes = () => {
receivers={receivers}
routeTree={rootRoute}
currentRoute={rootRoute}
alertGroups={fetchAlertGroups.result}
alertGroups={alertGroups ?? []}
contactPointsState={contactPointsState.receivers}
readOnly={readOnlyPolicies}
alertManagerSourceName={alertManagerSourceName}
@ -261,6 +272,7 @@ const AmRoutes = () => {
onDeletePolicy={openDeleteModal}
onShowAlertInstances={showAlertGroupsModal}
routesMatchingFilters={routesMatchingFilters}
routeAlertGroupsMap={routeAlertGroupsMap}
/>
)}
</Stack>

@ -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,7 @@ interface PolicyComponentProps {
readOnly?: boolean;
inheritedProperties?: InhertitableProperties;
routesMatchingFilters?: RouteWithID[];
routeAlertGroupsMap?: Map<string, AlertmanagerGroup[]>;
routeTree: RouteWithID;
currentRoute: RouteWithID;
@ -64,6 +62,7 @@ const Policy: FC<PolicyComponentProps> = ({
routeTree,
inheritedProperties,
routesMatchingFilters = [],
routeAlertGroupsMap,
onEditPolicy,
onAddPolicy,
onDeletePolicy,
@ -71,7 +70,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 +112,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 = routeAlertGroupsMap?.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,18 +194,16 @@ const Policy: FC<PolicyComponentProps> = ({
{/* Metadata row */}
<div className={styles.metadataRow}>
<Stack direction="row" alignItems="center" gap={1}>
{showMatchingInstances && (
<MetaText
icon="layers-alt"
onClick={() => {
onShowAlertInstances(matchingAlertGroups, matchers);
}}
data-testid="matching-instances"
>
<Strong>{numberOfAlertInstances}</Strong>
<span>{pluralize('instance', numberOfAlertInstances)}</span>
</MetaText>
)}
<MetaText
icon="layers-alt"
onClick={() => {
matchingAlertGroups && onShowAlertInstances(matchingAlertGroups, matchers);
}}
data-testid="matching-instances"
>
<Strong>{numberOfAlertInstances ?? '-'}</Strong>
<span>{pluralize('instance', numberOfAlertInstances)}</span>
</MetaText>
{contactPoint && (
<MetaText icon="at" data-testid="contact-point">
<span>Delivered to</span>
@ -298,6 +294,7 @@ const Policy: FC<PolicyComponentProps> = ({
alertManagerSourceName={alertManagerSourceName}
alertGroups={alertGroups}
routesMatchingFilters={routesMatchingFilters}
routeAlertGroupsMap={routeAlertGroupsMap}
/>
);
})}

@ -1,14 +1,7 @@
import { FeatureDescription } from 'react-enable/dist/FeatureState';
export enum AlertingFeature {
NotificationPoliciesV2MatchingInstances = 'notification-policies.v2.matching-instances',
}
export enum AlertingFeature {}
const FEATURES: FeatureDescription[] = [
{
name: AlertingFeature.NotificationPoliciesV2MatchingInstances,
defaultValue: false,
},
];
const FEATURES: FeatureDescription[] = [];
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,31 @@
import * as comlink from 'comlink';
import { useCallback } from 'react';
import { AlertmanagerGroup, RouteWithID } from '../../../plugins/datasource/alertmanager/types';
import { logInfo } from './Analytics';
import type { RouteGroupsMatcher } from './routeGroupsMatcher.worker';
const worker = new Worker(new URL('./routeGroupsMatcher.worker.ts', import.meta.url), { type: 'module' });
const routeMatcher = comlink.wrap<RouteGroupsMatcher>(worker);
export function useRouteGroupsMatcher() {
const getRouteGroupsMap = useCallback(async (rootRoute: RouteWithID, alertGroups: AlertmanagerGroup[]) => {
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;
}, []);
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