diff --git a/packages/grafana-ui/src/components/Forms/FieldArray.tsx b/packages/grafana-ui/src/components/Forms/FieldArray.tsx index d9f07bb5e1c..48e4eb7df3c 100644 --- a/packages/grafana-ui/src/components/Forms/FieldArray.tsx +++ b/packages/grafana-ui/src/components/Forms/FieldArray.tsx @@ -6,10 +6,11 @@ export interface FieldArrayProps extends UseFieldArrayProps { children: (api: FieldArrayApi) => JSX.Element; } -export const FieldArray: FC = ({ name, control, children }) => { +export const FieldArray: FC = ({ name, control, children, ...rest }) => { const { fields, append, prepend, remove, swap, move, insert } = useFieldArray({ control, name, + ...rest, }); return children({ fields, append, prepend, remove, swap, move, insert }); }; diff --git a/public/app/features/alerting/unified/Receivers.tsx b/public/app/features/alerting/unified/Receivers.tsx index 9392a4fc44e..7be5ebf75cb 100644 --- a/public/app/features/alerting/unified/Receivers.tsx +++ b/public/app/features/alerting/unified/Receivers.tsx @@ -4,7 +4,9 @@ import { useDispatch } from 'react-redux'; import { Redirect, Route, RouteChildrenProps, Switch, useLocation } from 'react-router-dom'; import { AlertingPageWrapper } from './components/AlertingPageWrapper'; import { AlertManagerPicker } from './components/AlertManagerPicker'; +import { EditReceiverView } from './components/receivers/EditReceiverView'; import { EditTemplateView } from './components/receivers/EditTemplateView'; +import { NewReceiverView } from './components/receivers/NewReceiverView'; import { NewTemplateView } from './components/receivers/NewTemplateView'; import { ReceiversAndTemplatesView } from './components/receivers/ReceiversAndTemplatesView'; import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName'; @@ -76,6 +78,20 @@ const Receivers: FC = () => { ) } + + + + + {({ match }: RouteChildrenProps<{ name: string }>) => + match?.params.name && ( + + ) + } + )} diff --git a/public/app/features/alerting/unified/api/alertmanager.ts b/public/app/features/alerting/unified/api/alertmanager.ts index 8064d6d7d73..16689b7f216 100644 --- a/public/app/features/alerting/unified/api/alertmanager.ts +++ b/public/app/features/alerting/unified/api/alertmanager.ts @@ -28,7 +28,8 @@ export async function fetchAlertManagerConfig(alertManagerSourceName: string): P // if no config has been uploaded to grafana, it returns error instead of latest config if ( alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME && - e.data?.message?.includes('failed to get latest configuration') + (e.data?.message?.includes('failed to get latest configuration') || + e.data?.message?.includes('could not find an Alertmanager configuration')) ) { return { template_files: {}, diff --git a/public/app/features/alerting/unified/components/receivers/EditReceiverView.tsx b/public/app/features/alerting/unified/components/receivers/EditReceiverView.tsx new file mode 100644 index 00000000000..11cb52b8b40 --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/EditReceiverView.tsx @@ -0,0 +1,28 @@ +import { InfoBox } from '@grafana/ui'; +import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; +import React, { FC } from 'react'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; +import { GrafanaReceiverForm } from './form/GrafanaReceiverForm'; + +interface Props { + receiverName: string; + config: AlertManagerCortexConfig; + alertManagerSourceName: string; +} + +export const EditReceiverView: FC = ({ config, receiverName, alertManagerSourceName }) => { + const receiver = config.alertmanager_config.receivers?.find(({ name }) => name === receiverName); + if (!receiver) { + return ( + + Sorry, this receiver does not seem to exit. + + ); + } + + if (alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME) { + return ; + } else { + return

@TODO cloud receiver editing not implemented yet

; + } +}; diff --git a/public/app/features/alerting/unified/components/receivers/NewReceiverView.tsx b/public/app/features/alerting/unified/components/receivers/NewReceiverView.tsx new file mode 100644 index 00000000000..4c433f1b98a --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/NewReceiverView.tsx @@ -0,0 +1,17 @@ +import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; +import React, { FC } from 'react'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; +import { GrafanaReceiverForm } from './form/GrafanaReceiverForm'; + +interface Props { + config: AlertManagerCortexConfig; + alertManagerSourceName: string; +} + +export const NewReceiverView: FC = ({ alertManagerSourceName, config }) => { + if (alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME) { + return ; + } else { + return

@TODO cloud receiver editing not implemented yet

; + } +}; diff --git a/public/app/features/alerting/unified/components/receivers/ReceiversTable.test.tsx b/public/app/features/alerting/unified/components/receivers/ReceiversTable.test.tsx index 5fa5ae299f4..731deee228e 100644 --- a/public/app/features/alerting/unified/components/receivers/ReceiversTable.test.tsx +++ b/public/app/features/alerting/unified/components/receivers/ReceiversTable.test.tsx @@ -32,13 +32,12 @@ const renderReceieversTable = async (receivers: Receiver[], notifiers: NotifierD const mockGrafanaReceiver = (type: string): GrafanaManagedReceiverConfig => ({ type, - id: 2, - frequency: 1, disableResolveMessage: false, secureFields: {}, settings: {}, sendReminder: false, uid: '2', + name: type, }); const mockNotifier = (type: NotifierType, name: string): NotifierDTO => ({ diff --git a/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx b/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx index 14baa778895..92ca052e24e 100644 --- a/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx +++ b/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx @@ -63,10 +63,10 @@ export const ReceiversTable: FC = ({ config, alertManagerName }) => { `/alerting/notifications/receivers/${encodeURIComponent(receiver.name)}/edit`, alertManagerName )} - tooltip="edit receiver" + tooltip="Edit contact point" icon="pen" /> - + ))} diff --git a/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx b/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx index 9c481a4991b..ce9ec850cf5 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx @@ -64,7 +64,15 @@ export const TemplateForm: FC = ({ existing, alertManagerSourceName, conf templates, }, }; - dispatch(updateAlertManagerConfigAction({ alertManagerSourceName, newConfig, oldConfig: config })); + dispatch( + updateAlertManagerConfigAction({ + alertManagerSourceName, + newConfig, + oldConfig: config, + successMessage: 'Template saved.', + redirectPath: '/alerting/notifications', + }) + ); }; const { handleSubmit, register, errors } = useForm({ diff --git a/public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx b/public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx new file mode 100644 index 00000000000..7d63194c404 --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { Button, Checkbox, Field, Input } from '@grafana/ui'; +import { OptionElement } from './OptionElement'; +import { ChannelValues, ReceiverFormValues } from '../../../types/receiver-form'; +import { useFormContext, FieldError, NestDataObject } from 'react-hook-form'; +import { NotificationChannelOption, NotificationChannelSecureFields } from 'app/types'; + +export interface Props { + selectedChannelOptions: NotificationChannelOption[]; + secureFields: NotificationChannelSecureFields; + + onResetSecureField: (key: string) => void; + errors?: NestDataObject; + pathPrefix?: string; +} + +export function ChannelOptions({ + selectedChannelOptions, + onResetSecureField, + secureFields, + errors, + pathPrefix = '', +}: Props): JSX.Element { + const { register, watch } = useFormContext>(); + const currentFormValues = watch() as Record; // react hook form types ARE LYING! + return ( + <> + {selectedChannelOptions.map((option: NotificationChannelOption, index: number) => { + const key = `${option.label}-${index}`; + // Some options can be dependent on other options, this determines what is selected in the dependency options + // I think this needs more thought. + const selectedOptionValue = + currentFormValues[`${pathPrefix}settings.${option.showWhen.field}`] && + currentFormValues[`${pathPrefix}settings.${option.showWhen.field}`]; + + if (option.showWhen.field && selectedOptionValue !== option.showWhen.is) { + return null; + } + + if (option.element === 'checkbox') { + return ( + + + + ); + } + + const error: FieldError | undefined = ((option.secure ? errors?.secureSettings : errors?.settings) as + | Record + | undefined)?.[option.propertyName]; + + return ( + + {secureFields && secureFields[option.propertyName] ? ( + onResetSecureField(option.propertyName)} + variant="link" + type="button" + size="sm" + > + Clear + + } + /> + ) : ( + + )} + + ); + })} + + ); +} diff --git a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx new file mode 100644 index 00000000000..73f0ef168f9 --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx @@ -0,0 +1,154 @@ +import { GrafanaThemeV2, SelectableValue } from '@grafana/data'; +import { NotifierDTO } from 'app/types'; +import React, { useEffect, useMemo, useState } from 'react'; +import { css } from '@emotion/css'; +import { Alert, Button, Field, InputControl, Select, useStyles2 } from '@grafana/ui'; +import { useFormContext, FieldError, NestDataObject } from 'react-hook-form'; +import { ChannelValues, CommonSettingsComponentType } from '../../../types/receiver-form'; +import { ChannelOptions } from './ChannelOptions'; +import { CollapsibleSection } from './CollapsibleSection'; + +interface Props { + pathPrefix: string; + notifiers: NotifierDTO[]; + onDuplicate: () => void; + commonSettingsComponent: CommonSettingsComponentType; + + secureFields?: Record; + errors?: NestDataObject; + onDelete?: () => void; +} + +export function ChannelSubForm({ + pathPrefix, + onDuplicate, + onDelete, + notifiers, + errors, + secureFields, + commonSettingsComponent: CommonSettingsComponent, +}: Props): JSX.Element { + const styles = useStyles2(getStyles); + const name = (fieldName: string) => `${pathPrefix}${fieldName}`; + const { control, watch, register, unregister } = useFormContext(); + const selectedType = watch(name('type')); + + // keep the __id field registered so it's always passed to submit + useEffect(() => { + register({ name: `${pathPrefix}__id` }); + return () => { + unregister(`${pathPrefix}__id`); + }; + }); + + const [_secureFields, setSecureFields] = useState(secureFields ?? {}); + + const onResetSecureField = (key: string) => { + if (_secureFields[key]) { + const updatedSecureFields = { ...secureFields }; + delete updatedSecureFields[key]; + setSecureFields(updatedSecureFields); + } + }; + + const typeOptions = useMemo( + (): SelectableValue[] => + notifiers.map(({ name, type }) => ({ + label: name, + value: type, + })), + [notifiers] + ); + + const notifier = notifiers.find(({ type }) => type === selectedType); + // if there are mandatory options defined, optional options will be hidden by a collapse + // if there aren't mandatory options, all options will be shown without collapse + const mandatoryOptions = notifier?.options.filter((o) => o.required); + const optionalOptions = notifier?.options.filter((o) => !o.required); + + return ( +
+
+
+ + values[0]?.value} + /> + +
+
+ + {onDelete && ( + + )} +
+
+ {notifier && ( +
+ + selectedChannelOptions={mandatoryOptions?.length ? mandatoryOptions! : optionalOptions!} + secureFields={_secureFields} + errors={errors} + onResetSecureField={onResetSecureField} + pathPrefix={pathPrefix} + /> + {!!(mandatoryOptions?.length && optionalOptions?.length) && ( + + {notifier.info !== '' && ( + + {notifier.info} + + )} + + selectedChannelOptions={optionalOptions!} + secureFields={_secureFields} + onResetSecureField={onResetSecureField} + errors={errors} + pathPrefix={pathPrefix} + /> + + )} + + + +
+ )} +
+ ); +} + +const getStyles = (theme: GrafanaThemeV2) => ({ + buttons: css` + & > * + * { + margin-left: ${theme.spacing(1)}; + } + `, + innerContent: css` + max-width: 536px; + `, + wrapper: css` + margin: ${theme.spacing(2, 0)}; + padding: ${theme.spacing(1)}; + border: solid 1px ${theme.colors.border.medium}; + border-radius: ${theme.shape.borderRadius(1)}; + max-width: ${theme.breakpoints.values.xl}${theme.breakpoints.unit}; + `, + topRow: css` + display: flex; + flex-direction: row; + justify-content: space-between; + `, + channelSettingsHeader: css` + margin-top: ${theme.spacing(2)}; + `, +}); diff --git a/public/app/features/alerting/unified/components/receivers/form/CollapsibleSection.tsx b/public/app/features/alerting/unified/components/receivers/form/CollapsibleSection.tsx new file mode 100644 index 00000000000..660e816c92b --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/form/CollapsibleSection.tsx @@ -0,0 +1,44 @@ +import { css } from '@emotion/css'; +import { GrafanaThemeV2 } from '@grafana/data'; +import { Icon, useStyles2 } from '@grafana/ui'; +import React, { FC, useState } from 'react'; + +interface Props { + label: string; +} + +export const CollapsibleSection: FC = ({ label, children }) => { + const styles = useStyles2(getStyles); + const [isCollapsed, setIsCollapsed] = useState(true); + + const toggleCollapse = () => setIsCollapsed(!isCollapsed); + + return ( +
+
+ +
{label}
+
+
{children}
+
+ ); +}; + +const getStyles = (theme: GrafanaThemeV2) => ({ + wrapper: css` + margin-top: ${theme.spacing(1)}; + padding-bottom: ${theme.spacing(1)}; + `, + caret: css` + margin-left: -${theme.spacing(0.5)}; // make it align with fields despite icon size + `, + heading: css` + cursor: pointer; + h6 { + display: inline-block; + } + `, + hidden: css` + display: none; + `, +}); diff --git a/public/app/features/alerting/unified/components/receivers/form/GrafanaCommonChannelSettings.tsx b/public/app/features/alerting/unified/components/receivers/form/GrafanaCommonChannelSettings.tsx new file mode 100644 index 00000000000..cd0f106b519 --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/form/GrafanaCommonChannelSettings.tsx @@ -0,0 +1,28 @@ +import { Checkbox, Field } from '@grafana/ui'; +import React, { FC } from 'react'; +import { CommonSettingsComponentProps } from '../../../types/receiver-form'; +import { useFormContext } from 'react-hook-form'; + +export const GrafanaCommonChannelSettings: FC = ({ pathPrefix, className }) => { + const { register } = useFormContext(); + return ( +
+ + + + + + +
+ ); +}; diff --git a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx new file mode 100644 index 00000000000..6f5577d4b11 --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx @@ -0,0 +1,92 @@ +import { LoadingPlaceholder } from '@grafana/ui'; +import { + AlertManagerCortexConfig, + GrafanaManagedReceiverConfig, + Receiver, +} from 'app/plugins/datasource/alertmanager/types'; +import React, { FC, useEffect, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector'; +import { fetchGrafanaNotifiersAction, updateAlertManagerConfigAction } from '../../../state/actions'; +import { GrafanaChannelValues, ReceiverFormValues } from '../../../types/receiver-form'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource'; +import { + formValuesToGrafanaReceiver, + grafanaReceiverToFormValues, + updateConfigWithReceiver, +} from '../../../utils/receiver-form'; +import { GrafanaCommonChannelSettings } from './GrafanaCommonChannelSettings'; +import { ReceiverForm } from './ReceiverForm'; + +interface Props { + alertManagerSourceName: string; + config: AlertManagerCortexConfig; + existing?: Receiver; +} + +const defaultChannelValues: GrafanaChannelValues = Object.freeze({ + __id: '', + sendReminder: true, + secureSettings: {}, + settings: {}, + secureFields: {}, + disableResolveMessage: false, + type: 'email', +}); + +export const GrafanaReceiverForm: FC = ({ existing, alertManagerSourceName, config }) => { + const grafanaNotifiers = useUnifiedAlertingSelector((state) => state.grafanaNotifiers); + + const dispatch = useDispatch(); + + useEffect(() => { + if (!(grafanaNotifiers.result || grafanaNotifiers.loading)) { + dispatch(fetchGrafanaNotifiersAction()); + } + }, [grafanaNotifiers, dispatch]); + + // transform receiver DTO to form values + const [existingValue, id2original] = useMemo((): [ + ReceiverFormValues | undefined, + Record + ] => { + if (!existing || !grafanaNotifiers.result) { + return [undefined, {}]; + } + return grafanaReceiverToFormValues(existing, grafanaNotifiers.result!); + }, [existing, grafanaNotifiers.result]); + + const onSubmit = (values: ReceiverFormValues) => { + const newReceiver = formValuesToGrafanaReceiver(values, id2original, defaultChannelValues); + dispatch( + updateAlertManagerConfigAction({ + newConfig: updateConfigWithReceiver(config, newReceiver, existing?.name), + oldConfig: config, + alertManagerSourceName: GRAFANA_RULES_SOURCE_NAME, + successMessage: existing ? 'Receiver updated.' : 'Receiver created', + redirectPath: '/alerting/notifications', + }) + ); + }; + + const takenReceiverNames = useMemo( + () => config.alertmanager_config.receivers?.map(({ name }) => name).filter((name) => name !== existing?.name) ?? [], + [config, existing] + ); + + if (grafanaNotifiers.result) { + return ( + + onSubmit={onSubmit} + initialValues={existingValue} + notifiers={grafanaNotifiers.result} + alertManagerSourceName={alertManagerSourceName} + defaultItem={defaultChannelValues} + takenReceiverNames={takenReceiverNames} + commonSettingsComponent={GrafanaCommonChannelSettings} + /> + ); + } else { + return ; + } +}; diff --git a/public/app/features/alerting/unified/components/receivers/form/OptionElement.tsx b/public/app/features/alerting/unified/components/receivers/form/OptionElement.tsx new file mode 100644 index 00000000000..bb18a43fd99 --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/form/OptionElement.tsx @@ -0,0 +1,64 @@ +import React, { FC } from 'react'; +import { Input, InputControl, Select, TextArea } from '@grafana/ui'; +import { NotificationChannelOption } from 'app/types'; +import { useFormContext } from 'react-hook-form'; + +interface Props { + option: NotificationChannelOption; + invalid?: boolean; + pathPrefix?: string; +} + +export const OptionElement: FC = ({ option, invalid, pathPrefix = '' }) => { + const { control, register } = useFormContext(); + const modelValue = option.secure + ? `${pathPrefix}secureSettings.${option.propertyName}` + : `${pathPrefix}settings.${option.propertyName}`; + switch (option.element) { + case 'input': + return ( + (option.validationRule !== '' ? validateOption(v, option.validationRule) : true), + })} + placeholder={option.placeholder} + /> + ); + + case 'select': + return ( + values[0].value} + /> + ); + + case 'textarea': + return ( +