Harden dashboard realtime state and controls
This commit is contained in:
1 parent
4b3f00729f
commit
d108903174
21 files changed
+422
-29
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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}
|
||||
</>;
|
||||
}
|
||||
@@ -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); },
|
||||
};
|
||||
Reference in new issue
Block a user