Alerting: Add notification policies preview in alert creation (#68839)

* Add notification policies preview in alert rule form
Co-authored-by: Konrad Lalik <konrad.lalik@grafana.com>

* Refactor using new useGetPotentialInstances hook and apply some style changes

* Add notification policy detail modal

* Use backtesting api for simulating  potential alert instances

* Fix logic to travserse all the children from the root route

* Split notification preview by alert manager

* Add instance count to matching policy header and fix some styles

* Move some logic to a new hook useGetAlertManagersSourceNames to make the code more clean

* Fix some tests

* Add initial test for NotificationPreview

* Use button to preview potential instances

* Add link to contact point details

* Add route matching result details

* Show AlertManager image in the routing preview list

* Add tests setup, add single AM preview test

* Handle no matchers and no labels use case

* Update some style in collapse component and fix policy path in modal

* Update modal styles

* Update styles

* Update collapse header styling

* Normalize tree nodes should happen before findMatchingRoutes call

* Fix findMatchingRoutes and findMatchingAlertGroups methods after reabasing

* Move instances matching to the web worker code

* Fix config fetching for vanilla prometheus AMs

* Add tests

* Add tests mocks

* Fix tests after adding web worker

* Display matching labels for each matching alert instance

* Add minor css improvements

* Revert changes added in Collapse component as we don't use it anymore

* Move the route details modal to a separate file

* Move NotificationRoute and preview hook into separate files

* Fix Alertmanager preview tests

* Fix tests

* Move matcher code to a separate file, improve matcher mock

* Add permissions control for contact point edit view link

* Fix from and to for the temporal use of backtesting api

* Fix tests, add lazy loading of the preview component

Co-authored-by: Sonia Aguilar <soniaaguilarpeiron@gmail.com>

* Fix preview test

* Add onclick on the header div so it collapse and expands when clicking on it, and update styles to be consistent with the rest of tables

* Adapt the code to the new rule testing endpoint definition

* Fix tests

* small changes after reviewing the final code

* compute entire inherited tree before computing the routes map

* Throw error in case of not having receiver in routesByIdMap and add test for the use case of inheriting receiver from parent to check UI throws no errors

* Add list of labels in the policy route path that produces the policy matchers to match potential instances

* Use color determined by the key, in label tags when hovering matchers in the policy tree

* Remove labels in modal and handle empty string as receiver to inherit from parent as we do with undefined

* Revert "Add list of labels in the policy route path that produces the policy matchers to match potential instances"

This reverts commit ee73ae9cf9.

* fix inheritance for computeInheritedTree

* Fix message shown when preview has not been executed yet

* First round for adressing PR review comments

* Adress the rest of PR review commments

* Update texts and rename id prop in NotificaitonStep to alertUid

---------

Co-authored-by: Konrad Lalik <konrad.lalik@grafana.com>
Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
pull/70320/head
Sonia Aguilar 2 years ago committed by GitHub
parent 1a985c488c
commit 9a252c763a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 47
      packages/grafana-ui/src/components/Tags/TagList.tsx
  2. 8
      packages/grafana-ui/src/utils/tags.ts
  3. 1
      public/app/features/alerting/unified/AlertGroups.test.tsx
  4. 28
      public/app/features/alerting/unified/CloneRuleEditor.test.tsx
  5. 5
      public/app/features/alerting/unified/ExistingRuleEditor.tsx
  6. 2
      public/app/features/alerting/unified/NotificationPolicies.test.tsx
  7. 18
      public/app/features/alerting/unified/NotificationPolicies.tsx
  8. 4
      public/app/features/alerting/unified/RuleEditor.tsx
  9. 3
      public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx
  10. 20
      public/app/features/alerting/unified/__mocks__/useRouteGroupsMatcher.ts
  11. 4
      public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap
  12. 80
      public/app/features/alerting/unified/api/alertRuleApi.ts
  13. 24
      public/app/features/alerting/unified/components/notification-policies/Filters.tsx
  14. 9
      public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx
  15. 6
      public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx
  16. 11
      public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx
  17. 29
      public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx
  18. 31
      public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPolicyMatchers.tsx
  19. 415
      public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.test.tsx
  20. 146
      public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx
  21. 117
      public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreviewByAlertManager.tsx
  22. 238
      public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx
  23. 176
      public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx
  24. 26
      public/app/features/alerting/unified/components/rule-editor/notificaton-preview/route.ts
  25. 72
      public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts
  26. 28
      public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useGetAlertManagersSourceNamesAndImage.tsx
  27. 66
      public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx
  28. 26
      public/app/features/alerting/unified/hooks/useAlertmanagerConfig.ts
  29. 141
      public/app/features/alerting/unified/mockApi.ts
  30. 8
      public/app/features/alerting/unified/mocks/alertRuleApi.ts
  31. 22
      public/app/features/alerting/unified/mocks/alertmanagerApi.ts
  32. 54
      public/app/features/alerting/unified/routeGroupsMatcher.ts
  33. 26
      public/app/features/alerting/unified/routeGroupsMatcher.worker.ts
  34. 4
      public/app/features/alerting/unified/state/actions.ts
  35. 10
      public/app/features/alerting/unified/types/rule-form.ts
  36. 48
      public/app/features/alerting/unified/useRouteGroupsMatcher.ts
  37. 2
      public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap
  38. 11
      public/app/features/alerting/unified/utils/amroutes.ts
  39. 3
      public/app/features/alerting/unified/utils/getNumberEvaluationsToStartAlerting.test.ts
  40. 14
      public/app/features/alerting/unified/utils/labels.ts
  41. 34
      public/app/features/alerting/unified/utils/notification-policies.test.ts
  42. 141
      public/app/features/alerting/unified/utils/notification-policies.ts
  43. 18
      public/app/features/alerting/unified/utils/rule-form.ts
  44. 44
      public/app/features/alerting/unified/utils/rules.ts
  45. 8
      public/app/features/alerting/unified/utils/time.ts

@ -19,29 +19,40 @@ export interface Props {
className?: string;
/** aria-label for the `i`-th Tag component */
getAriaLabel?: (name: string, i: number) => string;
//** Should return an index of a color defined in the TAG_COLORS array */
getColorIndex?: (name: string, i: number) => number;
/** Icon to show next to tag label */
icon?: IconName;
}
const TagListComponent = memo(
forwardRef<HTMLUListElement, Props>(({ displayMax, tags, icon, onClick, className, getAriaLabel }, ref) => {
const theme = useTheme2();
const styles = getStyles(theme, Boolean(displayMax && displayMax > 0));
const numTags = tags.length;
const tagsToDisplay = displayMax ? tags.slice(0, displayMax) : tags;
return (
<ul className={cx(styles.wrapper, className)} aria-label="Tags" ref={ref}>
{tagsToDisplay.map((tag, i) => (
<li className={styles.li} key={tag}>
<Tag name={tag} icon={icon} onClick={onClick} aria-label={getAriaLabel?.(tag, i)} data-tag-id={i} />
</li>
))}
{displayMax && displayMax > 0 && numTags - displayMax > 0 && (
<span className={styles.moreTagsLabel}>+ {numTags - displayMax}</span>
)}
</ul>
);
})
forwardRef<HTMLUListElement, Props>(
({ displayMax, tags, icon, onClick, className, getAriaLabel, getColorIndex }, ref) => {
const theme = useTheme2();
const styles = getStyles(theme, Boolean(displayMax && displayMax > 0));
const numTags = tags.length;
const tagsToDisplay = displayMax ? tags.slice(0, displayMax) : tags;
return (
<ul className={cx(styles.wrapper, className)} aria-label="Tags" ref={ref}>
{tagsToDisplay.map((tag, i) => (
<li className={styles.li} key={tag}>
<Tag
name={tag}
icon={icon}
onClick={onClick}
aria-label={getAriaLabel?.(tag, i)}
data-tag-id={i}
colorIndex={getColorIndex?.(tag, i)}
/>
</li>
))}
{displayMax && displayMax > 0 && numTags - displayMax > 0 && (
<span className={styles.moreTagsLabel}>+ {numTags - displayMax}</span>
)}
</ul>
);
}
)
);
TagListComponent.displayName = 'TagList';

@ -62,13 +62,17 @@ const TAG_BORDER_COLORS = [
'#655181',
];
export function getTagColorIndexFromName(name = ''): number {
const hash = djb2(name.toLowerCase());
return Math.abs(hash % TAG_COLORS.length);
}
/**
* Returns tag badge background and border colors based on hashed tag name.
* @param name tag name
*/
export function getTagColorsFromName(name = ''): { color: string; borderColor: string } {
const hash = djb2(name.toLowerCase());
const index = Math.abs(hash % TAG_COLORS.length);
const index = getTagColorIndexFromName(name);
return getTagColor(index);
}

@ -12,6 +12,7 @@ import { mockAlertGroup, mockAlertmanagerAlert, mockDataSource, MockDataSourceSr
import { DataSourceType } from './utils/datasource';
jest.mock('./api/alertmanager');
jest.mock('app/core/services/context_srv', () => ({
contextSrv: {
isEditor: true,

@ -8,6 +8,7 @@ import { byRole, byTestId, byText } from 'testing-library-selector';
import { selectors } from '@grafana/e2e-selectors/src';
import { config, setBackendSrv, setDataSourceSrv } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import 'whatwg-fetch';
import { RulerGrafanaRuleDTO } from '../../../types/unified-alerting-dto';
@ -15,10 +16,12 @@ import { RulerGrafanaRuleDTO } from '../../../types/unified-alerting-dto';
import { CloneRuleEditor } from './CloneRuleEditor';
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import { mockDataSource, MockDataSourceSrv, mockRulerAlertingRule, mockRulerGrafanaRule, mockStore } from './mocks';
import { mockAlertmanagerConfigResponse } from './mocks/alertmanagerApi';
import { mockSearchApiResponse } from './mocks/grafanaApi';
import { mockRulerRulesApiResponse, mockRulerRulesGroupApiResponse } from './mocks/rulerApi';
import { RuleFormValues } from './types/rule-form';
import { Annotation } from './utils/constants';
import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
import { getDefaultFormValues } from './utils/rule-form';
import { hashRulerRule } from './utils/rule-id';
@ -29,6 +32,12 @@ jest.mock('./components/rule-editor/ExpressionEditor', () => ({
),
}));
// For simplicity of the test we mock the NotificationPreview component
// Otherwise we would need to mock a few more HTTP api calls which are not relevant for these tests
jest.mock('./components/rule-editor/notificaton-preview/NotificationPreview', () => ({
NotificationPreview: () => <div />,
}));
const server = setupServer();
beforeAll(() => {
@ -97,6 +106,23 @@ function getProvidersWrapper() {
};
}
const amConfig: AlertManagerCortexConfig = {
alertmanager_config: {
receivers: [{ name: 'default' }, { name: 'critical' }],
route: {
receiver: 'default',
group_by: ['alertname'],
routes: [
{
matchers: ['env=prod', 'region!=EU'],
},
],
},
templates: [],
},
template_files: {},
};
describe('CloneRuleEditor', function () {
describe('Grafana-managed rules', function () {
it('should populate form values from the existing alert rule', async function () {
@ -116,6 +142,7 @@ describe('CloneRuleEditor', function () {
});
mockSearchApiResponse(server, []);
mockAlertmanagerConfigResponse(server, GRAFANA_RULES_SOURCE_NAME, amConfig);
render(<CloneRuleEditor sourceRuleId={{ uid: 'grafana-rule-1', ruleSourceName: 'grafana' }} />, {
wrapper: getProvidersWrapper(),
@ -166,6 +193,7 @@ describe('CloneRuleEditor', function () {
});
mockSearchApiResponse(server, []);
mockAlertmanagerConfigResponse(server, GRAFANA_RULES_SOURCE_NAME, amConfig);
render(
<CloneRuleEditor

@ -15,9 +15,10 @@ import * as ruleId from './utils/rule-id';
interface ExistingRuleEditorProps {
identifier: RuleIdentifier;
id?: string;
}
export function ExistingRuleEditor({ identifier }: ExistingRuleEditorProps) {
export function ExistingRuleEditor({ identifier, id }: ExistingRuleEditorProps) {
useCleanup((state) => (state.unifiedAlerting.ruleForm.existingRule = initialAsyncRequestState));
const {
@ -61,5 +62,5 @@ export function ExistingRuleEditor({ identifier }: ExistingRuleEditorProps) {
return <AlertWarning title="Cannot edit rule">Sorry! You do not have permission to edit this rule.</AlertWarning>;
}
return <AlertRuleForm existing={result} />;
return <AlertRuleForm existing={result} id={id} />;
}

@ -29,6 +29,8 @@ import { getAllDataSources } from './utils/config';
import { ALERTMANAGER_NAME_QUERY_KEY } from './utils/constants';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
import 'core-js/stable/structured-clone';
jest.mock('./api/alertmanager');
jest.mock('./utils/config');
jest.mock('app/core/services/context_srv');

@ -34,8 +34,8 @@ import {
import { Policy } from './components/notification-policies/Policy';
import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName';
import { useAlertManagersByPermission } from './hooks/useAlertManagerSources';
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
import { fetchAlertManagerConfigAction, updateAlertManagerConfigAction } from './state/actions';
import { useAlertmanagerConfig } from './hooks/useAlertmanagerConfig';
import { updateAlertManagerConfigAction } from './state/actions';
import { FormAmRoute } from './types/amroutes';
import { useRouteGroupsMatcher } from './useRouteGroupsMatcher';
import { addUniqueIdentifierToRoute } from './utils/amroutes';
@ -68,27 +68,15 @@ const AmRoutes = () => {
const alertManagers = useAlertManagersByPermission('notification');
const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName(alertManagers);
const amConfigs = useUnifiedAlertingSelector((state) => state.amConfigs);
const contactPointsState = useGetContactPointsState(alertManagerSourceName ?? '');
useEffect(() => {
if (alertManagerSourceName) {
dispatch(fetchAlertManagerConfigAction(alertManagerSourceName));
}
}, [alertManagerSourceName, dispatch]);
const { result, config, loading: resultLoading, error: resultError } = useAlertmanagerConfig(alertManagerSourceName);
const { currentData: alertGroups, refetch: refetchAlertGroups } = useGetAlertmanagerAlertGroupsQuery(
{ amSourceName: alertManagerSourceName ?? '' },
{ skip: !alertManagerSourceName }
);
const {
result,
loading: resultLoading,
error: resultError,
} = (alertManagerSourceName && amConfigs[alertManagerSourceName]) || initialAsyncRequestState;
const config = result?.alertmanager_config;
const receivers = config?.receivers ?? [];
const rootRoute = useMemo(() => {

@ -64,13 +64,13 @@ const RuleEditor = ({ match }: RuleEditorProps) => {
}
if (identifier) {
return <ExistingRuleEditor key={id} identifier={identifier} />;
return <ExistingRuleEditor key={id} identifier={identifier} id={id} />;
}
if (copyFromIdentifier) {
return <CloneRuleEditor sourceRuleId={copyFromIdentifier} />;
}
// new alert rule
return <AlertRuleForm />;
}, [canCreateCloudRules, canCreateGrafanaRules, canEditRules, copyFromIdentifier, id, identifier, loading]);

@ -1,4 +1,4 @@
import { waitFor, screen, within, waitForElementToBeRemoved } from '@testing-library/react';
import { screen, waitFor, waitForElementToBeRemoved, within } from '@testing-library/react';
import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event';
import React from 'react';
import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor';
@ -163,6 +163,7 @@ describe('RuleEditor grafana managed rules', () => {
is_paused: false,
no_data_state: 'NoData',
title: 'my great new rule',
uid: '',
},
},
],

@ -1,20 +1,18 @@
import { useCallback } from 'react';
import { Labels } from '@grafana/data';
import { AlertmanagerGroup, RouteWithID } from '../../../../plugins/datasource/alertmanager/types';
import { routeGroupsMatcher } from '../routeGroupsMatcher';
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);
const getRouteGroupsMap = useCallback(async (route: RouteWithID, groups: AlertmanagerGroup[]) => {
return routeGroupsMatcher.getRouteGroupsMap(route, groups);
}, []);
return groupsMap;
const matchInstancesToRoute = useCallback(async (rootRoute: RouteWithID, instancesToMatch: Labels[]) => {
return routeGroupsMatcher.matchInstancesToRoute(rootRoute, instancesToMatch);
}, []);
return { getRouteGroupsMap };
return { getRouteGroupsMap, matchInstancesToRoute };
}

@ -13,10 +13,6 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button
},
],
"condition": "C",
"folder": {
"id": 1,
"title": "super folder",
},
"name": "mypanel",
"queries": [
{

@ -0,0 +1,80 @@
import { RelativeTimeRange } from '@grafana/data';
import { AlertQuery, Annotations, GrafanaAlertStateDecision, Labels } from 'app/types/unified-alerting-dto';
import { Folder } from '../components/rule-editor/RuleFolderPicker';
import { arrayKeyValuesToObject } from '../utils/labels';
import { alertingApi } from './alertingApi';
export type ResponseLabels = {
labels: AlertInstances[];
};
export type PreviewResponse = ResponseLabels[];
export interface Datasource {
type: string;
uid: string;
}
export const PREVIEW_URL = '/api/v1/rule/test/grafana';
export interface Data {
refId: string;
relativeTimeRange: RelativeTimeRange;
queryType: string;
datasourceUid: string;
model: AlertQuery;
}
export interface GrafanaAlert {
data?: Data;
condition: string;
no_data_state: GrafanaAlertStateDecision;
title: string;
}
export interface Rule {
grafana_alert: GrafanaAlert;
for: string;
labels: Labels;
annotations: Annotations;
}
export type AlertInstances = Record<string, string>;
export const alertRuleApi = alertingApi.injectEndpoints({
endpoints: (build) => ({
preview: build.mutation<
PreviewResponse,
{
alertQueries: AlertQuery[];
condition: string;
folder: Folder;
customLabels: Array<{
key: string;
value: string;
}>;
alertName?: string;
alertUid?: string;
}
>({
query: ({ alertQueries, condition, customLabels, folder, alertName, alertUid }) => ({
url: PREVIEW_URL,
data: {
rule: {
grafana_alert: {
data: alertQueries,
condition: condition,
no_data_state: 'Alerting',
title: alertName,
uid: alertUid ?? 'N/A',
},
for: '0s',
labels: arrayKeyValuesToObject(customLabels),
annotations: {},
},
folderUid: folder.uid,
folderTitle: folder.title,
},
method: 'POST',
}),
}),
}),
});

@ -1,14 +1,15 @@
import { css } from '@emotion/css';
import { debounce, pick } from 'lodash';
import { debounce } from 'lodash';
import React, { useCallback, useEffect, useRef } from 'react';
import { SelectableValue } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { Button, Field, Icon, Input, Label as LabelElement, Select, Tooltip, useStyles2 } from '@grafana/ui';
import { ObjectMatcher, Receiver, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import { ObjectMatcher, Receiver, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import { useURLSearchParams } from '../../hooks/useURLSearchParams';
import { matcherToObjectMatcher, parseMatchers } from '../../utils/alertmanager';
import { getInheritedProperties } from '../../utils/notification-policies';
interface NotificationPoliciesFilterProps {
receivers: Receiver[];
@ -131,22 +132,15 @@ export function findRoutesMatchingPredicate(routeTree: RouteWithID, predicateFn:
/**
* This function will compute the full tree with inherited properties this is mostly used for search and filtering
*/
export function computeInheritedTree(routeTree: RouteWithID): RouteWithID {
export function computeInheritedTree<T extends Route>(parent: T): T {
return {
...routeTree,
routes: routeTree.routes?.map((route) => {
const inheritableProperties = pick(routeTree, [
'receiver',
'group_by',
'group_wait',
'group_interval',
'repeat_interval',
'mute_time_intervals',
]);
...parent,
routes: parent.routes?.map((child) => {
const inheritedProperties = getInheritedProperties(parent, child);
return computeInheritedTree({
...inheritableProperties,
...route,
...child,
...inheritedProperties,
});
}),
};

@ -20,7 +20,7 @@ import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelect
import { deleteRuleAction, saveRuleFormAction } from '../../state/actions';
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
import { initialAsyncRequestState } from '../../utils/redux';
import { getDefaultFormValues, getDefaultQueries, rulerRuleToFormValues } from '../../utils/rule-form';
import { getDefaultFormValues, getDefaultQueries, MINUTE, rulerRuleToFormValues } from '../../utils/rule-form';
import * as ruleId from '../../utils/rule-id';
import { CloudEvaluationBehavior } from './CloudEvaluationBehavior';
@ -69,14 +69,13 @@ const AlertRuleNameInput = () => {
);
};
export const MINUTE = '1m';
type Props = {
existing?: RuleWithLocation;
prefill?: Partial<RuleFormValues>; // Existing implies we modify existing rule. Prefill only provides default form values
id?: string;
};
export const AlertRuleForm = ({ existing, prefill }: Props) => {
export const AlertRuleForm = ({ existing, prefill, id }: Props) => {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const notifyApp = useAppNotification();
@ -254,7 +253,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
<CloudEvaluationBehavior />
)}
<DetailsStep />
<NotificationsStep />
<NotificationsStep alertUid={id} />
</>
)}
</div>

@ -13,19 +13,19 @@ import { CombinedRuleGroup } from 'app/types/unified-alerting';
import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { fetchRulerRulesIfNotFetchedYet } from '../../state/actions';
import { RuleForm, RuleFormValues } from '../../types/rule-form';
import { RuleFormValues } from '../../types/rule-form';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { MINUTE } from '../../utils/rule-form';
import { isGrafanaRulerRule } from '../../utils/rules';
import { InfoIcon } from '../InfoIcon';
import { MINUTE } from './AlertRuleForm';
import { Folder, RuleFolderPicker } from './RuleFolderPicker';
import { checkForPathSeparator } from './util';
export const SLICE_GROUP_RESULTS_TO = 1000;
interface FolderAndGroupProps {
initialFolder: RuleForm | null;
initialFolder: Folder | null;
}
export const useGetGroupOptionsFromFolder = (folderTitle: string) => {

@ -4,23 +4,24 @@ import { RegisterOptions, useFormContext } from 'react-hook-form';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { Button, Field, InlineLabel, Input, InputControl, useStyles2, Switch, Tooltip, Icon } from '@grafana/ui';
import { Button, Field, Icon, InlineLabel, Input, InputControl, Switch, Tooltip, useStyles2 } from '@grafana/ui';
import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { CombinedRuleGroup, CombinedRuleNamespace } from '../../../../../types/unified-alerting';
import { logInfo, LogMessages } from '../../Analytics';
import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { RuleForm, RuleFormValues } from '../../types/rule-form';
import { RuleFormValues } from '../../types/rule-form';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { MINUTE } from '../../utils/rule-form';
import { parsePrometheusDuration } from '../../utils/time';
import { CollapseToggle } from '../CollapseToggle';
import { EditCloudGroupModal, evaluateEveryValidationOptions } from '../rules/EditRuleGroupModal';
import { MINUTE } from './AlertRuleForm';
import { FolderAndGroup, useGetGroupOptionsFromFolder } from './FolderAndGroup';
import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker';
import { RuleEditorSection } from './RuleEditorSection';
import { Folder } from './RuleFolderPicker';
export const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds
@ -118,7 +119,7 @@ function FolderGroupAndEvaluationInterval({
evaluateEvery,
setEvaluateEvery,
}: {
initialFolder: RuleForm | null;
initialFolder: Folder | null;
evaluateEvery: string;
setEvaluateEvery: (value: string) => void;
}) {
@ -253,7 +254,7 @@ export function GrafanaEvaluationBehavior({
setEvaluateEvery,
existing,
}: {
initialFolder: RuleForm | null;
initialFolder: Folder | null;
evaluateEvery: string;
setEvaluateEvery: (value: string) => void;
existing: boolean;

@ -10,16 +10,29 @@ import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import LabelsField from './LabelsField';
import { RuleEditorSection } from './RuleEditorSection';
import { NotificationPreview } from './notificaton-preview/NotificationPreview';
export const NotificationsStep = () => {
type NotificationsStepProps = {
alertUid?: string;
};
export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => {
const styles = useStyles2(getStyles);
const { watch, getValues } = useFormContext<RuleFormValues & { location?: string }>();
const type = watch('type');
const [type, labels, queries, condition, folder, alertName] = watch([
'type',
'labels',
'queries',
'condition',
'folder',
'name',
]);
const dataSourceName = watch('dataSourceName') ?? GRAFANA_RULES_SOURCE_NAME;
const hasLabelsDefined = getNonEmptyLabels(getValues('labels')).length > 0;
const shouldRenderPreview = Boolean(condition) && Boolean(folder) && type === RuleFormType.grafana;
return (
<RuleEditorSection
stepNo={type === RuleFormType.cloudRecording ? 4 : 5}
@ -45,6 +58,18 @@ export const NotificationsStep = () => {
<LabelsField dataSourceName={dataSourceName} />
</div>
</div>
{shouldRenderPreview &&
condition &&
folder && ( // need to check for condition and folder again because of typescript
<NotificationPreview
alertQueries={queries}
customLabels={labels}
condition={condition}
folder={folder}
alertName={alertName}
alertUid={alertUid}
/>
)}
</RuleEditorSection>
);
};

@ -0,0 +1,31 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { Matchers } from '../../notification-policies/Matchers';
import { hasEmptyMatchers, isDefaultPolicy, RouteWithPath } from './route';
export function NotificationPolicyMatchers({ route }: { route: RouteWithPath }) {
const styles = useStyles2(getStyles);
if (isDefaultPolicy(route)) {
return <div className={styles.defaultPolicy}>Default policy</div>;
} else if (hasEmptyMatchers(route)) {
return <div className={styles.textMuted}>No matchers</div>;
} else {
return <Matchers matchers={route.object_matchers ?? []} />;
}
}
const getStyles = (theme: GrafanaTheme2) => ({
defaultPolicy: css`
padding: ${theme.spacing(0.5)};
background: ${theme.colors.background.secondary};
width: fit-content;
`,
textMuted: css`
color: ${theme.colors.text.secondary};
`,
});

@ -0,0 +1,415 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { byRole, byTestId, byText } from 'testing-library-selector';
import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction } from 'app/types/accessControl';
import 'core-js/stable/structured-clone';
import { TestProvider } from '../../../../../../../test/helpers/TestProvider';
import { MatcherOperator } from '../../../../../../plugins/datasource/alertmanager/types';
import { Labels } from '../../../../../../types/unified-alerting-dto';
import { mockApi, setupMswServer } from '../../../mockApi';
import { mockAlertQuery } from '../../../mocks';
import { mockPreviewApiResponse } from '../../../mocks/alertRuleApi';
import * as dataSource from '../../../utils/datasource';
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
import { Folder } from '../RuleFolderPicker';
import { NotificationPreview } from './NotificationPreview';
import NotificationPreviewByAlertManager from './NotificationPreviewByAlertManager';
import * as notificationPreview from './useGetAlertManagersSourceNamesAndImage';
import { useGetAlertManagersSourceNamesAndImage } from './useGetAlertManagersSourceNamesAndImage';
jest.mock('../../../useRouteGroupsMatcher');
jest
.spyOn(notificationPreview, 'useGetAlertManagersSourceNamesAndImage')
.mockReturnValue([{ name: GRAFANA_RULES_SOURCE_NAME, img: '' }]);
jest.spyOn(notificationPreview, 'useGetAlertManagersSourceNamesAndImage').mockReturnValue([
{ name: GRAFANA_RULES_SOURCE_NAME, img: '' },
{ name: GRAFANA_RULES_SOURCE_NAME, img: '' },
]);
jest.spyOn(dataSource, 'getDatasourceAPIUid').mockImplementation((ds: string) => ds);
jest.mock('app/core/services/context_srv');
const contextSrvMock = jest.mocked(contextSrv);
const useGetAlertManagersSourceNamesAndImageMock = useGetAlertManagersSourceNamesAndImage as jest.MockedFunction<
typeof useGetAlertManagersSourceNamesAndImage
>;
const ui = {
route: byTestId('matching-policy-route'),
routeButton: byRole('button', { name: /Expand policy route/ }),
routeMatchingInstances: byTestId('route-matching-instance'),
loadingIndicator: byText(/Loading/),
previewButton: byRole('button', { name: /preview routing/i }),
grafanaAlertManagerLabel: byText(/alert manager:grafana/i),
otherAlertManagerLabel: byText(/alert manager:other_am/i),
seeDetails: byText(/see details/i),
details: {
title: byRole('heading', { name: /routing details/i }),
modal: byRole('dialog'),
linkToContactPoint: byRole('link', { name: /see details/i }),
},
};
const server = setupMswServer();
beforeEach(() => {
jest.clearAllMocks();
});
const alertQuery = mockAlertQuery({ datasourceUid: 'whatever', refId: 'A' });
function mockOneAlertManager() {
useGetAlertManagersSourceNamesAndImageMock.mockReturnValue([{ name: GRAFANA_RULES_SOURCE_NAME, img: '' }]);
mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
amConfigBuilder
.withRoute((routeBuilder) =>
routeBuilder
.withReceiver('email')
.addRoute((rb) => rb.withReceiver('slack').addMatcher('tomato', MatcherOperator.equal, 'red'))
.addRoute((rb) => rb.withReceiver('opsgenie').addMatcher('team', MatcherOperator.equal, 'operations'))
)
.addReceivers((b) => b.withName('email').addEmailConfig((eb) => eb.withTo('test@example.com')))
.addReceivers((b) => b.withName('slack'))
.addReceivers((b) => b.withName('opsgenie'))
);
}
function mockTwoAlertManagers() {
useGetAlertManagersSourceNamesAndImageMock.mockReturnValue([
{ name: GRAFANA_RULES_SOURCE_NAME, img: '' },
{ name: 'OTHER_AM', img: '' },
]);
mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
amConfigBuilder
.withRoute((routeBuilder) =>
routeBuilder
.withReceiver('email')
.addRoute((rb) => rb.withReceiver('slack').addMatcher('tomato', MatcherOperator.equal, 'red'))
.addRoute((rb) => rb.withReceiver('opsgenie').addMatcher('team', MatcherOperator.equal, 'operations'))
)
.addReceivers((b) => b.withName('email').addEmailConfig((eb) => eb.withTo('test@example.com')))
.addReceivers((b) => b.withName('slack'))
.addReceivers((b) => b.withName('opsgenie'))
);
mockApi(server).getAlertmanagerConfig('OTHER_AM', (amConfigBuilder) =>
amConfigBuilder
.withRoute((routeBuilder) =>
routeBuilder
.withReceiver('email')
.addRoute((rb) => rb.withReceiver('slack').addMatcher('tomato', MatcherOperator.equal, 'red'))
.addRoute((rb) => rb.withReceiver('opsgenie').addMatcher('team', MatcherOperator.equal, 'operations'))
)
.addReceivers((b) => b.withName('email').addEmailConfig((eb) => eb.withTo('test@example.com')))
.addReceivers((b) => b.withName('slack'))
.addReceivers((b) => b.withName('opsgenie'))
);
}
function mockHasEditPermission(enabled: boolean) {
contextSrvMock.accessControlEnabled.mockReturnValue(true);
contextSrvMock.hasAccess.mockImplementation((action) => {
const onlyReadPermissions: string[] = [
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsExternalRead,
];
const readAndWritePermissions: string[] = [
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsWrite,
AccessControlAction.AlertingNotificationsExternalRead,
AccessControlAction.AlertingNotificationsExternalWrite,
];
return enabled ? readAndWritePermissions.includes(action) : onlyReadPermissions.includes(action);
});
}
const folder: Folder = {
uid: '1',
title: 'title',
};
describe('NotificationPreview', () => {
it('should render notification preview without alert manager label, when having only one alert manager configured to receive alerts', async () => {
mockOneAlertManager();
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="" folder={folder} />, {
wrapper: TestProvider,
});
await userEvent.click(ui.previewButton.get());
await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
});
// we expect the alert manager label to be missing as there is only one alert manager configured to receive alerts
expect(ui.grafanaAlertManagerLabel.query()).not.toBeInTheDocument();
expect(ui.otherAlertManagerLabel.query()).not.toBeInTheDocument();
const matchingPoliciesElements = ui.route.queryAll();
expect(matchingPoliciesElements).toHaveLength(1);
expect(matchingPoliciesElements[0]).toHaveTextContent(/tomato = red/);
});
it('should render notification preview with alert manager sections, when having more than one alert manager configured to receive alerts', async () => {
// two alert managers configured to receive alerts
mockTwoAlertManagers();
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="" folder={folder} />, {
wrapper: TestProvider,
});
await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
});
await userEvent.click(ui.previewButton.get());
await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
});
await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
});
// we expect the alert manager label to be present as there is more than one alert manager configured to receive alerts
expect(ui.grafanaAlertManagerLabel.query()).toBeInTheDocument();
expect(ui.otherAlertManagerLabel.query()).toBeInTheDocument();
const matchingPoliciesElements = ui.route.queryAll();
expect(matchingPoliciesElements).toHaveLength(2);
expect(matchingPoliciesElements[0]).toHaveTextContent(/tomato = red/);
expect(matchingPoliciesElements[1]).toHaveTextContent(/tomato = red/);
});
it('should render details modal when clicking see details button', async () => {
// two alert managers configured to receive alerts
mockOneAlertManager();
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
mockHasEditPermission(true);
render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="" folder={folder} />, {
wrapper: TestProvider,
});
await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
});
await userEvent.click(ui.previewButton.get());
await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
});
//open details modal
await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
});
await userEvent.click(ui.seeDetails.get());
expect(ui.details.title.query()).toBeInTheDocument();
//we expect seeing the default policy
expect(screen.getByText(/default policy/i)).toBeInTheDocument();
const matchingPoliciesElements = within(ui.details.modal.get()).getAllByTestId('label-matchers');
expect(matchingPoliciesElements).toHaveLength(1);
expect(matchingPoliciesElements[0]).toHaveTextContent(/tomato = red/);
expect(within(ui.details.modal.get()).getByText(/slack/i)).toBeInTheDocument();
expect(ui.details.linkToContactPoint.get()).toBeInTheDocument();
});
it('should not render contact point link in details modal if user has no permissions for editing contact points', async () => {
// two alert managers configured to receive alerts
mockOneAlertManager();
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
mockHasEditPermission(false);
render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="" folder={folder} />, {
wrapper: TestProvider,
});
await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
});
await userEvent.click(ui.previewButton.get());
await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
});
//open details modal
await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
});
await userEvent.click(ui.seeDetails.get());
expect(ui.details.title.query()).toBeInTheDocument();
//we expect seeing the default policy
expect(screen.getByText(/default policy/i)).toBeInTheDocument();
const matchingPoliciesElements = within(ui.details.modal.get()).getAllByTestId('label-matchers');
expect(matchingPoliciesElements).toHaveLength(1);
expect(matchingPoliciesElements[0]).toHaveTextContent(/tomato = red/);
expect(within(ui.details.modal.get()).getByText(/slack/i)).toBeInTheDocument();
expect(ui.details.linkToContactPoint.query()).not.toBeInTheDocument();
});
});
describe('NotificationPreviewByAlertmanager', () => {
it('should render route matching preview for alertmanager', async () => {
const potentialInstances: Labels[] = [
{ foo: 'bar', severity: 'critical' },
{ job: 'prometheus', severity: 'warning' },
];
mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
amConfigBuilder
.withRoute((routeBuilder) =>
routeBuilder
.withReceiver('email')
.addRoute((rb) => rb.withReceiver('slack').addMatcher('severity', MatcherOperator.equal, 'critical'))
.addRoute((rb) => rb.withReceiver('opsgenie').addMatcher('team', MatcherOperator.equal, 'operations'))
)
.addReceivers((b) => b.withName('email').addEmailConfig((eb) => eb.withTo('test@example.com')))
.addReceivers((b) => b.withName('slack'))
.addReceivers((b) => b.withName('opsgenie'))
);
const user = userEvent.setup();
render(
<NotificationPreviewByAlertManager
alertManagerSource={{ name: GRAFANA_RULES_SOURCE_NAME, img: '' }}
potentialInstances={potentialInstances}
onlyOneAM={true}
/>,
{ wrapper: TestProvider }
);
await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
});
const routeElements = ui.route.getAll();
expect(routeElements).toHaveLength(2);
expect(routeElements[0]).toHaveTextContent(/slack/);
expect(routeElements[1]).toHaveTextContent(/email/);
await user.click(ui.routeButton.get(routeElements[0]));
await user.click(ui.routeButton.get(routeElements[1]));
const matchingInstances0 = ui.routeMatchingInstances.get(routeElements[0]);
const matchingInstances1 = ui.routeMatchingInstances.get(routeElements[1]);
expect(matchingInstances0).toHaveTextContent(/severity=critical/);
expect(matchingInstances0).toHaveTextContent(/foo=bar/);
expect(matchingInstances1).toHaveTextContent(/job=prometheus/);
expect(matchingInstances1).toHaveTextContent(/severity=warning/);
});
it('should render route matching preview for alertmanager without errors if receiver is inherited from parent route (no receiver) ', async () => {
const potentialInstances: Labels[] = [
{ foo: 'bar', severity: 'critical' },
{ job: 'prometheus', severity: 'warning' },
];
mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
amConfigBuilder
.withRoute((routeBuilder) =>
routeBuilder
.withReceiver('email')
.addRoute((rb) => {
rb.addRoute((rb) => rb.withoutReceiver().addMatcher('foo', MatcherOperator.equal, 'bar'));
return rb.withReceiver('slack').addMatcher('severity', MatcherOperator.equal, 'critical');
})
.addRoute((rb) => rb.withReceiver('opsgenie').addMatcher('team', MatcherOperator.equal, 'operations'))
)
.addReceivers((b) => b.withName('email').addEmailConfig((eb) => eb.withTo('test@example.com')))
.addReceivers((b) => b.withName('slack'))
.addReceivers((b) => b.withName('opsgenie'))
);
const user = userEvent.setup();
render(
<NotificationPreviewByAlertManager
alertManagerSource={{ name: GRAFANA_RULES_SOURCE_NAME, img: '' }}
potentialInstances={potentialInstances}
onlyOneAM={true}
/>,
{ wrapper: TestProvider }
);
await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
});
const routeElements = ui.route.getAll();
expect(routeElements).toHaveLength(2);
expect(routeElements[0]).toHaveTextContent(/slack/);
expect(routeElements[1]).toHaveTextContent(/email/);
await user.click(ui.routeButton.get(routeElements[0]));
await user.click(ui.routeButton.get(routeElements[1]));
const matchingInstances0 = ui.routeMatchingInstances.get(routeElements[0]);
const matchingInstances1 = ui.routeMatchingInstances.get(routeElements[1]);
expect(matchingInstances0).toHaveTextContent(/severity=critical/);
expect(matchingInstances0).toHaveTextContent(/foo=bar/);
expect(matchingInstances1).toHaveTextContent(/job=prometheus/);
expect(matchingInstances1).toHaveTextContent(/severity=warning/);
});
it('should render route matching preview for alertmanager without errors if receiver is inherited from parent route (empty string receiver)', async () => {
const potentialInstances: Labels[] = [
{ foo: 'bar', severity: 'critical' },
{ job: 'prometheus', severity: 'warning' },
];
mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
amConfigBuilder
.withRoute((routeBuilder) =>
routeBuilder
.withReceiver('email')
.addRoute((rb) => {
rb.addRoute((rb) => rb.withEmptyReceiver().addMatcher('foo', MatcherOperator.equal, 'bar'));
return rb.withReceiver('slack').addMatcher('severity', MatcherOperator.equal, 'critical');
})
.addRoute((rb) => rb.withReceiver('opsgenie').addMatcher('team', MatcherOperator.equal, 'operations'))
)
.addReceivers((b) => b.withName('email').addEmailConfig((eb) => eb.withTo('test@example.com')))
.addReceivers((b) => b.withName('slack'))
.addReceivers((b) => b.withName('opsgenie'))
);
const user = userEvent.setup();
render(
<NotificationPreviewByAlertManager
alertManagerSource={{ name: GRAFANA_RULES_SOURCE_NAME, img: '' }}
potentialInstances={potentialInstances}
onlyOneAM={true}
/>,
{ wrapper: TestProvider }
);
await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
});
const routeElements = ui.route.getAll();
expect(routeElements).toHaveLength(2);
expect(routeElements[0]).toHaveTextContent(/slack/);
expect(routeElements[1]).toHaveTextContent(/email/);
await user.click(ui.routeButton.get(routeElements[0]));
await user.click(ui.routeButton.get(routeElements[1]));
const matchingInstances0 = ui.routeMatchingInstances.get(routeElements[0]);
const matchingInstances1 = ui.routeMatchingInstances.get(routeElements[1]);
expect(matchingInstances0).toHaveTextContent(/severity=critical/);
expect(matchingInstances0).toHaveTextContent(/foo=bar/);
expect(matchingInstances1).toHaveTextContent(/job=prometheus/);
expect(matchingInstances1).toHaveTextContent(/severity=warning/);
});
});

