From 54c33c6cdd969ac19c55ae01d4bc585f46fc613b Mon Sep 17 00:00:00 2001 From: Domas Date: Wed, 19 May 2021 19:58:31 +0300 Subject: [PATCH] Alerting: update email template (#34205) --- emails/templates/ng_alert_notification.html | 324 +++++++++++------- .../ngalert/notifier/channels/email.go | 22 +- .../ngalert/notifier/channels/email_test.go | 25 +- .../notifier/channels/template_data.go | 153 +++++++++ .../components/silences/SilencesEditor.tsx | 31 +- .../alerting/unified/utils/matchers.ts | 10 + public/emails/ng_alert_notification.html | 250 ++++++-------- 7 files changed, 528 insertions(+), 287 deletions(-) create mode 100644 pkg/services/ngalert/notifier/channels/template_data.go create mode 100644 public/app/features/alerting/unified/utils/matchers.ts diff --git a/emails/templates/ng_alert_notification.html b/emails/templates/ng_alert_notification.html index 7eb44968824..d68c13e9b02 100644 --- a/emails/templates/ng_alert_notification.html +++ b/emails/templates/ng_alert_notification.html @@ -1,150 +1,218 @@ [[Subject .Subject "[[.Title]]"]] +[[ define "alert" ]] + [[ if gt (len .Annotations.SortedPairs) 0 ]] + + + [[ range .Annotations.SortedPairs ]] +

[[ .Name ]]: [[ .Value ]]

+ [[ end ]] + + + [[ end ]] + + Labels: + + + + + + + [[ if .SilenceURL ]] + + + Silence + + [[ end ]] + [[ if .Annotations.runbook_url ]] + + + View Runbook + + [[ end ]] + [[ if .DashboardURL]] + + + Go to Dashboard + + [[ end ]] + [[ if .PanelURL]] + + + Go to Panel + + [[ end ]] + [[ if gt (len .GeneratorURL) 0 ]]Source[[ end ]] + + + + +
+
+
+ + +[[ end ]] + +[[ if gt (len .Message) 0 ]] + [[ .Message ]] +[[ else ]] + - - +
- + - -
+ + [[ if gt (len .Alerts.Firing) 0 ]] - -
- [[ if gt (len .Alerts.Firing) 0 ]] -

[[.Title]]

- [[ else ]] -

[[.Title]]

- [[ end ]] +
+ Firing: [[ .Alerts.Firing | len ]] alert[[ if gt (len .Alerts.Firing) 1 ]]s[[ end ]][[ if gt (len .GroupLabels.SortedPairs) 1 ]] for + [[ range .GroupLabels.SortedPairs ]] + [[ .Name ]]=[[ .Value ]] + [[ end ]][[ end ]]
-
- - - - [[ if gt (len .Alerts.Firing) 0 ]] - - [[ else ]] - - [[ end ]] - - - -
- [[ .Alerts | len ]] alert[[ if gt (len .Alerts) 1 ]]s[[ end ]] for - [[ range .GroupLabels.SortedPairs ]] - [[ .Name ]]=[[ .Value ]] - [[ end ]] - - [[ .Alerts | len ]] alert[[ if gt (len .Alerts) 1 ]]s[[ end ]] for - [[ range .GroupLabels.SortedPairs ]] - [[ .Name ]]=[[ .Value ]] - [[ end ]] -
- - [[ if gt (len .Alerts.Firing) 0 ]] - - - - [[ end ]] [[ range .Alerts.Firing ]] - - - - - - - - + + + + + [[ template "alert" . ]] [[ end ]] - - [[ if gt (len .Alerts.Resolved) 0 ]] - [[ if gt (len .Alerts.Firing) 0 ]] + [[ end ]] + [[ if gt (len .Alerts.Resolved) 0 ]] - - [[ end ]] - - - - [[ end ]] [[ range .Alerts.Resolved ]] - - - - - - - - + + + + + [[ template "alert" . ]] [[ end ]] + [[ end ]] + + + +
-
([[ .Alerts.Firing | len ]]) Firing
-
-
Labels
-
- [[ if gt (len .Annotations) 0 ]]
Annotations
[[ end ]] -
- [[ range .Labels.SortedPairs ]][[ .Name ]] = [[ .Value ]]
[[ end ]] - Source
-
- [[ range .Annotations.SortedPairs ]][[ .Name ]] = [[ .Value ]]
[[ end ]] -
+ Firing + + [[ .Labels.alertname ]] +
-
-
-
+
+ Resolved: [[ .Alerts.Resolved | len ]] alert[[ if gt (len .Alerts.Resolved) 1 ]]s[[ end ]][[ if gt (len .GroupLabels.SortedPairs) 1 ]] for + [[ range .GroupLabels.SortedPairs ]] + [[ .Name ]]=[[ .Value ]] + [[ end ]][[ end ]]
-
([[ .Alerts.Resolved | len ]]) Resolved
-
-
Labels
-
- [[ if gt (len .Annotations) 0 ]]
Annotations
[[ end ]] -
- [[ range .Labels.SortedPairs ]][[ .Name ]] = [[ .Value ]]
[[ end ]] - Source
-
- [[ range .Annotations.SortedPairs ]][[ .Name ]] = [[ .Value ]]
[[ end ]] -
+ Resolved + + [[ .Labels.alertname ]] +
+ Go to alerts page +
- - - - - - - -
- - - - - -
- - - - -
- View your Alert rule -
-
- - - - -
- Go to the Alerts page -
-
-
- - +[[ end ]] \ No newline at end of file diff --git a/pkg/services/ngalert/notifier/channels/email.go b/pkg/services/ngalert/notifier/channels/email.go index 344c6da405b..24ec0de23ac 100644 --- a/pkg/services/ngalert/notifier/channels/email.go +++ b/pkg/services/ngalert/notifier/channels/email.go @@ -3,6 +3,7 @@ package channels import ( "context" "fmt" + "net/url" "path" gokit_log "github.com/go-kit/kit/log" @@ -66,12 +67,25 @@ func NewEmailNotifier(model *NotificationChannelConfig, t *template.Template) (* // Notify sends the alert notification. func (en *EmailNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { // We only need ExternalURL from this template object. This hack should go away with https://github.com/prometheus/alertmanager/pull/2508. - data := notify.GetTemplateData(ctx, &template.Template{ExternalURL: en.tmpl.ExternalURL}, as, gokit_log.NewLogfmtLogger(logging.NewWrapper(en.log))) + data, err := ExtendData(notify.GetTemplateData(ctx, &template.Template{ExternalURL: en.tmpl.ExternalURL}, as, gokit_log.NewLogfmtLogger(logging.NewWrapper(en.log)))) + if err != nil { + return false, err + } var tmplErr error - tmpl := notify.TmplText(en.tmpl, data, &tmplErr) + tmpl := TmplText(en.tmpl, data, &tmplErr) title := tmpl(`{{ template "default.title" . }}`) + u, err := url.Parse(en.tmpl.ExternalURL.String()) + if err != nil { + return false, fmt.Errorf("failed to parse external URL: %w", err) + } + basePath := u.Path + u.Path = path.Join(basePath, "/alerting/list") + ruleURL := u.String() + u.RawQuery = "alertState=firing&view=state" + alertPageURL := u.String() + cmd := &models.SendEmailCommandSync{ SendEmailCommand: models.SendEmailCommand{ Subject: title, @@ -84,8 +98,8 @@ func (en *EmailNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, "CommonLabels": data.CommonLabels, "CommonAnnotations": data.CommonAnnotations, "ExternalURL": data.ExternalURL, - "RuleUrl": path.Join(en.tmpl.ExternalURL.String(), "/alerting/list"), - "AlertPageUrl": path.Join(en.tmpl.ExternalURL.String(), "/alerting/list?alertState=firing&view=state"), + "RuleUrl": ruleURL, + "AlertPageUrl": alertPageURL, }, To: en.Addresses, SingleEmail: en.SingleEmail, diff --git a/pkg/services/ngalert/notifier/channels/email_test.go b/pkg/services/ngalert/notifier/channels/email_test.go index 184af54e18b..907a52283e9 100644 --- a/pkg/services/ngalert/notifier/channels/email_test.go +++ b/pkg/services/ngalert/notifier/channels/email_test.go @@ -18,7 +18,7 @@ import ( func TestEmailNotifier(t *testing.T) { tmpl := templateForTests(t) - externalURL, err := url.Parse("http://localhost") + externalURL, err := url.Parse("http://localhost/base") require.NoError(t, err) tmpl.ExternalURL = externalURL @@ -67,7 +67,7 @@ func TestEmailNotifier(t *testing.T) { { Alert: model.Alert{ Labels: model.LabelSet{"alertname": "AlwaysFiring", "severity": "warning"}, - Annotations: model.LabelSet{"runbook_url": "http://fix.me"}, + Annotations: model.LabelSet{"runbook_url": "http://fix.me", "__dashboardUid__": "abc", "__panelId__": "5"}, }, }, } @@ -85,20 +85,23 @@ func TestEmailNotifier(t *testing.T) { "Title": "[FIRING:1] (AlwaysFiring warning)", "Message": "[FIRING:1] (AlwaysFiring warning)", "Status": "firing", - "Alerts": template.Alerts{ - template.Alert{ - Status: "firing", - Labels: template.KV{"alertname": "AlwaysFiring", "severity": "warning"}, - Annotations: template.KV{"runbook_url": "http://fix.me"}, - Fingerprint: "15a37193dce72bab", + "Alerts": ExtendedAlerts{ + ExtendedAlert{ + Status: "firing", + Labels: template.KV{"alertname": "AlwaysFiring", "severity": "warning"}, + Annotations: template.KV{"runbook_url": "http://fix.me"}, + Fingerprint: "15a37193dce72bab", + SilenceURL: "http://localhost/base/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DAlwaysFiring%2Cseverity%3Dwarning", + DashboardURL: "http://localhost/base/d/abc", + PanelURL: "http://localhost/base/d/abc?viewPanel=5", }, }, "GroupLabels": template.KV{}, "CommonLabels": template.KV{"alertname": "AlwaysFiring", "severity": "warning"}, "CommonAnnotations": template.KV{"runbook_url": "http://fix.me"}, - "ExternalURL": "http://localhost", - "RuleUrl": "http:/localhost/alerting/list", - "AlertPageUrl": "http:/localhost/alerting/list?alertState=firing&view=state", + "ExternalURL": "http://localhost/base", + "RuleUrl": "http://localhost/base/alerting/list", + "AlertPageUrl": "http://localhost/base/alerting/list?alertState=firing&view=state", }, }, expected) }) diff --git a/pkg/services/ngalert/notifier/channels/template_data.go b/pkg/services/ngalert/notifier/channels/template_data.go new file mode 100644 index 00000000000..bb3ace8d64f --- /dev/null +++ b/pkg/services/ngalert/notifier/channels/template_data.go @@ -0,0 +1,153 @@ +package channels + +import ( + "fmt" + "net/url" + "path" + "sort" + "strings" + "time" + + "github.com/prometheus/alertmanager/template" + "github.com/prometheus/common/model" +) + +type ExtendedAlert struct { + Status string `json:"status"` + Labels template.KV `json:"labels"` + Annotations template.KV `json:"annotations"` + StartsAt time.Time `json:"startsAt"` + EndsAt time.Time `json:"endsAt"` + GeneratorURL string `json:"generatorURL"` + Fingerprint string `json:"fingerprint"` + SilenceURL string `json:"silenceURL"` + DashboardURL string `json:"dashboardURL"` + PanelURL string `json:"panelURL"` +} + +type ExtendedAlerts []ExtendedAlert + +type ExtendedData struct { + Receiver string `json:"receiver"` + Status string `json:"status"` + Alerts ExtendedAlerts `json:"alerts"` + + GroupLabels template.KV `json:"groupLabels"` + CommonLabels template.KV `json:"commonLabels"` + CommonAnnotations template.KV `json:"commonAnnotations"` + + ExternalURL string `json:"externalURL"` +} + +func removePrivateItems(kv template.KV) template.KV { + for key := range kv { + if strings.HasPrefix(key, "__") && strings.HasSuffix(key, "__") { + kv = kv.Remove([]string{key}) + } + } + return kv +} + +func extendAlert(alert template.Alert, externalURL string) (*ExtendedAlert, error) { + extended := ExtendedAlert{ + Status: alert.Status, + Labels: alert.Labels, + Annotations: alert.Annotations, + StartsAt: alert.StartsAt, + EndsAt: alert.EndsAt, + GeneratorURL: alert.GeneratorURL, + Fingerprint: alert.Fingerprint, + } + + // fill in some grafana-specific urls + if len(externalURL) > 0 { + u, err := url.Parse(externalURL) + if err != nil { + return nil, fmt.Errorf("failed to parse external URL: %w", err) + } + externalPath := u.Path + dashboardUid := alert.Annotations["__dashboardUid__"] + if len(dashboardUid) > 0 { + u.Path = path.Join(externalPath, "/d/", dashboardUid) + extended.DashboardURL = u.String() + panelId := alert.Annotations["__panelId__"] + if len(panelId) > 0 { + u.RawQuery = "viewPanel=" + panelId + extended.PanelURL = u.String() + } + } + + matchers := make([]string, 0) + for key, value := range alert.Labels { + if !(strings.HasPrefix(key, "__") && strings.HasSuffix(key, "__")) { + matchers = append(matchers, key+"="+value) + } + } + sort.Strings(matchers) + u.Path = path.Join(externalPath, "/alerting/silence/new") + u.RawQuery = "alertmanager=grafana&matchers=" + url.QueryEscape(strings.Join(matchers, ",")) + extended.SilenceURL = u.String() + } + + // remove "private" annotations & labels so they don't show up in the template + extended.Annotations = removePrivateItems(extended.Annotations) + extended.Labels = removePrivateItems(extended.Labels) + + return &extended, nil +} + +func ExtendData(data *template.Data) (*ExtendedData, error) { + alerts := []ExtendedAlert{} + + for _, alert := range data.Alerts { + extendedAlert, err := extendAlert(alert, data.ExternalURL) + if err != nil { + return nil, err + } + alerts = append(alerts, *extendedAlert) + } + + extended := &ExtendedData{ + Receiver: data.Receiver, + Status: data.Status, + Alerts: alerts, + GroupLabels: data.GroupLabels, + CommonLabels: removePrivateItems(data.CommonLabels), + CommonAnnotations: removePrivateItems(data.CommonAnnotations), + + ExternalURL: data.ExternalURL, + } + return extended, nil +} + +func TmplText(tmpl *template.Template, data *ExtendedData, err *error) func(string) string { + return func(name string) (s string) { + if *err != nil { + return + } + s, *err = tmpl.ExecuteTextString(name, data) + return s + } +} + +// Firing returns the subset of alerts that are firing. +func (as ExtendedAlerts) Firing() []ExtendedAlert { + res := []ExtendedAlert{} + for _, a := range as { + if a.Status == string(model.AlertFiring) { + res = append(res, a) + } + } + return res +} + +// Resolved returns the subset of alerts that are resolved. +func (as ExtendedAlerts) Resolved() []ExtendedAlert { + res := []ExtendedAlert{} + for _, a := range as { + if a.Status == string(model.AlertResolved) { + res = append(res, a) + } + } + return res +} diff --git a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx index 4101304ce0d..ea1b1f51a83 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx @@ -1,5 +1,5 @@ import { Silence, SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types'; -import React, { FC, useState } from 'react'; +import React, { FC, useMemo, useState } from 'react'; import { Button, Field, FieldSet, Input, LinkButton, TextArea, useStyles } from '@grafana/ui'; import { DefaultTimeZone, @@ -9,6 +9,7 @@ import { addDurationToDate, dateTime, isValidDate, + UrlQueryMap, } from '@grafana/data'; import { useDebounce } from 'react-use'; import { config } from '@grafana/runtime'; @@ -23,13 +24,34 @@ import { css, cx } from '@emotion/css'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { makeAMLink } from '../../utils/misc'; import { useCleanup } from 'app/core/hooks/useCleanup'; +import { useQueryParams } from 'app/core/hooks/useQueryParams'; +import { parseQueryParamMatchers } from '../../utils/matchers'; interface Props { silence?: Silence; alertManagerSourceName: string; } -const getDefaultFormValues = (silence?: Silence): SilenceFormFields => { +const defaultsFromQuery = (queryParams: UrlQueryMap): Partial => { + const defaults: Partial = {}; + + const { matchers, comment } = queryParams; + + if (typeof matchers === 'string') { + const formMatchers = parseQueryParamMatchers(matchers); + if (formMatchers.length) { + defaults.matchers = formMatchers; + } + } + + if (typeof comment === 'string') { + defaults.comment = comment; + } + + return defaults; +}; + +const getDefaultFormValues = (queryParams: UrlQueryMap, silence?: Silence): SilenceFormFields => { const now = new Date(); if (silence) { const isExpired = Date.parse(silence.endsAt) < Date.now(); @@ -66,12 +88,15 @@ const getDefaultFormValues = (silence?: Silence): SilenceFormFields => { matcherName: '', matcherValue: '', timeZone: DefaultTimeZone, + ...defaultsFromQuery(queryParams), }; } }; export const SilencesEditor: FC = ({ silence, alertManagerSourceName }) => { - const formAPI = useForm({ defaultValues: getDefaultFormValues(silence) }); + const [queryParams] = useQueryParams(); + const defaultValues = useMemo(() => getDefaultFormValues(queryParams, silence), [silence, queryParams]); + const formAPI = useForm({ defaultValues }); const dispatch = useDispatch(); const styles = useStyles(getStyles); diff --git a/public/app/features/alerting/unified/utils/matchers.ts b/public/app/features/alerting/unified/utils/matchers.ts new file mode 100644 index 00000000000..d740569620e --- /dev/null +++ b/public/app/features/alerting/unified/utils/matchers.ts @@ -0,0 +1,10 @@ +import { Matcher } from 'app/plugins/datasource/alertmanager/types'; +import { parseMatcher } from './alertmanager'; + +// parses comma separated matchers like "foo=bar,baz=~bad*" into SilenceMatcher[] +export function parseQueryParamMatchers(paramValue: string): Matcher[] { + return paramValue + .split(',') + .filter((x) => !!x.trim()) + .map((x) => parseMatcher(x.trim())); +} diff --git a/public/emails/ng_alert_notification.html b/public/emails/ng_alert_notification.html index 06082c71b02..1eaae4fef27 100644 --- a/public/emails/ng_alert_notification.html +++ b/public/emails/ng_alert_notification.html @@ -92,21 +92,12 @@ text-decoration: underline; table[class="body"] .columns { table-layout: fixed !important; float: none !important; width: 100% !important; padding-right: 0px !important; padding-left: 0px !important; display: block !important; } - table[class="body"] .column { - table-layout: fixed !important; float: none !important; width: 100% !important; padding-right: 0px !important; padding-left: 0px !important; display: block !important; - } table[class="body"] table.columns td { width: 100% !important; } - table[class="body"] .columns td.four { - width: 33.333333% !important; - } table[class="body"] .columns td.six { width: 50% !important; } - table[class="body"] .columns td.eight { - width: 66.666666% !important; - } table[class="body"] .columns td.twelve { width: 100% !important; } @@ -215,150 +206,127 @@ text-decoration: underline; {{Subject .Subject "{{.Title}}"}} +{{ define "alert" }} + {{ if gt (len .Annotations.SortedPairs) 0 }} + + + {{ range .Annotations.SortedPairs }} +

{{ .Name }}: {{ .Value }}

+ {{ end }} + + + {{ end }} + + Labels: + +
    + {{ range .Labels.SortedPairs }}
  • {{ .Name }}: {{ .Value }}
  • {{ end }} +
+ + + + + {{ if .SilenceURL }} + + + Silence + + {{ end }} + {{ if .Annotations.runbook_url }} + + + View Runbook + + {{ end }} + {{ if .DashboardURL}} + + + Go to Dashboard + + {{ end }} + {{ if .PanelURL}} + + + Go to Panel + + {{ end }} + {{ if gt (len .GeneratorURL) 0 }}Source{{ end }} + + + + +
+
+
+ + +{{ end }} + +{{ if gt (len .Message) 0 }} + {{ .Message }} +{{ else }} + -
- + - -
+ + {{ if gt (len .Alerts.Firing) 0 }} - -
- {{ if gt (len .Alerts.Firing) 0 }} -

{{.Title}}

- {{ else }} -

{{.Title}}

- {{ end }} +
+ Firing: {{ .Alerts.Firing | len }} alert{{ if gt (len .Alerts.Firing) 1 }}s{{ end }}{{ if gt (len .GroupLabels.SortedPairs) 1 }} for + {{ range .GroupLabels.SortedPairs }} + {{ .Name }}={{ .Value }} + {{ end }}{{ end }}
-
- - - - {{ if gt (len .Alerts.Firing) 0 }} - - {{ else }} - - {{ end }} - - - - - +
- {{ .Alerts | len }} alert{{ if gt (len .Alerts) 1 }}s{{ end }} for - {{ range .GroupLabels.SortedPairs }} - {{ .Name }}={{ .Value }} - {{ end }} - - {{ .Alerts | len }} alert{{ if gt (len .Alerts) 1 }}s{{ end }} for - {{ range .GroupLabels.SortedPairs }} - {{ .Name }}={{ .Value }} - {{ end }} -
- - {{ if gt (len .Alerts.Firing) 0 }} - - - - {{ end }} {{ range .Alerts.Firing }} - - - - - - - - + + + + + {{ template "alert" . }} {{ end }} - - {{ if gt (len .Alerts.Resolved) 0 }} - {{ if gt (len .Alerts.Firing) 0 }} + {{ end }} + {{ if gt (len .Alerts.Resolved) 0 }} - - {{ end }} - - - - {{ end }} {{ range .Alerts.Resolved }} - - - - - - - - + + + + + {{ template "alert" . }} {{ end }} + {{ end }} + + +
-
({{ .Alerts.Firing | len }}) Firing
-
-
Labels
-
- {{ if gt (len .Annotations) 0 }}
Annotations
{{ end }} -
- {{ range .Labels.SortedPairs }}{{ .Name }} = {{ .Value }}
{{ end }} - Source
-
- {{ range .Annotations.SortedPairs }}{{ .Name }} = {{ .Value }}
{{ end }} -
+ Firing + + {{ .Labels.alertname }} +
-
-
-
+
+ Resolved: {{ .Alerts.Resolved | len }} alert{{ if gt (len .Alerts.Resolved) 1 }}s{{ end }}{{ if gt (len .GroupLabels.SortedPairs) 1 }} for + {{ range .GroupLabels.SortedPairs }} + {{ .Name }}={{ .Value }} + {{ end }}{{ end }}
-
({{ .Alerts.Resolved | len }}) Resolved
-
-
Labels
-
- {{ if gt (len .Annotations) 0 }}
Annotations
{{ end }} -
- {{ range .Labels.SortedPairs }}{{ .Name }} = {{ .Value }}
{{ end }} - Source
-
- {{ range .Annotations.SortedPairs }}{{ .Name }} = {{ .Value }}
{{ end }} -
+ Resolved + + {{ .Labels.alertname }} +
+ Go to alerts page +
-
+ +
- - - - - - - -
- - - - - -
- - - - -
- View your Alert rule -
-
- - - - -
- Go to the Alerts page -
-
-
- - - +{{ end }} - + @@ -367,7 +335,7 @@ text-decoration: underline; -
+

Sent by Grafana v{{.BuildVersion}} @@ -381,9 +349,9 @@ text-decoration: underline;

- - - - + + + +