Expose stable webhook message keys

Add a language-neutral condition key and explicit resource context to generic alert payloads so receivers can translate notifications without parsing English text.

Refs #1779

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-08-26 12:04:43 +01:00
parent 4d457159fc
commit 18b74c0adb
16 changed files with 142 additions and 24 deletions
+6 -2
View File
@@ -28,8 +28,8 @@ Pulse includes built-in templates for popular services and a generic JSON templa
For generic webhooks, use Go templates to format the JSON payload.
**Variables (common):**
- `{{.ID}}`, `{{.Level}}`, `{{.Type}}`
- `{{.ResourceName}}`, `{{.ResourceID}}`, `{{.ResourceType}}`, `{{.Node}}`
- `{{.ID}}`, `{{.Event}}`, `{{.MessageKey}}`, `{{.Level}}`, `{{.Type}}`
- `{{.ResourceName}}`, `{{.ResourceID}}`, `{{.ResourceType}}`, `{{.Node}}`, `{{.NodeDisplayName}}`
- `{{.Message}}`, `{{.Value}}`, `{{.Threshold}}`, `{{.Duration}}`, `{{.Timestamp}}`
- `{{.Instance}}` (Pulse public URL if configured)
- `{{.TenantID}}`, `{{.TenantName}}` (tenant identity in multi-tenant orgs and MSP client runtimes; empty on plain single-tenant installs)
@@ -71,6 +71,8 @@ These fields and behaviors are stable; ticket-routing integrations can rely on t
**Alert type.** `{{.Type}}` is the metric or condition that fired: `cpu`, `memory`, `disk`, `diskRead`, `diskWrite`, `networkIn`, `networkOut`, `connectivity`, and similar. The alert ID (`{{.ID}}`) is stable for the lifetime of an alert occurrence, so the `resolved` event carries the same ID as the `alert` event it closes.
**Message key.** `{{.MessageKey}}` is the stable, language-neutral condition key for rebuilding or translating a notification. Canonical alerts use `<kind>.<type>` (for example, `metric-threshold.disk`); older alert paths fall back to `{{.Type}}`. Combine it with `{{.Event}}` and `{{.ResourceType}}` when the receiving system needs separate wording for firing/recovery events or different resource classes. Unlike a numeric message index, the symbolic key does not change when another alert type is added.
**Tenant identity.** In multi-tenant organizations and MSP client runtimes, `{{.TenantID}}` and `{{.TenantName}}` identify which tenant fired the alert. Client runtimes get identity from the `PULSE_TENANT_ID` / `PULSE_TENANT_NAME` environment; shared-process organizations stamp the org ID and display name automatically.
**Resource tag routing.** Email and each alert webhook can be limited to resources with selected tags in **Alerts → Notifications**. An empty filter receives every alert. With multiple tags, choose **Match all tags** or **Match any tag**. Matching ignores case. Proxmox tags are matched as shown; Docker container and service labels are exposed as `key:value` tags (or `key` when the label value is empty). Recovery notifications follow the destinations that received the firing alert, even if a resource's tags change before recovery.
@@ -192,11 +194,13 @@ stable severity/type fields from the [delivery contract](#-delivery-contract):
{
"event": "{{.Event}}",
"alertId": "{{.ID | jsonString}}",
"messageKey": "{{.MessageKey | jsonString}}",
"severity": "{{.Level | jsonString}}",
"alertType": "{{.Type | jsonString}}",
"tenantId": "{{.TenantID | jsonString}}",
"tenantName": "{{.TenantName | jsonString}}",
"resource": "{{.ResourceName | jsonString}}",
"resourceType": "{{.ResourceType | jsonString}}",
"node": "{{.Node | jsonString}}",
"summary": "{{.Message | jsonString}}",
"value": {{.Value}},
@@ -434,6 +434,12 @@ inspectability, or convert missing/stale evidence into health.
## Current State
The alert webhook editor exposes the delivery contract's language-neutral
`MessageKey`, event, resource type and node display name alongside the existing
type, severity and metric fields. Presentation must advertise the backend-owned
template data rather than asking operators to parse the English alert message
or reach into metadata for canonical alert identity.
### Agent custom sensors use canonical health-assessment alerts
Typed `HostSensorSummary.Custom` numeric, boolean, or timestamp readings with
@@ -25,6 +25,9 @@ in the existing customer-facing section and have exact packet proof in
`render_release_body_test.py`; a note about repository-scoped Proxmox backup
review must name both PBS server and datastore rather than implying that a
workload or node filter provides the same boundary.
The next-candidate release notes and changelog must also describe newly stable
integration fields when external receivers need them to consume the release;
packet proof keeps the customer-facing summary and detailed changelog aligned.
Provider-hosted MSP deploy artifacts must package the provider control plane as
a least-privilege Docker provisioner. The packaged compose/setup path must avoid
@@ -198,6 +198,13 @@ its tenant block only when an identity is present so single-tenant payloads
keep their existing shape. PSA/ticket-bridge receivers must get tenant routing
identity from this payload boundary, not by inferring it from webhook endpoint
configuration.
That payload boundary also owns the language-neutral `MessageKey` exposed to
custom templates. Canonical alerts derive it from `canonicalAlertKind` and the
alert type (for example `metric-threshold.disk`); legacy alert paths use the
alert type directly. The generic JSON templates must emit the message key,
event, resource type, node display name and formatted metric values explicitly
so external receivers can translate or reconstruct notifications without
parsing English message text or reaching into the metadata map.
That same transport boundary also owns outbound delivery integrity. A webhook
config may carry an optional signing secret; when present, every JSON delivery
through the canonical webhook transport must send `X-Pulse-Timestamp` and
@@ -7,6 +7,7 @@
- **Faster large-estate navigation** — Storage and platform cold loads fell from roughly 15.6 seconds to 1.1 seconds in the measured large-estate case, with windowed tables keeping scrolling responsive.
- **Lighter live updates** — The `/api/state` response fell from 4.75 MB to 4.09 MB, while changed-item catch-up and faster patch merging reduce work after reconnects and open mobile Alerts reliably.
- **Visible notification delivery** — A recent-delivery log shows redacted outcomes, and test sends now say when delivery is paused instead of implying that a message was sent.
- **Translation-ready webhooks** — Generic alert payloads include a stable message key and explicit resource context, so external systems can rebuild notifications in another language without parsing Pulse's English message.
- **Safer agent operation** — A least-privilege install profile limits the agent account and sudo access, while Docker and Kubernetes recovery controls allow deliberate re-enrolment after removal.
- **Clearer plan choices** — MSP evaluation mode supports provider trials, and the Business tier is now available for teams that need the annual business plan.
- **Simpler saved filtering** — Saved views have been removed; bookmarkable filtered URLs remain available for returning to useful table filters.
@@ -7,6 +7,9 @@ next minor release._
## Added
- Generic alert webhooks expose a stable language-neutral message key plus
explicit event, resource type, display name and formatted metric fields for
external notification translation and reconstruction.
- Notification delivery history exposes recent redacted delivery outcomes,
while test sends report when delivery is paused instead of claiming success.
- A least-privilege Unified Agent install profile creates a non-login service
+12 -19
View File
@@ -1,19 +1,15 @@
{
"version": 1,
"base_sha": "e27585b0a25bfcfc5b99d72f8dd3dcf7c668811e",
"verified_at": "2026-08-26T10:17:12Z",
"base_sha": "4d457159fc7684e7e63c9fd68b85db313300f2ed",
"verified_at": "2026-08-26T11:03:57Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/guestDrawerModel.ts",
"frontend-modern/src/hooks/useWorkloads.ts",
"frontend-modern/src/types/workloads.ts"
"frontend-modern/src/utils/alertWebhookPresentation.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/guestDrawerModel.ts": "8cf63f54a06cd0d2d0a02fbec871c553dc61d31d82724efa4dfa737ddbd5745e",
"frontend-modern/src/hooks/useWorkloads.ts": "87d048e258d71d04087d76d3fb5a3fcf109d454beaed915d1992f50ab4996c94",
"frontend-modern/src/types/workloads.ts": "b10648f1bcc689e1cd078ae1ab8f9f0651a6fab6d0106c30f0eddc797d13c249"
"frontend-modern/src/utils/alertWebhookPresentation.ts": "aaf1bc2327b6b232470569f9110159a88222e7c8cd7b0bf4e473ff0c34eb5147"
},
"routes": ["/proxmox/overview"],
"routes": ["/alerts/notifications"],
"viewports": [
{
"width": 1280,
@@ -25,17 +21,14 @@
}
],
"states": [
"checkout-web-265 VM row filtered and expanded from the Proxmox guest table",
"24-hour History tab with utilization, network I/O, and disk I/O series rendered",
"1-hour History range loaded with the same three drawable chart groups",
"narrow History layout stacked vertically without page-level horizontal overflow",
"drawer collapsed with focus returned to the workload row toggle"
"new Generic webhook editor with its default custom-payload placeholder visible",
"translation-ready variable list showing Event, MessageKey, ResourceType and NodeDisplayName",
"narrow webhook editor with no page-level horizontal overflow"
],
"interactions": [
"signed into the current local build and filtered the Proxmox guest table to checkout-web-265",
"expanded checkout-web-265 and selected History at 1280 by 800; confirmed three chart groups, seven SVG paths, and zero Collecting history placeholders",
"switched the history selector from 24 hours to 1 hour and back; both ranges retained seven drawable SVG paths with no collection placeholder",
"rechecked the open History drawer at 390 by 844; confirmed the three charts stack vertically and the 390-pixel page has no horizontal overflow",
"restored the viewport override, collapsed checkout-web-265, and confirmed focus returned to the row expansion control"
"signed into the current local mock build and opened Alerts, Notifications, then Add Webhook",
"confirmed the Generic payload placeholder includes event, message_key and resource_type at desktop width",
"confirmed the available-variable help exposes MessageKey, ResourceType and NodeDisplayName",
"rechecked the open editor at 390 by 844 and measured document scroll width equal to the 390-pixel viewport"
]
}
+6 -2
View File
@@ -28,8 +28,8 @@ Pulse includes built-in templates for popular services and a generic JSON templa
For generic webhooks, use Go templates to format the JSON payload.
**Variables (common):**
- `{{.ID}}`, `{{.Level}}`, `{{.Type}}`
- `{{.ResourceName}}`, `{{.ResourceID}}`, `{{.ResourceType}}`, `{{.Node}}`
- `{{.ID}}`, `{{.Event}}`, `{{.MessageKey}}`, `{{.Level}}`, `{{.Type}}`
- `{{.ResourceName}}`, `{{.ResourceID}}`, `{{.ResourceType}}`, `{{.Node}}`, `{{.NodeDisplayName}}`
- `{{.Message}}`, `{{.Value}}`, `{{.Threshold}}`, `{{.Duration}}`, `{{.Timestamp}}`
- `{{.Instance}}` (Pulse public URL if configured)
- `{{.TenantID}}`, `{{.TenantName}}` (tenant identity in multi-tenant orgs and MSP client runtimes; empty on plain single-tenant installs)
@@ -71,6 +71,8 @@ These fields and behaviors are stable; ticket-routing integrations can rely on t
**Alert type.** `{{.Type}}` is the metric or condition that fired: `cpu`, `memory`, `disk`, `diskRead`, `diskWrite`, `networkIn`, `networkOut`, `connectivity`, and similar. The alert ID (`{{.ID}}`) is stable for the lifetime of an alert occurrence, so the `resolved` event carries the same ID as the `alert` event it closes.
**Message key.** `{{.MessageKey}}` is the stable, language-neutral condition key for rebuilding or translating a notification. Canonical alerts use `<kind>.<type>` (for example, `metric-threshold.disk`); older alert paths fall back to `{{.Type}}`. Combine it with `{{.Event}}` and `{{.ResourceType}}` when the receiving system needs separate wording for firing/recovery events or different resource classes. Unlike a numeric message index, the symbolic key does not change when another alert type is added.
**Tenant identity.** In multi-tenant organizations and MSP client runtimes, `{{.TenantID}}` and `{{.TenantName}}` identify which tenant fired the alert. Client runtimes get identity from the `PULSE_TENANT_ID` / `PULSE_TENANT_NAME` environment; shared-process organizations stamp the org ID and display name automatically.
**Resource tag routing.** Email and each alert webhook can be limited to resources with selected tags in **Alerts → Notifications**. An empty filter receives every alert. With multiple tags, choose **Match all tags** or **Match any tag**. Matching ignores case. Proxmox tags are matched as shown; Docker container and service labels are exposed as `key:value` tags (or `key` when the label value is empty). Recovery notifications follow the destinations that received the firing alert, even if a resource's tags change before recovery.
@@ -192,11 +194,13 @@ stable severity/type fields from the [delivery contract](#-delivery-contract):
{
"event": "{{.Event}}",
"alertId": "{{.ID | jsonString}}",
"messageKey": "{{.MessageKey | jsonString}}",
"severity": "{{.Level | jsonString}}",
"alertType": "{{.Type | jsonString}}",
"tenantId": "{{.TenantID | jsonString}}",
"tenantName": "{{.TenantName | jsonString}}",
"resource": "{{.ResourceName | jsonString}}",
"resourceType": "{{.ResourceType | jsonString}}",
"node": "{{.Node | jsonString}}",
"summary": "{{.Message | jsonString}}",
"value": {{.Value}},
@@ -1,4 +1,6 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { extractChangelog, extractHighlights, isReleaseVersion } from '../whatsNewModel';
import whatsNewCardSource from '../WhatsNewCard.tsx?raw';
@@ -261,3 +263,19 @@ describe('post-update telemetry disclosure', () => {
expect(whatsNewCardSource).not.toContain('setDialogVisible(true);\n markVersionSeen');
});
});
describe('current candidate notification packet', () => {
it('keeps translation-ready webhook support in both release summaries', () => {
const releaseNotes = readFileSync(
path.resolve(process.cwd(), '../docs/releases/RELEASE_NOTES_v6.4.0-rc.3.md'),
'utf8',
);
const changelog = readFileSync(
path.resolve(process.cwd(), '../docs/releases/V6_CHANGELOG_v6.4.0-rc.3.md'),
'utf8',
);
expect(releaseNotes).toContain('Translation-ready webhooks');
expect(changelog).toContain('stable language-neutral message key');
});
});
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
ALERT_WEBHOOK_PAYLOAD_TEMPLATE_PLACEHOLDER,
ALERT_WEBHOOK_PAYLOAD_VARIABLES,
ALERT_WEBHOOK_TEST_FAILURE,
ALERT_WEBHOOK_TEST_SUCCESS,
getAlertWebhookServices,
@@ -15,6 +17,18 @@ import {
} from '@/utils/alertWebhookPresentation';
describe('alertWebhookPresentation', () => {
it('advertises translation-ready generic webhook variables', () => {
expect(ALERT_WEBHOOK_PAYLOAD_TEMPLATE_PLACEHOLDER).toContain(
'"message_key": "{{.MessageKey}}"',
);
expect(ALERT_WEBHOOK_PAYLOAD_TEMPLATE_PLACEHOLDER).toContain(
'"resource_type": "{{.ResourceType}}"',
);
expect(ALERT_WEBHOOK_PAYLOAD_VARIABLES).toContain('{{.MessageKey}}');
expect(ALERT_WEBHOOK_PAYLOAD_VARIABLES).toContain('{{.ResourceType}}');
expect(ALERT_WEBHOOK_PAYLOAD_VARIABLES).toContain('{{.NodeDisplayName}}');
});
it('returns canonical webhook test-result copy', () => {
expect(ALERT_WEBHOOK_TEST_SUCCESS).toBe('Test webhook sent successfully!');
expect(ALERT_WEBHOOK_TEST_FAILURE).toBe('Failed to send test webhook');
@@ -112,13 +112,16 @@ export const ALERT_WEBHOOK_MENTION_HELP_LABEL = 'Optional — tag users or group
export const ALERT_WEBHOOK_MENTION_FALLBACK_PLACEHOLDER = '@everyone';
export const ALERT_WEBHOOK_PAYLOAD_HELP_LABEL = 'Optional — leave empty to use default';
export const ALERT_WEBHOOK_PAYLOAD_TEMPLATE_PLACEHOLDER = `{
"event": "{{.Event}}",
"message_key": "{{.MessageKey}}",
"text": "Alert: {{.Level}} - {{.Message}}",
"resource_type": "{{.ResourceType}}",
"resource": "{{.ResourceName}}",
"value": {{.Value}},
"threshold": {{.Threshold}}
}`;
export const ALERT_WEBHOOK_PAYLOAD_VARIABLES =
'{{.ID}}, {{.Level}}, {{.Type}}, {{.ResourceName}}, {{.Node}}, {{.Message}}, {{.Value}}, {{.Threshold}}, {{.Duration}}, {{.Timestamp}}';
'{{.ID}}, {{.Event}}, {{.MessageKey}}, {{.Level}}, {{.Type}}, {{.ResourceType}}, {{.ResourceName}}, {{.Node}}, {{.NodeDisplayName}}, {{.Message}}, {{.Value}}, {{.Threshold}}, {{.Duration}}, {{.Timestamp}}';
export const ALERT_WEBHOOK_CUSTOM_FIELDS_HELP = 'Available as';
export const ALERT_WEBHOOK_CUSTOM_FIELDS_REFERENCE = '{{.CustomFields.<name>}}';
export const ALERT_WEBHOOK_CUSTOM_FIELD_KEY_PLACEHOLDER = 'Field name';
+8
View File
@@ -3052,10 +3052,17 @@ func (n *NotificationManager) prepareWebhookData(alert *alerts.Alert, customFiel
}
resourceType := ""
messageKey := strings.TrimSpace(alert.Type)
if alert.Metadata != nil {
if rt, ok := alert.Metadata["resourceType"].(string); ok {
resourceType = rt
}
if kind, ok := alert.Metadata["canonicalAlertKind"].(string); ok && strings.TrimSpace(kind) != "" {
messageKey = strings.TrimSpace(kind)
if alertType := strings.TrimSpace(alert.Type); alertType != "" {
messageKey += "." + alertType
}
}
}
var metadataCopy map[string]interface{}
@@ -3079,6 +3086,7 @@ func (n *NotificationManager) prepareWebhookData(alert *alerts.Alert, customFiel
return WebhookPayloadData{
ID: alert.ID,
MessageKey: messageKey,
Level: string(alert.Level),
Type: alert.Type,
ResourceName: alert.ResourceName,
@@ -2293,6 +2293,35 @@ func TestPrepareWebhookData(t *testing.T) {
}
})
t.Run("builds a language-neutral key from canonical alert identity", func(t *testing.T) {
nm := &NotificationManager{}
alert := &alerts.Alert{
ID: "test-1",
Type: "disk",
StartTime: time.Now(),
Metadata: map[string]interface{}{
"canonicalAlertKind": "metric-threshold",
},
}
result := nm.prepareWebhookData(alert, nil)
if result.MessageKey != "metric-threshold.disk" {
t.Fatalf("expected canonical message key, got %q", result.MessageKey)
}
})
t.Run("uses alert type as the message key for legacy alert paths", func(t *testing.T) {
nm := &NotificationManager{}
alert := &alerts.Alert{ID: "test-1", Type: "connectivity", StartTime: time.Now()}
result := nm.prepareWebhookData(alert, nil)
if result.MessageKey != "connectivity" {
t.Fatalf("expected legacy message key, got %q", result.MessageKey)
}
})
t.Run("resourceType empty when not in metadata", func(t *testing.T) {
nm := &NotificationManager{}
alert := &alerts.Alert{
+15
View File
@@ -438,6 +438,21 @@ func TestGetWebhookTemplates_GenericSettings(t *testing.T) {
if generic.Method != "POST" {
t.Errorf("Generic Method = %q, want POST", generic.Method)
}
for _, field := range []string{
`"event": "{{.Event | jsonString}}"`,
`"message_key": "{{.MessageKey | jsonString}}"`,
`"resource_type": "{{.ResourceType | jsonString}}"`,
`"node_display_name": "{{.NodeDisplayName | jsonString}}"`,
`"value_formatted": "{{.ValueFormatted | jsonString}}"`,
`"threshold_formatted": "{{.ThresholdFormatted | jsonString}}"`,
} {
if !strings.Contains(generic.PayloadTemplate, field) {
t.Errorf("Generic PayloadTemplate missing %s", field)
}
}
if !strings.Contains(generic.ResolvedPayloadTemplate, `"message_key": "{{.MessageKey | jsonString}}"`) {
t.Error("Generic ResolvedPayloadTemplate missing message_key")
}
}
func TestGetWebhookTemplates_PayloadTemplatesHaveRequiredFields(t *testing.T) {
@@ -40,6 +40,7 @@ type WebhookFilterRules struct {
type WebhookPayloadData struct {
// Alert fields
ID string
MessageKey string
Level string
Type string
ResourceName string
@@ -534,15 +534,21 @@ View in Pulse: {{.Instance}}`,
Method: "POST",
Headers: map[string]string{"Content-Type": "application/json"},
PayloadTemplate: `{
"event": "{{.Event | jsonString}}",
"alert": {
"id": "{{.ID | jsonString}}",
"message_key": "{{.MessageKey | jsonString}}",
"level": "{{.Level | jsonString}}",
"type": "{{.Type | jsonString}}",
"resource_type": "{{.ResourceType | jsonString}}",
"resource_name": "{{.ResourceName | jsonString}}",
"node": "{{.Node | jsonString}}",
"node_display_name": "{{.NodeDisplayName | jsonString}}",
"message": "{{.Message | jsonString}}",
"value": {{.Value}},
"value_formatted": "{{.ValueFormatted | jsonString}}",
"threshold": {{.Threshold}},
"threshold_formatted": "{{.ThresholdFormatted | jsonString}}",
"start_time": "{{.StartTime | jsonString}}",
"duration": "{{.Duration | jsonString}}"
},{{if .TenantID}}
@@ -557,9 +563,12 @@ View in Pulse: {{.Instance}}`,
"event": "resolved",
"alert": {
"id": "{{.ID | jsonString}}",
"message_key": "{{.MessageKey | jsonString}}",
"type": "{{.Type | jsonString}}",
"resource_type": "{{.ResourceType | jsonString}}",
"resource_name": "{{.ResourceName | jsonString}}",
"node": "{{.Node | jsonString}}",
"node_display_name": "{{.NodeDisplayName | jsonString}}",
"message": "{{.Message | jsonString}}",
"start_time": "{{.StartTime | jsonString}}",
"duration": "{{.Duration | jsonString}}"