Split alert destinations tab section owners

This commit is contained in:
rcourtman
2026-03-22 17:08:14 +00:00
parent 46c9291e81
commit 07ec62d597
10 changed files with 489 additions and 379 deletions
@@ -256,8 +256,12 @@ owns webhook load/mutate/test flow,
destination test actions plus retry orchestration around that webhook runtime,
while
`frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx` stays the
destinations render shell. Future config cleanup should extend the config
transport hook, the config model, the override-projection hook, or the
destinations render shell and composes
`frontend-modern/src/features/alerts/AlertEmailDestinationsSection.tsx`,
`frontend-modern/src/features/alerts/AlertAppriseDestinationsSection.tsx`,
`frontend-modern/src/features/alerts/AlertWebhookDestinationsSection.tsx`, and
the dedicated load/error wrappers. Future config cleanup should extend the
config transport hook, the config model, the override-projection hook, or the
destinations runtime hook based on which subsystem actually owns the behavior
instead of letting the broader configuration hook absorb all four concerns
again.
@@ -456,9 +456,11 @@ owns webhook runtime, and
`frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts` now owns
destination test actions plus retry orchestration while
`frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx` stays the
render shell. Future cleanup should extend the transport hook, config model,
override hook, or destinations runtime hook based on the true owner, not move
config control flow back into the top-level page shell.
render shell and should compose the dedicated email, Apprise, webhook, and
load/error section owners instead of carrying those panels inline. Future
cleanup should extend the transport hook, config model, override hook, or
destinations runtime hook based on the true owner, not move config control
flow back into the top-level page shell.
The alert email provider picker now also follows the shell/runtime split:
`frontend-modern/src/components/Alerts/useEmailProviderSelectState.ts` owns
provider-catalog loading and provider-default application, while
@@ -0,0 +1,251 @@
import { Show } from 'solid-js';
import { SettingsPanel } from '@/components/shared/SettingsPanel';
import { Toggle } from '@/components/shared/Toggle';
import {
formControl,
formField,
formHelpText,
labelClass,
} from '@/components/shared/Form';
import type { UIAppriseConfig } from './types';
import {
ALERT_DESTINATIONS_APPRISE_API_KEY_HEADER_HELP,
ALERT_DESTINATIONS_APPRISE_API_KEY_HEADER_LABEL,
ALERT_DESTINATIONS_APPRISE_API_KEY_HEADER_PLACEHOLDER,
ALERT_DESTINATIONS_APPRISE_API_KEY_HELP,
ALERT_DESTINATIONS_APPRISE_API_KEY_LABEL,
ALERT_DESTINATIONS_APPRISE_API_KEY_PLACEHOLDER,
ALERT_DESTINATIONS_APPRISE_CLI_PATH_HELP,
ALERT_DESTINATIONS_APPRISE_CLI_PATH_LABEL,
ALERT_DESTINATIONS_APPRISE_CLI_PATH_PLACEHOLDER,
ALERT_DESTINATIONS_APPRISE_CONFIG_KEY_HELP,
ALERT_DESTINATIONS_APPRISE_CONFIG_KEY_LABEL,
ALERT_DESTINATIONS_APPRISE_CONFIG_KEY_PLACEHOLDER,
ALERT_DESTINATIONS_APPRISE_MODE_CLI_LABEL,
ALERT_DESTINATIONS_APPRISE_MODE_HELP,
ALERT_DESTINATIONS_APPRISE_MODE_HTTP_LABEL,
ALERT_DESTINATIONS_APPRISE_MODE_LABEL,
ALERT_DESTINATIONS_APPRISE_PANEL_DESCRIPTION,
ALERT_DESTINATIONS_APPRISE_PANEL_TITLE,
ALERT_DESTINATIONS_APPRISE_SERVER_URL_HELP,
ALERT_DESTINATIONS_APPRISE_SERVER_URL_LABEL,
ALERT_DESTINATIONS_APPRISE_SERVER_URL_PLACEHOLDER,
ALERT_DESTINATIONS_APPRISE_TARGETS_LABEL,
ALERT_DESTINATIONS_APPRISE_TARGETS_PLACEHOLDER,
ALERT_DESTINATIONS_APPRISE_TIMEOUT_HELP,
ALERT_DESTINATIONS_APPRISE_TIMEOUT_LABEL,
ALERT_DESTINATIONS_APPRISE_TLS_CHECKBOX_LABEL,
ALERT_DESTINATIONS_APPRISE_TLS_HELP,
ALERT_DESTINATIONS_APPRISE_TLS_LABEL,
getAlertDestinationsAppriseTargetsHelp,
getAlertDestinationsAppriseTestLabel,
getAlertDestinationsStatusLabel,
} from '@/utils/alertDestinationsPresentation';
interface AlertAppriseDestinationsSectionProps {
config: UIAppriseConfig;
updateApprise: (partial: Partial<UIAppriseConfig>) => void;
setHasUnsavedChanges: (value: boolean) => void;
onTest: () => void;
testing: boolean;
}
export function AlertAppriseDestinationsSection(props: AlertAppriseDestinationsSectionProps) {
return (
<SettingsPanel
title={ALERT_DESTINATIONS_APPRISE_PANEL_TITLE}
description={ALERT_DESTINATIONS_APPRISE_PANEL_DESCRIPTION}
action={
<div class="flex items-center gap-3 sm:self-start">
<Toggle
checked={props.config.enabled}
onChange={(event) => {
props.updateApprise({ enabled: event.currentTarget.checked });
props.setHasUnsavedChanges(true);
}}
label={
<span class="text-xs font-medium text-muted">
{getAlertDestinationsStatusLabel(props.config.enabled)}
</span>
}
/>
<button
class="rounded-md border border-border px-3 py-2 text-sm font-medium text-base-content transition hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60"
disabled={!props.config.enabled || props.testing}
onClick={props.onTest}
>
{getAlertDestinationsAppriseTestLabel(props.testing)}
</button>
</div>
}
class="min-w-0"
bodyClass="space-y-4"
>
<div class="space-y-4">
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
{ALERT_DESTINATIONS_APPRISE_MODE_LABEL}
</label>
<select
class={formControl}
value={props.config.mode}
onInput={(event) => {
props.updateApprise({ mode: event.currentTarget.value as 'cli' | 'http' });
props.setHasUnsavedChanges(true);
}}
>
<option value="cli">{ALERT_DESTINATIONS_APPRISE_MODE_CLI_LABEL}</option>
<option value="http">{ALERT_DESTINATIONS_APPRISE_MODE_HTTP_LABEL}</option>
</select>
<p class={formHelpText}>{ALERT_DESTINATIONS_APPRISE_MODE_HELP}</p>
</div>
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
{ALERT_DESTINATIONS_APPRISE_TARGETS_LABEL}
</label>
<textarea
rows={4}
class={`${formControl} min-h-[120px] font-mono`}
value={props.config.targetsText}
placeholder={ALERT_DESTINATIONS_APPRISE_TARGETS_PLACEHOLDER}
onInput={(event) => {
props.updateApprise({ targetsText: event.currentTarget.value });
props.setHasUnsavedChanges(true);
}}
/>
<p class={formHelpText}>{getAlertDestinationsAppriseTargetsHelp(props.config.mode)}</p>
</div>
<Show when={props.config.mode === 'cli'}>
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
{ALERT_DESTINATIONS_APPRISE_CLI_PATH_LABEL}
</label>
<input
type="text"
value={props.config.cliPath}
class={formControl}
placeholder={ALERT_DESTINATIONS_APPRISE_CLI_PATH_PLACEHOLDER}
onInput={(event) => {
props.updateApprise({ cliPath: event.currentTarget.value });
props.setHasUnsavedChanges(true);
}}
/>
<p class={formHelpText}>{ALERT_DESTINATIONS_APPRISE_CLI_PATH_HELP}</p>
</div>
</Show>
<Show when={props.config.mode === 'http'}>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class={`${formField} sm:col-span-2`}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
{ALERT_DESTINATIONS_APPRISE_SERVER_URL_LABEL}
</label>
<input
type="text"
value={props.config.serverUrl}
class={formControl}
placeholder={ALERT_DESTINATIONS_APPRISE_SERVER_URL_PLACEHOLDER}
onInput={(event) => {
props.updateApprise({ serverUrl: event.currentTarget.value });
props.setHasUnsavedChanges(true);
}}
/>
<p class={formHelpText}>{ALERT_DESTINATIONS_APPRISE_SERVER_URL_HELP}</p>
</div>
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
{ALERT_DESTINATIONS_APPRISE_CONFIG_KEY_LABEL}
</label>
<input
type="text"
value={props.config.configKey}
class={formControl}
placeholder={ALERT_DESTINATIONS_APPRISE_CONFIG_KEY_PLACEHOLDER}
onInput={(event) => {
props.updateApprise({ configKey: event.currentTarget.value });
props.setHasUnsavedChanges(true);
}}
/>
<p class={formHelpText}>{ALERT_DESTINATIONS_APPRISE_CONFIG_KEY_HELP}</p>
</div>
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
{ALERT_DESTINATIONS_APPRISE_API_KEY_LABEL}
</label>
<input
type="password"
value={props.config.apiKey}
class={formControl}
placeholder={ALERT_DESTINATIONS_APPRISE_API_KEY_PLACEHOLDER}
onInput={(event) => {
props.updateApprise({ apiKey: event.currentTarget.value });
props.setHasUnsavedChanges(true);
}}
/>
<p class={formHelpText}>{ALERT_DESTINATIONS_APPRISE_API_KEY_HELP}</p>
</div>
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
{ALERT_DESTINATIONS_APPRISE_API_KEY_HEADER_LABEL}
</label>
<input
type="text"
value={props.config.apiKeyHeader}
class={formControl}
placeholder={ALERT_DESTINATIONS_APPRISE_API_KEY_HEADER_PLACEHOLDER}
onInput={(event) => {
props.updateApprise({ apiKeyHeader: event.currentTarget.value });
props.setHasUnsavedChanges(true);
}}
/>
<p class={formHelpText}>{ALERT_DESTINATIONS_APPRISE_API_KEY_HEADER_HELP}</p>
</div>
<div class={`${formField} sm:col-span-2`}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
{ALERT_DESTINATIONS_APPRISE_TLS_LABEL}
</label>
<label class="inline-flex items-center gap-2">
<input
type="checkbox"
class="h-4 w-4 rounded border border-border"
checked={props.config.skipTlsVerify}
onChange={(event) => {
props.updateApprise({ skipTlsVerify: event.currentTarget.checked });
props.setHasUnsavedChanges(true);
}}
/>
<span class="text-sm text-muted">
{ALERT_DESTINATIONS_APPRISE_TLS_CHECKBOX_LABEL}
</span>
</label>
<p class={formHelpText}>{ALERT_DESTINATIONS_APPRISE_TLS_HELP}</p>
</div>
</div>
</Show>
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
{ALERT_DESTINATIONS_APPRISE_TIMEOUT_LABEL}
</label>
<input
type="number"
min="5"
max="120"
value={props.config.timeoutSeconds}
class={formControl}
onInput={(event) => {
const raw = event.currentTarget.valueAsNumber;
const safe = Number.isNaN(raw) ? 15 : Math.min(120, Math.max(5, Math.trunc(raw)));
props.updateApprise({ timeoutSeconds: safe });
props.setHasUnsavedChanges(true);
}}
/>
<p class={formHelpText}>{ALERT_DESTINATIONS_APPRISE_TIMEOUT_HELP}</p>
</div>
</div>
</SettingsPanel>
);
}
@@ -0,0 +1,35 @@
import AlertTriangleIcon from 'lucide-solid/icons/alert-triangle';
import { Card } from '@/components/shared/Card';
import {
getAlertDestinationsLoadErrorBanner,
getAlertDestinationsRetryLabel,
} from '@/utils/alertDestinationsPresentation';
interface AlertDestinationsLoadErrorCardProps {
error: string;
isRetrying: boolean;
onRetry: () => void;
}
export function AlertDestinationsLoadErrorCard(props: AlertDestinationsLoadErrorCardProps) {
return (
<Card tone="danger" padding="sm" class="border-red-200 dark:border-red-800 sm:p-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-2 text-red-800 dark:text-red-200">
<AlertTriangleIcon class="h-4 w-4 flex-shrink-0" />
<span class="text-sm font-medium">
{getAlertDestinationsLoadErrorBanner(props.error)}
</span>
</div>
<button
class="flex-shrink-0 rounded-md border border-red-300 bg-transparent px-3 py-1.5 text-sm font-medium text-red-800 transition hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-red-700 dark:text-red-200 dark:hover:bg-red-900/30"
disabled={props.isRetrying}
onClick={props.onRetry}
>
{getAlertDestinationsRetryLabel(props.isRetrying)}
</button>
</div>
</Card>
);
}
@@ -0,0 +1,44 @@
export function AlertDestinationsLoadingState() {
return (
<div class="flex w-full flex-col gap-6 animate-pulse pointer-events-none select-none md:gap-8">
<div class="rounded-lg border border-border bg-surface p-6 space-y-4">
<div class="flex items-center justify-between">
<div class="space-y-2">
<div class="h-5 w-40 rounded bg-surface-hover" />
<div class="h-3 w-64 rounded bg-surface-hover" />
</div>
<div class="h-6 w-12 rounded-full bg-surface-hover" />
</div>
<div class="space-y-3">
<div class="h-4 w-24 rounded bg-surface-hover" />
<div class="h-10 w-full rounded bg-surface-hover" />
<div class="h-4 w-32 rounded bg-surface-hover" />
<div class="h-10 w-full rounded bg-surface-hover" />
</div>
</div>
<div class="rounded-lg border border-border bg-surface p-6 space-y-4">
<div class="flex items-center justify-between">
<div class="space-y-2">
<div class="h-5 w-44 rounded bg-surface-hover" />
<div class="h-3 w-72 rounded bg-surface-hover" />
</div>
<div class="h-6 w-12 rounded-full bg-surface-hover" />
</div>
<div class="space-y-3">
<div class="h-4 w-28 rounded bg-surface-hover" />
<div class="h-10 w-full rounded bg-surface-hover" />
</div>
</div>
<div class="rounded-lg border border-border bg-surface p-6 space-y-4">
<div class="flex items-center justify-between">
<div class="space-y-2">
<div class="h-5 w-28 rounded bg-surface-hover" />
<div class="h-3 w-56 rounded bg-surface-hover" />
</div>
<div class="h-4 w-20 rounded bg-surface-hover" />
</div>
<div class="h-10 w-full rounded bg-surface-hover" />
</div>
</div>
);
}
@@ -0,0 +1,60 @@
import { EmailProviderSelect } from '@/components/Alerts/EmailProviderSelect';
import { SettingsPanel } from '@/components/shared/SettingsPanel';
import { Toggle } from '@/components/shared/Toggle';
import type { UIEmailConfig } from './types';
import {
ALERT_DESTINATIONS_EMAIL_PANEL_DESCRIPTION,
ALERT_DESTINATIONS_EMAIL_PANEL_TITLE,
getAlertDestinationsStatusLabel,
} from '@/utils/alertDestinationsPresentation';
interface AlertEmailDestinationsSectionProps {
config: UIEmailConfig;
setConfig: (config: UIEmailConfig) => void;
setHasUnsavedChanges: (value: boolean) => void;
onTest: () => void;
testing: boolean;
}
export function AlertEmailDestinationsSection(props: AlertEmailDestinationsSectionProps) {
return (
<SettingsPanel
title={ALERT_DESTINATIONS_EMAIL_PANEL_TITLE}
description={ALERT_DESTINATIONS_EMAIL_PANEL_DESCRIPTION}
action={
<Toggle
checked={props.config.enabled}
onChange={(event) => {
props.setConfig({
...props.config,
enabled: event.currentTarget.checked,
});
props.setHasUnsavedChanges(true);
}}
containerClass="sm:self-start"
label={
<span class="text-xs font-medium text-muted">
{getAlertDestinationsStatusLabel(props.config.enabled)}
</span>
}
/>
}
class="min-w-0"
bodyClass=""
>
<div
class={`${!props.config.enabled ? 'pointer-events-none opacity-50 transition-opacity' : 'transition-opacity'}`}
>
<EmailProviderSelect
config={props.config}
onChange={(config) => {
props.setConfig(config);
props.setHasUnsavedChanges(true);
}}
onTest={props.onTest}
testing={props.testing}
/>
</div>
</SettingsPanel>
);
}
@@ -0,0 +1,37 @@
import { WebhookConfig } from '@/components/Alerts/WebhookConfig';
import { SettingsPanel } from '@/components/shared/SettingsPanel';
import type { Webhook } from '@/api/notifications';
import {
getAlertWebhooksSectionDescription,
getAlertWebhooksSectionTitle,
} from '@/utils/alertWebhookPresentation';
interface AlertWebhookDestinationsSectionProps {
webhooks: Webhook[];
addWebhook: (webhook: Omit<Webhook, 'id'>) => void;
updateWebhook: (webhook: Webhook) => void;
deleteWebhook: (id: string) => void;
testWebhook: (webhookId: string, webhookData?: Omit<Webhook, 'id'>) => void;
testingWebhook: string | null;
}
export function AlertWebhookDestinationsSection(props: AlertWebhookDestinationsSectionProps) {
return (
<SettingsPanel
title={getAlertWebhooksSectionTitle()}
description={getAlertWebhooksSectionDescription()}
action={<span class="whitespace-nowrap text-xs text-muted">{props.webhooks.length} configured</span>}
class="min-w-0"
bodyClass="space-y-4"
>
<WebhookConfig
webhooks={props.webhooks}
onAdd={props.addWebhook}
onUpdate={props.updateWebhook}
onDelete={props.deleteWebhook}
onTest={props.testWebhook}
testing={props.testingWebhook}
/>
</SettingsPanel>
);
}
@@ -1,58 +1,9 @@
import { Show } from 'solid-js';
import AlertTriangleIcon from 'lucide-solid/icons/alert-triangle';
import { EmailProviderSelect } from '@/components/Alerts/EmailProviderSelect';
import { WebhookConfig } from '@/components/Alerts/WebhookConfig';
import { Card } from '@/components/shared/Card';
import {
formControl,
formField,
formHelpText,
labelClass,
} from '@/components/shared/Form';
import { SettingsPanel } from '@/components/shared/SettingsPanel';
import { Toggle } from '@/components/shared/Toggle';
import {
ALERT_DESTINATIONS_APPRISE_API_KEY_HEADER_HELP,
ALERT_DESTINATIONS_APPRISE_API_KEY_HEADER_LABEL,
ALERT_DESTINATIONS_APPRISE_API_KEY_HEADER_PLACEHOLDER,
ALERT_DESTINATIONS_APPRISE_API_KEY_HELP,
ALERT_DESTINATIONS_APPRISE_API_KEY_LABEL,
ALERT_DESTINATIONS_APPRISE_API_KEY_PLACEHOLDER,
ALERT_DESTINATIONS_APPRISE_CLI_PATH_HELP,
ALERT_DESTINATIONS_APPRISE_CLI_PATH_LABEL,
ALERT_DESTINATIONS_APPRISE_CLI_PATH_PLACEHOLDER,
ALERT_DESTINATIONS_APPRISE_CONFIG_KEY_HELP,
ALERT_DESTINATIONS_APPRISE_CONFIG_KEY_LABEL,
ALERT_DESTINATIONS_APPRISE_CONFIG_KEY_PLACEHOLDER,
ALERT_DESTINATIONS_APPRISE_MODE_CLI_LABEL,
ALERT_DESTINATIONS_APPRISE_MODE_HELP,
ALERT_DESTINATIONS_APPRISE_MODE_HTTP_LABEL,
ALERT_DESTINATIONS_APPRISE_MODE_LABEL,
ALERT_DESTINATIONS_APPRISE_PANEL_DESCRIPTION,
ALERT_DESTINATIONS_APPRISE_PANEL_TITLE,
ALERT_DESTINATIONS_APPRISE_SERVER_URL_HELP,
ALERT_DESTINATIONS_APPRISE_SERVER_URL_LABEL,
ALERT_DESTINATIONS_APPRISE_SERVER_URL_PLACEHOLDER,
ALERT_DESTINATIONS_APPRISE_TARGETS_LABEL,
ALERT_DESTINATIONS_APPRISE_TARGETS_PLACEHOLDER,
ALERT_DESTINATIONS_APPRISE_TIMEOUT_HELP,
ALERT_DESTINATIONS_APPRISE_TIMEOUT_LABEL,
ALERT_DESTINATIONS_APPRISE_TLS_CHECKBOX_LABEL,
ALERT_DESTINATIONS_APPRISE_TLS_HELP,
ALERT_DESTINATIONS_APPRISE_TLS_LABEL,
ALERT_DESTINATIONS_EMAIL_PANEL_DESCRIPTION,
ALERT_DESTINATIONS_EMAIL_PANEL_TITLE,
getAlertDestinationsAppriseTargetsHelp,
getAlertDestinationsAppriseTestLabel,
getAlertDestinationsLoadErrorBanner,
getAlertDestinationsRetryLabel,
getAlertDestinationsStatusLabel,
} from '@/utils/alertDestinationsPresentation';
import {
getAlertWebhooksSectionDescription,
getAlertWebhooksSectionTitle,
} from '@/utils/alertWebhookPresentation';
import { AlertAppriseDestinationsSection } from '../AlertAppriseDestinationsSection';
import { AlertDestinationsLoadErrorCard } from '../AlertDestinationsLoadErrorCard';
import { AlertDestinationsLoadingState } from '../AlertDestinationsLoadingState';
import { AlertEmailDestinationsSection } from '../AlertEmailDestinationsSection';
import { AlertWebhookDestinationsSection } from '../AlertWebhookDestinationsSection';
import { useAlertDestinationsTabState, type AlertDestinationsTabStateProps } from '../useAlertDestinationsTabState';
@@ -68,325 +19,40 @@ export function DestinationsTab(props: DestinationsTabProps) {
<div class="flex w-full max-w-full flex-col gap-6 md:gap-8">
<Show
when={!state.isLoading()}
fallback={
<div class="flex w-full flex-col gap-6 animate-pulse pointer-events-none select-none md:gap-8">
<div class="rounded-lg border border-border bg-surface p-6 space-y-4">
<div class="flex items-center justify-between">
<div class="space-y-2">
<div class="h-5 w-40 rounded bg-surface-hover" />
<div class="h-3 w-64 rounded bg-surface-hover" />
</div>
<div class="h-6 w-12 rounded-full bg-surface-hover" />
</div>
<div class="space-y-3">
<div class="h-4 w-24 rounded bg-surface-hover" />
<div class="h-10 w-full rounded bg-surface-hover" />
<div class="h-4 w-32 rounded bg-surface-hover" />
<div class="h-10 w-full rounded bg-surface-hover" />
</div>
</div>
<div class="rounded-lg border border-border bg-surface p-6 space-y-4">
<div class="flex items-center justify-between">
<div class="space-y-2">
<div class="h-5 w-44 rounded bg-surface-hover" />
<div class="h-3 w-72 rounded bg-surface-hover" />
</div>
<div class="h-6 w-12 rounded-full bg-surface-hover" />
</div>
<div class="space-y-3">
<div class="h-4 w-28 rounded bg-surface-hover" />
<div class="h-10 w-full rounded bg-surface-hover" />
</div>
</div>
<div class="rounded-lg border border-border bg-surface p-6 space-y-4">
<div class="flex items-center justify-between">
<div class="space-y-2">
<div class="h-5 w-28 rounded bg-surface-hover" />
<div class="h-3 w-56 rounded bg-surface-hover" />
</div>
<div class="h-4 w-20 rounded bg-surface-hover" />
</div>
<div class="h-10 w-full rounded bg-surface-hover" />
</div>
</div>
}
fallback={<AlertDestinationsLoadingState />}
>
<Show when={state.hasLoadError()}>
<Card tone="danger" padding="sm" class="border-red-200 dark:border-red-800 sm:p-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-2 text-red-800 dark:text-red-200">
<AlertTriangleIcon class="h-4 w-4 flex-shrink-0" />
<span class="text-sm font-medium">
{getAlertDestinationsLoadErrorBanner(
props.configLoadError() || state.webhookLoadError() || '',
)}
</span>
</div>
<button
class="flex-shrink-0 rounded-md border border-red-300 bg-transparent px-3 py-1.5 text-sm font-medium text-red-800 transition hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-red-700 dark:text-red-200 dark:hover:bg-red-900/30"
disabled={props.isRetrying()}
onClick={state.handleRetry}
>
{getAlertDestinationsRetryLabel(props.isRetrying())}
</button>
</div>
</Card>
<AlertDestinationsLoadErrorCard
error={props.configLoadError() || state.webhookLoadError() || ''}
isRetrying={props.isRetrying()}
onRetry={state.handleRetry}
/>
</Show>
<SettingsPanel
title={ALERT_DESTINATIONS_EMAIL_PANEL_TITLE}
description={ALERT_DESTINATIONS_EMAIL_PANEL_DESCRIPTION}
action={
<Toggle
checked={props.emailConfig().enabled}
onChange={(event) => {
props.setEmailConfig({
...props.emailConfig(),
enabled: event.currentTarget.checked,
});
props.setHasUnsavedChanges(true);
}}
containerClass="sm:self-start"
label={
<span class="text-xs font-medium text-muted">
{getAlertDestinationsStatusLabel(props.emailConfig().enabled)}
</span>
}
/>
}
class="min-w-0"
bodyClass=""
>
<div
class={`${!props.emailConfig().enabled ? 'pointer-events-none opacity-50 transition-opacity' : 'transition-opacity'}`}
>
<EmailProviderSelect
config={props.emailConfig()}
onChange={(config) => {
props.setEmailConfig(config);
props.setHasUnsavedChanges(true);
}}
onTest={state.testEmailConfig}
testing={state.testingEmail()}
/>
</div>
</SettingsPanel>
<AlertEmailDestinationsSection
config={props.emailConfig()}
setConfig={props.setEmailConfig}
setHasUnsavedChanges={props.setHasUnsavedChanges}
onTest={state.testEmailConfig}
testing={state.testingEmail()}
/>
<SettingsPanel
title={ALERT_DESTINATIONS_APPRISE_PANEL_TITLE}
description={ALERT_DESTINATIONS_APPRISE_PANEL_DESCRIPTION}
action={
<div class="flex items-center gap-3 sm:self-start">
<Toggle
checked={state.appriseState().enabled}
onChange={(event) => {
state.updateApprise({ enabled: event.currentTarget.checked });
props.setHasUnsavedChanges(true);
}}
label={
<span class="text-xs font-medium text-muted">
{getAlertDestinationsStatusLabel(state.appriseState().enabled)}
</span>
}
/>
<button
class="rounded-md border border-border px-3 py-2 text-sm font-medium text-base-content transition hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60"
disabled={!state.appriseState().enabled || state.testingApprise()}
onClick={state.testApprise}
>
{getAlertDestinationsAppriseTestLabel(state.testingApprise())}
</button>
</div>
}
class="min-w-0"
bodyClass="space-y-4"
>
<div class="space-y-4">
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
{ALERT_DESTINATIONS_APPRISE_MODE_LABEL}
</label>
<select
class={formControl}
value={state.appriseState().mode}
onInput={(event) => {
state.updateApprise({ mode: event.currentTarget.value as 'cli' | 'http' });
props.setHasUnsavedChanges(true);
}}
>
<option value="cli">{ALERT_DESTINATIONS_APPRISE_MODE_CLI_LABEL}</option>
<option value="http">{ALERT_DESTINATIONS_APPRISE_MODE_HTTP_LABEL}</option>
</select>
<p class={formHelpText}>{ALERT_DESTINATIONS_APPRISE_MODE_HELP}</p>
</div>
<AlertAppriseDestinationsSection
config={state.appriseState()}
updateApprise={state.updateApprise}
setHasUnsavedChanges={props.setHasUnsavedChanges}
onTest={state.testApprise}
testing={state.testingApprise()}
/>
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
{ALERT_DESTINATIONS_APPRISE_TARGETS_LABEL}
</label>
<textarea
rows={4}
class={`${formControl} min-h-[120px] font-mono`}
value={state.appriseState().targetsText}
placeholder={ALERT_DESTINATIONS_APPRISE_TARGETS_PLACEHOLDER}
onInput={(event) => {
state.updateApprise({ targetsText: event.currentTarget.value });
props.setHasUnsavedChanges(true);
}}
/>
<p class={formHelpText}>
{getAlertDestinationsAppriseTargetsHelp(state.appriseState().mode)}
</p>
</div>
<Show when={state.appriseState().mode === 'cli'}>
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
{ALERT_DESTINATIONS_APPRISE_CLI_PATH_LABEL}
</label>
<input
type="text"
value={state.appriseState().cliPath}
class={formControl}
placeholder={ALERT_DESTINATIONS_APPRISE_CLI_PATH_PLACEHOLDER}
onInput={(event) => {
state.updateApprise({ cliPath: event.currentTarget.value });
props.setHasUnsavedChanges(true);
}}
/>
<p class={formHelpText}>{ALERT_DESTINATIONS_APPRISE_CLI_PATH_HELP}</p>
</div>
</Show>
<Show when={state.appriseState().mode === 'http'}>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class={`${formField} sm:col-span-2`}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
{ALERT_DESTINATIONS_APPRISE_SERVER_URL_LABEL}
</label>
<input
type="text"
value={state.appriseState().serverUrl}
class={formControl}
placeholder={ALERT_DESTINATIONS_APPRISE_SERVER_URL_PLACEHOLDER}
onInput={(event) => {
state.updateApprise({ serverUrl: event.currentTarget.value });
props.setHasUnsavedChanges(true);
}}
/>
<p class={formHelpText}>{ALERT_DESTINATIONS_APPRISE_SERVER_URL_HELP}</p>
</div>
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
{ALERT_DESTINATIONS_APPRISE_CONFIG_KEY_LABEL}
</label>
<input
type="text"
value={state.appriseState().configKey}
class={formControl}
placeholder={ALERT_DESTINATIONS_APPRISE_CONFIG_KEY_PLACEHOLDER}
onInput={(event) => {
state.updateApprise({ configKey: event.currentTarget.value });
props.setHasUnsavedChanges(true);
}}
/>
<p class={formHelpText}>{ALERT_DESTINATIONS_APPRISE_CONFIG_KEY_HELP}</p>
</div>
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
{ALERT_DESTINATIONS_APPRISE_API_KEY_LABEL}
</label>
<input
type="password"
value={state.appriseState().apiKey}
class={formControl}
placeholder={ALERT_DESTINATIONS_APPRISE_API_KEY_PLACEHOLDER}
onInput={(event) => {
state.updateApprise({ apiKey: event.currentTarget.value });
props.setHasUnsavedChanges(true);
}}
/>
<p class={formHelpText}>{ALERT_DESTINATIONS_APPRISE_API_KEY_HELP}</p>
</div>
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
{ALERT_DESTINATIONS_APPRISE_API_KEY_HEADER_LABEL}
</label>
<input
type="text"
value={state.appriseState().apiKeyHeader}
class={formControl}
placeholder={ALERT_DESTINATIONS_APPRISE_API_KEY_HEADER_PLACEHOLDER}
onInput={(event) => {
state.updateApprise({ apiKeyHeader: event.currentTarget.value });
props.setHasUnsavedChanges(true);
}}
/>
<p class={formHelpText}>{ALERT_DESTINATIONS_APPRISE_API_KEY_HEADER_HELP}</p>
</div>
<div class={`${formField} sm:col-span-2`}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
{ALERT_DESTINATIONS_APPRISE_TLS_LABEL}
</label>
<label class="inline-flex items-center gap-2">
<input
type="checkbox"
class="h-4 w-4 rounded border border-border"
checked={state.appriseState().skipTlsVerify}
onChange={(event) => {
state.updateApprise({ skipTlsVerify: event.currentTarget.checked });
props.setHasUnsavedChanges(true);
}}
/>
<span class="text-sm text-muted">
{ALERT_DESTINATIONS_APPRISE_TLS_CHECKBOX_LABEL}
</span>
</label>
<p class={formHelpText}>{ALERT_DESTINATIONS_APPRISE_TLS_HELP}</p>
</div>
</div>
</Show>
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
{ALERT_DESTINATIONS_APPRISE_TIMEOUT_LABEL}
</label>
<input
type="number"
min="5"
max="120"
value={state.appriseState().timeoutSeconds}
class={formControl}
onInput={(event) => {
const raw = event.currentTarget.valueAsNumber;
const safe = Number.isNaN(raw) ? 15 : Math.min(120, Math.max(5, Math.trunc(raw)));
state.updateApprise({ timeoutSeconds: safe });
props.setHasUnsavedChanges(true);
}}
/>
<p class={formHelpText}>{ALERT_DESTINATIONS_APPRISE_TIMEOUT_HELP}</p>
</div>
</div>
</SettingsPanel>
<SettingsPanel
title={getAlertWebhooksSectionTitle()}
description={getAlertWebhooksSectionDescription()}
action={
<span class="whitespace-nowrap text-xs text-muted">{state.webhooks().length} configured</span>
}
class="min-w-0"
bodyClass="space-y-4"
>
<WebhookConfig
webhooks={state.webhooks()}
onAdd={state.addWebhook}
onUpdate={state.updateWebhook}
onDelete={state.deleteWebhook}
onTest={state.testWebhook}
testing={state.testingWebhook()}
/>
</SettingsPanel>
<AlertWebhookDestinationsSection
webhooks={state.webhooks()}
addWebhook={state.addWebhook}
updateWebhook={state.updateWebhook}
deleteWebhook={state.deleteWebhook}
testWebhook={state.testWebhook}
testingWebhook={state.testingWebhook()}
/>
</Show>
</div>
);
@@ -248,9 +248,17 @@ describe('tab path helpers', () => {
expect(alertsPageSource).not.toContain('function HistoryTab(');
expect(alertHistoryTabSource).toContain('useAlertHistoryState');
expect(alertDestinationsTabSource).toContain('useAlertDestinationsTabState');
expect(alertDestinationsTabSource).toContain('AlertDestinationsLoadingState');
expect(alertDestinationsTabSource).toContain('AlertDestinationsLoadErrorCard');
expect(alertDestinationsTabSource).toContain('AlertEmailDestinationsSection');
expect(alertDestinationsTabSource).toContain('AlertAppriseDestinationsSection');
expect(alertDestinationsTabSource).toContain('AlertWebhookDestinationsSection');
expect(alertDestinationsTabSource).not.toContain('NotificationsAPI.getWebhooks');
expect(alertDestinationsTabSource).not.toContain('NotificationsAPI.testNotification');
expect(alertDestinationsTabSource).not.toContain('NotificationsAPI.createWebhook');
expect(alertDestinationsTabSource).not.toContain('ALERT_DESTINATIONS_EMAIL_PANEL_TITLE');
expect(alertDestinationsTabSource).not.toContain('ALERT_DESTINATIONS_APPRISE_PANEL_TITLE');
expect(alertDestinationsTabSource).not.toContain('getAlertWebhooksSectionTitle');
expect(alertHistoryTabSource).toContain('IncidentTimelinePanel');
expect(alertHistoryTabSource).not.toContain('useAlertIncidentTimelineState');
expect(alertHistoryTabSource).not.toContain('AlertsAPI.getHistory');
@@ -2823,15 +2823,18 @@ describe('frontend resource type boundaries', () => {
expect(alertWebhookDestinationsStateSource).toContain(
'getAlertDestinationsWebhookLoadError',
);
expect(alertDestinationsTabSource).toContain('getAlertDestinationsLoadErrorBanner');
expect(alertDestinationsTabSource).toContain('getAlertDestinationsAppriseTargetsHelp');
expect(alertDestinationsTabSource).toContain('getAlertDestinationsAppriseTestLabel');
expect(alertDestinationsTabSource).toContain('getAlertDestinationsRetryLabel');
expect(alertDestinationsTabSource).toContain('getAlertDestinationsStatusLabel');
expect(alertDestinationsTabSource).toContain('useAlertDestinationsTabState');
expect(alertDestinationsTabSource).toContain('AlertDestinationsLoadingState');
expect(alertDestinationsTabSource).toContain('AlertDestinationsLoadErrorCard');
expect(alertDestinationsTabSource).toContain('AlertEmailDestinationsSection');
expect(alertDestinationsTabSource).toContain('AlertAppriseDestinationsSection');
expect(alertDestinationsTabSource).toContain('AlertWebhookDestinationsSection');
expect(alertDestinationsTabSource).not.toContain('NotificationsAPI.getWebhooks');
expect(alertDestinationsTabSource).not.toContain('NotificationsAPI.testNotification');
expect(alertDestinationsTabSource).not.toContain('NotificationsAPI.createWebhook');
expect(alertDestinationsTabSource).not.toContain('ALERT_DESTINATIONS_EMAIL_PANEL_TITLE');
expect(alertDestinationsTabSource).not.toContain('ALERT_DESTINATIONS_APPRISE_PANEL_TITLE');
expect(alertDestinationsTabSource).not.toContain('getAlertWebhooksSectionTitle');
expect(alertsPageSource).toContain(
"import { HistoryTab } from '@/features/alerts/tabs/HistoryTab';",
);
@@ -3890,8 +3893,8 @@ describe('frontend resource type boundaries', () => {
expect(alertsPageSource).not.toContain('function ScheduleTab(');
expect(alertDestinationsTabSource).toContain('useAlertDestinationsTabState');
expect(alertDestinationsTabSource).not.toContain('NotificationsAPI.getWebhooks');
expect(alertDestinationsTabSource).toContain('EmailProviderSelect');
expect(alertDestinationsTabSource).toContain('WebhookConfig');
expect(alertDestinationsTabSource).toContain('AlertEmailDestinationsSection');
expect(alertDestinationsTabSource).toContain('AlertWebhookDestinationsSection');
expect(alertScheduleTabSource).toContain('getAlertConfigQuietHourSuppressOptions');
expect(alertScheduleTabSource).toContain('getAlertGroupingCardClass');
expect(alertScheduleTabSource).toContain('getAlertQuietDayButtonClass');