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,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