@ -0,0 +1,146 @@
import { css } from '@emotion/css';
import { compact } from 'lodash';
import React, { lazy, Suspense } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
import { H4 } from '@grafana/ui/src/unstable';
import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi';
import { Stack } from 'app/plugins/datasource/parca/QueryEditor/Stack';
import { AlertQuery } from 'app/types/unified-alerting-dto';
import { Folder } from '../RuleFolderPicker';
import { useGetAlertManagersSourceNamesAndImage } from './useGetAlertManagersSourceNamesAndImage';
const NotificationPreviewByAlertManager = lazy(() => import('./NotificationPreviewByAlertManager'));
interface NotificationPreviewProps {
customLabels: Array<{
key: string;
value: string;
}>;
alertQueries: AlertQuery[];
condition: string;
folder: Folder;
alertName?: string;
alertUid?: string;
}
export const NotificationPreview = ({
alertQueries,
customLabels,
condition,
folder,
alertName,
alertUid,
}: NotificationPreviewProps) => {
const styles = useStyles2(getStyles);
const { usePreviewMutation } = alertRuleApi;
const [trigger, { data = [], isLoading, isUninitialized: previewUninitialized }] = usePreviewMutation();
// potential instances are the instances that are going to be routed to the notification policies
// convert data to list of labels: are the representation of the potential instances
const potentialInstances = compact(data.flatMap((label) => label?.labels));
const onPreview = () => {
// Get the potential labels given the alert queries, the condition and the custom labels (autogenerated labels are calculated on the BE side)
trigger({
alertQueries: alertQueries,
condition: condition,
customLabels: customLabels,
folder: folder,
alertName: alertName,
alertUid: alertUid,
});
};
// Get list of alert managers source name + image
const alertManagerSourceNamesAndImage = useGetAlertManagersSourceNamesAndImage();
const onlyOneAM = alertManagerSourceNamesAndImage.length === 1;
const renderHowToPreview = !Boolean(data?.length) && !isLoading;
return (
<Stack direction="column" gap={2}>
<div className={styles.routePreviewHeaderRow}>
<div className={styles.previewHeader}>
<H4>Alert instance routing preview</H4>
</div>
<div className={styles.button}>
<Button icon="sync" variant="secondary" type="button" onClick={onPreview}>
Preview routing
</Button>
</div>
</div>
{!renderHowToPreview && (
<div className={styles.textMuted}>
Based on the labels added, alert instances are routed to the following notification policies. Expand each
notification policy below to view more details.
</div>
)}
{isLoading && <div className={styles.textMuted}>Loading...</div>}
{renderHowToPreview && (
<div className={styles.previewHowToText}>
{`When your query and labels are configured, click "Preview routing" to see the results here.`}
</div>
)}
{!isLoading && !previewUninitialized && potentialInstances.length > 0 && (
<Suspense fallback={<LoadingPlaceholder text="Loading preview..." />}>
{alertManagerSourceNamesAndImage.map((alertManagerSource) => (
<NotificationPreviewByAlertManager
alertManagerSource={alertManagerSource}
potentialInstances={potentialInstances}
onlyOneAM={onlyOneAM}
key={alertManagerSource.name}
/>
))}
</Suspense>
)}
</Stack>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
collapsableSection: css`
width: auto;
border: 0;
`,
textMuted: css`
color: ${theme.colors.text.secondary};
`,
previewHowToText: css`
display: flex;
color: ${theme.colors.text.secondary};
justify-content: center;
font-size: ${theme.typography.size.sm};
`,
previewHeader: css`
margin: 0;
`,
routePreviewHeaderRow: css`
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
`,
collapseLabel: css`
flex: 1;
`,
button: css`
justify-content: flex-end;
display: flex;
`,
tagsInDetails: css`
display: flex;
justify-content: flex-start;
flex-wrap: wrap;
`,
policyPathItemMatchers: css`
display: flex;
flex-direction: row;
gap: ${theme.spacing(1)};
`,
});

