Migration: Migrate New notification channel page (#25265)

* creating page

* add types select

* adding switches

* start with converting angular templates to json

* converting more alert channels to new format

* convert remaining channels

* typing the form

* add validation, update models

* fix default value in type select

* fix type

* fix issue with validation rule

* add missing settings

* fix type errors

* test notification

* add comments to structs

* fix selectable value and minor things on each channel

* More typings

* fix strictnull

* rename ModelValue -> PropertyName

* rename show -> showWhen

* add enums and adding comments

* fix comment

* break out channel options to component

* use try catch

* adding default case to OptionElement if element not supported
This commit is contained in:
Peter Holmberg
2020-06-29 13:39:12 +02:00
committed by GitHub
parent 61a7f6e2f3
commit 6465b2f0a3
30 changed files with 1186 additions and 23 deletions

View File

@@ -0,0 +1,132 @@
import React, { PureComponent } from 'react';
import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux';
import { NavModel, SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Form } from '@grafana/ui';
import Page from 'app/core/components/Page/Page';
import { NewNotificationChannelForm } from './components/NewNotificationChannelForm';
import { getNavModel } from 'app/core/selectors/navModel';
import { createNotificationChannel, loadNotificationTypes, testNotificationChannel } from './state/actions';
import { NotificationChannel, NotificationChannelDTO, StoreState } from '../../types';
interface OwnProps {}
interface ConnectedProps {
navModel: NavModel;
notificationChannels: NotificationChannel[];
}
interface DispatchProps {
createNotificationChannel: typeof createNotificationChannel;
loadNotificationTypes: typeof loadNotificationTypes;
testNotificationChannel: typeof testNotificationChannel;
}
type Props = OwnProps & ConnectedProps & DispatchProps;
const defaultValues: NotificationChannelDTO = {
name: '',
type: { value: 'email', label: 'Email' },
sendReminder: false,
disableResolveMessage: false,
frequency: '15m',
settings: {
uploadImage: config.rendererAvailable,
autoResolve: true,
httpMethod: 'POST',
severity: 'critical',
},
isDefault: false,
};
class NewAlertNotificationPage extends PureComponent<Props> {
componentDidMount() {
this.props.loadNotificationTypes();
}
onSubmit = (data: NotificationChannelDTO) => {
/*
Some settings can be options in a select, in order to not save a SelectableValue<T>
we need to use check if it is a SelectableValue and use its value.
*/
const settings = Object.fromEntries(
Object.entries(data.settings).map(([key, value]) => {
return [key, value.hasOwnProperty('value') ? value.value : value];
})
);
this.props.createNotificationChannel({
...defaultValues,
...data,
type: data.type.value,
settings: { ...defaultValues.settings, ...settings },
});
};
onTestChannel = (data: NotificationChannelDTO) => {
this.props.testNotificationChannel({
name: data.name,
type: data.type.value,
frequency: data.frequency ?? defaultValues.frequency,
settings: { ...Object.assign(defaultValues.settings, data.settings) },
});
};
render() {
const { navModel, notificationChannels } = this.props;
/*
Need to transform these as we have options on notificationChannels,
this will render a dropdown within the select.
TODO: Memoize?
*/
const selectableChannels: Array<SelectableValue<string>> = notificationChannels.map(channel => ({
value: channel.value,
label: channel.label,
description: channel.description,
}));
return (
<Page navModel={navModel}>
<Page.Contents>
<h2>New Notification Channel</h2>
<Form onSubmit={this.onSubmit} validateOn="onChange" defaultValues={defaultValues}>
{({ register, errors, control, getValues, watch }) => {
const selectedChannel = notificationChannels.find(c => c.value === getValues().type.value);
return (
<NewNotificationChannelForm
selectableChannels={selectableChannels}
selectedChannel={selectedChannel}
onTestChannel={this.onTestChannel}
register={register}
errors={errors}
getValues={getValues}
control={control}
watch={watch}
imageRendererAvailable={config.rendererAvailable}
/>
);
}}
</Form>
</Page.Contents>
</Page>
);
}
}
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = state => {
return {
navModel: getNavModel(state.navIndex, 'channels'),
notificationChannels: state.alertRules.notificationChannels,
};
};
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = {
createNotificationChannel,
loadNotificationTypes,
testNotificationChannel,
};
export default connect(mapStateToProps, mapDispatchToProps)(NewAlertNotificationPage);

View File

@@ -0,0 +1,125 @@
import React, { FC, useEffect } from 'react';
import { css } from 'emotion';
import { GrafanaTheme, SelectableValue } from '@grafana/data';
import {
Button,
Field,
FormAPI,
HorizontalGroup,
InfoBox,
Input,
InputControl,
Select,
stylesFactory,
Switch,
useTheme,
} from '@grafana/ui';
import { NotificationChannel, NotificationChannelDTO } from '../../../types';
import { NotificationChannelOptions } from './NotificationChannelOptions';
interface Props extends Omit<FormAPI<NotificationChannelDTO>, 'formState'> {
selectableChannels: Array<SelectableValue<string>>;
selectedChannel?: NotificationChannel;
imageRendererAvailable: boolean;
onTestChannel: (data: NotificationChannelDTO) => void;
}
export const NewNotificationChannelForm: FC<Props> = ({
control,
errors,
selectedChannel,
selectableChannels,
register,
watch,
getValues,
imageRendererAvailable,
onTestChannel,
}) => {
const styles = getStyles(useTheme());
useEffect(() => {
watch(['type', 'settings.priority', 'sendReminder', 'uploadImage']);
}, []);
const currentFormValues = getValues();
return (
<>
<div className={styles.basicSettings}>
<Field label="Name" invalid={!!errors.name} error={errors.name && errors.name.message}>
<Input name="name" ref={register({ required: 'Name is required' })} />
</Field>
<Field label="Type">
<InputControl
name="type"
as={Select}
options={selectableChannels}
control={control}
rules={{ required: true }}
/>
</Field>
<Field label="Default" description="Use this notification for all alerts">
<Switch name="isDefault" ref={register} />
</Field>
<Field label="Include image" description="Captures an image and include it in the notification">
<Switch name="settings.uploadImage" ref={register} />
</Field>
{currentFormValues.uploadImage && !imageRendererAvailable && (
<InfoBox title="No image renderer available/installed">
Grafana cannot find an image renderer to capture an image for the notification. Please make sure the Grafana
Image Renderer plugin is installed. Please contact your Grafana administrator to install the plugin.
</InfoBox>
)}
<Field
label="Disable Resolve Message"
description="Disable the resolve message [OK] that is sent when alerting state returns to false"
>
<Switch name="disableResolveMessage" ref={register} />
</Field>
<Field label="Send reminders" description="Send additional notifications for triggered alerts">
<Switch name="sendReminder" ref={register} />
</Field>
{currentFormValues.sendReminder && (
<>
<Field
label="Send reminder every"
description="Specify how often reminders should be sent, e.g. every 30s, 1m, 10m, 30m or 1h etc."
>
<Input name="frequency" ref={register} />
</Field>
<InfoBox>
Alert reminders are sent after rules are evaluated. Therefore a reminder can never be sent more frequently
than a configured alert rule evaluation interval.
</InfoBox>
</>
)}
</div>
{selectedChannel && (
<NotificationChannelOptions
selectedChannel={selectedChannel}
currentFormValues={currentFormValues}
register={register}
errors={errors}
control={control}
/>
)}
<HorizontalGroup>
<Button type="submit">Save</Button>
<Button type="button" variant="secondary" onClick={() => onTestChannel(getValues({ nest: true }))}>
Test
</Button>
<Button type="button" variant="secondary">
Back
</Button>
</HorizontalGroup>
</>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
basicSettings: css`
margin-bottom: ${theme.spacing.xl};
`,
};
});

