mirror of https://github.com/grafana/grafana
Alerting: edit cloud receivers (#33570)
parent
e642506dcb
commit
10a4606315
@ -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); |
||||
}); |
@ -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,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'; |
||||
}; |
@ -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)}; |
||||
`,
|
||||
}); |
@ -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] |
||||
), |
||||
}; |
||||
} |
File diff suppressed because it is too large
Load Diff
@ -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); |
||||
}); |
||||
}); |
||||
}); |
Loading…
Reference in new issue