@ -0,0 +1,117 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, LoadingPlaceholder, useStyles2, withErrorBoundary } from '@grafana/ui';
import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack';
import { Labels } from '../../../../../../types/unified-alerting-dto';
import { NotificationRoute } from './NotificationRoute';
import { useAlertmanagerNotificationRoutingPreview } from './useAlertmanagerNotificationRoutingPreview';
import { AlertManagerNameWithImage } from './useGetAlertManagersSourceNamesAndImage';
function NotificationPreviewByAlertManager({
alertManagerSource,
potentialInstances,
onlyOneAM,
}: {
alertManagerSource: AlertManagerNameWithImage;
potentialInstances: Labels[];
onlyOneAM: boolean;
}) {
const styles = useStyles2(getStyles);
const { routesByIdMap, receiversByName, matchingMap, loading, error } = useAlertmanagerNotificationRoutingPreview(
alertManagerSource.name,
potentialInstances
);
if (error) {
return (
<Alert title="Cannot load Alertmanager configuration" severity="error">
{error.message}
</Alert>
);
}
if (loading) {
return <LoadingPlaceholder text="Loading routing preview..." />;
}
const matchingPoliciesFound = matchingMap.size > 0;
return matchingPoliciesFound ? (
<div className={styles.alertManagerRow}>
{!onlyOneAM && (
<Stack direction="row" alignItems="center">
<div className={styles.firstAlertManagerLine}></div>
<div className={styles.alertManagerName}>
{' '}
Alert manager:
<img src={alertManagerSource.img} alt="" className={styles.img} />
{alertManagerSource.name}
</div>
<div className={styles.secondAlertManagerLine}></div>
</Stack>
)}
<Stack gap={1} direction="column">
{Array.from(matchingMap.entries()).map(([routeId, instanceMatches]) => {
const route = routesByIdMap.get(routeId);
const receiver = route?.receiver && receiversByName.get(route.receiver);
if (!route) {
return null;
}
if (!receiver) {
throw new Error('Receiver not found');
}
return (
<NotificationRoute
instanceMatches={instanceMatches}
route={route}
receiver={receiver}
key={routeId}
routesByIdMap={routesByIdMap}
alertManagerSourceName={alertManagerSource.name}
/>
);
})}
</Stack>
</div>
) : null;
}
// export default because we want to load the component dynamically using React.lazy
// Due to loading of the web worker we don't want to load this component when not necessary
export default withErrorBoundary(NotificationPreviewByAlertManager);
const getStyles = (theme: GrafanaTheme2) => ({
alertManagerRow: css`
margin-top: ${theme.spacing(2)};
display: flex;
flex-direction: column;
gap: ${theme.spacing(1)};
width: 100%;
`,
firstAlertManagerLine: css`
height: 1px;
width: ${theme.spacing(4)};
background-color: ${theme.colors.secondary.main};
`,
alertManagerName: css`
width: fit-content;
`,
secondAlertManagerLine: css`
height: 1px;
width: 100%;
flex: 1;
background-color: ${theme.colors.secondary.main};
`,
img: css`
margin-left: ${theme.spacing(2)};
width: ${theme.spacing(3)};
height: ${theme.spacing(3)};
margin-right: ${theme.spacing(1)};
`,
});

