Centralize alert webhook service metadata

This commit is contained in:
rcourtman 2026-03-19 05:10:52 +00:00
parent 062721f020
commit 3aea37c3d2
9 changed files with 198 additions and 101 deletions

View file

@ -69,6 +69,10 @@ The alert webhook editor now mirrors that canonical Pushover field rule through
same alias and preset mapping instead of carrying its own local webhook-field
normalization fork.
The alert webhook service chooser also now derives its service set from the
backend webhook template registry, rather than keeping a second frontend-only
list of services and descriptions.
Frontend alert surfaces and backend alert-support files now require explicit
registry path-policy coverage, so new alert-owned runtime files must be mapped
to a concrete proof route instead of silently inheriting subsystem-default

View file

@ -68,13 +68,19 @@ webhook delivery now also share one owned rendering path for URL rendering,
service-specific enrichment, and template selection. When persistent queue
bootstrap fails, the runtime now falls back to an in-memory queue owner
instead of dropping into a separate nil-queue direct-send mode. Service-specific
webhook compatibility like Pushover `app_token` / `user_token` legacy fields is
webhook compatibility like Pushover `app_token` / `user_token` legacy fields is
now canonicalized at webhook-config ownership boundaries, so runtime delivery
only handles canonical `token` / `user` fields instead of injecting aliases
mid-flight. That boundary includes config persistence plus API/UI ingress for
create, update, and ad hoc test requests; `internal/notifications/` may not
silently rewrite those legacy keys once webhook state is already live in the
runtime.
The webhook template registry is also the canonical source of truth for the
alert webhook service set and the metadata the frontend uses to build its
service chooser. Frontend presentation may format the options, but it must
derive the available services from the backend template registry instead of
keeping a second hardcoded service list.
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

