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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1186 additions and 23 deletions

View File

@ -23,7 +23,7 @@ export function Form<T>({
validateOn = 'onSubmit',
maxWidth = 400,
}: FormProps<T>) {
const { handleSubmit, register, errors, control, triggerValidation, getValues, formState } = useForm<T>({
const { handleSubmit, register, errors, control, triggerValidation, getValues, formState, watch } = useForm<T>({
mode: validateOn,
defaultValues,
});
@ -42,7 +42,7 @@ export function Form<T>({
`}
onSubmit={handleSubmit(onSubmit)}
>
{children({ register, errors, control, getValues, formState })}
{children({ register, errors, control, getValues, formState, watch })}
</form>
);
}

View File

@ -1,4 +1,7 @@
import { FormContextValues } from 'react-hook-form';
export { OnSubmit as FormsOnSubmit, FieldErrors as FormFieldErrors } from 'react-hook-form';
export type FormAPI<T> = Pick<FormContextValues<T>, 'register' | 'errors' | 'control' | 'formState' | 'getValues'>;
export type FormAPI<T> = Pick<
FormContextValues<T>,
'register' | 'errors' | 'control' | 'formState' | 'getValues' | 'watch'
>;

View File

@ -22,11 +22,64 @@ var newImageUploaderProvider = func() (imguploader.ImageUploader, error) {
// NotifierPlugin holds meta information about a notifier.
type NotifierPlugin struct {
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description"`
OptionsTemplate string `json:"optionsTemplate"`
Factory NotifierFactory `json:"-"`
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"`
}
// NotifierOption holds information about options specific for the NotifierPlugin.
type NotifierOption struct {
Element ElementType `json:"element"`
InputType InputType `json:"inputType"`
Label string `json:"label"`
Description string `json:"description"`
Placeholder string `json:"placeholder"`
PropertyName string `json:"propertyName"`
SelectOptions []SelectOption `json:"selectOptions"`
ShowWhen ShowWhen `json:"showWhen"`
Required bool `json:"required"`
ValidationRule string `json:"validationRule"`
}
// InputType is the type of input that can be rendered in the frontend.
type InputType string
const (
// InputTypeText will render a text field in the frontend
InputTypeText = "text"
// InputTypePassword will render a text field in the frontend
InputTypePassword = "password"
)
// ElementType is the type of element that can be rendered in the frontend.
type ElementType string
const (
// ElementTypeInput will render an input
ElementTypeInput = "input"
// ElementTypeSelect will render a select
ElementTypeSelect = "select"
// ElementTypeSwitch will render a switch
ElementTypeSwitch = "switch"
// ElementTypeTextArea will render a textarea
ElementTypeTextArea = "textarea"
)
// SelectOption is a simple type for Options that have dropdown options. Should be used when Element is ElementTypeSelect.
type SelectOption struct {
Value string `json:"value"`
Label string `json:"label"`
}
// ShowWhen holds information about when options are dependant on other options.
type ShowWhen struct {
Field string `json:"field"`
Is string `json:"is"`
}
func newNotificationService(renderService rendering.Service) *notificationService {

View File

@ -18,6 +18,7 @@ func init() {
Type: "prometheus-alertmanager",
Name: "Prometheus Alertmanager",
Description: "Sends alert to Prometheus Alertmanager",
Heading: "Alertmanager settings",
Factory: NewAlertmanagerNotifier,
OptionsTemplate: `
<h3 class="page-heading">Alertmanager settings</h3>
@ -38,6 +39,29 @@ func init() {
</div>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "Url",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Description: "As specified in Alertmanager documentation, do not specify a load balancer here. Enter all your Alertmanager URLs comma-separated.",
Placeholder: "http://localhost:9093",
PropertyName: "url",
Required: true,
},
{
Label: "Basic Auth User",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
PropertyName: "basicAuthUser",
},
{
Label: "Basic Auth Password",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypePassword,
PropertyName: "basicAuthPassword",
},
},
})
}

View File

@ -29,8 +29,33 @@ func init() {
Type: "dingding",
Name: "DingDing",
Description: "Sends HTTP POST request to DingDing",
Heading: "DingDing settings",
Factory: newDingDingNotifier,
OptionsTemplate: dingdingOptionsTemplate,
Options: []alerting.NotifierOption{
{
Label: "Url",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "https://oapi.dingtalk.com/robot/send?access_token=xxxxxxxxx",
PropertyName: "url",
Required: true,
},
{
Label: "Message Type",
Element: alerting.ElementTypeSelect,
PropertyName: "msgType",
SelectOptions: []alerting.SelectOption{
{
Value: "link",
Label: "Link"},
{
Value: "actionCard",
Label: "ActionCard",
},
},
},
},
})
}

View File

@ -22,6 +22,7 @@ func init() {
Name: "Discord",
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">
@ -40,6 +41,23 @@ func init() {
<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",
Description: "Mention a group using @ or a user using <@ID> when notifying in a channel",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
PropertyName: "content",
},
{
Label: "Webhook URL",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "Discord webhook URL",
PropertyName: "url",
Required: true,
},
},
})
}

View File

@ -18,6 +18,7 @@ func init() {
Name: "Email",
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">
@ -39,6 +40,21 @@ func init() {
<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,
PropertyName: "singleEmail",
},
{
Label: "Addresses",
Description: "You can enter multiple email addresses using a \";\" separator",
Element: alerting.ElementTypeTextArea,
PropertyName: "addresses",
Required: true,
},
},
})
}

View File

@ -14,11 +14,11 @@ import (
func init() {
alerting.RegisterNotifier(&alerting.NotifierPlugin{
Type: "googlechat",
Name: "Google Hangouts Chat",
Description: "Sends notifications to Google Hangouts Chat via webhooks based on the official JSON message " +
"format (https://developers.google.com/hangouts/chat/reference/message-formats/).",
Factory: newGoogleChatNotifier,
Type: "googlechat",
Name: "Google Hangouts Chat",
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">
@ -26,6 +26,16 @@ func init() {
<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",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "Google Hangouts Chat incoming webhook url",
PropertyName: "url",
Required: true,
},
},
})
}

View File

@ -18,6 +18,7 @@ func init() {
Type: "hipchat",
Name: "HipChat",
Description: "Sends notifications uto a HipChat Room",
Heading: "HipChat settings",
Factory: NewHipChatNotifier,
OptionsTemplate: `
<h3 class="page-heading">HipChat settings</h3>
@ -38,6 +39,30 @@ func init() {
</input>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "Hip Chat Url",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "HipChat URL (ex https://grafana.hipchat.com)",
PropertyName: "url",
Required: true,
},
{
Label: "API Key",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "HipChat API Key",
PropertyName: "apiKey",
Required: true,
},
{
Label: "Room ID",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
PropertyName: "roomid",
},
},
})
}

View File

@ -17,6 +17,7 @@ func init() {
Type: "kafka",
Name: "Kafka REST Proxy",
Description: "Sends notifications to Kafka Rest Proxy",
Heading: "Kafka settings",
Factory: NewKafkaNotifier,
OptionsTemplate: `
<h3 class="page-heading">Kafka settings</h3>
@ -29,6 +30,24 @@ func init() {
<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",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "http://localhost:8082",
PropertyName: "kafkaRestProxy",
Required: true,
},
{
Label: "Topic",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "topic1",
PropertyName: "kafkaTopic",
Required: true,
},
},
})
}

View File

@ -15,6 +15,7 @@ func init() {
Type: "LINE",
Name: "LINE",
Description: "Send notifications to LINE notify",
Heading: "LINE notify settings",
Factory: NewLINENotifier,
OptionsTemplate: `
<div class="gf-form-group">
@ -25,6 +26,15 @@ func init() {
</div>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "Token",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "LINE notify token key",
PropertyName: "token",
Required: true,
}},
})
}

