From 85ec3552686df70a55b2bb21b10925203a936cc2 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 4 Jun 2026 09:29:47 +0100 Subject: [PATCH] Preserve webhook mentions in list API and resolved notifications (#1118) Back-port v5 fixes 5997fd81f and 0a7b93a84 to v6: - GetWebhooks list response now includes the configured mention so the UI shows it after reload instead of blanking it. - sendResolvedWebhook now assigns data.Mention (v6 set it for grouped/ firing webhooks but dropped it on resolved), and the Discord/Slack/Teams/ Mattermost ResolvedPayloadTemplate strings gained {{if .Mention}} guards. Without these, a configured @everyone/@channel was silently omitted from resolved/cleared notifications. Adds list-API and per-service resolved mention regression tests. --- internal/api/notifications.go | 5 ++ internal/api/notifications_test.go | 2 + internal/notifications/notifications.go | 5 ++ .../notifications_additional_test.go | 84 +++++++++++++++++++ internal/notifications/webhook_templates.go | 13 ++- 5 files changed, 107 insertions(+), 2 deletions(-) diff --git a/internal/api/notifications.go b/internal/api/notifications.go index 702dd00be..a0b2c9556 100644 --- a/internal/api/notifications.go +++ b/internal/api/notifications.go @@ -318,6 +318,11 @@ func (h *NotificationHandlers) GetWebhooks(w http.ResponseWriter, r *http.Reques whMap["template"] = webhook.Template } + // Include the configured mention so the UI can render it after reload (#1118) + if webhook.Mention != "" { + whMap["mention"] = webhook.Mention + } + maskedWebhooks[i] = whMap } diff --git a/internal/api/notifications_test.go b/internal/api/notifications_test.go index caaf8eb07..da1b86935 100644 --- a/internal/api/notifications_test.go +++ b/internal/api/notifications_test.go @@ -357,6 +357,7 @@ func TestNotificationHandlers(t *testing.T) { Name: "Test Webhook", URL: "https://example.com", Headers: map[string]string{"Authorization": "Bearer token"}, + Mention: "@everyone", }, } mockManager.On("GetWebhooks").Return(webhooks).Once() @@ -370,6 +371,7 @@ func TestNotificationHandlers(t *testing.T) { _ = json.Unmarshal(w.Body.Bytes(), &resp) assert.Equal(t, 1, len(resp)) assert.Equal(t, "wh1", resp[0]["id"]) + assert.Equal(t, "@everyone", resp[0]["mention"]) headers := resp[0]["headers"].(map[string]interface{}) assert.Equal(t, "***REDACTED***", headers["Authorization"]) }) diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index d545282bf..37b2e0869 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -2174,6 +2174,11 @@ func (n *NotificationManager) sendResolvedWebhook(webhook WebhookConfig, alertLi customFields := convertWebhookCustomFields(webhook.CustomFields) data := n.prepareWebhookData(alert, customFields) + // Carry the configured mention into the resolved payload too, matching the + // firing/grouped paths so a configured @everyone/@channel is not dropped on + // resolved notifications (#1118). + data.Mention = webhook.Mention + // Override fields for resolved context data.Event = "resolved" data.ResolvedAt = resolvedAt.Format(time.RFC3339) diff --git a/internal/notifications/notifications_additional_test.go b/internal/notifications/notifications_additional_test.go index 70a9a04cf..ae4a7adac 100644 --- a/internal/notifications/notifications_additional_test.go +++ b/internal/notifications/notifications_additional_test.go @@ -266,6 +266,90 @@ func TestSendResolvedWebhookHTTP(t *testing.T) { } } +func TestSendResolvedWebhookIncludesMention(t *testing.T) { + cases := []struct { + service string + assertFn func(t *testing.T, payload map[string]any) + }{ + { + service: "discord", + assertFn: func(t *testing.T, payload map[string]any) { + if payload["content"] != "@everyone" { + t.Fatalf("expected discord resolved mention in content, got %v", payload["content"]) + } + }, + }, + { + service: "slack", + assertFn: func(t *testing.T, payload map[string]any) { + text, _ := payload["text"].(string) + if !strings.HasPrefix(text, "@everyone ") { + t.Fatalf("expected slack resolved text to start with mention, got %q", text) + } + }, + }, + { + service: "mattermost", + assertFn: func(t *testing.T, payload map[string]any) { + text, _ := payload["text"].(string) + if !strings.HasPrefix(text, "@everyone\n\n") { + t.Fatalf("expected mattermost resolved text to start with mention, got %q", text) + } + }, + }, + } + + for _, tc := range cases { + t.Run(tc.service, func(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.StatusNoContent) + })) + defer server.Close() + + manager := NewNotificationManager("") + defer manager.Stop() + manager.webhookClient = server.Client() + if err := manager.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil { + t.Fatalf("allowlist: %v", err) + } + + alertList := []*alerts.Alert{{ + ID: "a1", + Type: "cpu", + Level: alerts.AlertLevelWarning, + ResourceID: "vm100", + ResourceName: "web-server", + Node: "pve1", + Message: "CPU usage high", + Value: 95, + Threshold: 90, + StartTime: time.Now().Add(-5 * time.Minute), + }} + + webhook := WebhookConfig{ + Name: tc.service + "-test", + URL: server.URL + "/" + tc.service, + Enabled: true, + Service: tc.service, + Mention: "@everyone", + } + + if err := manager.sendResolvedWebhook(webhook, alertList, time.Now()); err != nil { + t.Fatalf("sendResolvedWebhook error: %v", err) + } + + var payload map[string]any + if err := json.Unmarshal(gotBody, &payload); err != nil { + t.Fatalf("unmarshal payload: %v (body=%s)", err, gotBody) + } + tc.assertFn(t, payload) + }) + } +} + func TestSendResolvedWebhookNtfySingleAlert(t *testing.T) { var gotMethod string var gotBody string diff --git a/internal/notifications/webhook_templates.go b/internal/notifications/webhook_templates.go index 3371e9e03..f80a5d0ed 100644 --- a/internal/notifications/webhook_templates.go +++ b/internal/notifications/webhook_templates.go @@ -52,6 +52,7 @@ func GetWebhookTemplates() []WebhookTemplate { }`, ResolvedPayloadTemplate: `{ "username": "Pulse Monitoring", + {{if .Mention}}"content": "{{.Mention | jsonString}}",{{end}} "embeds": [{ "title": "Resolved: {{.ResourceName | jsonString}}", "description": "{{.Message | jsonString}}", @@ -151,8 +152,15 @@ func GetWebhookTemplates() []WebhookTemplate { ] }`, ResolvedPayloadTemplate: `{ - "text": "Resolved: {{.ResourceName | jsonString}}", + "text": "{{if .Mention}}{{.Mention | jsonString}} {{end}}Resolved: {{.ResourceName | jsonString}}", "blocks": [ + {{if .Mention}}{ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "{{.Mention | jsonString}}" + } + },{{end}} { "type": "header", "text": { @@ -235,6 +243,7 @@ func GetWebhookTemplates() []WebhookTemplate { "@context": "http://schema.org/extensions", "themeColor": "2DC72D", "summary": "Resolved: {{.ResourceName | jsonString}}", + {{if .Mention}}"text": "{{.Mention | jsonString}}",{{end}} "sections": [{ "activityTitle": "Resolved: {{.ResourceName | jsonString}}", "activitySubtitle": "{{.Message | jsonString}}", @@ -512,7 +521,7 @@ View in Pulse: {{.Instance}}`, 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 | 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}} |" + "text": "{{if .Mention}}{{.Mention | jsonString}}\n\n{{end}}: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.", },