Forward-port webhook JSON template escaping

This commit is contained in:
rcourtman 2026-04-01 17:04:40 +01:00
parent 4564742c18
commit b8a551ce22
6 changed files with 254 additions and 135 deletions

View file

@ -84,6 +84,14 @@ template registry instead of keeping a second hardcoded service list. Mention
field visibility plus mention-placeholder/help copy for supported services
must also come from the same backend registry so the editor does not carry a
second service-specific presentation map.
That same template-registry boundary owns JSON-safe string rendering for the
built-in webhook providers. Canonical JSON templates must render runtime
strings through the shared notification template helper that JSON-escapes
quoted, multi-line, and path-like alert content before validation, instead of
injecting raw alert fields directly into JSON bodies. Custom user templates
may still choose their own formatting, but the shipped provider templates may
not rely on callers to pre-sanitize alert text or resource names just to keep
their JSON payloads valid.
Email single-alert, grouped, resolved, and HTML send paths must follow that
same ownership rule: they may expose different calling surfaces, but they must
all route through one canonical enhanced email executor instead of rebuilding

View file

@ -2478,6 +2478,16 @@ func templateFuncMap() template.FuncMap {
}
return strings.ToUpper(s[:1]) + strings.ToLower(s[1:])
},
"jsonString": func(v interface{}) string {
encoded, err := json.Marshal(v)
if err != nil {
return ""
}
if len(encoded) >= 2 && encoded[0] == '"' && encoded[len(encoded)-1] == '"' {
return string(encoded[1 : len(encoded)-1])
}
return string(encoded)
},
"upper": strings.ToUpper,
"lower": strings.ToLower,
"printf": fmt.Sprintf,

View file