View File

@ -16,6 +16,7 @@ func init() {
Type: "opsgenie",
Name: "OpsGenie",
Description: "Sends notifications to OpsGenie",
Heading: "OpsGenie settings",
Factory: NewOpsGenieNotifier,
OptionsTemplate: `
<h3 class="page-heading">OpsGenie settings</h3>
@ -46,6 +47,35 @@ func init() {
</gf-form-switch>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "API Key",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "OpsGenie API Key",
PropertyName: "apiKey",
Required: true,
},
{
Label: "Alert API Url",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "https://api.opsgenie.com/v2/alerts",
PropertyName: "apiUrl",
Required: true,
},
{
Label: "Auto close incidents",
Element: alerting.ElementTypeSwitch,
Description: "Automatically close alerts in OpsGenie once the alert goes back to ok.",
PropertyName: "autoClose",
}, {
Label: "Override priority",
Element: alerting.ElementTypeSwitch,
Description: "Allow the alert priority to be set using the og_priority tag",
PropertyName: "overridePriority",
},
},
})
}

View File

@ -18,6 +18,7 @@ func init() {
Type: "pagerduty",
Name: "PagerDuty",
Description: "Sends notifications to PagerDuty",
Heading: "PagerDuty settings",
Factory: NewPagerdutyNotifier,
OptionsTemplate: `
<h3 class="page-heading">PagerDuty settings</h3>
@ -54,6 +55,45 @@ func init() {
</gf-form-switch>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "Integration Key",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "Pagerduty Integration Key",
PropertyName: "integrationKey",
Required: true,
},
{
Label: "Severity",
Element: alerting.ElementTypeSelect,
SelectOptions: []alerting.SelectOption{
{
Value: "critical",
Label: "Critical",
},
{
Value: "error",
Label: "Error",
},
{
Value: "warning",
Label: "Warning",
},
{
Value: "info",
Label: "Info",
},
},
PropertyName: "severity",
},
{
Label: "Auto resolve incidents",
Element: alerting.ElementTypeSwitch,
Description: "Resolve incidents in pagerduty once the alert goes back to ok.",
PropertyName: "autoResolve",
},
},
})
}

