Alerting: edit cloud receivers (#33570)

pull/33693/head^2
Domas 4 years ago committed by GitHub
parent e642506dcb
commit 10a4606315
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      public/app/features/alerting/components/OptionElement.tsx
  2. 305
      public/app/features/alerting/unified/Receivers.test.tsx
  3. 2
      public/app/features/alerting/unified/api/alertmanager.ts
  4. 7
      public/app/features/alerting/unified/components/AlertManagerPicker.tsx
  5. 3
      public/app/features/alerting/unified/components/receivers/EditReceiverView.tsx
  6. 3
      public/app/features/alerting/unified/components/receivers/NewReceiverView.tsx
  7. 9
      public/app/features/alerting/unified/components/receivers/ReceiversSection.tsx
  8. 6
      public/app/features/alerting/unified/components/receivers/ReceiversTable.test.tsx
  9. 5
      public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx
  10. 4
      public/app/features/alerting/unified/components/receivers/TemplatesTable.tsx
  11. 68
      public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx
  12. 39
      public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx
  13. 19
      public/app/features/alerting/unified/components/receivers/form/CloudCommonChannelSettings.tsx
  14. 78
      public/app/features/alerting/unified/components/receivers/form/CloudReceiverForm.tsx
  15. 15
      public/app/features/alerting/unified/components/receivers/form/CollapsibleSection.tsx
  16. 3
      public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx
  17. 75
      public/app/features/alerting/unified/components/receivers/form/OptionElement.tsx
  18. 56
      public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx
  19. 0
      public/app/features/alerting/unified/components/receivers/form/SubformOptionElement.tsx
  20. 21
      public/app/features/alerting/unified/components/receivers/form/fields/DeletedSubform.tsx
  21. 102
      public/app/features/alerting/unified/components/receivers/form/fields/KeyValueMapInput.tsx
  22. 152
      public/app/features/alerting/unified/components/receivers/form/fields/OptionField.tsx
  23. 72
      public/app/features/alerting/unified/components/receivers/form/fields/StringArrayInput.tsx
  24. 67
      public/app/features/alerting/unified/components/receivers/form/fields/SubformArrayField.tsx
  25. 66
      public/app/features/alerting/unified/components/receivers/form/fields/SubformField.tsx
  26. 30
      public/app/features/alerting/unified/components/receivers/form/fields/styles.ts
  27. 28
      public/app/features/alerting/unified/components/rules/ActionIcon.tsx
  28. 6
      public/app/features/alerting/unified/components/rules/RulesGroup.tsx
  29. 4
      public/app/features/alerting/unified/components/rules/RulesTable.tsx
  30. 9
      public/app/features/alerting/unified/components/silences/SilenceTableRow.tsx
  31. 2
      public/app/features/alerting/unified/components/silences/SilencedAlertsTableRow.tsx
  32. 61
      public/app/features/alerting/unified/hooks/useControlledFieldArray.ts
  33. 80
      public/app/features/alerting/unified/mocks.ts
  34. 1120
      public/app/features/alerting/unified/mocks/grafana-notifiers.ts
  35. 9
      public/app/features/alerting/unified/state/actions.ts
  36. 21
      public/app/features/alerting/unified/types/receiver-form.ts
  37. 18
      public/app/features/alerting/unified/utils/alertmanager-config.ts
  38. 332
      public/app/features/alerting/unified/utils/cloud-alertmanager-notifier-types.ts
  39. 44
      public/app/features/alerting/unified/utils/receiver-form.test.ts
  40. 110
      public/app/features/alerting/unified/utils/receiver-form.ts
  41. 23
      public/app/features/alerting/unified/utils/redux.ts
  42. 26
      public/app/types/alerting.ts

@ -29,7 +29,7 @@ export const OptionElement: FC<Props> = ({ control, option, register, invalid })
control={control}
name={`${modelValue}`}
render={({ field: { ref, ...field } }) => (
<Select {...field} options={option.selectOptions} invalid={invalid} />
<Select {...field} options={option.selectOptions ?? undefined} invalid={invalid} />
)}
/>
);

@ -0,0 +1,305 @@
import { configureStore } from 'app/store/configureStore';
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import Receivers from './Receivers';
import React from 'react';
import { locationService, setDataSourceSrv } from '@grafana/runtime';
import { act, render } from '@testing-library/react';
import { getAllDataSources } from './utils/config';
import { typeAsJestMock } from 'test/helpers/typeAsJestMock';
import { updateAlertManagerConfig, fetchAlertManagerConfig } from './api/alertmanager';
import { mockDataSource, MockDataSourceSrv, someCloudAlertManagerConfig, someGrafanaAlertManagerConfig } from './mocks';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
import { fetchNotifiers } from './api/grafana';
import { grafanaNotifiersMock } from './mocks/grafana-notifiers';
import { byLabelText, byRole, byTestId, byText } from 'testing-library-selector';
import userEvent from '@testing-library/user-event';
import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, ALERTMANAGER_NAME_QUERY_KEY } from './utils/constants';
import store from 'app/core/store';
jest.mock('./api/alertmanager');
jest.mock('./api/grafana');
jest.mock('./utils/config');
const mocks = {
getAllDataSources: typeAsJestMock(getAllDataSources),
api: {
fetchConfig: typeAsJestMock(fetchAlertManagerConfig),
updateConfig: typeAsJestMock(updateAlertManagerConfig),
fetchNotifiers: typeAsJestMock(fetchNotifiers),
},
};
const renderReceivers = (alertManagerSourceName?: string) => {
const store = configureStore();
locationService.push(
'/alerting/notifications' +
(alertManagerSourceName ? `?${ALERTMANAGER_NAME_QUERY_KEY}=${alertManagerSourceName}` : '')
);
return render(
<Provider store={store}>
<Router history={locationService.getHistory()}>
<Receivers />
</Router>
</Provider>
);
};
const dataSources = {
alertManager: mockDataSource({
name: 'CloudManager',
type: DataSourceType.Alertmanager,
}),
};
const ui = {
newContactPointButton: byRole('link', { name: /new contact point/i }),
saveContactButton: byRole('button', { name: /save contact point/i }),
newContactPointTypeButton: byRole('button', { name: /new contact point type/i }),
receiversTable: byTestId('receivers-table'),
templatesTable: byTestId('templates-table'),
alertManagerPicker: byTestId('alertmanager-picker'),
channelFormContainer: byTestId('item-container'),
inputs: {
name: byLabelText('Name'),
email: {
addresses: byLabelText('Addresses'),
},
hipchat: {
url: byLabelText('Hip Chat Url'),
apiKey: byLabelText('API Key'),
},
slack: {
webhookURL: byLabelText(/Webhook URL/i),
},
webhook: {
URL: byLabelText(/The endpoint to send HTTP POST requests to/i),
},
},
};
const clickSelectOption = async (selectElement: HTMLElement, optionText: string): Promise<void> => {
userEvent.click(byRole('textbox').get(selectElement));
userEvent.click(byText(optionText).get(selectElement));
};
describe('Receivers', () => {
beforeEach(() => {
jest.resetAllMocks();
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
mocks.api.fetchNotifiers.mockResolvedValue(grafanaNotifiersMock);
setDataSourceSrv(new MockDataSourceSrv(dataSources));
store.delete(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY);
});
it('Template and receiver tables are rendered, alert manager can be selected', async () => {
mocks.api.fetchConfig.mockImplementation((name) =>
Promise.resolve(name === GRAFANA_RULES_SOURCE_NAME ? someGrafanaAlertManagerConfig : someCloudAlertManagerConfig)
);
await renderReceivers();
// check that by default grafana templates & receivers are fetched rendered in appropriate tables
let receiversTable = await ui.receiversTable.find();
let templatesTable = await ui.templatesTable.find();
let templateRows = templatesTable.querySelectorAll('tbody tr');
expect(templateRows).toHaveLength(3);
expect(templateRows[0]).toHaveTextContent('first template');
expect(templateRows[1]).toHaveTextContent('second template');
expect(templateRows[2]).toHaveTextContent('third template');
let receiverRows = receiversTable.querySelectorAll('tbody tr');
expect(receiverRows[0]).toHaveTextContent('default');
expect(receiverRows[1]).toHaveTextContent('critical');
expect(receiverRows).toHaveLength(2);
expect(mocks.api.fetchConfig).toHaveBeenCalledTimes(1);
expect(mocks.api.fetchConfig).toHaveBeenCalledWith(GRAFANA_RULES_SOURCE_NAME);
expect(mocks.api.fetchNotifiers).toHaveBeenCalledTimes(1);
expect(locationService.getSearchObject()[ALERTMANAGER_NAME_QUERY_KEY]).toEqual(undefined);
// select external cloud alertmanager, check that data is retrieved and contents are rendered as appropriate
await clickSelectOption(ui.alertManagerPicker.get(), 'CloudManager');
await byText('cloud-receiver').find();
expect(mocks.api.fetchConfig).toHaveBeenCalledTimes(2);
expect(mocks.api.fetchConfig).toHaveBeenLastCalledWith('CloudManager');
receiversTable = await ui.receiversTable.find();
templatesTable = await ui.templatesTable.find();
templateRows = templatesTable.querySelectorAll('tbody tr');
expect(templateRows[0]).toHaveTextContent('foo template');
expect(templateRows).toHaveLength(1);
receiverRows = receiversTable.querySelectorAll('tbody tr');
expect(receiverRows[0]).toHaveTextContent('cloud-receiver');
expect(receiverRows).toHaveLength(1);
expect(locationService.getSearchObject()[ALERTMANAGER_NAME_QUERY_KEY]).toEqual('CloudManager');
});
it('Grafana receiver can be created', async () => {
mocks.api.fetchConfig.mockResolvedValue(someGrafanaAlertManagerConfig);
mocks.api.updateConfig.mockResolvedValue();
await renderReceivers();
// go to new contact point page
await userEvent.click(await ui.newContactPointButton.find());
await byRole('heading', { name: /create contact point/i }).find();
expect(locationService.getLocation().pathname).toEqual('/alerting/notifications/receivers/new');
// type in a name for the new receiver
await userEvent.type(byLabelText('Name').get(), 'my new receiver');
// check that default email form is rendered
await ui.inputs.name.find();
// select hipchat
clickSelectOption(byTestId('items.0.type').get(), 'HipChat');
// check that email options are gone and hipchat options appear
expect(ui.inputs.email.addresses.query()).not.toBeInTheDocument();
const urlInput = ui.inputs.hipchat.url.get();
const apiKeyInput = ui.inputs.hipchat.apiKey.get();
await userEvent.type(urlInput, 'http://hipchat');
await userEvent.type(apiKeyInput, 'foobarbaz');
// it seems react-hook-form does some async state updates after submit
await act(async () => {
await userEvent.click(ui.saveContactButton.get());
});
// see that we're back to main page and proper api calls have been made
await ui.receiversTable.find();
expect(mocks.api.updateConfig).toHaveBeenCalledTimes(1);
expect(mocks.api.fetchConfig).toHaveBeenCalledTimes(3);
expect(locationService.getLocation().pathname).toEqual('/alerting/notifications');
expect(mocks.api.updateConfig).toHaveBeenLastCalledWith(GRAFANA_RULES_SOURCE_NAME, {
...someGrafanaAlertManagerConfig,
alertmanager_config: {
...someGrafanaAlertManagerConfig.alertmanager_config,
receivers: [
...(someGrafanaAlertManagerConfig.alertmanager_config.receivers ?? []),
{
name: 'my new receiver',
grafana_managed_receiver_configs: [
{
disableResolveMessage: false,
name: 'my new receiver',
secureSettings: {},
sendReminder: true,
settings: {
apiKey: 'foobarbaz',
roomid: '',
url: 'http://hipchat',
},
type: 'hipchat',
},
],
},
],
},
});
});
it('Cloud alertmanager receiver can be edited', async () => {
mocks.api.fetchConfig.mockResolvedValue(someCloudAlertManagerConfig);
mocks.api.updateConfig.mockResolvedValue();
await renderReceivers('CloudManager');
// click edit button for the receiver
const receiversTable = await ui.receiversTable.find();
const receiverRows = receiversTable.querySelectorAll<HTMLTableRowElement>('tbody tr');
expect(receiverRows[0]).toHaveTextContent('cloud-receiver');
await userEvent.click(byTestId('edit').get(receiverRows[0]));
// check that form is open
await byRole('heading', { name: /update contact point/i }).find();
expect(locationService.getLocation().pathname).toEqual('/alerting/notifications/receivers/cloud-receiver/edit');
expect(ui.channelFormContainer.queryAll()).toHaveLength(2);
// delete the email channel
expect(ui.channelFormContainer.queryAll()).toHaveLength(2);
await userEvent.click(byTestId('items.0.delete-button').get());
expect(ui.channelFormContainer.queryAll()).toHaveLength(1);
// modify webhook url
const slackContainer = ui.channelFormContainer.get();
await userEvent.click(byText('Optional Slack settings').get(slackContainer));
userEvent.type(ui.inputs.slack.webhookURL.get(slackContainer), 'http://newgreaturl');
// add confirm button to action
await userEvent.click(byText(/Actions \(1\)/i).get(slackContainer));
await userEvent.click(await byTestId('items.1.settings.actions.0.confirm.add-button').find());
const confirmSubform = byTestId('items.1.settings.actions.0.confirm.container').get();
await userEvent.type(byLabelText('Text').get(confirmSubform), 'confirm this');
// delete a field
await userEvent.click(byText(/Fields \(2\)/i).get(slackContainer));
await userEvent.click(byTestId('items.1.settings.fields.0.delete-button').get());
await byText(/Fields \(1\)/i).get(slackContainer);
// add another channel
await userEvent.click(ui.newContactPointTypeButton.get());
await clickSelectOption(await byTestId('items.2.type').find(), 'Webhook');
await userEvent.type(await ui.inputs.webhook.URL.find(), 'http://webhookurl');
// it seems react-hook-form does some async state updates after submit
await act(async () => {
await userEvent.click(ui.saveContactButton.get());
});
// see that we're back to main page and proper api calls have been made
await ui.receiversTable.find();
expect(mocks.api.updateConfig).toHaveBeenCalledTimes(1);
expect(mocks.api.fetchConfig).toHaveBeenCalledTimes(3);
expect(locationService.getLocation().pathname).toEqual('/alerting/notifications');
expect(mocks.api.updateConfig).toHaveBeenLastCalledWith('CloudManager', {
...someCloudAlertManagerConfig,
alertmanager_config: {
...someCloudAlertManagerConfig.alertmanager_config,
receivers: [
{
name: 'cloud-receiver',
slack_configs: [
{
actions: [
{
confirm: {
text: 'confirm this',
},
text: 'action1text',
type: 'action1type',
url: 'http://action1',
},
],
api_url: 'http://slack1http://newgreaturl',
channel: '#mychannel',
fields: [
{
short: false,
title: 'field2',
value: 'text2',
},
],
link_names: false,
send_resolved: false,
short_fields: false,
},
],
webhook_configs: [
{
send_resolved: true,
url: 'http://webhookurl',
},
],
},
],
},
});
}, 10000);
});