@ -132,6 +132,81 @@ func TestSendGroupedWebhookGeneric(t *testing.T) {
}
}
func TestSendGroupedWebhookDiscordEscapesSpecialCharacters(t *testing.T) {
var gotBody []byte
server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
gotBody = body
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
manager := NewNotificationManager("https://pulse.example")
defer manager.Stop()
manager.webhookClient = server.Client()
if err := manager.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil {
t.Fatalf("failed to allowlist localhost: %v", err)
}
alert := &alerts.Alert{
ID: "alert-1",
Type: "cpu",
Level: alerts.AlertLevelWarning,
ResourceID: "vm-100",
ResourceName: `db "primary"`,
Node: `node\01`,
Message: "CPU spike on \"db\"\nPath C:\\temp",
Value: 92.3,
Threshold: 80,
StartTime: time.Now().Add(-5 * time.Minute),
}
webhook := WebhookConfig{
Name: "discord-test",
URL: server.URL,
Enabled: true,
Service: "discord",
}
if err := manager.sendGroupedWebhook(webhook, []*alerts.Alert{alert}); err != nil {
t.Fatalf("sendGroupedWebhook error: %v", err)
}
var payload map[string]interface{}
if err := json.Unmarshal(gotBody, &payload); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
embeds, ok := payload["embeds"].([]interface{})
if !ok || len(embeds) == 0 {
t.Fatalf("expected embeds in payload, got: %v", payload)
}
embed, ok := embeds[0].(map[string]interface{})
if !ok {
t.Fatalf("expected embed object, got %T", embeds[0])
}
description, _ := embed["description"].(string)
if description != alert.Message {
t.Fatalf("expected description %q, got %q", alert.Message, description)
}
fields, ok := embed["fields"].([]interface{})
if !ok || len(fields) == 0 {
t.Fatalf("expected fields in embed, got: %v", embed)
}
resourceField, ok := fields[0].(map[string]interface{})
if !ok {
t.Fatalf("expected first field object, got %T", fields[0])
}
if resourceField["value"] != alert.ResourceName {
t.Fatalf("expected resource field %q, got %v", alert.ResourceName, resourceField["value"])
}
}
func TestSendResolvedWebhookHTTP(t *testing.T) {
var gotBody []byte
server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View file

@ -2502,6 +2502,31 @@ func TestGeneratePayloadFromTemplateWithService(t *testing.T) {
}
})
t.Run("template with jsonString helper escapes special characters", func(t *testing.T) {
nm := &NotificationManager{}
data := WebhookPayloadData{
ResourceName: `db "primary"`,
Message: "Line1\nLine2 with \"quotes\" and C:\\temp",
}
template := `{"resource":"{{.ResourceName | jsonString}}","message":"{{.Message | jsonString}}"}`
result, err := nm.generatePayloadFromTemplateWithService(template, data, "generic")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
var parsed map[string]interface{}
if err := json.Unmarshal(result, &parsed); err != nil {
t.Fatalf("result is not valid JSON: %v", err)
}
if parsed["resource"] != data.ResourceName {
t.Fatalf("expected resource %q, got %v", data.ResourceName, parsed["resource"])
}
if parsed["message"] != data.Message {
t.Fatalf("expected message %q, got %v", data.Message, parsed["message"])
}
})
t.Run("telegram service validates JSON", func(t *testing.T) {
nm := &NotificationManager{}
data := WebhookPayloadData{

View file

@ -400,7 +400,8 @@ func TestGetWebhookTemplates_PagerDutySettings(t *testing.T) {
if !strings.Contains(pagerduty.PayloadTemplate, "event_action") {
t.Error("PagerDuty PayloadTemplate should contain 'event_action'")
}
if !strings.Contains(pagerduty.PayloadTemplate, `"alert_identifier": "{{.ID}}"`) {
if !strings.Contains(pagerduty.PayloadTemplate, `"alert_identifier":`) ||
!strings.Contains(pagerduty.PayloadTemplate, `{{.ID | jsonString}}`) {
t.Error("PagerDuty PayloadTemplate should contain canonical 'alert_identifier'")
}
if strings.Contains(pagerduty.PayloadTemplate, legacyPagerDutyAlertIdentifierField) {

View file

@ -31,20 +31,20 @@ func GetWebhookTemplates() []WebhookTemplate {
Headers: map[string]string{"Content-Type": "application/json"},
PayloadTemplate: `{
"username": "Pulse Monitoring",
{{if .Mention}}"content": "{{.Mention}}",{{end}}
{{if .Mention}}"content": "{{.Mention | jsonString}}",{{end}}
"embeds": [{
"title": "Pulse Alert: {{.Level | title}}",
"description": "{{.Message}}",
"title": "Pulse Alert: {{.Level | title | jsonString}}",
"description": "{{.Message | jsonString}}",
"color": {{if eq .Level "critical"}}15158332{{else if eq .Level "warning"}}15105570{{else}}3447003{{end}},
"fields": [
{"name": "Resource", "value": "{{.ResourceName}}", "inline": true},
{"name": "Node", "value": "{{.Node}}", "inline": true},
{"name": "Type", "value": "{{.Type | title}}", "inline": true},
{"name": "Resource", "value": "{{.ResourceName | jsonString}}", "inline": true},
{"name": "Node", "value": "{{.Node | jsonString}}", "inline": true},
{"name": "Type", "value": "{{.Type | title | jsonString}}", "inline": true},
{"name": "Value", "value": "{{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.1f" .Value}} MB/s{{else}}{{printf "%.1f" .Value}}%{{end}}", "inline": true},
{"name": "Threshold", "value": "{{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.0f" .Threshold}} MB/s{{else}}{{printf "%.0f" .Threshold}}%{{end}}", "inline": true},
{"name": "Duration", "value": "{{.Duration}}", "inline": true}
{"name": "Duration", "value": "{{.Duration | jsonString}}", "inline": true}
],
"timestamp": "{{.Timestamp}}",
"timestamp": "{{.Timestamp | jsonString}}",
"footer": {
"text": "Pulse Monitoring"
}
@ -53,17 +53,17 @@ func GetWebhookTemplates() []WebhookTemplate {
ResolvedPayloadTemplate: `{
"username": "Pulse Monitoring",
"embeds": [{
"title": "Resolved: {{.ResourceName}}",
"description": "{{.Message}}",
"title": "Resolved: {{.ResourceName | jsonString}}",
"description": "{{.Message | jsonString}}",
"color": 3066993,
"fields": [
{"name": "Resource", "value": "{{.ResourceName}}", "inline": true},
{"name": "Node", "value": "{{.Node}}", "inline": true},
{"name": "Type", "value": "{{.Type | title}}", "inline": true},
{"name": "Duration", "value": "{{.Duration}}", "inline": true},
{"name": "Resolved At", "value": "{{.ResolvedAt}}", "inline": true}
{"name": "Resource", "value": "{{.ResourceName | jsonString}}", "inline": true},
{"name": "Node", "value": "{{.Node | jsonString}}", "inline": true},
{"name": "Type", "value": "{{.Type | title | jsonString}}", "inline": true},
{"name": "Duration", "value": "{{.Duration | jsonString}}", "inline": true},
{"name": "Resolved At", "value": "{{.ResolvedAt | jsonString}}", "inline": true}
],
"timestamp": "{{.Timestamp}}",
"timestamp": "{{.Timestamp | jsonString}}",
"footer": {
"text": "Pulse Monitoring"
}
@ -80,14 +80,14 @@ func GetWebhookTemplates() []WebhookTemplate {
Method: "POST",
Headers: map[string]string{"Content-Type": "application/json"},
PayloadTemplate: `{
"chat_id": "{{.ChatID}}",
"text": "*Pulse Alert: {{.Level | title}}*\n\n{{.Message}}\n\n*Details:*\n• Resource: {{.ResourceName}}\n• Node: {{.Node}}\n• Type: {{.Type | title}}\n• Value: {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.1f" .Value}} MB/s{{else}}{{printf "%.1f" .Value}}%{{end}}\n• Threshold: {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.0f" .Threshold}} MB/s{{else}}{{printf "%.0f" .Threshold}}%{{end}}\n• Duration: {{.Duration}}\n\n[View in Pulse]({{.Instance}})",
"chat_id": "{{.ChatID | jsonString}}",
"text": "*Pulse Alert: {{.Level | title | jsonString}}*\n\n{{.Message | jsonString}}\n\n*Details:*\n• Resource: {{.ResourceName | jsonString}}\n• Node: {{.Node | jsonString}}\n• Type: {{.Type | title | jsonString}}\n• Value: {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.1f" .Value}} MB/s{{else}}{{printf "%.1f" .Value}}%{{end}}\n• Threshold: {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.0f" .Threshold}} MB/s{{else}}{{printf "%.0f" .Threshold}}%{{end}}\n• Duration: {{.Duration | jsonString}}\n\n[View in Pulse]({{.Instance | jsonString}})",
"parse_mode": "Markdown",
"disable_web_page_preview": true
}`,
ResolvedPayloadTemplate: `{
"chat_id": "{{.ChatID}}",
"text": "*Resolved: {{.ResourceName}}*\n\n{{.Message}}\n\n*Details:*\n• Resource: {{.ResourceName}}\n• Node: {{.Node}}\n• Type: {{.Type | title}}\n• Duration: {{.Duration}}\n• Resolved At: {{.ResolvedAt}}",
"chat_id": "{{.ChatID | jsonString}}",
"text": "*Resolved: {{.ResourceName | jsonString}}*\n\n{{.Message | jsonString}}\n\n*Details:*\n• Resource: {{.ResourceName | jsonString}}\n• Node: {{.Node | jsonString}}\n• Type: {{.Type | title | jsonString}}\n• Duration: {{.Duration | jsonString}}\n• Resolved At: {{.ResolvedAt | jsonString}}",
"parse_mode": "Markdown",
"disable_web_page_preview": true
}`,
@ -104,20 +104,20 @@ func GetWebhookTemplates() []WebhookTemplate {
Method: "POST",
Headers: map[string]string{"Content-Type": "application/json"},
PayloadTemplate: `{
"text": "{{if .Mention}}{{.Mention}} {{end}}Pulse Alert: {{.Level | title}} - {{.ResourceName}}",
"text": "{{if .Mention}}{{.Mention | jsonString}} {{end}}Pulse Alert: {{.Level | title | jsonString}} - {{.ResourceName | jsonString}}",
"blocks": [
{{if .Mention}}{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "{{.Mention}}"
"text": "{{.Mention | jsonString}}"
}
},{{end}}
{
"type": "header",
"text": {
"type": "plain_text",
"text": "Pulse Alert: {{.Level | title}}",
"text": "Pulse Alert: {{.Level | title | jsonString}}",
"emoji": true
}
},
@ -125,18 +125,18 @@ func GetWebhookTemplates() []WebhookTemplate {
"type": "section",
"text": {
"type": "mrkdwn",
"text": "{{.Message}}"
"text": "{{.Message | jsonString}}"
}
},
{
"type": "section",
"fields": [
{"type": "mrkdwn", "text": "*Resource:*\n{{.ResourceName}}"},
{"type": "mrkdwn", "text": "*Node:*\n{{.Node}}"},
{"type": "mrkdwn", "text": "*Type:*\n{{.Type | title}}"},
{"type": "mrkdwn", "text": "*Resource:*\n{{.ResourceName | jsonString}}"},
{"type": "mrkdwn", "text": "*Node:*\n{{.Node | jsonString}}"},
{"type": "mrkdwn", "text": "*Type:*\n{{.Type | title | jsonString}}"},
{"type": "mrkdwn", "text": "*Value:*\n{{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.1f" .Value}} MB/s{{else}}{{printf "%.1f" .Value}}%{{end}}"},
{"type": "mrkdwn", "text": "*Threshold:*\n{{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.0f" .Threshold}} MB/s{{else}}{{printf "%.0f" .Threshold}}%{{end}}"},
{"type": "mrkdwn", "text": "*Duration:*\n{{.Duration}}"}
{"type": "mrkdwn", "text": "*Duration:*\n{{.Duration | jsonString}}"}
]
},
{
@ -144,20 +144,20 @@ func GetWebhookTemplates() []WebhookTemplate {
"elements": [
{
"type": "mrkdwn",
"text": "View in <{{.Instance}}|Proxmox> | Alert Identifier: {{.ID}}"
"text": "View in <{{.Instance | jsonString}}|Proxmox> | Alert Identifier: {{.ID | jsonString}}"
}
]
}
]
}`,
ResolvedPayloadTemplate: `{
"text": "Resolved: {{.ResourceName}}",
"text": "Resolved: {{.ResourceName | jsonString}}",
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "Resolved: {{.ResourceName}}",
"text": "Resolved: {{.ResourceName | jsonString}}",
"emoji": true
}
},
@ -165,17 +165,17 @@ func GetWebhookTemplates() []WebhookTemplate {
"type": "section",
"text": {
"type": "mrkdwn",
"text": "{{.Message}}"
"text": "{{.Message | jsonString}}"
}
},
{
"type": "section",
"fields": [
{"type": "mrkdwn", "text": "*Resource:*\n{{.ResourceName}}"},
{"type": "mrkdwn", "text": "*Node:*\n{{.Node}}"},
{"type": "mrkdwn", "text": "*Type:*\n{{.Type | title}}"},
{"type": "mrkdwn", "text": "*Duration:*\n{{.Duration}}"},
{"type": "mrkdwn", "text": "*Resolved At:*\n{{.ResolvedAt}}"}
{"type": "mrkdwn", "text": "*Resource:*\n{{.ResourceName | jsonString}}"},
{"type": "mrkdwn", "text": "*Node:*\n{{.Node | jsonString}}"},
{"type": "mrkdwn", "text": "*Type:*\n{{.Type | title | jsonString}}"},
{"type": "mrkdwn", "text": "*Duration:*\n{{.Duration | jsonString}}"},
{"type": "mrkdwn", "text": "*Resolved At:*\n{{.ResolvedAt | jsonString}}"}
]
},
{
@ -183,7 +183,7 @@ func GetWebhookTemplates() []WebhookTemplate {
"elements": [
{
"type": "mrkdwn",
"text": "Alert Identifier: {{.ID}}"
"text": "Alert Identifier: {{.ID | jsonString}}"
}
]
}
@ -205,19 +205,19 @@ func GetWebhookTemplates() []WebhookTemplate {
"@type": "MessageCard",
"@context": "http://schema.org/extensions",
"themeColor": "{{if eq .Level "critical"}}FF0000{{else if eq .Level "warning"}}FFA500{{else}}00FF00{{end}}",
"summary": "Pulse Alert: {{.Level | title}} - {{.ResourceName}}",
{{if .Mention}}"text": "{{.Mention}}",{{end}}
"summary": "Pulse Alert: {{.Level | title | jsonString}} - {{.ResourceName | jsonString}}",
{{if .Mention}}"text": "{{.Mention | jsonString}}",{{end}}
"sections": [{
"activityTitle": "Pulse Alert: {{.Level | title}}",
"activitySubtitle": "{{.Message}}",
"activityTitle": "Pulse Alert: {{.Level | title | jsonString}}",
"activitySubtitle": "{{.Message | jsonString}}",
"facts": [
{"name": "Resource", "value": "{{.ResourceName}}"},
{"name": "Node", "value": "{{.Node}}"},
{"name": "Type", "value": "{{.Type | title}}"},
{"name": "Resource", "value": "{{.ResourceName | jsonString}}"},
{"name": "Node", "value": "{{.Node | jsonString}}"},
{"name": "Type", "value": "{{.Type | title | jsonString}}"},
{"name": "Value", "value": "{{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.1f" .Value}} MB/s{{else}}{{printf "%.1f" .Value}}%{{end}}"},
{"name": "Threshold", "value": "{{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.0f" .Threshold}} MB/s{{else}}{{printf "%.0f" .Threshold}}%{{end}}"},
{"name": "Duration", "value": "{{.Duration}}"},
{"name": "Instance", "value": "{{.Instance}}"}
{"name": "Duration", "value": "{{.Duration | jsonString}}"},
{"name": "Instance", "value": "{{.Instance | jsonString}}"}
],
"markdown": true
}],
@ -226,7 +226,7 @@ func GetWebhookTemplates() []WebhookTemplate {
"name": "View in Proxmox",
"targets": [{
"os": "default",
"uri": "{{.Instance}}"
"uri": "{{.Instance | jsonString}}"
}]
}]
}`,
@ -234,16 +234,16 @@ func GetWebhookTemplates() []WebhookTemplate {
"@type": "MessageCard",
"@context": "http://schema.org/extensions",
"themeColor": "2DC72D",
"summary": "Resolved: {{.ResourceName}}",
"summary": "Resolved: {{.ResourceName | jsonString}}",
"sections": [{
"activityTitle": "Resolved: {{.ResourceName}}",
"activitySubtitle": "{{.Message}}",
"activityTitle": "Resolved: {{.ResourceName | jsonString}}",
"activitySubtitle": "{{.Message | jsonString}}",
"facts": [
{"name": "Resource", "value": "{{.ResourceName}}"},
{"name": "Node", "value": "{{.Node}}"},
{"name": "Type", "value": "{{.Type | title}}"},
{"name": "Duration", "value": "{{.Duration}}"},
{"name": "Resolved At", "value": "{{.ResolvedAt}}"}
{"name": "Resource", "value": "{{.ResourceName | jsonString}}"},
{"name": "Node", "value": "{{.Node | jsonString}}"},
{"name": "Type", "value": "{{.Type | title | jsonString}}"},
{"name": "Duration", "value": "{{.Duration | jsonString}}"},
{"name": "Resolved At", "value": "{{.ResolvedAt | jsonString}}"}
],
"markdown": true
}]
@ -262,37 +262,37 @@ func GetWebhookTemplates() []WebhookTemplate {
"Accept": "application/vnd.pagerduty+json;version=2",
},
PayloadTemplate: `{
"routing_key": "{{.CustomFields.routing_key}}",
"routing_key": "{{.CustomFields.routing_key | jsonString}}",
"event_action": "trigger",
"dedup_key": "{{.ID}}",
"dedup_key": "{{.ID | jsonString}}",
"payload": {
"summary": "{{.Message}}",
"timestamp": "{{.Timestamp}}",
"summary": "{{.Message | jsonString}}",
"timestamp": "{{.Timestamp | jsonString}}",
"severity": "{{if eq .Level "critical"}}critical{{else if eq .Level "warning"}}warning{{else}}info{{end}}",
"source": "{{.Node}}",
"component": "{{.ResourceName}}",
"group": "{{.Type}}",
"class": "{{.Type}}",
"source": "{{.Node | jsonString}}",
"component": "{{.ResourceName | jsonString}}",
"group": "{{.Type | jsonString}}",
"class": "{{.Type | jsonString}}",
"custom_details": {
"alert_identifier": "{{.ID}}",
"resource_type": "{{.Type}}",
"alert_identifier": "{{.ID | jsonString}}",
"resource_type": "{{.Type | jsonString}}",
"current_value": "{{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.1f" .Value}} MB/s{{else}}{{printf "%.1f" .Value}}%{{end}}",
"threshold": "{{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.0f" .Threshold}} MB/s{{else}}{{printf "%.0f" .Threshold}}%{{end}}",
"duration": "{{.Duration}}",
"instance": "{{.Instance}}"
"duration": "{{.Duration | jsonString}}",
"instance": "{{.Instance | jsonString}}"
}
},
"client": "Pulse Monitoring",
"client_url": "{{.Instance}}",
"client_url": "{{.Instance | jsonString}}",
"links": [{
"href": "{{.Instance}}",
"href": "{{.Instance | jsonString}}",
"text": "View in Proxmox"
}]
}`,
ResolvedPayloadTemplate: `{
"routing_key": "{{.CustomFields.routing_key}}",
"routing_key": "{{.CustomFields.routing_key | jsonString}}",
"event_action": "resolve",
"dedup_key": "{{.ID}}"
"dedup_key": "{{.ID | jsonString}}"
}`,
Instructions: "1. In PagerDuty, go to Configuration > Services\n2. Add an integration > Events API V2\n3. Copy the Integration Key\n4. Add the key as a custom field named 'routing_key'\n\nNote: PagerDuty recommends using Events API v2 for new integrations.",
},
@ -324,27 +324,27 @@ func GetWebhookTemplates() []WebhookTemplate {
},
{
"type": "TextBlock",
"text": "{{.Message}}",
"text": "{{.Message | jsonString}}",
"wrap": true,
"spacing": "Small"
},
{
"type": "FactSet",
"facts": [
{"title": "Resource", "value": "{{.ResourceName}}"},
{"title": "Node", "value": "{{.Node}}"},
{"title": "Type", "value": "{{.Type | title}}"},
{"title": "Resource", "value": "{{.ResourceName | jsonString}}"},
{"title": "Node", "value": "{{.Node | jsonString}}"},
{"title": "Type", "value": "{{.Type | title | jsonString}}"},
{"title": "Current Value", "value": "{{printf "%.1f" .Value}}%"},
{"title": "Threshold", "value": "{{printf "%.0f" .Threshold}}%"},
{"title": "Duration", "value": "{{.Duration}}"},
{"title": "Alert Identifier", "value": "{{.ID}}"}
{"title": "Duration", "value": "{{.Duration | jsonString}}"},
{"title": "Alert Identifier", "value": "{{.ID | jsonString}}"}
]
}
],
"actions": [{
"type": "Action.OpenUrl",
"title": "View in Proxmox",
"url": "{{.Instance}}"
"url": "{{.Instance | jsonString}}"
}]
}
}]
@ -367,19 +367,19 @@ func GetWebhookTemplates() []WebhookTemplate {
},
{
"type": "TextBlock",
"text": "{{.Message}}",
"text": "{{.Message | jsonString}}",
"wrap": true,
"spacing": "Small"
},
{
"type": "FactSet",
"facts": [
{"title": "Resource", "value": "{{.ResourceName}}"},
{"title": "Node", "value": "{{.Node}}"},
{"title": "Type", "value": "{{.Type | title}}"},
{"title": "Duration", "value": "{{.Duration}}"},
{"title": "Resolved At", "value": "{{.ResolvedAt}}"},
{"title": "Alert Identifier", "value": "{{.ID}}"}
{"title": "Resource", "value": "{{.ResourceName | jsonString}}"},
{"title": "Node", "value": "{{.Node | jsonString}}"},
{"title": "Type", "value": "{{.Type | title | jsonString}}"},
{"title": "Duration", "value": "{{.Duration | jsonString}}"},
{"title": "Resolved At", "value": "{{.ResolvedAt | jsonString}}"},
{"title": "Alert Identifier", "value": "{{.ID | jsonString}}"}
]
}
]
@ -397,23 +397,23 @@ func GetWebhookTemplates() []WebhookTemplate {
Method: "POST",
Headers: map[string]string{"Content-Type": "application/json"},
PayloadTemplate: `{
"token": "{{.CustomFields.token}}",
"user": "{{.CustomFields.user}}",
"title": "Pulse Alert: {{.Level | title}} - {{.ResourceName}}",
"message": "{{.Message}}\n\n• Resource: {{.ResourceName}}\n• Node: {{.Node}}\n• Type: {{.Type | title}}\n• Value: {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.1f" .Value}} MB/s{{else}}{{printf "%.1f" .Value}}%{{end}}\n• Threshold: {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.0f" .Threshold}} MB/s{{else}}{{printf "%.0f" .Threshold}}%{{end}}\n• Duration: {{.Duration}}",
"token": "{{.CustomFields.token | jsonString}}",
"user": "{{.CustomFields.user | jsonString}}",
"title": "Pulse Alert: {{.Level | title | jsonString}} - {{.ResourceName | jsonString}}",
"message": "{{.Message | jsonString}}\n\n• Resource: {{.ResourceName | jsonString}}\n• Node: {{.Node | jsonString}}\n• Type: {{.Type | title | jsonString}}\n• Value: {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.1f" .Value}} MB/s{{else}}{{printf "%.1f" .Value}}%{{end}}\n• Threshold: {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.0f" .Threshold}} MB/s{{else}}{{printf "%.0f" .Threshold}}%{{end}}\n• Duration: {{.Duration | jsonString}}",
"priority": {{if .CustomFields.priority}}{{.CustomFields.priority}}{{else}}{{if eq .Level "critical"}}1{{else if eq .Level "warning"}}0{{else}}-1{{end}}{{end}},
"sound": "{{if .CustomFields.sound}}{{.CustomFields.sound}}{{else}}{{if eq .Level "critical"}}siren{{else if eq .Level "warning"}}tugboat{{else}}pushover{{end}}{{end}}",
"device": "{{if .CustomFields.device}}{{.CustomFields.device}}{{else}}{{.ResourceName}}{{end}}",
"timestamp": "{{.Timestamp}}"
"sound": "{{if .CustomFields.sound}}{{.CustomFields.sound | jsonString}}{{else}}{{if eq .Level "critical"}}siren{{else if eq .Level "warning"}}tugboat{{else}}pushover{{end}}{{end}}",
"device": "{{if .CustomFields.device}}{{.CustomFields.device | jsonString}}{{else}}{{.ResourceName | jsonString}}{{end}}",
"timestamp": "{{.Timestamp | jsonString}}"
}`,
ResolvedPayloadTemplate: `{
"token": "{{.CustomFields.token}}",
"user": "{{.CustomFields.user}}",
"title": "Resolved: {{.ResourceName}}",
"message": "{{.Message}}\n\n• Resource: {{.ResourceName}}\n• Node: {{.Node}}\n• Type: {{.Type | title}}\n• Duration: {{.Duration}}\n• Resolved At: {{.ResolvedAt}}",
"token": "{{.CustomFields.token | jsonString}}",
"user": "{{.CustomFields.user | jsonString}}",
"title": "Resolved: {{.ResourceName | jsonString}}",
"message": "{{.Message | jsonString}}\n\n• Resource: {{.ResourceName | jsonString}}\n• Node: {{.Node | jsonString}}\n• Type: {{.Type | title | jsonString}}\n• Duration: {{.Duration | jsonString}}\n• Resolved At: {{.ResolvedAt | jsonString}}",
"priority": -1,
"sound": "pushover",
"timestamp": "{{.Timestamp}}"
"timestamp": "{{.Timestamp | jsonString}}"
}`,
Instructions: "1. Create an application at https://pushover.net/apps\n2. Copy your Application Token\n3. Get your User Key from your Pushover dashboard\n4. URL: https://api.pushover.net/1/messages.json\n5. Add custom fields:\n • token: YOUR_APP_TOKEN (required)\n • user: YOUR_USER_KEY (required)\n • sound: notification sound (optional, e.g., spacealarm, siren, tugboat)\n • priority: -2 to 2 (optional, overrides level-based default)\n • device: specific device name (optional, overrides ResourceName)",
},
@ -426,41 +426,41 @@ func GetWebhookTemplates() []WebhookTemplate {
Method: "POST",
Headers: map[string]string{"Content-Type": "application/json"},
PayloadTemplate: `{
"message": "**{{if eq .Level "critical"}}CRITICAL{{else if eq .Level "warning"}}WARNING{{else}}INFO{{end}}**: **{{.ResourceName}}** on **{{.Node}}**\n\n{{.Message}}\n\n**Alert Details:**\n- **Resource:** {{.ResourceName}}\n- **Node:** {{.Node}}\n- **Type:** {{.Type | title}}\n- **Current:** {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.1f" .Value}} MB/s{{else}}{{printf "%.1f" .Value}}%{{end}}\n- **Threshold:** {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.0f" .Threshold}} MB/s{{else}}{{printf "%.0f" .Threshold}}%{{end}}\n- **Duration:** {{.Duration}}\n- **Alert Identifier:** {{.ID}}\n\n[View in Pulse]({{.Instance}})",
"title": "{{.ResourceName}} - {{.Type | title}} {{.Level | upper}} Alert",
"message": "**{{if eq .Level "critical"}}CRITICAL{{else if eq .Level "warning"}}WARNING{{else}}INFO{{end}}**: **{{.ResourceName | jsonString}}** on **{{.Node | jsonString}}**\n\n{{.Message | jsonString}}\n\n**Alert Details:**\n- **Resource:** {{.ResourceName | jsonString}}\n- **Node:** {{.Node | jsonString}}\n- **Type:** {{.Type | title | jsonString}}\n- **Current:** {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.1f" .Value}} MB/s{{else}}{{printf "%.1f" .Value}}%{{end}}\n- **Threshold:** {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.0f" .Threshold}} MB/s{{else}}{{printf "%.0f" .Threshold}}%{{end}}\n- **Duration:** {{.Duration | jsonString}}\n- **Alert Identifier:** {{.ID | jsonString}}\n\n[View in Pulse]({{.Instance | jsonString}})",
"title": "{{.ResourceName | jsonString}} - {{.Type | title | jsonString}} {{.Level | upper | jsonString}} Alert",
"priority": {{if eq .Level "critical"}}10{{else if eq .Level "warning"}}5{{else}}2{{end}},
"extras": {
"client::display": {
"contentType": "text/markdown"
},
"pulse::alert": {
"id": "{{.ID}}",
"level": "{{.Level}}",
"type": "{{.Type}}",
"resource_name": "{{.ResourceName}}",
"node": "{{.Node}}",
"id": "{{.ID | jsonString}}",
"level": "{{.Level | jsonString}}",
"type": "{{.Type | jsonString}}",
"resource_name": "{{.ResourceName | jsonString}}",
"node": "{{.Node | jsonString}}",
"value": {{.Value}},
"threshold": {{.Threshold}},
"duration": "{{.Duration}}",
"instance": "{{.Instance}}"
"duration": "{{.Duration | jsonString}}",
"instance": "{{.Instance | jsonString}}"
}
}
}`,
ResolvedPayloadTemplate: `{
"message": "**RESOLVED**: **{{.ResourceName}}** on **{{.Node}}**\n\n{{.Message}}\n\n**Details:**\n- **Resource:** {{.ResourceName}}\n- **Node:** {{.Node}}\n- **Type:** {{.Type | title}}\n- **Duration:** {{.Duration}}\n- **Resolved At:** {{.ResolvedAt}}\n- **Alert Identifier:** {{.ID}}",
"title": "Resolved: {{.ResourceName}} - {{.Type | title}}",
"message": "**RESOLVED**: **{{.ResourceName | jsonString}}** on **{{.Node | jsonString}}**\n\n{{.Message | jsonString}}\n\n**Details:**\n- **Resource:** {{.ResourceName | jsonString}}\n- **Node:** {{.Node | jsonString}}\n- **Type:** {{.Type | title | jsonString}}\n- **Duration:** {{.Duration | jsonString}}\n- **Resolved At:** {{.ResolvedAt | jsonString}}\n- **Alert Identifier:** {{.ID | jsonString}}",
"title": "Resolved: {{.ResourceName | jsonString}} - {{.Type | title | jsonString}}",
"priority": 2,
"extras": {
"client::display": {
"contentType": "text/markdown"
},
"pulse::alert": {
"id": "{{.ID}}",
"id": "{{.ID | jsonString}}",
"event": "resolved",
"resource_name": "{{.ResourceName}}",
"node": "{{.Node}}",
"duration": "{{.Duration}}",
"resolved_at": "{{.ResolvedAt}}"
"resource_name": "{{.ResourceName | jsonString}}",
"node": "{{.Node | jsonString}}",
"duration": "{{.Duration | jsonString}}",
"resolved_at": "{{.ResolvedAt | jsonString}}"
}
}
}`,
@ -507,12 +507,12 @@ View in Pulse: {{.Instance}}`,
PayloadTemplate: `{
"username": "Pulse Monitoring",
"icon_url": "https://raw.githubusercontent.com/rcourtman/Pulse/main/frontend-modern/public/android-chrome-192x192.png",
"text": "{{if eq .Level "critical"}}:rotating_light: **CRITICAL ALERT**{{else if eq .Level "warning"}}:warning: **WARNING ALERT**{{else}}:information_source: **INFO**{{end}}\n\n**{{.ResourceName}}** on **{{.Node}}**\n\n{{.Message}}\n\n| Detail | Value |\n|:-------|:------|\n| Resource | {{.ResourceName}} |\n| Node | {{.Node}} |\n| Type | {{.Type | title}} |\n| Current | {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.1f" .Value}} MB/s{{else}}{{printf "%.1f" .Value}}%{{end}} |\n| Threshold | {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.0f" .Threshold}} MB/s{{else}}{{printf "%.0f" .Threshold}}%{{end}} |\n| Duration | {{.Duration}} |\n| Alert Identifier | {{.ID}} |\n\n[View in Pulse]({{.Instance}})"
"text": "{{if eq .Level "critical"}}:rotating_light: **CRITICAL ALERT**{{else if eq .Level "warning"}}:warning: **WARNING ALERT**{{else}}:information_source: **INFO**{{end}}\n\n**{{.ResourceName | jsonString}}** on **{{.Node | jsonString}}**\n\n{{.Message | jsonString}}\n\n| Detail | Value |\n|:-------|:------|\n| Resource | {{.ResourceName | jsonString}} |\n| Node | {{.Node | jsonString}} |\n| Type | {{.Type | title | jsonString}} |\n| Current | {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.1f" .Value}} MB/s{{else}}{{printf "%.1f" .Value}}%{{end}} |\n| Threshold | {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.0f" .Threshold}} MB/s{{else}}{{printf "%.0f" .Threshold}}%{{end}} |\n| Duration | {{.Duration | jsonString}} |\n| Alert Identifier | {{.ID | jsonString}} |\n\n[View in Pulse]({{.Instance | jsonString}})"
}`,
ResolvedPayloadTemplate: `{
"username": "Pulse Monitoring",
"icon_url": "https://raw.githubusercontent.com/rcourtman/Pulse/main/frontend-modern/public/android-chrome-192x192.png",
"text": ":white_check_mark: **RESOLVED**\n\n**{{.ResourceName}}** on **{{.Node}}**\n\n{{.Message}}\n\n| Detail | Value |\n|:-------|:------|\n| Resource | {{.ResourceName}} |\n| Node | {{.Node}} |\n| Type | {{.Type | title}} |\n| Duration | {{.Duration}} |\n| Resolved At | {{.ResolvedAt}} |\n| Alert Identifier | {{.ID}} |"
"text": ":white_check_mark: **RESOLVED**\n\n**{{.ResourceName | jsonString}}** on **{{.Node | jsonString}}**\n\n{{.Message | jsonString}}\n\n| Detail | Value |\n|:-------|:------|\n| Resource | {{.ResourceName | jsonString}} |\n| Node | {{.Node | jsonString}} |\n| Type | {{.Type | title | jsonString}} |\n| Duration | {{.Duration | jsonString}} |\n| Resolved At | {{.ResolvedAt | jsonString}} |\n| Alert Identifier | {{.ID | jsonString}} |"
}`,
Instructions: "1. In Mattermost, go to Integrations > Incoming Webhooks\n2. Create a new webhook and select the channel\n3. Copy the webhook URL and paste it here\n\nNote: This template uses Markdown formatting which is fully supported by Mattermost.",
},
@ -526,33 +526,33 @@ View in Pulse: {{.Instance}}`,
Headers: map[string]string{"Content-Type": "application/json"},
PayloadTemplate: `{
"alert": {
"id": "{{.ID}}",
"level": "{{.Level}}",
"type": "{{.Type}}",
"resource_name": "{{.ResourceName}}",
"node": "{{.Node}}",
"message": "{{.Message}}",
"id": "{{.ID | jsonString}}",
"level": "{{.Level | jsonString}}",
"type": "{{.Type | jsonString}}",
"resource_name": "{{.ResourceName | jsonString}}",
"node": "{{.Node | jsonString}}",
"message": "{{.Message | jsonString}}",
"value": {{.Value}},
"threshold": {{.Threshold}},
"start_time": "{{.StartTime}}",
"duration": "{{.Duration}}"
"start_time": "{{.StartTime | jsonString}}",
"duration": "{{.Duration | jsonString}}"
},
"timestamp": "{{.Timestamp}}",
"timestamp": "{{.Timestamp | jsonString}}",
"source": "pulse-monitoring"
}`,
ResolvedPayloadTemplate: `{
"event": "resolved",
"alert": {
"id": "{{.ID}}",
"type": "{{.Type}}",
"resource_name": "{{.ResourceName}}",
"node": "{{.Node}}",
"message": "{{.Message}}",
"start_time": "{{.StartTime}}",
"duration": "{{.Duration}}"
"id": "{{.ID | jsonString}}",
"type": "{{.Type | jsonString}}",
"resource_name": "{{.ResourceName | jsonString}}",
"node": "{{.Node | jsonString}}",
"message": "{{.Message | jsonString}}",
"start_time": "{{.StartTime | jsonString}}",
"duration": "{{.Duration | jsonString}}"
},
"resolved_at": "{{.ResolvedAt}}",
"timestamp": "{{.Timestamp}}",
"resolved_at": "{{.ResolvedAt | jsonString}}",
"timestamp": "{{.Timestamp | jsonString}}",
"source": "pulse-monitoring"
}`,
Instructions: "Configure with your custom webhook endpoint",