View File

@ -17,7 +17,7 @@ import (
const pushoverEndpoint = "https://api.pushover.net/1/messages.json"
func init() {
sounds := `
sounds := `
'default',
'pushover',
'bike',
@ -42,10 +42,85 @@ func init() {
'updown',
'none'`
soundOptions := []alerting.SelectOption{
{
Value: "default",
Label: "Default",
},
{
Value: "pushover",
Label: "Pushover",
}, {
Value: "bike",
Label: "Bike",
}, {
Value: "bugle",
Label: "Bugle",
}, {
Value: "cashregister",
Label: "Cashregister",
}, {
Value: "classical",
Label: "Classical",
}, {
Value: "cosmic",
Label: "Cosmic",
}, {
Value: "falling",
Label: "Falling",
}, {
Value: "gamelan",
Label: "Gamelan",
}, {
Value: "incoming",
Label: "Incoming",
}, {
Value: "intermission",
Label: "Intermission",
}, {
Value: "magic",
Label: "Magic",
}, {
Value: "mechanical",
Label: "Mechanical",
}, {
Value: "pianobar",
Label: "Pianobar",
}, {
Value: "siren",
Label: "Siren",
}, {
Value: "spacealarm",
Label: "Spacealarm",
}, {
Value: "tugboat",
Label: "Tugboat",
}, {
Value: "alien",
Label: "Alien",
}, {
Value: "climb",
Label: "Climb",
}, {
Value: "persistent",
Label: "Persistent",
}, {
Value: "echo",
Label: "Echo",
}, {
Value: "updown",
Label: "Updown",
}, {
Value: "none",
Label: "None",
},
}
alerting.RegisterNotifier(&alerting.NotifierPlugin{
Type: "pushover",
Name: "Pushover",
Description: "Sends HTTP POST request to the Pushover API",
Heading: "Pushover settings",
Factory: NewPushoverNotifier,
OptionsTemplate: `
<h3 class="page-heading">Pushover settings</h3>
@ -77,7 +152,7 @@ func init() {
</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>
<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>
@ -92,6 +167,92 @@ func init() {
]" ng-init="ctrl.model.settings.okSound=ctrl.model.settings.okSound||'default'"></select>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "API Token",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "Application token",
PropertyName: "apiToken",
Required: true,
},
{
Label: "User key(s)",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "comma-separated list",
PropertyName: "userKey",
Required: true,
},
{
Label: "Device(s) (optional)",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "comma-separated list; leave empty to send to all devices",
PropertyName: "device",
},
{
Label: "Priority",
Element: alerting.ElementTypeSelect,
SelectOptions: []alerting.SelectOption{
{
Value: "2",
Label: "Emergency",
},
{
Value: "1",
Label: "High",
},
{
Value: "0",
Label: "Normal",
},
{
Value: "-1",
Label: "Low",
},
{
Value: "-2",
Label: "Lowest",
},
},
PropertyName: "priority",
},
{
Label: "Retry",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "minimum 30 seconds",
PropertyName: "retry",
ShowWhen: alerting.ShowWhen{
Field: "priority",
Is: "2",
},
},
{
Label: "Expire",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "maximum 86400 seconds",
PropertyName: "expire",
ShowWhen: alerting.ShowWhen{
Field: "priority",
Is: "2",
},
},
{
Label: "Alerting sound",
Element: alerting.ElementTypeSelect,
SelectOptions: soundOptions,
PropertyName: "sound",
},
{
Label: "OK sound",
Element: alerting.ElementTypeSelect,
SelectOptions: soundOptions,
PropertyName: "okSound",
},
},
})
}

