diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 3a1d89d17d..5154315123 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -76,6 +76,15 @@ backend webhook template registry, rather than keeping a second frontend-only list of services, labels, descriptions, and mention-copy metadata. The WebhookConfig editor now imports the shared webhook template API type directly so it does not retain a local duplicate shape for chooser metadata. +That webhook editor now also keeps runtime ownership in +`frontend-modern/src/components/Alerts/useWebhookConfigState.ts`, while +`frontend-modern/src/components/Alerts/WebhookConfigList.tsx` owns the +existing-webhook list surface and +`frontend-modern/src/components/Alerts/WebhookConfigForm.tsx` owns the +add/edit form surface. Future webhook template loading, form normalization, +custom-field preset handling, or webhook editor state transitions should land +in those owners instead of being rebuilt inline in +`frontend-modern/src/components/Alerts/WebhookConfig.tsx`. Alert spec validation still accepts the explicit migration-bridge resource types (`node`, `agent-disk`, `docker-host`, `backup-subject`, diff --git a/frontend-modern/src/components/Alerts/WebhookConfig.test.tsx b/frontend-modern/src/components/Alerts/WebhookConfig.test.tsx index e77fd41569..72a7d53e3e 100644 --- a/frontend-modern/src/components/Alerts/WebhookConfig.test.tsx +++ b/frontend-modern/src/components/Alerts/WebhookConfig.test.tsx @@ -1,5 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; +import webhookConfigSource from '@/components/Alerts/WebhookConfig.tsx?raw'; +import webhookConfigFormSource from '@/components/Alerts/WebhookConfigForm.tsx?raw'; +import webhookConfigListSource from '@/components/Alerts/WebhookConfigList.tsx?raw'; +import webhookConfigStateSource from '@/components/Alerts/useWebhookConfigState.ts?raw'; // --- Mock setup (must be before component import) --- const getWebhookTemplatesMock = vi.fn(); @@ -119,6 +123,22 @@ describe('WebhookConfig', () => { cleanup(); }); + it('keeps webhook runtime state and section owners out of the shell', () => { + expect(webhookConfigSource).toContain('useWebhookConfigState'); + expect(webhookConfigSource).toContain('WebhookConfigList'); + expect(webhookConfigSource).toContain('WebhookConfigForm'); + expect(webhookConfigSource).not.toContain('NotificationsAPI.getWebhookTemplates'); + expect(webhookConfigSource).not.toContain('const saveWebhook = () => {'); + expect(webhookConfigSource).not.toContain('const editWebhook = (webhook: Webhook) => {'); + expect(webhookConfigSource).not.toContain('const toggleAllWebhooks = (enabled: boolean) => {'); + expect(webhookConfigListSource).toContain('getAlertWebhookSummaryLabel'); + expect(webhookConfigListSource).toContain('getAlertWebhookToggleAllLabel'); + expect(webhookConfigFormSource).toContain('getAlertWebhookServices'); + expect(webhookConfigFormSource).toContain('getAlertWebhookSubmitLabel'); + expect(webhookConfigStateSource).toContain('NotificationsAPI.getWebhookTemplates'); + expect(webhookConfigStateSource).toContain('normalizeAlertWebhookCustomFields'); + }); + // --- Rendering existing webhooks --- it('renders the "Add Webhook" button when no form is open', () => { diff --git a/frontend-modern/src/components/Alerts/WebhookConfig.tsx b/frontend-modern/src/components/Alerts/WebhookConfig.tsx index 42196bc0ae..a5272cb047 100644 --- a/frontend-modern/src/components/Alerts/WebhookConfig.tsx +++ b/frontend-modern/src/components/Alerts/WebhookConfig.tsx @@ -1,57 +1,13 @@ -import { createSignal, createEffect, Show, For, Index } from 'solid-js'; -import { NotificationsAPI, Webhook, type WebhookTemplate } from '@/api/notifications'; -import { logger } from '@/utils/logger'; -import { - ALERT_WEBHOOK_ADD_LABEL, - ALERT_WEBHOOK_CANCEL_LABEL, - ALERT_WEBHOOK_CUSTOM_FIELDS_HELP, - ALERT_WEBHOOK_CUSTOM_FIELDS_PUSHHOVER_HELP, - ALERT_WEBHOOK_CUSTOM_FIELDS_REFERENCE, - ALERT_WEBHOOK_CUSTOM_FIELD_ADD_LABEL, - ALERT_WEBHOOK_CUSTOM_FIELD_KEY_PLACEHOLDER, - ALERT_WEBHOOK_CUSTOM_FIELD_REMOVE_LABEL, - ALERT_WEBHOOK_CUSTOM_FIELD_VALUE_PLACEHOLDER, - ALERT_WEBHOOK_DELETE_LABEL, - ALERT_WEBHOOK_EDIT_LABEL, - ALERT_WEBHOOK_ENABLE_LABEL, - ALERT_WEBHOOK_HEADER_ADD_LABEL, - ALERT_WEBHOOK_HEADER_HELP, - ALERT_WEBHOOK_HEADER_KEY_PLACEHOLDER, - ALERT_WEBHOOK_HEADER_REMOVE_LABEL, - ALERT_WEBHOOK_HEADER_VALUE_PLACEHOLDER, - ALERT_WEBHOOK_HEADERS_HELP_LABEL, - ALERT_WEBHOOK_MENTION_HELP_LABEL, - ALERT_WEBHOOK_PAYLOAD_HELP_LABEL, - ALERT_WEBHOOK_PAYLOAD_TEMPLATE_PLACEHOLDER, - ALERT_WEBHOOK_PAYLOAD_VARIABLES, - ALERT_WEBHOOK_URL_HELP_PATH, - ALERT_WEBHOOK_URL_HELP_QUERY, - ALERT_WEBHOOK_URL_HELP_TEMPLATE_VARIABLE, - getAlertWebhookMentionHelpFromTemplates, - getAlertWebhookMentionPlaceholderFromTemplates, - hasAlertWebhookMentionSupportFromTemplates, - getAlertWebhookCustomFieldInputs, - getAlertWebhookNamePlaceholder, - getAlertWebhookServiceLabelFromTemplates, - getAlertWebhookServices, - getAlertWebhookSetupInstructionsTitle, - getAlertWebhookSubmitLabel, - getAlertWebhookSummaryLabel, - getAlertWebhookTestLabel, - getAlertWebhookToggleAllLabel, - getAlertWebhookToggleLabel, - getAlertWebhookUrlPlaceholder, - normalizeAlertWebhookCustomFields, -} from '@/utils/alertWebhookPresentation'; -import { - formField, - labelClass, - controlClass, - formHelpText, - formCheckbox, -} from '@/components/shared/Form'; +import { Show } from 'solid-js'; -interface WebhookConfigProps { +import type { Webhook } from '@/api/notifications'; +import { ALERT_WEBHOOK_ADD_LABEL } from '@/utils/alertWebhookPresentation'; + +import { WebhookConfigForm } from './WebhookConfigForm'; +import { WebhookConfigList } from './WebhookConfigList'; +import { useWebhookConfigState } from './useWebhookConfigState'; + +export interface WebhookConfigProps { webhooks: Webhook[]; onAdd: (webhook: Omit) => void; onUpdate: (webhook: Webhook) => void; @@ -60,701 +16,54 @@ interface WebhookConfigProps { testing?: string | null; } -type HeaderInput = { id: string; key: string; value: string }; - -type CustomFieldInput = HeaderInput & { - label?: string; - placeholder?: string; - required?: boolean; -}; - -const buildMapFromInputs = ( - inputs: Array<{ key: string; value: string }>, -): Record => { - const map: Record = {}; - inputs.forEach(({ key, value }) => { - if (key) { - map[key] = value; - } - }); - return map; -}; - export function WebhookConfig(props: WebhookConfigProps) { - const [adding, setAdding] = createSignal(false); - const [editingId, setEditingId] = createSignal(null); - const [formData, setFormData] = createSignal< - Omit & { service: string; payloadTemplate?: string } - >({ - name: '', - url: '', - method: 'POST', - service: 'generic', - headers: { 'Content-Type': 'application/json' }, - enabled: true, - payloadTemplate: '', - customFields: {}, - mention: '', - }); - const [templates, setTemplates] = createSignal([]); - const [showServiceDropdown, setShowServiceDropdown] = createSignal(false); - - // Track header inputs separately to avoid focus loss - const [headerInputs, setHeaderInputs] = createSignal([]); - const [customFieldInputs, _setCustomFieldInputs] = createSignal([]); - - const setCustomFieldInputs = (inputs: CustomFieldInput[]) => { - _setCustomFieldInputs(inputs); - setFormData((prev) => ({ - ...prev, - customFields: buildMapFromInputs(inputs), - })); - }; - - const updateCustomFieldInputs = (updater: (inputs: CustomFieldInput[]) => CustomFieldInput[]) => { - _setCustomFieldInputs((prev) => { - const next = updater(prev); - setFormData((prevForm) => ({ - ...prevForm, - customFields: buildMapFromInputs(next), - })); - return next; - }); - }; - - // Load webhook templates - createEffect(async () => { - try { - const data = await NotificationsAPI.getWebhookTemplates(); - setTemplates(data); - } catch (err) { - logger.error('Failed to load webhook templates:', err); - } - }); - - const saveWebhook = () => { - const data = formData(); - if (!data.name || !data.url) return; - - // Build headers from headerInputs - const headers: Record = {}; - headerInputs().forEach((input) => { - if (input.key) { - headers[input.key] = input.value; - } - }); - const customFields = buildMapFromInputs(customFieldInputs()); - const normalizedCustomFields = normalizeAlertWebhookCustomFields(data.service, customFields); - - if (editingId()) { - props.onUpdate({ - ...data, - id: editingId()!, - headers, - service: data.service, - template: data.payloadTemplate, - customFields: normalizedCustomFields, - mention: data.mention, - }); - setEditingId(null); - setAdding(false); - setHeaderInputs([]); - setCustomFieldInputs([]); - } else { - // onAdd expects a webhook without id, but with service - const newWebhook: Omit = { - name: data.name, - url: data.url, - method: data.method, - headers, - enabled: data.enabled, - service: data.service, - template: data.payloadTemplate, - customFields: normalizedCustomFields, - mention: data.mention, - }; - props.onAdd(newWebhook); - // Reset form and close the adding panel - setFormData({ - name: '', - url: '', - method: 'POST', - service: 'generic', - headers: { 'Content-Type': 'application/json' }, - enabled: true, - payloadTemplate: '', - customFields: {}, - mention: '', - }); - setHeaderInputs([]); - setCustomFieldInputs([]); - setAdding(false); - } - }; - - const cancelForm = () => { - setAdding(false); - setEditingId(null); - setFormData({ - name: '', - url: '', - method: 'POST', - service: 'generic', - headers: { 'Content-Type': 'application/json' }, - enabled: true, - payloadTemplate: '', - customFields: {}, - mention: '', - }); - setHeaderInputs([]); - setCustomFieldInputs([]); - }; - - const editWebhook = (webhook: Webhook) => { - if (webhook.id) { - setEditingId(webhook.id); - } - setFormData({ - ...webhook, - service: webhook.service || 'generic', - payloadTemplate: webhook.template || '', - customFields: normalizeAlertWebhookCustomFields(webhook.service || 'generic', webhook.customFields || {}), - mention: webhook.mention || '', - }); - // Set up header inputs for editing - const headers = webhook.headers || {}; - setHeaderInputs( - Object.entries(headers).map(([key, value], index) => ({ - id: `header-${Date.now()}-${index}`, - key, - value, - })), - ); - - const service = webhook.service || 'generic'; - const existingCustomFields = webhook.customFields || {}; - setCustomFieldInputs( - getAlertWebhookCustomFieldInputs(service, existingCustomFields).map((field, index) => ({ - id: `custom-${field.key}-${Date.now()}-${index}`, - ...field, - })), - ); - setAdding(true); - }; - - const selectService = (service: string) => { - const template = templates().find((t) => t.service === service); - if (template) { - setFormData({ - ...formData(), - service: template.service, - method: template.method, - headers: { ...template.headers }, - name: formData().name || template.name, - // Clear the payload template when switching services - // Only generic service should have custom payloads - payloadTemplate: service === 'generic' ? formData().payloadTemplate : '', - }); - // Update header inputs when switching services - const headers = template.headers || {}; - setHeaderInputs( - Object.entries(headers).map(([key, value], index) => ({ - id: `header-${Date.now()}-${index}`, - key, - value, - })), - ); - } else { - setFormData({ - ...formData(), - service, - }); - } - setCustomFieldInputs( - getAlertWebhookCustomFieldInputs(service, formData().customFields || {}).map((field, index) => ({ - id: `custom-${field.key}-${Date.now()}-${index}`, - ...field, - })), - ); - setShowServiceDropdown(false); - }; - - const currentTemplate = () => templates().find((t) => t.service === formData().service); - - const toggleAllWebhooks = (enabled: boolean) => { - props.webhooks.forEach((webhook) => { - props.onUpdate({ ...webhook, enabled }); - }); - }; - - const allEnabled = () => props.webhooks.every((w) => w.enabled); - const someEnabled = () => props.webhooks.some((w) => w.enabled); + const state = useWebhookConfigState(props); return (
- {/* Existing Webhooks List */} 0}> -
- {/* Quick Actions Bar */} -
-
- {getAlertWebhookSummaryLabel( - props.webhooks.filter((w) => w.enabled).length, - props.webhooks.length, - )} -
-
- - -
-
- - {(webhook) => ( -
-
- {webhook.name} - -
-
- - {getAlertWebhookServiceLabelFromTemplates(webhook.service || 'generic', templates())} - - - {webhook.method} - -
-

- {webhook.url} -

-
- - - -
-
- )} -
-
+ props.onUpdate({ ...webhook, enabled: !webhook.enabled })} + onTestWebhook={(webhook) => webhook.id && props.onTest(webhook.id)} + onEditWebhook={state.editWebhook} + onDeleteWebhook={(webhook) => webhook.id && props.onDelete(webhook.id)} + />
- {/* Add/Edit Form */} - -
- {/* Service Selection */} -
-
- - -
- - -
- - {(service) => ( - - )} - -
-
- - -
-