View File

@@ -0,0 +1,50 @@
import React, { FC } from 'react';
import { SelectableValue } from '@grafana/data';
import { Field, FormAPI, InfoBox } from '@grafana/ui';
import { OptionElement } from './OptionElement';
import { NotificationChannel, NotificationChannelDTO, Option } from '../../../types';
interface Props extends Omit<FormAPI<NotificationChannelDTO>, 'formState' | 'getValues' | 'watch'> {
selectedChannel: NotificationChannel;
currentFormValues: NotificationChannelDTO;
}
export const NotificationChannelOptions: FC<Props> = ({
control,
currentFormValues,
errors,
selectedChannel,
register,
}) => {
return (
<>
<h3>{selectedChannel.heading}</h3>
{selectedChannel.info !== '' && <InfoBox>{selectedChannel.info}</InfoBox>}
{selectedChannel.options.map((option: Option, index: number) => {
const key = `${option.label}-${index}`;
// Some options can be dependent on other options, this determines what is selected in the dependency options
// I think this needs more thought.
const selectedOptionValue =
currentFormValues[`settings.${option.showWhen.field}`] &&
(currentFormValues[`settings.${option.showWhen.field}`] as SelectableValue<string>).value;
if (option.showWhen.field && selectedOptionValue !== option.showWhen.is) {
return null;
}
return (
<Field
key={key}
label={option.label}
description={option.description}
invalid={errors.settings && !!errors.settings[option.propertyName]}
error={errors.settings && errors.settings[option.propertyName]?.message}
>
<OptionElement option={option} register={register} control={control} />
</Field>
);
})}
</>
);
};

