Split alert webhook runtime owners

This commit is contained in:
rcourtman
2026-03-22 11:55:07 +00:00
parent 67b723a28d
commit 4882508929
7 changed files with 958 additions and 752 deletions
@@ -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`,
@@ -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', () => {
@@ -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<Webhook, 'id'>) => 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<string, string> => {
const map: Record<string, string> = {};
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<string | null>(null);
const [formData, setFormData] = createSignal<
Omit<Webhook, 'id'> & { service: string; payloadTemplate?: string }
>({
name: '',
url: '',
method: 'POST',
service: 'generic',
headers: { 'Content-Type': 'application/json' },
enabled: true,
payloadTemplate: '',
customFields: {},
mention: '',
});
const [templates, setTemplates] = createSignal<WebhookTemplate[]>([]);
const [showServiceDropdown, setShowServiceDropdown] = createSignal(false);
// Track header inputs separately to avoid focus loss
const [headerInputs, setHeaderInputs] = createSignal<HeaderInput[]>([]);
const [customFieldInputs, _setCustomFieldInputs] = createSignal<CustomFieldInput[]>([]);
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<string, string> = {};
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<Webhook, 'id'> = {
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 (
<div class="space-y-6 min-w-0 w-full">
{/* Existing Webhooks List */}
<Show when={props.webhooks.length > 0}>
<div class="space-y-3 w-full">
{/* Quick Actions Bar */}
<div class="flex flex-col gap-2 rounded border border-border px-3 py-3 text-xs sm:flex-row sm:items-center sm:justify-between">
<div class="text-muted sm:text-sm">
{getAlertWebhookSummaryLabel(
props.webhooks.filter((w) => w.enabled).length,
props.webhooks.length,
)}
</div>
<div class="flex flex-wrap gap-2 sm:flex-nowrap">
<button
onClick={() => toggleAllWebhooks(false)}
disabled={!someEnabled()}
class="w-full rounded border px-3 py-1 text-xs transition-colors hover:bg-surface-hover sm:w-auto"
>
{getAlertWebhookToggleAllLabel(false)}
</button>
<button
onClick={() => toggleAllWebhooks(true)}
disabled={allEnabled()}
class="w-full rounded border border-green-500 px-3 py-1 text-xs text-green-700 transition-colors hover:bg-green-50 dark:border-green-600 dark:text-green-400 dark:hover:bg-green-900 sm:w-auto"
>
{getAlertWebhookToggleAllLabel(true)}
</button>
</div>
</div>
<For each={props.webhooks}>
{(webhook) => (
<div class="w-full px-3 py-3 border border-border text-xs sm:text-sm">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="font-medium text-base-content">{webhook.name}</span>
<button
onClick={() => props.onUpdate({ ...webhook, enabled: !webhook.enabled })}
class={`rounded border px-3 py-1 text-xs font-medium transition-colors ${webhook.enabled ? 'border-green-500 text-green-700 hover:bg-green-50 dark:border-green-600 dark:text-green-400 dark:hover:bg-green-900' : 'border-border text-muted hover:bg-surface-hover'}`}
>
{getAlertWebhookToggleLabel(webhook.enabled)}
</button>
</div>
<div class="mt-2 flex flex-wrap gap-2 text-[11px] text-muted sm:text-xs">
<span class="rounded bg-surface-alt px-2 py-0.5 text-base-content">
{getAlertWebhookServiceLabelFromTemplates(webhook.service || 'generic', templates())}
</span>
<span class="rounded bg-surface-alt px-2 py-0.5 text-base-content">
{webhook.method}
</span>
</div>
<p class="mt-2 break-all text-[11px] font-mono text-muted sm:text-xs">
{webhook.url}
</p>
<div class="mt-3 flex flex-wrap gap-2 border-t border-border-subtle pt-2 sm:justify-end w-full">
<button
onClick={() => webhook.id && props.onTest(webhook.id)}
disabled={props.testing === webhook.id || !webhook.enabled}
class="rounded border px-3 py-1 text-xs text-base-content transition-colors hover:bg-surface-hover disabled:opacity-50"
>
{getAlertWebhookTestLabel(props.testing === webhook.id)}
</button>
<button
onClick={() => editWebhook(webhook)}
class="rounded border border-blue-300 px-3 py-1 text-xs text-blue-600 transition-colors hover:bg-blue-50 dark:border-blue-500 dark:text-blue-300 dark:hover:bg-blue-900"
>
{ALERT_WEBHOOK_EDIT_LABEL}
</button>
<button
onClick={() => webhook.id && props.onDelete(webhook.id)}
class="rounded border border-red-300 px-3 py-1 text-xs text-red-600 transition-colors hover:bg-red-50 dark:border-red-500 dark:text-red-300 dark:hover:bg-red-900"
>
{ALERT_WEBHOOK_DELETE_LABEL}
</button>
</div>
</div>
)}
</For>
</div>
<WebhookConfigList
webhooks={props.webhooks}
templates={state.templates}
testing={props.testing}
allEnabled={state.allEnabled}
someEnabled={state.someEnabled}
toggleAllWebhooks={state.toggleAllWebhooks}
onToggleWebhook={(webhook) => 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)}
/>
</Show>
{/* Add/Edit Form */}
<Show when={adding()}>
<div class="space-y-4 text-sm">
{/* Service Selection */}
<div>
<div class="flex items-center justify-between mb-4">
<label class="text-sm font-medium text-base-content">Service Type</label>
<button
type="button"
onClick={() => setShowServiceDropdown(!showServiceDropdown())}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
{getAlertWebhookServiceLabelFromTemplates(formData().service, templates())} →
</button>
</div>
<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(templates())}>
{(service) => (
<button
type="button"
onClick={() => selectService(service.id)}
class={`px-2 py-1.5 text-left border transition-colors text-xs ${
formData().service === service.id
? 'border-blue-500 bg-blue-50 dark:bg-blue-900'
: 'border-border hover:bg-surface-hover'
}`}
>
<div class="font-medium text-xs text-base-content">{service.label}</div>
<div class="text-[11px] text-muted mt-1">{service.description}</div>
</button>
)}
</For>
</div>
</Show>
<Show when={currentTemplate()?.instructions}>
<div class="mb-3 border-l-2 border-blue-300 pl-3 text-xs leading-relaxed text-blue-800 dark:border-blue-700 dark:text-blue-200">
<h4 class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
{getAlertWebhookSetupInstructionsTitle()}
</h4>
{currentTemplate()!.instructions}
</div>
</Show>
</div>
{/* Basic Configuration */}
<div class="grid w-full grid-cols-1 gap-3 md:grid-cols-2">
<div class={formField}>
<label class={labelClass()}>Name</label>
<input
type="text"
value={formData().name}
onInput={(e) => setFormData({ ...formData(), name: e.currentTarget.value })}
placeholder={getAlertWebhookNamePlaceholder(currentTemplate()?.name)}
class={controlClass('px-2 py-1.5')}
/>
</div>
<div class={formField}>
<label class={labelClass()}>HTTP method</label>
<select
value={formData().method}
onChange={(e) => setFormData({ ...formData(), method: e.currentTarget.value })}
class={controlClass('px-2 py-1.5 pr-8 appearance-none')}
>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
</select>
</div>
</div>
<div class={formField}>
<label class={labelClass()}>Webhook URL</label>
<input
type="url"
value={formData().url}
onInput={(e) => setFormData({ ...formData(), url: e.currentTarget.value })}
placeholder={getAlertWebhookUrlPlaceholder(currentTemplate()?.urlPattern)}
class={controlClass('px-2 py-1.5 font-mono')}
/>
<p class={formHelpText + ' mt-1'}>
Supports template variables like{' '}
<code class="font-mono text-[11px] text-muted">
{ALERT_WEBHOOK_URL_HELP_TEMPLATE_VARIABLE}
</code>
. Use{' '}
<code class="font-mono text-[11px] text-muted">{ALERT_WEBHOOK_URL_HELP_PATH}</code> or{' '}
<code class="font-mono text-[11px] text-muted">{ALERT_WEBHOOK_URL_HELP_QUERY}</code>{' '}
to keep dynamic values URL-safe.
</p>
</div>
{/* Mention Field - show for supported services */}
<Show
when={hasAlertWebhookMentionSupportFromTemplates(formData().service, templates())}
>
<div class={formField}>
<label class={labelClass('flex items-center gap-2')}>
Mention
<span class="text-xs text-muted">{ALERT_WEBHOOK_MENTION_HELP_LABEL}</span>
</label>
<input
type="text"
value={formData().mention || ''}
onInput={(e) => setFormData({ ...formData(), mention: e.currentTarget.value })}
placeholder={getAlertWebhookMentionPlaceholderFromTemplates(
formData().service,
templates(),
)}
class={controlClass('px-2 py-1.5')}
/>
<Show
when={getAlertWebhookMentionHelpFromTemplates(formData().service, templates())}
>
<p class={formHelpText + ' mt-1'}>
{getAlertWebhookMentionHelpFromTemplates(formData().service, templates())}
</p>
</Show>
</div>
</Show>
{/* Custom Payload Template - only show for generic service */}
<Show when={formData().service === 'generic'}>
<div class={formField}>
<label class={labelClass('flex items-center gap-2')}>
Custom payload template (JSON)
<span class="text-xs text-muted">{ALERT_WEBHOOK_PAYLOAD_HELP_LABEL}</span>
</label>
<textarea
value={formData().payloadTemplate || ''}
onInput={(e) =>
setFormData({ ...formData(), payloadTemplate: e.currentTarget.value })
}
placeholder={ALERT_WEBHOOK_PAYLOAD_TEMPLATE_PLACEHOLDER}
rows={8}
class={controlClass('px-2 py-1.5 text-xs font-mono min-h-[160px]')}
/>
<p class={formHelpText + ' mt-1'}>
Available variables: {ALERT_WEBHOOK_PAYLOAD_VARIABLES}
</p>
</div>
</Show>
{/* Custom Fields Section */}
<Show when={customFieldInputs().length > 0 || formData().service === 'pushover'}>
<div class={formField}>
<label class={labelClass('flex items-center gap-2')}>
Custom fields
<span class="text-xs text-muted">
{ALERT_WEBHOOK_CUSTOM_FIELDS_HELP}{' '}
<code class="font-mono text-[11px] text-muted">
{ALERT_WEBHOOK_CUSTOM_FIELDS_REFERENCE}
</code>{' '}
in templates
</span>
</label>
<div class="space-y-2 text-xs">
<Index each={customFieldInputs()}>
{(field, index) => (
<div class="flex gap-2 text-xs">
<div class="flex flex-1 flex-col gap-1">
<Show when={field().label}>
<span class="text-[11px] text-muted">{field().label}</span>
</Show>
<input
type="text"
value={field().key}
disabled={field().required}
onInput={(e) => {
const newKey = e.currentTarget.value;
updateCustomFieldInputs((inputs) => {
const next = [...inputs];
next[index] = { ...next[index], key: newKey };
return next;
});
}}
placeholder={ALERT_WEBHOOK_CUSTOM_FIELD_KEY_PLACEHOLDER}
class={controlClass('flex-1 px-2 py-1.5 text-xs font-mono')}
/>
</div>
<input
type="text"
value={field().value}
onInput={(e) => {
const newValue = e.currentTarget.value;
updateCustomFieldInputs((inputs) => {
const next = [...inputs];
next[index] = { ...next[index], value: newValue };
return next;
});
}}
placeholder={
field().placeholder || ALERT_WEBHOOK_CUSTOM_FIELD_VALUE_PLACEHOLDER
}
class={controlClass('flex-1 px-2 py-1.5 text-xs font-mono')}
/>
<Show when={!field().required}>
<button
type="button"
onClick={() => {
updateCustomFieldInputs((inputs) =>
inputs.filter((_, i) => i !== index),
);
}}
class="px-2 py-1 text-xs text-red-600 hover:underline dark:text-red-400"
>
{ALERT_WEBHOOK_CUSTOM_FIELD_REMOVE_LABEL}
</button>
</Show>
</div>
)}
</Index>
<button
type="button"
onClick={() => {
const newId = `custom-${Date.now()}-${Math.random()}`;
updateCustomFieldInputs((inputs) => [
...inputs,
{
id: newId,
key: '',
value: '',
},
]);
}}
class="w-full border border-dashed border-border px-2 py-1 text-xs hover:bg-surface-hover"
>
{ALERT_WEBHOOK_CUSTOM_FIELD_ADD_LABEL}
</button>
</div>
<p class="mt-2 text-xs text-muted">{ALERT_WEBHOOK_CUSTOM_FIELDS_PUSHHOVER_HELP}</p>
</div>
</Show>
{/* Custom Headers Section */}
<div class={formField}>
<label class={labelClass('flex items-center gap-2')}>
Custom headers
<span class="text-xs text-muted">{ALERT_WEBHOOK_HEADERS_HELP_LABEL}</span>
</label>
<div class="space-y-2 text-xs">
<Index each={headerInputs()}>
{(header, index) => (
<div class="flex gap-2 text-xs">
<input
type="text"
value={header().key}
onInput={(e) => {
const newKey = e.currentTarget.value;
setHeaderInputs((inputs) => {
const newInputs = [...inputs];
newInputs[index] = { ...newInputs[index], key: newKey };
return newInputs;
});
}}
placeholder={ALERT_WEBHOOK_HEADER_KEY_PLACEHOLDER}
class={controlClass('flex-1 px-2 py-1.5 text-xs font-mono')}
/>
<input
type="text"
value={header().value}
onInput={(e) => {
const newValue = e.currentTarget.value;
setHeaderInputs((inputs) => {
const newInputs = [...inputs];
newInputs[index] = { ...newInputs[index], value: newValue };
return newInputs;
});
}}
placeholder={ALERT_WEBHOOK_HEADER_VALUE_PLACEHOLDER}
class={controlClass('flex-1 px-2 py-1.5 text-xs font-mono')}
/>
<button
type="button"
onClick={() => {
setHeaderInputs((inputs) => inputs.filter((_, i) => i !== index));
}}
class="px-2 py-1 text-xs text-red-600 hover:underline dark:text-red-400"
>
{ALERT_WEBHOOK_HEADER_REMOVE_LABEL}
</button>
</div>
)}
</Index>
<button
type="button"
onClick={() => {
const newId = `header-${Date.now()}-${Math.random()}`;
setHeaderInputs([
...headerInputs(),
{
id: newId,
key: '',
value: '',
},
]);
}}
class="w-full border border-dashed border-border px-2 py-1 text-xs hover:bg-surface-hover"
>
{ALERT_WEBHOOK_HEADER_ADD_LABEL}
</button>
</div>
<p class="mt-2 text-xs text-muted">{ALERT_WEBHOOK_HEADER_HELP}</p>
</div>
<div>
<label class="flex items-center gap-2 text-sm text-base-content">
<input
type="checkbox"
checked={formData().enabled}
onChange={(e) => setFormData({ ...formData(), enabled: e.currentTarget.checked })}
class={formCheckbox}
/>
<span>{ALERT_WEBHOOK_ENABLE_LABEL}</span>
</label>
</div>
<div class="flex justify-end gap-2 text-xs">
<button
onClick={cancelForm}
class="px-3 py-1.5 border border-border rounded text-xs hover:"
>
{ALERT_WEBHOOK_CANCEL_LABEL}
</button>
<Show when={formData().url && formData().name}>
<button
onClick={() => {
// Test the webhook with current form data
// Build headers from headerInputs
const headers: Record<string, string> = {};
headerInputs().forEach((input) => {
if (input.key) {
headers[input.key] = input.value;
}
});
const customFields = buildMapFromInputs(customFieldInputs());
const { payloadTemplate, ...restFormData } = formData();
const testPayload = {
...restFormData,
headers,
customFields,
template: payloadTemplate ?? restFormData.template ?? '',
};
// Use a consistent temporary ID for this form session
const tempId = editingId() || 'temp-new-webhook';
props.onTest(tempId, testPayload);
}}
disabled={props.testing === (editingId() || 'temp-new-webhook')}
class="px-3 py-1.5 border border-border rounded text-xs hover:bg-slate-100"
>
{getAlertWebhookTestLabel(
props.testing === (editingId() || 'temp-new-webhook'),
true,
)}
</button>
</Show>
<button
onClick={saveWebhook}
disabled={!formData().name || !formData().url}
class="px-3 py-1.5 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{getAlertWebhookSubmitLabel(Boolean(editingId()))}
</button>
</div>
</div>
<Show when={state.adding()}>
<WebhookConfigForm
editingId={state.editingId}
formData={state.formData}
setFormData={state.setFormData}
templates={state.templates}
currentTemplate={state.currentTemplate}
showServiceDropdown={state.showServiceDropdown}
setShowServiceDropdown={state.setShowServiceDropdown}
headerInputs={state.headerInputs}
customFieldInputs={state.customFieldInputs}
selectService={state.selectService}
updateHeaderInput={state.updateHeaderInput}
removeHeaderInput={state.removeHeaderInput}
addHeaderInput={state.addHeaderInput}
updateCustomFieldInput={state.updateCustomFieldInput}
removeCustomFieldInput={state.removeCustomFieldInput}
addCustomFieldInput={state.addCustomFieldInput}
cancelForm={state.cancelForm}
testWebhookForm={state.testWebhookForm}
saveWebhook={state.saveWebhook}
testing={props.testing}
/>
</Show>
{/* Add Webhook Button */}
<Show when={!adding()}>
<Show when={!state.adding()}>
<button
onClick={() => {
setAdding(true);
// Initialize with default Content-Type header
setHeaderInputs([
{
id: `header-${Date.now()}-0`,
key: 'Content-Type',
value: 'application/json',
},
]);
setCustomFieldInputs([]);
}}
onClick={state.openAddForm}
class="w-full border border-dashed border-border px-2 py-1 text-xs text-muted hover:bg-surface-hover"
>
+ {ALERT_WEBHOOK_ADD_LABEL}
@@ -0,0 +1,394 @@
import { Index, Show, For } from 'solid-js';
import type { Accessor } from 'solid-js';
import type { WebhookTemplate } from '@/api/notifications';
import {
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_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,
getAlertWebhookNamePlaceholder,
getAlertWebhookServiceLabelFromTemplates,
getAlertWebhookServices,
getAlertWebhookSetupInstructionsTitle,
getAlertWebhookSubmitLabel,
getAlertWebhookTestLabel,
getAlertWebhookUrlPlaceholder,
hasAlertWebhookMentionSupportFromTemplates,
} from '@/utils/alertWebhookPresentation';
import {
formCheckbox,
formField,
formHelpText,
controlClass,
labelClass,
} from '@/components/shared/Form';
import type {
CustomFieldInput,
HeaderInput,
WebhookConfigFormData,
} from './useWebhookConfigState';
type SetterLike<T> = (value: T | ((prev: T) => T)) => T;
interface WebhookConfigFormProps {
editingId: Accessor<string | null>;
formData: Accessor<WebhookConfigFormData>;
setFormData: SetterLike<WebhookConfigFormData>;
templates: Accessor<WebhookTemplate[]>;
currentTemplate: Accessor<WebhookTemplate | undefined>;
showServiceDropdown: Accessor<boolean>;
setShowServiceDropdown: SetterLike<boolean>;
headerInputs: Accessor<HeaderInput[]>;
customFieldInputs: Accessor<CustomFieldInput[]>;
selectService: (service: string) => void;
updateHeaderInput: (index: number, patch: Partial<HeaderInput>) => void;
removeHeaderInput: (index: number) => void;
addHeaderInput: () => void;
updateCustomFieldInput: (index: number, patch: Partial<CustomFieldInput>) => void;
removeCustomFieldInput: (index: number) => void;
addCustomFieldInput: () => void;
cancelForm: () => void;
testWebhookForm: () => void;
saveWebhook: () => void;
testing?: string | null;
}
export function WebhookConfigForm(props: WebhookConfigFormProps) {
return (
<div class="space-y-4 text-sm">
<div>
<div class="flex items-center justify-between mb-4">
<label class="text-sm font-medium text-base-content">Service Type</label>
<button
type="button"
onClick={() => props.setShowServiceDropdown((open) => !open)}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
{getAlertWebhookServiceLabelFromTemplates(
props.formData().service,
props.templates(),
)}{' '}
→
</button>
</div>
<Show when={props.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(props.templates())}>
{(service) => (
<button
type="button"
onClick={() => props.selectService(service.id)}
class={`px-2 py-1.5 text-left border transition-colors text-xs ${
props.formData().service === service.id
? 'border-blue-500 bg-blue-50 dark:bg-blue-900'
: 'border-border hover:bg-surface-hover'
}`}
>
<div class="font-medium text-xs text-base-content">{service.label}</div>
<div class="text-[11px] text-muted mt-1">{service.description}</div>
</button>
)}
</For>
</div>
</Show>
<Show when={props.currentTemplate()?.instructions}>
<div class="mb-3 border-l-2 border-blue-300 pl-3 text-xs leading-relaxed text-blue-800 dark:border-blue-700 dark:text-blue-200">
<h4 class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
{getAlertWebhookSetupInstructionsTitle()}
</h4>
{props.currentTemplate()!.instructions}
</div>
</Show>
</div>
<div class="grid w-full grid-cols-1 gap-3 md:grid-cols-2">
<div class={formField}>
<label class={labelClass()}>Name</label>
<input
type="text"
value={props.formData().name}
onInput={(e) =>
props.setFormData((prev) => ({ ...prev, name: e.currentTarget.value }))
}
placeholder={getAlertWebhookNamePlaceholder(props.currentTemplate()?.name)}
class={controlClass('px-2 py-1.5')}
/>
</div>
<div class={formField}>
<label class={labelClass()}>HTTP method</label>
<select
value={props.formData().method}
onChange={(e) =>
props.setFormData((prev) => ({ ...prev, method: e.currentTarget.value }))
}
class={controlClass('px-2 py-1.5 pr-8 appearance-none')}
>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
</select>
</div>
</div>
<div class={formField}>
<label class={labelClass()}>Webhook URL</label>
<input
type="url"
value={props.formData().url}
onInput={(e) =>
props.setFormData((prev) => ({ ...prev, url: e.currentTarget.value }))
}
placeholder={getAlertWebhookUrlPlaceholder(props.currentTemplate()?.urlPattern)}
class={controlClass('px-2 py-1.5 font-mono')}
/>
<p class={formHelpText + ' mt-1'}>
Supports template variables like{' '}
<code class="font-mono text-[11px] text-muted">
{ALERT_WEBHOOK_URL_HELP_TEMPLATE_VARIABLE}
</code>
. Use{' '}
<code class="font-mono text-[11px] text-muted">{ALERT_WEBHOOK_URL_HELP_PATH}</code> or{' '}
<code class="font-mono text-[11px] text-muted">{ALERT_WEBHOOK_URL_HELP_QUERY}</code> to
keep dynamic values URL-safe.
</p>
</div>
<Show when={hasAlertWebhookMentionSupportFromTemplates(props.formData().service, props.templates())}>
<div class={formField}>
<label class={labelClass('flex items-center gap-2')}>
Mention
<span class="text-xs text-muted">{ALERT_WEBHOOK_MENTION_HELP_LABEL}</span>
</label>
<input
type="text"
value={props.formData().mention || ''}
onInput={(e) =>
props.setFormData((prev) => ({ ...prev, mention: e.currentTarget.value }))
}
placeholder={getAlertWebhookMentionPlaceholderFromTemplates(
props.formData().service,
props.templates(),
)}
class={controlClass('px-2 py-1.5')}
/>
<Show
when={getAlertWebhookMentionHelpFromTemplates(
props.formData().service,
props.templates(),
)}
>
<p class={formHelpText + ' mt-1'}>
{getAlertWebhookMentionHelpFromTemplates(
props.formData().service,
props.templates(),
)}
</p>
</Show>
</div>
</Show>
<Show when={props.formData().service === 'generic'}>
<div class={formField}>
<label class={labelClass('flex items-center gap-2')}>
Custom payload template (JSON)
<span class="text-xs text-muted">{ALERT_WEBHOOK_PAYLOAD_HELP_LABEL}</span>
</label>
<textarea
value={props.formData().payloadTemplate || ''}
onInput={(e) =>
props.setFormData((prev) => ({
...prev,
payloadTemplate: e.currentTarget.value,
}))
}
placeholder={ALERT_WEBHOOK_PAYLOAD_TEMPLATE_PLACEHOLDER}
rows={8}
class={controlClass('px-2 py-1.5 text-xs font-mono min-h-[160px]')}
/>
<p class={formHelpText + ' mt-1'}>
Available variables: {ALERT_WEBHOOK_PAYLOAD_VARIABLES}
</p>
</div>
</Show>
<Show when={props.customFieldInputs().length > 0 || props.formData().service === 'pushover'}>
<div class={formField}>
<label class={labelClass('flex items-center gap-2')}>
Custom fields
<span class="text-xs text-muted">
{ALERT_WEBHOOK_CUSTOM_FIELDS_HELP}{' '}
<code class="font-mono text-[11px] text-muted">
{ALERT_WEBHOOK_CUSTOM_FIELDS_REFERENCE}
</code>{' '}
in templates
</span>
</label>
<div class="space-y-2 text-xs">
<Index each={props.customFieldInputs()}>
{(field, index) => (
<div class="flex gap-2 text-xs">
<div class="flex flex-1 flex-col gap-1">
<Show when={field().label}>
<span class="text-[11px] text-muted">{field().label}</span>
</Show>
<input
type="text"
value={field().key}
disabled={field().required}
onInput={(e) =>
props.updateCustomFieldInput(index, { key: e.currentTarget.value })
}
placeholder={ALERT_WEBHOOK_CUSTOM_FIELD_KEY_PLACEHOLDER}
class={controlClass('flex-1 px-2 py-1.5 text-xs font-mono')}
/>
</div>
<input
type="text"
value={field().value}
onInput={(e) =>
props.updateCustomFieldInput(index, { value: e.currentTarget.value })
}
placeholder={
field().placeholder || ALERT_WEBHOOK_CUSTOM_FIELD_VALUE_PLACEHOLDER
}
class={controlClass('flex-1 px-2 py-1.5 text-xs font-mono')}
/>
<Show when={!field().required}>
<button
type="button"
onClick={() => props.removeCustomFieldInput(index)}
class="px-2 py-1 text-xs text-red-600 hover:underline dark:text-red-400"
>
{ALERT_WEBHOOK_CUSTOM_FIELD_REMOVE_LABEL}
</button>
</Show>
</div>
)}
</Index>
<button
type="button"
onClick={props.addCustomFieldInput}
class="w-full border border-dashed border-border px-2 py-1 text-xs hover:bg-surface-hover"
>
{ALERT_WEBHOOK_CUSTOM_FIELD_ADD_LABEL}
</button>
</div>
<p class="mt-2 text-xs text-muted">{ALERT_WEBHOOK_CUSTOM_FIELDS_PUSHHOVER_HELP}</p>
</div>
</Show>
<div class={formField}>
<label class={labelClass('flex items-center gap-2')}>
Custom headers
<span class="text-xs text-muted">{ALERT_WEBHOOK_HEADERS_HELP_LABEL}</span>
</label>
<div class="space-y-2 text-xs">
<Index each={props.headerInputs()}>
{(header, index) => (
<div class="flex gap-2 text-xs">
<input
type="text"
value={header().key}
onInput={(e) => props.updateHeaderInput(index, { key: e.currentTarget.value })}
placeholder={ALERT_WEBHOOK_HEADER_KEY_PLACEHOLDER}
class={controlClass('flex-1 px-2 py-1.5 text-xs font-mono')}
/>
<input
type="text"
value={header().value}
onInput={(e) =>
props.updateHeaderInput(index, { value: e.currentTarget.value })
}
placeholder={ALERT_WEBHOOK_HEADER_VALUE_PLACEHOLDER}
class={controlClass('flex-1 px-2 py-1.5 text-xs font-mono')}
/>
<button
type="button"
onClick={() => props.removeHeaderInput(index)}
class="px-2 py-1 text-xs text-red-600 hover:underline dark:text-red-400"
>
{ALERT_WEBHOOK_HEADER_REMOVE_LABEL}
</button>
</div>
)}
</Index>
<button
type="button"
onClick={props.addHeaderInput}
class="w-full border border-dashed border-border px-2 py-1 text-xs hover:bg-surface-hover"
>
{ALERT_WEBHOOK_HEADER_ADD_LABEL}
</button>
</div>
<p class="mt-2 text-xs text-muted">{ALERT_WEBHOOK_HEADER_HELP}</p>
</div>
<div>
<label class="flex items-center gap-2 text-sm text-base-content">
<input
type="checkbox"
checked={props.formData().enabled}
onChange={(e) =>
props.setFormData((prev) => ({ ...prev, enabled: e.currentTarget.checked }))
}
class={formCheckbox}
/>
<span>{ALERT_WEBHOOK_ENABLE_LABEL}</span>
</label>
</div>
<div class="flex justify-end gap-2 text-xs">
<button
onClick={props.cancelForm}
class="px-3 py-1.5 border border-border rounded text-xs hover:bg-surface-hover"
>
{ALERT_WEBHOOK_CANCEL_LABEL}
</button>
<Show when={props.formData().url && props.formData().name}>
<button
onClick={props.testWebhookForm}
disabled={props.testing === (props.editingId() || 'temp-new-webhook')}
class="px-3 py-1.5 border border-border rounded text-xs hover:bg-slate-100"
>
{getAlertWebhookTestLabel(
props.testing === (props.editingId() || 'temp-new-webhook'),
true,
)}
</button>
</Show>
<button
onClick={props.saveWebhook}
disabled={!props.formData().name || !props.formData().url}
class="px-3 py-1.5 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{getAlertWebhookSubmitLabel(Boolean(props.editingId()))}
</button>
</div>
</div>
);
}
@@ -0,0 +1,106 @@
import { For } from 'solid-js';
import type { Accessor } from 'solid-js';
import type { Webhook, WebhookTemplate } from '@/api/notifications';
import {
ALERT_WEBHOOK_DELETE_LABEL,
ALERT_WEBHOOK_EDIT_LABEL,
getAlertWebhookServiceLabelFromTemplates,
getAlertWebhookSummaryLabel,
getAlertWebhookTestLabel,
getAlertWebhookToggleAllLabel,
getAlertWebhookToggleLabel,
} from '@/utils/alertWebhookPresentation';
interface WebhookConfigListProps {
webhooks: Webhook[];
templates: Accessor<WebhookTemplate[]>;
testing?: string | null;
allEnabled: Accessor<boolean>;
someEnabled: Accessor<boolean>;
toggleAllWebhooks: (enabled: boolean) => void;
onToggleWebhook: (webhook: Webhook) => void;
onTestWebhook: (webhook: Webhook) => void;
onEditWebhook: (webhook: Webhook) => void;
onDeleteWebhook: (webhook: Webhook) => void;
}
export function WebhookConfigList(props: WebhookConfigListProps) {
return (
<div class="space-y-3 w-full">
<div class="flex flex-col gap-2 rounded border border-border px-3 py-3 text-xs sm:flex-row sm:items-center sm:justify-between">
<div class="text-muted sm:text-sm">
{getAlertWebhookSummaryLabel(
props.webhooks.filter((webhook) => webhook.enabled).length,
props.webhooks.length,
)}
</div>
<div class="flex flex-wrap gap-2 sm:flex-nowrap">
<button
onClick={() => props.toggleAllWebhooks(false)}
disabled={!props.someEnabled()}
class="w-full rounded border px-3 py-1 text-xs transition-colors hover:bg-surface-hover sm:w-auto"
>
{getAlertWebhookToggleAllLabel(false)}
</button>
<button
onClick={() => props.toggleAllWebhooks(true)}
disabled={props.allEnabled()}
class="w-full rounded border border-green-500 px-3 py-1 text-xs text-green-700 transition-colors hover:bg-green-50 dark:border-green-600 dark:text-green-400 dark:hover:bg-green-900 sm:w-auto"
>
{getAlertWebhookToggleAllLabel(true)}
</button>
</div>
</div>
<For each={props.webhooks}>
{(webhook) => (
<div class="w-full px-3 py-3 border border-border text-xs sm:text-sm">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="font-medium text-base-content">{webhook.name}</span>
<button
onClick={() => props.onToggleWebhook(webhook)}
class={`rounded border px-3 py-1 text-xs font-medium transition-colors ${webhook.enabled ? 'border-green-500 text-green-700 hover:bg-green-50 dark:border-green-600 dark:text-green-400 dark:hover:bg-green-900' : 'border-border text-muted hover:bg-surface-hover'}`}
>
{getAlertWebhookToggleLabel(webhook.enabled)}
</button>
</div>
<div class="mt-2 flex flex-wrap gap-2 text-[11px] text-muted sm:text-xs">
<span class="rounded bg-surface-alt px-2 py-0.5 text-base-content">
{getAlertWebhookServiceLabelFromTemplates(
webhook.service || 'generic',
props.templates(),
)}
</span>
<span class="rounded bg-surface-alt px-2 py-0.5 text-base-content">
{webhook.method}
</span>
</div>
<p class="mt-2 break-all text-[11px] font-mono text-muted sm:text-xs">{webhook.url}</p>
<div class="mt-3 flex flex-wrap gap-2 border-t border-border-subtle pt-2 sm:justify-end w-full">
<button
onClick={() => props.onTestWebhook(webhook)}
disabled={props.testing === webhook.id || !webhook.enabled}
class="rounded border px-3 py-1 text-xs text-base-content transition-colors hover:bg-surface-hover disabled:opacity-50"
>
{getAlertWebhookTestLabel(props.testing === webhook.id)}
</button>
<button
onClick={() => props.onEditWebhook(webhook)}
class="rounded border border-blue-300 px-3 py-1 text-xs text-blue-600 transition-colors hover:bg-blue-50 dark:border-blue-500 dark:text-blue-300 dark:hover:bg-blue-900"
>
{ALERT_WEBHOOK_EDIT_LABEL}
</button>
<button
onClick={() => props.onDeleteWebhook(webhook)}
class="rounded border border-red-300 px-3 py-1 text-xs text-red-600 transition-colors hover:bg-red-50 dark:border-red-500 dark:text-red-300 dark:hover:bg-red-900"
>
{ALERT_WEBHOOK_DELETE_LABEL}
</button>
</div>
</div>
)}
</For>
</div>
);
}
@@ -0,0 +1,352 @@
import { createEffect, createSignal } from 'solid-js';
import type { Accessor } from 'solid-js';
import {
NotificationsAPI,
type Webhook,
type WebhookTemplate,
} from '@/api/notifications';
import { logger } from '@/utils/logger';
import {
getAlertWebhookCustomFieldInputs,
normalizeAlertWebhookCustomFields,
} from '@/utils/alertWebhookPresentation';
import type { WebhookConfigProps } from './WebhookConfig';
export type HeaderInput = {
id: string;
key: string;
value: string;
};
export type CustomFieldInput = HeaderInput & {
label?: string;
placeholder?: string;
required?: boolean;
};
export type WebhookConfigFormData = Omit<Webhook, 'id'> & {
service: string;
payloadTemplate?: string;
};
type SetterLike<T> = (value: T | ((prev: T) => T)) => T;
const createDefaultFormData = (): WebhookConfigFormData => ({
name: '',
url: '',
method: 'POST',
service: 'generic',
headers: { 'Content-Type': 'application/json' },
enabled: true,
payloadTemplate: '',
customFields: {},
mention: '',
});
const buildMapFromInputs = (
inputs: Array<{ key: string; value: string }>,
): Record<string, string> => {
const map: Record<string, string> = {};
inputs.forEach(({ key, value }) => {
if (key) {
map[key] = value;
}
});
return map;
};
const createHeaderInput = (index: number, key = '', value = ''): HeaderInput => ({
id: `header-${Date.now()}-${index}`,
key,
value,
});
const createCustomFieldInput = (
field: Omit<CustomFieldInput, 'id'>,
index: number,
): CustomFieldInput => ({
id: `custom-${field.key || 'field'}-${Date.now()}-${index}`,
...field,
});
export interface WebhookConfigState {
adding: Accessor<boolean>;
editingId: Accessor<string | null>;
formData: Accessor<WebhookConfigFormData>;
templates: Accessor<WebhookTemplate[]>;
currentTemplate: Accessor<WebhookTemplate | undefined>;
showServiceDropdown: Accessor<boolean>;
headerInputs: Accessor<HeaderInput[]>;
customFieldInputs: Accessor<CustomFieldInput[]>;
allEnabled: Accessor<boolean>;
someEnabled: Accessor<boolean>;
setFormData: SetterLike<WebhookConfigFormData>;
setShowServiceDropdown: SetterLike<boolean>;
openAddForm: () => void;
cancelForm: () => void;
editWebhook: (webhook: Webhook) => void;
selectService: (service: string) => void;
saveWebhook: () => void;
testWebhookForm: () => void;
toggleAllWebhooks: (enabled: boolean) => void;
updateHeaderInput: (index: number, patch: Partial<HeaderInput>) => void;
removeHeaderInput: (index: number) => void;
addHeaderInput: () => void;
updateCustomFieldInput: (index: number, patch: Partial<CustomFieldInput>) => void;
removeCustomFieldInput: (index: number) => void;
addCustomFieldInput: () => void;
}
export function useWebhookConfigState(props: WebhookConfigProps): WebhookConfigState {
const [adding, setAdding] = createSignal(false);
const [editingId, setEditingId] = createSignal<string | null>(null);
const [formData, setFormData] = createSignal<WebhookConfigFormData>(createDefaultFormData());
const [templates, setTemplates] = createSignal<WebhookTemplate[]>([]);
const [showServiceDropdown, setShowServiceDropdown] = createSignal(false);
const [headerInputs, setHeaderInputs] = createSignal<HeaderInput[]>([]);
const [customFieldInputs, _setCustomFieldInputs] = createSignal<CustomFieldInput[]>([]);
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;
});
};
const resetForm = () => {
setAdding(false);
setEditingId(null);
setFormData(createDefaultFormData());
setHeaderInputs([]);
setCustomFieldInputs([]);
setShowServiceDropdown(false);
};
createEffect(async () => {
try {
const data = await NotificationsAPI.getWebhookTemplates();
setTemplates(data);
} catch (err) {
logger.error('Failed to load webhook templates:', err);
}
});
const currentTemplate = () => templates().find((template) => template.service === formData().service);
const toggleAllWebhooks = (enabled: boolean) => {
props.webhooks.forEach((webhook) => {
props.onUpdate({ ...webhook, enabled });
});
};
const allEnabled = () => props.webhooks.every((webhook) => webhook.enabled);
const someEnabled = () => props.webhooks.some((webhook) => webhook.enabled);
const openAddForm = () => {
setAdding(true);
setEditingId(null);
setFormData(createDefaultFormData());
setShowServiceDropdown(false);
setHeaderInputs([createHeaderInput(0, 'Content-Type', 'application/json')]);
setCustomFieldInputs([]);
};
const cancelForm = () => {
resetForm();
};
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 || '',
});
const headers = webhook.headers || {};
setHeaderInputs(
Object.entries(headers).map(([key, value], index) => createHeaderInput(index, key, value)),
);
const service = webhook.service || 'generic';
const existingCustomFields = webhook.customFields || {};
setCustomFieldInputs(
getAlertWebhookCustomFieldInputs(service, existingCustomFields).map((field, index) =>
createCustomFieldInput(field, index),
),
);
setShowServiceDropdown(false);
setAdding(true);
};
const selectService = (service: string) => {
const template = templates().find((candidate) => candidate.service === service);
if (template) {
setFormData((prev) => ({
...prev,
service: template.service,
method: template.method,
headers: { ...template.headers },
name: prev.name || template.name,
payloadTemplate: service === 'generic' ? prev.payloadTemplate : '',
}));
const headers = template.headers || {};
setHeaderInputs(
Object.entries(headers).map(([key, value], index) => createHeaderInput(index, key, value)),
);
} else {
setFormData((prev) => ({
...prev,
service,
}));
}
setCustomFieldInputs(
getAlertWebhookCustomFieldInputs(service, formData().customFields || {}).map((field, index) =>
createCustomFieldInput(field, index),
),
);
setShowServiceDropdown(false);
};
const saveWebhook = () => {
const data = formData();
if (!data.name || !data.url) return;
const headers = buildMapFromInputs(headerInputs());
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,
});
resetForm();
return;
}
props.onAdd({
name: data.name,
url: data.url,
method: data.method,
headers,
enabled: data.enabled,
service: data.service,
template: data.payloadTemplate,
customFields: normalizedCustomFields,
mention: data.mention,
});
resetForm();
};
const testWebhookForm = () => {
const data = formData();
const headers = buildMapFromInputs(headerInputs());
const customFields = buildMapFromInputs(customFieldInputs());
const { payloadTemplate, ...restFormData } = data;
const testPayload = {
...restFormData,
headers,
customFields,
template: payloadTemplate ?? restFormData.template ?? '',
};
const tempId = editingId() || 'temp-new-webhook';
props.onTest(tempId, testPayload);
};
const updateHeaderInput = (index: number, patch: Partial<HeaderInput>) => {
setHeaderInputs((inputs) => {
const next = [...inputs];
next[index] = { ...next[index], ...patch };
return next;
});
};
const removeHeaderInput = (index: number) => {
setHeaderInputs((inputs) => inputs.filter((_, currentIndex) => currentIndex !== index));
};
const addHeaderInput = () => {
setHeaderInputs((inputs) => [...inputs, createHeaderInput(inputs.length)]);
};
const updateCustomFieldInput = (index: number, patch: Partial<CustomFieldInput>) => {
updateCustomFieldInputs((inputs) => {
const next = [...inputs];
next[index] = { ...next[index], ...patch };
return next;
});
};
const removeCustomFieldInput = (index: number) => {
updateCustomFieldInputs((inputs) =>
inputs.filter((_, currentIndex) => currentIndex !== index),
);
};
const addCustomFieldInput = () => {
updateCustomFieldInputs((inputs) => [
...inputs,
createCustomFieldInput({ key: '', value: '' }, inputs.length),
]);
};
return {
adding,
editingId,
formData,
templates,
currentTemplate,
showServiceDropdown,
headerInputs,
customFieldInputs,
allEnabled,
someEnabled,
setFormData,
setShowServiceDropdown,
openAddForm,
cancelForm,
editWebhook,
selectService,
saveWebhook,
testWebhookForm,
toggleAllWebhooks,
updateHeaderInput,
removeHeaderInput,
addHeaderInput,
updateCustomFieldInput,
removeCustomFieldInput,
addCustomFieldInput,
};
}
@@ -341,6 +341,9 @@ import alertResourceTableSource from '@/components/Alerts/ResourceTable.tsx?raw'
import alertResourceTableStateSource from '@/components/Alerts/useAlertResourceTableState.ts?raw';
import emailProviderSelectSource from '@/components/Alerts/EmailProviderSelect.tsx?raw';
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';
import alertActivationPresentationSource from '@/utils/alertActivationPresentation.ts?raw';
import alertFrequencyPresentationSource from '@/utils/alertFrequencyPresentation.ts?raw';
import alertSeverityPresentationSource from '@/utils/alertSeverityPresentation.ts?raw';
@@ -3884,20 +3887,15 @@ describe('frontend resource type boundaries', () => {
});
it('keeps alert webhook service vocabulary and action copy in a shared presentation utility', () => {
expect(webhookConfigSource).toContain('getAlertWebhookServices(templates())');
expect(webhookConfigSource).toContain('getAlertWebhookServiceLabelFromTemplates');
expect(webhookConfigSource).toContain('getAlertWebhookCustomFieldInputs');
expect(webhookConfigSource).toContain('normalizeAlertWebhookCustomFields');
expect(webhookConfigSource).toContain('getAlertWebhookSummaryLabel');
expect(webhookConfigSource).toContain('getAlertWebhookToggleAllLabel');
expect(webhookConfigSource).toContain('getAlertWebhookToggleLabel');
expect(webhookConfigSource).toContain('getAlertWebhookNamePlaceholder');
expect(webhookConfigSource).toContain('getAlertWebhookUrlPlaceholder');
expect(webhookConfigSource).toContain('getAlertWebhookMentionPlaceholderFromTemplates');
expect(webhookConfigSource).toContain('getAlertWebhookMentionHelpFromTemplates');
expect(webhookConfigSource).toContain('hasAlertWebhookMentionSupportFromTemplates');
expect(webhookConfigSource).toContain('getAlertWebhookTestLabel');
expect(webhookConfigSource).toContain('getAlertWebhookSubmitLabel');
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(webhookConfigSource).not.toContain('Custom webhook endpoint');
expect(webhookConfigSource).not.toContain('Discord server webhook');
expect(webhookConfigSource).not.toContain('My Webhook');
@@ -3915,6 +3913,24 @@ 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(webhookConfigListSource).toContain('getAlertWebhookServiceLabelFromTemplates');
expect(webhookConfigListSource).toContain('getAlertWebhookSummaryLabel');
expect(webhookConfigListSource).toContain('getAlertWebhookToggleAllLabel');
expect(webhookConfigListSource).toContain('getAlertWebhookToggleLabel');
expect(webhookConfigListSource).toContain('getAlertWebhookTestLabel');
expect(webhookConfigFormSource).toContain('getAlertWebhookServices');
expect(webhookConfigFormSource).toContain('getAlertWebhookNamePlaceholder');
expect(webhookConfigFormSource).toContain('getAlertWebhookUrlPlaceholder');
expect(webhookConfigFormSource).toContain(
'getAlertWebhookMentionPlaceholderFromTemplates',
);
expect(webhookConfigFormSource).toContain('getAlertWebhookMentionHelpFromTemplates');
expect(webhookConfigFormSource).toContain('hasAlertWebhookMentionSupportFromTemplates');
expect(webhookConfigFormSource).toContain('getAlertWebhookTestLabel');
expect(webhookConfigFormSource).toContain('getAlertWebhookSubmitLabel');
expect(webhookConfigStateSource).toContain('NotificationsAPI.getWebhookTemplates');
expect(webhookConfigStateSource).toContain('getAlertWebhookCustomFieldInputs');
expect(webhookConfigStateSource).toContain('normalizeAlertWebhookCustomFields');
expect(alertWebhookPresentationSource).toContain(
'export function getAlertWebhookServiceLabelFromTemplates',
);