View File

@ -16,6 +16,7 @@ func init() {
Type: "sensu",
Name: "Sensu",
Description: "Sends HTTP POST request to a Sensu API",
Heading: "Sensu settings",
Factory: NewSensuNotifier,
OptionsTemplate: `
<h3 class="page-heading">Sensu settings</h3>
@ -40,6 +41,42 @@ func init() {
<input type="text" class="gf-form-input max-width-14" ng-model="ctrl.model.settings.password"></input>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "Url",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "http://sensu-api.local:4567/results",
PropertyName: "url",
Required: true,
},
{
Label: "Source",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Description: "If empty rule id will be used",
PropertyName: "source",
},
{
Label: "Handler",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "default",
PropertyName: "handler",
},
{
Label: "Username",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
PropertyName: "username",
},
{
Label: "Password",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypePassword,
PropertyName: "passsword ",
},
},
})
}

View File

@ -25,6 +25,7 @@ func init() {
Type: "slack",
Name: "Slack",
Description: "Sends notifications to Slack via Slack Webhooks",
Heading: "Slack settings",
Factory: NewSlackNotifier,
OptionsTemplate: `
<h3 class="page-heading">Slack settings</h3>
@ -124,6 +125,85 @@ func init() {
</info-popover>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "Url",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "Slack incoming webhook url",
PropertyName: "url",
Required: true,
},
{
Label: "Recipient",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Description: "Override default channel or user, use #channel-name, @username (has to be all lowercase, no whitespace), or user/channel Slack ID",
PropertyName: "recipient",
},
{
Label: "Username",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Description: "Set the username for the bot's message",
PropertyName: "username",
},
{
Label: "Icon emoji",
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",
},
{
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",
},
{
Label: "Mention Users",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Description: "Mention one or more users (comma separated) when notifying in a channel, by ID (you can copy this from the user's Slack profile)",
PropertyName: "mentionUsers",
},
{
Label: "Mention Groups",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Description: "Mention one or more groups (comma separated) when notifying in a channel (you can copy this from the group's Slack profile URL)",
PropertyName: "mentionGroups",
},
{
Label: "Mention Channel",
Element: alerting.ElementTypeSelect,
SelectOptions: []alerting.SelectOption{
{
Value: "",
Label: "Disabled",
},
{
Value: "here",
Label: "Every active channel member",
},
{
Value: "channel",
Label: "Every channel member",
},
},
Description: "Mention whole channel or just active members when notifying",
PropertyName: "mentionChannel",
},
{
Label: "Token",
Element: alerting.ElementTypeInput,
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",
},
},
})
}

