mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-06 12:52:19 +00:00
Extract alert destinations tab runtime owner
This commit is contained in:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user