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.
This commit is contained in:
rcourtman 2026-06-04 09:29:47 +01:00
parent 4bfd211a76
commit 85ec355268
5 changed files with 107 additions and 2 deletions

View file

@ -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
}

View file

@ -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"])
})

View file

@ -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)

View file

@ -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

View file

@ -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.",
},