mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: edit receivers (grafana) (#33327)
This commit is contained in:
parent
7ccb022c03
commit
df4181c43a
@ -6,10 +6,11 @@ export interface FieldArrayProps extends UseFieldArrayProps {
|
||||
children: (api: FieldArrayApi) => JSX.Element;
|
||||
}
|
||||
|
||||
export const FieldArray: FC<FieldArrayProps> = ({ name, control, children }) => {
|
||||
export const FieldArray: FC<FieldArrayProps> = ({ 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 });
|
||||
};
|
||||
|
@ -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 = () => {
|
||||
)
|
||||
}
|
||||
</Route>
|
||||
<Route exact={true} path="/alerting/notifications/receivers/new">
|
||||
<NewReceiverView config={config} alertManagerSourceName={alertManagerSourceName} />
|
||||
</Route>
|
||||
<Route exact={true} path="/alerting/notifications/receivers/:name/edit">
|
||||
{({ match }: RouteChildrenProps<{ name: string }>) =>
|
||||
match?.params.name && (
|
||||
<EditReceiverView
|
||||
alertManagerSourceName={alertManagerSourceName}
|
||||
config={config}
|
||||
receiverName={decodeURIComponent(match?.params.name)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Route>
|
||||
</Switch>
|
||||
)}
|
||||
</AlertingPageWrapper>
|
||||
|
@ -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: {},
|
||||
|
@ -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<Props> = ({ config, receiverName, alertManagerSourceName }) => {
|
||||
const receiver = config.alertmanager_config.receivers?.find(({ name }) => name === receiverName);
|
||||
if (!receiver) {
|
||||
return (
|
||||
<InfoBox severity="error" title="Receiver not found">
|
||||
Sorry, this receiver does not seem to exit.
|
||||
</InfoBox>
|
||||
);
|
||||
}
|
||||
|
||||
if (alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME) {
|
||||
return <GrafanaReceiverForm config={config} alertManagerSourceName={alertManagerSourceName} existing={receiver} />;
|
||||
} else {
|
||||
return <p>@TODO cloud receiver editing not implemented yet</p>;
|
||||
}
|
||||
};
|
@ -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<Props> = ({ alertManagerSourceName, config }) => {
|
||||
if (alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME) {
|
||||
return <GrafanaReceiverForm alertManagerSourceName={alertManagerSourceName} config={config} />;
|
||||
} else {
|
||||
return <p>@TODO cloud receiver editing not implemented yet</p>;
|
||||
}
|
||||
};
|
@ -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 => ({
|
||||
|
@ -63,10 +63,10 @@ export const ReceiversTable: FC<Props> = ({ config, alertManagerName }) => {
|
||||
`/alerting/notifications/receivers/${encodeURIComponent(receiver.name)}/edit`,
|
||||
alertManagerName
|
||||
)}
|
||||
tooltip="edit receiver"
|
||||
tooltip="Edit contact point"
|
||||
icon="pen"
|
||||
/>
|
||||
<ActionIcon tooltip="delete receiver" icon="trash-alt" />
|
||||
<ActionIcon tooltip="Delete contact point" icon="trash-alt" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
@ -64,7 +64,15 @@ export const TemplateForm: FC<Props> = ({ 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<Values>({
|
||||
|
@ -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<R extends ChannelValues> {
|
||||
selectedChannelOptions: NotificationChannelOption[];
|
||||
secureFields: NotificationChannelSecureFields;
|
||||
|
||||
onResetSecureField: (key: string) => void;
|
||||
errors?: NestDataObject<R, FieldError>;
|
||||
pathPrefix?: string;
|
||||
}
|
||||
|
||||
export function ChannelOptions<R extends ChannelValues>({
|
||||
selectedChannelOptions,
|
||||
onResetSecureField,
|
||||
secureFields,
|
||||
errors,
|
||||
pathPrefix = '',
|
||||
}: Props<R>): JSX.Element {
|
||||
const { register, watch } = useFormContext<ReceiverFormValues<R>>();
|
||||
const currentFormValues = watch() as Record<string, any>; // 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 (
|
||||
<Field key={key}>
|
||||
<Checkbox
|
||||
name={
|
||||
option.secure
|
||||
? `${pathPrefix}secureSettings.${option.propertyName}`
|
||||
: `${pathPrefix}settings.${option.propertyName}`
|
||||
}
|
||||
ref={register()}
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
const error: FieldError | undefined = ((option.secure ? errors?.secureSettings : errors?.settings) as
|
||||
| Record<string, FieldError>
|
||||
| undefined)?.[option.propertyName];
|
||||
|
||||
return (
|
||||
<Field
|
||||
key={key}
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
invalid={!!error}
|
||||
error={error?.message}
|
||||
>
|
||||
{secureFields && secureFields[option.propertyName] ? (
|
||||
<Input
|
||||
readOnly={true}
|
||||
value="Configured"
|
||||
suffix={
|
||||
<Button
|
||||
onClick={() => onResetSecureField(option.propertyName)}
|
||||
variant="link"
|
||||
type="button"
|
||||
size="sm"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<OptionElement pathPrefix={pathPrefix} option={option} />
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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<R> {
|
||||
pathPrefix: string;
|
||||
notifiers: NotifierDTO[];
|
||||
onDuplicate: () => void;
|
||||
commonSettingsComponent: CommonSettingsComponentType;
|
||||
|
||||
secureFields?: Record<string, boolean>;
|
||||
errors?: NestDataObject<R, FieldError>;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export function ChannelSubForm<R extends ChannelValues>({
|
||||
pathPrefix,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
notifiers,
|
||||
errors,
|
||||
secureFields,
|
||||
commonSettingsComponent: CommonSettingsComponent,
|
||||
}: Props<R>): 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 (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.topRow}>
|
||||
<div>
|
||||
<Field label="Contact point type">
|
||||
<InputControl
|
||||
name={name('type')}
|
||||
as={Select}
|
||||
width={37}
|
||||
options={typeOptions}
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
onChange={(values) => values[0]?.value}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
<Button size="xs" variant="secondary" type="button" onClick={() => onDuplicate()} icon="copy">
|
||||
Duplicate
|
||||
</Button>
|
||||
{onDelete && (
|
||||
<Button size="xs" variant="secondary" type="button" onClick={() => onDelete()} icon="trash-alt">
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{notifier && (
|
||||
<div className={styles.innerContent}>
|
||||
<ChannelOptions<R>
|
||||
selectedChannelOptions={mandatoryOptions?.length ? mandatoryOptions! : optionalOptions!}
|
||||
secureFields={_secureFields}
|
||||
errors={errors}
|
||||
onResetSecureField={onResetSecureField}
|
||||
pathPrefix={pathPrefix}
|
||||
/>
|
||||
{!!(mandatoryOptions?.length && optionalOptions?.length) && (
|
||||
<CollapsibleSection label={`Optional ${notifier.name} settings`}>
|
||||
{notifier.info !== '' && (
|
||||
<Alert title="" severity="info">
|
||||
{notifier.info}
|
||||
</Alert>
|
||||
)}
|
||||
<ChannelOptions<R>
|
||||
selectedChannelOptions={optionalOptions!}
|
||||
secureFields={_secureFields}
|
||||
onResetSecureField={onResetSecureField}
|
||||
errors={errors}
|
||||
pathPrefix={pathPrefix}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
<CollapsibleSection label="Notification settings">
|
||||
<CommonSettingsComponent pathPrefix={pathPrefix} />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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)};
|
||||
`,
|
||||
});
|
@ -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<Props> = ({ label, children }) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
|
||||
const toggleCollapse = () => setIsCollapsed(!isCollapsed);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.heading} onClick={toggleCollapse}>
|
||||
<Icon className={styles.caret} size="xl" name={isCollapsed ? 'angle-right' : 'angle-down'} />
|
||||
<h6>{label}</h6>
|
||||
</div>
|
||||
<div className={isCollapsed ? styles.hidden : undefined}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
`,
|
||||
});
|
@ -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<CommonSettingsComponentProps> = ({ pathPrefix, className }) => {
|
||||
const { register } = useFormContext();
|
||||
return (
|
||||
<div className={className}>
|
||||
<Field>
|
||||
<Checkbox
|
||||
name={`${pathPrefix}disableResolveMessage`}
|
||||
ref={register()}
|
||||
label="Disable resolved message"
|
||||
description="Disable the resolve message [OK] that is sent when alerting state returns to false"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Checkbox
|
||||
name={`${pathPrefix}sendReminder`}
|
||||
ref={register()}
|
||||
label="Send reminders"
|
||||
description="Send additional notifications for triggered alerts"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -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<Props> = ({ 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<GrafanaChannelValues> | undefined,
|
||||
Record<string, GrafanaManagedReceiverConfig>
|
||||
] => {
|
||||
if (!existing || !grafanaNotifiers.result) {
|
||||
return [undefined, {}];
|
||||
}
|
||||
return grafanaReceiverToFormValues(existing, grafanaNotifiers.result!);
|
||||
}, [existing, grafanaNotifiers.result]);
|
||||
|
||||
const onSubmit = (values: ReceiverFormValues<GrafanaChannelValues>) => {
|
||||
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 (
|
||||
<ReceiverForm<GrafanaChannelValues>
|
||||
onSubmit={onSubmit}
|
||||
initialValues={existingValue}
|
||||
notifiers={grafanaNotifiers.result}
|
||||
alertManagerSourceName={alertManagerSourceName}
|
||||
defaultItem={defaultChannelValues}
|
||||
takenReceiverNames={takenReceiverNames}
|
||||
commonSettingsComponent={GrafanaCommonChannelSettings}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return <LoadingPlaceholder text="Loading notifiers..." />;
|
||||
}
|
||||
};
|
@ -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<Props> = ({ 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 (
|
||||
<Input
|
||||
invalid={invalid}
|
||||
type={option.inputType}
|
||||
name={`${modelValue}`}
|
||||
ref={register({
|
||||
required: option.required ? 'Required' : false,
|
||||
validate: (v) => (option.validationRule !== '' ? validateOption(v, option.validationRule) : true),
|
||||
})}
|
||||
placeholder={option.placeholder}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<InputControl
|
||||
as={Select}
|
||||
options={option.selectOptions}
|
||||
control={control}
|
||||
name={`${modelValue}`}
|
||||
invalid={invalid}
|
||||
onChange={(values) => values[0].value}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<TextArea
|
||||
invalid={invalid}
|
||||
name={`${modelValue}`}
|
||||
ref={register({
|
||||
required: option.required ? 'Required' : false,
|
||||
validate: (v) => (option.validationRule !== '' ? validateOption(v, option.validationRule) : true),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
console.error('Element not supported', option.element);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const validateOption = (value: string, validationRule: string) => {
|
||||
return RegExp(validationRule).test(value) ? true : 'Invalid format';
|
||||
};
|
@ -0,0 +1,133 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaThemeV2 } from '@grafana/data';
|
||||
import { Alert, Button, Field, Input, LinkButton, useStyles2 } from '@grafana/ui';
|
||||
import { useCleanup } from 'app/core/hooks/useCleanup';
|
||||
import { NotifierDTO } from 'app/types';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useForm, FormContext, NestDataObject, FieldError, Validate } from 'react-hook-form';
|
||||
import { useControlledFieldArray } from '../../../hooks/useControlledFieldArray';
|
||||
import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector';
|
||||
import { ChannelValues, CommonSettingsComponentType, ReceiverFormValues } from '../../../types/receiver-form';
|
||||
import { makeAMLink } from '../../../utils/misc';
|
||||
import { ChannelSubForm } from './ChannelSubForm';
|
||||
|
||||
interface Props<R extends ChannelValues> {
|
||||
notifiers: NotifierDTO[];
|
||||
defaultItem: R;
|
||||
alertManagerSourceName: string;
|
||||
onSubmit: (values: ReceiverFormValues<R>) => void;
|
||||
takenReceiverNames: string[]; // will validate that user entered receiver name is not one of these
|
||||
commonSettingsComponent: CommonSettingsComponentType;
|
||||
initialValues?: ReceiverFormValues<R>;
|
||||
}
|
||||
|
||||
export function ReceiverForm<R extends ChannelValues>({
|
||||
initialValues,
|
||||
defaultItem,
|
||||
notifiers,
|
||||
alertManagerSourceName,
|
||||
onSubmit,
|
||||
takenReceiverNames,
|
||||
commonSettingsComponent,
|
||||
}: Props<ChannelValues>): JSX.Element {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const defaultValues = initialValues || {
|
||||
name: '',
|
||||
items: [
|
||||
{
|
||||
...defaultItem,
|
||||
__id: String(Math.random()),
|
||||
} as any,
|
||||
],
|
||||
};
|
||||
|
||||
const formAPI = useForm<ReceiverFormValues<R>>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
useCleanup((state) => state.unifiedAlerting.saveAMConfig);
|
||||
|
||||
const { loading, error } = useUnifiedAlertingSelector((state) => state.saveAMConfig);
|
||||
|
||||
const { handleSubmit, register, errors, getValues } = formAPI;
|
||||
|
||||
const { items, append, remove } = useControlledFieldArray<R>('items', formAPI);
|
||||
|
||||
const validateNameIsAvailable: Validate = useCallback(
|
||||
(name: string) =>
|
||||
takenReceiverNames.map((name) => name.trim().toLowerCase()).includes(name.trim().toLowerCase())
|
||||
? 'Another receiver with this name already exists.'
|
||||
: true,
|
||||
[takenReceiverNames]
|
||||
);
|
||||
|
||||
return (
|
||||
<FormContext {...formAPI}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<h4 className={styles.heading}>{initialValues ? 'Update contact point' : 'Create contact point'}</h4>
|
||||
{error && (
|
||||
<Alert severity="error" title="Error saving template">
|
||||
{error.message || (error as any)?.data?.message || String(error)}
|
||||
</Alert>
|
||||
)}
|
||||
<Field label="Name" invalid={!!errors.name} error={errors.name && errors.name.message}>
|
||||
<Input
|
||||
width={39}
|
||||
name="name"
|
||||
ref={register({ required: 'Name is required', validate: { nameIsAvailable: validateNameIsAvailable } })}
|
||||
/>
|
||||
</Field>
|
||||
{items.map((item, index) => {
|
||||
const initialItem = initialValues?.items.find(({ __id }) => __id === item.__id);
|
||||
return (
|
||||
<ChannelSubForm<R>
|
||||
key={item.__id}
|
||||
onDuplicate={() => {
|
||||
const currentValues = getValues({ nest: true }).items[index];
|
||||
append({ ...currentValues, __id: String(Math.random()) });
|
||||
}}
|
||||
onDelete={() => remove(index)}
|
||||
pathPrefix={`items.${index}.`}
|
||||
notifiers={notifiers}
|
||||
secureFields={initialItem?.secureFields}
|
||||
errors={errors?.items?.[index] as NestDataObject<R, FieldError>}
|
||||
commonSettingsComponent={commonSettingsComponent}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Button type="button" icon="plus" onClick={() => append({ ...defaultItem, __id: String(Math.random()) } as R)}>
|
||||
New contact point type
|
||||
</Button>
|
||||
<div className={styles.buttons}>
|
||||
{loading && (
|
||||
<Button disabled={true} icon="fa fa-spinner" variant="primary">
|
||||
Saving...
|
||||
</Button>
|
||||
)}
|
||||
{!loading && <Button type="submit">Save contact point</Button>}
|
||||
<LinkButton
|
||||
disabled={loading}
|
||||
variant="secondary"
|
||||
href={makeAMLink('/alerting/notifications', alertManagerSourceName)}
|
||||
>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
</div>
|
||||
</form>
|
||||
</FormContext>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaThemeV2) => ({
|
||||
heading: css`
|
||||
margin: ${theme.spacing(4, 0)};
|
||||
`,
|
||||
buttons: css`
|
||||
margin-top: ${theme.spacing(4)};
|
||||
|
||||
& > * + * {
|
||||
margin-left: ${theme.spacing(1)};
|
||||
}
|
||||
`,
|
||||
});
|
@ -0,0 +1,38 @@
|
||||
import { useCallback } from 'react';
|
||||
import { FormContextValues } from 'react-hook-form';
|
||||
|
||||
/*
|
||||
* react-hook-form's own useFieldArray is uncontrolled and super buggy.
|
||||
* this is a simple controlled version. It's dead simple and more robust at the cost of re-rendering the form
|
||||
* on every change to the sub forms in the array.
|
||||
* Warning: you'll have to take care of your own unique identiifer to use as `key` for the ReactNode array.
|
||||
* Using index will cause problems.
|
||||
*/
|
||||
export function useControlledFieldArray<R>(name: string, formAPI: FormContextValues<any>) {
|
||||
const { watch, getValues, reset } = formAPI;
|
||||
|
||||
const items: R[] = watch(name);
|
||||
|
||||
return {
|
||||
items,
|
||||
append: useCallback(
|
||||
(values: R) => {
|
||||
const existingValues = getValues({ nest: true });
|
||||
reset({
|
||||
...existingValues,
|
||||
[name]: [...(existingValues[name] ?? []), values],
|
||||
});
|
||||
},
|
||||
[getValues, reset, name]
|
||||
),
|
||||
remove: useCallback(
|
||||
(index: number) => {
|
||||
const values = getValues({ nest: true });
|
||||
const items = values[name] ?? [];
|
||||
items.splice(index, 1);
|
||||
reset({ ...values, [name]: items });
|
||||
},
|
||||
[getValues, reset, name]
|
||||
),
|
||||
};
|
||||
}
|
@ -324,15 +324,17 @@ export const fetchGrafanaNotifiersAction = createAsyncThunk(
|
||||
(): Promise<NotifierDTO[]> => withSerializedError(fetchNotifiers())
|
||||
);
|
||||
|
||||
interface UpdateALertManagerConfigActionOptions {
|
||||
interface UpdateAlertManagerConfigActionOptions {
|
||||
alertManagerSourceName: string;
|
||||
oldConfig: AlertManagerCortexConfig; // it will be checked to make sure it didn't change in the meanwhile
|
||||
newConfig: AlertManagerCortexConfig;
|
||||
successMessage?: string; // show toast on success
|
||||
redirectPath?: string; // where to redirect on success
|
||||
}
|
||||
|
||||
export const updateAlertManagerConfigAction = createAsyncThunk<void, UpdateALertManagerConfigActionOptions, {}>(
|
||||
export const updateAlertManagerConfigAction = createAsyncThunk<void, UpdateAlertManagerConfigActionOptions, {}>(
|
||||
'unifiedalerting/updateAMConfig',
|
||||
({ alertManagerSourceName, oldConfig, newConfig }, thunkApi): Promise<void> =>
|
||||
({ alertManagerSourceName, oldConfig, newConfig, successMessage, redirectPath }): Promise<void> =>
|
||||
withSerializedError(
|
||||
(async () => {
|
||||
const latestConfig = await fetchAlertManagerConfig(alertManagerSourceName);
|
||||
@ -342,8 +344,12 @@ export const updateAlertManagerConfigAction = createAsyncThunk<void, UpdateALert
|
||||
);
|
||||
}
|
||||
await updateAlertmanagerConfig(alertManagerSourceName, newConfig);
|
||||
appEvents.emit(AppEvents.alertSuccess, ['Template saved.']);
|
||||
locationService.push(makeAMLink('/alerting/notifications', alertManagerSourceName));
|
||||
if (successMessage) {
|
||||
appEvents.emit(AppEvents.alertSuccess, [successMessage]);
|
||||
}
|
||||
if (redirectPath) {
|
||||
locationService.push(makeAMLink(redirectPath, alertManagerSourceName));
|
||||
}
|
||||
})()
|
||||
)
|
||||
);
|
||||
|
33
public/app/features/alerting/unified/types/receiver-form.ts
Normal file
33
public/app/features/alerting/unified/types/receiver-form.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { NotifierType } from 'app/types';
|
||||
import React from 'react';
|
||||
|
||||
export interface ChannelValues {
|
||||
__id: string; // used to correllate form values to original DTOs
|
||||
type: string;
|
||||
settings: Record<string, any>;
|
||||
secureSettings: Record<string, any>;
|
||||
secureFields: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface ReceiverFormValues<R extends ChannelValues> {
|
||||
name: string;
|
||||
items: R[];
|
||||
}
|
||||
|
||||
export interface CloudChannelValues extends ChannelValues {
|
||||
type: string;
|
||||
sendResolved: boolean;
|
||||
}
|
||||
|
||||
export interface GrafanaChannelValues extends ChannelValues {
|
||||
type: NotifierType;
|
||||
uid?: string;
|
||||
sendReminder: boolean;
|
||||
disableResolveMessage: boolean;
|
||||
}
|
||||
|
||||
export interface CommonSettingsComponentProps {
|
||||
pathPrefix: string;
|
||||
className?: string;
|
||||
}
|
||||
export type CommonSettingsComponentType = React.ComponentType<CommonSettingsComponentProps>;
|
154
public/app/features/alerting/unified/utils/receiver-form.ts
Normal file
154
public/app/features/alerting/unified/utils/receiver-form.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import {
|
||||
AlertManagerCortexConfig,
|
||||
GrafanaManagedReceiverConfig,
|
||||
Receiver,
|
||||
Route,
|
||||
} from 'app/plugins/datasource/alertmanager/types';
|
||||
import { NotifierDTO, NotifierType } from 'app/types';
|
||||
import { GrafanaChannelValues, ReceiverFormValues } from '../types/receiver-form';
|
||||
|
||||
// id to notifier
|
||||
type GrafanaChannelMap = Record<string, GrafanaManagedReceiverConfig>;
|
||||
|
||||
export function grafanaReceiverToFormValues(
|
||||
receiver: Receiver,
|
||||
notifiers: NotifierDTO[]
|
||||
): [ReceiverFormValues<GrafanaChannelValues>, GrafanaChannelMap] {
|
||||
const channelMap: GrafanaChannelMap = {};
|
||||
// giving each form receiver item a unique id so we can use it to map back to "original" items
|
||||
// as well as to use as `key` prop.
|
||||
// @TODO use uid once backend is fixed to provide it. then we can get rid of the GrafanaChannelMap
|
||||
let idCounter = 1;
|
||||
const values = {
|
||||
name: receiver.name,
|
||||
items:
|
||||
receiver.grafana_managed_receiver_configs?.map((channel) => {
|
||||
const id = String(idCounter++);
|
||||
channelMap[id] = channel;
|
||||
const notifier = notifiers.find(({ type }) => type === channel.type);
|
||||
return grafanaChannelConfigToFormChannelValues(id, channel, notifier);
|
||||
}) ?? [],
|
||||
};
|
||||
return [values, channelMap];
|
||||
}
|
||||
|
||||
export function formValuesToGrafanaReceiver(
|
||||
values: ReceiverFormValues<GrafanaChannelValues>,
|
||||
channelMap: GrafanaChannelMap,
|
||||
defaultChannelValues: GrafanaChannelValues
|
||||
): Receiver {
|
||||
return {
|
||||
name: values.name,
|
||||
grafana_managed_receiver_configs: (values.items ?? []).map((channelValues) => {
|
||||
const existing: GrafanaManagedReceiverConfig | undefined = channelMap[channelValues.__id];
|
||||
return formChannelValuesToGrafanaChannelConfig(channelValues, defaultChannelValues, values.name, existing);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// will add new receiver, or replace exisitng one
|
||||
export function updateConfigWithReceiver(
|
||||
config: AlertManagerCortexConfig,
|
||||
receiver: Receiver,
|
||||
replaceReceiverName?: string
|
||||
): AlertManagerCortexConfig {
|
||||
const oldReceivers = config.alertmanager_config.receivers ?? [];
|
||||
|
||||
// sanity check that name is not duplicated
|
||||
if (receiver.name !== replaceReceiverName && !!oldReceivers.find(({ name }) => name === receiver.name)) {
|
||||
throw new Error(`Duplicate receiver name ${receiver.name}`);
|
||||
}
|
||||
|
||||
// sanity check that existing receiver exists
|
||||
if (replaceReceiverName && !oldReceivers.find(({ name }) => name === replaceReceiverName)) {
|
||||
throw new Error(`Expected receiver ${replaceReceiverName} to exist, but did not find it in the config`);
|
||||
}
|
||||
|
||||
const updated: AlertManagerCortexConfig = {
|
||||
...config,
|
||||
alertmanager_config: {
|
||||
// @todo rename receiver on routes as necessary
|
||||
...config.alertmanager_config,
|
||||
receivers: replaceReceiverName
|
||||
? oldReceivers.map((existingReceiver) =>
|
||||
existingReceiver.name === replaceReceiverName ? receiver : existingReceiver
|
||||
)
|
||||
: [...oldReceivers, receiver],
|
||||
},
|
||||
};
|
||||
|
||||
// if receiver was renamed, rename it in routes as well
|
||||
if (updated.alertmanager_config.route && replaceReceiverName && receiver.name !== replaceReceiverName) {
|
||||
updated.alertmanager_config.route = renameReceiverInRoute(
|
||||
updated.alertmanager_config.route,
|
||||
replaceReceiverName,
|
||||
receiver.name
|
||||
);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
function renameReceiverInRoute(route: Route, oldName: string, newName: string) {
|
||||
const updated: Route = {
|
||||
...route,
|
||||
};
|
||||
if (updated.receiver === oldName) {
|
||||
updated.receiver = newName;
|
||||
}
|
||||
if (updated.routes) {
|
||||
updated.routes = updated.routes.map((route) => renameReceiverInRoute(route, oldName, newName));
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
function grafanaChannelConfigToFormChannelValues(
|
||||
id: string,
|
||||
channel: GrafanaManagedReceiverConfig,
|
||||
notifier?: NotifierDTO
|
||||
): GrafanaChannelValues {
|
||||
const values: GrafanaChannelValues = {
|
||||
__id: id,
|
||||
type: channel.type as NotifierType,
|
||||
uid: channel.uid,
|
||||
secureSettings: {},
|
||||
settings: { ...channel.settings },
|
||||
sendReminder: channel.sendReminder,
|
||||
secureFields: { ...channel.secureFields },
|
||||
disableResolveMessage: channel.disableResolveMessage,
|
||||
};
|
||||
|
||||
// work around https://github.com/grafana/alerting-squad/issues/100
|
||||
notifier?.options.forEach((option) => {
|
||||
if (option.secure && values.settings[option.propertyName]) {
|
||||
delete values.settings[option.propertyName];
|
||||
values.secureFields[option.propertyName] = true;
|
||||
}
|
||||
});
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
function formChannelValuesToGrafanaChannelConfig(
|
||||
values: GrafanaChannelValues,
|
||||
defaults: GrafanaChannelValues,
|
||||
name: string,
|
||||
existing?: GrafanaManagedReceiverConfig
|
||||
): GrafanaManagedReceiverConfig {
|
||||
const channel: GrafanaManagedReceiverConfig = {
|
||||
settings: {
|
||||
...(existing?.settings ?? {}),
|
||||
...(values.settings ?? {}),
|
||||
},
|
||||
secureSettings: values.secureSettings ?? {},
|
||||
type: values.type,
|
||||
sendReminder: values.sendReminder ?? existing?.sendReminder ?? defaults.sendReminder,
|
||||
name,
|
||||
disableResolveMessage:
|
||||
values.disableResolveMessage ?? existing?.disableResolveMessage ?? defaults.disableResolveMessage,
|
||||
};
|
||||
if (existing) {
|
||||
channel.uid = existing.uid;
|
||||
}
|
||||
return channel;
|
||||
}
|
@ -68,14 +68,14 @@ export type WebhookConfig = {
|
||||
};
|
||||
|
||||
export type GrafanaManagedReceiverConfig = {
|
||||
id?: number;
|
||||
frequency: number;
|
||||
uid?: string;
|
||||
disableResolveMessage: boolean;
|
||||
secureFields: Record<string, unknown>;
|
||||
secureFields?: Record<string, boolean>;
|
||||
secureSettings?: Record<string, unknown>;
|
||||
settings: Record<string, unknown>;
|
||||
sendReminder: boolean;
|
||||
type: string;
|
||||
uid: string;
|
||||
name: string;
|
||||
updated?: string;
|
||||
created?: string;
|
||||
};
|
||||
|
@ -387,6 +387,18 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsIndex')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/alerting/notifications/receivers/new',
|
||||
component: SafeDynamicImport(
|
||||
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsIndex')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/alerting/notifications/receivers/:id/edit',
|
||||
component: SafeDynamicImport(
|
||||
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsIndex')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/alerting/notification/new',
|
||||
component: SafeDynamicImport(
|
||||
|
Loading…
Reference in New Issue
Block a user