Extract alert destinations tab runtime owner

This commit is contained in:
rcourtman
2026-03-22 16:27:05 +00:00
parent ba92ddb194
commit 9549decc6e
7 changed files with 510 additions and 249 deletions
@@ -246,10 +246,14 @@ config transport, defaults, and save/load orchestration,
`frontend-modern/src/features/alerts/useAlertOverridesState.ts` for raw
override normalization plus resource-backed override projection, and
`frontend-modern/src/features/alerts/useAlertDestinationsState.ts` for
notification destination reload and persistence. Future config cleanup should
extend the config transport hook, the override-projection hook, or the
destinations hook based on which subsystem actually owns the behavior instead
of letting the broader configuration hook absorb all three concerns again.
notification destination reload and persistence.
`frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts` now owns
webhook load/mutate/test flow plus destination test actions, while
`frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx` stays the
destinations render shell. Future config cleanup should extend the config
transport hook, 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 three concerns again.
Alert filter metadata and grouped header consumers must also preserve the
canonical `agent` and `node` header boundary when reusing shared filter
@@ -447,9 +447,13 @@ is the feature shell. The canonical runtime owner is now
config transport, `frontend-modern/src/features/alerts/useAlertOverridesState.ts`
for override projection and thresholds-facing resource selectors, and
`frontend-modern/src/features/alerts/useAlertDestinationsState.ts` for
notification destination reload and persistence. Future cleanup should extend
the transport hook, override hook, or destinations hook based on the true
owner, not move config control flow back into the top-level page shell.
notification destination reload and persistence.
`frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts` now owns
webhook runtime and destination test actions while
`frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx` stays the
render shell. Future cleanup should extend the transport hook, override hook,
or destinations runtime hook based on the true owner, not move config control
flow back into the top-level page shell.
The same rule now also covers cross-tab incident timelines: the shared runtime
owner is `frontend-modern/src/features/alerts/useAlertIncidentTimelineState.ts`,
while `frontend-modern/src/features/alerts/OverviewTab.tsx` and
@@ -0,0 +1,199 @@
import { renderHook, waitFor } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { NotificationsAPI } from '@/api/notifications';
import { notificationStore } from '@/stores/notifications';
import { showErrorWithDetail } from '@/utils/toast';
import { useAlertDestinationsTabState } from '../useAlertDestinationsTabState';
import type { UIAppriseConfig, UIEmailConfig } from '../types';
vi.mock('@/api/notifications', () => ({
NotificationsAPI: {
createWebhook: vi.fn(),
deleteWebhook: vi.fn(),
getWebhooks: vi.fn(),
testNotification: vi.fn(),
testWebhook: vi.fn(),
updateWebhook: vi.fn(),
},
}));
vi.mock('@/stores/notifications', () => ({
notificationStore: {
error: vi.fn(),
success: vi.fn(),
},
}));
vi.mock('@/utils/logger', () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock('@/utils/toast', () => ({
showErrorWithDetail: vi.fn(),
}));
const buildEmailConfig = (): UIEmailConfig => ({
enabled: true,
from: 'pulse@example.com',
maxRetries: 3,
password: '',
port: 587,
provider: 'smtp',
rateLimit: 60,
replyTo: '',
retryDelay: 5,
server: 'smtp.example.com',
startTLS: true,
tls: true,
to: ['alerts@example.com'],
username: 'ops@example.com',
});
const buildAppriseConfig = (): UIAppriseConfig => ({
apiKey: '',
apiKeyHeader: 'X-API-KEY',
cliPath: '/usr/local/bin/apprise',
configKey: '',
enabled: true,
mode: 'cli',
serverUrl: '',
skipTlsVerify: false,
targetsText: 'mailto://alerts@example.com',
timeoutSeconds: 20,
});
describe('useAlertDestinationsTabState', () => {
beforeEach(() => {
vi.mocked(NotificationsAPI.createWebhook).mockReset();
vi.mocked(NotificationsAPI.deleteWebhook).mockReset();
vi.mocked(NotificationsAPI.getWebhooks).mockReset();
vi.mocked(NotificationsAPI.testNotification).mockReset();
vi.mocked(NotificationsAPI.testWebhook).mockReset();
vi.mocked(NotificationsAPI.updateWebhook).mockReset();
vi.mocked(notificationStore.error).mockReset();
vi.mocked(notificationStore.success).mockReset();
vi.mocked(showErrorWithDetail).mockReset();
});
it('owns webhook runtime and destination test actions separately from config load/save state', async () => {
const [emailConfig] = createSignal(buildEmailConfig());
const [appriseConfig, setAppriseConfig] = createSignal(buildAppriseConfig());
const [configLoadError] = createSignal<string | null>(null);
const [isRetrying] = createSignal(false);
const [isLoadingDestinations] = createSignal(false);
const onRetryLoad = vi.fn();
vi.mocked(NotificationsAPI.getWebhooks).mockResolvedValue([
{
enabled: true,
headers: {},
id: 'hook-1',
method: 'POST',
name: 'Ops',
url: 'https://hooks.example.test/ops',
},
] as never);
vi.mocked(NotificationsAPI.testNotification).mockResolvedValue({ success: true } as never);
vi.mocked(NotificationsAPI.testWebhook).mockResolvedValue({ success: true } as never);
vi.mocked(NotificationsAPI.createWebhook).mockResolvedValue({
enabled: true,
headers: {},
id: 'hook-2',
method: 'POST',
name: 'Pager',
service: 'slack',
url: 'https://hooks.example.test/pager',
} as never);
vi.mocked(NotificationsAPI.updateWebhook).mockResolvedValue({
enabled: false,
headers: {},
id: 'hook-2',
method: 'POST',
name: 'Pager Updated',
service: 'slack',
url: 'https://hooks.example.test/pager',
} as never);
vi.mocked(NotificationsAPI.deleteWebhook).mockResolvedValue({ success: true } as never);
const { result } = renderHook(() =>
useAlertDestinationsTabState({
appriseConfig,
configLoadError,
emailConfig,
isLoadingDestinations,
isRetrying,
onRetryLoad,
setAppriseConfig,
}),
);
await waitFor(() => expect(NotificationsAPI.getWebhooks).toHaveBeenCalledTimes(1));
expect(result.webhooks()).toEqual([
expect.objectContaining({ id: 'hook-1', service: 'generic' }),
]);
await result.testEmailConfig();
expect(NotificationsAPI.testNotification).toHaveBeenCalledWith(
expect.objectContaining({ type: 'email' }),
);
await result.testApprise();
expect(NotificationsAPI.testNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'apprise',
config: expect.objectContaining({
mode: 'cli',
targets: ['mailto://alerts@example.com'],
}),
}),
);
await result.addWebhook({
enabled: true,
headers: {},
method: 'POST',
name: 'Pager',
service: 'slack',
url: 'https://hooks.example.test/pager',
});
expect(result.webhooks().map((hook) => hook.id)).toEqual(['hook-1', 'hook-2']);
await result.updateWebhook({
enabled: true,
headers: {},
id: 'hook-2',
method: 'POST',
name: 'Pager',
service: 'slack',
url: 'https://hooks.example.test/pager',
});
expect(result.webhooks().find((hook) => hook.id === 'hook-2')).toEqual(
expect.objectContaining({ enabled: false, name: 'Pager Updated' }),
);
await result.testWebhook('hook-2');
expect(NotificationsAPI.testNotification).toHaveBeenCalledWith({
type: 'webhook',
webhookId: 'hook-2',
});
await result.deleteWebhook('hook-1');
expect(result.webhooks().map((hook) => hook.id)).toEqual(['hook-2']);
result.updateApprise({ mode: 'http', serverUrl: 'https://apprise.internal' });
expect(result.appriseState()).toEqual(
expect.objectContaining({ mode: 'http', serverUrl: 'https://apprise.internal' }),
);
result.handleRetry();
expect(onRetryLoad).toHaveBeenCalledTimes(1);
await waitFor(() => expect(NotificationsAPI.getWebhooks).toHaveBeenCalledTimes(2));
expect(notificationStore.success).toHaveBeenCalled();
expect(showErrorWithDetail).not.toHaveBeenCalled();
});
});
@@ -1,7 +1,6 @@
import { createSignal, onMount, Show } from 'solid-js';
import { Show } from 'solid-js';
import AlertTriangleIcon from 'lucide-solid/icons/alert-triangle';
import { NotificationsAPI, type Webhook } from '@/api/notifications';
import { EmailProviderSelect } from '@/components/Alerts/EmailProviderSelect';
import { WebhookConfig } from '@/components/Alerts/WebhookConfig';
import { Card } from '@/components/shared/Card';
@@ -13,9 +12,6 @@ import {
} from '@/components/shared/Form';
import { SettingsPanel } from '@/components/shared/SettingsPanel';
import { Toggle } from '@/components/shared/Toggle';
import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
import { showErrorWithDetail } from '@/utils/toast';
import {
ALERT_DESTINATIONS_APPRISE_API_KEY_HEADER_HELP,
ALERT_DESTINATIONS_APPRISE_API_KEY_HEADER_LABEL,
@@ -48,180 +44,30 @@ import {
ALERT_DESTINATIONS_EMAIL_PANEL_DESCRIPTION,
ALERT_DESTINATIONS_EMAIL_PANEL_TITLE,
getAlertDestinationsAppriseTargetsHelp,
getAlertDestinationsAppriseTestFailure,
getAlertDestinationsAppriseTestLabel,
getAlertDestinationsAppriseTestSuccess,
getAlertDestinationsAppriseValidationError,
getAlertDestinationsEmailTestFailure,
getAlertDestinationsEmailTestSuccess,
getAlertDestinationsLoadErrorBanner,
getAlertDestinationsRetryLabel,
getAlertDestinationsStatusLabel,
getAlertDestinationsWebhookLoadError,
} from '@/utils/alertDestinationsPresentation';
import {
getAlertWebhookMutationFailure,
getAlertWebhookMutationSuccess,
getAlertWebhookTestFailure,
getAlertWebhookTestSuccess,
getAlertWebhooksSectionDescription,
getAlertWebhooksSectionTitle,
} from '@/utils/alertWebhookPresentation';
import { parseAppriseTargets } from '../helpers';
import type { AppriseConfig } from '@/api/notifications';
import type { UIAppriseConfig, UIEmailConfig } from '../types';
import { useAlertDestinationsTabState, type AlertDestinationsTabStateProps } from '../useAlertDestinationsTabState';
export interface DestinationsTabProps {
export interface DestinationsTabProps extends AlertDestinationsTabStateProps {
setHasUnsavedChanges: (value: boolean) => void;
emailConfig: () => UIEmailConfig;
setEmailConfig: (config: UIEmailConfig) => void;
appriseConfig: () => UIAppriseConfig;
setAppriseConfig: (config: UIAppriseConfig) => void;
configLoadError: () => string | null;
isRetrying: () => boolean;
isLoadingDestinations: () => boolean;
onRetryLoad: () => void;
setEmailConfig: (config: ReturnType<AlertDestinationsTabStateProps['emailConfig']>) => void;
}
export function DestinationsTab(props: DestinationsTabProps) {
const [webhooks, setWebhooks] = createSignal<Webhook[]>([]);
const [webhookLoadError, setWebhookLoadError] = createSignal<string | null>(null);
const [isLoadingWebhooks, setIsLoadingWebhooks] = createSignal(true);
const [testingEmail, setTestingEmail] = createSignal(false);
const [testingApprise, setTestingApprise] = createSignal(false);
const [testingWebhook, setTestingWebhook] = createSignal<string | null>(null);
const isLoading = () =>
props.isLoadingDestinations() || isLoadingWebhooks() || props.isRetrying();
const appriseState = () => props.appriseConfig();
const updateApprise = (partial: Partial<UIAppriseConfig>) => {
props.setAppriseConfig({ ...props.appriseConfig(), ...partial });
};
const buildAppriseRequestConfig = (): AppriseConfig => {
const config = appriseState();
const serverUrl = (config.serverUrl || '').trim();
const apiKeyHeader = (config.apiKeyHeader || '').trim() || 'X-API-KEY';
return {
enabled: config.enabled,
mode: config.mode,
targets: parseAppriseTargets(config.targetsText),
cliPath: config.cliPath?.trim() || 'apprise',
timeoutSeconds: config.timeoutSeconds,
serverUrl,
configKey: config.configKey.trim(),
apiKey: config.apiKey,
apiKeyHeader,
skipTlsVerify: config.skipTlsVerify,
};
};
const loadWebhooks = async () => {
setWebhookLoadError(null);
setIsLoadingWebhooks(true);
try {
const hooks = await NotificationsAPI.getWebhooks();
setWebhooks(
hooks.map((hook) => ({
...hook,
service: hook.service || 'generic',
})),
);
} catch (error) {
logger.error('Failed to load webhooks:', error);
setWebhookLoadError(getAlertDestinationsWebhookLoadError());
} finally {
setIsLoadingWebhooks(false);
}
};
onMount(() => {
void loadWebhooks();
});
const testEmailConfig = async () => {
setTestingEmail(true);
try {
await NotificationsAPI.testNotification({
type: 'email',
config: { ...props.emailConfig() } as Record<string, unknown>,
});
notificationStore.success(getAlertDestinationsEmailTestSuccess());
} catch (error) {
logger.error(getAlertDestinationsEmailTestFailure(), error);
const message =
error instanceof Error ? error.message : getAlertDestinationsEmailTestFailure();
const detail = (error as Error & { detail?: string })?.detail;
showErrorWithDetail(message, detail);
} finally {
setTestingEmail(false);
}
};
const testApprise = async () => {
setTestingApprise(true);
try {
const config = buildAppriseRequestConfig();
if (!config.enabled) {
throw new Error(getAlertDestinationsAppriseValidationError('disabled'));
}
const targets = config.targets || [];
if (config.mode === 'cli' && targets.length === 0) {
throw new Error(getAlertDestinationsAppriseValidationError('missingTargets'));
}
if (config.mode === 'http' && !config.serverUrl) {
throw new Error(getAlertDestinationsAppriseValidationError('missingServerUrl'));
}
await NotificationsAPI.testNotification({
type: 'apprise',
config,
});
notificationStore.success(getAlertDestinationsAppriseTestSuccess());
} catch (error) {
logger.error(getAlertDestinationsAppriseTestFailure(), error);
const message =
error instanceof Error ? error.message : getAlertDestinationsAppriseTestFailure();
const detail = (error as Error & { detail?: string })?.detail;
showErrorWithDetail(message, detail);
} finally {
setTestingApprise(false);
}
};
const testWebhook = async (webhookId: string, webhookData?: Omit<Webhook, 'id'>) => {
setTestingWebhook(webhookId);
try {
if (webhookData) {
await NotificationsAPI.testWebhook(webhookData);
} else {
await NotificationsAPI.testNotification({ type: 'webhook', webhookId });
}
notificationStore.success(getAlertWebhookTestSuccess());
} catch (error) {
const message = error instanceof Error ? error.message : getAlertWebhookTestFailure();
const detail = (error as Error & { detail?: string })?.detail;
showErrorWithDetail(message, detail);
} finally {
setTestingWebhook(null);
}
};
const hasLoadError = () => props.configLoadError() || webhookLoadError();
const handleRetry = () => {
props.onRetryLoad();
void loadWebhooks();
};
const state = useAlertDestinationsTabState(props);
return (
<div class="flex w-full max-w-full flex-col gap-6 md:gap-8">
<Show
when={!isLoading()}
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">
@@ -265,21 +111,21 @@ export function DestinationsTab(props: DestinationsTabProps) {
</div>
}
>
<Show when={hasLoadError()}>
<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() || webhookLoadError() || '',
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={handleRetry}
onClick={state.handleRetry}
>
{getAlertDestinationsRetryLabel(props.isRetrying())}
</button>
@@ -320,8 +166,8 @@ export function DestinationsTab(props: DestinationsTabProps) {
props.setEmailConfig(config);
props.setHasUnsavedChanges(true);
}}
onTest={testEmailConfig}
testing={testingEmail()}
onTest={state.testEmailConfig}
testing={state.testingEmail()}
/>
</div>
</SettingsPanel>
@@ -332,23 +178,23 @@ export function DestinationsTab(props: DestinationsTabProps) {
action={
<div class="flex items-center gap-3 sm:self-start">
<Toggle
checked={appriseState().enabled}
checked={state.appriseState().enabled}
onChange={(event) => {
updateApprise({ enabled: event.currentTarget.checked });
state.updateApprise({ enabled: event.currentTarget.checked });
props.setHasUnsavedChanges(true);
}}
label={
<span class="text-xs font-medium text-muted">
{getAlertDestinationsStatusLabel(appriseState().enabled)}
{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={!appriseState().enabled || testingApprise()}
onClick={testApprise}
disabled={!state.appriseState().enabled || state.testingApprise()}
onClick={state.testApprise}
>
{getAlertDestinationsAppriseTestLabel(testingApprise())}
{getAlertDestinationsAppriseTestLabel(state.testingApprise())}
</button>
</div>
}
@@ -362,9 +208,9 @@ export function DestinationsTab(props: DestinationsTabProps) {
</label>
<select
class={formControl}
value={appriseState().mode}
value={state.appriseState().mode}
onInput={(event) => {
updateApprise({ mode: event.currentTarget.value as 'cli' | 'http' });
state.updateApprise({ mode: event.currentTarget.value as 'cli' | 'http' });
props.setHasUnsavedChanges(true);
}}
>
@@ -381,30 +227,30 @@ export function DestinationsTab(props: DestinationsTabProps) {
<textarea
rows={4}
class={`${formControl} min-h-[120px] font-mono`}
value={appriseState().targetsText}
value={state.appriseState().targetsText}
placeholder={ALERT_DESTINATIONS_APPRISE_TARGETS_PLACEHOLDER}
onInput={(event) => {
updateApprise({ targetsText: event.currentTarget.value });
state.updateApprise({ targetsText: event.currentTarget.value });
props.setHasUnsavedChanges(true);
}}
/>
<p class={formHelpText}>
{getAlertDestinationsAppriseTargetsHelp(appriseState().mode)}
{getAlertDestinationsAppriseTargetsHelp(state.appriseState().mode)}
</p>
</div>
<Show when={appriseState().mode === 'cli'}>
<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={appriseState().cliPath}
value={state.appriseState().cliPath}
class={formControl}
placeholder={ALERT_DESTINATIONS_APPRISE_CLI_PATH_PLACEHOLDER}
onInput={(event) => {
updateApprise({ cliPath: event.currentTarget.value });
state.updateApprise({ cliPath: event.currentTarget.value });
props.setHasUnsavedChanges(true);
}}
/>
@@ -412,7 +258,7 @@ export function DestinationsTab(props: DestinationsTabProps) {
</div>
</Show>
<Show when={appriseState().mode === 'http'}>
<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]')}>
@@ -420,11 +266,11 @@ export function DestinationsTab(props: DestinationsTabProps) {
</label>
<input
type="text"
value={appriseState().serverUrl}
value={state.appriseState().serverUrl}
class={formControl}
placeholder={ALERT_DESTINATIONS_APPRISE_SERVER_URL_PLACEHOLDER}
onInput={(event) => {
updateApprise({ serverUrl: event.currentTarget.value });
state.updateApprise({ serverUrl: event.currentTarget.value });
props.setHasUnsavedChanges(true);
}}
/>
@@ -436,11 +282,11 @@ export function DestinationsTab(props: DestinationsTabProps) {
</label>
<input
type="text"
value={appriseState().configKey}
value={state.appriseState().configKey}
class={formControl}
placeholder={ALERT_DESTINATIONS_APPRISE_CONFIG_KEY_PLACEHOLDER}
onInput={(event) => {
updateApprise({ configKey: event.currentTarget.value });
state.updateApprise({ configKey: event.currentTarget.value });
props.setHasUnsavedChanges(true);
}}
/>
@@ -452,11 +298,11 @@ export function DestinationsTab(props: DestinationsTabProps) {
</label>
<input
type="password"
value={appriseState().apiKey}
value={state.appriseState().apiKey}
class={formControl}
placeholder={ALERT_DESTINATIONS_APPRISE_API_KEY_PLACEHOLDER}
onInput={(event) => {
updateApprise({ apiKey: event.currentTarget.value });
state.updateApprise({ apiKey: event.currentTarget.value });
props.setHasUnsavedChanges(true);
}}
/>
@@ -468,11 +314,11 @@ export function DestinationsTab(props: DestinationsTabProps) {
</label>
<input
type="text"
value={appriseState().apiKeyHeader}
value={state.appriseState().apiKeyHeader}
class={formControl}
placeholder={ALERT_DESTINATIONS_APPRISE_API_KEY_HEADER_PLACEHOLDER}
onInput={(event) => {
updateApprise({ apiKeyHeader: event.currentTarget.value });
state.updateApprise({ apiKeyHeader: event.currentTarget.value });
props.setHasUnsavedChanges(true);
}}
/>
@@ -486,9 +332,9 @@ export function DestinationsTab(props: DestinationsTabProps) {
<input
type="checkbox"
class="h-4 w-4 rounded border border-border"
checked={appriseState().skipTlsVerify}
checked={state.appriseState().skipTlsVerify}
onChange={(event) => {
updateApprise({ skipTlsVerify: event.currentTarget.checked });
state.updateApprise({ skipTlsVerify: event.currentTarget.checked });
props.setHasUnsavedChanges(true);
}}
/>
@@ -509,12 +355,12 @@ export function DestinationsTab(props: DestinationsTabProps) {
type="number"
min="5"
max="120"
value={appriseState().timeoutSeconds}
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)));
updateApprise({ timeoutSeconds: safe });
state.updateApprise({ timeoutSeconds: safe });
props.setHasUnsavedChanges(true);
}}
/>
@@ -527,51 +373,18 @@ export function DestinationsTab(props: DestinationsTabProps) {
title={getAlertWebhooksSectionTitle()}
description={getAlertWebhooksSectionDescription()}
action={
<span class="whitespace-nowrap text-xs text-muted">{webhooks().length} configured</span>
<span class="whitespace-nowrap text-xs text-muted">{state.webhooks().length} configured</span>
}
class="min-w-0"
bodyClass="space-y-4"
>
<WebhookConfig
webhooks={webhooks()}
onAdd={async (webhook) => {
try {
const created = await NotificationsAPI.createWebhook(webhook);
setWebhooks([...webhooks(), created]);
notificationStore.success(getAlertWebhookMutationSuccess('add'));
} catch (error) {
logger.error('Failed to add webhook:', error);
notificationStore.error(
error instanceof Error ? error.message : getAlertWebhookMutationFailure('add'),
);
}
}}
onUpdate={async (webhook) => {
try {
const updated = await NotificationsAPI.updateWebhook(webhook.id!, webhook);
setWebhooks(webhooks().map((current) => (current.id === webhook.id ? updated : current)));
notificationStore.success(getAlertWebhookMutationSuccess('update'));
} catch (error) {
logger.error('Failed to update webhook:', error);
notificationStore.error(
error instanceof Error ? error.message : getAlertWebhookMutationFailure('update'),
);
}
}}
onDelete={async (id) => {
try {
await NotificationsAPI.deleteWebhook(id);
setWebhooks(webhooks().filter((current) => current.id !== id));
notificationStore.success(getAlertWebhookMutationSuccess('delete'));
} catch (error) {
logger.error('Failed to delete webhook:', error);
notificationStore.error(
error instanceof Error ? error.message : getAlertWebhookMutationFailure('delete'),
);
}
}}
onTest={testWebhook}
testing={testingWebhook()}
webhooks={state.webhooks()}
onAdd={state.addWebhook}
onUpdate={state.updateWebhook}
onDelete={state.deleteWebhook}
onTest={state.testWebhook}
testing={state.testingWebhook()}
/>
</SettingsPanel>
</Show>
@@ -0,0 +1,230 @@
import { createMemo, createSignal, onMount, type Accessor } from 'solid-js';
import { NotificationsAPI, type AppriseConfig, type Webhook } from '@/api/notifications';
import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
import { showErrorWithDetail } from '@/utils/toast';
import {
getAlertDestinationsAppriseTestFailure,
getAlertDestinationsAppriseTestSuccess,
getAlertDestinationsAppriseValidationError,
getAlertDestinationsEmailTestFailure,
getAlertDestinationsEmailTestSuccess,
getAlertDestinationsWebhookLoadError,
} from '@/utils/alertDestinationsPresentation';
import {
getAlertWebhookMutationFailure,
getAlertWebhookMutationSuccess,
getAlertWebhookTestFailure,
getAlertWebhookTestSuccess,
} from '@/utils/alertWebhookPresentation';
import { parseAppriseTargets } from './helpers';
import type { UIAppriseConfig, UIEmailConfig } from './types';
export interface AlertDestinationsTabStateProps {
emailConfig: Accessor<UIEmailConfig>;
appriseConfig: Accessor<UIAppriseConfig>;
setAppriseConfig: (config: UIAppriseConfig) => void;
configLoadError: Accessor<string | null>;
isRetrying: Accessor<boolean>;
isLoadingDestinations: Accessor<boolean>;
onRetryLoad: () => void;
}
const normalizeWebhook = (webhook: Webhook): Webhook => ({
...webhook,
service: webhook.service || 'generic',
});
export function useAlertDestinationsTabState(props: AlertDestinationsTabStateProps) {
const [webhooks, setWebhooks] = createSignal<Webhook[]>([]);
const [webhookLoadError, setWebhookLoadError] = createSignal<string | null>(null);
const [isLoadingWebhooks, setIsLoadingWebhooks] = createSignal(true);
const [testingEmail, setTestingEmail] = createSignal(false);
const [testingApprise, setTestingApprise] = createSignal(false);
const [testingWebhook, setTestingWebhook] = createSignal<string | null>(null);
const isLoading = createMemo(
() => props.isLoadingDestinations() || isLoadingWebhooks() || props.isRetrying(),
);
const hasLoadError = createMemo(() => props.configLoadError() || webhookLoadError());
const appriseState = createMemo(() => props.appriseConfig());
const updateApprise = (partial: Partial<UIAppriseConfig>) => {
props.setAppriseConfig({ ...props.appriseConfig(), ...partial });
};
const buildAppriseRequestConfig = (): AppriseConfig => {
const config = appriseState();
const serverUrl = (config.serverUrl || '').trim();
const apiKeyHeader = (config.apiKeyHeader || '').trim() || 'X-API-KEY';
return {
enabled: config.enabled,
mode: config.mode,
targets: parseAppriseTargets(config.targetsText),
cliPath: config.cliPath?.trim() || 'apprise',
timeoutSeconds: config.timeoutSeconds,
serverUrl,
configKey: config.configKey.trim(),
apiKey: config.apiKey,
apiKeyHeader,
skipTlsVerify: config.skipTlsVerify,
};
};
const loadWebhooks = async () => {
setWebhookLoadError(null);
setIsLoadingWebhooks(true);
try {
const hooks = await NotificationsAPI.getWebhooks();
setWebhooks(hooks.map(normalizeWebhook));
} catch (error) {
logger.error('Failed to load webhooks:', error);
setWebhookLoadError(getAlertDestinationsWebhookLoadError());
} finally {
setIsLoadingWebhooks(false);
}
};
onMount(() => {
void loadWebhooks();
});
const testEmailConfig = async () => {
setTestingEmail(true);
try {
await NotificationsAPI.testNotification({
type: 'email',
config: { ...props.emailConfig() } as Record<string, unknown>,
});
notificationStore.success(getAlertDestinationsEmailTestSuccess());
} catch (error) {
logger.error(getAlertDestinationsEmailTestFailure(), error);
const message =
error instanceof Error ? error.message : getAlertDestinationsEmailTestFailure();
const detail = (error as Error & { detail?: string })?.detail;
showErrorWithDetail(message, detail);
} finally {
setTestingEmail(false);
}
};
const testApprise = async () => {
setTestingApprise(true);
try {
const config = buildAppriseRequestConfig();
if (!config.enabled) {
throw new Error(getAlertDestinationsAppriseValidationError('disabled'));
}
const targets = config.targets || [];
if (config.mode === 'cli' && targets.length === 0) {
throw new Error(getAlertDestinationsAppriseValidationError('missingTargets'));
}
if (config.mode === 'http' && !config.serverUrl) {
throw new Error(getAlertDestinationsAppriseValidationError('missingServerUrl'));
}
await NotificationsAPI.testNotification({
type: 'apprise',
config,
});
notificationStore.success(getAlertDestinationsAppriseTestSuccess());
} catch (error) {
logger.error(getAlertDestinationsAppriseTestFailure(), error);
const message =
error instanceof Error ? error.message : getAlertDestinationsAppriseTestFailure();
const detail = (error as Error & { detail?: string })?.detail;
showErrorWithDetail(message, detail);
} finally {
setTestingApprise(false);
}
};
const testWebhook = async (webhookId: string, webhookData?: Omit<Webhook, 'id'>) => {
setTestingWebhook(webhookId);
try {
if (webhookData) {
await NotificationsAPI.testWebhook(webhookData);
} else {
await NotificationsAPI.testNotification({ type: 'webhook', webhookId });
}
notificationStore.success(getAlertWebhookTestSuccess());
} catch (error) {
const message = error instanceof Error ? error.message : getAlertWebhookTestFailure();
const detail = (error as Error & { detail?: string })?.detail;
showErrorWithDetail(message, detail);
} finally {
setTestingWebhook(null);
}
};
const addWebhook = async (webhook: Omit<Webhook, 'id'>) => {
try {
const created = await NotificationsAPI.createWebhook(webhook);
setWebhooks((current) => [...current, normalizeWebhook(created)]);
notificationStore.success(getAlertWebhookMutationSuccess('add'));
} catch (error) {
logger.error('Failed to add webhook:', error);
notificationStore.error(
error instanceof Error ? error.message : getAlertWebhookMutationFailure('add'),
);
}
};
const updateWebhook = async (webhook: Webhook) => {
try {
const updated = await NotificationsAPI.updateWebhook(webhook.id, webhook);
setWebhooks((current) =>
current.map((entry) => (entry.id === webhook.id ? normalizeWebhook(updated) : entry)),
);
notificationStore.success(getAlertWebhookMutationSuccess('update'));
} catch (error) {
logger.error('Failed to update webhook:', error);
notificationStore.error(
error instanceof Error ? error.message : getAlertWebhookMutationFailure('update'),
);
}
};
const deleteWebhook = async (id: string) => {
try {
await NotificationsAPI.deleteWebhook(id);
setWebhooks((current) => current.filter((entry) => entry.id !== id));
notificationStore.success(getAlertWebhookMutationSuccess('delete'));
} catch (error) {
logger.error('Failed to delete webhook:', error);
notificationStore.error(
error instanceof Error ? error.message : getAlertWebhookMutationFailure('delete'),
);
}
};
const handleRetry = () => {
props.onRetryLoad();
void loadWebhooks();
};
return {
addWebhook,
appriseState,
deleteWebhook,
handleRetry,
hasLoadError,
isLoading,
isLoadingWebhooks,
loadWebhooks,
testApprise,
testEmailConfig,
testWebhook,
testingApprise,
testingEmail,
testingWebhook,
updateApprise,
updateWebhook,
webhookLoadError,
webhooks,
};
}
@@ -4,6 +4,7 @@ import alertsConfigurationSurfaceSource from '@/features/alerts/AlertsConfigurat
import alertsConfigurationStateSource from '@/features/alerts/useAlertsConfigurationState.ts?raw';
import alertOverridesStateSource from '@/features/alerts/useAlertOverridesState.ts?raw';
import alertDestinationsStateSource from '@/features/alerts/useAlertDestinationsState.ts?raw';
import alertDestinationsTabStateSource from '@/features/alerts/useAlertDestinationsTabState.ts?raw';
import alertAcknowledgementStateSource from '@/features/alerts/useAlertAcknowledgementState.ts?raw';
import alertHistoryStateSource from '@/features/alerts/useAlertHistoryState.ts?raw';
import alertIncidentTimelineStateSource from '@/features/alerts/useAlertIncidentTimelineState.ts?raw';
@@ -212,12 +213,19 @@ describe('tab path helpers', () => {
expect(alertDestinationsStateSource).toContain('export function useAlertDestinationsState');
expect(alertDestinationsStateSource).toContain('NotificationsAPI.getEmailConfig');
expect(alertDestinationsStateSource).toContain('NotificationsAPI.updateEmailConfig');
expect(alertDestinationsTabStateSource).toContain('export function useAlertDestinationsTabState');
expect(alertDestinationsTabStateSource).toContain('NotificationsAPI.getWebhooks');
expect(alertDestinationsTabStateSource).toContain('NotificationsAPI.testNotification');
expect(alertDestinationsTabStateSource).toContain('NotificationsAPI.createWebhook');
expect(alertsPageSource).toContain(
"import { HistoryTab } from '@/features/alerts/tabs/HistoryTab';",
);
expect(alertsPageSource).not.toContain('function HistoryTab(');
expect(alertHistoryTabSource).toContain('useAlertHistoryState');
expect(alertDestinationsTabSource).toContain('NotificationsAPI.getWebhooks');
expect(alertDestinationsTabSource).toContain('useAlertDestinationsTabState');
expect(alertDestinationsTabSource).not.toContain('NotificationsAPI.getWebhooks');
expect(alertDestinationsTabSource).not.toContain('NotificationsAPI.testNotification');
expect(alertDestinationsTabSource).not.toContain('NotificationsAPI.createWebhook');
expect(alertHistoryTabSource).toContain('IncidentTimelinePanel');
expect(alertHistoryTabSource).not.toContain('useAlertIncidentTimelineState');
expect(alertHistoryTabSource).not.toContain('AlertsAPI.getHistory');
@@ -328,6 +328,7 @@ import alertsConfigurationSurfaceSource from '@/features/alerts/AlertsConfigurat
import alertsConfigurationStateSource from '@/features/alerts/useAlertsConfigurationState.ts?raw';
import alertOverridesStateSource from '@/features/alerts/useAlertOverridesState.ts?raw';
import alertDestinationsStateSource from '@/features/alerts/useAlertDestinationsState.ts?raw';
import alertDestinationsTabStateSource from '@/features/alerts/useAlertDestinationsTabState.ts?raw';
import alertAcknowledgementStateSource from '@/features/alerts/useAlertAcknowledgementState.ts?raw';
import alertHistoryStateSource from '@/features/alerts/useAlertHistoryState.ts?raw';
import alertIncidentTimelineStateSource from '@/features/alerts/useAlertIncidentTimelineState.ts?raw';
@@ -2790,19 +2791,20 @@ describe('frontend resource type boundaries', () => {
expect(alertDestinationsStateSource).toContain('getAlertDestinationsConfigLoadError');
expect(alertDestinationsStateSource).toContain('NotificationsAPI.getEmailConfig');
expect(alertDestinationsStateSource).toContain('NotificationsAPI.updateEmailConfig');
expect(alertDestinationsTabSource).toContain('getAlertDestinationsWebhookLoadError');
expect(alertDestinationsTabStateSource).toContain('export function useAlertDestinationsTabState');
expect(alertDestinationsTabStateSource).toContain('NotificationsAPI.getWebhooks');
expect(alertDestinationsTabStateSource).toContain('NotificationsAPI.testNotification');
expect(alertDestinationsTabStateSource).toContain('NotificationsAPI.createWebhook');
expect(alertDestinationsTabStateSource).toContain('getAlertDestinationsWebhookLoadError');
expect(alertDestinationsTabSource).toContain('getAlertDestinationsLoadErrorBanner');
expect(alertDestinationsTabSource).toContain('getAlertDestinationsAppriseTargetsHelp');
expect(alertDestinationsTabSource).toContain('getAlertDestinationsAppriseTestLabel');
expect(alertDestinationsTabSource).toContain('getAlertDestinationsAppriseValidationError');
expect(alertDestinationsTabSource).toContain('getAlertDestinationsEmailTestSuccess');
expect(alertDestinationsTabSource).toContain('getAlertDestinationsEmailTestFailure');
expect(alertDestinationsTabSource).toContain('getAlertDestinationsAppriseTestSuccess');
expect(alertDestinationsTabSource).toContain('getAlertDestinationsAppriseTestFailure');
expect(alertDestinationsTabSource).toContain('getAlertDestinationsRetryLabel');
expect(alertDestinationsTabSource).toContain('getAlertDestinationsStatusLabel');
expect(alertDestinationsTabSource).toContain('getAlertWebhookTestSuccess');
expect(alertDestinationsTabSource).toContain('getAlertWebhookTestFailure');
expect(alertDestinationsTabSource).toContain('useAlertDestinationsTabState');
expect(alertDestinationsTabSource).not.toContain('NotificationsAPI.getWebhooks');
expect(alertDestinationsTabSource).not.toContain('NotificationsAPI.testNotification');
expect(alertDestinationsTabSource).not.toContain('NotificationsAPI.createWebhook');
expect(alertsPageSource).toContain(
"import { HistoryTab } from '@/features/alerts/tabs/HistoryTab';",
);
@@ -3858,7 +3860,8 @@ describe('frontend resource type boundaries', () => {
expect(alertsConfigurationSurfaceSource).toContain('useAlertsConfigurationState');
expect(alertsPageSource).not.toContain('function DestinationsTab(');
expect(alertsPageSource).not.toContain('function ScheduleTab(');
expect(alertDestinationsTabSource).toContain('NotificationsAPI.getWebhooks');
expect(alertDestinationsTabSource).toContain('useAlertDestinationsTabState');
expect(alertDestinationsTabSource).not.toContain('NotificationsAPI.getWebhooks');
expect(alertDestinationsTabSource).toContain('EmailProviderSelect');
expect(alertDestinationsTabSource).toContain('WebhookConfig');
expect(alertScheduleTabSource).toContain('getAlertConfigQuietHourSuppressOptions');