Migration: Edit notification channel (#25980)

* implement edit page

* connectWithCleanup

* remove angular related code

* use loadingindicator

* use the correct loading component

* handle secureFields

* fixed implementation of secure fields

* Keep secureFields after rerendering the form

* CollapsableSection and Page refactor

* use checkbox instead of switch

* fix comment

* add cursor to section

* Fixed issues after PR review

* Fix issue with some settings being undefined

* new reducer and start with test

* algorithm to migrate secure fields

* UX: Minor UI Tweaks

* Added field around checkboxes, and missing required field

* fixed test

* tests for util

* minor tweaks and changes

* define as records

* fix typ error

* forward invalid to textarea and inputcontrol

* merge formdata and redux data in test

* fix issue with creating channel

* do not figure out securefields in migration

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Peter Holmberg
2020-09-09 12:46:19 +02:00
committed by GitHub
parent 1e2f3ca599
commit 400aafa3b3
45 changed files with 1297 additions and 1059 deletions

View File

@@ -0,0 +1,10 @@
import { Meta, Props } from '@storybook/addon-docs/blocks';
import { CollapsableSection } from './CollapsableSection';
<Meta title="MDX|CollapsableSection" component={CollapsableSection} />
# Collapsable Section
A simple container for enabling collapsing/expanding of content.
<Props of={CollapsableSection} />

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { CollapsableSection } from './CollapsableSection';
import mdx from './CollapsableSection.mdx';
export default {
title: 'Layout/CollapsableSection',
component: CollapsableSection,
parameters: {
docs: {
page: mdx,
},
},
};
export const simple = () => {
return (
<CollapsableSection label="Collapsable section" isOpen>
<div>Here's some content</div>
</CollapsableSection>
);
};

View File

@@ -0,0 +1,38 @@
import React, { FC, ReactNode, useState } from 'react';
import { css } from 'emotion';
import { GrafanaTheme } from '@grafana/data';
import { useStyles } from '../../themes';
import { Icon } from '..';
export interface Props {
label: string;
isOpen: boolean;
children: ReactNode;
}
export const CollapsableSection: FC<Props> = ({ label, isOpen, children }) => {
const [open, toggleOpen] = useState<boolean>(isOpen);
const styles = useStyles(collapsableSectionStyles);
return (
<div>
<div onClick={() => toggleOpen(!open)} className={styles.header}>
<Icon name={open ? 'angle-down' : 'angle-right'} size="xl" />
{label}
</div>
<div className={styles.content}>{open && children}</div>
</div>
);
};
const collapsableSectionStyles = (theme: GrafanaTheme) => {
return {
header: css`
font-size: ${theme.typography.size.lg};
cursor: pointer;
`,
content: css`
padding: ${theme.spacing.md} 0 ${theme.spacing.md} ${theme.spacing.md};
`,
};
};

View File

@@ -80,7 +80,7 @@ export const getCheckboxStyles = stylesFactory((theme: GrafanaTheme) => {
background: ${theme.colors.formInputBg};
border: 1px solid ${theme.colors.formInputBorder};
position: absolute;
top: 1px;
top: 2px;
left: 0;
&:hover {

View File

@@ -93,6 +93,7 @@ export {
export { Alert, AlertVariant } from './Alert/Alert';
export { GraphSeriesToggler, GraphSeriesTogglerAPI } from './Graph/GraphSeriesToggler';
export { Collapse, ControlledCollapse } from './Collapse/Collapse';
export { CollapsableSection } from './Collapse/CollapsableSection';
export { LogLabels } from './Logs/LogLabels';
export { LogRows } from './Logs/LogRows';
export { getLogRowStyles } from './Logs/getLogRowStyles';

View File

@@ -23,14 +23,13 @@ var newImageUploaderProvider = func() (imguploader.ImageUploader, error) {
// NotifierPlugin holds meta information about a notifier.
type NotifierPlugin struct {
Type string `json:"type"`
Name string `json:"name"`
Heading string `json:"heading"`
Description string `json:"description"`
Info string `json:"info"`
OptionsTemplate string `json:"optionsTemplate"`
Factory NotifierFactory `json:"-"`
Options []NotifierOption `json:"options"`
Type string `json:"type"`
Name string `json:"name"`
Heading string `json:"heading"`
Description string `json:"description"`
Info string `json:"info"`
Factory NotifierFactory `json:"-"`
Options []NotifierOption `json:"options"`
}
// NotifierOption holds information about options specific for the NotifierPlugin.
@@ -45,6 +44,7 @@ type NotifierOption struct {
ShowWhen ShowWhen `json:"showWhen"`
Required bool `json:"required"`
ValidationRule string `json:"validationRule"`
Secure bool `json:"secure"`
}
// InputType is the type of input that can be rendered in the frontend.
@@ -65,8 +65,8 @@ const (
ElementTypeInput = "input"
// ElementTypeSelect will render a select
ElementTypeSelect = "select"
// ElementTypeSwitch will render a switch
ElementTypeSwitch = "switch"
// ElementTypeCheckbox will render a checkbox
ElementTypeCheckbox = "checkbox"
// ElementTypeTextArea will render a textarea
ElementTypeTextArea = "textarea"
)

View File

@@ -20,36 +20,6 @@ func init() {
Description: "Sends alert to Prometheus Alertmanager",
Heading: "Alertmanager settings",
Factory: NewAlertmanagerNotifier,
OptionsTemplate: `
<h3 class="page-heading">Alertmanager settings</h3>
<div class="gf-form max-width-30">
<span class="gf-form-label width-10">Url(s)</span>
<input type="text" required class="gf-form-input max-width-30" ng-model="ctrl.model.settings.url" placeholder="http://localhost:9093"></input>
<info-popover mode="right-absolute">
As specified in Alertmanager documentation, do not specify a load balancer here. Enter all your Alertmanager URLs comma-separated.
</info-popover>
</div>
<div class="gf-form max-width-30">
<span class="gf-form-label width-10">Basic Auth User</span>
<input type="text" class="gf-form-input max-width-30" ng-model="ctrl.model.settings.basicAuthUser" placeholder=""></input>
</div>
<div class="gf-form max-width-30">
<span class="gf-form-label width-10">Basic Auth Password</span>
<div class="gf-form gf-form--grow" ng-if="!ctrl.model.secureFields.basicAuthPassword">
<input type="text"
class="gf-form-input max-width-30"
ng-init="ctrl.model.secureSettings.basicAuthPassword = ctrl.model.settings.basicAuthPassword || null; ctrl.model.settings.basicAuthPassword = null;"
ng-model="ctrl.model.secureSettings.basicAuthPassword"
data-placement="right">
</input>
</div>
<div class="gf-form" ng-if="ctrl.model.secureFields.basicAuthPassword">
<input type="text" class="gf-form-input max-width-18" disabled="disabled" value="configured" />
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="ctrl.model.secureFields.basicAuthPassword = false">reset</a>
</div>
</div>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "Url",
@@ -71,6 +41,7 @@ func init() {
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypePassword,
PropertyName: "basicAuthPassword",
Secure: true,
},
},
})

View File

@@ -12,26 +12,14 @@ import (
)
const defaultDingdingMsgType = "link"
const dingdingOptionsTemplate = `
<h3 class="page-heading">DingDing settings</h3>
<div class="gf-form">
<span class="gf-form-label width-10">Url</span>
<input type="text" required class="gf-form-input max-width-70" ng-model="ctrl.model.settings.url" placeholder="https://oapi.dingtalk.com/robot/send?access_token=xxxxxxxxx"></input>
</div>
<div class="gf-form">
<span class="gf-form-label width-10">MessageType</span>
<select class="gf-form-input max-width-14" ng-model="ctrl.model.settings.msgType" ng-options="s for s in ['link','actionCard']" ng-init="ctrl.model.settings.msgType=ctrl.model.settings.msgType || '` + defaultDingdingMsgType + `'"></select>
</div>
`
func init() {
alerting.RegisterNotifier(&alerting.NotifierPlugin{
Type: "dingding",
Name: "DingDing",
Description: "Sends HTTP POST request to DingDing",
Heading: "DingDing settings",
Factory: newDingDingNotifier,
OptionsTemplate: dingdingOptionsTemplate,
Type: "dingding",
Name: "DingDing",
Description: "Sends HTTP POST request to DingDing",
Heading: "DingDing settings",
Factory: newDingDingNotifier,
Options: []alerting.NotifierOption{
{
Label: "Url",

View File

@@ -23,24 +23,6 @@ func init() {
Description: "Sends notifications to Discord",
Factory: newDiscordNotifier,
Heading: "Discord settings",
OptionsTemplate: `
<h3 class="page-heading">Discord settings</h3>
<div class="gf-form max-width-30">
<span class="gf-form-label width-10">Message Content</span>
<input type="text"
class="gf-form-input max-width-30"
ng-model="ctrl.model.settings.content"
data-placement="right">
</input>
<info-popover mode="right-absolute">
Mention a group using @ or a user using <@ID> when notifying in a channel
</info-popover>
</div>
<div class="gf-form max-width-30">
<span class="gf-form-label width-10">Webhook URL</span>
<input type="text" required class="gf-form-input max-width-30" ng-model="ctrl.model.settings.url" placeholder="Discord webhook URL"></input>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "Message Content",

View File

@@ -19,32 +19,11 @@ func init() {
Description: "Sends notifications using Grafana server configured SMTP settings",
Factory: NewEmailNotifier,
Heading: "Email settings",
OptionsTemplate: `
<h3 class="page-heading">Email settings</h3>
<div class="gf-form">
<gf-form-switch
class="gf-form"
label="Single email"
label-class="width-8"
checked="ctrl.model.settings.singleEmail"
tooltip="Send a single email to all recipients">
</gf-form-switch>
</div>
<div class="gf-form">
<label class="gf-form-label width-8">
Addresses
</label>
<textarea rows="7" class="gf-form-input width-27" required ng-model="ctrl.model.settings.addresses"></textarea>
</div>
<div class="gf-form offset-width-8">
<span>You can enter multiple email addresses using a ";" separator</span>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "Single email",
Description: "Send a single email to all recipients",
Element: alerting.ElementTypeSwitch,
Element: alerting.ElementTypeCheckbox,
PropertyName: "singleEmail",
},
{

View File

@@ -19,13 +19,6 @@ func init() {
Description: "Sends notifications to Google Hangouts Chat via webhooks based on the official JSON message format",
Factory: newGoogleChatNotifier,
Heading: "Google Hangouts Chat settings",
OptionsTemplate: `
<h3 class="page-heading">Google Hangouts Chat settings</h3>
<div class="gf-form max-width-30">
<span class="gf-form-label width-6">Url</span>
<input type="text" required class="gf-form-input max-width-30" ng-model="ctrl.model.settings.url" placeholder="Google Hangouts Chat incoming webhook url"></input>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "Url",

View File

@@ -20,25 +20,6 @@ func init() {
Description: "Sends notifications uto a HipChat Room",
Heading: "HipChat settings",
Factory: NewHipChatNotifier,
OptionsTemplate: `
<h3 class="page-heading">HipChat settings</h3>
<div class="gf-form max-width-30">
<span class="gf-form-label width-8">Hip Chat Url</span>
<input type="text" required class="gf-form-input max-width-30" ng-model="ctrl.model.settings.url" placeholder="HipChat URL (ex https://grafana.hipchat.com)"></input>
</div>
<div class="gf-form max-width-30">
<span class="gf-form-label width-8">API Key</span>
<input type="text" required class="gf-form-input max-width-30" ng-model="ctrl.model.settings.apikey" placeholder="HipChat API Key"></input>
</div>
<div class="gf-form max-width-30">
<span class="gf-form-label width-8">Room ID</span>
<input type="text"
class="gf-form-input max-width-30"
ng-model="ctrl.model.settings.roomid"
data-placement="right">
</input>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "Hip Chat Url",

View File

@@ -19,17 +19,6 @@ func init() {
Description: "Sends notifications to Kafka Rest Proxy",
Heading: "Kafka settings",
Factory: NewKafkaNotifier,
OptionsTemplate: `
<h3 class="page-heading">Kafka settings</h3>
<div class="gf-form">
<span class="gf-form-label width-14">Kafka REST Proxy</span>
<input type="text" required class="gf-form-input max-width-22" ng-model="ctrl.model.settings.kafkaRestProxy" placeholder="http://localhost:8082"></input>
</div>
<div class="gf-form">
<span class="gf-form-label width-14">Topic</span>
<input type="text" required class="gf-form-input max-width-22" ng-model="ctrl.model.settings.kafkaTopic" placeholder="topic1"></input>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "Kafka REST Proxy",

View File

@@ -17,25 +17,6 @@ func init() {
Description: "Send notifications to LINE notify",
Heading: "LINE notify settings",
Factory: NewLINENotifier,
OptionsTemplate: `
<h3 class="page-heading">LINE notify settings</h3>
<div class="gf-form">
<label class="gf-form-label max-width-14">Token</label>
<div class="gf-form gf-form--grow" ng-if="!ctrl.model.secureFields.token">
<input type="text"
required
class="gf-form-input max-width-22"
ng-init="ctrl.model.secureSettings.token = ctrl.model.settings.token || null; ctrl.model.settings.token = null;"
ng-model="ctrl.model.secureSettings.token"
data-placement="right">
</input>
</div>
<div class="gf-form" ng-if="ctrl.model.secureFields.token">
<input type="text" class="gf-form-input max-width-18" disabled="disabled" value="configured" />
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="ctrl.model.secureFields.token = false">reset</a>
</div>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "Token",
@@ -44,6 +25,7 @@ func init() {
Placeholder: "LINE notify token key",
PropertyName: "token",
Required: true,
Secure: true,
}},
})
}

View File

@@ -18,48 +18,6 @@ func init() {
Description: "Sends notifications to OpsGenie",
Heading: "OpsGenie settings",
Factory: NewOpsGenieNotifier,
OptionsTemplate: `
<h3 class="page-heading">OpsGenie settings</h3>
<div class="gf-form">
<label class="gf-form-label max-width-14">API Key</label>
<div class="gf-form gf-form--grow" ng-if="!ctrl.model.secureFields.apiKey">
<input type="text"
required
class="gf-form-input max-width-22"
ng-init="ctrl.model.secureSettings.apiKey = ctrl.model.settings.apiKey || null; ctrl.model.settings.apiKey = null;"
ng-model="ctrl.model.secureSettings.apiKey"
placeholder="OpsGenie API Key"
data-placement="right">
</input>
</div>
<div class="gf-form" ng-if="ctrl.model.secureFields.apiKey">
<input type="text" class="gf-form-input max-width-18" disabled="disabled" value="configured" />
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="ctrl.model.secureFields.apiKey = false">reset</a>
</div>
</div>
<div class="gf-form">
<span class="gf-form-label width-14">Alert API Url</span>
<input type="text" required class="gf-form-input max-width-22" ng-model="ctrl.model.settings.apiUrl" placeholder="https://api.opsgenie.com/v2/alerts"></input>
</div>
<div class="gf-form">
<gf-form-switch
class="gf-form"
label="Auto close incidents"
label-class="width-14"
checked="ctrl.model.settings.autoClose"
tooltip="Automatically close alerts in OpsGenie once the alert goes back to ok.">
</gf-form-switch>
</div>
<div class="gf-form">
<gf-form-switch
class="gf-form"
label="Override priority"
label-class="width-14"
checked="ctrl.model.settings.overridePriority"
tooltip="Allow the alert priority to be set using the og_priority tag">
</gf-form-switch>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "API Key",
@@ -68,6 +26,7 @@ func init() {
Placeholder: "OpsGenie API Key",
PropertyName: "apiKey",
Required: true,
Secure: true,
},
{
Label: "Alert API Url",
@@ -79,12 +38,12 @@ func init() {
},
{
Label: "Auto close incidents",
Element: alerting.ElementTypeSwitch,
Element: alerting.ElementTypeCheckbox,
Description: "Automatically close alerts in OpsGenie once the alert goes back to ok.",
PropertyName: "autoClose",
}, {
Label: "Override priority",
Element: alerting.ElementTypeSwitch,
Element: alerting.ElementTypeCheckbox,
Description: "Allow the alert priority to be set using the og_priority tag",
PropertyName: "overridePriority",
},

View File

@@ -20,53 +20,6 @@ func init() {
Description: "Sends notifications to PagerDuty",
Heading: "PagerDuty settings",
Factory: NewPagerdutyNotifier,
OptionsTemplate: `
<h3 class="page-heading">PagerDuty settings</h3>
<div class="gf-form">
<span class="gf-form-label width-14">Integration Key</span>
<div class="gf-form gf-form--grow" ng-if="!ctrl.model.secureFields.integrationKey">
<input type="text"
class="gf-form-input max-width-22"
ng-init="ctrl.model.secureSettings.integrationKey = ctrl.model.settings.integrationKey || null; ctrl.model.settings.integrationKey = null;"
ng-model="ctrl.model.secureSettings.integrationKey"
placeholder="Pagerduty Integration Key"
data-placement="right">
</input>
</div>
<div class="gf-form" ng-if="ctrl.model.secureFields.integrationKey">
<input type="text" class="gf-form-input max-width-18" disabled="disabled" value="configured" />
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="ctrl.model.secureFields.integrationKey = false">reset</a>
</div>
</div>
<div class="gf-form">
<span class="gf-form-label width-14">Severity</span>
<div class="gf-form-select-wrapper width-14">
<select
class="gf-form-input"
ng-model="ctrl.model.settings.severity"
ng-options="s for s in ['critical', 'error', 'warning', 'info']">
</select>
</div>
</div>
<div class="gf-form">
<gf-form-switch
class="gf-form"
label="Auto resolve incidents"
label-class="width-14"
checked="ctrl.model.settings.autoResolve"
tooltip="Resolve incidents in pagerduty once the alert goes back to ok.">
</gf-form-switch>
</div>
<div class="gf-form">
<gf-form-switch
class="gf-form"
label="Include message in details"
label-class="width-14"
checked="ctrl.model.settings.messageInDetails"
tooltip="Move the alert message from the PD summary into the custom details. This changes the custom details object and may break event rules you have configured">
</gf-form-switch>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "Integration Key",
@@ -101,10 +54,16 @@ func init() {
},
{
Label: "Auto resolve incidents",
Element: alerting.ElementTypeSwitch,
Element: alerting.ElementTypeCheckbox,
Description: "Resolve incidents in pagerduty once the alert goes back to ok.",
PropertyName: "autoResolve",
},
{
Label: "Include message in details",
Element: alerting.ElementTypeCheckbox,
Description: "Move the alert message from the PD summary into the custom details. This changes the custom details object and may break event rules you have configured",
PropertyName: "messageInDetails",
},
},
})
}

View File

@@ -17,31 +17,6 @@ import (
const pushoverEndpoint = "https://api.pushover.net/1/messages.json"
func init() {
sounds := `
'default',
'pushover',
'bike',
'bugle',
'cashregister',
'classical',
'cosmic',
'falling',
'gamelan',
'incoming',
'intermission',
'magic',
'mechanical',
'pianobar',
'siren',
'spacealarm',
'tugboat',
'alien',
'climb',
'persistent',
'echo',
'updown',
'none'`
soundOptions := []alerting.SelectOption{
{
Value: "default",
@@ -122,76 +97,6 @@ func init() {
Description: "Sends HTTP POST request to the Pushover API",
Heading: "Pushover settings",
Factory: NewPushoverNotifier,
OptionsTemplate: `
<h3 class="page-heading">Pushover settings</h3>
<div class="gf-form">
<label class="gf-form-label width-10">API Token</label>
<div class="gf-form gf-form--grow" ng-if="!ctrl.model.secureFields.apiToken">
<input type="text"
required
class="gf-form-input"
ng-init="ctrl.model.secureSettings.apiToken = ctrl.model.settings.apiToken || null; ctrl.model.settings.apiToken = null;"
ng-model="ctrl.model.secureSettings.apiToken"
data-placement="right">
</input>
</div>
<div class="gf-form" ng-if="ctrl.model.secureFields.apiToken">
<input type="text" class="gf-form-input max-width-18" disabled="disabled" value="configured" />
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="ctrl.model.secureFields.apiToken = false">reset</a>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label max-width-10">User Key(s)</label>
<div class="gf-form gf-form--grow" ng-if="!ctrl.model.secureFields.userKey">
<input type="text"
required
class="gf-form-input"
ng-init="ctrl.model.secureSettings.userKey = ctrl.model.settings.userKey || null; ctrl.model.settings.userKey = null;"
ng-model="ctrl.model.secureSettings.userKey"
placeholder="comma-separated list"
data-placement="right">
</input>
</div>
<div class="gf-form" ng-if="ctrl.model.secureFields.userKey">
<input type="text" class="gf-form-input max-width-18" disabled="disabled" value="configured" />
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="ctrl.model.secureFields.userKey = false">reset</a>
</div>
</div>
<div class="gf-form">
<span class="gf-form-label width-10">Device(s) (optional)</span>
<input type="text" class="gf-form-input" placeholder="comma-separated list; leave empty to send to all devices" ng-model="ctrl.model.settings.device"></input>
</div>
<div class="gf-form">
<span class="gf-form-label width-10">Priority</span>
<select class="gf-form-input max-width-14" ng-model="ctrl.model.settings.priority" ng-options="v as k for (k, v) in {
Emergency: '2',
High: '1',
Normal: '0',
Low: '-1',
Lowest: '-2'
}" ng-init="ctrl.model.settings.priority=ctrl.model.settings.priority||'0'"></select>
</div>
<div class="gf-form" ng-show="ctrl.model.settings.priority == '2'">
<span class="gf-form-label width-10">Retry</span>
<input type="text" class="gf-form-input max-width-14" ng-required="ctrl.model.settings.priority == '2'" placeholder="minimum 30 seconds" ng-model="ctrl.model.settings.retry" ng-init="ctrl.model.settings.retry=ctrl.model.settings.retry||'60'></input>
</div>
<div class="gf-form" ng-show="ctrl.model.settings.priority == '2'">
<span class="gf-form-label width-10">Expire</span>
<input type="text" class="gf-form-input max-width-14" ng-required="ctrl.model.settings.priority == '2'" placeholder="maximum 86400 seconds" ng-model="ctrl.model.settings.expire" ng-init="ctrl.model.settings.expire=ctrl.model.settings.expire||'3600'"></input>
</div>
<div class="gf-form">
<span class="gf-form-label width-10">Alerting sound</span>
<select class="gf-form-input max-width-14" ng-model="ctrl.model.settings.sound" ng-options="s for s in [
` + sounds + `
]" ng-init="ctrl.model.settings.sound=ctrl.model.settings.sound||'default'"></select>
</div>
<div class="gf-form">
<span class="gf-form-label width-10">OK sound</span>
<select class="gf-form-input max-width-14" ng-model="ctrl.model.settings.okSound" ng-options="s for s in [
` + sounds + `
]" ng-init="ctrl.model.settings.okSound=ctrl.model.settings.okSound||'default'"></select>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "API Token",
@@ -200,6 +105,7 @@ func init() {
Placeholder: "Application token",
PropertyName: "apiToken",
Required: true,
Secure: true,
},
{
Label: "User key(s)",
@@ -208,6 +114,7 @@ func init() {
Placeholder: "comma-separated list",
PropertyName: "userKey",
Required: true,
Secure: true,
},
{
Label: "Device(s) (optional)",

View File

@@ -18,41 +18,6 @@ func init() {
Description: "Sends HTTP POST request to a Sensu API",
Heading: "Sensu settings",
Factory: NewSensuNotifier,
OptionsTemplate: `
<h3 class="page-heading">Sensu settings</h3>
<div class="gf-form">
<span class="gf-form-label width-10">Url</span>
<input type="text" required class="gf-form-input max-width-26" ng-model="ctrl.model.settings.url" placeholder="http://sensu-api.local:4567/results"></input>
</div>
<div class="gf-form">
<span class="gf-form-label width-10">Source</span>
<input type="text" class="gf-form-input max-width-14" ng-model="ctrl.model.settings.source" bs-tooltip="'If empty rule id will be used'" data-placement="right"></input>
</div>
<div class="gf-form">
<span class="gf-form-label width-10">Handler</span>
<input type="text" class="gf-form-input max-width-14" ng-model="ctrl.model.settings.handler" placeholder="default"></input>
</div>
<div class="gf-form">
<span class="gf-form-label width-10">Username</span>
<input type="text" class="gf-form-input max-width-14" ng-model="ctrl.model.settings.username"></input>
</div>
<div class="gf-form">
<label class="gf-form-label width-10">Password</label>
<div class="gf-form gf-form--grow" ng-if="!ctrl.model.secureFields.password">
<input type="text"
required
class="gf-form-input max-width-14"
ng-init="ctrl.model.secureSettings.password = ctrl.model.settings.password || null; ctrl.model.settings.password = null;"
ng-model="ctrl.model.secureSettings.password"
data-placement="right">
</input>
</div>
<div class="gf-form" ng-if="ctrl.model.secureFields.password">
<input type="text" class="gf-form-input max-width-14" disabled="disabled" value="configured" />
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="ctrl.model.secureFields.password = false">reset</a>
</div>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "Url",
@@ -87,6 +52,7 @@ func init() {
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypePassword,
PropertyName: "passsword ",
Secure: true,
},
},
})

View File

@@ -27,123 +27,6 @@ func init() {
Description: "Sends notifications to Slack via Slack Webhooks",
Heading: "Slack settings",
Factory: NewSlackNotifier,
OptionsTemplate: `
<h3 class="page-heading">Slack settings</h3>
<div class="gf-form max-width-30">
<span class="gf-form-label width-8">Url</span>
<div class="gf-form gf-form--grow" ng-if="!ctrl.model.secureFields.url">
<input type="text"
required
class="gf-form-input max-width-30"
ng-init="ctrl.model.secureSettings.url = ctrl.model.settings.url || null; ctrl.model.settings.url = null;"
ng-model="ctrl.model.secureSettings.url"
placeholder="Slack incoming webhook url">
</input>
</div>
<div class="gf-form" ng-if="ctrl.model.secureFields.url">
<input type="text" class="gf-form-input max-width-18" disabled="disabled" value="configured" />
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="ctrl.model.secureFields.url = false">reset</a>
</div>
</div>
<div class="gf-form max-width-30">
<span class="gf-form-label width-8">Recipient</span>
<input type="text"
class="gf-form-input max-width-30"
ng-model="ctrl.model.settings.recipient"
data-placement="right">
</input>
<info-popover mode="right-absolute">
Override default channel or user, use #channel-name, @username (has to be all lowercase, no whitespace), or user/channel Slack ID
</info-popover>
</div>
<div class="gf-form max-width-30">
<span class="gf-form-label width-8">Username</span>
<input type="text"
class="gf-form-input max-width-30"
ng-model="ctrl.model.settings.username"
data-placement="right">
</input>
<info-popover mode="right-absolute">
Set the username for the bot's message
</info-popover>
</div>
<div class="gf-form max-width-30">
<span class="gf-form-label width-8">Icon emoji</span>
<input type="text"
class="gf-form-input max-width-30"
ng-model="ctrl.model.settings.icon_emoji"
data-placement="right">
</input>
<info-popover mode="right-absolute">
Provide an emoji to use as the icon for the bot's message. Overrides the icon URL
</info-popover>
</div>
<div class="gf-form max-width-30">
<span class="gf-form-label width-8">Icon URL</span>
<input type="text"
class="gf-form-input max-width-30"
ng-model="ctrl.model.settings.icon_url"
data-placement="right">
</input>
<info-popover mode="right-absolute">
Provide a URL to an image to use as the icon for the bot's message
</info-popover>
</div>
<div class="gf-form max-width-30">
<span class="gf-form-label width-8">Mention Users</span>
<input type="text"
class="gf-form-input max-width-30"
ng-model="ctrl.model.settings.mentionUsers"
data-placement="right">
</input>
<info-popover mode="right-absolute">
Mention one or more users (comma separated) when notifying in a channel, by ID (you can copy this from the user's Slack profile)
</info-popover>
</div>
<div class="gf-form max-width-30">
<span class="gf-form-label width-8">Mention Groups</span>
<input type="text"
class="gf-form-input max-width-30"
ng-model="ctrl.model.settings.mentionGroups"
data-placement="right">
</input>
<info-popover mode="right-absolute">
Mention one or more groups (comma separated) when notifying in a channel (you can copy this from the group's Slack profile URL)
</info-popover>
</div>
<div class="gf-form max-width-30">
<span class="gf-form-label width-8">Mention Channel</span>
<select
class="gf-form-input max-width-30"
ng-model="ctrl.model.settings.mentionChannel"
data-placement="right">
<option value="">Disabled</option>
<option value="here">Every active channel member</option>
<option value="channel">Every channel member</option>
</select>
<info-popover mode="right-absolute">
Mention whole channel or just active members when notifying
</info-popover>
</div>
<div class="gf-form max-width-30">
<div class="gf-form gf-form--v-stretch"><label class="gf-form-label width-8">Token</label></div>
<div class="gf-form gf-form--grow" ng-if="!ctrl.model.secureFields.token">
<input type="text"
class="gf-form-input max-width-30"
ng-init="ctrl.model.secureSettings.token = ctrl.model.settings.token || null; ctrl.model.settings.token = null;"
ng-model="ctrl.model.secureSettings.token"
data-placement="right">
</input>
<info-popover mode="right-absolute">
Provide a bot token to use the Slack file.upload API (starts with "xoxb"). Specify Recipient for this to work
</info-popover>
</div>
<div class="gf-form" ng-if="ctrl.model.secureFields.token">
<input type="text" class="gf-form-input max-width-18" disabled="disabled" value="configured" />
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="ctrl.model.secureFields.token = false">reset</a>
</div>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "Url",
@@ -152,6 +35,7 @@ func init() {
Placeholder: "Slack incoming webhook url",
PropertyName: "url",
Required: true,
Secure: true,
},
{
Label: "Recipient",
@@ -172,14 +56,14 @@ func init() {
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Description: "Provide an emoji to use as the icon for the bot's message. Overrides the icon URL.",
PropertyName: "icon_emoji",
PropertyName: "iconEmoji",
},
{
Label: "Icon URL",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Description: "Provide a URL to an image to use as the icon for the bot's message",
PropertyName: "icon_url",
PropertyName: "iconUrl",
},
{
Label: "Mention Users",
@@ -221,6 +105,7 @@ func init() {
InputType: alerting.InputTypeText,
Description: "Provide a bot token to use the Slack file.upload API (starts with \"xoxb\"). Specify Recipient for this to work",
PropertyName: "token",
Secure: true,
},
},
})

View File

@@ -16,13 +16,6 @@ func init() {
Description: "Sends notifications using Incoming Webhook connector to Microsoft Teams",
Heading: "Teams settings",
Factory: NewTeamsNotifier,
OptionsTemplate: `
<h3 class="page-heading">Teams settings</h3>
<div class="gf-form max-width-30">
<span class="gf-form-label width-6">Url</span>
<input type="text" InputType class="gf-form-input max-width-30" ng-model="ctrl.model.settings.url" placeholder="Teams incoming webhook url"></input>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "URL",
@@ -30,6 +23,7 @@ func init() {
InputType: alerting.InputTypeText,
Placeholder: "Teams incoming webhook url",
PropertyName: "url",
Required: true,
},
},
})

View File

@@ -28,37 +28,6 @@ func init() {
Description: "Sends notifications to Telegram",
Heading: "Telegram API settings",
Factory: NewTelegramNotifier,
OptionsTemplate: `
<h3 class="page-heading">Telegram API settings</h3>
<div class="gf-form max-width-30">
<label class="gf-form-label width-10">BOT API Token</label>
<div class="gf-form gf-form--grow" ng-if="!ctrl.model.secureFields.bottoken">
<input type="text"
required
class="gf-form-input max-width-30"
ng-init="ctrl.model.secureSettings.bottoken = ctrl.model.settings.bottoken || null; ctrl.model.settings.bottoken = null;"
ng-model="ctrl.model.secureSettings.bottoken"
placeholder="Telegram BOT API Token"
data-placement="right">
</input>
</div>
<div class="gf-form" ng-if="ctrl.model.secureFields.bottoken">
<input type="text" class="gf-form-input max-width-18" disabled="disabled" value="configured" />
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="ctrl.model.secureFields.bottoken = false">reset</a>
</div>
</div>
<div class="gf-form max-width-30">
<label class="gf-form-label width-10">Chat ID</label>
<input type="text"
required
class="gf-form-input max-width-30"
ng-model="ctrl.model.settings.chatid"
placeholder="Telegram Chat ID"
data-placement="right">
</input>
<info-popover mode="right-absolute">Integer Telegram Chat Identifier</info-popover>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "BOT API Token",
@@ -67,6 +36,7 @@ func init() {
Placeholder: "Telegram BOT API Token",
PropertyName: "bottoken",
Required: true,
Secure: true,
},
{
Label: "Chat ID",

View File

@@ -24,55 +24,6 @@ func init() {
Info: "Notifications can be configured for any Threema Gateway ID of type \"Basic\". End-to-End IDs are not currently supported." +
"The Threema Gateway ID can be set up at https://gateway.threema.ch/.",
Factory: NewThreemaNotifier,
OptionsTemplate: `
<h3 class="page-heading">Threema Gateway settings</h3>
<p>
Notifications can be configured for any Threema Gateway ID of type
"Basic". End-to-End IDs are not currently supported.
</p>
<p>
The Threema Gateway ID can be set up at
<a href="https://gateway.threema.ch/" target="_blank" rel="noopener noreferrer">https://gateway.threema.ch/</a>.
</p>
<div class="gf-form max-width-30">
<span class="gf-form-label width-8">Gateway ID</span>
<input type="text" required maxlength="8" pattern="\*[0-9A-Z]{7}"
class="gf-form-input max-width-30"
ng-model="ctrl.model.settings.gateway_id"
placeholder="*3MAGWID">
</input>
<info-popover mode="right-absolute">
Your 8 character Threema Gateway ID (starting with a *)
</info-popover>
</div>
<div class="gf-form max-width-30">
<span class="gf-form-label width-8">Recipient ID</span>
<input type="text" required maxlength="8" pattern="[0-9A-Z]{8}"
class="gf-form-input max-width-30"
ng-model="ctrl.model.settings.recipient_id"
placeholder="YOUR3MID">
</input>
<info-popover mode="right-absolute">
The 8 character Threema ID that should receive the alerts
</info-popover>
</div>
<div class="gf-form max-width-30">
<label class="gf-form-label width-8">API Secret</label>
<div class="gf-form gf-form--grow" ng-if="!ctrl.model.secureFields.api_secret">
<input type="text"
required
class="gf-form-input max-width-30"
ng-init="ctrl.model.secureSettings.api_secret = ctrl.model.settings.api_secret || null; ctrl.model.settings.api_secret = null;"
ng-model="ctrl.model.secureSettings.api_secret"
data-placement="right">
</input>
</div>
<div class="gf-form" ng-if="ctrl.model.secureFields.api_secret">
<input type="text" class="gf-form-input max-width-18" disabled="disabled" value="configured" />
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="ctrl.model.secureFields.api_secret = false">reset</a>
</div>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "Gateway ID",
@@ -101,6 +52,7 @@ func init() {
Description: "Your Threema Gateway API secret.",
PropertyName: "api_secret",
Required: true,
Secure: true,
},
},
})

View File

@@ -25,29 +25,6 @@ func init() {
Description: "Sends notifications to VictorOps",
Heading: "VictorOps settings",
Factory: NewVictoropsNotifier,
OptionsTemplate: `
<h3 class="page-heading">VictorOps settings</h3>
<div class="gf-form">
<span class="gf-form-label width-6">Url</span>
<input type="text" required class="gf-form-input max-width-30" ng-model="ctrl.model.settings.url" placeholder="VictorOps url"></input>
</div>
<div class="gf-form">
<span class="gf-form-label width-10">No Data Alert Type</span>
<div class="gf-form-select-wrapper width-14">
<select class="gf-form-input" ng-model="ctrl.model.settings.noDataAlertType" ng-options="t for t in ['CRITICAL', 'WARNING']" ng-init="ctrl.model.settings.noDataAlertType=ctrl.model.settings.noDataAlertType || '` + AlertStateWarning + `'">
</select>
</div>
</div>
<div class="gf-form">
<gf-form-switch
class="gf-form"
label="Auto resolve incidents"
label-class="width-14"
checked="ctrl.model.settings.autoResolve"
tooltip="Resolve incidents in VictorOps once the alert goes back to ok.">
</gf-form-switch>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "Url",
@@ -60,7 +37,7 @@ func init() {
{
Label: "Auto resolve incidents",
Description: "Resolve incidents in VictorOps once the alert goes back to ok.",
Element: alerting.ElementTypeSwitch,
Element: alerting.ElementTypeCheckbox,
PropertyName: "autoResolve",
},
},

View File

@@ -15,39 +15,6 @@ func init() {
Description: "Sends HTTP POST request to a URL",
Heading: "Webhook settings",
Factory: NewWebHookNotifier,
OptionsTemplate: `
<h3 class="page-heading">Webhook settings</h3>
<div class="gf-form max-width-30">
<span class="gf-form-label width-8">Url</span>
<input type="text" required class="gf-form-input max-width-26" ng-model="ctrl.model.settings.url"></input>
</div>
<div class="gf-form max-width-30">
<span class="gf-form-label width-8">Http Method</span>
<div class="gf-form-select-wrapper max-width-30">
<select class="gf-form-input" ng-model="ctrl.model.settings.httpMethod" ng-options="t for t in ['POST', 'PUT']">
</select>
</div>
</div>
<div class="gf-form max-width-30">
<span class="gf-form-label width-8">Username</span>
<input type="text" class="gf-form-input max-width-30" ng-model="ctrl.model.settings.username"></input>
</div>
<div class="gf-form max-width-30">
<div class="gf-form gf-form--v-stretch"><label class="gf-form-label width-8">Password</label></div>
<div class="gf-form gf-form--grow" ng-if="!ctrl.model.secureFields.password">
<input type="text"
class="gf-form-input max-width-30"
ng-init="ctrl.model.secureSettings.password = ctrl.model.settings.password || null; ctrl.model.settings.password = null;"
ng-model="ctrl.model.secureSettings.password"
data-placement="right">
</input>
</div>
<div class="gf-form" ng-if="ctrl.model.secureFields.password">
<input type="text" class="gf-form-input max-width-18" disabled="disabled" value="configured" />
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="ctrl.model.secureFields.password = false">reset</a>
</div>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "Url",
@@ -82,6 +49,7 @@ func init() {
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypePassword,
PropertyName: "password",
Secure: true,
},
},
})

View File

@@ -0,0 +1,150 @@
import React, { PureComponent } from 'react';
import { MapDispatchToProps, MapStateToProps } from 'react-redux';
import { NavModel } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Form, Spinner } from '@grafana/ui';
import Page from 'app/core/components/Page/Page';
import { connectWithCleanUp } from 'app/core/components/connectWithCleanUp';
import { NotificationChannelForm } from './components/NotificationChannelForm';
import {
loadNotificationChannel,
loadNotificationTypes,
testNotificationChannel,
updateNotificationChannel,
} from './state/actions';
import { getNavModel } from 'app/core/selectors/navModel';
import { getRouteParamsId } from 'app/core/selectors/location';
import { mapChannelsToSelectableValue, transformSubmitData, transformTestData } from './utils/notificationChannels';
import { NotificationChannelType, NotificationChannelDTO, StoreState } from 'app/types';
import { resetSecureField } from './state/reducers';
interface OwnProps {}
interface ConnectedProps {
navModel: NavModel;
channelId: number;
notificationChannel: any;
notificationChannelTypes: NotificationChannelType[];
}
interface DispatchProps {
loadNotificationTypes: typeof loadNotificationTypes;
loadNotificationChannel: typeof loadNotificationChannel;
testNotificationChannel: typeof testNotificationChannel;
updateNotificationChannel: typeof updateNotificationChannel;
resetSecureField: typeof resetSecureField;
}
type Props = OwnProps & ConnectedProps & DispatchProps;
export class EditNotificationChannelPage extends PureComponent<Props> {
componentDidMount() {
const { channelId } = this.props;
this.props.loadNotificationTypes();
this.props.loadNotificationChannel(channelId);
}
onSubmit = (formData: NotificationChannelDTO) => {
const { notificationChannel } = this.props;
this.props.updateNotificationChannel({
/*
Some settings which lives in a collapsed section will not be registered since
the section will not be rendered if a user doesn't expand it. Therefore we need to
merge the initialData with any changes from the form.
*/
...transformSubmitData({
...notificationChannel,
...formData,
settings: { ...notificationChannel.settings, ...formData.settings },
}),
id: notificationChannel.id,
});
};
onTestChannel = (formData: NotificationChannelDTO) => {
const { notificationChannel } = this.props;
/*
Same as submit
*/
this.props.testNotificationChannel(
transformTestData({
...notificationChannel,
...formData,
settings: { ...notificationChannel.settings, ...formData.settings },
})
);
};
render() {
const { navModel, notificationChannel, notificationChannelTypes } = this.props;
return (
<Page navModel={navModel}>
<Page.Contents>
<h2 className="page-sub-heading">Edit notification channel</h2>
{notificationChannel && notificationChannel.id > 0 ? (
<Form
width={600}
onSubmit={this.onSubmit}
defaultValues={{
...notificationChannel,
type: notificationChannelTypes.find(n => n.value === notificationChannel.type),
}}
>
{({ control, errors, getValues, register, watch }) => {
const selectedChannel = notificationChannelTypes.find(c => c.value === getValues().type.value);
return (
<NotificationChannelForm
selectableChannels={mapChannelsToSelectableValue(notificationChannelTypes)}
selectedChannel={selectedChannel}
imageRendererAvailable={config.rendererAvailable}
onTestChannel={this.onTestChannel}
register={register}
watch={watch}
errors={errors}
getValues={getValues}
control={control}
resetSecureField={this.props.resetSecureField}
secureFields={notificationChannel.secureFields}
/>
);
}}
</Form>
) : (
<div>
Loading notification channel
<Spinner />
</div>
)}
</Page.Contents>
</Page>
);
}
}
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = state => {
const channelId = getRouteParamsId(state.location) as number;
return {
navModel: getNavModel(state.navIndex, 'channels'),
channelId,
notificationChannel: state.notificationChannel.notificationChannel,
notificationChannelTypes: state.notificationChannel.notificationChannelTypes,
};
};
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = {
loadNotificationTypes,
loadNotificationChannel,
testNotificationChannel,
updateNotificationChannel,
resetSecureField,
};
export default connectWithCleanUp(
mapStateToProps,
mapDispatchToProps,
state => state.notificationChannel
)(EditNotificationChannelPage);

View File

@@ -1,132 +0,0 @@
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,96 @@
import React, { PureComponent } from 'react';
import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux';
import { NavModel } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Form } from '@grafana/ui';
import Page from 'app/core/components/Page/Page';
import { NotificationChannelForm } from './components/NotificationChannelForm';
import {
defaultValues,
mapChannelsToSelectableValue,
transformSubmitData,
transformTestData,
} from './utils/notificationChannels';
import { getNavModel } from 'app/core/selectors/navModel';
import { createNotificationChannel, loadNotificationTypes, testNotificationChannel } from './state/actions';
import { NotificationChannelType, NotificationChannelDTO, StoreState } from '../../types';
import { resetSecureField } from './state/reducers';
interface OwnProps {}
interface ConnectedProps {
navModel: NavModel;
notificationChannelTypes: NotificationChannelType[];
}
interface DispatchProps {
createNotificationChannel: typeof createNotificationChannel;
loadNotificationTypes: typeof loadNotificationTypes;
testNotificationChannel: typeof testNotificationChannel;
resetSecureField: typeof resetSecureField;
}
type Props = OwnProps & ConnectedProps & DispatchProps;
class NewNotificationChannelPage extends PureComponent<Props> {
componentDidMount() {
this.props.loadNotificationTypes();
}
onSubmit = (data: NotificationChannelDTO) => {
this.props.createNotificationChannel(transformSubmitData({ ...defaultValues, ...data }));
};
onTestChannel = (data: NotificationChannelDTO) => {
this.props.testNotificationChannel(transformTestData({ ...defaultValues, ...data }));
};
render() {
const { navModel, notificationChannelTypes } = this.props;
return (
<Page navModel={navModel}>
<Page.Contents>
<h2 className="page-sub-heading">New notification channel</h2>
<Form onSubmit={this.onSubmit} validateOn="onChange" defaultValues={defaultValues}>
{({ register, errors, control, getValues, watch }) => {
const selectedChannel = notificationChannelTypes.find(c => c.value === getValues().type.value);
return (
<NotificationChannelForm
selectableChannels={mapChannelsToSelectableValue(notificationChannelTypes)}
selectedChannel={selectedChannel}
onTestChannel={this.onTestChannel}
register={register}
errors={errors}
getValues={getValues}
control={control}
watch={watch}
imageRendererAvailable={config.rendererAvailable}
resetSecureField={this.props.resetSecureField}
secureFields={{}}
/>
);
}}
</Form>
</Page.Contents>
</Page>
);
}
}
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = state => {
return {
navModel: getNavModel(state.navIndex, 'channels'),
notificationChannelTypes: state.notificationChannel.notificationChannelTypes,
};
};
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = {
createNotificationChannel,
loadNotificationTypes,
testNotificationChannel,
resetSecureField,
};
export default connect(mapStateToProps, mapDispatchToProps)(NewNotificationChannelPage);

View File

@@ -0,0 +1,44 @@
import React, { FC } from 'react';
import { SelectableValue } from '@grafana/data';
import { CollapsableSection, Field, Input, InputControl, Select } from '@grafana/ui';
import { NotificationChannelOptions } from './NotificationChannelOptions';
import { NotificationSettingsProps } from './NotificationChannelForm';
import { NotificationChannelSecureFields, NotificationChannelType } from '../../../types';
interface Props extends NotificationSettingsProps {
selectedChannel: NotificationChannelType;
channels: Array<SelectableValue<string>>;
secureFields: NotificationChannelSecureFields;
resetSecureField: (key: string) => void;
}
export const BasicSettings: FC<Props> = ({
control,
currentFormValues,
errors,
secureFields,
selectedChannel,
channels,
register,
resetSecureField,
}) => {
return (
<CollapsableSection label="Channel" isOpen>
<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={channels} control={control} rules={{ required: true }} />
</Field>
<NotificationChannelOptions
selectedChannelOptions={selectedChannel.options.filter(o => o.required)}
currentFormValues={currentFormValues}
secureFields={secureFields}
onResetSecureField={resetSecureField}
register={register}
errors={errors}
control={control}
/>
</CollapsableSection>
);
};

View File

@@ -0,0 +1,36 @@
import React, { FC } from 'react';
import { CollapsableSection, InfoBox } from '@grafana/ui';
import { NotificationChannelOptions } from './NotificationChannelOptions';
import { NotificationSettingsProps } from './NotificationChannelForm';
import { NotificationChannelSecureFields, NotificationChannelType } from '../../../types';
interface Props extends NotificationSettingsProps {
selectedChannel: NotificationChannelType;
secureFields: NotificationChannelSecureFields;
resetSecureField: (key: string) => void;
}
export const ChannelSettings: FC<Props> = ({
control,
currentFormValues,
errors,
selectedChannel,
secureFields,
register,
resetSecureField,
}) => {
return (
<CollapsableSection label={`Optional ${selectedChannel.heading}`} isOpen={false}>
{selectedChannel.info !== '' && <InfoBox>{selectedChannel.info}</InfoBox>}
<NotificationChannelOptions
selectedChannelOptions={selectedChannel.options.filter(o => !o.required)}
currentFormValues={currentFormValues}
register={register}
errors={errors}
control={control}
onResetSecureField={resetSecureField}
secureFields={secureFields}
/>
</CollapsableSection>
);
};

View File

@@ -1,125 +0,0 @@
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,100 @@
import React, { FC, useEffect } from 'react';
import { css } from 'emotion';
import { GrafanaTheme, SelectableValue } from '@grafana/data';
import { Button, FormAPI, HorizontalGroup, stylesFactory, useTheme, Spinner } from '@grafana/ui';
import { NotificationChannelType, NotificationChannelDTO, NotificationChannelSecureFields } from '../../../types';
import { NotificationSettings } from './NotificationSettings';
import { BasicSettings } from './BasicSettings';
import { ChannelSettings } from './ChannelSettings';
interface Props extends Omit<FormAPI<NotificationChannelDTO>, 'formState'> {
selectableChannels: Array<SelectableValue<string>>;
selectedChannel?: NotificationChannelType;
imageRendererAvailable: boolean;
secureFields: NotificationChannelSecureFields;
resetSecureField: (key: string) => void;
onTestChannel: (data: NotificationChannelDTO) => void;
}
export interface NotificationSettingsProps
extends Omit<FormAPI<NotificationChannelDTO>, 'formState' | 'watch' | 'getValues'> {
currentFormValues: NotificationChannelDTO;
}
export const NotificationChannelForm: FC<Props> = ({
control,
errors,
selectedChannel,
selectableChannels,
register,
watch,
getValues,
imageRendererAvailable,
onTestChannel,
resetSecureField,
secureFields,
}) => {
const styles = getStyles(useTheme());
useEffect(() => {
watch(['type', 'settings.priority', 'sendReminder', 'uploadImage']);
}, []);
const currentFormValues = getValues();
return selectedChannel ? (
<>
<div className={styles.basicSettings}>
<BasicSettings
selectedChannel={selectedChannel}
channels={selectableChannels}
secureFields={secureFields}
resetSecureField={resetSecureField}
currentFormValues={currentFormValues}
register={register}
errors={errors}
control={control}
/>
{/* If there are no non-required fields, don't render this section*/}
{selectedChannel.options.filter(o => !o.required).length > 0 && (
<ChannelSettings
selectedChannel={selectedChannel}
secureFields={secureFields}
resetSecureField={resetSecureField}
currentFormValues={currentFormValues}
register={register}
errors={errors}
control={control}
/>
)}
<NotificationSettings
imageRendererAvailable={imageRendererAvailable}
currentFormValues={currentFormValues}
register={register}
errors={errors}
control={control}
/>
</div>
<HorizontalGroup>
<Button type="submit">Save</Button>
<Button type="button" variant="secondary" onClick={() => onTestChannel(getValues({ nest: true }))}>
Test
</Button>
<a href="/alerting/notifications">
<Button type="button" variant="secondary">
Back
</Button>
</a>
</HorizontalGroup>
</>
) : (
<Spinner />
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
basicSettings: css`
margin-bottom: ${theme.spacing.xl};
`,
};
});

View File

@@ -1,28 +1,30 @@
import React, { FC } from 'react';
import { SelectableValue } from '@grafana/data';
import { Field, FormAPI, InfoBox } from '@grafana/ui';
import { Button, Checkbox, Field, FormAPI, Input } from '@grafana/ui';
import { OptionElement } from './OptionElement';
import { NotificationChannel, NotificationChannelDTO, Option } from '../../../types';
import { NotificationChannelDTO, NotificationChannelOption, NotificationChannelSecureFields } from '../../../types';
interface Props extends Omit<FormAPI<NotificationChannelDTO>, 'formState' | 'getValues' | 'watch'> {
selectedChannel: NotificationChannel;
selectedChannelOptions: NotificationChannelOption[];
currentFormValues: NotificationChannelDTO;
secureFields: NotificationChannelSecureFields;
onResetSecureField: (key: string) => void;
}
export const NotificationChannelOptions: FC<Props> = ({
control,
currentFormValues,
errors,
selectedChannel,
selectedChannelOptions,
register,
onResetSecureField,
secureFields,
}) => {
return (
<>
<h3>{selectedChannel.heading}</h3>
{selectedChannel.info !== '' && <InfoBox>{selectedChannel.info}</InfoBox>}
{selectedChannel.options.map((option: Option, index: number) => {
{selectedChannelOptions.map((option: NotificationChannelOption, index: number) => {
const key = `${option.label}-${index}`;
// Some options can be dependent on other options, this determines what is selected in the dependency options
// I think this needs more thought.
const selectedOptionValue =
@@ -33,6 +35,18 @@ export const NotificationChannelOptions: FC<Props> = ({
return null;
}
if (option.element === 'checkbox') {
return (
<Field key={key}>
<Checkbox
name={option.secure ? `secureSettings.${option.propertyName}` : `settings.${option.propertyName}`}
ref={register}
label={option.label}
description={option.description}
/>
</Field>
);
}
return (
<Field
key={key}
@@ -41,7 +55,19 @@ export const NotificationChannelOptions: FC<Props> = ({
invalid={errors.settings && !!errors.settings[option.propertyName]}
error={errors.settings && errors.settings[option.propertyName]?.message}
>
<OptionElement option={option} register={register} control={control} />
{secureFields && secureFields[option.propertyName] ? (
<Input
readOnly={true}
value="Configured"
addonAfter={
<Button onClick={() => onResetSecureField(option.propertyName)} variant="secondary" type="button">
Reset
</Button>
}
/>
) : (
<OptionElement option={option} register={register} control={control} />
)}
</Field>
);
})}

View File

@@ -0,0 +1,59 @@
import React, { FC } from 'react';
import { Checkbox, CollapsableSection, Field, InfoBox, Input } from '@grafana/ui';
import { NotificationSettingsProps } from './NotificationChannelForm';
interface Props extends NotificationSettingsProps {
imageRendererAvailable: boolean;
}
export const NotificationSettings: FC<Props> = ({ currentFormValues, imageRendererAvailable, register }) => {
return (
<CollapsableSection label="Notification settings" isOpen={false}>
<Field>
<Checkbox name="isDefault" ref={register} label="Default" description="Use this notification for all alerts" />
</Field>
<Field>
<Checkbox
name="settings.uploadImage"
ref={register}
label="Include image"
description="Captures an image and include it in the notification"
/>
</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>
<Checkbox
name="disableResolveMessage"
ref={register}
label="Disable Resolve Message"
description="Disable the resolve message [OK] that is sent when alerting state returns to false"
/>
</Field>
<Field>
<Checkbox
name="sendReminder"
ref={register}
label="Send reminders"
description="Send additional notifications for triggered alerts"
/>
</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.
Alert reminders are sent after rules are evaluated. Therefore a reminder can never be sent more frequently
than a configured alert rule evaluation interval."
>
<Input name="frequency" ref={register} width={8} />
</Field>
</>
)}
</CollapsableSection>
);
};

View File

@@ -1,17 +1,19 @@
import React, { FC } from 'react';
import { FormAPI, Input, InputControl, Select, Switch, TextArea } from '@grafana/ui';
import { Option } from '../../../types';
import { FormAPI, Input, InputControl, Select, TextArea } from '@grafana/ui';
import { NotificationChannelOption } from '../../../types';
interface Props extends Pick<FormAPI<any>, 'register' | 'control'> {
option: Option;
option: NotificationChannelOption;
invalid?: boolean;
}
export const OptionElement: FC<Props> = ({ control, option, register }) => {
const modelValue = `settings.${option.propertyName}`;
export const OptionElement: FC<Props> = ({ control, option, register, invalid }) => {
const modelValue = option.secure ? `secureSettings.${option.propertyName}` : `settings.${option.propertyName}`;
switch (option.element) {
case 'input':
return (
<Input
invalid={invalid}
type={option.inputType}
name={`${modelValue}`}
ref={register({
@@ -23,11 +25,20 @@ export const OptionElement: FC<Props> = ({ control, option, register }) => {
);
case 'select':
return <InputControl as={Select} options={option.selectOptions} control={control} name={`${modelValue}`} />;
return (
<InputControl
as={Select}
options={option.selectOptions}
control={control}
name={`${modelValue}`}
invalid={invalid}
/>
);
case 'textarea':
return (
<TextArea
invalid={invalid}
name={`${modelValue}`}
ref={register({
required: option.required ? 'Required' : false,
@@ -36,16 +47,6 @@ export const OptionElement: FC<Props> = ({ control, option, register }) => {
/>
);
case 'switch':
return (
<Switch
name={`${modelValue}`}
ref={register({
required: option.required ? 'Required' : false,
})}
/>
);
default:
console.error('Element not supported', option.element);
return null;

View File

@@ -1,89 +0,0 @@
<page-header model="ctrl.navModel"></page-header>
<div class="page-container page-body">
<h3 class="page-sub-heading" ng-hide="ctrl.isNew">Edit Notification Channel</h3>
<h3 class="page-sub-heading" ng-show="ctrl.isNew">New Notification Channel</h3>
<form name="ctrl.theForm" ng-if="ctrl.notifiers">
<div class="gf-form-group">
<div class="gf-form">
<span class="gf-form-label width-12">Name</span>
<input type="text" required class="gf-form-input max-width-15" ng-model="ctrl.model.name" required></input>
</div>
<div class="gf-form">
<span class="gf-form-label width-12">Type</span>
<div class="gf-form-select-wrapper width-15">
<select class="gf-form-input" ng-model="ctrl.model.type" ng-options="t.type as t.name for t in ctrl.notifiers" ng-change="ctrl.typeChanged(notification, $index)">
</select>
</div>
</div>
<gf-form-switch
class="gf-form"
label="Default (send on all alerts)"
label-class="width-14"
checked="ctrl.model.isDefault"
tooltip="Use this notification for all alerts">
</gf-form-switch>
<gf-form-switch
class="gf-form"
label="Include image"
label-class="width-14"
checked="ctrl.model.settings.uploadImage"
tooltip="Captures an image and include it in the notification">
</gf-form-switch>
<div class="grafana-info-box m-t-2" ng-show="ctrl.model.settings.uploadImage && !ctrl.rendererAvailable">
<h5>No image renderer available/installed</h5>
<p>
Grafana cannot find an image renderer to capture an image for the notification.
Please make sure the <a href="https://grafana.com/grafana/plugins/grafana-image-renderer" target="_blank" rel="noreferrer">Grafana Image Renderer plugin</a> is installed.
</p>
<p>
Please contact your Grafana administrator to install the plugin.
</p>
</div>
<gf-form-switch
class="gf-form"
label="Disable Resolve Message"
label-class="width-14"
checked="ctrl.model.disableResolveMessage"
tooltip="Disable the resolve message [OK] that is sent when alerting state returns to false">
</gf-form-switch>
<gf-form-switch
class="gf-form"
label="Send reminders"
label-class="width-14"
checked="ctrl.model.sendReminder"
tooltip="Send additional notifications for triggered alerts">
</gf-form-switch>
<div class="gf-form-inline">
<div class="gf-form" ng-if="ctrl.model.sendReminder">
<span class="gf-form-label width-12">Send reminder every
<info-popover mode="right-normal" position="top center">
Specify how often reminders should be sent, e.g. every 30s, 1m, 10m, 30m or 1h etc.
</info-popover>
</span>
<input type="text" placeholder="Select or specify custom" class="gf-form-input width-15" ng-model="ctrl.model.frequency"
bs-typeahead="ctrl.getFrequencySuggestion" data-min-length=0 ng-required="ctrl.model.sendReminder">
</div>
</div>
<div class="gf-form">
<span class="alert alert-info width-30" ng-if="ctrl.model.sendReminder">
Alert reminders are sent after rules are evaluated. Therefore a reminder can never be sent more frequently than a configured alert rule evaluation interval.
</span>
</div>
</div>
<div class="gf-form-group" ng-include src="ctrl.notifierTemplateId">
</div>
<div class="gf-form-group gf-form-button-row">
<button type="submit" ng-click="ctrl.save()" class="btn btn-primary width-7">Save</button>
<button type="submit" ng-click="ctrl.testNotification()" class="btn btn-secondary">Send Test</button>
<button type="delete" ng-if="!ctrl.isNew" ng-click="ctrl.deleteNotification()" class="btn btn-danger width-7">Delete</button>
<a href="alerting/notifications" class="btn btn-inverse">Back</a>
</div>
</form>
</div>
<footer />

View File

@@ -3,7 +3,7 @@ import { getBackendSrv } from '@grafana/runtime';
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';
import { notificationChannelLoaded, loadAlertRules, loadedAlertRules, setNotificationChannels } from './reducers';
export function getAlertRulesAsync(options: { state: string }): ThunkResult<void> {
return async dispatch => {
@@ -33,9 +33,22 @@ export function createNotificationChannel(data: any): ThunkResult<void> {
};
}
export function updateNotificationChannel(data: any): ThunkResult<void> {
return async dispatch => {
try {
await getBackendSrv().put(`/api/alert-notifications/${data.id}`, data);
appEvents.emit(AppEvents.alertSuccess, ['Notification updated']);
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);
return async (dispatch, getState) => {
const channel = getState().notificationChannel.notificationChannel;
await getBackendSrv().post('/api/alert-notifications/test', { id: channel.id, ...data });
};
}
@@ -43,22 +56,20 @@ 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;
});
const notificationTypes = alertNotifiers.sort((o1, o2) => {
if (o1.name > o2.name) {
return 1;
}
return -1;
});
dispatch(setNotificationChannels(notificationTypes));
};
}
export function loadNotificationChannel(id: number): ThunkResult<void> {
return async dispatch => {
const notificationChannel = await getBackendSrv().get(`/api/alert-notifications/${id}`);
dispatch(notificationChannelLoaded(notificationChannel));
};
}

View File

@@ -1,6 +1,15 @@
import { dateTime } from '@grafana/data';
import { alertRulesReducer, initialState, loadAlertRules, loadedAlertRules, setSearchQuery } from './reducers';
import { AlertRuleDTO, AlertRulesState } from 'app/types';
import {
alertRulesReducer,
initialChannelState,
initialState,
loadAlertRules,
loadedAlertRules,
notificationChannelReducer,
setSearchQuery,
notificationChannelLoaded,
} from './reducers';
import { AlertRuleDTO, AlertRulesState, NotificationChannelState, NotifierDTO } from 'app/types';
import { reducerTester } from '../../../../test/core/redux/reducerTester';
describe('Alert rules', () => {
@@ -225,3 +234,190 @@ describe('Alert rules', () => {
});
});
});
describe('Notification channel', () => {
const notifiers: NotifierDTO[] = [
{
type: 'webhook',
name: 'webhook',
heading: 'Webhook settings',
description: 'Sends HTTP POST request to a URL',
info: '',
options: [
{
element: 'input',
inputType: 'text',
label: 'Url',
description: '',
placeholder: '',
propertyName: 'url',
showWhen: { field: '', is: '' },
required: true,
validationRule: '',
secure: false,
},
{
element: 'select',
inputType: '',
label: 'Http Method',
description: '',
placeholder: '',
propertyName: 'httpMethod',
selectOptions: [
{ value: 'POST', label: 'POST' },
{ value: 'PUT', label: 'PUT' },
],
showWhen: { field: '', is: '' },
required: false,
validationRule: '',
secure: false,
},
{
element: 'input',
inputType: 'text',
label: 'Username',
description: '',
placeholder: '',
propertyName: 'username',
showWhen: { field: '', is: '' },
required: false,
validationRule: '',
secure: false,
},
{
element: 'input',
inputType: 'password',
label: 'Password',
description: '',
placeholder: '',
propertyName: 'password',
showWhen: { field: '', is: '' },
required: false,
validationRule: '',
secure: true,
},
],
},
];
describe('Load notification channel', () => {
it('should migrate non secure settings to secure fields', () => {
const payload = {
id: 2,
uid: '9L3FrrHGk',
name: 'Webhook test',
type: 'webhook',
isDefault: false,
sendReminder: false,
disableResolveMessage: false,
frequency: '',
created: '2020-08-28T08:49:24Z',
updated: '2020-08-28T08:49:24Z',
settings: {
autoResolve: true,
httpMethod: 'POST',
password: 'asdf',
severity: 'critical',
uploadImage: true,
url: 'http://localhost.webhook',
username: 'asdf',
},
};
const expected = {
id: 2,
uid: '9L3FrrHGk',
name: 'Webhook test',
type: 'webhook',
isDefault: false,
sendReminder: false,
disableResolveMessage: false,
frequency: '',
created: '2020-08-28T08:49:24Z',
updated: '2020-08-28T08:49:24Z',
secureSettings: {
password: 'asdf',
},
settings: {
autoResolve: true,
httpMethod: 'POST',
password: '',
severity: 'critical',
uploadImage: true,
url: 'http://localhost.webhook',
username: 'asdf',
},
};
reducerTester<NotificationChannelState>()
.givenReducer(notificationChannelReducer, { ...initialChannelState, notifiers: notifiers })
.whenActionIsDispatched(notificationChannelLoaded(payload))
.thenStateShouldEqual({
...initialChannelState,
notifiers: notifiers,
notificationChannel: expected,
});
});
it('should handle already secure field', () => {
const payload = {
id: 2,
uid: '9L3FrrHGk',
name: 'Webhook test',
type: 'webhook',
isDefault: false,
sendReminder: false,
disableResolveMessage: false,
frequency: '',
created: '2020-08-28T08:49:24Z',
updated: '2020-08-28T08:49:24Z',
secureFields: {
password: true,
},
settings: {
autoResolve: true,
httpMethod: 'POST',
password: '',
severity: 'critical',
uploadImage: true,
url: 'http://localhost.webhook',
username: 'asdf',
},
};
const expected = {
id: 2,
uid: '9L3FrrHGk',
name: 'Webhook test',
type: 'webhook',
isDefault: false,
sendReminder: false,
disableResolveMessage: false,
frequency: '',
created: '2020-08-28T08:49:24Z',
updated: '2020-08-28T08:49:24Z',
secureFields: {
password: true,
},
settings: {
autoResolve: true,
httpMethod: 'POST',
password: '',
severity: 'critical',
uploadImage: true,
url: 'http://localhost.webhook',
username: 'asdf',
},
};
reducerTester<NotificationChannelState>()
.givenReducer(notificationChannelReducer, { ...initialChannelState, notifiers: notifiers })
.whenActionIsDispatched(notificationChannelLoaded(payload))
.thenStateShouldEqual({
...initialChannelState,
notifiers: notifiers,
notificationChannel: expected,
});
});
});
});

View File

@@ -1,9 +1,26 @@
import { AlertRule, AlertRuleDTO, AlertRulesState, NotificationChannel } from 'app/types';
import {
AlertRule,
AlertRuleDTO,
AlertRulesState,
NotificationChannelOption,
NotificationChannelState,
NotifierDTO,
} 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, notificationChannels: [] };
export const initialState: AlertRulesState = {
items: [],
searchQuery: '',
isLoading: false,
};
export const initialChannelState: NotificationChannelState = {
notificationChannelTypes: [],
notificationChannel: {},
notifiers: [],
};
function convertToAlertRule(dto: AlertRuleDTO, state: string): AlertRule {
const stateModel = alertDef.getStateDisplayModel(state);
@@ -47,16 +64,103 @@ 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 };
},
});
const notificationChannelSlice = createSlice({
name: 'notificationChannel',
initialState: initialChannelState,
reducers: {
setNotificationChannels: (state, action: PayloadAction<NotifierDTO[]>): NotificationChannelState => {
return {
...state,
notificationChannelTypes: transformNotifiers(action.payload),
notifiers: action.payload,
};
},
notificationChannelLoaded: (state, action: PayloadAction<any>): NotificationChannelState => {
const notificationChannel = action.payload;
const selectedType: NotifierDTO = state.notifiers.find(t => t.type === notificationChannel.type)!;
const secureChannelOptions = selectedType.options.filter((o: NotificationChannelOption) => o.secure);
/*
If any secure field is in plain text we need to migrate it to use secure field instead.
*/
if (
secureChannelOptions.length > 0 &&
secureChannelOptions.some((o: NotificationChannelOption) => {
return notificationChannel.settings[o.propertyName] !== '';
})
) {
return migrateSecureFields(state, action.payload, secureChannelOptions);
}
return { ...state, notificationChannel: notificationChannel };
},
resetSecureField: (state, action: PayloadAction<string>): NotificationChannelState => {
return {
...state,
notificationChannel: {
...state.notificationChannel,
secureFields: { ...state.notificationChannel.secureFields, [action.payload]: false },
},
};
},
},
});
export const { loadAlertRules, loadedAlertRules, setSearchQuery, setNotificationChannels } = alertRulesSlice.actions;
export const { loadAlertRules, loadedAlertRules, setSearchQuery } = alertRulesSlice.actions;
export const {
setNotificationChannels,
notificationChannelLoaded,
resetSecureField,
} = notificationChannelSlice.actions;
export const alertRulesReducer = alertRulesSlice.reducer;
export const notificationChannelReducer = notificationChannelSlice.reducer;
export default {
alertRules: alertRulesReducer,
notificationChannel: notificationChannelReducer,
};
function migrateSecureFields(
state: NotificationChannelState,
notificationChannel: any,
secureChannelOptions: NotificationChannelOption[]
) {
const cleanedSettings: { [key: string]: string } = {};
const secureSettings: { [key: string]: string } = {};
secureChannelOptions.forEach(option => {
secureSettings[option.propertyName] = notificationChannel.settings[option.propertyName];
cleanedSettings[option.propertyName] = '';
});
return {
...state,
notificationChannel: {
...notificationChannel,
settings: { ...notificationChannel.settings, ...cleanedSettings },
secureSettings: { ...secureSettings },
},
};
}
function transformNotifiers(notifiers: NotifierDTO[]) {
return notifiers
.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;
});
}

View File

@@ -1,4 +1,4 @@
import { AlertRulesState } from 'app/types';
import { AlertRulesState, NotificationChannelState } from 'app/types';
export const getSearchQuery = (state: AlertRulesState) => state.searchQuery;
@@ -9,3 +9,11 @@ export const getAlertRuleItems = (state: AlertRulesState) => {
return regex.test(item.name) || regex.test(item.stateText) || regex.test(item.info!);
});
};
export const getNotificationChannel = (state: NotificationChannelState, channelId: number) => {
if (state.notificationChannel.id === channelId) {
return state.notificationChannel;
}
return null;
};

View File

@@ -0,0 +1,209 @@
import { transformSubmitData } from './notificationChannels';
import { NotificationChannelDTO } from '../../../types';
const basicFormData: NotificationChannelDTO = {
id: 1,
uid: 'pX7fbbHGk',
name: 'Pete discord',
type: {
value: 'discord',
label: 'Discord',
type: 'discord',
name: 'Discord',
heading: 'Discord settings',
description: 'Sends notifications to Discord',
info: '',
options: [
{
element: 'input',
inputType: 'text',
label: 'Message Content',
description: 'Mention a group using @ or a user using <@ID> when notifying in a channel',
placeholder: '',
propertyName: 'content',
selectOptions: null,
showWhen: { field: '', is: '' },
required: false,
validationRule: '',
secure: false,
},
{
element: 'input',
inputType: 'text',
label: 'Webhook URL',
description: '',
placeholder: 'Discord webhook URL',
propertyName: 'url',
selectOptions: null,
showWhen: { field: '', is: '' },
required: true,
validationRule: '',
secure: false,
},
],
typeName: 'discord',
},
isDefault: false,
sendReminder: false,
disableResolveMessage: false,
frequency: '',
created: '2020-08-24T10:46:43+02:00',
updated: '2020-09-02T14:08:27+02:00',
settings: {
url: 'https://discordapp.com/api/webhooks/',
uploadImage: true,
content: '',
autoResolve: true,
httpMethod: 'POST',
severity: 'critical',
},
secureFields: {},
secureSettings: {},
};
const selectFormData: NotificationChannelDTO = {
id: 23,
uid: 'BxEN9rNGk',
name: 'Webhook',
type: {
value: 'webhook',
label: 'webhook',
type: 'webhook',
name: 'webhook',
heading: 'Webhook settings',
description: 'Sends HTTP POST request to a URL',
info: '',
options: [
{
element: 'input',
inputType: 'text',
label: 'Url',
description: '',
placeholder: '',
propertyName: 'url',
selectOptions: null,
showWhen: { field: '', is: '' },
required: true,
validationRule: '',
secure: false,
},
{
element: 'select',
inputType: '',
label: 'Http Method',
description: '',
placeholder: '',
propertyName: 'httpMethod',
selectOptions: [
{ value: 'POST', label: 'POST' },
{ value: 'PUT', label: 'PUT' },
],
showWhen: { field: '', is: '' },
required: false,
validationRule: '',
secure: false,
},
{
element: 'input',
inputType: 'text',
label: 'Username',
description: '',
placeholder: '',
propertyName: 'username',
selectOptions: null,
showWhen: { field: '', is: '' },
required: false,
validationRule: '',
secure: false,
},
{
element: 'input',
inputType: 'password',
label: 'Password',
description: '',
placeholder: '',
propertyName: 'password',
selectOptions: null,
showWhen: { field: '', is: '' },
required: false,
validationRule: '',
secure: true,
},
],
typeName: 'webhook',
},
isDefault: false,
sendReminder: false,
disableResolveMessage: false,
frequency: '',
created: '2020-08-28T10:47:37+02:00',
updated: '2020-09-03T09:37:21+02:00',
settings: {
autoResolve: true,
httpMethod: 'POST',
password: '',
severity: 'critical',
uploadImage: true,
url: 'http://asdf',
username: 'asdf',
},
secureFields: { password: true },
secureSettings: {},
};
describe('Transform submit data', () => {
it('basic transform', () => {
const expected = {
id: 1,
name: 'Pete discord',
type: 'discord',
sendReminder: false,
disableResolveMessage: false,
frequency: '15m',
settings: {
uploadImage: true,
autoResolve: true,
httpMethod: 'POST',
severity: 'critical',
url: 'https://discordapp.com/api/webhooks/',
content: '',
},
secureSettings: {},
secureFields: {},
isDefault: false,
uid: 'pX7fbbHGk',
created: '2020-08-24T10:46:43+02:00',
updated: '2020-09-02T14:08:27+02:00',
};
expect(transformSubmitData(basicFormData)).toEqual(expected);
});
it('should transform form data with selects', () => {
const expected = {
created: '2020-08-28T10:47:37+02:00',
disableResolveMessage: false,
frequency: '15m',
id: 23,
isDefault: false,
name: 'Webhook',
secureFields: { password: true },
secureSettings: {},
sendReminder: false,
settings: {
autoResolve: true,
httpMethod: 'POST',
password: '',
severity: 'critical',
uploadImage: true,
url: 'http://asdf',
username: 'asdf',
},
type: 'webhook',
uid: 'BxEN9rNGk',
updated: '2020-09-03T09:37:21+02:00',
};
expect(transformSubmitData(selectFormData)).toEqual(expected);
});
});

View File

@@ -0,0 +1,63 @@
import memoizeOne from 'memoize-one';
import { SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime';
import { NotificationChannelDTO, NotificationChannelType } from 'app/types';
export const defaultValues: NotificationChannelDTO = {
id: -1,
name: '',
type: { value: 'email', label: 'Email' },
sendReminder: false,
disableResolveMessage: false,
frequency: '15m',
settings: {
uploadImage: config.rendererAvailable,
autoResolve: true,
httpMethod: 'POST',
severity: 'critical',
},
secureSettings: {},
secureFields: {},
isDefault: false,
};
export const mapChannelsToSelectableValue = memoizeOne(
(notificationChannels: NotificationChannelType[]): Array<SelectableValue<string>> => {
return notificationChannels.map(channel => ({
value: channel.value,
label: channel.label,
description: channel.description,
}));
}
);
export const transformSubmitData = (formData: 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(formData.settings).map(([key, value]) => {
return [key, value && value.hasOwnProperty('value') ? value.value : value];
})
);
return {
...defaultValues,
...formData,
frequency: formData.frequency === '' ? defaultValues.frequency : formData.frequency,
type: formData.type.value,
settings: { ...defaultValues.settings, ...settings },
secureSettings: { ...formData.secureSettings },
};
};
export const transformTestData = (formData: NotificationChannelDTO) => {
return {
name: formData.name,
type: formData.type.value,
frequency: formData.frequency ?? defaultValues.frequency,
settings: { ...Object.assign(defaultValues.settings, formData.settings) },
secureSettings: { ...formData.secureSettings },
};
};

View File

@@ -5,7 +5,6 @@ import './dashboard';
import './playlist/all';
import './panel/all';
import './admin';
import './alerting/NotificationsEditCtrl';
import './manage-dashboards';
import './profile/all';
import './datasources/settings/HttpSettingsCtrl';

View File

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

View File

@@ -58,34 +58,40 @@ export type NotifierType =
export interface NotifierDTO {
name: string;
description: string;
optionsTemplate: string;
type: NotifierType;
heading: string;
options: Option[];
options: NotificationChannelOption[];
info?: string;
secure?: boolean;
}
export interface NotificationChannel {
export interface NotificationChannelType {
value: string;
label: string;
description: string;
type: NotifierType;
heading: string;
options: Option[];
options: NotificationChannelOption[];
info?: string;
}
export interface NotificationChannelDTO {
[key: string]: string | boolean | SelectableValue<string>;
[key: string]: string | boolean | number | SelectableValue<string>;
id: number;
name: string;
type: SelectableValue<string>;
sendReminder: boolean;
disableResolveMessage: boolean;
frequency: string;
settings: ChannelTypeSettings;
secureSettings: NotificationChannelSecureSettings;
secureFields: NotificationChannelSecureFields;
isDefault: boolean;
}
export type NotificationChannelSecureSettings = Record<string, string | number>;
export type NotificationChannelSecureFields = Record<string, boolean>;
export interface ChannelTypeSettings {
[key: string]: any;
autoResolve: true;
@@ -94,24 +100,30 @@ export interface ChannelTypeSettings {
uploadImage: boolean;
}
export interface Option {
element: 'input' | 'select' | 'switch' | 'textarea';
export interface NotificationChannelOption {
element: 'input' | 'select' | 'checkbox' | 'textarea';
inputType: string;
label: string;
description: string;
placeholder: string;
propertyName: string;
selectOptions: Array<SelectableValue<string>>;
showWhen: { field: string; is: string };
required: boolean;
secure: boolean;
selectOptions?: Array<SelectableValue<string>>;
showWhen: { field: string; is: string };
validationRule: string;
}
export interface NotificationChannelState {
notificationChannelTypes: NotificationChannelType[];
notifiers: NotifierDTO[];
notificationChannel: any;
}
export interface AlertRulesState {
items: AlertRule[];
searchQuery: string;
isLoading: boolean;
notificationChannels: NotificationChannel[];
}
export interface AlertNotification {

View File

@@ -3,7 +3,7 @@ import { PayloadAction } from '@reduxjs/toolkit';
import { NavIndex } from '@grafana/data';
import { LocationState } from './location';
import { AlertRulesState } from './alerting';
import { AlertRulesState, NotificationChannelState } from './alerting';
import { TeamsState, TeamState } from './teams';
import { FolderState } from './folders';
import { DashboardState } from './dashboard';
@@ -44,6 +44,7 @@ export interface StoreState {
userListAdmin: UserListAdminState;
templating: TemplatingState;
importDashboard: ImportDashboardState;
notificationChannel: NotificationChannelState;
}
/*