@ -0,0 +1,238 @@
import { css, cx } from '@emotion/css';
import { uniqueId } from 'lodash';
import pluralize from 'pluralize';
import React, { useState } from 'react';
import { useToggle } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, getTagColorIndexFromName, TagList, useStyles2 } from '@grafana/ui';
import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types';
import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack';
import { AlertInstanceMatch } from '../../../utils/notification-policies';
import { CollapseToggle } from '../../CollapseToggle';
import { MetaText } from '../../MetaText';
import { Spacer } from '../../Spacer';
import { NotificationPolicyMatchers } from './NotificationPolicyMatchers';
import { NotificationRouteDetailsModal } from './NotificationRouteDetailsModal';
import { RouteWithPath } from './route';
function NotificationRouteHeader({
route,
receiver,
routesByIdMap,
instancesCount,
alertManagerSourceName,
expandRoute,
onExpandRouteClick,
}: {
route: RouteWithPath;
receiver: Receiver;
routesByIdMap: Map<string, RouteWithPath>;
instancesCount: number;
alertManagerSourceName: string;
expandRoute: boolean;
onExpandRouteClick: (expand: boolean) => void;
}) {
const styles = useStyles2(getStyles);
const [showDetails, setShowDetails] = useState(false);
const onClickDetails = () => {
setShowDetails(true);
};
// @TODO: re-use component ContactPointsHoverDetails from Policy once we have it for cloud AMs.
return (
<div className={styles.routeHeader}>
<CollapseToggle
isCollapsed={!expandRoute}
onToggle={(isCollapsed) => onExpandRouteClick(!isCollapsed)}
aria-label="Expand policy route"
/>
<Stack flexGrow={1} gap={1}>
<div onClick={() => onExpandRouteClick(!expandRoute)} className={styles.expandable}>
<Stack gap={1} direction="row" alignItems="center">
Notification policy
<NotificationPolicyMatchers route={route} />
</Stack>
</div>
<Spacer />
<Stack gap={2} direction="row" alignItems="center">
<MetaText icon="layers-alt" data-testid="matching-instances">
{instancesCount ?? '-'}
<span>{pluralize('instance', instancesCount)}</span>
</MetaText>
<Stack gap={1} direction="row" alignItems="center">
<div>
<span className={styles.textMuted}>@ Delivered to</span> {receiver.name}
</div>
<div className={styles.verticalBar} />
<Button type="button" onClick={onClickDetails} variant="secondary" fill="outline" size="sm">
See details
</Button>
</Stack>
</Stack>
</Stack>
{showDetails && (
<NotificationRouteDetailsModal
onClose={() => setShowDetails(false)}
route={route}
receiver={receiver}
routesByIdMap={routesByIdMap}
alertManagerSourceName={alertManagerSourceName}
/>
)}
</div>
);
}
interface NotificationRouteProps {
route: RouteWithPath;
receiver: Receiver;
instanceMatches: AlertInstanceMatch[];
routesByIdMap: Map<string, RouteWithPath>;
alertManagerSourceName: string;
}
export function NotificationRoute({
route,
instanceMatches,
receiver,
routesByIdMap,
alertManagerSourceName,
}: NotificationRouteProps) {
const styles = useStyles2(getStyles);
const [expandRoute, setExpandRoute] = useToggle(false);
// @TODO: The color index might be updated at some point in the future.Maybe we should roll our own tag component,
// one that supports a custom function to define the color and allow manual color overrides
const GREY_COLOR_INDEX = 9;
return (
<div data-testid="matching-policy-route">
<NotificationRouteHeader
route={route}
receiver={receiver}
routesByIdMap={routesByIdMap}
instancesCount={instanceMatches.length}
alertManagerSourceName={alertManagerSourceName}
expandRoute={expandRoute}
onExpandRouteClick={setExpandRoute}
/>
{expandRoute && (
<Stack gap={1} direction="column">
<div className={styles.routeInstances} data-testid="route-matching-instance">
{instanceMatches.map((instanceMatch) => {
const matchArray = Array.from(instanceMatch.labelsMatch);
let matchResult = matchArray.map(([label, matchResult]) => ({
label: `${label[0]}=${label[1]}`,
match: matchResult.match,
colorIndex: matchResult.match ? getTagColorIndexFromName(label[0]) : GREY_COLOR_INDEX,
}));
const matchingLabels = matchResult.filter((mr) => mr.match);
const nonMatchingLabels = matchResult.filter((mr) => !mr.match);
return (
<div className={styles.tagListCard} key={uniqueId()}>
{matchArray.length > 0 ? (
<>
{matchingLabels.length > 0 ? (
<TagList
tags={matchingLabels.map((mr) => mr.label)}
className={styles.labelList}
getColorIndex={(_, index) => matchingLabels[index].colorIndex}
/>
) : (
<div className={cx(styles.textMuted, styles.textItalic)}>No matching labels</div>
)}
<div className={styles.labelSeparator} />
<TagList
tags={nonMatchingLabels.map((mr) => mr.label)}
className={styles.labelList}
getColorIndex={(_, index) => nonMatchingLabels[index].colorIndex}
/>
</>
) : (
<div className={styles.textMuted}>No labels</div>
)}
</div>
);
})}
</div>
</Stack>
)}
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
textMuted: css`
color: ${theme.colors.text.secondary};
`,
textItalic: css`
font-style: italic;
`,
expandable: css`
cursor: pointer;
`,
routeHeader: css`
display: flex;
flex-direction: row;
gap: ${theme.spacing(1)};
align-items: center;
border-bottom: 1px solid ${theme.colors.border.weak};
&:hover {
background-color: ${theme.components.table.rowHoverBackground};
}
padding: ${theme.spacing(0.5, 0.5, 0.5, 0)};
`,
labelList: css`
flex: 0 1 auto;
justify-content: flex-start;
`,
labelSeparator: css`
width: 1px;
background-color: ${theme.colors.border.weak};
`,
tagListCard: css`
display: flex;
flex-direction: row;
gap: ${theme.spacing(2)};
position: relative;
background: ${theme.colors.background.secondary};
padding: ${theme.spacing(1)};
border-radius: ${theme.shape.borderRadius(2)};
border: solid 1px ${theme.colors.border.weak};
`,
routeInstances: css`
padding: ${theme.spacing(1, 0, 1, 4)};
position: relative;
display: flex;
flex-direction: column;
gap: ${theme.spacing(1)};
&:before {
content: '';
position: absolute;
left: ${theme.spacing(2)};
height: calc(100% - ${theme.spacing(2)});
width: ${theme.spacing(4)};
border-left: solid 1px ${theme.colors.border.weak};
}
`,
verticalBar: css`
width: 1px;
height: 20px;
background-color: ${theme.colors.secondary.main};
margin-left: ${theme.spacing(1)};
margin-right: ${theme.spacing(1)};
`,
});