@ -40,6 +40,7 @@ function makeWebhook(overrides: Partial<Webhook> = {}): Webhook {
const discordTemplate = {
service: 'discord',
name: 'Discord Webhook',
description: 'Discord server webhook',
urlPattern: 'https://discord.com/api/webhooks/...',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -50,6 +51,7 @@ const discordTemplate = {
const slackTemplate = {
service: 'slack',
name: 'Slack Webhook',
description: 'Slack incoming webhook',
urlPattern: 'https://hooks.slack.com/services/...',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -60,6 +62,7 @@ const slackTemplate = {
const genericTemplate = {
service: 'generic',
name: 'Generic Webhook',
description: 'Custom webhook endpoint',
urlPattern: 'https://example.com/webhook',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -67,7 +70,18 @@ const genericTemplate = {
instructions: '',
};
const mockTemplates = [genericTemplate, discordTemplate, slackTemplate];
const pushoverTemplate = {
service: 'pushover',
name: 'Pushover',
description: 'Mobile push notifications',
urlPattern: 'https://api.pushover.net/1/messages.json',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
payloadTemplate: '',
instructions: 'Create an application at https://pushover.net/apps',
};
const mockTemplates = [genericTemplate, discordTemplate, slackTemplate, pushoverTemplate];
// --- Tests ---
describe('WebhookConfig', () => {
@ -663,7 +677,7 @@ describe('WebhookConfig', () => {
// --- Service selection ---
it('toggles service dropdown when service type button is clicked', () => {
it('toggles service dropdown when service type button is clicked', async () => {
render(() => (
<WebhookConfig
webhooks={[]}
@ -680,14 +694,14 @@ describe('WebhookConfig', () => {
const serviceBtn = screen.getByText(/Generic →/);
fireEvent.click(serviceBtn);
await waitFor(() => {
expect(screen.getByText('Discord')).toBeInTheDocument();
});
// Service options should appear
expect(screen.getByText('Discord')).toBeInTheDocument();
expect(screen.getByText('Slack')).toBeInTheDocument();
expect(screen.getByText('Telegram')).toBeInTheDocument();
expect(screen.getByText('Microsoft Teams')).toBeInTheDocument();
expect(screen.getByText('PagerDuty')).toBeInTheDocument();
expect(screen.getByText('Pushover')).toBeInTheDocument();
expect(screen.getByText('Gotify')).toBeInTheDocument();
});
it('applies template settings when selecting a service', async () => {
@ -713,6 +727,10 @@ describe('WebhookConfig', () => {
const serviceBtn = screen.getByText(/Generic →/);
fireEvent.click(serviceBtn);
await waitFor(() => {
expect(screen.getByText('Discord')).toBeInTheDocument();
});
// Select Discord
const discordBtn = screen.getByText('Discord server webhook').closest('button')!;
fireEvent.click(discordBtn);
@ -753,6 +771,10 @@ describe('WebhookConfig', () => {
const serviceBtn = screen.getByText(/Generic →/);
fireEvent.click(serviceBtn);
await waitFor(() => {
expect(screen.getByText('Discord')).toBeInTheDocument();
});
const discordBtn = screen.getByText('Discord server webhook').closest('button')!;
fireEvent.click(discordBtn);
@ -784,6 +806,10 @@ describe('WebhookConfig', () => {
const serviceBtn = screen.getByText(/Generic →/);
fireEvent.click(serviceBtn);
await waitFor(() => {
expect(screen.getByText('Discord')).toBeInTheDocument();
});
const discordBtn = screen.getByText('Discord server webhook').closest('button')!;
fireEvent.click(discordBtn);
@ -851,6 +877,10 @@ describe('WebhookConfig', () => {
const serviceBtn = screen.getByText(/Generic →/);
fireEvent.click(serviceBtn);
await waitFor(() => {
expect(screen.getByText('Discord')).toBeInTheDocument();
});
const discordBtn = screen.getByText('Discord server webhook').closest('button')!;
fireEvent.click(discordBtn);
@ -1160,6 +1190,9 @@ describe('WebhookConfig', () => {
});
fireEvent.click(screen.getByText(/Generic →/));
await waitFor(() => {
expect(screen.getByText('Pushover')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Pushover').closest('button')!);
expect(screen.getByText('Application Token')).toBeInTheDocument();
@ -1295,6 +1328,10 @@ describe('WebhookConfig', () => {
const serviceBtn = screen.getByText(/Generic →/);
fireEvent.click(serviceBtn);
await waitFor(() => {
expect(screen.getByText('Discord')).toBeInTheDocument();
});
const discordBtn = screen.getByText('Discord server webhook').closest('button')!;
fireEvent.click(discordBtn);
@ -1432,6 +1469,10 @@ describe('WebhookConfig', () => {
const serviceBtn = screen.getByText(/Generic →/);
fireEvent.click(serviceBtn);
await waitFor(() => {
expect(screen.getByText('Discord')).toBeInTheDocument();
});
const discordBtn = screen.getByText('Discord server webhook').closest('button')!;
fireEvent.click(discordBtn);

View file

@ -53,6 +53,7 @@ import {
interface WebhookTemplate {
service: string;
name: string;
description?: string;
urlPattern: string;
method: string;
headers: Record<string, string>;
@ -446,7 +447,7 @@ export function WebhookConfig(props: WebhookConfigProps) {
<Show when={showServiceDropdown()}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 border border-border px-3 py-2 mb-3 text-xs">
<For each={getAlertWebhookServices()}>
<For each={getAlertWebhookServices(templates())}>
{(service) => (
<button
type="button"

View file

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
ALERT_WEBHOOK_TEST_FAILURE,
ALERT_WEBHOOK_TEST_SUCCESS,
getAlertWebhookServices,
getAlertWebhookCustomFieldPresets,
getAlertWebhookTestFailure,
getAlertWebhookTestSuccess,
@ -54,4 +55,42 @@ describe('alertWebhookPresentation', () => {
user_token: 'legacy-user',
});
});
it('derives webhook service options from the backend template registry', () => {
expect(
getAlertWebhookServices([
{
service: 'discord',
name: 'Discord Webhook',
description: 'Discord server webhook',
urlPattern: 'https://discord.com/api/webhooks/.../...',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
payloadTemplate: '',
instructions: '',
},
{
service: 'generic',
name: 'Generic Webhook',
description: 'Custom webhook endpoint',
urlPattern: '',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
payloadTemplate: '',
instructions: '',
},
]),
).toEqual([
{
id: 'discord',
label: 'Discord',
description: 'Discord server webhook',
},
{
id: 'generic',
label: 'Generic',
description: 'Custom webhook endpoint',
},
]);
});
});

View file

@ -2975,7 +2975,7 @@ describe('frontend resource type boundaries', () => {
});
it('keeps alert webhook service vocabulary and action copy in a shared presentation utility', () => {
expect(webhookConfigSource).toContain('getAlertWebhookServices');
expect(webhookConfigSource).toContain('getAlertWebhookServices(templates())');
expect(webhookConfigSource).toContain('getAlertWebhookServiceLabel');
expect(webhookConfigSource).toContain('getAlertWebhookCustomFieldPresets');
expect(webhookConfigSource).toContain('normalizeAlertWebhookCustomFields');
@ -3000,13 +3000,13 @@ describe('frontend resource type boundaries', () => {
expect(webhookConfigSource).not.toContain('Enable All');
expect(webhookConfigSource).not.toContain('Disable All');
expect(webhookConfigSource).not.toContain('Enable this webhook');
expect(alertWebhookPresentationSource).toContain('export const ALERT_WEBHOOK_SERVICES');
expect(alertWebhookPresentationSource).toContain(
'export function getAlertWebhookServiceLabel',
);
expect(alertWebhookPresentationSource).toContain(
'export function getAlertWebhookServices',
);
expect(alertWebhookPresentationSource).not.toContain('ALERT_WEBHOOK_SERVICES');
expect(alertWebhookPresentationSource).toContain(
'export function getAlertWebhookCustomFieldPresets',
);

View file

@ -1,22 +1,20 @@
export const ALERT_WEBHOOK_SERVICES = [
'generic',
'discord',
'slack',
'mattermost',
'telegram',
'teams',
'teams-adaptive',
'pagerduty',
'pushover',
'gotify',
'ntfy',
] as const;
import type { WebhookTemplate as AlertWebhookTemplate } from '@/api/notifications';
type AlertWebhookService = (typeof ALERT_WEBHOOK_SERVICES)[number];
type AlertWebhookService =
| 'generic'
| 'discord'
| 'slack'
| 'mattermost'
| 'telegram'
| 'teams'
| 'teams-adaptive'
| 'pagerduty'
| 'pushover'
| 'gotify'
| 'ntfy';
type AlertWebhookServicePresentation = {
label: string;
description: string;
mentionPlaceholder?: string;
mentionHelp?: string;
};
@ -34,57 +32,46 @@ const ALERT_WEBHOOK_SERVICE_PRESENTATION: Record<
> = {
generic: {
label: 'Generic',
description: 'Custom webhook endpoint',
},
discord: {
label: 'Discord',
description: 'Discord server webhook',
mentionPlaceholder: '@everyone or <@USER_ID> or <@&ROLE_ID>',
mentionHelp: 'Discord: Use @everyone, @here, <@USER_ID>, or <@&ROLE_ID>',
},
slack: {
label: 'Slack',
description: 'Slack incoming webhook',
mentionPlaceholder: '@channel, @here, or <@USER_ID>',
mentionHelp: 'Slack: Use @channel, @here, <@USER_ID>, or <!subteam^ID>',
},
mattermost: {
label: 'Mattermost',
description: 'Mattermost incoming webhook',
mentionPlaceholder: '@channel, @all, or @username',
mentionHelp: 'Mattermost: Use @channel, @all, or @username',
},
telegram: {
label: 'Telegram',
description: 'Telegram bot notifications',
},
teams: {
label: 'Microsoft Teams',
description: 'Microsoft Teams webhook',
mentionPlaceholder: '@General or user email',
mentionHelp: 'Teams: Use channel names like @General',
},
'teams-adaptive': {
label: 'Teams (Adaptive)',
description: 'Teams with Adaptive Cards',
mentionPlaceholder: '@General or user email',
mentionHelp: 'Teams: Use channel names like @General',
},
pagerduty: {
label: 'PagerDuty',
description: 'PagerDuty Events API v2',
},
pushover: {
label: 'Pushover',
description: 'Mobile push notifications',
},
gotify: {
label: 'Gotify',
description: 'Self-hosted push notifications',
},
ntfy: {
label: 'ntfy',
description: 'Push notifications via ntfy.sh',
},
};
@ -163,11 +150,11 @@ export const ALERT_WEBHOOK_DISABLED_LABEL = 'Disabled';
export const ALERT_WEBHOOK_DISABLE_ALL_LABEL = 'Disable All';
export const ALERT_WEBHOOK_ENABLE_ALL_LABEL = 'Enable All';
export function getAlertWebhookServices() {
return ALERT_WEBHOOK_SERVICES.map((service) => ({
id: service,
label: ALERT_WEBHOOK_SERVICE_PRESENTATION[service].label,
description: ALERT_WEBHOOK_SERVICE_PRESENTATION[service].description,
export function getAlertWebhookServices(templates: AlertWebhookTemplate[] = []) {
return templates.map((template) => ({
id: template.service,
label: getAlertWebhookServiceLabel(template.service),
description: template.description || template.name,
}));
}
@ -175,13 +162,6 @@ export function getAlertWebhookServiceLabel(service: string) {
return ALERT_WEBHOOK_SERVICE_PRESENTATION[service as AlertWebhookService]?.label || service;
}
export function getAlertWebhookServiceDescription(service: string) {
return (
ALERT_WEBHOOK_SERVICE_PRESENTATION[service as AlertWebhookService]?.description ||
ALERT_WEBHOOK_SERVICE_PRESENTATION.pagerduty.description
);
}
export function getAlertWebhookCustomFieldPresets(service: string) {
return ALERT_WEBHOOK_CUSTOM_FIELD_PRESETS[service.trim().toLowerCase()];
}

View file

@ -179,6 +179,11 @@ func TestGetWebhookTemplates(t *testing.T) {
t.Errorf("Template %q has empty Name", tmpl.Service)
}
// Description must be non-empty so the frontend can derive the service chooser
if tmpl.Description == "" {
t.Errorf("Template %q has empty Description", tmpl.Service)
}
// Method must be valid HTTP method
validMethods := map[string]bool{"GET": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true}
if !validMethods[tmpl.Method] {
@ -228,6 +233,15 @@ func TestGetWebhookTemplates_KnownServices(t *testing.T) {
}
}
func TestGetWebhookTemplates_Descriptions(t *testing.T) {
templates := GetWebhookTemplates()
for _, tmpl := range templates {
if tmpl.Description == "" {
t.Fatalf("template %q has empty description", tmpl.Service)
}
}
}
func TestGetWebhookTemplates_DiscordSettings(t *testing.T) {
templates := GetWebhookTemplates()

View file

@ -4,6 +4,7 @@ package notifications
type WebhookTemplate struct {
Service string `json:"service"`
Name string `json:"name"`
Description string `json:"description"`
URLPattern string `json:"urlPattern"`
Method string `json:"method"`
Headers map[string]string `json:"headers"`
@ -16,11 +17,12 @@ type WebhookTemplate struct {
func GetWebhookTemplates() []WebhookTemplate {
return []WebhookTemplate{
{
Service: "discord",
Name: "Discord Webhook",
URLPattern: "https://discord.com/api/webhooks/{webhook_id}/{webhook_token}",
Method: "POST",
Headers: map[string]string{"Content-Type": "application/json"},
Service: "discord",
Name: "Discord Webhook",
Description: "Discord server webhook",
URLPattern: "https://discord.com/api/webhooks/{webhook_id}/{webhook_token}",
Method: "POST",
Headers: map[string]string{"Content-Type": "application/json"},
PayloadTemplate: `{
"username": "Pulse Monitoring",
{{if .Mention}}"content": "{{.Mention}}",{{end}}
@ -64,11 +66,12 @@ func GetWebhookTemplates() []WebhookTemplate {
Instructions: "1. In Discord, go to Server Settings > Integrations > Webhooks\n2. Create a new webhook and copy the URL\n3. Paste the URL here (format: https://discord.com/api/webhooks/...)\n4. Optional: Add a mention in the Mention field (e.g., @everyone, <@USER_ID>, <@&ROLE_ID>)",
},
{
Service: "telegram",
Name: "Telegram Bot",
URLPattern: "https://api.telegram.org/bot{bot_token}/sendMessage",
Method: "POST",
Headers: map[string]string{"Content-Type": "application/json"},
Service: "telegram",
Name: "Telegram Bot",
Description: "Telegram bot notifications",
URLPattern: "https://api.telegram.org/bot{bot_token}/sendMessage",
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}})",
@ -84,11 +87,12 @@ func GetWebhookTemplates() []WebhookTemplate {
Instructions: "1. Create a bot with @BotFather on Telegram\n2. Get your bot token\n3. Get your chat ID by messaging the bot and visiting: https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates\n4. URL format: https://api.telegram.org/bot<BOT_TOKEN>/sendMessage?chat_id=<CHAT_ID>\n5. IMPORTANT: You MUST include ?chat_id=YOUR_CHAT_ID in the URL",
},
{
Service: "slack",
Name: "Slack Incoming Webhook",
URLPattern: "https://hooks.slack.com/services/{webhook_path}",
Method: "POST",
Headers: map[string]string{"Content-Type": "application/json"},
Service: "slack",
Name: "Slack Incoming Webhook",
Description: "Slack incoming webhook",
URLPattern: "https://hooks.slack.com/services/{webhook_path}",
Method: "POST",
Headers: map[string]string{"Content-Type": "application/json"},
PayloadTemplate: `{
"text": "{{if .Mention}}{{.Mention}} {{end}}Pulse Alert: {{.Level | title}} - {{.ResourceName}}",
"blocks": [
@ -178,11 +182,12 @@ func GetWebhookTemplates() []WebhookTemplate {
Instructions: "1. In Slack, go to Apps > Incoming Webhooks\n2. Add to Slack and choose a channel\n3. Copy the webhook URL and paste it here (format: https://hooks.slack.com/services/...)\n4. Optional: Add a mention in the Mention field (e.g., @channel, @here, <@USER_ID>, <!subteam^ID>)",
},
{
Service: "teams",
Name: "Microsoft Teams",
URLPattern: "https://{tenant}.webhook.office.com/webhookb2/{webhook_path}",
Method: "POST",
Headers: map[string]string{"Content-Type": "application/json"},
Service: "teams",
Name: "Microsoft Teams",
Description: "Microsoft Teams webhook",
URLPattern: "https://{tenant}.webhook.office.com/webhookb2/{webhook_path}",
Method: "POST",
Headers: map[string]string{"Content-Type": "application/json"},
PayloadTemplate: `{
"@type": "MessageCard",
"@context": "http://schema.org/extensions",
@ -233,10 +238,11 @@ func GetWebhookTemplates() []WebhookTemplate {
Instructions: "1. In Teams channel, click ... > Connectors\n2. Configure Incoming Webhook\n3. Copy the URL and paste it here\n4. Optional: Add a mention in the Mention field (e.g., @General)\n\nNote: MessageCard format is supported until December 2025. For new implementations, consider using Adaptive Cards.",
},
{
Service: "pagerduty",
Name: "PagerDuty Events API v2",
URLPattern: "https://events.pagerduty.com/v2/enqueue",
Method: "POST",
Service: "pagerduty",
Name: "PagerDuty Events API v2",
Description: "PagerDuty Events API v2",
URLPattern: "https://events.pagerduty.com/v2/enqueue",
Method: "POST",
Headers: map[string]string{
"Content-Type": "application/json",
"Accept": "application/vnd.pagerduty+json;version=2",
@ -277,11 +283,12 @@ func GetWebhookTemplates() []WebhookTemplate {
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.",
},
{
Service: "teams-adaptive",
Name: "Microsoft Teams (Adaptive Card)",
URLPattern: "https://{tenant}.webhook.office.com/webhookb2/{webhook_path}",
Method: "POST",
Headers: map[string]string{"Content-Type": "application/json"},
Service: "teams-adaptive",
Name: "Microsoft Teams (Adaptive Card)",
Description: "Teams with Adaptive Cards",
URLPattern: "https://{tenant}.webhook.office.com/webhookb2/{webhook_path}",
Method: "POST",
Headers: map[string]string{"Content-Type": "application/json"},
PayloadTemplate: `{
"type": "message",
"attachments": [{
@ -365,11 +372,12 @@ func GetWebhookTemplates() []WebhookTemplate {
Instructions: "1. In Teams channel, click ... > Connectors\n2. Configure Incoming Webhook\n3. Copy the URL and paste it here\n\nThis uses the modern Adaptive Card format recommended for new implementations.",
},
{
Service: "pushover",
Name: "Pushover",
URLPattern: "https://api.pushover.net/1/messages.json",
Method: "POST",
Headers: map[string]string{"Content-Type": "application/json"},
Service: "pushover",
Name: "Pushover",
Description: "Mobile push notifications",
URLPattern: "https://api.pushover.net/1/messages.json",
Method: "POST",
Headers: map[string]string{"Content-Type": "application/json"},
PayloadTemplate: `{
"token": "{{.CustomFields.token}}",
"user": "{{.CustomFields.user}}",
@ -392,11 +400,12 @@ func GetWebhookTemplates() []WebhookTemplate {
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)",
},
{
Service: "gotify",
Name: "Gotify",
URLPattern: "https://{your-gotify-server}/message?token={your-app-token}",
Method: "POST",
Headers: map[string]string{"Content-Type": "application/json"},
Service: "gotify",
Name: "Gotify",
Description: "Self-hosted push notifications",
URLPattern: "https://{your-gotify-server}/message?token={your-app-token}",
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",
@ -439,10 +448,11 @@ func GetWebhookTemplates() []WebhookTemplate {
Instructions: "1. In Gotify, create a new application\n2. Copy the application token\n3. URL format: https://your-gotify-server/message?token=YOUR_APP_TOKEN\n4. The token must be included in the URL as a parameter",
},
{
Service: "ntfy",
Name: "ntfy.sh",
URLPattern: "https://ntfy.sh/{topic}",
Method: "POST",
Service: "ntfy",
Name: "ntfy.sh",
Description: "Push notifications via ntfy.sh",
URLPattern: "https://ntfy.sh/{topic}",
Method: "POST",
Headers: map[string]string{
"Content-Type": "text/plain",
// Note: Title, Priority, and Tags headers should be added dynamically based on alert level
@ -465,11 +475,12 @@ View in Pulse: {{.Instance}}`,
Instructions: "1. Choose a topic name (e.g., 'my-pulse-alerts')\n2. URL format: https://ntfy.sh/YOUR_TOPIC\n Or for self-hosted: https://your-ntfy-server/YOUR_TOPIC\n3. For authentication, add a custom header:\n • Header Name: Authorization\n • Header Value: Bearer YOUR_TOKEN (or Basic base64_encoded_credentials)\n4. Subscribe to the topic in your ntfy app using the same topic name",
},
{
Service: "mattermost",
Name: "Mattermost Incoming Webhook",
URLPattern: "https://{your-mattermost-server}/hooks/{webhook_id}",
Method: "POST",
Headers: map[string]string{"Content-Type": "application/json"},
Service: "mattermost",
Name: "Mattermost Incoming Webhook",
Description: "Mattermost incoming webhook",
URLPattern: "https://{your-mattermost-server}/hooks/{webhook_id}",
Method: "POST",
Headers: map[string]string{"Content-Type": "application/json"},
PayloadTemplate: `{
"username": "Pulse Monitoring",
"icon_url": "https://raw.githubusercontent.com/rcourtman/Pulse/main/frontend-modern/public/android-chrome-192x192.png",
@ -483,11 +494,12 @@ View in Pulse: {{.Instance}}`,
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.",
},
{
Service: "generic",
Name: "Generic JSON Webhook",
URLPattern: "",
Method: "POST",
Headers: map[string]string{"Content-Type": "application/json"},
Service: "generic",
Name: "Generic JSON Webhook",
Description: "Custom webhook endpoint",
URLPattern: "",
Method: "POST",
Headers: map[string]string{"Content-Type": "application/json"},
PayloadTemplate: `{
"alert": {
"id": "{{.ID}}",