Harden dashboard realtime state and controls

This commit is contained in:
jjbrunton committed 2026-08-08 15:41:58 +01:00
1 parent 4b3f00729f
commit d108903174
21 files changed
+422 -29

No files matched your search

+2
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import { navigationItems } from './navigation.js';
import { ShellStatusPanel } from './shell-status-panel.js';
export function AppShell() {
const [isNavigationOpen, setIsNavigationOpen] = useState(false);
@@ -31,6 +32,7 @@ export function AppShell() {
</NavLink>
))}
</nav>
<ShellStatusPanel />
</aside>
{isNavigationOpen ? (
<button
+2 -1
View File
@@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { PropsWithChildren } from 'react';
import { DashboardRealtimeProvider } from './realtime-provider.js';
const queryClient = new QueryClient({
defaultOptions: {
@@ -13,5 +14,5 @@ const queryClient = new QueryClient({
});
export function AppProviders({ children }: PropsWithChildren) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
return <QueryClientProvider client={queryClient}><DashboardRealtimeProvider>{children}</DashboardRealtimeProvider></QueryClientProvider>;
}
@@ -0,0 +1,45 @@
import { useQueryClient } from '@tanstack/react-query';
import { createContext, useContext, useEffect, useMemo, useRef, useState, type PropsWithChildren } from 'react';
import type { DashboardEvent } from '../../../../packages/api-contracts/src/index.js';
import {
createBrowserEventSource, createDashboardRealtimeClient, queryKeysForDashboardEvent,
type RealtimeConnectionSnapshot,
} from '../features/operations/realtime-client.js';
import { browserPollingScheduler, browserVisibilitySource, createVisibilityAwarePollingFallback } from '../features/operations/realtime-fallback.js';
import { repairRealtimeSnapshots } from './realtime-repair.js';
type EventListener = (event: DashboardEvent) => void;
interface RealtimeContextValue { connection: RealtimeConnectionSnapshot; subscribe(listener: EventListener): () => void; }
const RealtimeContext = createContext<RealtimeContextValue | null>(null);
export function DashboardRealtimeProvider({ children }: PropsWithChildren) {
const queryClient = useQueryClient();
const listeners = useRef(new Set<EventListener>());
const [connection, setConnection] = useState<RealtimeConnectionSnapshot>({ status: 'idle', isStale: true });
useEffect(() => {
const repair = () => { void repairRealtimeSnapshots(queryClient); };
const fallback = createVisibilityAwarePollingFallback({ intervalMs: 15_000, poll: repair, scheduler: browserPollingScheduler, visibility: browserVisibilitySource });
fallback.start();
if (typeof EventSource === 'undefined') { fallback.setStale(true); return () => fallback.stop(); }
const client = createDashboardRealtimeClient({
sourceFactory: createBrowserEventSource,
onConnected: repair,
onEvent(event) {
for (const queryKey of queryKeysForDashboardEvent(event)) void queryClient.invalidateQueries({ queryKey });
for (const listener of listeners.current) listener(event);
},
onStateChange(snapshot) { setConnection(snapshot); fallback.setStale(snapshot.isStale); },
});
client.start();
return () => { client.stop(); fallback.stop(); };
}, [queryClient]);
const value = useMemo<RealtimeContextValue>(() => ({
connection,
subscribe(listener) { listeners.current.add(listener); return () => listeners.current.delete(listener); },
}), [connection]);
return <RealtimeContext.Provider value={value}>{children}</RealtimeContext.Provider>;
}
export function useDashboardRealtime(): RealtimeContextValue {
return useContext(RealtimeContext) ?? { connection: { status: 'idle', isStale: true }, subscribe: () => () => undefined };
}
+26
View File
@@ -0,0 +1,26 @@
import type { QueryKey } from '@tanstack/react-query';
import { dashboardQueryKeys } from '../api/queries.js';
import { operationsQueryKeys } from '../features/operations/operations-queries.js';
import { settingsQueryKeys } from '../features/settings/settings-queries.js';
export const realtimeSnapshotKeys: readonly QueryKey[] = [
dashboardQueryKeys.stats,
dashboardQueryKeys.timers,
settingsQueryKeys.loginStatus,
operationsQueryKeys.syncStatus,
['conversations'],
['media'],
['performance'],
];
export interface RealtimeQueryRepairTarget {
invalidateQueries(filters: { queryKey: QueryKey; refetchType: 'none' }): Promise<unknown>;
refetchQueries(filters: { queryKey: QueryKey; type: 'active' }): Promise<unknown>;
}
export async function repairRealtimeSnapshots(target: RealtimeQueryRepairTarget): Promise<void> {
await Promise.all(realtimeSnapshotKeys.map(async (queryKey) => {
await target.invalidateQueries({ queryKey, refetchType: 'none' });
await target.refetchQueries({ queryKey, type: 'active' });
}));
}
@@ -0,0 +1,7 @@
import { isRouteErrorResponse, useRouteError } from 'react-router-dom';
export function RouteErrorPage() {
const error = useRouteError();
const message = isRouteErrorResponse(error) ? `${error.status} ${error.statusText}` : error instanceof Error ? error.message : 'Unexpected route failure';
return <main className="page-state error-state" role="alert"><strong>This page could not be loaded</strong><span>{message}</span><a className="button secondary" href="/">Return to overview</a></main>;
}
+2
View File
@@ -6,12 +6,14 @@ import { MediaPage } from '../features/media/media-page.js';
import { OperationsPage } from '../features/operations/operations-page.js';
import { PerformancePage } from '../features/performance/performance-page.js';
import { SettingsRoute } from '../features/settings/settings-route.js';
import { RouteErrorPage } from './route-error-page.js';
export function createDashboardRoutes(): RouteObject[] {
return [
{
path: '/',
element: <AppShell />,
errorElement: <RouteErrorPage />,
children: [
{ index: true, element: <OverviewPage /> },
{ path: 'conversations', element: <ConversationRoute /> },
@@ -0,0 +1,4 @@
.shell-status { display: grid; gap: .45rem; margin: 1rem .5rem; padding: .8rem; color: var(--muted); background: var(--surface); border: 1px solid var(--border); border-radius: .75rem; font-size: .75rem; }
.shell-status strong { color: #e9f1ec; }
.shell-status .button { margin-top: .3rem; }
.shell-warning { color: var(--warning); }
@@ -0,0 +1,26 @@
import { useState } from 'react';
import { useStatsQuery, useSetRunningMutation } from '../api/queries.js';
import { ConfirmationDialog } from '../components/confirmation-dialog.js';
import { useSyncStatusQuery } from '../features/operations/operations-queries.js';
import { useLoginStatusQuery } from '../features/settings/settings-queries.js';
import { useDashboardRealtime } from './realtime-provider.js';
import { deriveShellStatus } from './shell-status.js';
import './shell-status-panel.css';
export function ShellStatusPanel() {
const stats = useStatsQuery();
const reddit = useLoginStatusQuery();
const sync = useSyncStatusQuery();
const realtime = useDashboardRealtime();
const control = useSetRunningMutation();
const [confirming, setConfirming] = useState<'start' | 'stop' | null>(null);
const status = deriveShellStatus({ stats: stats.data, reddit: reddit.data, sync: sync.data, realtime: realtime.connection });
return <>
<section aria-label="Platform status" className="shell-status">
<strong>{status.bot}</strong><span>{status.reddit}</span><span>{status.realtime}</span><span>{status.sync}</span>
{status.cooldown ? <span className="shell-warning">Cooldown: {status.cooldown}</span> : null}
<button aria-label={`${status.controlAction === 'start' ? 'Start' : 'Stop'} bot from global status`} className={status.controlAction === 'start' ? 'button primary' : 'button danger'} disabled={status.controlDisabled || control.isPending} onClick={() => setConfirming(status.controlAction)} type="button">{status.controlAction === 'start' ? 'Start bot' : 'Stop bot'}</button>
</section>
{confirming ? <ConfirmationDialog action={confirming} isPending={control.isPending} onCancel={() => setConfirming(null)} onConfirm={() => control.mutate(confirming === 'start', { onSuccess: () => setConfirming(null) })} /> : null}
</>;
}
+39
View File
@@ -0,0 +1,39 @@
import type { RealtimeConnectionSnapshot } from '../features/operations/realtime-client.js';
interface ShellStatusInput {
stats?: { isRunning: boolean; dryRunMode: boolean; invitationCooldown: { reason: string } | null };
reddit?: { ready: boolean; loggedIn?: boolean; status?: string; error?: string };
realtime: RealtimeConnectionSnapshot;
sync?: { syncing: boolean; syncingKind?: string | null };
}
export interface ShellStatus {
bot: string;
reddit: string;
realtime: string;
cooldown: string | null;
sync: string;
controlAction: 'start' | 'stop';
controlDisabled: boolean;
}
function sentence(value: string): string { return value.charAt(0).toUpperCase() + value.slice(1); }
export function deriveShellStatus(input: ShellStatusInput): ShellStatus {
const stats = input.stats;
const realtime = input.realtime.status === 'connected'
? 'Realtime connected'
: `Realtime ${input.realtime.status}${input.realtime.isStale ? ' (stale)' : ''}`;
const reddit = input.reddit?.ready
? input.reddit.loggedIn ? `Reddit ${input.reddit.status ?? 'authenticated'}` : 'Reddit login required'
: 'Reddit session unavailable';
return {
bot: stats ? `${stats.isRunning ? 'Running' : 'Stopped'}${stats.dryRunMode ? ' (dry run)' : ''}` : 'Bot state unavailable',
reddit,
realtime,
cooldown: stats?.invitationCooldown?.reason ?? null,
sync: input.sync?.syncing ? `${sentence(input.sync.syncingKind ?? 'unknown')} sync active` : 'No active sync',
controlAction: stats?.isRunning ? 'stop' : 'start',
controlDisabled: stats === undefined,
};
}
@@ -1,6 +1,6 @@
import { useEffect, useReducer, useRef, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import type { ActivityItem, ChatSyncStatus, DashboardEvent } from '../../../../../packages/api-contracts/src/index.js';
import { useDashboardRealtime } from '../../app/realtime-provider.js';
import {
createLiveConsoleState,
reduceLiveConsoleState,
@@ -9,12 +9,7 @@ import {
type ConsoleLevelFilter,
} from './live-console-model.js';
import { useActivityQuery, useFetchAllChatsMutation, useLogsQuery, useSyncStatusQuery, useTriggerChatSyncMutation } from './operations-queries.js';
import {
createBrowserEventSource,
createDashboardRealtimeClient,
queryKeysForDashboardEvent,
type RealtimeConnectionSnapshot,
} from './realtime-client.js';
import type { RealtimeConnectionSnapshot } from './realtime-client.js';
import './operations.css';
function formatTimestamp(timestamp: number | string): string {
@@ -73,7 +68,7 @@ export interface OperationsPageProps {
}
export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps) {
const queryClient = useQueryClient();
const realtime = useDashboardRealtime();
const activityQuery = useActivityQuery();
const logsQuery = useLogsQuery();
const syncQuery = useSyncStatusQuery();
@@ -84,10 +79,7 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
reduceLiveConsoleState,
createLiveConsoleState(500),
);
const [connection, setConnection] = useState<RealtimeConnectionSnapshot>({
status: 'idle',
isStale: true,
});
const connection = realtimeEnabled ? realtime.connection : { status: 'idle', isStale: true } as const;
const consoleEnd = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -106,10 +98,7 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
}, [logsQuery.data]);
useEffect(() => {
if (!realtimeEnabled || typeof EventSource === 'undefined') {
setConnection({ status: 'idle', isStale: true });
return;
}
if (!realtimeEnabled) return;
function onEvent(event: DashboardEvent): void {
if (event.type === 'log.created') {
const entry: ConsoleEntry = {
@@ -121,18 +110,9 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
};
dispatchConsole({ type: 'append', entry });
}
for (const queryKey of queryKeysForDashboardEvent(event)) {
void queryClient.invalidateQueries({ queryKey });
}
}
const client = createDashboardRealtimeClient({
sourceFactory: createBrowserEventSource,
onEvent,
onStateChange: setConnection,
});
client.start();
return () => client.stop();
}, [queryClient, realtimeEnabled]);
return realtime.subscribe(onEvent);
}, [realtime, realtimeEnabled]);
const visibleEntries = selectVisibleConsoleEntries(consoleState);
const syncBusy = syncQuery.data?.syncing === true || syncMutation.isPending || fetchAllMutation.isPending;
@@ -40,6 +40,7 @@ export interface DashboardRealtimeClientOptions {
url?: string;
sourceFactory(url: string): DashboardEventSource;
onEvent(event: DashboardEvent): void;
onConnected?(context: { isReconnect: boolean }): void;
onStateChange?(snapshot: RealtimeConnectionSnapshot): void;
onInvalidEvent?(value: unknown): void;
}
@@ -54,6 +55,7 @@ export function createDashboardRealtimeClient(
): DashboardRealtimeClient {
let source: DashboardEventSource | undefined;
let snapshot: RealtimeConnectionSnapshot = { status: 'idle', isStale: true };
let hasConnected = false;
function update(next: RealtimeConnectionSnapshot): void {
snapshot = next;
@@ -89,6 +91,8 @@ export function createDashboardRealtimeClient(
source = options.sourceFactory(options.url ?? '/api/events/stream');
source.addEventListener('open', () => {
update({ ...snapshot, status: 'connected', isStale: false });
options.onConnected?.({ isReconnect: hasConnected });
hasConnected = true;
});
source.addEventListener('error', () => {
update({ ...snapshot, status: 'reconnecting', isStale: true });
@@ -124,7 +128,7 @@ export function queryKeysForDashboardEvent(event: DashboardEvent): readonly Quer
case 'sync.failed':
return [['operations', 'sync-status'], ['conversations'], ['media']];
case 'reddit.session.changed':
return [['reddit', 'session']];
return [['dashboard', 'login', 'status']];
case 'log.created':
return [];
}
@@ -0,0 +1,54 @@
export interface PollingScheduler {
setInterval(callback: () => void, intervalMs: number): unknown;
clearInterval(handle: unknown): void;
}
export interface VisibilitySource {
isVisible(): boolean;
subscribe(listener: () => void): () => void;
}
export interface VisibilityAwarePollingFallback {
start(): void;
stop(): void;
setStale(stale: boolean): void;
}
export function createVisibilityAwarePollingFallback(options: {
intervalMs: number;
poll(): void;
scheduler: PollingScheduler;
visibility: VisibilitySource;
}): VisibilityAwarePollingFallback {
let stale = false;
let timer: unknown;
let unsubscribe: (() => void) | undefined;
const pollIfNeeded = () => { if (stale && options.visibility.isVisible()) options.poll(); };
return {
start() {
if (timer !== undefined) return;
timer = options.scheduler.setInterval(pollIfNeeded, options.intervalMs);
unsubscribe = options.visibility.subscribe(pollIfNeeded);
},
stop() {
if (timer !== undefined) options.scheduler.clearInterval(timer);
timer = undefined;
unsubscribe?.();
unsubscribe = undefined;
},
setStale(value) { stale = value; },
};
}
export const browserVisibilitySource: VisibilitySource = {
isVisible: () => document.visibilityState === 'visible',
subscribe(listener) {
document.addEventListener('visibilitychange', listener);
return () => document.removeEventListener('visibilitychange', listener);
},
};
export const browserPollingScheduler: PollingScheduler = {
setInterval: (callback, intervalMs) => window.setInterval(callback, intervalMs),
clearInterval: (handle) => { if (typeof handle === 'number') window.clearInterval(handle); },
};
+51
View File
@@ -0,0 +1,51 @@
import type { RealtimeEvent } from './event-types.js';
import type { DashboardEventPublisher } from './event-publisher.js';
type Published = RealtimeEvent;
type SyncInput =
| { phase: 'started'; id: string; kind: 'delta' | 'full' | 'media' }
| { phase: 'progress'; id: string; kind: 'delta' | 'full' | 'media'; current: number; total?: number; label?: string }
| { phase: 'completed'; id: string; kind: 'delta' | 'full' | 'media'; processed: number }
| { phase: 'failed'; id: string; kind: 'delta' | 'full' | 'media'; error: string; code?: string };
const syncKind = (kind: SyncInput['kind']) => kind === 'delta' ? 'conversations' as const : kind;
const errorReason = (error: unknown) => error instanceof Error ? error.message : String(error);
export function createDashboardEventAdapters(hub: DashboardEventPublisher) {
return {
bot(input: { running: boolean; dryRun: boolean; transitioning?: 'starting' | 'stopping'; reason?: string }): Published {
return hub.publish('bot.status.changed', { status: input.transitioning ?? (input.running ? 'running' : 'stopped'), dryRun: input.dryRun, reason: input.reason });
},
messageSent(input: { id: string; username: string; conversationId?: string }): Published {
return hub.publish('message.sent', { messageId: input.id, recipient: input.username, conversationId: input.conversationId });
},
messageFailed(input: { username: string; error: unknown; messageId?: string; conversationId?: string; code?: string; retryable?: boolean }): Published {
return hub.publish('message.failed', { recipient: input.username, reason: errorReason(input.error), messageId: input.messageId, conversationId: input.conversationId, code: input.code, retryable: input.retryable });
},
conversation(input: { id: string; state: 'new' | 'awaiting_reply' | 'replied' | 'closed'; unread?: number }): Published {
return hub.publish('conversation.updated', { conversationId: input.id, status: input.state === 'awaiting_reply' ? 'awaiting-reply' : input.state, unreadCount: input.unread });
},
reply(input: { conversationId: string; id: string; username: string; body?: string }): Published {
return hub.publish('reply.received', { conversationId: input.conversationId, replyId: input.id, sender: input.username, preview: input.body });
},
sync(input: SyncInput): Published {
const kind = syncKind(input.kind);
switch (input.phase) {
case 'started': return hub.publish('sync.started', { syncId: input.id, kind });
case 'progress': return hub.publish('sync.progress', { syncId: input.id, kind, completed: input.current, total: input.total, message: input.label });
case 'completed': return hub.publish('sync.completed', { syncId: input.id, kind, processed: input.processed });
case 'failed': return hub.publish('sync.failed', { syncId: input.id, kind, reason: input.error, code: input.code });
}
},
rateLimit(input: { blocked: boolean; remaining?: number; resetAt?: number; reason?: string }): Published {
return hub.publish('rate-limit.changed', { limited: input.blocked, remaining: input.remaining, resetsAt: input.resetAt === undefined ? undefined : new Date(input.resetAt).toISOString(), reason: input.reason });
},
redditSession(input: { state: 'unknown' | 'authenticating' | 'authenticated' | 'expired' | 'login-required' | 'unreachable'; username?: string; reason?: string }): Published {
const status = input.state === 'login-required' ? 'logged-out' : input.state === 'unreachable' ? 'error' : input.state;
return hub.publish('reddit.session.changed', { status, username: input.username, reason: input.reason });
},
log(input: { level: 'debug' | 'info' | 'warn' | 'error'; message: string; data?: unknown }): Published {
return hub.publish('log.created', input);
},
};
}
+10
View File
@@ -0,0 +1,10 @@
import type { RealtimeEvent, RealtimeEventMap, RealtimeEventType } from './event-types.js';
import type { RealtimeEventHub } from './realtime-event-hub.js';
export interface DashboardEventPublisher {
publish<TType extends RealtimeEventType>(type: TType, payload: RealtimeEventMap[TType]): RealtimeEvent<TType>;
}
export function createDashboardEventPublisher(hub: RealtimeEventHub): DashboardEventPublisher {
return { publish: (type, payload) => hub.publish(type, payload) };
}
+2
View File
@@ -21,3 +21,5 @@ export {
type RealtimeEventSubscription,
type SubscribeOptions,
} from './realtime-event-hub.js';
export { createDashboardEventAdapters } from './event-adapters.js';
export { createDashboardEventPublisher, type DashboardEventPublisher } from './event-publisher.js';
+18
View File
@@ -57,6 +57,24 @@ describe('dashboard realtime client', () => {
expect(client.getSnapshot()).toMatchObject({ status: 'closed', isStale: true });
});
it('requests an authoritative repair on initial connection and every reconnect', () => {
const source = new FakeEventSource();
const connected = vi.fn();
const client = createDashboardRealtimeClient({
sourceFactory: () => source,
onEvent: vi.fn(),
onConnected: connected,
});
client.start();
source.emit('open');
source.emit('error');
source.emit('open');
expect(connected).toHaveBeenNthCalledWith(1, { isReconnect: false });
expect(connected).toHaveBeenNthCalledWith(2, { isReconnect: true });
});
it('maps events to the queries whose authoritative snapshots need refresh', () => {
expect(
queryKeysForDashboardEvent({
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from 'vitest';
import { createVisibilityAwarePollingFallback } from '../../apps/dashboard/src/features/operations/realtime-fallback.js';
describe('visibility-aware realtime fallback', () => {
it('polls only while stale and visible, including immediately on returning visible', () => {
let visible = false;
let interval: (() => void) | undefined;
let visibilityListener: (() => void) | undefined;
const poll = vi.fn();
const fallback = createVisibilityAwarePollingFallback({
intervalMs: 5_000,
poll,
scheduler: {
setInterval(callback) { interval = callback; return 'timer'; },
clearInterval: vi.fn(),
},
visibility: {
isVisible: () => visible,
subscribe(listener) { visibilityListener = listener; return vi.fn(); },
},
});
fallback.start();
fallback.setStale(true);
interval?.();
expect(poll).not.toHaveBeenCalled();
visible = true;
visibilityListener?.();
expect(poll).toHaveBeenCalledOnce();
interval?.();
expect(poll).toHaveBeenCalledTimes(2);
fallback.setStale(false);
interval?.();
expect(poll).toHaveBeenCalledTimes(2);
});
});
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it, vi } from 'vitest';
import { repairRealtimeSnapshots } from '../../apps/dashboard/src/app/realtime-repair.js';
describe('authoritative realtime repair', () => {
it('marks snapshots stale and explicitly refetches active observers', async () => {
const invalidateQueries = vi.fn().mockResolvedValue(undefined);
const refetchQueries = vi.fn().mockResolvedValue(undefined);
await repairRealtimeSnapshots({ invalidateQueries, refetchQueries });
expect(invalidateQueries).toHaveBeenCalled();
expect(refetchQueries).toHaveBeenCalled();
expect(refetchQueries).toHaveBeenCalledWith(expect.objectContaining({ type: 'active' }));
});
});
@@ -0,0 +1,10 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { createDashboardRoutes } from '../../apps/dashboard/src/app/router.js';
describe('dashboard route error boundary', () => {
it('protects the application shell from route rendering failures', () => {
const root = createDashboardRoutes()[0];
expect(root?.errorElement).toBeTruthy();
});
});
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { deriveShellStatus } from '../../apps/dashboard/src/app/shell-status.js';
describe('global shell status', () => {
it('derives safety-critical labels from authoritative snapshots', () => {
expect(deriveShellStatus({
stats: { isRunning: true, dryRunMode: true, invitationCooldown: { reason: 'Reddit cooldown' } },
reddit: { ready: true, loggedIn: true, status: 'authenticated' },
realtime: { status: 'reconnecting', isStale: true },
sync: { syncing: true, syncingKind: 'full' },
})).toEqual({
bot: 'Running (dry run)', reddit: 'Reddit authenticated', realtime: 'Realtime reconnecting (stale)',
cooldown: 'Reddit cooldown', sync: 'Full sync active', controlAction: 'stop', controlDisabled: false,
});
});
it('guards bot controls until the authoritative bot state is available', () => {
expect(deriveShellStatus({ realtime: { status: 'idle', isStale: true } })).toMatchObject({
bot: 'Bot state unavailable', controlDisabled: true,
});
});
});
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { RealtimeEventHub } from '../../../src/web/events/realtime-event-hub.js';
import { createDashboardEventAdapters } from '../../../src/web/events/event-adapters.js';
import { createDashboardEventPublisher } from '../../../src/web/events/event-publisher.js';
describe('dashboard event adapters', () => {
it('provides a transport-neutral typed publisher over the event hub', () => {
const hub = new RealtimeEventHub({ capacity: 2 });
const publisher = createDashboardEventPublisher(hub);
const event = publisher.publish('bot.status.changed', { status: 'running', dryRun: false });
expect(event.type).toBe('bot.status.changed');
expect(hub.recent()).toEqual([event]);
});
it('translates every route and service event family to typed dashboard events', () => {
const hub = new RealtimeEventHub({ capacity: 20 });
const adapter = createDashboardEventAdapters(hub);
adapter.bot({ running: true, dryRun: true });
adapter.messageSent({ id: 'm1', username: 'alice', conversationId: 'c1' });
adapter.messageFailed({ username: 'bob', error: new Error('blocked'), retryable: false });
adapter.conversation({ id: 'c1', state: 'awaiting_reply', unread: 2 });
adapter.reply({ conversationId: 'c1', id: 'r1', username: 'alice', body: 'hello' });
adapter.sync({ phase: 'started', id: 's1', kind: 'delta' });
adapter.sync({ phase: 'progress', id: 's1', kind: 'delta', current: 2, total: 3 });
adapter.sync({ phase: 'completed', id: 's1', kind: 'delta', processed: 3 });
adapter.sync({ phase: 'failed', id: 's2', kind: 'media', error: 'network' });
adapter.rateLimit({ blocked: true, remaining: 0, resetAt: 1_786_176_000_000 });
adapter.redditSession({ state: 'login-required' });
adapter.log({ level: 'warn', message: 'careful' });
expect(hub.recent().map((event) => event.type)).toEqual([
'bot.status.changed', 'message.sent', 'message.failed', 'conversation.updated', 'reply.received',
'sync.started', 'sync.progress', 'sync.completed', 'sync.failed', 'rate-limit.changed',
'reddit.session.changed', 'log.created',
]);
expect(hub.recent()[2]?.payload).toMatchObject({ recipient: 'bob', reason: 'blocked' });
expect(hub.recent()[3]?.payload).toMatchObject({ status: 'awaiting-reply', unreadCount: 2 });
expect(hub.recent()[10]?.payload).toEqual({ status: 'logged-out' });
});
});