@ -40,7 +40,7 @@ export async function fetchAlertManagerConfig(alertManagerSourceName: string): P
}
}
export async function updateAlertmanagerConfig(
export async function updateAlertManagerConfig(
alertManagerSourceName: string,
config: AlertManagerCortexConfig
): Promise<void> {

@ -39,7 +39,12 @@ export const AlertManagerPicker: FC<Props> = ({ onChange, current, disabled = fa
}
return (
<Field className={styles.field} label={disabled ? 'Alert manager' : 'Choose alert manager'} disabled={disabled}>
<Field
className={styles.field}
label={disabled ? 'Alert manager' : 'Choose alert manager'}
disabled={disabled}
data-testid="alertmanager-picker"
>
<Select
width={29}
className="ds-picker select-container"

@ -2,6 +2,7 @@ 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 { CloudReceiverForm } from './form/CloudReceiverForm';
import { GrafanaReceiverForm } from './form/GrafanaReceiverForm';
interface Props {
@ -23,6 +24,6 @@ export const EditReceiverView: FC<Props> = ({ config, receiverName, alertManager
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>;
return <CloudReceiverForm config={config} alertManagerSourceName={alertManagerSourceName} existing={receiver} />;
}
};

@ -1,6 +1,7 @@
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import React, { FC } from 'react';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { CloudReceiverForm } from './form/CloudReceiverForm';
import { GrafanaReceiverForm } from './form/GrafanaReceiverForm';
interface Props {
@ -12,6 +13,6 @@ 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>;
return <CloudReceiverForm alertManagerSourceName={alertManagerSourceName} config={config} />;
}
};

@ -1,7 +1,8 @@
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { LinkButton, useStyles2 } from '@grafana/ui';
import { Button, useStyles2 } from '@grafana/ui';
import React, { FC } from 'react';
import { Link } from 'react-router-dom';
interface Props {
title: string;
@ -27,9 +28,9 @@ export const ReceiversSection: FC<Props> = ({
<h4>{title}</h4>
<p className={styles.description}>{description}</p>
</div>
<LinkButton href={addButtonTo} icon="plus">
{addButtonLabel}
</LinkButton>
<Link to={addButtonTo}>
<Button icon="plus">{addButtonLabel}</Button>
</Link>
</div>
{children}
</>

@ -11,6 +11,8 @@ import { ReceiversTable } from './ReceiversTable';
import { fetchGrafanaNotifiersAction } from '../../state/actions';
import { NotifierDTO, NotifierType } from 'app/types';
import { byRole } from 'testing-library-selector';
import { Router } from 'react-router-dom';
import { locationService } from '@grafana/runtime';
const renderReceieversTable = async (receivers: Receiver[], notifiers: NotifierDTO[]) => {
const config: AlertManagerCortexConfig = {
@ -25,7 +27,9 @@ const renderReceieversTable = async (receivers: Receiver[], notifiers: NotifierD
return render(
<Provider store={store}>
<ReceiversTable config={config} alertManagerName="alertmanager-1" />
<Router history={locationService.getHistory()}>
<ReceiversTable config={config} alertManagerName="alertmanager-1" />
</Router>
</Provider>
);
};

@ -38,7 +38,7 @@ export const ReceiversTable: FC<Props> = ({ config, alertManagerName }) => {
addButtonLabel="New contact point"
addButtonTo={makeAMLink('/alerting/notifications/receivers/new', alertManagerName)}
>
<table className={tableStyles.table}>
<table className={tableStyles.table} data-testid="receivers-table">
<colgroup>
<col />
<col />
@ -63,7 +63,8 @@ export const ReceiversTable: FC<Props> = ({ config, alertManagerName }) => {
<td>{receiver.types.join(', ')}</td>
<td className={tableStyles.actionsCell}>
<ActionIcon
href={makeAMLink(
data-testid="edit"
to={makeAMLink(
`/alerting/notifications/receivers/${encodeURIComponent(receiver.name)}/edit`,
alertManagerName
)}

@ -26,7 +26,7 @@ export const TemplatesTable: FC<Props> = ({ config, alertManagerName }) => {
addButtonLabel="New template"
addButtonTo={makeAMLink('/alerting/notifications/templates/new', alertManagerName)}
>
<table className={tableStyles.table}>
<table className={tableStyles.table} data-testid="templates-table">
<colgroup>
<col className={tableStyles.colExpand} />
<col />
@ -59,7 +59,7 @@ export const TemplatesTable: FC<Props> = ({ config, alertManagerName }) => {
<td>{name}</td>
<td className={tableStyles.actionsCell}>
<ActionIcon
href={makeAMLink(
to={makeAMLink(
`/alerting/notifications/templates/${encodeURIComponent(name)}/edit`,
alertManagerName
)}

@ -1,11 +1,12 @@
import React from 'react';
import { Button, Checkbox, Field, Input } from '@grafana/ui';
import { OptionElement } from './OptionElement';
import { ChannelValues } from '../../../types/receiver-form';
import { useFormContext, FieldError, FieldErrors } from 'react-hook-form';
import { Button, Field, Input } from '@grafana/ui';
import { OptionField } from './fields/OptionField';
import { ChannelValues, ReceiverFormValues } from '../../../types/receiver-form';
import { useFormContext, FieldError, FieldErrors, DeepMap } from 'react-hook-form';
import { NotificationChannelOption, NotificationChannelSecureFields } from 'app/types';
export interface Props<R extends ChannelValues> {
defaultValues: R;
selectedChannelOptions: NotificationChannelOption[];
secureFields: NotificationChannelSecureFields;
@ -15,13 +16,14 @@ export interface Props<R extends ChannelValues> {
}
export function ChannelOptions<R extends ChannelValues>({
defaultValues,
selectedChannelOptions,
onResetSecureField,
secureFields,
errors,
pathPrefix = '',
}: Props<R>): JSX.Element {
const { register, watch } = useFormContext();
const { watch } = useFormContext<ReceiverFormValues<R>>();
const currentFormValues = watch() as Record<string, any>; // react hook form types ARE LYING!
return (
<>
@ -29,43 +31,15 @@ export function ChannelOptions<R extends ChannelValues>({
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}`];
const selectedOptionValue = currentFormValues[`${pathPrefix}settings.${option.showWhen.field}`];
if (option.showWhen.field && selectedOptionValue !== option.showWhen.is) {
return null;
}
if (option.element === 'checkbox') {
if (secureFields && secureFields[option.propertyName]) {
return (
<Field key={key}>
<Checkbox
{...register(
option.secure
? `${pathPrefix}secureSettings.${option.propertyName}`
: `${pathPrefix}settings.${option.propertyName}`
)}
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] ? (
<Field key={key} label={option.label} description={option.description || undefined}>
<Input
readOnly={true}
value="Configured"
@ -80,10 +54,24 @@ export function ChannelOptions<R extends ChannelValues>({
</Button>
}
/>
) : (
<OptionElement pathPrefix={pathPrefix} option={option} />
)}
</Field>
</Field>
);
}
const error: FieldError | DeepMap<any, FieldError> | undefined = ((option.secure
? errors?.secureSettings
: errors?.settings) as DeepMap<any, FieldError> | undefined)?.[option.propertyName];
const defaultValue = defaultValues?.settings?.[option.propertyName];
return (
<OptionField
defaultValue={defaultValue}
key={key}
error={error}
pathPrefix={option.secure ? `${pathPrefix}secureSettings.` : `${pathPrefix}settings.`}
option={option}
/>
);
})}
</>

@ -1,6 +1,6 @@
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { NotifierDTO } from 'app/types';
import React, { useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { css } from '@emotion/css';
import { Alert, Button, Field, InputControl, Select, useStyles2 } from '@grafana/ui';
import { useFormContext, FieldErrors } from 'react-hook-form';
@ -32,9 +32,13 @@ export function ChannelSubForm<R extends ChannelValues>({
}: Props<R>): JSX.Element {
const styles = useStyles2(getStyles);
const name = (fieldName: string) => `${pathPrefix}${fieldName}`;
const { control, watch } = useFormContext();
const { control, watch, register } = useFormContext();
const selectedType = watch(name('type')) ?? defaultValues.type; // nope, setting "default" does not work at all.
useEffect(() => {
register(`${pathPrefix}.__id`);
}, [register, pathPrefix]);
const [_secureFields, setSecureFields] = useState(secureFields ?? {});
const onResetSecureField = (key: string) => {
@ -47,10 +51,12 @@ export function ChannelSubForm<R extends ChannelValues>({
const typeOptions = useMemo(
(): SelectableValue[] =>
notifiers.map(({ name, type }) => ({
label: name,
value: type,
})),
notifiers
.map(({ name, type }) => ({
label: name,
value: type,
}))
.sort((a, b) => a.label.localeCompare(b.label)),
[notifiers]
);
@ -61,16 +67,10 @@ export function ChannelSubForm<R extends ChannelValues>({
const optionalOptions = notifier?.options.filter((o) => !o.required);
return (
<div className={styles.wrapper}>
<div className={styles.wrapper} data-testid="item-container">
<div className={styles.topRow}>
<div>
<InputControl
name={name('__id')}
render={({ field }) => <input type="hidden" {...field} />}
defaultValue={defaultValues.__id}
control={control}
/>
<Field label="Contact point type">
<Field label="Contact point type" data-testid={`${pathPrefix}type`}>
<InputControl
name={name('type')}
defaultValue={defaultValues.type}
@ -87,7 +87,14 @@ export function ChannelSubForm<R extends ChannelValues>({
Duplicate
</Button>
{onDelete && (
<Button size="xs" variant="secondary" type="button" onClick={() => onDelete()} icon="trash-alt">
<Button
data-testid={`${pathPrefix}delete-button`}
size="xs"
variant="secondary"
type="button"
onClick={() => onDelete()}
icon="trash-alt"
>
Delete
</Button>
)}
@ -96,6 +103,7 @@ export function ChannelSubForm<R extends ChannelValues>({
{notifier && (
<div className={styles.innerContent}>
<ChannelOptions<R>
defaultValues={defaultValues}
selectedChannelOptions={mandatoryOptions?.length ? mandatoryOptions! : optionalOptions!}
secureFields={_secureFields}
errors={errors}
@ -110,6 +118,7 @@ export function ChannelSubForm<R extends ChannelValues>({
</Alert>
)}
<ChannelOptions<R>
defaultValues={defaultValues}
selectedChannelOptions={optionalOptions!}
secureFields={_secureFields}
onResetSecureField={onResetSecureField}

@ -0,0 +1,19 @@
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 CloudCommonChannelSettings: FC<CommonSettingsComponentProps> = ({ pathPrefix, className }) => {
const { register } = useFormContext();
return (
<div className={className}>
<Field>
<Checkbox
{...register(`${pathPrefix}sendResolved`)}
label="Send resolved"
description="Whether or not to notify about resolved alerts."
/>
</Field>
</div>
);
};

@ -0,0 +1,78 @@
import { Alert } from '@grafana/ui';
import { AlertManagerCortexConfig, Receiver } from 'app/plugins/datasource/alertmanager/types';
import React, { FC, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { updateAlertManagerConfigAction } from '../../../state/actions';
import { CloudChannelValues, ReceiverFormValues, CloudChannelMap } from '../../../types/receiver-form';
import { cloudNotifierTypes } from '../../../utils/cloud-alertmanager-notifier-types';
import { makeAMLink } from '../../../utils/misc';
import {
cloudReceiverToFormValues,
formValuesToCloudReceiver,
updateConfigWithReceiver,
} from '../../../utils/receiver-form';
import { CloudCommonChannelSettings } from './CloudCommonChannelSettings';
import { ReceiverForm } from './ReceiverForm';
interface Props {
alertManagerSourceName: string;
config: AlertManagerCortexConfig;
existing?: Receiver;
}
const defaultChannelValues: CloudChannelValues = Object.freeze({
__id: '',
sendResolved: true,
secureSettings: {},
settings: {},
secureFields: {},
type: 'email',
});
export const CloudReceiverForm: FC<Props> = ({ existing, alertManagerSourceName, config }) => {
const dispatch = useDispatch();
// transform receiver DTO to form values
const [existingValue] = useMemo((): [ReceiverFormValues<CloudChannelValues> | undefined, CloudChannelMap] => {
if (!existing) {
return [undefined, {}];
}
return cloudReceiverToFormValues(existing, cloudNotifierTypes);
}, [existing]);
const onSubmit = (values: ReceiverFormValues<CloudChannelValues>) => {
const newReceiver = formValuesToCloudReceiver(values, defaultChannelValues);
dispatch(
updateAlertManagerConfigAction({
newConfig: updateConfigWithReceiver(config, newReceiver, existing?.name),
oldConfig: config,
alertManagerSourceName,
successMessage: existing ? 'Contact point updated.' : 'Contact point created.',
redirectPath: makeAMLink('/alerting/notifications', alertManagerSourceName),
})
);
};
const takenReceiverNames = useMemo(
() => config.alertmanager_config.receivers?.map(({ name }) => name).filter((name) => name !== existing?.name) ?? [],
[config, existing]
);
return (
<>
<Alert title="Info" severity="info">
Note that empty string values will be replaced with global defaults were appropriate.
</Alert>
<ReceiverForm<CloudChannelValues>
config={config}
onSubmit={onSubmit}
initialValues={existingValue}
notifiers={cloudNotifierTypes}
alertManagerSourceName={alertManagerSourceName}
defaultItem={defaultChannelValues}
takenReceiverNames={takenReceiverNames}
commonSettingsComponent={CloudCommonChannelSettings}
/>
</>
);
};

@ -1,24 +1,27 @@
import { css } from '@emotion/css';
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, useStyles2 } from '@grafana/ui';
import React, { FC, useState } from 'react';
interface Props {
label: string;
description?: string;
className?: string;
}
export const CollapsibleSection: FC<Props> = ({ label, children }) => {
export const CollapsibleSection: FC<Props> = ({ label, description, children, className }) => {
const styles = useStyles2(getStyles);
const [isCollapsed, setIsCollapsed] = useState(true);
const toggleCollapse = () => setIsCollapsed(!isCollapsed);
return (
<div className={styles.wrapper}>
<div className={cx(styles.wrapper, className)}>
<div className={styles.heading} onClick={toggleCollapse}>
<Icon className={styles.caret} size="xl" name={isCollapsed ? 'angle-right' : 'angle-down'} />
<h6>{label}</h6>
</div>
{description && <p className={styles.description}>{description}</p>}
<div className={isCollapsed ? styles.hidden : undefined}>{children}</div>
</div>
);
@ -41,4 +44,10 @@ const getStyles = (theme: GrafanaTheme2) => ({
hidden: css`
display: none;
`,
description: css`
color: ${theme.colors.text.secondary};
font-size: ${theme.typography.size.sm};
font-weight: ${theme.typography.fontWeightRegular};
margin: 0;
`,
});

@ -63,7 +63,7 @@ export const GrafanaReceiverForm: FC<Props> = ({ existing, alertManagerSourceNam
newConfig: updateConfigWithReceiver(config, newReceiver, existing?.name),
oldConfig: config,
alertManagerSourceName: GRAFANA_RULES_SOURCE_NAME,
successMessage: existing ? 'Receiver updated.' : 'Receiver created',
successMessage: existing ? 'Contact point updated.' : 'Contact point created',
redirectPath: '/alerting/notifications',
})
);
@ -77,6 +77,7 @@ export const GrafanaReceiverForm: FC<Props> = ({ existing, alertManagerSourceNam
if (grafanaNotifiers.result) {
return (
<ReceiverForm<GrafanaChannelValues>
config={config}
onSubmit={onSubmit}
initialValues={existingValue}
notifiers={grafanaNotifiers.result}

@ -1,75 +0,0 @@
import React, { FC, useEffect } 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, unregister } = useFormContext();
const modelValue = option.secure
? `${pathPrefix}secureSettings.${option.propertyName}`
: `${pathPrefix}settings.${option.propertyName}`;
// workaround for https://github.com/react-hook-form/react-hook-form/issues/4993#issuecomment-829012506
useEffect(
() => () => {
unregister(modelValue);
},
[unregister, modelValue]
);
switch (option.element) {
case 'input':
return (
<Input
invalid={invalid}
type={option.inputType}
{...register(`${modelValue}`, {
required: option.required ? 'Required' : false,
validate: (v) => (option.validationRule !== '' ? validateOption(v, option.validationRule) : true),
})}
placeholder={option.placeholder}
/>
);
case 'select':
return (
<InputControl
render={({ field: { onChange, ref, ...field } }) => (
<Select
{...field}
options={option.selectOptions}
invalid={invalid}
onChange={(value) => onChange(value.value)}
/>
)}
control={control}
name={`${modelValue}`}
/>
);
case 'textarea':
return (
<TextArea
{...register(`${modelValue}`, {
required: option.required ? 'Required' : false,
validate: (v) => (option.validationRule !== '' ? validateOption(v, option.validationRule) : true),
})}
invalid={invalid}
/>
);
default:
console.error('Element not supported', option.element);
return null;
}
};
const validateOption = (value: string, validationRule: string) => {
return RegExp(validationRule).test(value) ? true : 'Invalid format';
};

@ -2,15 +2,19 @@ import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, Button, Field, Input, LinkButton, useStyles2 } from '@grafana/ui';
import { useCleanup } from 'app/core/hooks/useCleanup';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import { NotifierDTO } from 'app/types';
import React, { useCallback } from 'react';
import { useForm, FormProvider, FieldErrors, Validate, useFieldArray } from 'react-hook-form';
import { useForm, FormProvider, FieldErrors, 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';
import { DeletedSubForm } from './fields/DeletedSubform';
interface Props<R extends ChannelValues> {
config: AlertManagerCortexConfig;
notifiers: NotifierDTO[];
defaultItem: R;
alertManagerSourceName: string;
@ -21,6 +25,7 @@ interface Props<R extends ChannelValues> {
}
export function ReceiverForm<R extends ChannelValues>({
config,
initialValues,
defaultItem,
notifiers,
@ -28,7 +33,7 @@ export function ReceiverForm<R extends ChannelValues>({
onSubmit,
takenReceiverNames,
commonSettingsComponent,
}: Props<ChannelValues>): JSX.Element {
}: Props<R>): JSX.Element {
const styles = useStyles2(getStyles);
const defaultValues = initialValues || {
@ -42,7 +47,8 @@ export function ReceiverForm<R extends ChannelValues>({
};
const formAPI = useForm<ReceiverFormValues<R>>({
defaultValues,
// making a copy here beacuse react-hook-form will mutate these, and break if the object is frozen. for real.
defaultValues: JSON.parse(JSON.stringify(defaultValues)),
});
useCleanup((state) => state.unifiedAlerting.saveAMConfig);
@ -54,13 +60,9 @@ export function ReceiverForm<R extends ChannelValues>({
register,
formState: { errors },
getValues,
control,
} = formAPI;
const { fields, append, remove } = useFieldArray({
control,
name: 'items' as any, // bug in types
});
const { fields, append, remove } = useControlledFieldArray<R>({ name: 'items', formAPI, softDelete: true });
const validateNameIsAvailable: Validate<string> = useCallback(
(name: string) =>
@ -70,17 +72,30 @@ export function ReceiverForm<R extends ChannelValues>({
[takenReceiverNames]
);
const submitCallback = (values: ReceiverFormValues<R>) => {
onSubmit({
...values,
items: values.items.filter((item) => !item.__deleted),
});
};
return (
<FormProvider {...formAPI}>
<form onSubmit={handleSubmit(onSubmit)}>
{!config.alertmanager_config.route && (
<Alert severity="warning" title="Attention">
Because there is no default policy configured yet, this contact point will automatically be set as default.
</Alert>
)}
<form onSubmit={handleSubmit(submitCallback)}>
<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 severity="error" title="Error saving receiver">
{error.message || String(error)}
</Alert>
)}
<Field label="Name" invalid={!!errors.name} error={errors.name && errors.name.message}>
<Input
id="name"
{...register('name', {
required: 'Name is required',
validate: { nameIsAvailable: validateNameIsAvailable },
@ -88,18 +103,22 @@ export function ReceiverForm<R extends ChannelValues>({
width={39}
/>
</Field>
{fields.map((field: R & { id: string }, index) => {
{fields.map((field, index) => {
const pathPrefix = `items.${index}.`;
if (field.__deleted) {
return <DeletedSubForm key={field.__id} pathPrefix={pathPrefix} />;
}
const initialItem = initialValues?.items.find(({ __id }) => __id === field.__id);
return (
<ChannelSubForm<R>
defaultValues={field}
key={field.id}
key={field.__id}
onDuplicate={() => {
const currentValues: R = getValues().items[index];
append({ ...currentValues, __id: String(Math.random()) });
}}
onDelete={() => remove(index)}
pathPrefix={`items.${index}.`}
pathPrefix={pathPrefix}
notifiers={notifiers}
secureFields={initialItem?.secureFields}
errors={errors?.items?.[index] as FieldErrors<R>}
@ -107,7 +126,12 @@ export function ReceiverForm<R extends ChannelValues>({
/>
);
})}
<Button type="button" icon="plus" onClick={() => append({ ...defaultItem, __id: String(Math.random()) } as R)}>
<Button
type="button"
icon="plus"
variant="secondary"
onClick={() => append({ ...defaultItem, __id: String(Math.random()) } as R)}
>
New contact point type
</Button>
<div className={styles.buttons}>
@ -119,8 +143,8 @@ export function ReceiverForm<R extends ChannelValues>({
{!loading && <Button type="submit">Save contact point</Button>}
<LinkButton
disabled={loading}
variant="secondary"
fill="outline"
variant="secondary"
href={makeAMLink('alerting/notifications', alertManagerSourceName)}
>
Cancel

@ -0,0 +1,21 @@
import React, { useEffect } from 'react';
import { useFormContext } from 'react-hook-form';
interface Props {
pathPrefix: string;
}
// we can't drop the deleted item from list entirely because
// there will be a rece condition with register/unregister calls in react-hook-form
// and fields will become randomly erroneously unregistered
export function DeletedSubForm({ pathPrefix }: Props): JSX.Element {
const { register } = useFormContext();
// required to be registered or react-hook-form will randomly drop the values when it feels like it
useEffect(() => {
register(`${pathPrefix}.__id`);
register(`${pathPrefix}.__deleted`);
}, [register, pathPrefix]);
return <></>;
}

@ -0,0 +1,102 @@
import React, { FC, useEffect, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { css } from '@emotion/css';
import { Button, Input, useStyles2 } from '@grafana/ui';
import { ActionIcon } from '../../../rules/ActionIcon';
interface Props {
value?: Record<string, string>;
onChange: (value: Record<string, string>) => void;
}
export const KeyValueMapInput: FC<Props> = ({ value, onChange }) => {
const styles = useStyles2(getStyles);
const [pairs, setPairs] = useState(recordToPairs(value));
useEffect(() => setPairs(recordToPairs(value)), [value]);
const emitChange = (pairs: Array<[string, string]>) => {
onChange(pairsToRecord(pairs));
};
const deleteItem = (index: number) => {
const newPairs = pairs.slice();
const removed = newPairs.splice(index, 1)[0];
setPairs(newPairs);
if (removed[0]) {
emitChange(newPairs);
}
};
const updatePair = (values: [string, string], index: number) => {
const old = pairs[index];
const newPairs = pairs.map((pair, i) => (i === index ? values : pair));
setPairs(newPairs);
if (values[0] || old[0]) {
emitChange(newPairs);
}
};
return (
<div>
{!!pairs.length && (
<table className={styles.table}>
<thead>
<tr>
<th>Name</th>
<th>Value</th>
<th></th>
</tr>
</thead>
<tbody>
{pairs.map(([key, value], index) => (
<tr key={index}>
<td>
<Input value={key} onChange={(e) => updatePair([e.currentTarget.value, value], index)} />
</td>
<td>
<Input value={value} onChange={(e) => updatePair([key, e.currentTarget.value], index)} />
</td>
<td>
<ActionIcon icon="trash-alt" tooltip="delete" onClick={() => deleteItem(index)} />
</td>
</tr>
))}
</tbody>
</table>
)}
<Button
className={styles.addButton}
type="button"
variant="secondary"
icon="plus"
size="sm"
onClick={() => setPairs([...pairs, ['', '']])}
>
Add
</Button>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
addButton: css`
margin-top: ${theme.spacing(1)};
`,
table: css`
tbody td {
padding: 0 ${theme.spacing(1)} ${theme.spacing(1)} 0;
}
`,
});
const pairsToRecord = (pairs: Array<[string, string]>): Record<string, string> => {
const record: Record<string, string> = {};
for (const [key, value] of pairs) {
if (key) {
record[key] = value;
}
}
return record;
};
const recordToPairs = (obj?: Record<string, string>): Array<[string, string]> => Object.entries(obj ?? {});

@ -0,0 +1,152 @@
import React, { FC, useEffect } from 'react';
import { Checkbox, Field, Input, InputControl, Select, TextArea } from '@grafana/ui';
import { NotificationChannelOption } from 'app/types';
import { useFormContext, FieldError, DeepMap } from 'react-hook-form';
import { SubformField } from './SubformField';
import { css } from '@emotion/css';
import { KeyValueMapInput } from './KeyValueMapInput';
import { SubformArrayField } from './SubformArrayField';
import { StringArrayInput } from './StringArrayInput';
interface Props {
defaultValue: any;
option: NotificationChannelOption;
invalid?: boolean;
pathPrefix: string;
error?: FieldError | DeepMap<any, FieldError>;
}
export const OptionField: FC<Props> = ({ option, invalid, pathPrefix, error, defaultValue }) => {
if (option.element === 'subform') {
return (
<SubformField
defaultValue={defaultValue}
option={option}
errors={error as DeepMap<any, FieldError> | undefined}
pathPrefix={pathPrefix}
/>
);
}
if (option.element === 'subform_array') {
return (
<SubformArrayField
defaultValues={defaultValue}
option={option}
pathPrefix={pathPrefix}
errors={error as Array<DeepMap<any, FieldError>> | undefined}
/>
);
}
return (
<Field
label={option.element !== 'checkbox' ? option.label : undefined}
description={option.description || undefined}
invalid={!!error}
error={error?.message}
>
<OptionInput
id={`${pathPrefix}${option.propertyName}`}
defaultValue={defaultValue}
option={option}
invalid={invalid}
pathPrefix={pathPrefix}
/>
</Field>
);
};
const OptionInput: FC<Props & { id: string }> = ({ option, invalid, id, pathPrefix = '' }) => {
const { control, register, unregister } = useFormContext();
const name = `${pathPrefix}${option.propertyName}`;
// workaround for https://github.com/react-hook-form/react-hook-form/issues/4993#issuecomment-829012506
useEffect(
() => () => {
unregister(name, { keepValue: false });
},
[unregister, name]
);
switch (option.element) {
case 'checkbox':
return (
<Checkbox
id={id}
className={styles.checkbox}
{...register(name)}
label={option.label}
description={option.description}
/>
);
case 'input':
return (
<Input
id={id}
invalid={invalid}
type={option.inputType}
{...register(name, {
required: option.required ? 'Required' : false,
validate: (v) => (option.validationRule !== '' ? validateOption(v, option.validationRule) : true),
})}
placeholder={option.placeholder}
/>
);
case 'select':
return (
<InputControl
render={({ field: { onChange, ref, ...field } }) => (
<Select
{...field}
options={option.selectOptions ?? undefined}
invalid={invalid}
onChange={(value) => onChange(value.value)}
/>
)}
control={control}
name={name}
/>
);
case 'textarea':
return (
<TextArea
id={id}
invalid={invalid}
{...register(name, {
required: option.required ? 'Required' : false,
validate: (v) => (option.validationRule !== '' ? validateOption(v, option.validationRule) : true),
})}
/>
);
case 'string_array':
return (
<InputControl
render={({ field: { value, onChange } }) => <StringArrayInput value={value} onChange={onChange} />}
control={control}
name={name}
/>
);
case 'key_value_map':
return (
<InputControl
render={({ field: { value, onChange } }) => <KeyValueMapInput value={value} onChange={onChange} />}
control={control}
name={name}
/>
);
default:
console.error('Element not supported', option.element);
return null;
}
};
const styles = {
checkbox: css`
height: auto; // native chekbox has fixed height which does not take into account description
`,
};
const validateOption = (value: string, validationRule: string) => {
return RegExp(validationRule).test(value) ? true : 'Invalid format';
};

@ -0,0 +1,72 @@
import React, { FC } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { css } from '@emotion/css';
import { Button, Input, useStyles2 } from '@grafana/ui';
import { ActionIcon } from '../../../rules/ActionIcon';
interface Props {
value?: string[];
onChange: (value: string[]) => void;
}
export const StringArrayInput: FC<Props> = ({ value, onChange }) => {
const styles = useStyles2(getStyles);
const deleteItem = (index: number) => {
if (!value) {
return;
}
const newValue = value.slice();
newValue.splice(index, 1);
onChange(newValue);
};
const updateValue = (itemValue: string, index: number) => {
if (!value) {
return;
}
onChange(value.map((v, i) => (i === index ? itemValue : v)));
};
return (
<div>
{!!value?.length &&
value.map((v, index) => (
<div key={index} className={styles.row}>
<Input value={v} onChange={(e) => updateValue(e.currentTarget.value, index)} />
<ActionIcon
className={styles.deleteIcon}
icon="trash-alt"
tooltip="delete"
onClick={() => deleteItem(index)}
/>
</div>
))}
<Button
className={styles.addButton}
type="button"
variant="secondary"
icon="plus"
size="sm"
onClick={() => onChange([...(value ?? []), ''])}
>
Add
</Button>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
row: css`
display: flex;
flex-direction: row;
margin-bottom: ${theme.spacing(1)};
align-items: center;
`,
deleteIcon: css`
margin-left: ${theme.spacing(1)};
`,
addButton: css`
margin-top: ${theme.spacing(1)};
`,
});

@ -0,0 +1,67 @@
import React, { FC } from 'react';
import { NotificationChannelOption } from 'app/types';
import { FieldError, DeepMap, useFormContext } from 'react-hook-form';
import { Button, useStyles2 } from '@grafana/ui';
import { CollapsibleSection } from '../CollapsibleSection';
import { ActionIcon } from '../../../rules/ActionIcon';
import { OptionField } from './OptionField';
import { useControlledFieldArray } from 'app/features/alerting/unified/hooks/useControlledFieldArray';
import { getReceiverFormFieldStyles } from './styles';
interface Props {
defaultValues?: any[];
option: NotificationChannelOption;
pathPrefix: string;
errors?: Array<DeepMap<any, FieldError>>;
}
export const SubformArrayField: FC<Props> = ({ option, pathPrefix, errors, defaultValues }) => {
const styles = useStyles2(getReceiverFormFieldStyles);
const path = `${pathPrefix}${option.propertyName}`;
const formAPI = useFormContext();
const { fields, append, remove } = useControlledFieldArray({ name: path, formAPI, defaults: defaultValues });
return (
<div className={styles.wrapper}>
<CollapsibleSection
className={styles.collapsibleSection}
label={`${option.label} (${fields.length})`}
description={option.description}
>
{(fields ?? defaultValues ?? []).map((field, itemIndex) => {
return (
<div key={itemIndex} className={styles.wrapper}>
<ActionIcon
data-testid={`${path}.${itemIndex}.delete-button`}
icon="trash-alt"
tooltip="delete"
onClick={() => remove(itemIndex)}
className={styles.deleteIcon}
/>
{option.subformOptions?.map((option) => (
<OptionField
defaultValue={field?.[option.propertyName]}
key={option.propertyName}
option={option}
pathPrefix={`${path}.${itemIndex}.`}
error={errors?.[itemIndex]?.[option.propertyName]}
/>
))}
</div>
);
})}
<Button
data-testid={`${path}.add-button`}
className={styles.addButton}
type="button"
variant="secondary"
icon="plus"
size="sm"
onClick={() => append({ __id: String(Math.random()) })}
>
Add
</Button>
</CollapsibleSection>
</div>
);
};

@ -0,0 +1,66 @@
import React, { FC, useState } from 'react';
import { NotificationChannelOption } from 'app/types';
import { FieldError, DeepMap, useFormContext } from 'react-hook-form';
import { OptionField } from './OptionField';
import { Button, useStyles2 } from '@grafana/ui';
import { ActionIcon } from '../../../rules/ActionIcon';
import { getReceiverFormFieldStyles } from './styles';
interface Props {
defaultValue: any;
option: NotificationChannelOption;
pathPrefix: string;
errors?: DeepMap<any, FieldError>;
}
export const SubformField: FC<Props> = ({ option, pathPrefix, errors, defaultValue }) => {
const styles = useStyles2(getReceiverFormFieldStyles);
const name = `${pathPrefix}${option.propertyName}`;
const { watch } = useFormContext();
const _watchValue = watch(name);
const value = _watchValue === undefined ? defaultValue : _watchValue;
const [show, setShow] = useState(!!value);
return (
<div className={styles.wrapper} data-testid={`${name}.container`}>
<h6>{option.label}</h6>
{option.description && <p className={styles.description}>{option.description}</p>}
{show && (
<>
<ActionIcon
data-testid={`${name}.delete-button`}
icon="trash-alt"
tooltip="delete"
onClick={() => setShow(false)}
className={styles.deleteIcon}
/>
{(option.subformOptions ?? []).map((subOption) => {
return (
<OptionField
defaultValue={defaultValue?.[subOption.propertyName]}
key={subOption.propertyName}
option={subOption}
pathPrefix={`${name}.`}
error={errors?.[subOption.propertyName]}
/>
);
})}
</>
)}
{!show && (
<Button
className={styles.addButton}
type="button"
variant="secondary"
icon="plus"
size="sm"
onClick={() => setShow(true)}
data-testid={`${name}.add-button`}
>
Add
</Button>
)}
</div>
);
};

@ -0,0 +1,30 @@
import { GrafanaTheme2 } from '@grafana/data';
import { css } from '@emotion/css';
export const getReceiverFormFieldStyles = (theme: GrafanaTheme2) => ({
collapsibleSection: css`
margin: 0;
padding: 0;
`,
wrapper: css`
margin: ${theme.spacing(2, 0)};
padding: ${theme.spacing(1)};
border: solid 1px ${theme.colors.border.medium};
border-radius: ${theme.shape.borderRadius(1)};
position: relative;
`,
description: css`
color: ${theme.colors.text.secondary};
font-size: ${theme.typography.size.sm};
font-weight: ${theme.typography.fontWeightRegular};
margin: 0;
`,
deleteIcon: css`
position: absolute;
right: ${theme.spacing(1)};
top: ${theme.spacing(1)};
`,
addButton: css`
margin-top: ${theme.spacing(1)};
`,
});

@ -2,29 +2,41 @@ import { Icon, IconName, useStyles, Tooltip } from '@grafana/ui';
import { PopoverContent } from '@grafana/ui/src/components/Tooltip/Tooltip';
import { TooltipPlacement } from '@grafana/ui/src/components/Tooltip/PopoverController';
import React, { FC } from 'react';
import { css } from '@emotion/css';
import { css, cx } from '@emotion/css';
import { Link } from 'react-router-dom';
interface Props {
tooltip: PopoverContent;
icon: IconName;
className?: string;
tooltipPlacement?: TooltipPlacement;
href?: string;
to?: string;
target?: string;
onClick?: (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => void;
onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
'data-testid'?: string;
}
export const ActionIcon: FC<Props> = ({ tooltip, icon, href, target, onClick, tooltipPlacement = 'top' }) => {
const iconEl = <Icon className={useStyles(getStyle)} name={icon} />;
export const ActionIcon: FC<Props> = ({
tooltip,
icon,
to,
target,
onClick,
className,
tooltipPlacement = 'top',
...rest
}) => {
const iconEl = <Icon className={cx(useStyles(getStyle), className)} onClick={onClick} name={icon} {...rest} />;
return (
<Tooltip content={tooltip} placement={tooltipPlacement}>
{(() => {
if (href || onClick) {
if (to) {
return (
<a href={href} onClick={onClick} target={target}>
<Link to={to} target={target}>
{iconEl}
</a>
</Link>
);
}
return iconEl;

@ -69,15 +69,13 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace }) => {
const folderUID = rulerRule && isGrafanaRulerRule(rulerRule) && rulerRule.grafana_alert.namespace_uid;
if (folderUID) {
const baseUrl = `dashboards/f/${folderUID}/${kbn.slugifyForUrl(namespace.name)}`;
actionIcons.push(
<ActionIcon key="edit" icon="pen" tooltip="edit" href={baseUrl + '/settings'} target="__blank" />
);
actionIcons.push(<ActionIcon key="edit" icon="pen" tooltip="edit" to={baseUrl + '/settings'} target="__blank" />);
actionIcons.push(
<ActionIcon
key="manage-perms"
icon="lock"
tooltip="manage permissions"
href={baseUrl + '/permissions'}
to={baseUrl + '/permissions'}
target="__blank"
/>
);

@ -138,14 +138,14 @@ export const RulesTable: FC<Props> = ({
icon="chart-line"
tooltip="view in explore"
target="__blank"
href={createExploreLink(rulesSource.name, rule.query)}
to={createExploreLink(rulesSource.name, rule.query)}
/>
)}
{!!rulerRule && (
<ActionIcon
icon="pen"
tooltip="edit rule"
href={`alerting/${encodeURIComponent(
to={`alerting/${encodeURIComponent(
stringifyRuleIdentifier(
getRuleIdentifier(getRulesSourceName(rulesSource), namespace.name, group.name, rulerRule)
)

@ -11,6 +11,7 @@ import { expireSilenceAction } from '../../state/actions';
import { useDispatch } from 'react-redux';
import { Matchers } from './Matchers';
import { SilenceStateTag } from './SilenceStateTag';
import { makeAMLink } from '../../utils/misc';
interface Props {
className?: string;
silence: Silence;
@ -54,7 +55,7 @@ const SilenceTableRow: FC<Props> = ({ silence, className, silencedAlerts, alertM
</td>
<td className={styles.actionsCell}>
{status.state === 'expired' ? (
<Link href={`/alerting/silence/${silence.id}/edit`}>
<Link href={makeAMLink(`/alerting/silence/${silence.id}/edit`, alertManagerSourceName)}>
<ActionButton icon="sync">Recreate</ActionButton>
</Link>
) : (
@ -63,7 +64,11 @@ const SilenceTableRow: FC<Props> = ({ silence, className, silencedAlerts, alertM
</ActionButton>
)}
{status.state !== 'expired' && (
<ActionIcon href={`/alerting/silence/${silence.id}/edit`} icon="pen" tooltip="edit" />
<ActionIcon
to={makeAMLink(`/alerting/silence/${silence.id}/edit`, alertManagerSourceName)}
icon="pen"
tooltip="edit"
/>
)}
</td>
</tr>

@ -35,7 +35,7 @@ export const SilencedAlertsTableRow: FC<Props> = ({ alert, className }) => {
<td>for {alertDuration} seconds</td>
<td>{alertName}</td>
<td className={tableStyles.actionsCell}>
<ActionIcon icon="chart-line" href={alert.generatorURL} tooltip="View in explorer" />
<ActionIcon icon="chart-line" to={alert.generatorURL} tooltip="View in explorer" />
</td>
</tr>
{!isCollapsed && (

@ -0,0 +1,61 @@
import { useCallback } from 'react';
import { UseFormReturn } from 'react-hook-form';
import { set } from 'lodash';
interface Options<R> {
name: string;
formAPI: UseFormReturn<any>;
defaults?: R[];
// if true, sets `__deleted: true` but does not remove item from the array in values
softDelete?: boolean;
}
export type ControlledField<R> = R & {
__deleted?: boolean;
};
const EMPTY_ARRAY = [] as const;
/*
* 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>(options: Options<R>) {
const { name, formAPI, defaults, softDelete } = options;
const { watch, getValues, reset, setValue } = formAPI;
const fields: Array<ControlledField<R>> = watch(name) ?? defaults ?? EMPTY_ARRAY;
const update = useCallback(
(updateFn: (fields: R[]) => R[]) => {
const values = JSON.parse(JSON.stringify(getValues()));
const newItems = updateFn(fields ?? []);
reset(set(values, name, newItems));
},
[getValues, name, reset, fields]
);
return {
fields,
append: useCallback((values: R) => update((fields) => [...fields, values]), [update]),
remove: useCallback(
(index: number) => {
if (softDelete) {
setValue(`${name}.${index}.__deleted`, true);
} else {
update((items) => {
const newItems = items.slice();
newItems.splice(index, 1);
return newItems;
});
}
},
[update, name, setValue, softDelete]
),
};
}

@ -3,6 +3,7 @@ import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-
import { AlertingRule, Alert, RecordingRule, RuleGroup, RuleNamespace } from 'app/types/unified-alerting';
import DatasourceSrv from 'app/features/plugins/datasource_srv';
import { DataSourceSrv, GetDataSourceListFilters } from '@grafana/runtime';
import { AlertManagerCortexConfig, GrafanaManagedReceiverConfig } from 'app/plugins/datasource/alertmanager/types';
let nextDataSourceId = 1;
@ -140,3 +141,82 @@ export class MockDataSourceSrv implements DataSourceSrv {
);
}
}
export const mockGrafanaReceiver = (
type: string,
overrides: Partial<GrafanaManagedReceiverConfig> = {}
): GrafanaManagedReceiverConfig => ({
type: type,
name: type,
disableResolveMessage: false,
settings: {},
sendReminder: true,
...overrides,
});
export const someGrafanaAlertManagerConfig: AlertManagerCortexConfig = {
template_files: {
'first template': 'first template content',
'second template': 'second template content',
'third template': 'third template',
},
alertmanager_config: {
route: {
receiver: 'default',
},
receivers: [
{
name: 'default',
grafana_managed_receiver_configs: [mockGrafanaReceiver('email')],
},
{
name: 'critical',
grafana_managed_receiver_configs: [mockGrafanaReceiver('slack'), mockGrafanaReceiver('pagerduty')],
},
],
},
};
export const someCloudAlertManagerConfig: AlertManagerCortexConfig = {
template_files: {
'foo template': 'foo content',
},
alertmanager_config: {
route: {
receiver: 'cloud-receiver',
},
receivers: [
{
name: 'cloud-receiver',
email_configs: [
{
to: 'domas.lapinskas@grafana.com',
},
],
slack_configs: [
{
api_url: 'http://slack1',
channel: '#mychannel',
actions: [
{
text: 'action1text',
type: 'action1type',
url: 'http://action1',
},
],
fields: [
{
title: 'field1',
value: 'text1',
},
{
title: 'field2',
value: 'text2',
},
],
},
],
},
],
},
};

@ -23,7 +23,7 @@ import {
fetchAlerts,
fetchSilences,
createOrUpdateSilence,
updateAlertmanagerConfig,
updateAlertManagerConfig,
} from '../api/alertmanager';
import { fetchRules } from '../api/prometheus';
import {
@ -47,6 +47,7 @@ import {
ruleWithLocationToRuleIdentifier,
stringifyRuleIdentifier,
} from '../utils/rules';
import { addDefaultsToAlertmanagerConfig } from '../utils/alertmanager-config';
export const fetchPromRulesAction = createAsyncThunk(
'unifiedalerting/fetchPromRules',
@ -349,9 +350,10 @@ export const updateAlertManagerConfigAction = createAsyncThunk<void, UpdateAlert
'It seems configuration has been recently updated. Please reload page and try again to make sure that recent changes are not overwritten.'
);
}
await updateAlertmanagerConfig(alertManagerSourceName, newConfig);
await updateAlertManagerConfig(alertManagerSourceName, addDefaultsToAlertmanagerConfig(newConfig));
if (successMessage) {
appEvents.emit(AppEvents.alertSuccess, [successMessage]);
appEvents?.emit(AppEvents.alertSuccess, [successMessage]);
}
if (redirectPath) {
locationService.push(makeAMLink(redirectPath, alertManagerSourceName));
@ -359,6 +361,7 @@ export const updateAlertManagerConfigAction = createAsyncThunk<void, UpdateAlert
})()
)
);
export const fetchAmAlertsAction = createAsyncThunk(
'unifiedalerting/fetchAmAlerts',
(alertManagerSourceName: string): Promise<AlertmanagerAlert[]> =>

@ -1,5 +1,7 @@
import { NotifierType } from 'app/types';
import { GrafanaManagedReceiverConfig } from 'app/plugins/datasource/alertmanager/types';
import { CloudNotifierType, NotifierType } from 'app/types';
import React from 'react';
import { ControlledField } from '../hooks/useControlledFieldArray';
export interface ChannelValues {
__id: string; // used to correllate form values to original DTOs
@ -11,7 +13,7 @@ export interface ChannelValues {
export interface ReceiverFormValues<R extends ChannelValues> {
name: string;
items: R[];
items: Array<ControlledField<R>>;
}
export interface CloudChannelValues extends ChannelValues {
@ -31,3 +33,18 @@ export interface CommonSettingsComponentProps {
className?: string;
}
export type CommonSettingsComponentType = React.ComponentType<CommonSettingsComponentProps>;
export type CloudChannelConfig = {
send_resolved: boolean;
[key: string]: unknown;
};
// id to notifier
export type GrafanaChannelMap = Record<string, GrafanaManagedReceiverConfig>;
export type CloudChannelMap = Record<
string,
{
type: CloudNotifierType;
config: CloudChannelConfig;
}
>;

@ -0,0 +1,18 @@
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
export function addDefaultsToAlertmanagerConfig(config: AlertManagerCortexConfig): AlertManagerCortexConfig {
// add default receiver if it does not exist
if (!config.alertmanager_config.receivers) {
config.alertmanager_config.receivers = [{ name: 'default ' }];
}
// add default route if it does not exists
if (!config.alertmanager_config.route) {
config.alertmanager_config.route = {
receiver: config.alertmanager_config.receivers![0].name,
};
}
if (!config.template_files) {
config.template_files = {};
}
return config;
}

@ -0,0 +1,332 @@
import { NotificationChannelOption, NotifierDTO } from 'app/types';
function option(
propertyName: string,
label: string,
description: string,
rest: Partial<NotificationChannelOption> = {}
): NotificationChannelOption {
return {
propertyName,
label,
description,
element: 'input',
inputType: '',
required: false,
secure: false,
placeholder: '',
validationRule: '',
showWhen: { field: '', is: '' },
...rest,
};
}
const basicAuthOption: NotificationChannelOption = option(
'basic_auth',
'Basic auth',
'Sets the `Authorization` header with the configured username and password. Password and password_file are mutually exclusive.',
{
element: 'subform',
subformOptions: [
option('ussername', 'Username', ''),
option('password', 'Password', ''),
option('password_file', 'Password file', ''),
],
}
);
const tlsConfigOption: NotificationChannelOption = option('tls_config', 'TLS config', 'Configures the TLS settings.', {
element: 'subform',
subformOptions: [
option('ca_file', 'CA file', 'CA certificate to validate the server certificate with.'),
option('cert_file', 'Cert file', 'Certificate for client cert authentication to the server.'),
option('key_file', 'Key file', 'Key file for client cert authentication to the server.'),
option('server_name', 'Server name', 'ServerName extension to indicate the name of the server.'),
option('insecure_skip_verify', 'Skip verify', 'Disable validation of the server certificate.', {
element: 'checkbox',
}),
],
});
const httpConfigOption: NotificationChannelOption = option(
'http_config',
'HTTP Config',
'Note that `basic_auth`, `bearer_token` and `bearer_token_file` options are mutually exclusive.',
{
element: 'subform',
subformOptions: [
option('bearer_token', 'Bearer token', 'Sets the `Authorization` header with the configured bearer token.'),
option(
'bearer_token_file',
'Bearer token file',
'Sets the `Authorization` header with the bearer token read from the configured file.'
),
option('proxy_url', 'Proxy URL', 'Optional proxy URL.'),
basicAuthOption,
tlsConfigOption,
],
}
);
export const cloudNotifierTypes: NotifierDTO[] = [
{
name: 'Email',
description: 'Send notification over SMTP',
type: 'email',
info: '',
heading: 'Email settings',
options: [
option('to', 'To', 'The email address to send notifications to.', { required: true }),
option('from', 'From', 'The sender address.'),
option('smarthost', 'SMTP host', 'The SMTP host through which emails are sent.'),
option('hello', 'Hello', 'The hostname to identify to the SMTP server.'),
option('auth_username', 'Username', 'SMTP authentication information'),
option('auth_password', 'Password', 'SMTP authentication information'),
option('auth_secret', 'Secret', 'SMTP authentication information'),
option('auth_identity', 'Identity', 'SMTP authentication information'),
option('require_tls', 'Require TLS', 'The SMTP TLS requirement', { element: 'checkbox' }),
option('html', 'Email HTML body', 'The HTML body of the email notification.', {
placeholder: '{{ template "email.default.html" . }}',
element: 'textarea',
}),
option('text', 'Email text body', 'The text body of the email notification.', { element: 'textarea' }),
option(
'headers',
'Headers',
'Further headers email header key/value pairs. Overrides any headers previously set by the notification implementation.',
{ element: 'key_value_map' }
),
tlsConfigOption,
],
},
{
name: 'PagerDuty',
description: 'Send notifications to PagerDuty',
type: 'pagerduty',
info: '',
heading: 'PagerDuty settings',
options: [
option(
'routing_key',
'Routing key',
'The PagerDuty integration key (when using PagerDuty integration type `Events API v2`)'
),
option(
'service_key',
'Service key',
'The PagerDuty integration key (when using PagerDuty integration type `Prometheus`).'
),
option('url', 'URL', 'The URL to send API requests to'),
option('client', 'Client', 'The client identification of the Alertmanager.', {
placeholder: '{{ template "pagerduty.default.client" . }}',
}),
option('client_url', 'Client URL', 'A backlink to the sender of the notification.', {
placeholder: '{{ template "pagerduty.default.clientURL" . }}',
}),
option('description', 'Description', 'A description of the incident.', {
placeholder: '{{ template "pagerduty.default.description" .}}',
}),
option('severity', 'Severity', 'Severity of the incident.', { placeholder: 'error' }),
option(
'details',
'Details',
'A set of arbitrary key/value pairs that provide further detail about the incident.',
{
element: 'key_value_map',
}
),
option('images', 'Images', 'Images to attach to the incident.', {
element: 'subform_array',
subformOptions: [
option('href', 'URL', '', { required: true }),
option('source', 'Source', '', { required: true }),
option('alt', 'Alt', '', { required: true }),
],
}),
option('links', 'Links', 'Links to attach to the incident.', {
element: 'subform_array',
subformOptions: [option('href', 'URL', '', { required: true }), option('text', 'Text', '', { required: true })],
}),
httpConfigOption,
],
},
{
name: 'Pushover',
description: 'Send notifications to Pushover',
type: 'pushover',
info: '',
heading: 'Pushover settings',
options: [
option('user_key', 'User key', 'The recipient user’s user key.', { required: true }),
option('token', 'Token', 'Your registered application’s API token, see https://pushover.net/app', {
required: true,
}),
option('title', 'Title', 'Notification title.', {
placeholder: '{{ template "pushover.default.title" . }}',
}),
option('message', 'Message', 'Notification message.', {
placeholder: '{{ template "pushover.default.message" . }}',
}),
option('url', 'URL', 'A supplementary URL shown alongside the message.', {
placeholder: '{{ template "pushover.default.url" . }}',
}),
option('priority', 'Priority', 'Priority, see https://pushover.net/api#priority', {
placeholder: '{{ if eq .Status "firing" }}2{{ else }}0{{ end }}',
}),
option(
'retry',
'Retry',
'How often the Pushover servers will send the same notification to the user. Must be at least 30 seconds.',
{
placeholder: '1m',
}
),
option(
'expire',
'Expire',
'How long your notification will continue to be retried for, unless the user acknowledges the notification.',
{
placeholder: '1h',
}
),
httpConfigOption,
],
},
{
name: 'Slack',
description: 'Send notifications to Slack',
type: 'slack',
info: '',
heading: 'Slack settings',
options: [
option('api_url', 'Webhook URL', 'The Slack webhook URL.'),
option('channel', 'Channel', 'The #channel or @user to send notifications to.', { required: true }),
option('icon_emoji', 'Emoji icon', ''),
option('icon_url', 'Icon URL', ''),
option('link_names', 'Names link', '', { element: 'checkbox' }),
option('username', 'Username', '', { placeholder: '{{ template "slack.default.username" . }}' }),
option('callback_id', 'Callback ID', '', { placeholder: '{{ template "slack.default.callbackid" . }}' }),
option('color', 'Color', '', { placeholder: '{{ if eq .Status "firing" }}danger{{ else }}good{{ end }}' }),
option('fallback', 'Fallback', '', { placeholder: '{{ template "slack.default.fallback" . }}' }),
option('footer', 'Footer', '', { placeholder: '{{ template "slack.default.footer" . }}' }),
option('mrkdwn_in', 'Mrkdwn fields', 'An array of field names that should be formatted by mrkdwn syntax.', {
element: 'string_array',
}),
option('pretext', 'Pre-text', '', { placeholder: '{{ template "slack.default.pretext" . }}' }),
option('short_fields', 'Short fields', '', { element: 'checkbox' }),
option('text', 'Message body', '', { element: 'textarea', placeholder: '{{ template "slack.default.text" . }}' }),
option('title', 'Title', '', { placeholder: '{{ template "slack.default.title" . }}' }),
option('title_link', 'Title link', '', { placeholder: '{{ template "slack.default.titlelink" . }}' }),
option('image_url', 'Image URL', ''),
option('thumb_url', 'Thumbnail URL', ''),
option('actions', 'Actions', '', {
element: 'subform_array',
subformOptions: [
option('text', 'Text', '', { required: true }),
option('type', 'Type', '', { required: true }),
option('url', 'URL', 'Either url or name and value are mandatory.'),
option('name', 'Name', ''),
option('value', 'Value', ''),
option('confirm', 'Confirm', '', {
element: 'subform',
subformOptions: [
option('text', 'Text', '', { required: true }),
option('dismiss_text', 'Dismiss text', ''),
option('ok_text', 'OK text', ''),
option('title', 'Title', ''),
],
}),
option('style', 'Style', ''),
],
}),
option('fields', 'Fields', '', {
element: 'subform_array',
subformOptions: [
option('title', 'Title', '', { required: true }),
option('value', 'Value', '', { required: true }),
option('short', 'Short', '', { element: 'checkbox' }),
],
}),
httpConfigOption,
],
},
{
name: 'OpsGenie',
description: 'Send notifications to OpsGenie',
type: 'opsgenie',
info: '',
heading: 'OpsGenie settings',
options: [
option('api_key', 'API key', 'The API key to use when talking to the OpsGenie API.'),
option('api_url', 'API URL', 'The host to send OpsGenie API requests to.'),
option('message', 'Message', 'Alert text limited to 130 characters.'),
option('description', 'Description', 'A description of the incident.', {
placeholder: '{{ template "opsgenie.default.description" . }}',
}),
option('source', 'Source', 'A backlink to the sender of the notification.', {
placeholder: '{{ template "opsgenie.default.source" . }}',
}),
option(
'details',
'Details',
'A set of arbitrary key/value pairs that provide further detail about the incident.',
{
element: 'key_value_map',
}
),
option('tags', 'Tags', 'Comma separated list of tags attached to the notifications.'),
option('note', 'Note', 'Additional alert note.'),
option('priority', 'Priority', 'Priority level of alert. Possible values are P1, P2, P3, P4, and P5.'),
option('responders', 'Responders', 'List of responders responsible for notifications.', {
element: 'subform_array',
subformOptions: [
option('type', 'Type', '"team", "user", "escalation" or schedule".', { required: true }),
option('id', 'ID', 'Exactly one of these fields should be defined.'),
option('name', 'Name', 'Exactly one of these fields should be defined.'),
option('username', 'Username', 'Exactly one of these fields should be defined.'),
],
}),
httpConfigOption,
],
},
{
name: 'VictorOps',
description: 'Send notifications to VictorOps',
type: 'victorops',
info: '',
heading: 'VictorOps settings',
options: [
option('api_key', 'API key', 'The API key to use when talking to the VictorOps API.'),
option('api_url', 'API URL', 'The VictorOps API URL.'),
option('routing_key', 'Routing key', 'A key used to map the alert to a team.', { required: true }),
option('message_type', 'Message type', 'Describes the behavior of the alert (CRITICAL, WARNING, INFO).'),
option('entity_display_name', 'Entity display name', 'Contains summary of the alerted problem.', {
placeholder: '{{ template "victorops.default.entity_display_name" . }}',
}),
option('state_message', 'State message', 'Contains long explanation of the alerted problem.', {
placeholder: '{{ template "victorops.default.state_message" . }}',
}),
option('monitoring_tool', 'Monitoring tool', 'The monitoring tool the state message is from.', {
placeholder: '{{ template "victorops.default.monitoring_tool" . }}',
}),
httpConfigOption,
],
},
{
name: 'Webhook',
description: 'Send notifications to a webhook',
type: 'webhook',
info: '',
heading: 'Webhook settings',
options: [
option('url', 'URL', 'The endpoint to send HTTP POST requests to.', { required: true }),
option(
'max_alerts',
'Max alerts',
'The maximum number of alerts to include in a single webhook message. Alerts above this threshold are truncated. When leaving this at its default value of 0, all alerts are included.',
{ placeholder: '0', validationRule: '(^\\d+$|^$)' }
),
httpConfigOption,
],
},
];

@ -0,0 +1,44 @@
import { omitEmptyValues } from './receiver-form';
describe('Receiver form utils', () => {
describe('omitEmptyStringValues', () => {
it('should recursively omit empty strings but leave other properties in palce', () => {
const original = {
one: 'two',
remove: '',
three: 0,
four: null,
five: [
[
{
foo: 'bar',
remove: '',
notDefined: undefined,
},
],
{
foo: 'bar',
remove: '',
},
],
};
const expected = {
one: 'two',
three: 0,
five: [
[
{
foo: 'bar',
},
],
{
foo: 'bar',
},
],
};
expect(omitEmptyValues(original)).toEqual(expected);
});
});
});

@ -1,14 +1,19 @@
import { isArray } from 'angular';
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>;
import { CloudNotifierType, NotifierDTO, NotifierType } from 'app/types';
import {
CloudChannelConfig,
CloudChannelMap,
CloudChannelValues,
GrafanaChannelMap,
GrafanaChannelValues,
ReceiverFormValues,
} from '../types/receiver-form';
export function grafanaReceiverToFormValues(
receiver: Receiver,
@ -32,6 +37,41 @@ export function grafanaReceiverToFormValues(
return [values, channelMap];
}
export function cloudReceiverToFormValues(
receiver: Receiver,
notifiers: NotifierDTO[]
): [ReceiverFormValues<CloudChannelValues>, CloudChannelMap] {
const channelMap: CloudChannelMap = {};
// giving each form receiver item a unique id so we can use it to map back to "original" items
let idCounter = 1;
const items: CloudChannelValues[] = Object.entries(receiver)
// filter out only config items that are relevant to cloud
.filter(([type]) => type.endsWith('_configs') && type !== 'grafana_managed_receiver_configs')
// map property names to cloud notifier types by removing the `_config` suffix
.map(([type, configs]): [CloudNotifierType, CloudChannelConfig[]] => [
type.replace('_configs', '') as CloudNotifierType,
configs as CloudChannelConfig[],
])
// convert channel configs to form values
.map(([type, configs]) =>
configs.map((config) => {
const id = String(idCounter++);
channelMap[id] = { type, config };
const notifier = notifiers.find((notifier) => notifier.type === type);
if (!notifier) {
throw new Error(`unknown cloud notifier: ${type}`);
}
return cloudChannelConfigToFormChannelValues(id, type, config);
})
)
.flat();
const values = {
name: receiver.name,
items,
};
return [values, channelMap];
}
export function formValuesToGrafanaReceiver(
values: ReceiverFormValues<GrafanaChannelValues>,
channelMap: GrafanaChannelMap,
@ -46,6 +86,29 @@ export function formValuesToGrafanaReceiver(
};
}
export function formValuesToCloudReceiver(
values: ReceiverFormValues<CloudChannelValues>,
defaults: CloudChannelValues
): Receiver {
const recv: Receiver = {
name: values.name,
};
values.items.forEach(({ __id, type, settings, sendResolved }) => {
const channel = omitEmptyValues({
...settings,
send_resolved: sendResolved ?? defaults.sendResolved,
});
const configsKey = `${type}_configs`;
if (!recv[configsKey]) {
recv[configsKey] = [channel];
} else {
(recv[configsKey] as unknown[]).push(channel);
}
});
return recv;
}
// will add new receiver, or replace exisitng one
export function updateConfigWithReceiver(
config: AlertManagerCortexConfig,
@ -102,6 +165,23 @@ function renameReceiverInRoute(route: Route, oldName: string, newName: string) {
return updated;
}
function cloudChannelConfigToFormChannelValues(
id: string,
type: CloudNotifierType,
channel: CloudChannelConfig
): CloudChannelValues {
return {
__id: id,
type,
settings: {
...channel,
},
secureFields: {},
secureSettings: {},
sendResolved: channel.send_resolved,
};
}
function grafanaChannelConfigToFormChannelValues(
id: string,
channel: GrafanaManagedReceiverConfig,
@ -152,3 +232,23 @@ function formChannelValuesToGrafanaChannelConfig(
}
return channel;
}
// will remove properties that have empty ('', null, undefined) object properties.
// traverses nested objects and arrays as well. in place, mutates the object.
// this is needed because form will submit empty string for not filled in fields,
// but for cloud alertmanager receiver config to use global default value the property must be omitted entirely
// this isn't a perfect solution though. No way for user to intentionally provide an empty string. Will need rethinking later
export function omitEmptyValues<T>(obj: T): T {
if (isArray(obj)) {
obj.forEach(omitEmptyValues);
} else if (typeof obj === 'object' && obj !== null) {
Object.entries(obj).forEach(([key, value]) => {
if (value === '' || value === null || value === undefined) {
delete (obj as any)[key];
} else {
omitEmptyValues(value);
}
});
}
return obj;
}

@ -1,5 +1,6 @@
import { AnyAction, AsyncThunk, createSlice, Draft, isAsyncThunkAction, SerializedError } from '@reduxjs/toolkit';
import { FetchError } from '@grafana/runtime';
import { isArray } from 'angular';
export interface AsyncRequestState<T> {
result?: T;
loading: boolean;
@ -98,9 +99,27 @@ export function createAsyncMapSlice<T, ThunkArg = void, ThunkApiConfig = {}>(
export function withSerializedError<T>(p: Promise<T>): Promise<T> {
return p.catch((e) => {
const err: SerializedError = {
message: e.data?.message || e.message || e.statusText,
message: messageFromError(e),
code: e.statusCode,
};
throw err;
});
}
function isFetchError(e: unknown): e is FetchError {
return typeof e === 'object' && e !== null && 'status' in e && 'data' in e;
}
function messageFromError(e: Error | FetchError): string {
if (isFetchError(e)) {
if (e.data?.message) {
return e.data?.message;
} else if (isArray(e.data) && e.data.length && e.data[0]?.message) {
return e.data
.map((d) => d?.message)
.filter((m) => !!m)
.join(' ');
}
}
return (e as Error)?.message || String(e);
}

@ -36,7 +36,7 @@ export interface AlertRule {
evalData?: { noData?: boolean; evalMatches?: any };
}
export type NotifierType =
export type GrafanaNotifierType =
| 'discord'
| 'hipchat'
| 'email'
@ -57,6 +57,17 @@ export type NotifierType =
| 'LINE'
| 'kafka';
export type CloudNotifierType =
| 'email'
| 'pagerduty'
| 'pushover'
| 'slack'
| 'opsgenie'
| 'victorops'
| 'webhook'
| 'wechat';
export type NotifierType = GrafanaNotifierType | CloudNotifierType;
export interface NotifierDTO {
name: string;
description: string;
@ -103,7 +114,15 @@ export interface ChannelTypeSettings {
}
export interface NotificationChannelOption {
element: 'input' | 'select' | 'checkbox' | 'textarea';
element:
| 'input'
| 'select'
| 'checkbox'
| 'textarea'
| 'subform'
| 'subform_array'
| 'key_value_map'
| 'string_array';
inputType: string;
label: string;
description: string;
@ -111,9 +130,10 @@ export interface NotificationChannelOption {
propertyName: string;
required: boolean;
secure: boolean;
selectOptions?: Array<SelectableValue<string>>;
selectOptions?: Array<SelectableValue<string>> | null;
showWhen: { field: string; is: string };
validationRule: string;
subformOptions?: NotificationChannelOption[];
}
export interface NotificationChannelState {

Loading…
Cancel
Save