@ -0,0 +1,176 @@
import { css, cx } from '@emotion/css';
import { compact } from 'lodash';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Icon, Modal, useStyles2 } from '@grafana/ui';
import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types';
import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack';
import { getNotificationsPermissions } from '../../../utils/access-control';
import { makeAMLink } from '../../../utils/misc';
import { Authorize } from '../../Authorize';
import { Matchers } from '../../notification-policies/Matchers';
import { hasEmptyMatchers, isDefaultPolicy, RouteWithPath } from './route';
function PolicyPath({ route, routesByIdMap }: { routesByIdMap: Map<string, RouteWithPath>; route: RouteWithPath }) {
const styles = useStyles2(getStyles);
const routePathIds = route.path?.slice(1) ?? [];
const routePathObjects = [...compact(routePathIds.map((id) => routesByIdMap.get(id))), route];
return (
<div className={styles.policyPathWrapper}>
<div className={styles.defaultPolicy}>Default policy</div>
{routePathObjects.map((pathRoute, index) => {
return (
<div key={pathRoute.id}>
<div className={styles.policyInPath(index, index === routePathObjects.length - 1)}>
{hasEmptyMatchers(pathRoute) ? (
<div className={styles.textMuted}>No matchers</div>
) : (
<Matchers matchers={pathRoute.object_matchers ?? []} />
)}
</div>
</div>
);
})}
</div>
);
}
interface NotificationRouteDetailsModalProps {
onClose: () => void;
route: RouteWithPath;
receiver: Receiver;
routesByIdMap: Map<string, RouteWithPath>;
alertManagerSourceName: string;
}
export function NotificationRouteDetailsModal({
onClose,
route,
receiver,
routesByIdMap,
alertManagerSourceName,
}: NotificationRouteDetailsModalProps) {
const styles = useStyles2(getStyles);
const isDefault = isDefaultPolicy(route);
const permissions = getNotificationsPermissions(alertManagerSourceName);
return (
<Modal
className={styles.detailsModal}
isOpen={true}
title="Routing details"
onDismiss={onClose}
onClickBackdrop={onClose}
>
<Stack gap={0} direction="column">
<div className={cx(styles.textMuted, styles.marginBottom(2))}>Your alert instances are routed as follows.</div>
<div>Notification policy path</div>
{isDefault && <div className={styles.textMuted}>Default policy</div>}
<div className={styles.separator(1)} />
{!isDefault && (
<>
<PolicyPath route={route} routesByIdMap={routesByIdMap} />
</>
)}
<div className={styles.separator(4)} />
<div className={styles.contactPoint}>
<Stack gap={1} direction="row" alignItems="center">
Contact point:
<span className={styles.textMuted}>{receiver.name}</span>
</Stack>
<Authorize actions={[permissions.update]}>
<Stack gap={1} direction="row" alignItems="center">
<a
href={makeAMLink(
`/alerting/notifications/receivers/${encodeURIComponent(receiver.name)}/edit`,
alertManagerSourceName
)}
className={styles.link}
target="_blank"
rel="noreferrer"
>
See details <Icon name="external-link-alt" />
</a>
</Stack>
</Authorize>
</div>
<div className={styles.button}>
<Button variant="primary" type="button" onClick={onClose}>
Close
</Button>
</div>
</Stack>
</Modal>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
textMuted: css`
color: ${theme.colors.text.secondary};
`,
link: css`
display: block;
color: ${theme.colors.text.link};
`,
button: css`
justify-content: flex-end;
display: flex;
`,
detailsModal: css`
max-width: 560px;
`,
defaultPolicy: css`
padding: ${theme.spacing(0.5)};
background: ${theme.colors.background.secondary};
width: fit-content;
`,
contactPoint: css`
display: flex;
flex-direction: row;
gap: ${theme.spacing(1)};
align-items: center;
justify-content: space-between;
margin-bottom: ${theme.spacing(1)};
`,
policyPathWrapper: css`
display: flex;
flex-direction: column;
margin-top: ${theme.spacing(1)};
`,
separator: (units: number) => css`
margin-top: ${theme.spacing(units)};
`,
marginBottom: (units: number) => css`
margin-bottom: ${theme.spacing(theme.spacing(units))};
`,
policyInPath: (index = 0, higlight = false) => css`
margin-left: ${30 + index * 30}px;
padding: ${theme.spacing(1)};
margin-top: ${theme.spacing(1)};
border: solid 1px ${theme.colors.border.weak};
background: ${theme.colors.background.secondary};
width: fit-content;
position: relative;
${
higlight &&
css`
border: solid 1px ${theme.colors.info.border};
`
},
&:before {
content: '';
position: absolute;
height: calc(100% - 10px);
width: ${theme.spacing(1)};
border-left: solid 1px ${theme.colors.border.weak};
border-bottom: solid 1px ${theme.colors.border.weak};
margin-top: ${theme.spacing(-2)};
margin-left: -17px;
}
} `,
});

