mirror of
https://github.com/grafana/grafana.git
synced 2026-07-30 08:18:10 -05:00
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>
This commit is contained in:
co-authored by
Konrad Lalik
Sonia Aguilar
Gilles De Mey
parent
1a985c488c
commit
9a252c763a
@@ -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);
|
||||
|
||||
return groupsMap;
|
||||
const getRouteGroupsMap = useCallback(async (route: RouteWithID, groups: AlertmanagerGroup[]) => {
|
||||
return routeGroupsMatcher.getRouteGroupsMap(route, groups);
|
||||
}, []);
|
||||
|
||||
return { getRouteGroupsMap };
|
||||
const matchInstancesToRoute = useCallback(async (rootRoute: RouteWithID, instancesToMatch: Labels[]) => {
|
||||
return routeGroupsMatcher.matchInstancesToRoute(rootRoute, instancesToMatch);
|
||||
}, []);
|
||||
|
||||
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) => {
|
||||
|
||||
+6
-5
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+31
@@ -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};
|
||||
`,
|
||||
});
|
||||
+415
@@ -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/);
|
||||
});
|
||||
});
|
||||
+146
@@ -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)};
|
||||
`,
|
||||
});
|
||||
+117
@@ -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)};
|
||||
`,
|
||||
});
|
||||
+238
@@ -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)};
|
||||
`,
|
||||
});
|
||||
+176
@@ -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;
|
||||
}
|
||||
} `,
|
||||
});
|
||||
+26
@@ -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;
|
||||
}
|
||||
+72
@@ -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,
|
||||
};
|
||||
};
|
||||
+28
@@ -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;
|
||||
|
||||
+2
-1
@@ -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, labelsMatchObjectMatchers } from './matchers';
|
||||
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;
|
||||
}
|
||||
|
||||
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,21 +203,27 @@ 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;
|
||||
|
||||
const inheritFromParent =
|
||||
inheritFromParentUndefined || inheritFromParentEmptyString || inheritEmptyGroupByFromParent;
|
||||
|
||||
if (inheritFromParent) {
|
||||
// @ts-ignore
|
||||
inheritedProperties[property] = parentValue;
|
||||
}
|
||||
|
||||
if (inheritEmptyGroupByFromParent) {
|
||||
// @ts-ignore
|
||||
inheritedProperties[property] = parentValue;
|
||||
}
|
||||
|
||||
return inheritedProperties;
|
||||
},
|
||||
{}
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user