diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index f6bde60fd9..491e1917eb 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/notifications.md b/docs/release-control/v6/internal/subsystems/notifications.md index 7fed75358d..7169592c40 100644 --- a/docs/release-control/v6/internal/subsystems/notifications.md +++ b/docs/release-control/v6/internal/subsystems/notifications.md @@ -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 diff --git a/frontend-modern/src/components/Alerts/WebhookConfig.test.tsx b/frontend-modern/src/components/Alerts/WebhookConfig.test.tsx index f85bcf67e2..197efd9b5b 100644 --- a/frontend-modern/src/components/Alerts/WebhookConfig.test.tsx +++ b/frontend-modern/src/components/Alerts/WebhookConfig.test.tsx @@ -40,6 +40,7 @@ function makeWebhook(overrides: Partial = {}): 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(() => ( { 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); diff --git a/frontend-modern/src/components/Alerts/WebhookConfig.tsx b/frontend-modern/src/components/Alerts/WebhookConfig.tsx index 61b04a250c..c2683cba47 100644 --- a/frontend-modern/src/components/Alerts/WebhookConfig.tsx +++ b/frontend-modern/src/components/Alerts/WebhookConfig.tsx @@ -53,6 +53,7 @@ import { interface WebhookTemplate { service: string; name: string; + description?: string; urlPattern: string; method: string; headers: Record; @@ -446,7 +447,7 @@ export function WebhookConfig(props: WebhookConfigProps) {
- + {(service) => (