View File

@@ -0,0 +1,57 @@
import React, { FC } from 'react';
import { FormAPI, Input, InputControl, Select, Switch, TextArea } from '@grafana/ui';
import { Option } from '../../../types';
interface Props extends Pick<FormAPI<any>, 'register' | 'control'> {
option: Option;
}
export const OptionElement: FC<Props> = ({ control, option, register }) => {
const modelValue = `settings.${option.propertyName}`;
switch (option.element) {
case 'input':
return (
<Input
type={option.inputType}
name={`${modelValue}`}
ref={register({
required: option.required ? 'Required' : false,
validate: v => (option.validationRule !== '' ? validateOption(v, option.validationRule) : true),
})}
placeholder={option.placeholder}
/>
);
case 'select':
return <InputControl as={Select} options={option.selectOptions} control={control} name={`${modelValue}`} />;
case 'textarea':
return (
<TextArea
name={`${modelValue}`}
ref={register({
required: option.required ? 'Required' : false,
validate: v => (option.validationRule !== '' ? validateOption(v, option.validationRule) : true),
})}
/>
);
case 'switch':
return (
<Switch
name={`${modelValue}`}
ref={register({
required: option.required ? 'Required' : false,
})}
/>
);
default:
console.error('Element not supported', option.element);
return null;
}
};
const validateOption = (value: string, validationRule: string) => {
return RegExp(validationRule).test(value) ? true : 'Invalid format';
};

View File

@@ -1,6 +1,9 @@
import { AppEvents } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { AlertRuleDTO, ThunkResult } from 'app/types';
import { loadAlertRules, loadedAlertRules } from './reducers';
import { AlertRuleDTO, NotifierDTO, ThunkResult } from 'app/types';
import { appEvents } from 'app/core/core';
import { updateLocation } from 'app/core/actions';
import { loadAlertRules, loadedAlertRules, setNotificationChannels } from './reducers';
export function getAlertRulesAsync(options: { state: string }): ThunkResult<void> {
return async dispatch => {
@@ -17,3 +20,45 @@ export function togglePauseAlertRule(id: number, options: { paused: boolean }):
dispatch(getAlertRulesAsync({ state: stateFilter.toString() }));
};
}
export function createNotificationChannel(data: any): ThunkResult<void> {
return async dispatch => {
try {
await getBackendSrv().post(`/api/alert-notifications`, data);
appEvents.emit(AppEvents.alertSuccess, ['Notification created']);
dispatch(updateLocation({ path: 'alerting/notifications' }));
} catch (error) {
appEvents.emit(AppEvents.alertError, [error.data.error]);
}
};
}
export function testNotificationChannel(data: any): ThunkResult<void> {
return async () => {
await getBackendSrv().post('/api/alert-notifications/test', data);
};
}
export function loadNotificationTypes(): ThunkResult<void> {
return async dispatch => {
const alertNotifiers: NotifierDTO[] = await getBackendSrv().get(`/api/alert-notifiers`);
const notificationTypes = alertNotifiers
.map((option: NotifierDTO) => {
return {
value: option.type,
label: option.name,
...option,
typeName: option.type,
};
})
.sort((o1, o2) => {
if (o1.name > o2.name) {
return 1;
}
return -1;
});
dispatch(setNotificationChannels(notificationTypes));
};
}

View File

