mirror of
https://github.com/grafana/grafana.git
synced 2025-02-16 18:34:52 -06:00
Alerting: Simplify routing in alert form - part1 (#78040)
* Add routing option tabs * Use alertingSimplifiedRouting feature toggle * Move simplified routing tab to a separate component:SimplifiedRouting * Populate contact point selector with the right values * Show alert manager icons * Fix descriptions * Remove clear button on ContactPointSelector and save updated reducer state in the form * Load contact points and manual option from rule data in RuleFormValues * make contact point selector not clearable * Refactor * Add link to contact points view * Move ContactPointSelector to a separate file * Refactor: move hoook useReceiversMetadataMapByName to a separate file * Update Need more info texts * Address some PR review comments * Use useContactPointsWithStatus hook and wrap each ContacPointSelector with AlertmanagerProvider * use getAlertManagerDataSourcesByPermission instead of useGetAlertManagersMetadata in NotificationPreview * Update enum * Remove css style * remove console * update contact point selector * file cleanup * adds summary as description * Update text in manual tab * Fix preview routing not checking if alert manager can handle grafana alerts * Fix typo * remove unused location form field * fix prettier * fix test * Remove unused location form field from AlertRuleNameInput * Only use internal AlertManager for now --------- Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
parent
72759be6ec
commit
2acf153a26
@ -2266,16 +2266,6 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Styles should be written using objects.", "0"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "1"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-editor/LabelsField.tsx:5381": [
|
||||
[0, 0, 0, "Styles should be written using objects.", "0"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "1"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "2"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "3"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "4"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "5"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "6"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "7"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-editor/NeedHelpInfo.tsx:5381": [
|
||||
[0, 0, 0, "Styles should be written using objects.", "0"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "1"]
|
||||
|
@ -10,7 +10,9 @@ const fetch = jest.fn();
|
||||
|
||||
jest.mock('./prometheus');
|
||||
jest.mock('./ruler');
|
||||
jest.mock('app/core/services/context_srv', () => {});
|
||||
jest.mock('app/core/services/context_srv', () => ({
|
||||
contextSrv: jest.fn(),
|
||||
}));
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
getBackendSrv: () => ({ fetch }),
|
||||
|
@ -325,7 +325,7 @@ export const ContactPoint = ({
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className={styles.integrationWrapper}>
|
||||
<ContactPointReceiverSummary receivers={receivers} />
|
||||
</div>
|
||||
)}
|
||||
@ -493,35 +493,32 @@ type ContactPointReceiverSummaryProps = {
|
||||
* This summary is used when we're dealing with non-Grafana managed alertmanager since they
|
||||
* don't have any metadata worth showing other than a summary of what types are configured for the contact point
|
||||
*/
|
||||
const ContactPointReceiverSummary = ({ receivers }: ContactPointReceiverSummaryProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
export const ContactPointReceiverSummary = ({ receivers }: ContactPointReceiverSummaryProps) => {
|
||||
const countByType = groupBy(receivers, (receiver) => receiver.type);
|
||||
|
||||
return (
|
||||
<div className={styles.integrationWrapper}>
|
||||
<Stack direction="column" gap={0}>
|
||||
<Stack direction="row" alignItems="center" gap={1}>
|
||||
{Object.entries(countByType).map(([type, receivers], index) => {
|
||||
const iconName = INTEGRATION_ICONS[type];
|
||||
const receiverName = receiverTypeNames[type] ?? upperFirst(type);
|
||||
const isLastItem = size(countByType) - 1 === index;
|
||||
<Stack direction="column" gap={0}>
|
||||
<Stack direction="row" alignItems="center" gap={1}>
|
||||
{Object.entries(countByType).map(([type, receivers], index) => {
|
||||
const iconName = INTEGRATION_ICONS[type];
|
||||
const receiverName = receiverTypeNames[type] ?? upperFirst(type);
|
||||
const isLastItem = size(countByType) - 1 === index;
|
||||
|
||||
return (
|
||||
<React.Fragment key={type}>
|
||||
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||
{iconName && <Icon name={iconName} />}
|
||||
<Text variant="body" color="primary">
|
||||
{receiverName}
|
||||
{receivers.length > 1 && <> ({receivers.length})</>}
|
||||
</Text>
|
||||
</Stack>
|
||||
{!isLastItem && '⋅'}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
return (
|
||||
<React.Fragment key={type}>
|
||||
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||
{iconName && <Icon name={iconName} />}
|
||||
<Text variant="body">
|
||||
{receiverName}
|
||||
{receivers.length > 1 && <> ({receivers.length})</>}
|
||||
</Text>
|
||||
</Stack>
|
||||
{!isLastItem && '⋅'}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -18,7 +18,7 @@ export const AlertRuleNameInput = () => {
|
||||
register,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useFormContext<RuleFormValues & { location?: string }>();
|
||||
} = useFormContext<RuleFormValues>();
|
||||
|
||||
const ruleFormType = watch('type');
|
||||
const entityName = ruleFormType === RuleFormType.cloudRecording ? 'recording rule' : 'alert rule';
|
||||
|
@ -3,19 +3,7 @@ import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FieldArrayMethodProps, useFieldArray, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import {
|
||||
Button,
|
||||
Field,
|
||||
InlineLabel,
|
||||
Label,
|
||||
useStyles2,
|
||||
Text,
|
||||
Tooltip,
|
||||
Icon,
|
||||
Input,
|
||||
LoadingPlaceholder,
|
||||
Stack,
|
||||
} from '@grafana/ui';
|
||||
import { Button, Field, InlineLabel, Input, LoadingPlaceholder, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
import { useDispatch } from 'app/types';
|
||||
|
||||
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
|
||||
@ -23,6 +11,8 @@ import { fetchRulerRulesIfNotFetchedYet } from '../../state/actions';
|
||||
import { RuleFormValues } from '../../types/rule-form';
|
||||
import AlertLabelDropdown from '../AlertLabelDropdown';
|
||||
|
||||
import { NeedHelpInfo } from './NeedHelpInfo';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
dataSourceName?: string | null;
|
||||
@ -271,23 +261,20 @@ const LabelsField: FC<Props> = ({ dataSourceName }) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label description="A set of default labels is automatically added. Add additional labels as required.">
|
||||
<Stack gap={0.5} alignItems="center">
|
||||
<Text variant="bodySmall" color="primary">
|
||||
Labels
|
||||
<Stack direction="column" gap={1}>
|
||||
<Text element="h5">Labels</Text>
|
||||
<Stack direction={'row'} gap={1}>
|
||||
<Text variant="bodySmall" color="secondary">
|
||||
Add labels to your rule to annotate your rules, ease searching, or route to a notification policy.
|
||||
</Text>
|
||||
<Tooltip
|
||||
content={
|
||||
<div>
|
||||
The dropdown only displays labels that you have previously used for alerts. Select a label from the
|
||||
dropdown or type in a new one.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Icon className={styles.icon} name="info-circle" size="sm" />
|
||||
</Tooltip>
|
||||
<NeedHelpInfo
|
||||
contentText="The dropdown only displays labels that you have previously used for alerts.
|
||||
Select a label from the options below or type in a new one."
|
||||
title="Labels"
|
||||
/>
|
||||
</Stack>
|
||||
</Label>
|
||||
</Stack>
|
||||
<div className={styles.labelsContainer}></div>
|
||||
{dataSourceName ? <LabelsWithSuggestions dataSourceName={dataSourceName} /> : <LabelsWithoutSuggestions />}
|
||||
</div>
|
||||
);
|
||||
@ -295,47 +282,48 @@ const LabelsField: FC<Props> = ({ dataSourceName }) => {
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
icon: css`
|
||||
margin-right: ${theme.spacing(0.5)};
|
||||
`,
|
||||
flexColumn: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`,
|
||||
flexRow: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
|
||||
& + button {
|
||||
margin-left: ${theme.spacing(0.5)};
|
||||
}
|
||||
`,
|
||||
deleteLabelButton: css`
|
||||
margin-left: ${theme.spacing(0.5)};
|
||||
align-self: flex-start;
|
||||
`,
|
||||
addLabelButton: css`
|
||||
flex-grow: 0;
|
||||
align-self: flex-start;
|
||||
`,
|
||||
centerAlignRow: css`
|
||||
align-items: baseline;
|
||||
`,
|
||||
equalSign: css`
|
||||
align-self: flex-start;
|
||||
width: 28px;
|
||||
justify-content: center;
|
||||
margin-left: ${theme.spacing(0.5)};
|
||||
`,
|
||||
labelInput: css`
|
||||
width: 175px;
|
||||
margin-bottom: -${theme.spacing(1)};
|
||||
|
||||
& + & {
|
||||
margin-left: ${theme.spacing(1)};
|
||||
}
|
||||
`,
|
||||
icon: css({
|
||||
marginRight: theme.spacing(0.5),
|
||||
}),
|
||||
flexColumn: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}),
|
||||
flexRow: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
'& + button': {
|
||||
marginLeft: theme.spacing(0.5),
|
||||
},
|
||||
}),
|
||||
deleteLabelButton: css({
|
||||
marginLeft: theme.spacing(0.5),
|
||||
alignSelf: 'flex-start',
|
||||
}),
|
||||
addLabelButton: css({
|
||||
flexGrow: 0,
|
||||
alignSelf: 'flex-start',
|
||||
}),
|
||||
centerAlignRow: css({
|
||||
alignItems: 'baseline',
|
||||
}),
|
||||
equalSign: css({
|
||||
alignSelf: 'flex-start',
|
||||
width: '28px',
|
||||
justifyContent: 'center',
|
||||
marginLeft: theme.spacing(0.5),
|
||||
}),
|
||||
labelInput: css({
|
||||
width: '175px',
|
||||
marginBottom: `-${theme.spacing(1)}`,
|
||||
'& + &': {
|
||||
marginLeft: theme.spacing(1),
|
||||
},
|
||||
}),
|
||||
labelsContainer: css({
|
||||
marginBottom: theme.spacing(3),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -1,7 +1,10 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { Icon, Text, Stack } from '@grafana/ui';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Icon, RadioButtonGroup, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||
@ -9,80 +12,53 @@ import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||
import LabelsField from './LabelsField';
|
||||
import { NeedHelpInfo } from './NeedHelpInfo';
|
||||
import { RuleEditorSection } from './RuleEditorSection';
|
||||
import { SimplifiedRouting } from './alert-rule-form/simplifiedRouting/SimplifiedRouting';
|
||||
import { NotificationPreview } from './notificaton-preview/NotificationPreview';
|
||||
|
||||
type NotificationsStepProps = {
|
||||
alertUid?: string;
|
||||
};
|
||||
export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => {
|
||||
const { watch } = useFormContext<RuleFormValues & { location?: string }>();
|
||||
|
||||
const [type, labels, queries, condition, folder, alertName] = watch([
|
||||
enum RoutingOptions {
|
||||
NotificationPolicy = 'notification policy',
|
||||
ContactPoint = 'contact point',
|
||||
}
|
||||
|
||||
export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => {
|
||||
const { watch, setValue } = useFormContext<RuleFormValues>();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const [type, labels, queries, condition, folder, alertName, manualRouting] = watch([
|
||||
'type',
|
||||
'labels',
|
||||
'queries',
|
||||
'condition',
|
||||
'folder',
|
||||
'name',
|
||||
'manualRouting',
|
||||
]);
|
||||
|
||||
const dataSourceName = watch('dataSourceName') ?? GRAFANA_RULES_SOURCE_NAME;
|
||||
|
||||
const shouldRenderPreview = type === RuleFormType.grafana;
|
||||
|
||||
const NotificationsStepDescription = () => {
|
||||
return (
|
||||
<Stack direction="row" gap={0.5} alignItems="baseline">
|
||||
<Text variant="bodySmall" color="secondary">
|
||||
Add custom labels to change the way your notifications are routed.
|
||||
</Text>
|
||||
<NeedHelpInfo
|
||||
contentText={
|
||||
<Stack gap={1}>
|
||||
<Stack direction="row" gap={0}>
|
||||
<>
|
||||
Firing alert rule instances are routed to notification policies based on matching labels. All alert
|
||||
rules and instances, irrespective of their labels, match the default notification policy. If there are
|
||||
no nested policies, or no nested policies match the labels in the alert rule or alert instance, then
|
||||
the default notification policy is the matching policy.
|
||||
</>
|
||||
<a
|
||||
href={`https://grafana.com/docs/grafana/latest/alerting/fundamentals/notification-policies/notifications/`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Text color="link">
|
||||
Read about notification routing. <Icon name="external-link-alt" />
|
||||
</Text>
|
||||
</a>
|
||||
</Stack>
|
||||
<Stack direction="row" gap={0}>
|
||||
<>
|
||||
Custom labels change the way your notifications are routed. First, add labels to your alert rule and
|
||||
then connect them to your notification policy by adding label matchers.
|
||||
</>
|
||||
<a
|
||||
href={`https://grafana.com/docs/grafana/latest/alerting/fundamentals/annotation-label/`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Text color="link">
|
||||
Read about Labels and annotations. <Icon name="external-link-alt" />
|
||||
</Text>
|
||||
</a>
|
||||
</Stack>
|
||||
</Stack>
|
||||
}
|
||||
title="Notification routing"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
const routingOptions = [
|
||||
{ label: 'Manually select contact point', value: RoutingOptions.ContactPoint },
|
||||
{ label: 'Auto-select contact point', value: RoutingOptions.NotificationPolicy },
|
||||
];
|
||||
|
||||
const onRoutingOptionChange = (option: RoutingOptions) => {
|
||||
setValue('manualRouting', option === RoutingOptions.ContactPoint);
|
||||
};
|
||||
|
||||
const simplifiedRoutingToggleEnabled = config.featureToggles.alertingSimplifiedRouting ?? false;
|
||||
|
||||
const shouldAllowSimplifiedRouting = type === RuleFormType.grafana && simplifiedRoutingToggleEnabled;
|
||||
|
||||
return (
|
||||
<RuleEditorSection
|
||||
stepNo={type === RuleFormType.cloudRecording ? 4 : 5}
|
||||
title={type === RuleFormType.cloudRecording ? 'Add labels' : 'Configure notifications'}
|
||||
title={type === RuleFormType.cloudRecording ? 'Add labels' : 'Labels and notifications'}
|
||||
description={
|
||||
<Stack direction="row" gap={0.5} alignItems="baseline">
|
||||
{type === RuleFormType.cloudRecording ? (
|
||||
@ -90,23 +66,198 @@ export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => {
|
||||
Add labels to help you better manage your recording rules
|
||||
</Text>
|
||||
) : (
|
||||
<NotificationsStepDescription />
|
||||
shouldAllowSimplifiedRouting && (
|
||||
<Text variant="bodySmall" color="secondary">
|
||||
Select who should receive a notification when an alert rule fires.
|
||||
</Text>
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
fullWidth
|
||||
>
|
||||
<LabelsField dataSourceName={dataSourceName} />
|
||||
{shouldRenderPreview && (
|
||||
<NotificationPreview
|
||||
alertQueries={queries}
|
||||
customLabels={labels}
|
||||
condition={condition}
|
||||
folder={folder}
|
||||
alertName={alertName}
|
||||
alertUid={alertUid}
|
||||
/>
|
||||
{shouldAllowSimplifiedRouting && (
|
||||
<div className={styles.configureNotifications}>
|
||||
<Text element="h5">Configure notifications</Text>
|
||||
<Text variant="bodySmall" color="secondary">
|
||||
Select who should receive a notification when an alert rule fires.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
<RuleEditorSectionBody />
|
||||
</RuleEditorSection>
|
||||
);
|
||||
|
||||
/**
|
||||
* This component is used to render the section body of the NotificationsStep, depending on the routing option selected.
|
||||
* If simplified routing is not enabled, it will render the NotificationPreview component.
|
||||
* If simplified routing is enabled, it will render the switch between the manual routing and the notification policy routing.
|
||||
*
|
||||
*/
|
||||
function RuleEditorSectionBody() {
|
||||
if (!shouldAllowSimplifiedRouting) {
|
||||
return (
|
||||
<>
|
||||
{shouldRenderPreview && (
|
||||
<NotificationPreview
|
||||
alertQueries={queries}
|
||||
customLabels={labels}
|
||||
condition={condition}
|
||||
folder={folder}
|
||||
alertName={alertName}
|
||||
alertUid={alertUid}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack direction="column">
|
||||
<Stack direction="column">
|
||||
<RadioButtonGroup
|
||||
options={routingOptions}
|
||||
value={manualRouting ? RoutingOptions.ContactPoint : RoutingOptions.NotificationPolicy}
|
||||
onChange={onRoutingOptionChange}
|
||||
className={styles.routingOptions}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<RoutingOptionDescription manualRouting={manualRouting} />
|
||||
|
||||
{manualRouting ? (
|
||||
<div className={styles.simplifiedRouting}>
|
||||
<SimplifiedRouting />
|
||||
</div>
|
||||
) : (
|
||||
shouldRenderPreview && (
|
||||
<NotificationPreview
|
||||
alertQueries={queries}
|
||||
customLabels={labels}
|
||||
condition={condition}
|
||||
folder={folder}
|
||||
alertName={alertName}
|
||||
alertUid={alertUid}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Auxiliar components to build the texts and descriptions in the NotificationsStep
|
||||
function NeedHelpInfoForNotificationPolicy() {
|
||||
return (
|
||||
<NeedHelpInfo
|
||||
contentText={
|
||||
<Stack gap={1} direction="column">
|
||||
<Stack direction="column" gap={0}>
|
||||
<>
|
||||
Firing alert rule instances are routed to notification policies based on matching labels. All alert rules
|
||||
and instances, irrespective of their labels, match the default notification policy. If there are no nested
|
||||
policies, or no nested policies match the labels in the alert rule or alert instance, then the default
|
||||
notification policy is the matching policy.
|
||||
</>
|
||||
<a
|
||||
href={`https://grafana.com/docs/grafana/latest/alerting/fundamentals/notification-policies/notifications/`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Text color="link">
|
||||
Read about notification routing. <Icon name="external-link-alt" />
|
||||
</Text>
|
||||
</a>
|
||||
</Stack>
|
||||
<Stack direction="column" gap={0}>
|
||||
<>
|
||||
Custom labels change the way your notifications are routed. First, add labels to your alert rule and then
|
||||
connect them to your notification policy by adding label matchers.
|
||||
</>
|
||||
<a
|
||||
href={`https://grafana.com/docs/grafana/latest/alerting/fundamentals/annotation-label/`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Text color="link">
|
||||
Read about Labels and annotations. <Icon name="external-link-alt" />
|
||||
</Text>
|
||||
</a>
|
||||
</Stack>
|
||||
</Stack>
|
||||
}
|
||||
title="Notification routing"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NeedHelpInfoForContactpoint() {
|
||||
return (
|
||||
<NeedHelpInfo
|
||||
contentText={
|
||||
<>
|
||||
Select a contact point to notify all recipients in it.
|
||||
<br />
|
||||
<br />
|
||||
Notifications for firing alert instances are grouped based on folder and alert rule name.
|
||||
<br />
|
||||
The waiting time until the initial notification is sent for a new group created by an incoming alert is 30
|
||||
seconds.
|
||||
<br />
|
||||
The waiting time to send a batch of new alerts for that group after the first notification was sent is 5
|
||||
minutes.
|
||||
<br />
|
||||
The waiting time to resend an alert after they have successfully been sent is 4 hours.
|
||||
<br />
|
||||
Grouping and wait time values are defined in your default notification policy.
|
||||
</>
|
||||
}
|
||||
// todo: update the link with the new documentation about simplified routing
|
||||
externalLink="`https://grafana.com/docs/grafana/latest/alerting/fundamentals/notification-policies/notifications/`"
|
||||
linkText="Read more about notifiying contact points"
|
||||
title="Notify contact points"
|
||||
/>
|
||||
);
|
||||
}
|
||||
interface NotificationsStepDescriptionProps {
|
||||
manualRouting: boolean;
|
||||
}
|
||||
|
||||
export const RoutingOptionDescription = ({ manualRouting }: NotificationsStepDescriptionProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
return (
|
||||
<div className={styles.notificationsOptionDescription}>
|
||||
<Text variant="bodySmall" color="secondary">
|
||||
{manualRouting
|
||||
? 'Notifications for firing alerts are routed to a selected contact point.'
|
||||
: 'Notifications for firing alerts are routed to contact points based on matching labels.'}
|
||||
</Text>
|
||||
{manualRouting ? <NeedHelpInfoForContactpoint /> : <NeedHelpInfoForNotificationPolicy />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
routingOptions: css({
|
||||
marginTop: theme.spacing(2),
|
||||
width: 'fit-content',
|
||||
}),
|
||||
simplifiedRouting: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginTop: theme.spacing(2),
|
||||
}),
|
||||
configureNotifications: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
marginTop: theme.spacing(2),
|
||||
}),
|
||||
notificationsOptionDescription: css({
|
||||
marginTop: theme.spacing(1),
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
gap: theme.spacing(0.5),
|
||||
}),
|
||||
});
|
||||
|
@ -7,7 +7,7 @@ import { GroupAndNamespaceFields } from './GroupAndNamespaceFields';
|
||||
import { RuleEditorSection } from './RuleEditorSection';
|
||||
|
||||
export function RecordingRulesNameSpaceAndGroupStep() {
|
||||
const { watch } = useFormContext<RuleFormValues & { location?: string }>();
|
||||
const { watch } = useFormContext<RuleFormValues>();
|
||||
|
||||
const dataSourceName = watch('dataSourceName');
|
||||
|
||||
|
@ -0,0 +1,65 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
import { AnyAction } from 'redux';
|
||||
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { Alert, Field, LoadingPlaceholder, Select, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { AlertManagerDataSource } from 'app/features/alerting/unified/utils/datasource';
|
||||
|
||||
import { ContactPointReceiverSummary } from '../../../contact-points/ContactPoints.v2';
|
||||
import { useContactPointsWithStatus } from '../../../contact-points/useContactPoints';
|
||||
|
||||
import { selectContactPoint } from './SimplifiedRouting';
|
||||
|
||||
export interface ContactPointSelectorProps {
|
||||
alertManager: AlertManagerDataSource;
|
||||
selectedReceiver?: string;
|
||||
dispatch: React.Dispatch<AnyAction>;
|
||||
}
|
||||
export function ContactPointSelector({ selectedReceiver, alertManager, dispatch }: ContactPointSelectorProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const onChange = (value: SelectableValue<string>) => {
|
||||
dispatch(selectContactPoint({ receiver: value?.value, alertManager }));
|
||||
};
|
||||
const { isLoading, error, contactPoints: receivers } = useContactPointsWithStatus();
|
||||
const options = receivers.map((receiver) => {
|
||||
const integrations = receiver?.grafana_managed_receiver_configs;
|
||||
const description = <ContactPointReceiverSummary receivers={integrations ?? []} />;
|
||||
|
||||
return { label: receiver.name, value: receiver.name, description };
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return <Alert title="Failed to fetch contact points" severity="error" />;
|
||||
}
|
||||
if (isLoading) {
|
||||
return <LoadingPlaceholder text={'Loading...'} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="column">
|
||||
<Field label="Contact point">
|
||||
<div className={styles.contactPointsSelector}>
|
||||
<Select
|
||||
aria-label="Contact point"
|
||||
onChange={onChange}
|
||||
// We are passing a JSX.Element into the "description" for options, which isn't how the TS typings are defined.
|
||||
// The regular Select component will render it just fine, but we can't update the typings because SelectableValue
|
||||
// is shared with other components where the "description" _has_ to be a string.
|
||||
// I've tried unsuccessfully to separate the typings just I'm giving up :'(
|
||||
// @ts-ignore
|
||||
options={options}
|
||||
width={50}
|
||||
value={selectedReceiver}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
contactPointsSelector: css({
|
||||
marginTop: theme.spacing(1),
|
||||
}),
|
||||
});
|
@ -0,0 +1,153 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { createAction, createReducer } from '@reduxjs/toolkit';
|
||||
import React, { useEffect, useReducer } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Icon, Link, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext';
|
||||
import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form';
|
||||
import {
|
||||
AlertManagerDataSource,
|
||||
getAlertManagerDataSourcesByPermission,
|
||||
} from 'app/features/alerting/unified/utils/datasource';
|
||||
import { createUrl } from 'app/features/alerting/unified/utils/url';
|
||||
|
||||
import { ContactPointSelector } from './ContactPointSelector';
|
||||
|
||||
export interface AMContactPoint {
|
||||
alertManager: AlertManagerDataSource;
|
||||
selectedContactPoint?: string;
|
||||
}
|
||||
|
||||
export const selectContactPoint = createAction<{ receiver: string | undefined; alertManager: AlertManagerDataSource }>(
|
||||
'simplifiedRouting/selectContactPoint'
|
||||
);
|
||||
|
||||
export const receiversReducer = createReducer<AMContactPoint[]>([], (builder) => {
|
||||
builder.addCase(selectContactPoint, (state, action) => {
|
||||
const { receiver, alertManager } = action.payload;
|
||||
const newContactPoint: AMContactPoint = { selectedContactPoint: receiver, alertManager };
|
||||
const existingContactPoint = state.find((cp) => cp.alertManager.name === alertManager.name);
|
||||
|
||||
if (existingContactPoint) {
|
||||
existingContactPoint.selectedContactPoint = receiver;
|
||||
} else {
|
||||
state.push(newContactPoint);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export function SimplifiedRouting() {
|
||||
const { getValues, setValue } = useFormContext<RuleFormValues>();
|
||||
const styles = useStyles2(getStyles);
|
||||
const contactPointsInAlert = getValues('contactPoints');
|
||||
|
||||
const allAlertManagersByPermission = getAlertManagerDataSourcesByPermission('notification');
|
||||
|
||||
// We decided to only show internal alert manager for now. Once we want to show external alert managers we can use this code
|
||||
// const alertManagersDataSources = allAlertManagersByPermission.availableInternalDataSources.concat(
|
||||
// allAlertManagersByPermission.availableExternalDataSources
|
||||
// );
|
||||
|
||||
const alertManagersDataSources = allAlertManagersByPermission.availableInternalDataSources;
|
||||
|
||||
const alertManagersDataSourcesWithConfigAPI = alertManagersDataSources.filter((am) => am.hasConfigurationAPI);
|
||||
|
||||
// we merge the selected contact points with the alert manager meta data
|
||||
const alertManagersWithSelectedContactPoints = alertManagersDataSourcesWithConfigAPI.map((am) => {
|
||||
const selectedContactPoint = contactPointsInAlert?.find((cp) => cp.alertManager === am.name);
|
||||
return { alertManager: am, selectedContactPoint: selectedContactPoint?.selectedContactPoint };
|
||||
});
|
||||
|
||||
// use reducer to keep this alertManagersWithSelectedContactPoints in the state
|
||||
const [alertManagersWithCPState, dispatch] = useReducer(receiversReducer, alertManagersWithSelectedContactPoints);
|
||||
|
||||
function getContactPointsForForm(alertManagersWithCP: AMContactPoint[]) {
|
||||
return alertManagersWithCP.map((am) => {
|
||||
return { alertManager: am.alertManager.name, selectedContactPoint: am.selectedContactPoint };
|
||||
});
|
||||
}
|
||||
|
||||
// whenever we update the receiversState we have to update the form too
|
||||
useEffect(() => {
|
||||
const contactPointsForForm = getContactPointsForForm(alertManagersWithCPState);
|
||||
setValue('contactPoints', contactPointsForForm, { shouldValidate: false });
|
||||
}, [alertManagersWithCPState, setValue]);
|
||||
|
||||
const shouldShowAM = true;
|
||||
|
||||
return alertManagersWithCPState.map((alertManagerContactPoint, index) => {
|
||||
return (
|
||||
<div key={index}>
|
||||
<Stack direction="column">
|
||||
{shouldShowAM && (
|
||||
<Stack direction="row" alignItems="center">
|
||||
<div className={styles.firstAlertManagerLine}></div>
|
||||
<div className={styles.alertManagerName}>
|
||||
{' '}
|
||||
Alert manager:
|
||||
<img
|
||||
src={alertManagerContactPoint.alertManager.imgUrl}
|
||||
alt="Alert manager logo"
|
||||
className={styles.img}
|
||||
/>
|
||||
{alertManagerContactPoint.alertManager.name}
|
||||
</div>
|
||||
<div className={styles.secondAlertManagerLine}></div>
|
||||
</Stack>
|
||||
)}
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
<AlertmanagerProvider
|
||||
accessType={'notification'}
|
||||
alertmanagerSourceName={alertManagerContactPoint.alertManager.name}
|
||||
>
|
||||
<ContactPointSelector
|
||||
selectedReceiver={alertManagerContactPoint.selectedContactPoint}
|
||||
dispatch={dispatch}
|
||||
alertManager={alertManagerContactPoint.alertManager}
|
||||
/>
|
||||
</AlertmanagerProvider>
|
||||
<LinkToContactPoints />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function LinkToContactPoints() {
|
||||
const hrefToContactPoints = '/alerting/notifications';
|
||||
return (
|
||||
<Link target="_blank" href={createUrl(hrefToContactPoints)} rel="noopener" aria-label="View alert rule">
|
||||
<Stack direction="row" gap={1} alignItems="center" justifyContent="center">
|
||||
<Text color="secondary">To browse contact points and create new ones go to</Text>
|
||||
<Text color="link">Contact points</Text>
|
||||
<Icon name={'external-link-alt'} size="sm" color="link" />
|
||||
</Stack>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
firstAlertManagerLine: css({
|
||||
height: 1,
|
||||
width: theme.spacing(4),
|
||||
backgroundColor: theme.colors.secondary.main,
|
||||
}),
|
||||
alertManagerName: css({
|
||||
with: 'fit-content',
|
||||
}),
|
||||
secondAlertManagerLine: css({
|
||||
height: '1px',
|
||||
width: '100%',
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.secondary.main,
|
||||
}),
|
||||
img: css({
|
||||
marginLeft: theme.spacing(2),
|
||||
width: theme.spacing(3),
|
||||
height: theme.spacing(3),
|
||||
marginRight: theme.spacing(1),
|
||||
}),
|
||||
});
|
@ -13,30 +13,28 @@ import { mockApi, setupMswServer } from '../../../mockApi';
|
||||
import { grantUserPermissions, mockAlertQuery } from '../../../mocks';
|
||||
import { mockPreviewApiResponse } from '../../../mocks/alertRuleApi';
|
||||
import * as dataSource from '../../../utils/datasource';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
|
||||
import {
|
||||
AlertManagerDataSource,
|
||||
GRAFANA_RULES_SOURCE_NAME,
|
||||
useGetAlertManagerDataSourcesByPermissionAndConfig,
|
||||
} 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: '' },
|
||||
]);
|
||||
.spyOn(dataSource, 'useGetAlertManagerDataSourcesByPermissionAndConfig')
|
||||
.mockReturnValue([{ name: GRAFANA_RULES_SOURCE_NAME, imgUrl: '', hasConfigurationAPI: true }]);
|
||||
|
||||
jest.spyOn(dataSource, 'getDatasourceAPIUid').mockImplementation((ds: string) => ds);
|
||||
|
||||
const useGetAlertManagersSourceNamesAndImageMock = useGetAlertManagersSourceNamesAndImage as jest.MockedFunction<
|
||||
typeof useGetAlertManagersSourceNamesAndImage
|
||||
>;
|
||||
const getAlertManagerDataSourcesByPermissionAndConfigMock =
|
||||
useGetAlertManagerDataSourcesByPermissionAndConfig as jest.MockedFunction<
|
||||
typeof useGetAlertManagerDataSourcesByPermissionAndConfig
|
||||
>;
|
||||
|
||||
const ui = {
|
||||
route: byTestId('matching-policy-route'),
|
||||
@ -62,8 +60,14 @@ beforeEach(() => {
|
||||
|
||||
const alertQuery = mockAlertQuery({ datasourceUid: 'whatever', refId: 'A' });
|
||||
|
||||
const grafanaAlertManagerDataSource: AlertManagerDataSource = {
|
||||
name: GRAFANA_RULES_SOURCE_NAME,
|
||||
imgUrl: '',
|
||||
hasConfigurationAPI: true,
|
||||
};
|
||||
|
||||
function mockOneAlertManager() {
|
||||
useGetAlertManagersSourceNamesAndImageMock.mockReturnValue([{ name: GRAFANA_RULES_SOURCE_NAME, img: '' }]);
|
||||
getAlertManagerDataSourcesByPermissionAndConfigMock.mockReturnValue([grafanaAlertManagerDataSource]);
|
||||
mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
|
||||
amConfigBuilder
|
||||
.withRoute((routeBuilder) =>
|
||||
@ -79,10 +83,11 @@ function mockOneAlertManager() {
|
||||
}
|
||||
|
||||
function mockTwoAlertManagers() {
|
||||
useGetAlertManagersSourceNamesAndImageMock.mockReturnValue([
|
||||
{ name: GRAFANA_RULES_SOURCE_NAME, img: '' },
|
||||
{ name: 'OTHER_AM', img: '' },
|
||||
getAlertManagerDataSourcesByPermissionAndConfigMock.mockReturnValue([
|
||||
{ name: 'OTHER_AM', imgUrl: '', hasConfigurationAPI: true },
|
||||
grafanaAlertManagerDataSource,
|
||||
]);
|
||||
|
||||
mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
|
||||
amConfigBuilder
|
||||
.withRoute((routeBuilder) =>
|
||||
@ -272,7 +277,7 @@ describe('NotificationPreviewByAlertmanager', () => {
|
||||
|
||||
render(
|
||||
<NotificationPreviewByAlertManager
|
||||
alertManagerSource={{ name: GRAFANA_RULES_SOURCE_NAME, img: '' }}
|
||||
alertManagerSource={grafanaAlertManagerDataSource}
|
||||
potentialInstances={potentialInstances}
|
||||
onlyOneAM={true}
|
||||
/>,
|
||||
@ -327,7 +332,7 @@ describe('NotificationPreviewByAlertmanager', () => {
|
||||
|
||||
render(
|
||||
<NotificationPreviewByAlertManager
|
||||
alertManagerSource={{ name: GRAFANA_RULES_SOURCE_NAME, img: '' }}
|
||||
alertManagerSource={grafanaAlertManagerDataSource}
|
||||
potentialInstances={potentialInstances}
|
||||
onlyOneAM={true}
|
||||
/>,
|
||||
@ -382,7 +387,7 @@ describe('NotificationPreviewByAlertmanager', () => {
|
||||
|
||||
render(
|
||||
<NotificationPreviewByAlertManager
|
||||
alertManagerSource={{ name: GRAFANA_RULES_SOURCE_NAME, img: '' }}
|
||||
alertManagerSource={grafanaAlertManagerDataSource}
|
||||
potentialInstances={potentialInstances}
|
||||
onlyOneAM={true}
|
||||
/>,
|
||||
|
@ -3,15 +3,14 @@ import { compact } from 'lodash';
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Button, LoadingPlaceholder, useStyles2, Text } from '@grafana/ui';
|
||||
import { Button, LoadingPlaceholder, Text, useStyles2 } from '@grafana/ui';
|
||||
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 { useGetAlertManagerDataSourcesByPermissionAndConfig } from '../../../utils/datasource';
|
||||
import { Folder } from '../RuleFolderPicker';
|
||||
|
||||
import { useGetAlertManagersSourceNamesAndImage } from './useGetAlertManagersSourceNamesAndImage';
|
||||
|
||||
const NotificationPreviewByAlertManager = lazy(() => import('./NotificationPreviewByAlertManager'));
|
||||
|
||||
interface NotificationPreviewProps {
|
||||
@ -63,10 +62,10 @@ export const NotificationPreview = ({
|
||||
});
|
||||
};
|
||||
|
||||
// Get list of alert managers source name + image
|
||||
const alertManagerSourceNamesAndImage = useGetAlertManagersSourceNamesAndImage();
|
||||
// Get alert managers's data source information
|
||||
const alertManagerDataSources = useGetAlertManagerDataSourcesByPermissionAndConfig('notification');
|
||||
|
||||
const onlyOneAM = alertManagerSourceNamesAndImage.length === 1;
|
||||
const onlyOneAM = alertManagerDataSources.length === 1;
|
||||
|
||||
return (
|
||||
<Stack direction="column">
|
||||
@ -98,7 +97,7 @@ export const NotificationPreview = ({
|
||||
</div>
|
||||
{!isLoading && !previewUninitialized && potentialInstances.length > 0 && (
|
||||
<Suspense fallback={<LoadingPlaceholder text="Loading preview..." />}>
|
||||
{alertManagerSourceNamesAndImage.map((alertManagerSource) => (
|
||||
{alertManagerDataSources.map((alertManagerSource) => (
|
||||
<NotificationPreviewByAlertManager
|
||||
alertManagerSource={alertManagerSource}
|
||||
potentialInstances={potentialInstances}
|
||||
|
@ -6,17 +6,17 @@ import { Alert, LoadingPlaceholder, useStyles2, withErrorBoundary } from '@grafa
|
||||
|
||||
import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack';
|
||||
import { Labels } from '../../../../../../types/unified-alerting-dto';
|
||||
import { AlertManagerDataSource } from '../../../utils/datasource';
|
||||
|
||||
import { NotificationRoute } from './NotificationRoute';
|
||||
import { useAlertmanagerNotificationRoutingPreview } from './useAlertmanagerNotificationRoutingPreview';
|
||||
import { AlertManagerNameWithImage } from './useGetAlertManagersSourceNamesAndImage';
|
||||
|
||||
function NotificationPreviewByAlertManager({
|
||||
alertManagerSource,
|
||||
potentialInstances,
|
||||
onlyOneAM,
|
||||
}: {
|
||||
alertManagerSource: AlertManagerNameWithImage;
|
||||
alertManagerSource: AlertManagerDataSource;
|
||||
potentialInstances: Labels[];
|
||||
onlyOneAM: boolean;
|
||||
}) {
|
||||
@ -49,7 +49,7 @@ function NotificationPreviewByAlertManager({
|
||||
<div className={styles.alertManagerName}>
|
||||
{' '}
|
||||
Alert manager:
|
||||
<img src={alertManagerSource.img} alt="" className={styles.img} />
|
||||
<img src={alertManagerSource.imgUrl} alt="" className={styles.img} />
|
||||
{alertManagerSource.name}
|
||||
</div>
|
||||
<div className={styles.secondAlertManagerLine}></div>
|
||||
|
@ -1,28 +0,0 @@
|
||||
import { AlertmanagerChoice } from '../../../../../../plugins/datasource/alertmanager/types';
|
||||
import { alertmanagerApi } from '../../../api/alertmanagerApi';
|
||||
import { getExternalDsAlertManagers, 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 = getExternalDsAlertManagers().map((ds) => ({
|
||||
name: ds.name,
|
||||
img: ds.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;
|
||||
};
|
@ -18,7 +18,7 @@ export const CloudDataSourceSelector = ({ disabled, onChangeCloudDatasource }: C
|
||||
formState: { errors },
|
||||
setValue,
|
||||
watch,
|
||||
} = useFormContext<RuleFormValues & { location?: string }>();
|
||||
} = useFormContext<RuleFormValues>();
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
const ruleFormType = watch('type');
|
||||
@ -40,8 +40,6 @@ export const CloudDataSourceSelector = ({ disabled, onChangeCloudDatasource }: C
|
||||
{...field}
|
||||
disabled={disabled}
|
||||
onChange={(ds: DataSourceInstanceSettings) => {
|
||||
// reset location if switching data sources, as different rules source will have different groups and namespaces
|
||||
setValue('location', undefined);
|
||||
// reset expression as they don't need to persist after changing datasources
|
||||
setValue('expression', '');
|
||||
onChange(ds?.name ?? null);
|
||||
|
@ -29,7 +29,9 @@ const externalAmMimir: AlertManagerDataSource = {
|
||||
|
||||
describe('useAlertmanager', () => {
|
||||
it('Should return undefined alert manager name when there are no available alert managers', () => {
|
||||
jest.spyOn(useAlertManagerSources, 'useAlertManagersByPermission').mockReturnValueOnce([]);
|
||||
jest
|
||||
.spyOn(useAlertManagerSources, 'useAlertManagersByPermission')
|
||||
.mockReturnValueOnce({ availableExternalDataSources: [], availableInternalDataSources: [] });
|
||||
const wrapper = ({ children }: React.PropsWithChildren) => (
|
||||
<MemoryRouter>
|
||||
<AlertmanagerProvider accessType="instance">{children}</AlertmanagerProvider>
|
||||
@ -41,7 +43,11 @@ describe('useAlertmanager', () => {
|
||||
});
|
||||
|
||||
it('Should return Grafana AM when it is available and no alert manager query param exists', () => {
|
||||
jest.spyOn(useAlertManagerSources, 'useAlertManagersByPermission').mockReturnValueOnce([grafanaAm]);
|
||||
jest.spyOn(useAlertManagerSources, 'useAlertManagersByPermission').mockReturnValueOnce({
|
||||
availableExternalDataSources: [],
|
||||
availableInternalDataSources: [{ name: GRAFANA_RULES_SOURCE_NAME, imgUrl: '', hasConfigurationAPI: true }],
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: React.PropsWithChildren) => (
|
||||
<MemoryRouter>
|
||||
<AlertmanagerProvider accessType="instance">{children}</AlertmanagerProvider>
|
||||
@ -53,7 +59,9 @@ describe('useAlertmanager', () => {
|
||||
});
|
||||
|
||||
it('Should return alert manager included in the query param when available', () => {
|
||||
jest.spyOn(useAlertManagerSources, 'useAlertManagersByPermission').mockReturnValueOnce([externalAmProm]);
|
||||
jest
|
||||
.spyOn(useAlertManagerSources, 'useAlertManagersByPermission')
|
||||
.mockReturnValueOnce({ availableExternalDataSources: [externalAmProm], availableInternalDataSources: [] });
|
||||
|
||||
const history = createMemoryHistory();
|
||||
history.push({ search: `alertmanager=${externalAmProm.name}` });
|
||||
@ -69,7 +77,9 @@ describe('useAlertmanager', () => {
|
||||
});
|
||||
|
||||
it('Should return undefined if alert manager included in the query is not available', () => {
|
||||
jest.spyOn(useAlertManagerSources, 'useAlertManagersByPermission').mockReturnValueOnce([]);
|
||||
jest
|
||||
.spyOn(useAlertManagerSources, 'useAlertManagersByPermission')
|
||||
.mockReturnValueOnce({ availableExternalDataSources: [], availableInternalDataSources: [] });
|
||||
|
||||
const history = createMemoryHistory();
|
||||
history.push({ search: `alertmanager=Not available external AM` });
|
||||
@ -85,7 +95,9 @@ describe('useAlertmanager', () => {
|
||||
});
|
||||
|
||||
it('Should return alert manager from store if available and query is empty', () => {
|
||||
jest.spyOn(useAlertManagerSources, 'useAlertManagersByPermission').mockReturnValueOnce([externalAmProm]);
|
||||
jest
|
||||
.spyOn(useAlertManagerSources, 'useAlertManagersByPermission')
|
||||
.mockReturnValueOnce({ availableExternalDataSources: [externalAmProm], availableInternalDataSources: [] });
|
||||
|
||||
const wrapper = ({ children }: React.PropsWithChildren) => (
|
||||
<MemoryRouter>
|
||||
@ -100,9 +112,10 @@ describe('useAlertmanager', () => {
|
||||
});
|
||||
|
||||
it('Should prioritize the alert manager from query over store', () => {
|
||||
jest
|
||||
.spyOn(useAlertManagerSources, 'useAlertManagersByPermission')
|
||||
.mockReturnValueOnce([externalAmProm, externalAmMimir]);
|
||||
jest.spyOn(useAlertManagerSources, 'useAlertManagersByPermission').mockReturnValueOnce({
|
||||
availableExternalDataSources: [externalAmProm, externalAmMimir],
|
||||
availableInternalDataSources: [],
|
||||
});
|
||||
|
||||
const history = createMemoryHistory();
|
||||
history.push({ search: `alertmanager=${externalAmProm.name}` });
|
||||
|
@ -8,8 +8,8 @@ import { useAlertManagersByPermission } from '../hooks/useAlertManagerSources';
|
||||
import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, ALERTMANAGER_NAME_QUERY_KEY } from '../utils/constants';
|
||||
import {
|
||||
AlertManagerDataSource,
|
||||
getAlertmanagerDataSourceByName,
|
||||
GRAFANA_RULES_SOURCE_NAME,
|
||||
getAlertmanagerDataSourceByName,
|
||||
} from '../utils/datasource';
|
||||
|
||||
interface Context {
|
||||
@ -31,7 +31,10 @@ interface Props extends React.PropsWithChildren {
|
||||
|
||||
const AlertmanagerProvider = ({ children, accessType, alertmanagerSourceName }: Props) => {
|
||||
const [queryParams, updateQueryParams] = useQueryParams();
|
||||
const availableAlertManagers = useAlertManagersByPermission(accessType);
|
||||
const allAvailableAlertManagers = useAlertManagersByPermission(accessType);
|
||||
const availableAlertManagers = allAvailableAlertManagers.availableInternalDataSources.concat(
|
||||
allAvailableAlertManagers.availableExternalDataSources
|
||||
);
|
||||
|
||||
const updateSelectedAlertmanager = React.useCallback(
|
||||
(selectedAlertManager: string) => {
|
||||
|
@ -8,6 +8,11 @@ export enum RuleFormType {
|
||||
cloudRecording = 'cloud-recording',
|
||||
}
|
||||
|
||||
export interface ContactPoints {
|
||||
alertManager: string;
|
||||
selectedContactPoint?: string;
|
||||
}
|
||||
|
||||
export interface RuleFormValues {
|
||||
// common
|
||||
name: string;
|
||||
@ -27,6 +32,8 @@ export interface RuleFormValues {
|
||||
evaluateEvery: string;
|
||||
evaluateFor: string;
|
||||
isPaused?: boolean;
|
||||
contactPoints?: ContactPoints[];
|
||||
manualRouting: boolean;
|
||||
|
||||
// cortex / loki rules
|
||||
namespace: string;
|
||||
|
@ -1,10 +1,18 @@
|
||||
import { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types';
|
||||
import {
|
||||
AlertManagerDataSourceJsonData,
|
||||
AlertManagerImplementation,
|
||||
AlertmanagerChoice,
|
||||
} from 'app/plugins/datasource/alertmanager/types';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { RulesSource } from 'app/types/unified-alerting';
|
||||
|
||||
import { alertmanagerApi } from '../api/alertmanagerApi';
|
||||
import { useAlertManagersByPermission } from '../hooks/useAlertManagerSources';
|
||||
import { isAlertManagerWithConfigAPI } from '../state/AlertmanagerContext';
|
||||
|
||||
import { instancesPermissions, notificationsPermissions } from './access-control';
|
||||
import { getAllDataSources } from './config';
|
||||
|
||||
@ -21,6 +29,8 @@ export interface AlertManagerDataSource {
|
||||
name: string;
|
||||
imgUrl: string;
|
||||
meta?: DataSourceInstanceSettings['meta'];
|
||||
hasConfigurationAPI?: boolean;
|
||||
handleGrafanaManagedAlerts?: boolean;
|
||||
}
|
||||
|
||||
export const RulesDataSourceTypes: string[] = [DataSourceType.Loki, DataSourceType.Prometheus];
|
||||
@ -54,6 +64,7 @@ export function getExternalDsAlertManagers() {
|
||||
const grafanaAlertManagerDataSource: AlertManagerDataSource = {
|
||||
name: GRAFANA_RULES_SOURCE_NAME,
|
||||
imgUrl: 'public/img/grafana_icon.svg',
|
||||
hasConfigurationAPI: true,
|
||||
};
|
||||
|
||||
// Used only as a fallback for Alert Group plugin
|
||||
@ -69,17 +80,53 @@ export function getAllAlertManagerDataSources(): AlertManagerDataSource[] {
|
||||
];
|
||||
}
|
||||
|
||||
export function getAlertManagerDataSourcesByPermission(
|
||||
/**
|
||||
* This method gets all alert managers that the user has access, and then filter them first by being able to handle grafana managed alerts,
|
||||
* and then depending on the current alerting configuration returns either only the internal alert managers, only the external alert managers, or both.
|
||||
*
|
||||
*/
|
||||
export function useGetAlertManagerDataSourcesByPermissionAndConfig(
|
||||
permission: 'instance' | 'notification'
|
||||
): AlertManagerDataSource[] {
|
||||
const availableDataSources: AlertManagerDataSource[] = [];
|
||||
const allAlertManagersByPermission = useAlertManagersByPermission(permission); // this hook memoizes the result of getAlertManagerDataSourcesByPermission
|
||||
|
||||
const externalDsAlertManagers: AlertManagerDataSource[] =
|
||||
allAlertManagersByPermission.availableExternalDataSources.filter((ds) => ds.handleGrafanaManagedAlerts);
|
||||
const internalDSAlertManagers = allAlertManagersByPermission.availableInternalDataSources;
|
||||
|
||||
//get current alerting configuration
|
||||
const { currentData: amConfigStatus } = alertmanagerApi.useGetAlertmanagerChoiceStatusQuery(undefined);
|
||||
|
||||
const alertmanagerChoice = amConfigStatus?.alertmanagersChoice;
|
||||
|
||||
switch (alertmanagerChoice) {
|
||||
case AlertmanagerChoice.Internal:
|
||||
return internalDSAlertManagers;
|
||||
case AlertmanagerChoice.External:
|
||||
return externalDsAlertManagers;
|
||||
default:
|
||||
return [...internalDSAlertManagers, ...externalDsAlertManagers];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method gets all alert managers that the user has access to and then split them into two groups:
|
||||
* 1. Internal alert managers
|
||||
* 2. External alert managers
|
||||
*/
|
||||
export function getAlertManagerDataSourcesByPermission(permission: 'instance' | 'notification'): {
|
||||
availableInternalDataSources: AlertManagerDataSource[];
|
||||
availableExternalDataSources: AlertManagerDataSource[];
|
||||
} {
|
||||
const availableInternalDataSources: AlertManagerDataSource[] = [];
|
||||
const availableExternalDataSources: AlertManagerDataSource[] = [];
|
||||
const permissions = {
|
||||
instance: instancesPermissions.read,
|
||||
notification: notificationsPermissions.read,
|
||||
};
|
||||
|
||||
if (contextSrv.hasPermission(permissions[permission].grafana)) {
|
||||
availableDataSources.push(grafanaAlertManagerDataSource);
|
||||
availableInternalDataSources.push(grafanaAlertManagerDataSource);
|
||||
}
|
||||
|
||||
if (contextSrv.hasPermission(permissions[permission].external)) {
|
||||
@ -88,11 +135,13 @@ export function getAlertManagerDataSourcesByPermission(
|
||||
displayName: ds.name,
|
||||
imgUrl: ds.meta.info.logos.small,
|
||||
meta: ds.meta,
|
||||
hasConfigurationAPI: isAlertManagerWithConfigAPI(ds.jsonData),
|
||||
handleGrafanaManagedAlerts: ds.jsonData.handleGrafanaManagedAlerts,
|
||||
}));
|
||||
availableDataSources.push(...cloudSources);
|
||||
availableExternalDataSources.push(...cloudSources);
|
||||
}
|
||||
|
||||
return availableDataSources;
|
||||
return { availableInternalDataSources, availableExternalDataSources };
|
||||
}
|
||||
|
||||
export function getLotexDataSourceByName(dataSourceName: string): DataSourceInstanceSettings {
|
||||
|
@ -67,6 +67,8 @@ export const getDefaultFormValues = (): RuleFormValues => {
|
||||
execErrState: GrafanaAlertStateDecision.Error,
|
||||
evaluateFor: '5m',
|
||||
evaluateEvery: MINUTE,
|
||||
manualRouting: false, // let's decide this later
|
||||
contactPoints: [],
|
||||
|
||||
// cortex / loki
|
||||
namespace: '',
|
||||
@ -176,6 +178,8 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
|
||||
labels: listifyLabelsOrAnnotations(rule.labels, true),
|
||||
folder: { title: namespace, uid: ga.namespace_uid },
|
||||
isPaused: ga.is_paused,
|
||||
// manualrouting: ?? //todo depending on the implementation of the manual routing
|
||||
// contactPoints: ?? //todo depending on the implementation of the manual routing
|
||||
};
|
||||
} else {
|
||||
throw new Error('Unexpected type of rule for grafana rules source');
|
||||
|
Loading…
Reference in New Issue
Block a user