Alerting: edit receivers (grafana) (#33327)

This commit is contained in:
Domas 2021-04-28 12:22:48 +03:00 committed by GitHub
parent 7ccb022c03
commit df4181c43a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 936 additions and 16 deletions

View File

@ -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 });
};

View File

@ -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>

View File

@ -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: {},

View File

@ -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>;
}
};

View File

@ -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>;
}
};

View File

@ -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 => ({

View File

@ -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>
))}

View File

@ -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>({

View File

@ -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>
);
})}
</>
);
}

View File

@ -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)};
`,
});

View File

@ -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;
`,
});

View File

@ -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>
);
};

View File

@ -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..." />;
}
};

View File

@ -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';
};

View File

@ -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)};
}
`,
});

View File

@ -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]
),
};
}

View File

@ -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));
}
})()
)
);

View 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>;

View 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;
}

View File

@ -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;
};

View File

@ -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(