@ -0,0 +1,26 @@
import { RouteWithID } from '../../../../../../plugins/datasource/alertmanager/types';
export interface RouteWithPath extends RouteWithID {
path: string[]; // path from root route to this route
}
export function isDefaultPolicy(route: RouteWithPath) {
return route.path?.length === 0;
}
// we traverse the whole tree and we create a map with <id , RouteWithPath>
export function getRoutesByIdMap(rootRoute: RouteWithID): Map<string, RouteWithPath> {
const map = new Map<string, RouteWithPath>();
function addRoutesToMap(route: RouteWithID, path: string[] = []) {
map.set(route.id, { ...route, path: path });
route.routes?.forEach((r) => addRoutesToMap(r, [...path, route.id]));
}
addRoutesToMap(rootRoute, []);
return map;
}
export function hasEmptyMatchers(route: RouteWithID) {
return route.object_matchers?.length === 0;
}

@ -0,0 +1,72 @@
import { useMemo } from 'react';
import { useAsync } from 'react-use';
import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types';
import { Labels } from '../../../../../../types/unified-alerting-dto';
import { useAlertmanagerConfig } from '../../../hooks/useAlertmanagerConfig';
import { useRouteGroupsMatcher } from '../../../useRouteGroupsMatcher';
import { addUniqueIdentifierToRoute } from '../../../utils/amroutes';
import { AlertInstanceMatch, normalizeRoute } from '../../../utils/notification-policies';
import { computeInheritedTree } from '../../notification-policies/Filters';
import { getRoutesByIdMap, RouteWithPath } from './route';
export const useAlertmanagerNotificationRoutingPreview = (
alertManagerSourceName: string,
potentialInstances: Labels[]
) => {
const {
config: AMConfig,
loading: configLoading,
error: configError,
} = useAlertmanagerConfig(alertManagerSourceName);
const { matchInstancesToRoute } = useRouteGroupsMatcher();
// to create the list of matching contact points we need to first get the rootRoute
const { rootRoute, receivers } = useMemo(() => {
if (!AMConfig) {
return {
receivers: [],
rootRoute: undefined,
};
}
return {
rootRoute: AMConfig.route ? normalizeRoute(addUniqueIdentifierToRoute(AMConfig.route)) : undefined,
receivers: AMConfig.receivers ?? [],
};
}, [AMConfig]);
// create maps for routes to be get by id, this map also contains the path to the route
// ⚠ don't forget to compute the inherited tree before using this map
const routesByIdMap: Map<string, RouteWithPath> = rootRoute
? getRoutesByIdMap(computeInheritedTree(rootRoute))
: new Map();
// create map for receivers to be get by name
const receiversByName =
receivers.reduce((map, receiver) => {
return map.set(receiver.name, receiver);
}, new Map<string, Receiver>()) ?? new Map<string, Receiver>();
// match labels in the tree => map of notification policies and the alert instances (list of labels) in each one
const {
value: matchingMap = new Map<string, AlertInstanceMatch[]>(),
loading: matchingLoading,
error: matchingError,
} = useAsync(async () => {
if (!rootRoute) {
return;
}
return await matchInstancesToRoute(rootRoute, potentialInstances);
}, [rootRoute, potentialInstances]);
return {
routesByIdMap,
receiversByName,
matchingMap,
loading: configLoading || matchingLoading,
error: configError ?? matchingError,
};
};

@ -0,0 +1,28 @@
import { AlertmanagerChoice } from '../../../../../../plugins/datasource/alertmanager/types';
import { alertmanagerApi } from '../../../api/alertmanagerApi';
import { useExternalDataSourceAlertmanagers } from '../../../hooks/useExternalAmSelector';
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
export interface AlertManagerNameWithImage {
name: string;
img: string;
}
export const useGetAlertManagersSourceNamesAndImage = () => {
//get current alerting config
const { currentData: amConfigStatus } = alertmanagerApi.useGetAlertmanagerChoiceStatusQuery(undefined);
const externalDsAlertManagers: AlertManagerNameWithImage[] = useExternalDataSourceAlertmanagers().map((ds) => ({
name: ds.dataSource.name,
img: ds.dataSource.meta.info.logos.small,
}));
const alertmanagerChoice = amConfigStatus?.alertmanagersChoice;
const alertManagerSourceNamesWithImage: AlertManagerNameWithImage[] =
alertmanagerChoice === AlertmanagerChoice.Internal
? [{ name: GRAFANA_RULES_SOURCE_NAME, img: 'public/img/grafana_icon.svg' }]
: alertmanagerChoice === AlertmanagerChoice.External
? externalDsAlertManagers
: [{ name: GRAFANA_RULES_SOURCE_NAME, img: 'public/img/grafana_icon.svg' }, ...externalDsAlertManagers];
return alertManagerSourceNamesWithImage;
};