View File

@ -14,14 +14,24 @@ func init() {
Type: "teams",
Name: "Microsoft Teams",
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" required class="gf-form-input max-width-30" ng-model="ctrl.model.settings.url" placeholder="Teams incoming webhook url"></input>
<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",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "Teams incoming webhook url",
PropertyName: "url",
},
},
})
}

View File

@ -26,6 +26,7 @@ func init() {
Type: "telegram",
Name: "Telegram",
Description: "Sends notifications to Telegram",
Heading: "Telegram API settings",
Factory: NewTelegramNotifier,
OptionsTemplate: `
<h3 class="page-heading">Telegram API settings</h3>
@ -48,6 +49,24 @@ func init() {
</info-popover>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "BOT API Token",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "Telegram BOT API Token",
PropertyName: "bottoken",
Required: true,
},
{
Label: "Chat ID",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Description: "Integer Telegram Chat Identifier",
PropertyName: "chatid",
Required: true,
},
},
})
}

View File

@ -20,7 +20,10 @@ func init() {
Type: "threema",
Name: "Threema Gateway",
Description: "Sends notifications to Threema using the Threema Gateway",
Factory: NewThreemaNotifier,
Heading: "Threema Gateway settings",
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>
@ -64,6 +67,36 @@ func init() {
</info-popover>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "Gateway ID",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "*3MAGWID",
Description: "Your 8 character Threema Gateway ID (starting with a *).",
PropertyName: "gateway_id",
Required: true,
ValidationRule: "\\*[0-9A-Z]{7}",
},
{
Label: "Recipient ID",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "YOUR3MID",
Description: "The 8 character Threema ID that should receive the alerts.",
PropertyName: "recipient_id",
Required: true,
ValidationRule: "[0-9A-Z]{8}",
},
{
Label: "API Secret",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Description: "Your Threema Gateway API secret.",
PropertyName: "api_secret",
Required: true,
},
},
})
}

View File

@ -23,6 +23,7 @@ func init() {
Type: "victorops",
Name: "VictorOps",
Description: "Sends notifications to VictorOps",
Heading: "VictorOps settings",
Factory: NewVictoropsNotifier,
OptionsTemplate: `
<h3 class="page-heading">VictorOps settings</h3>
@ -47,6 +48,22 @@ func init() {
</gf-form-switch>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "Url",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "VictorOps url",
PropertyName: "url",
Required: true,
},
{
Label: "Auto resolve incidents",
Description: "Resolve incidents in VictorOps once the alert goes back to ok.",
Element: alerting.ElementTypeSwitch,
PropertyName: "autoResolve",
},
},
})
}

View File

@ -13,6 +13,7 @@ func init() {
Type: "webhook",
Name: "webhook",
Description: "Sends HTTP POST request to a URL",
Heading: "Webhook settings",
Factory: NewWebHookNotifier,
OptionsTemplate: `
<h3 class="page-heading">Webhook settings</h3>
@ -36,6 +37,42 @@ func init() {
<input type="text" class="gf-form-input max-width-14" ng-model="ctrl.model.settings.password"></input>
</div>
`,
Options: []alerting.NotifierOption{
{
Label: "Url",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
PropertyName: "url",
Required: true,
},
{
Label: "Http Method",
Element: alerting.ElementTypeSelect,
SelectOptions: []alerting.SelectOption{
{
Value: "POST",
Label: "POST",
},
{
Value: "PUT",
Label: "PUT",
},
},
PropertyName: "httpMethod",
},
{
Label: "Username",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
PropertyName: "username",
},
{
Label: "Password",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypePassword,
PropertyName: "password",
},
},
})
}

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 {