- {getAlertWebhookSetupInstructionsTitle()} -

- {currentTemplate()!.instructions} -
-
-
- - {/* Basic Configuration */} -
-
- - setFormData({ ...formData(), name: e.currentTarget.value })} - placeholder={getAlertWebhookNamePlaceholder(currentTemplate()?.name)} - class={controlClass('px-2 py-1.5')} - /> -
- -
- - -
-
- -
- - setFormData({ ...formData(), url: e.currentTarget.value })} - placeholder={getAlertWebhookUrlPlaceholder(currentTemplate()?.urlPattern)} - class={controlClass('px-2 py-1.5 font-mono')} - /> -

- Supports template variables like{' '} - - {ALERT_WEBHOOK_URL_HELP_TEMPLATE_VARIABLE} - - . Use{' '} - {ALERT_WEBHOOK_URL_HELP_PATH} or{' '} - {ALERT_WEBHOOK_URL_HELP_QUERY}{' '} - to keep dynamic values URL-safe. -

-
- - {/* Mention Field - show for supported services */} - -
- - setFormData({ ...formData(), mention: e.currentTarget.value })} - placeholder={getAlertWebhookMentionPlaceholderFromTemplates( - formData().service, - templates(), - )} - class={controlClass('px-2 py-1.5')} - /> - -

- {getAlertWebhookMentionHelpFromTemplates(formData().service, templates())} -

-
-
-
- - {/* Custom Payload Template - only show for generic service */} - -
- -