mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: edit cloud receivers (#33570)
This commit is contained in:
@@ -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} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
305
public/app/features/alerting/unified/Receivers.test.tsx
Normal file
305
public/app/features/alerting/unified/Receivers.test.tsx
Normal file
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
1120
public/app/features/alerting/unified/mocks/grafana-notifiers.ts
Normal file
1120
public/app/features/alerting/unified/mocks/grafana-notifiers.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user