@ -10,15 +10,15 @@ import { useAppNotification } from 'app/core/copy/appNotification';
import { useCleanup } from 'app/core/hooks/useCleanup';
import { useDispatch } from 'app/types';
import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting';
import { RulerRuleDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { rulesInSameGroupHaveInvalidFor, updateLotexNamespaceAndGroupAction } from '../../state/actions';
import { checkEvaluationIntervalGlobalLimit } from '../../utils/config';
import { getRulesSourceName, GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { initialAsyncRequestState } from '../../utils/redux';
import { isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from '../../utils/rules';
import { parsePrometheusDuration } from '../../utils/time';
import { AlertInfo, getAlertInfo, isRecordingRulerRule } from '../../utils/rules';
import { parsePrometheusDuration, safeParseDurationstr } from '../../utils/time';
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
import { InfoIcon } from '../InfoIcon';
import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning';
@ -26,12 +26,6 @@ import { MIN_TIME_RANGE_STEP_S } from '../rule-editor/GrafanaEvaluationBehavior'
const ITEMS_PER_PAGE = 10;
interface AlertInfo {
alertName: string;
forDuration: string;
evaluationsToFire: number;
}
function ForBadge({ message, error }: { message: string; error?: boolean }) {
if (error) {
return <Badge color="red" icon="exclamation-circle" text={'Error'} tooltip={message} />;
@ -40,43 +34,7 @@ function ForBadge({ message, error }: { message: string; error?: boolean }) {
}
}
export const getNumberEvaluationsToStartAlerting = (forDuration: string, currentEvaluation: string) => {
const evalNumberMs = safeParseDurationstr(currentEvaluation);
const forNumber = safeParseDurationstr(forDuration);
if (forNumber === 0 && evalNumberMs !== 0) {
return 1;
}
if (evalNumberMs === 0) {
return 0;
} else {
const evaluationsBeforeCeil = forNumber / evalNumberMs;
return evaluationsBeforeCeil < 1 ? 0 : Math.ceil(forNumber / evalNumberMs) + 1;
}
};
export const getAlertInfo = (alert: RulerRuleDTO, currentEvaluation: string): AlertInfo => {
const emptyAlert: AlertInfo = {
alertName: '',
forDuration: '0s',
evaluationsToFire: 0,
};
if (isGrafanaRulerRule(alert)) {
return {
alertName: alert.grafana_alert.title,
forDuration: alert.for,
evaluationsToFire: getNumberEvaluationsToStartAlerting(alert.for, currentEvaluation),
};
}
if (isAlertingRulerRule(alert)) {
return {
alertName: alert.alert,
forDuration: alert.for ?? '1m',
evaluationsToFire: getNumberEvaluationsToStartAlerting(alert.for ?? '1m', currentEvaluation),
};
}
return emptyAlert;
};
export const isValidEvaluation = (evaluation: string) => {
const isValidEvaluation = (evaluation: string) => {
try {
const duration = parsePrometheusDuration(evaluation);
@ -94,22 +52,6 @@ export const isValidEvaluation = (evaluation: string) => {
}
};
export const getGroupFromRuler = (
rulerRules: RulerRulesConfigDTO | null | undefined,
groupName: string,
folderName: string
) => {
const folderObj: Array<RulerRuleGroupDTO<RulerRuleDTO>> = rulerRules ? rulerRules[folderName] : [];
return folderObj?.find((rulerRuleGroup) => rulerRuleGroup.name === groupName);
};
export const safeParseDurationstr = (duration: string): number => {
try {
return parsePrometheusDuration(duration);
} catch (e) {
return 0;
}
};
type AlertsWithForTableColumnProps = DynamicTableColumnProps<AlertInfo>;
type AlertsWithForTableProps = DynamicTableItemProps<AlertInfo>;

@ -0,0 +1,26 @@
import { useEffect } from 'react';
import { useDispatch } from 'app/types';
import { fetchAlertManagerConfigAction } from '../state/actions';
import { initialAsyncRequestState } from '../utils/redux';
import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
export function useAlertmanagerConfig(amSourceName?: string) {
const dispatch = useDispatch();
useEffect(() => {
if (amSourceName) {
dispatch(fetchAlertManagerConfigAction(amSourceName));
}
}, [amSourceName, dispatch]);
const amConfigs = useUnifiedAlertingSelector((state) => state.amConfigs);
const { result, loading, error } = (amSourceName && amConfigs[amSourceName]) || initialAsyncRequestState;
const config = result?.alertmanager_config;
return { result, config, loading, error };
}

@ -0,0 +1,141 @@
import { rest } from 'msw';
import { setupServer, SetupServer } from 'msw/node';
import 'whatwg-fetch';
import { setBackendSrv } from '@grafana/runtime';
import { backendSrv } from '../../../core/services/backend_srv';
import {
AlertmanagerConfig,
AlertManagerCortexConfig,
EmailConfig,
MatcherOperator,
Receiver,
Route,
} from '../../../plugins/datasource/alertmanager/types';
class AlertmanagerConfigBuilder {
private alertmanagerConfig: AlertmanagerConfig = { receivers: [] };
addReceivers(configure: (builder: AlertmanagerReceiverBuilder) => void): AlertmanagerConfigBuilder {
const receiverBuilder = new AlertmanagerReceiverBuilder();
configure(receiverBuilder);
this.alertmanagerConfig.receivers?.push(receiverBuilder.build());
return this;
}
withRoute(configure: (routeBuilder: AlertmanagerRouteBuilder) => void): AlertmanagerConfigBuilder {
const routeBuilder = new AlertmanagerRouteBuilder();
configure(routeBuilder);
this.alertmanagerConfig.route = routeBuilder.build();
return this;
}
build() {
return this.alertmanagerConfig;
}
}
class AlertmanagerRouteBuilder {
private route: Route = { routes: [], object_matchers: [] };
withReceiver(receiver: string): AlertmanagerRouteBuilder {
this.route.receiver = receiver;
return this;
}
withoutReceiver(): AlertmanagerRouteBuilder {
return this;
}
withEmptyReceiver(): AlertmanagerRouteBuilder {
this.route.receiver = '';
return this;
}
addRoute(configure: (builder: AlertmanagerRouteBuilder) => void): AlertmanagerRouteBuilder {
const routeBuilder = new AlertmanagerRouteBuilder();
configure(routeBuilder);
this.route.routes?.push(routeBuilder.build());
return this;
}
addMatcher(key: string, operator: MatcherOperator, value: string): AlertmanagerRouteBuilder {
this.route.object_matchers?.push([key, operator, value]);
return this;
}
build() {
return this.route;
}
}
class EmailConfigBuilder {
private emailConfig: EmailConfig = { to: '' };
withTo(to: string): EmailConfigBuilder {
this.emailConfig.to = to;
return this;
}
build() {
return this.emailConfig;
}
}
class AlertmanagerReceiverBuilder {
private receiver: Receiver = { name: '', email_configs: [] };
withName(name: string): AlertmanagerReceiverBuilder {
this.receiver.name = name;
return this;
}
addEmailConfig(configure: (builder: EmailConfigBuilder) => void): AlertmanagerReceiverBuilder {
const builder = new EmailConfigBuilder();
configure(builder);
this.receiver.email_configs?.push(builder.build());
return this;
}
build() {
return this.receiver;
}
}
export function mockApi(server: SetupServer) {
return {
getAlertmanagerConfig: (amName: string, configure: (builder: AlertmanagerConfigBuilder) => void) => {
const builder = new AlertmanagerConfigBuilder();
configure(builder);
server.use(
rest.get(`api/alertmanager/${amName}/config/api/v1/alerts`, (req, res, ctx) =>
res(
ctx.status(200),
ctx.json<AlertManagerCortexConfig>({
alertmanager_config: builder.build(),
template_files: {},
})
)
)
);
},
};
}
// Creates a MSW server and sets up beforeAll and afterAll handlers for it
export function setupMswServer() {
const server = setupServer();
beforeAll(() => {
setBackendSrv(backendSrv);
server.listen({ onUnhandledRequest: 'error' });
});
afterAll(() => {
server.close();
});
return server;
}

@ -0,0 +1,8 @@
import { rest } from 'msw';
import { SetupServer } from 'msw/node';
import { PreviewResponse, PREVIEW_URL } from '../api/alertRuleApi';
export function mockPreviewApiResponse(server: SetupServer, result: PreviewResponse) {
server.use(rest.post(PREVIEW_URL, (req, res, ctx) => res(ctx.json<PreviewResponse>(result))));
}

@ -1,13 +1,29 @@
import { rest } from 'msw';
import { SetupServer } from 'msw/node';
import { ExternalAlertmanagersResponse } from '../../../../plugins/datasource/alertmanager/types';
import {
AlertManagerCortexConfig,
ExternalAlertmanagersResponse,
} from '../../../../plugins/datasource/alertmanager/types';
import { AlertmanagersChoiceResponse } from '../api/alertmanagerApi';
import { getDatasourceAPIUid } from '../utils/datasource';
export function mockAlertmanagerChoiceResponse(server: SetupServer, respose: AlertmanagersChoiceResponse) {
server.use(rest.get('/api/v1/ngalert', (req, res, ctx) => res(ctx.status(200), ctx.json(respose))));
export function mockAlertmanagerChoiceResponse(server: SetupServer, response: AlertmanagersChoiceResponse) {
server.use(rest.get('/api/v1/ngalert', (req, res, ctx) => res(ctx.status(200), ctx.json(response))));
}
export function mockAlertmanagersResponse(server: SetupServer, response: ExternalAlertmanagersResponse) {
server.use(rest.get('/api/v1/ngalert/alertmanagers', (req, res, ctx) => res(ctx.status(200), ctx.json(response))));
}
export function mockAlertmanagerConfigResponse(
server: SetupServer,
alertManagerSourceName: string,
response: AlertManagerCortexConfig
) {
server.use(
rest.get(`/api/alertmanager/${getDatasourceAPIUid(alertManagerSourceName)}/config/api/v1/alerts`, (req, res, ctx) =>
res(ctx.status(200), ctx.json(response))
)
);
}

@ -0,0 +1,54 @@
import { AlertmanagerGroup, RouteWithID } from '../../../plugins/datasource/alertmanager/types';
import { Labels } from '../../../types/unified-alerting-dto';
import {
AlertInstanceMatch,
findMatchingAlertGroups,
findMatchingRoutes,
normalizeRoute,
} from './utils/notification-policies';
export 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;
},
matchInstancesToRoute(routeTree: RouteWithID, instancesToMatch: Labels[]): Map<string, AlertInstanceMatch[]> {
const result = new Map<string, AlertInstanceMatch[]>();
const normalizedRootRoute = normalizeRoute(routeTree);
instancesToMatch.forEach((instance) => {
const matchingRoutes = findMatchingRoutes(normalizedRootRoute, Object.entries(instance));
matchingRoutes.forEach(({ route, details, labelsMatch }) => {
// Only to convert Label[] to Labels[] - needs better approach
const matchDetails = new Map(
Array.from(details.entries()).map(([matcher, labels]) => [matcher, Object.fromEntries(labels)])
);
const currentRoute = result.get(route.id);
if (currentRoute) {
currentRoute.push({ instance, matchDetails, labelsMatch });
} else {
result.set(route.id, [{ instance, matchDetails, labelsMatch }]);
}
});
});
return result;
},
};
export type RouteGroupsMatcher = typeof routeGroupsMatcher;

@ -1,27 +1,7 @@
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;
import { routeGroupsMatcher } from './routeGroupsMatcher';
// Worker is only a thin wrapper around routeGroupsMatcher to move processing to a separate thread
// routeGroupsMatcher should be used in mocks and tests because it's difficult to tests code with workers
comlink.expose(routeGroupsMatcher);

@ -61,7 +61,6 @@ import {
FetchRulerRulesFilter,
setRulerRuleGroup,
} from '../api/ruler';
import { getAlertInfo, safeParseDurationstr } from '../components/rules/EditRuleGroupModal';
import { RuleFormType, RuleFormValues } from '../types/rule-form';
import { addDefaultsToAlertmanagerConfig, removeMuteTimingFromRoute } from '../utils/alertmanager';
import {
@ -75,7 +74,8 @@ import { makeAMLink, retryWhile } from '../utils/misc';
import { AsyncRequestMapSlice, messageFromError, withAppEvents, withSerializedError } from '../utils/redux';
import * as ruleId from '../utils/rule-id';
import { getRulerClient } from '../utils/rulerClient';
import { isRulerNotSupportedResponse } from '../utils/rules';
import { getAlertInfo, isRulerNotSupportedResponse } from '../utils/rules';
import { safeParseDurationstr } from '../utils/time';
const FETCH_CONFIG_RETRY_TIMEOUT = 30 * 1000;

@ -1,19 +1,17 @@
import { AlertQuery, GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto';
import { Folder } from '../components/rule-editor/RuleFolderPicker';
export enum RuleFormType {
grafana = 'grafana',
cloudAlerting = 'cloud-alerting',
cloudRecording = 'cloud-recording',
}
export interface RuleForm {
title: string;
id: number;
}
export interface RuleFormValues {
// common
name: string;
uid: string;
type?: RuleFormType;
dataSourceName: string | null;
group: string;
@ -26,7 +24,7 @@ export interface RuleFormValues {
condition: string | null; // refId of the query that gets alerted on
noDataState: GrafanaAlertStateDecision;
execErrState: GrafanaAlertStateDecision;
folder: RuleForm | null;
folder: Folder | null;
evaluateEvery: string;
evaluateFor: string;
isPaused?: boolean;

@ -5,11 +5,12 @@ import { useEnabled } from 'react-enable';
import { logError } from '@grafana/runtime';
import { AlertmanagerGroup, RouteWithID } from '../../../plugins/datasource/alertmanager/types';
import { Labels } from '../../../types/unified-alerting-dto';
import { logInfo } from './Analytics';
import { createWorker } from './createRouteGroupsMatcherWorker';
import { AlertingFeature } from './features';
import type { RouteGroupsMatcher } from './routeGroupsMatcher.worker';
import type { RouteGroupsMatcher } from './routeGroupsMatcher';
let routeMatcher: comlink.Remote<RouteGroupsMatcher> | undefined;
@ -44,6 +45,19 @@ function loadWorker() {
return { disposeWorker };
}
function validateWorker(
toggleEnabled: boolean,
matcher: typeof routeMatcher
): asserts matcher is comlink.Remote<RouteGroupsMatcher> {
if (!toggleEnabled) {
throw new Error('Matching routes preview is disabled');
}
if (!routeMatcher) {
throw new Error('Route Matcher has not been initialized');
}
}
export function useRouteGroupsMatcher() {
const workerPreviewEnabled = useEnabled(AlertingFeature.NotificationPoliciesV2MatchingInstances);
@ -58,13 +72,7 @@ export function useRouteGroupsMatcher() {
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');
}
validateWorker(workerPreviewEnabled, routeMatcher);
const startTime = performance.now();
@ -84,5 +92,27 @@ export function useRouteGroupsMatcher() {
[workerPreviewEnabled]
);
return { getRouteGroupsMap };
const matchInstancesToRoute = useCallback(
async (rootRoute: RouteWithID, instancesToMatch: Labels[]) => {
validateWorker(workerPreviewEnabled, routeMatcher);
const startTime = performance.now();
const result = await routeMatcher.matchInstancesToRoute(rootRoute, instancesToMatch);
const timeSpent = performance.now() - startTime;
logInfo(`Instances Matched in ${timeSpent} ms`, {
matchingTime: timeSpent.toString(),
instancesToMatchCount: instancesToMatch.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, matchInstancesToRoute };
}

@ -15,6 +15,7 @@ exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form valu
"is_paused": false,
"no_data_state": "NoData",
"title": "",
"uid": "",
},
"labels": {
"": "",
@ -53,6 +54,7 @@ exports[`formValuesToRulerGrafanaRuleDTO should not save both instant and range
"is_paused": false,
"no_data_state": "NoData",
"title": "",
"uid": "",
},
"labels": {
"": "",

@ -3,7 +3,6 @@ import { uniqueId } from 'lodash';
import { SelectableValue } from '@grafana/data';
import { MatcherOperator, ObjectMatcher, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import { safeParseDurationstr } from '../components/rules/EditRuleGroupModal';
import { FormAmRoute } from '../types/amroutes';
import { MatcherFieldValue } from '../types/silence-form';
@ -11,7 +10,7 @@ import { matcherToMatcherField } from './alertmanager';
import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
import { normalizeMatchers, parseMatcher } from './matchers';
import { findExistingRoute } from './routeTree';
import { isValidPrometheusDuration } from './time';
import { isValidPrometheusDuration, safeParseDurationstr } from './time';
const matchersToArrayFieldMatchers = (
matchers: Record<string, string> | undefined,
@ -230,6 +229,14 @@ export function promDurationValidator(duration: string) {
return isValidPrometheusDuration(duration) || 'Invalid duration format. Must be {number}{time_unit}';
}
// function to convert ObjectMatchers to a array of strings
export const objectMatchersToString = (matchers: ObjectMatcher[]): string[] => {
return matchers.map((matcher) => {
const [name, operator, value] = matcher;
return `${name}${operator}${value}`;
});
};
export const repeatIntervalValidator = (repeatInterval: string, groupInterval: string) => {
if (repeatInterval.length === 0) {
return true;

@ -1,4 +1,5 @@
import { getNumberEvaluationsToStartAlerting } from './EditRuleGroupModal';
import { getNumberEvaluationsToStartAlerting } from './rules';
describe('getNumberEvaluationsToStartAlerting method', () => {
it('should return 0 in case of invalid data', () => {
expect(getNumberEvaluationsToStartAlerting('sd', 'ksdh')).toBe(0);

@ -18,3 +18,17 @@ export function arrayLabelsToObject(labels: Label[]): Labels {
});
return labelsObject;
}
export function arrayKeyValuesToObject(
labels: Array<{
key: string;
value: string;
}>
): Labels {
const labelsObject: Labels = {};
labels.forEach((label) => {
label.key && (labelsObject[label.key] = label.value);
});
return labelsObject;
}

@ -41,13 +41,13 @@ describe('findMatchingRoutes', () => {
it('should match root route with no matching labels', () => {
const matches = findMatchingRoutes(policies, []);
expect(matches).toHaveLength(1);
expect(matches[0]).toHaveProperty('receiver', 'ROOT');
expect(matches[0].route).toHaveProperty('receiver', 'ROOT');
});
it('should match parent route with no matching children', () => {
const matches = findMatchingRoutes(policies, [['team', 'operations']]);
expect(matches).toHaveLength(1);
expect(matches[0]).toHaveProperty('receiver', 'A');
expect(matches[0].route).toHaveProperty('receiver', 'A');
});
it('should match child route of matching parent', () => {
@ -56,28 +56,28 @@ describe('findMatchingRoutes', () => {
['region', 'europe'],
]);
expect(matches).toHaveLength(1);
expect(matches[0]).toHaveProperty('receiver', 'B1');
expect(matches[0].route).toHaveProperty('receiver', 'B1');
});
it('should match simple policy', () => {
const matches = findMatchingRoutes(policies, [['foo', 'bar']]);
expect(matches).toHaveLength(1);
expect(matches[0]).toHaveProperty('receiver', 'C');
expect(matches[0].route).toHaveProperty('receiver', 'C');
});
it('should match catch-all route', () => {
const policiesWithAll = {
const policiesWithAll: Route = {
...policies,
routes: [CATCH_ALL_ROUTE, ...(policies.routes ?? [])],
};
const matches = findMatchingRoutes(policiesWithAll, []);
expect(matches).toHaveLength(1);
expect(matches[0]).toHaveProperty('receiver', 'ALL');
expect(matches[0].route).toHaveProperty('receiver', 'ALL');
});
it('should match multiple routes with continue', () => {
const policiesWithAll = {
const policiesWithAll: Route = {
...policies,
routes: [
{
@ -90,8 +90,8 @@ describe('findMatchingRoutes', () => {
const matches = findMatchingRoutes(policiesWithAll, [['foo', 'bar']]);
expect(matches).toHaveLength(2);
expect(matches[0]).toHaveProperty('receiver', 'ALL');
expect(matches[1]).toHaveProperty('receiver', 'C');
expect(matches[0].route).toHaveProperty('receiver', 'ALL');
expect(matches[1].route).toHaveProperty('receiver', 'C');
});
it('should not match grandchild routes with same labels as parent', () => {
@ -117,7 +117,7 @@ describe('findMatchingRoutes', () => {
const matches = findMatchingRoutes(policies, [['foo', 'bar']]);
expect(matches).toHaveLength(1);
expect(matches[0]).toHaveProperty('receiver', 'PARENT');
expect(matches[0].route).toHaveProperty('receiver', 'PARENT');
});
});
@ -242,6 +242,20 @@ describe('getInheritedProperties()', () => {
const childInherited = getInheritedProperties(parent, child, { group_wait: '60s' });
expect(childInherited).not.toHaveProperty('group_wait');
});
it('should inherit if the child property is an empty string', () => {
const parent: Route = {
receiver: 'PARENT',
};
const child: Route = {
receiver: '',
group_wait: '30s',
};
const childInherited = getInheritedProperties(parent, child);
expect(childInherited).toHaveProperty('receiver', 'PARENT');
});
});
});

@ -1,28 +1,121 @@
import { isArray, merge, pick, reduce } from 'lodash';
import { AlertmanagerGroup, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import {
AlertmanagerGroup,
MatcherOperator,
ObjectMatcher,
Route,
RouteWithID,
} from 'app/plugins/datasource/alertmanager/types';
import { Labels } from 'app/types/unified-alerting-dto';
import { Label, normalizeMatchers } from './matchers';
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);
}
interface LabelMatchResult {
match: boolean;
matchers: ObjectMatcher[];
}
interface MatchingResult {
matches: boolean;
details: Map<ObjectMatcher, Label[]>;
labelsMatch: Map<Label, LabelMatchResult>;
}
// check if every matcher returns "true" for the set of labels
function matchLabels(matchers: ObjectMatcher[], labels: Label[]): MatchingResult {
const details = new Map<ObjectMatcher, Label[]>();
// If a policy has no matchers it still can be a match, hence matchers can be empty and match can be true
// So we cannot use empty array of matchers as an indicator of no match
const labelsMatch = new Map<Label, { match: boolean; matchers: ObjectMatcher[] }>(
labels.map((label) => [label, { match: false, matchers: [] }])
);
const matches = matchers.every((matcher) => {
const matchingLabels = labels.filter((label) => isLabelMatch(matcher, label));
matchingLabels.forEach((label) => {
const labelMatch = labelsMatch.get(label);
// The condition is just to satisfy TS. The map should have all the labels due to the previous map initialization
if (labelMatch) {
labelMatch.match = true;
labelMatch.matchers.push(matcher);
}
});
if (matchingLabels.length === 0) {
return false;
}
import { Label, normalizeMatchers, labelsMatchObjectMatchers } from './matchers';
details.set(matcher, matchingLabels);
return matchingLabels.length > 0;
});
return { matches, details, labelsMatch };
}
export interface AlertInstanceMatch {
instance: Labels;
matchDetails: Map<ObjectMatcher, Labels>;
labelsMatch: Map<Label, LabelMatchResult>;
}
export interface RouteMatchResult<T extends Route> {
route: T;
details: Map<ObjectMatcher, Label[]>;
labelsMatch: Map<Label, LabelMatchResult>;
}
// Match does a depth-first left-to-right search through the route tree
// and returns the matching routing nodes.
function findMatchingRoutes(root: Route, labels: Label[]): Route[] {
const matches: Route[] = [];
// If the current node is not a match, return nothing
// const normalizedMatchers = normalizeMatchers(root);
// Normalization should have happened earlier in the code
function findMatchingRoutes<T extends Route>(root: T, labels: Label[]): Array<RouteMatchResult<T>> {
let matches: Array<RouteMatchResult<T>> = [];
// If the current node is not a match, return nothing
// const normalizedMatchers = normalizeMatchers(root);
// Normalization should have happened earlier in the code
if (!root.object_matchers || !labelsMatchObjectMatchers(root.object_matchers, labels)) {
const matchResult = matchLabels(root.object_matchers ?? [], labels);
if (!matchResult.matches) {
return [];
}
// If the current node matches, recurse through child nodes
if (root.routes) {
for (const child of root.routes) {
const matchingChildren = findMatchingRoutes(child, labels);
matches.push(...matchingChildren);
for (let index = 0; index < root.routes.length; index++) {
let child = root.routes[index];
let matchingChildren = findMatchingRoutes(child, labels);
// TODO how do I solve this typescript thingy? It looks correct to me /shrug
// @ts-ignore
matches = matches.concat(matchingChildren);
// we have matching children and we don't want to continue, so break here
if (matchingChildren.length && !child.continue) {
break;
@ -32,7 +125,7 @@ function findMatchingRoutes(root: Route, labels: Label[]): Route[] {
// If no child nodes were matches, the current node itself is a match.
if (matches.length === 0) {
matches.push(root);
matches.push({ route: root, details: matchResult.details, labelsMatch: matchResult.labelsMatch });
}
return matches;
@ -69,7 +162,7 @@ function findMatchingAlertGroups(
// find matching alerts in the current group
const matchingAlerts = group.alerts.filter((alert) => {
const labels = Object.entries(alert.labels);
return findMatchingRoutes(routeTree, labels).some((matchingRoute) => matchingRoute === route);
return findMatchingRoutes(routeTree, labels).some((matchingRoute) => matchingRoute.route === route);
});
// if the groups has any alerts left after matching, add it to the results
@ -110,17 +203,23 @@ function getInheritedProperties(
const inherited = reduce(
inheritableProperties,
(inheritedProperties: Partial<Route> = {}, parentValue, property) => {
const parentHasValue = parentValue !== undefined;
// @ts-ignore
const inheritFromParent = parentValue !== undefined && childRoute[property] === undefined;
const inheritFromParentUndefined = parentHasValue && childRoute[property] === undefined;
// @ts-ignore
const inheritFromParentEmptyString = parentHasValue && childRoute[property] === '';
const inheritEmptyGroupByFromParent =
property === 'group_by' && isArray(childRoute[property]) && childRoute[property]?.length === 0;
property === 'group_by' &&
parentHasValue &&
isArray(childRoute[property]) &&
childRoute[property]?.length === 0;
if (inheritFromParent) {
// @ts-ignore
inheritedProperties[property] = parentValue;
}
const inheritFromParent =
inheritFromParentUndefined || inheritFromParentEmptyString || inheritEmptyGroupByFromParent;
if (inheritEmptyGroupByFromParent) {
if (inheritFromParent) {
// @ts-ignore
inheritedProperties[property] = parentValue;
}

@ -1,5 +1,6 @@
import {
DataQuery,
DataSourceInstanceSettings,
DataSourceRef,
getDefaultRelativeTimeRange,
IntervalValues,
@ -7,14 +8,13 @@ import {
RelativeTimeRange,
ScopedVars,
TimeRange,
DataSourceInstanceSettings,
} from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend';
import { DataSourceJsonData } from '@grafana/schema';
import { getNextRefIdChar } from 'app/core/utils/query';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { ExpressionQuery, ExpressionQueryType, ExpressionDatasourceUID } from 'app/features/expressions/types';
import { ExpressionDatasourceUID, ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types';
import { LokiQuery } from 'app/plugins/datasource/loki/types';
import { PromQuery } from 'app/plugins/datasource/prometheus/types';
import { RuleWithLocation } from 'app/types/unified-alerting';
@ -31,7 +31,6 @@ import {
} from 'app/types/unified-alerting-dto';
import { EvalFunction } from '../../state/alertDef';
import { MINUTE } from '../components/rule-editor/AlertRuleForm';
import { RuleFormType, RuleFormValues } from '../types/rule-form';
import { getRulesAccess } from './access-control';
@ -43,11 +42,14 @@ import { parseInterval } from './time';
export type PromOrLokiQuery = PromQuery | LokiQuery;
export const MINUTE = '1m';
export const getDefaultFormValues = (): RuleFormValues => {
const { canCreateGrafanaRules, canCreateCloudRules } = getRulesAccess();
return Object.freeze({
name: '',
uid: '',
labels: [{ key: '', value: '' }],
annotations: [
{ key: Annotation.summary, value: '' },
@ -106,6 +108,7 @@ export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): Postabl
return {
grafana_alert: {
title: name,
uid: values.uid,
condition,
no_data_state: noDataState,
exec_err_state: execErrState,
@ -131,6 +134,7 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
return {
...defaultFormValues,
name: ga.title,
uid: ga.uid,
type: RuleFormType.grafana,
group: group.name,
evaluateEvery: group.interval || defaultFormValues.evaluateEvery,
@ -141,7 +145,7 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
condition: ga.condition,
annotations: listifyLabelsOrAnnotations(rule.annotations),
labels: listifyLabelsOrAnnotations(rule.labels),
folder: { title: namespace, id: ga.namespace_id },
folder: { title: namespace, uid: ga.namespace_uid },
isPaused: ga.is_paused,
};
} else {
@ -416,14 +420,14 @@ export const panelToRuleFormValues = async (
queries.push(thresholdExpression);
}
const { folderId, folderTitle } = dashboard.meta;
const { folderTitle, folderUid } = dashboard.meta;
const formValues = {
type: RuleFormType.grafana,
folder:
folderId && folderTitle
folderUid && folderTitle
? {
id: folderId,
uid: folderUid,
title: folderTitle,
}
: undefined,

@ -35,6 +35,7 @@ import { RuleHealth } from '../search/rulesSearchParser';
import { RULER_NOT_SUPPORTED_MSG } from './constants';
import { getRulesSourceName } from './datasource';
import { AsyncRequestState } from './redux';
import { safeParseDurationstr } from './time';
export function isAlertingRule(rule: Rule | undefined): rule is AlertingRule {
return typeof rule === 'object' && rule.type === PromRuleType.Alerting;
@ -205,3 +206,46 @@ export function getRuleName(rule: RulerRuleDTO) {
return '';
}
export interface AlertInfo {
alertName: string;
forDuration: string;
evaluationsToFire: number;
}
export const getAlertInfo = (alert: RulerRuleDTO, currentEvaluation: string): AlertInfo => {
const emptyAlert: AlertInfo = {
alertName: '',
forDuration: '0s',
evaluationsToFire: 0,
};
if (isGrafanaRulerRule(alert)) {
return {
alertName: alert.grafana_alert.title,
forDuration: alert.for,
evaluationsToFire: getNumberEvaluationsToStartAlerting(alert.for, currentEvaluation),
};
}
if (isAlertingRulerRule(alert)) {
return {
alertName: alert.alert,
forDuration: alert.for ?? '1m',
evaluationsToFire: getNumberEvaluationsToStartAlerting(alert.for ?? '1m', currentEvaluation),
};
}
return emptyAlert;
};
export const getNumberEvaluationsToStartAlerting = (forDuration: string, currentEvaluation: string) => {
const evalNumberMs = safeParseDurationstr(currentEvaluation);
const forNumber = safeParseDurationstr(forDuration);
if (forNumber === 0 && evalNumberMs !== 0) {
return 1;
}
if (evalNumberMs === 0) {
return 0;
} else {
const evaluationsBeforeCeil = forNumber / evalNumberMs;
return evaluationsBeforeCeil < 1 ? 0 : Math.ceil(forNumber / evalNumberMs) + 1;
}
};

@ -95,6 +95,14 @@ export function parsePrometheusDuration(duration: string): number {
return totalDuration;
}
export const safeParseDurationstr = (duration: string): number => {
try {
return parsePrometheusDuration(duration);
} catch (e) {
return 0;
}
};
export const isNullDate = (date: string) => {
return date.includes('0001-01-01T00');
};

Loading…
Cancel
Save