@@ -1,9 +1,9 @@
import { AlertRule, AlertRuleDTO, AlertRulesState } from 'app/types';
import { AlertRule, AlertRuleDTO, AlertRulesState, NotificationChannel } from 'app/types';
import alertDef from './alertDef';
import { dateTime } from '@grafana/data';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export const initialState: AlertRulesState = { items: [], searchQuery: '', isLoading: false };
export const initialState: AlertRulesState = { items: [], searchQuery: '', isLoading: false, notificationChannels: [] };
function convertToAlertRule(dto: AlertRuleDTO, state: string): AlertRule {
const stateModel = alertDef.getStateDisplayModel(state);
@@ -47,10 +47,13 @@ const alertRulesSlice = createSlice({
setSearchQuery: (state, action: PayloadAction<string>): AlertRulesState => {
return { ...state, searchQuery: action.payload };
},
setNotificationChannels: (state, action: PayloadAction<NotificationChannel[]>): AlertRulesState => {
return { ...state, notificationChannels: action.payload };
},
},
});
export const { loadAlertRules, loadedAlertRules, setSearchQuery } = alertRulesSlice.actions;
export const { loadAlertRules, loadedAlertRules, setSearchQuery, setNotificationChannels } = alertRulesSlice.actions;
export const alertRulesReducer = alertRulesSlice.reducer;

View File

@@ -15,7 +15,7 @@ import DataSourcePicker from 'app/core/components/Select/DataSourcePicker';
import { DashboardInput, DashboardInputs, DataSourceInput, ImportDashboardDTO } from '../state/reducers';
import { validateTitle, validateUid } from '../utils/validation';
interface Props extends Omit<FormAPI<ImportDashboardDTO>, 'formState'> {
interface Props extends Omit<FormAPI<ImportDashboardDTO>, 'formState' | 'watch'> {
uidReset: boolean;
inputs: DashboardInputs;
initialFolderId: number;

View File

@@ -522,6 +522,15 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
controllerAs: 'ctrl',
reloadOnSearch: false,
})
.when('/alerting/notification/new2', {
template: '<react-container />',
resolve: {
component: () =>
SafeDynamicImport(
import(/* webpackChunkName: "NewNotificationChannel" */ 'app/features/alerting/NewAlertNotificationPage')
),
},
})
.when('/alerting/notification/:id/edit', {
templateUrl: 'public/app/features/alerting/partials/notification_edit.html',
controller: 'AlertNotificationEditCtrl',

View File

@@ -1,3 +1,5 @@
import { SelectableValue } from '@grafana/data';
export interface AlertRuleDTO {
id: number;
dashboardId: number;
@@ -33,10 +35,83 @@ export interface AlertRule {
evalData?: { noData?: boolean; evalMatches?: any };
}
export type NotifierType =
| 'discord'
| 'hipchat'
| 'email'
| 'sensu'
| 'googlechat'
| 'threema'
| 'teams'
| 'slack'
| 'pagerduty'
| 'prometheus-alertmanager'
| 'telegram'
| 'opsgenie'
| 'dingding'
| 'webhook'
| 'victorops'
| 'pushover'
| 'LINE'
| 'kafka';
export interface NotifierDTO {
name: string;
description: string;
optionsTemplate: string;
type: NotifierType;
heading: string;
options: Option[];
info?: string;
}
export interface NotificationChannel {
value: string;
label: string;
description: string;
type: NotifierType;
heading: string;
options: Option[];
info?: string;
}
export interface NotificationChannelDTO {
[key: string]: string | boolean | SelectableValue<string>;
name: string;
type: SelectableValue<string>;
sendReminder: boolean;
disableResolveMessage: boolean;
frequency: string;
settings: ChannelTypeSettings;
isDefault: boolean;
}
export interface ChannelTypeSettings {
[key: string]: any;
autoResolve: true;
httpMethod: string;
severity: string;
uploadImage: boolean;
}
export interface Option {
element: 'input' | 'select' | 'switch' | 'textarea';
inputType: string;
label: string;
description: string;
placeholder: string;
propertyName: string;
selectOptions: Array<SelectableValue<string>>;
showWhen: { field: string; is: string };
required: boolean;
validationRule: string;
}
export interface AlertRulesState {
items: AlertRule[];
searchQuery: string;
isLoading: boolean;
notificationChannels: NotificationChannel[];
}
export interface AlertNotification {