Extract alert webhook destinations runtime owner

This commit is contained in:
rcourtman
2026-03-22 17:04:26 +00:00
parent 97d47a326f
commit 46c9291e81
8 changed files with 283 additions and 166 deletions
@@ -250,13 +250,16 @@ serialization,
override normalization plus resource-backed override projection, and
`frontend-modern/src/features/alerts/useAlertDestinationsState.ts` for
notification destination reload and persistence.
`frontend-modern/src/features/alerts/useAlertWebhookDestinationsState.ts` now
owns webhook load/mutate/test flow,
`frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts` now owns
webhook load/mutate/test flow plus destination test actions, while
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 runtime hook based on which subsystem actually owns the behavior
instead of letting the broader configuration hook absorb all three concerns
instead of letting the broader configuration hook absorb all four concerns
again.
The email destination provider picker now follows that same split:
`frontend-modern/src/components/Alerts/useEmailProviderSelectState.ts` owns
@@ -451,8 +451,10 @@ serialization, `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.
`frontend-modern/src/features/alerts/useAlertWebhookDestinationsState.ts` now
owns webhook runtime, and
`frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts` now owns
webhook runtime and destination test actions while
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
@@ -100,25 +100,6 @@ describe('useAlertDestinationsTabState', () => {
] 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({
@@ -153,37 +134,9 @@ describe('useAlertDestinationsTabState', () => {
}),
);
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']);
expect(result.webhooks()).toEqual([
expect.objectContaining({ id: 'hook-1', service: 'generic' }),
]);
result.updateApprise({ mode: 'http', serverUrl: 'https://apprise.internal' });
expect(result.appriseState()).toEqual(
@@ -193,7 +146,7 @@ describe('useAlertDestinationsTabState', () => {
result.handleRetry();
expect(onRetryLoad).toHaveBeenCalledTimes(1);
await waitFor(() => expect(NotificationsAPI.getWebhooks).toHaveBeenCalledTimes(2));
expect(notificationStore.success).toHaveBeenCalled();
expect(notificationStore.success).toHaveBeenCalledTimes(2);
expect(showErrorWithDetail).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,127 @@
import { renderHook, waitFor } from '@solidjs/testing-library';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { NotificationsAPI } from '@/api/notifications';
import { notificationStore } from '@/stores/notifications';
import { showErrorWithDetail } from '@/utils/toast';
import { useAlertWebhookDestinationsState } from '../useAlertWebhookDestinationsState';
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(),
}));
describe('useAlertWebhookDestinationsState', () => {
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 load, mutation, and test runtime for alert destinations', async () => {
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.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(() => useAlertWebhookDestinationsState());
await waitFor(() => expect(NotificationsAPI.getWebhooks).toHaveBeenCalledTimes(1));
expect(result.webhooks()).toEqual([
expect.objectContaining({ id: 'hook-1', service: 'generic' }),
]);
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']);
await result.loadWebhooks();
expect(NotificationsAPI.getWebhooks).toHaveBeenCalledTimes(2);
expect(notificationStore.success).toHaveBeenCalled();
expect(showErrorWithDetail).not.toHaveBeenCalled();
});
});
@@ -1,6 +1,6 @@
import { createMemo, createSignal, onMount, type Accessor } from 'solid-js';
import { createMemo, createSignal, type Accessor } from 'solid-js';
import { NotificationsAPI, type AppriseConfig, type Webhook } from '@/api/notifications';
import { NotificationsAPI, type AppriseConfig } from '@/api/notifications';
import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
import { showErrorWithDetail } from '@/utils/toast';
@@ -10,17 +10,11 @@ import {
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';
import { useAlertWebhookDestinationsState } from './useAlertWebhookDestinationsState';
export interface AlertDestinationsTabStateProps {
emailConfig: Accessor<UIEmailConfig>;
@@ -32,23 +26,15 @@ export interface AlertDestinationsTabStateProps {
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 webhookState = useAlertWebhookDestinationsState();
const isLoading = createMemo(
() => props.isLoadingDestinations() || isLoadingWebhooks() || props.isRetrying(),
() => props.isLoadingDestinations() || webhookState.isLoadingWebhooks() || props.isRetrying(),
);
const hasLoadError = createMemo(() => props.configLoadError() || webhookLoadError());
const hasLoadError = createMemo(() => props.configLoadError() || webhookState.webhookLoadError());
const appriseState = createMemo(() => props.appriseConfig());
const updateApprise = (partial: Partial<UIAppriseConfig>) => {
@@ -73,24 +59,6 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro
};
};
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 {
@@ -143,88 +111,21 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro
}
};
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();
void webhookState.loadWebhooks();
};
return {
addWebhook,
appriseState,
deleteWebhook,
handleRetry,
hasLoadError,
isLoading,
isLoadingWebhooks,
loadWebhooks,
testApprise,
testEmailConfig,
testWebhook,
testingApprise,
testingEmail,
testingWebhook,
updateApprise,
updateWebhook,
webhookLoadError,
webhooks,
...webhookState,
};
}
@@ -0,0 +1,114 @@
import { createSignal, onMount } from 'solid-js';
import { NotificationsAPI, type Webhook } from '@/api/notifications';
import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
import { showErrorWithDetail } from '@/utils/toast';
import { getAlertDestinationsWebhookLoadError } from '@/utils/alertDestinationsPresentation';
import {
getAlertWebhookMutationFailure,
getAlertWebhookMutationSuccess,
getAlertWebhookTestFailure,
getAlertWebhookTestSuccess,
} from '@/utils/alertWebhookPresentation';
const normalizeWebhook = (webhook: Webhook): Webhook => ({
...webhook,
service: webhook.service || 'generic',
});
export function useAlertWebhookDestinationsState() {
const [webhooks, setWebhooks] = createSignal<Webhook[]>([]);
const [webhookLoadError, setWebhookLoadError] = createSignal<string | null>(null);
const [isLoadingWebhooks, setIsLoadingWebhooks] = createSignal(true);
const [testingWebhook, setTestingWebhook] = createSignal<string | null>(null);
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 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'),
);
}
};
return {
addWebhook,
deleteWebhook,
isLoadingWebhooks,
loadWebhooks,
testWebhook,
testingWebhook,
updateWebhook,
webhookLoadError,
webhooks,
};
}
@@ -6,6 +6,7 @@ import alertsConfigurationModelSource from '@/features/alerts/alertsConfiguratio
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 alertWebhookDestinationsStateSource from '@/features/alerts/useAlertWebhookDestinationsState.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';
@@ -231,9 +232,16 @@ describe('tab path helpers', () => {
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(alertDestinationsTabStateSource).toContain('useAlertWebhookDestinationsState');
expect(alertDestinationsTabStateSource).not.toContain('NotificationsAPI.getWebhooks');
expect(alertDestinationsTabStateSource).not.toContain('NotificationsAPI.createWebhook');
expect(alertWebhookDestinationsStateSource).toContain(
'export function useAlertWebhookDestinationsState',
);
expect(alertWebhookDestinationsStateSource).toContain('NotificationsAPI.getWebhooks');
expect(alertWebhookDestinationsStateSource).toContain('NotificationsAPI.createWebhook');
expect(alertWebhookDestinationsStateSource).toContain('NotificationsAPI.testNotification');
expect(alertsPageSource).toContain(
"import { HistoryTab } from '@/features/alerts/tabs/HistoryTab';",
);
@@ -330,6 +330,7 @@ import alertsConfigurationModelSource from '@/features/alerts/alertsConfiguratio
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 alertWebhookDestinationsStateSource from '@/features/alerts/useAlertWebhookDestinationsState.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';
@@ -2810,10 +2811,18 @@ describe('frontend resource type boundaries', () => {
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(alertDestinationsTabStateSource).toContain('getAlertDestinationsWebhookLoadError');
expect(alertDestinationsTabStateSource).toContain('useAlertWebhookDestinationsState');
expect(alertDestinationsTabStateSource).not.toContain('NotificationsAPI.getWebhooks');
expect(alertDestinationsTabStateSource).not.toContain('NotificationsAPI.createWebhook');
expect(alertWebhookDestinationsStateSource).toContain(
'export function useAlertWebhookDestinationsState',
);
expect(alertWebhookDestinationsStateSource).toContain('NotificationsAPI.getWebhooks');
expect(alertWebhookDestinationsStateSource).toContain('NotificationsAPI.createWebhook');
expect(alertWebhookDestinationsStateSource).toContain(
'getAlertDestinationsWebhookLoadError',
);
expect(alertDestinationsTabSource).toContain('getAlertDestinationsLoadErrorBanner');
expect(alertDestinationsTabSource).toContain('getAlertDestinationsAppriseTargetsHelp');
expect(alertDestinationsTabSource).toContain('getAlertDestinationsAppriseTestLabel');