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;
|
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({
|
const { fields, append, prepend, remove, swap, move, insert } = useFieldArray({
|
||||||
control,
|
control,
|
||||||
name,
|
name,
|
||||||
|
...rest,
|
||||||
});
|
});
|
||||||
return children({ fields, append, prepend, remove, swap, move, insert });
|
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 { Redirect, Route, RouteChildrenProps, Switch, useLocation } from 'react-router-dom';
|
||||||
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
|
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
|
||||||
import { AlertManagerPicker } from './components/AlertManagerPicker';
|
import { AlertManagerPicker } from './components/AlertManagerPicker';
|
||||||
|
import { EditReceiverView } from './components/receivers/EditReceiverView';
|
||||||
import { EditTemplateView } from './components/receivers/EditTemplateView';
|
import { EditTemplateView } from './components/receivers/EditTemplateView';
|
||||||
|
import { NewReceiverView } from './components/receivers/NewReceiverView';
|
||||||
import { NewTemplateView } from './components/receivers/NewTemplateView';
|
import { NewTemplateView } from './components/receivers/NewTemplateView';
|
||||||
import { ReceiversAndTemplatesView } from './components/receivers/ReceiversAndTemplatesView';
|
import { ReceiversAndTemplatesView } from './components/receivers/ReceiversAndTemplatesView';
|
||||||
import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName';
|
import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName';
|
||||||
@ -76,6 +78,20 @@ const Receivers: FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
</Route>
|
</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>
|
</Switch>
|
||||||
)}
|
)}
|
||||||
</AlertingPageWrapper>
|
</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 no config has been uploaded to grafana, it returns error instead of latest config
|
||||||
if (
|
if (
|
||||||
alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME &&
|
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 {
|
return {
|
||||||
template_files: {},
|
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 => ({
|
const mockGrafanaReceiver = (type: string): GrafanaManagedReceiverConfig => ({
|
||||||
type,
|
type,
|
||||||
id: 2,
|
|
||||||
frequency: 1,
|
|
||||||
disableResolveMessage: false,
|
disableResolveMessage: false,
|
||||||
secureFields: {},
|
secureFields: {},
|
||||||
settings: {},
|
settings: {},
|
||||||
sendReminder: false,
|
sendReminder: false,
|
||||||
uid: '2',
|
uid: '2',
|
||||||
|
name: type,
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockNotifier = (type: NotifierType, name: string): NotifierDTO => ({
|
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`,
|
`/alerting/notifications/receivers/${encodeURIComponent(receiver.name)}/edit`,
|
||||||
alertManagerName
|
alertManagerName
|
||||||
)}
|
)}
|
||||||
tooltip="edit receiver"
|
tooltip="Edit contact point"
|
||||||
icon="pen"
|
icon="pen"
|
||||||
/>
|
/>
|
||||||
<ActionIcon tooltip="delete receiver" icon="trash-alt" />
|
<ActionIcon tooltip="Delete contact point" icon="trash-alt" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
@ -64,7 +64,15 @@ export const TemplateForm: FC<Props> = ({ existing, alertManagerSourceName, conf
|
|||||||
templates,
|
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>({
|
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())
|
(): Promise<NotifierDTO[]> => withSerializedError(fetchNotifiers())
|
||||||
);
|
);
|
||||||
|
|
||||||
interface UpdateALertManagerConfigActionOptions {
|
interface UpdateAlertManagerConfigActionOptions {
|
||||||
alertManagerSourceName: string;
|
alertManagerSourceName: string;
|
||||||
oldConfig: AlertManagerCortexConfig; // it will be checked to make sure it didn't change in the meanwhile
|
oldConfig: AlertManagerCortexConfig; // it will be checked to make sure it didn't change in the meanwhile
|
||||||
newConfig: AlertManagerCortexConfig;
|
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',
|
'unifiedalerting/updateAMConfig',
|
||||||
({ alertManagerSourceName, oldConfig, newConfig }, thunkApi): Promise<void> =>
|
({ alertManagerSourceName, oldConfig, newConfig, successMessage, redirectPath }): Promise<void> =>
|
||||||
withSerializedError(
|
withSerializedError(
|
||||||
(async () => {
|
(async () => {
|
||||||
const latestConfig = await fetchAlertManagerConfig(alertManagerSourceName);
|
const latestConfig = await fetchAlertManagerConfig(alertManagerSourceName);
|
||||||
@ -342,8 +344,12 @@ export const updateAlertManagerConfigAction = createAsyncThunk<void, UpdateALert
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
await updateAlertmanagerConfig(alertManagerSourceName, newConfig);
|
await updateAlertmanagerConfig(alertManagerSourceName, newConfig);
|
||||||
appEvents.emit(AppEvents.alertSuccess, ['Template saved.']);
|
if (successMessage) {
|
||||||
locationService.push(makeAMLink('/alerting/notifications', alertManagerSourceName));
|
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 = {
|
export type GrafanaManagedReceiverConfig = {
|
||||||
id?: number;
|
uid?: string;
|
||||||
frequency: number;
|
|
||||||
disableResolveMessage: boolean;
|
disableResolveMessage: boolean;
|
||||||
secureFields: Record<string, unknown>;
|
secureFields?: Record<string, boolean>;
|
||||||
|
secureSettings?: Record<string, unknown>;
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
sendReminder: boolean;
|
sendReminder: boolean;
|
||||||
type: string;
|
type: string;
|
||||||
uid: string;
|
name: string;
|
||||||
updated?: string;
|
updated?: string;
|
||||||
created?: string;
|
created?: string;
|
||||||
};
|
};
|
||||||
|
@ -387,6 +387,18 @@ export function getAppRoutes(): RouteDescriptor[] {
|
|||||||
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsIndex')
|
() => 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',
|
path: '/alerting/notification/new',
|
||||||
component: SafeDynamicImport(
|
component: SafeDynamicImport(
|
||||||
|
Loading…
Reference in New Issue
Block a user