mirror of https://github.com/grafana/grafana
Alerting: edit receivers (grafana) (#33327)
parent
7ccb022c03
commit
df4181c43a
@ -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>; |
||||
} |
||||
}; |
@ -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] |
||||
), |
||||
}; |
||||
} |
@ -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>; |
@ -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; |
||||
} |
Loading…
Reference in new issue