feat(dashboard): add performance and operations slices
This commit is contained in:
1 parent
20fa83f245
commit
5917b63755
13 files changed
+1059
No files matched your search
@@ -0,0 +1,106 @@
|
||||
export type ConsoleLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
export type ConsoleLevelFilter = 'all' | ConsoleLevel;
|
||||
|
||||
export interface ConsoleEntry {
|
||||
id: string;
|
||||
occurredAt: string;
|
||||
level: ConsoleLevel;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface LiveConsoleState {
|
||||
capacity: number;
|
||||
entries: readonly ConsoleEntry[];
|
||||
pendingEntries: readonly ConsoleEntry[];
|
||||
isPaused: boolean;
|
||||
isAutoScrollEnabled: boolean;
|
||||
level: ConsoleLevelFilter;
|
||||
search: string;
|
||||
}
|
||||
|
||||
export type LiveConsoleAction =
|
||||
| { type: 'append'; entry: ConsoleEntry }
|
||||
| { type: 'set-paused'; paused: boolean }
|
||||
| { type: 'set-auto-scroll'; enabled: boolean }
|
||||
| { type: 'set-level'; level: ConsoleLevelFilter }
|
||||
| { type: 'set-search'; search: string };
|
||||
|
||||
function boundedAppend(
|
||||
entries: readonly ConsoleEntry[],
|
||||
entry: ConsoleEntry,
|
||||
capacity: number,
|
||||
): readonly ConsoleEntry[] {
|
||||
if (entries.some(({ id }) => id === entry.id)) return entries;
|
||||
return [...entries, entry].slice(-capacity);
|
||||
}
|
||||
|
||||
export function createLiveConsoleState(capacity = 500): LiveConsoleState {
|
||||
if (!Number.isInteger(capacity) || capacity <= 0) {
|
||||
throw new RangeError('capacity must be a positive integer');
|
||||
}
|
||||
return {
|
||||
capacity,
|
||||
entries: [],
|
||||
pendingEntries: [],
|
||||
isPaused: false,
|
||||
isAutoScrollEnabled: true,
|
||||
level: 'all',
|
||||
search: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function reduceLiveConsoleState(
|
||||
state: LiveConsoleState,
|
||||
action: LiveConsoleAction,
|
||||
): LiveConsoleState {
|
||||
switch (action.type) {
|
||||
case 'append': {
|
||||
if (
|
||||
state.entries.some(({ id }) => id === action.entry.id) ||
|
||||
state.pendingEntries.some(({ id }) => id === action.entry.id)
|
||||
) return state;
|
||||
return state.isPaused
|
||||
? { ...state, pendingEntries: boundedAppend(state.pendingEntries, action.entry, state.capacity) }
|
||||
: { ...state, entries: boundedAppend(state.entries, action.entry, state.capacity) };
|
||||
}
|
||||
case 'set-paused':
|
||||
if (action.paused === state.isPaused) return state;
|
||||
return action.paused
|
||||
? { ...state, isPaused: true }
|
||||
: {
|
||||
...state,
|
||||
isPaused: false,
|
||||
entries: [...state.entries, ...state.pendingEntries].slice(-state.capacity),
|
||||
pendingEntries: [],
|
||||
};
|
||||
case 'set-auto-scroll':
|
||||
return { ...state, isAutoScrollEnabled: action.enabled };
|
||||
case 'set-level':
|
||||
return { ...state, level: action.level };
|
||||
case 'set-search':
|
||||
return { ...state, search: action.search };
|
||||
}
|
||||
}
|
||||
|
||||
function searchableData(data: unknown): string {
|
||||
if (data === undefined) return '';
|
||||
try {
|
||||
return JSON.stringify(data);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function selectVisibleConsoleEntries(
|
||||
state: LiveConsoleState,
|
||||
): readonly ConsoleEntry[] {
|
||||
const search = state.search.trim().toLocaleLowerCase();
|
||||
return state.entries.filter((entry) => {
|
||||
if (state.level !== 'all' && entry.level !== state.level) return false;
|
||||
if (search.length === 0) return true;
|
||||
return `${entry.message} ${searchableData(entry.data)}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(search);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
ActivityResponseSchema,
|
||||
ChatSyncStatusSchema,
|
||||
} from '../../../../../packages/api-contracts/src/index.js';
|
||||
|
||||
export const RawLogEntrySchema = z.strictObject({
|
||||
timestamp: z.number(),
|
||||
level: z.union([z.literal('debug'), z.literal('info'), z.literal('warn'), z.literal('error')]),
|
||||
message: z.string(),
|
||||
data: z.unknown().optional(),
|
||||
});
|
||||
export const RawLogsResponseSchema = z.array(RawLogEntrySchema);
|
||||
export type RawLogEntry = z.infer<typeof RawLogEntrySchema>;
|
||||
|
||||
async function parseResponse<T>(
|
||||
response: Response,
|
||||
schema: { parse(value: unknown): T },
|
||||
): Promise<T> {
|
||||
const payload: unknown = await response.json();
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof payload === 'object' &&
|
||||
payload !== null &&
|
||||
'error' in payload &&
|
||||
typeof payload.error === 'string'
|
||||
? payload.error
|
||||
: `Request failed (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return schema.parse(payload);
|
||||
}
|
||||
|
||||
export function createOperationsApi(fetchImplementation?: typeof fetch) {
|
||||
function request(path: string): Promise<Response> {
|
||||
return (fetchImplementation ?? globalThis.fetch)(path);
|
||||
}
|
||||
return {
|
||||
async getActivity() {
|
||||
return parseResponse(await request('/api/activity'), ActivityResponseSchema);
|
||||
},
|
||||
async getLogs(limit = 200) {
|
||||
return parseResponse(
|
||||
await request(`/api/logs?limit=${limit}`),
|
||||
RawLogsResponseSchema,
|
||||
);
|
||||
},
|
||||
async getSyncStatus() {
|
||||
return parseResponse(await request('/api/chats/sync'), ChatSyncStatusSchema);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const operationsApi = createOperationsApi();
|
||||
@@ -0,0 +1,179 @@
|
||||
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 {
|
||||
createLiveConsoleState,
|
||||
reduceLiveConsoleState,
|
||||
selectVisibleConsoleEntries,
|
||||
type ConsoleEntry,
|
||||
type ConsoleLevelFilter,
|
||||
} from './live-console-model.js';
|
||||
import { useActivityQuery, useLogsQuery, useSyncStatusQuery } from './operations-queries.js';
|
||||
import {
|
||||
createBrowserEventSource,
|
||||
createDashboardRealtimeClient,
|
||||
queryKeysForDashboardEvent,
|
||||
type RealtimeConnectionSnapshot,
|
||||
} from './realtime-client.js';
|
||||
import './operations.css';
|
||||
|
||||
function formatTimestamp(timestamp: number | string): string {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
}).format(typeof timestamp === 'number' ? timestamp : new Date(timestamp));
|
||||
}
|
||||
|
||||
function ActivityView({ items }: { items: readonly ActivityItem[] }) {
|
||||
return items.length === 0 ? (
|
||||
<p className="feature-empty">No recent operator activity.</p>
|
||||
) : (
|
||||
<ol className="operations-activity-list">
|
||||
{items.map((item) => (
|
||||
<li key={`${item.timestamp}-${item.message}`}>
|
||||
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
|
||||
<div><strong>{item.message}</strong><small>{formatTimestamp(item.timestamp)}{item.recipient ? ` · u/${item.recipient}` : ''}</small></div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncSummary({ status }: { status: ChatSyncStatus }) {
|
||||
const progress = status.progress;
|
||||
return (
|
||||
<div className="sync-summary">
|
||||
<div><span className={`pill ${status.syncing ? 'warning' : 'success'}`}>{status.syncing ? `${status.syncingKind ?? 'Unknown'} sync running` : 'Idle'}</span><strong>{status.cachedChats} cached conversations</strong></div>
|
||||
{progress ? (
|
||||
<div>
|
||||
<div className="sync-progress-label"><span>{progress.label ?? progress.phase}</span><strong>{progress.current} of {progress.total}</strong></div>
|
||||
<progress aria-label="Sync progress" max={Math.max(1, progress.total)} value={progress.current} />
|
||||
</div>
|
||||
) : null}
|
||||
<dl className="sync-history">
|
||||
<div><dt>Last delta sync</dt><dd>{status.lastDeltaSyncAt ? formatTimestamp(status.lastDeltaSyncAt) : 'Never'}</dd></div>
|
||||
<div><dt>Last full sync</dt><dd>{status.lastFullSyncAt ? formatTimestamp(status.lastFullSyncAt) : 'Never'}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function connectionLabel(snapshot: RealtimeConnectionSnapshot): string {
|
||||
switch (snapshot.status) {
|
||||
case 'connected': return 'Realtime connected';
|
||||
case 'connecting': return 'Connecting to realtime updates';
|
||||
case 'reconnecting': return 'Realtime reconnecting';
|
||||
case 'closed': return 'Realtime closed';
|
||||
case 'idle': return 'Realtime unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
export interface OperationsPageProps {
|
||||
realtimeEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const activityQuery = useActivityQuery();
|
||||
const logsQuery = useLogsQuery();
|
||||
const syncQuery = useSyncStatusQuery();
|
||||
const [consoleState, dispatchConsole] = useReducer(
|
||||
reduceLiveConsoleState,
|
||||
createLiveConsoleState(500),
|
||||
);
|
||||
const [connection, setConnection] = useState<RealtimeConnectionSnapshot>({
|
||||
status: 'idle',
|
||||
isStale: true,
|
||||
});
|
||||
const consoleEnd = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
logsQuery.data?.forEach((entry, index) => {
|
||||
dispatchConsole({
|
||||
type: 'append',
|
||||
entry: {
|
||||
id: `initial-${entry.timestamp}-${index}`,
|
||||
occurredAt: new Date(entry.timestamp).toISOString(),
|
||||
level: entry.level,
|
||||
message: entry.message,
|
||||
data: entry.data,
|
||||
},
|
||||
});
|
||||
});
|
||||
}, [logsQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!realtimeEnabled || typeof EventSource === 'undefined') {
|
||||
setConnection({ status: 'idle', isStale: true });
|
||||
return;
|
||||
}
|
||||
function onEvent(event: DashboardEvent): void {
|
||||
if (event.type === 'log.created') {
|
||||
const entry: ConsoleEntry = {
|
||||
id: event.id,
|
||||
occurredAt: event.occurredAt,
|
||||
level: event.payload.level,
|
||||
message: event.payload.message,
|
||||
data: event.payload.data,
|
||||
};
|
||||
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]);
|
||||
|
||||
const visibleEntries = selectVisibleConsoleEntries(consoleState);
|
||||
useEffect(() => {
|
||||
if (
|
||||
consoleState.isAutoScrollEnabled &&
|
||||
!consoleState.isPaused &&
|
||||
typeof consoleEnd.current?.scrollIntoView === 'function'
|
||||
) {
|
||||
consoleEnd.current.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [consoleState.entries, consoleState.isAutoScrollEnabled, consoleState.isPaused]);
|
||||
|
||||
return (
|
||||
<div className="page operations-page">
|
||||
<header className="page-header"><div><span className="eyebrow">Observability</span><h1>Operations</h1><p>Human-readable activity, background work, and raw diagnostic output in distinct views.</p></div></header>
|
||||
<div aria-label="Realtime connection" className={`connection-banner ${connection.isStale ? 'stale' : ''}`} role="status">
|
||||
<strong>{connectionLabel(connection)}</strong>
|
||||
{connection.isStale ? <span>State may be stale</span> : <span>Events are updating live</span>}
|
||||
</div>
|
||||
<div className="operations-summary-grid">
|
||||
<section aria-labelledby="operations-activity-heading" className="panel feature-panel" role="region">
|
||||
<div className="panel-heading"><div><span className="eyebrow">Readable history</span><h2 id="operations-activity-heading">Activity</h2></div></div>
|
||||
{activityQuery.isPending ? <p role="status">Loading activity…</p> : activityQuery.isError ? <p role="alert">{activityQuery.error.message}</p> : <ActivityView items={activityQuery.data} />}
|
||||
</section>
|
||||
<section aria-labelledby="sync-jobs-heading" className="panel feature-panel" role="region">
|
||||
<div className="panel-heading"><div><span className="eyebrow">Background work</span><h2 id="sync-jobs-heading">Sync jobs</h2></div></div>
|
||||
{syncQuery.isPending ? <p role="status">Loading sync state…</p> : syncQuery.isError ? <p role="alert">{syncQuery.error.message}</p> : <SyncSummary status={syncQuery.data} />}
|
||||
</section>
|
||||
</div>
|
||||
<section aria-labelledby="live-console-heading" className="panel console-panel" role="region">
|
||||
<div className="panel-heading"><div><span className="eyebrow">Raw diagnostics</span><h2 id="live-console-heading">Live console</h2></div><span className="console-count">{consoleState.entries.length} retained</span></div>
|
||||
<div className="console-controls">
|
||||
<button aria-pressed={consoleState.isPaused} className="button secondary" onClick={() => dispatchConsole({ type: 'set-paused', paused: !consoleState.isPaused })} type="button">{consoleState.isPaused ? `Resume console (${consoleState.pendingEntries.length})` : 'Pause console'}</button>
|
||||
<label>Log level<select aria-label="Log level" onChange={(event) => dispatchConsole({ type: 'set-level', level: event.target.value as ConsoleLevelFilter })} value={consoleState.level}><option value="all">All levels</option><option value="debug">Debug</option><option value="info">Info</option><option value="warn">Warning</option><option value="error">Error</option></select></label>
|
||||
<label>Search logs<input aria-label="Search logs" onChange={(event) => dispatchConsole({ type: 'set-search', search: event.target.value })} placeholder="Message or metadata" type="search" value={consoleState.search} /></label>
|
||||
<label className="checkbox-control"><input checked={consoleState.isAutoScrollEnabled} onChange={(event) => dispatchConsole({ type: 'set-auto-scroll', enabled: event.target.checked })} type="checkbox" />Auto-scroll</label>
|
||||
</div>
|
||||
<div aria-live="polite" className="live-console" role="log">
|
||||
{logsQuery.isPending ? <p>Loading logs…</p> : visibleEntries.length === 0 ? <p>No logs match the current filters.</p> : visibleEntries.map((entry) => (
|
||||
<article className={`console-entry ${entry.level}`} key={entry.id}><time dateTime={entry.occurredAt}>{formatTimestamp(entry.occurredAt)}</time><strong>{entry.level.toUpperCase()}</strong><span>{entry.message}</span></article>
|
||||
))}
|
||||
<div ref={consoleEnd} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { operationsApi } from './operations-api.js';
|
||||
|
||||
export const operationsQueryKeys = {
|
||||
activity: ['operations', 'activity'] as const,
|
||||
logs: ['operations', 'logs'] as const,
|
||||
syncStatus: ['operations', 'sync-status'] as const,
|
||||
};
|
||||
|
||||
export function useActivityQuery() {
|
||||
return useQuery({ queryKey: operationsQueryKeys.activity, queryFn: operationsApi.getActivity });
|
||||
}
|
||||
|
||||
export function useLogsQuery() {
|
||||
return useQuery({ queryKey: operationsQueryKeys.logs, queryFn: () => operationsApi.getLogs(200) });
|
||||
}
|
||||
|
||||
export function useSyncStatusQuery() {
|
||||
return useQuery({ queryKey: operationsQueryKeys.syncStatus, queryFn: operationsApi.getSyncStatus });
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
.operations-page { display: grid; gap: .8rem; }
|
||||
.connection-banner { display: flex; justify-content: space-between; gap: 1rem; padding: .8rem 1rem; color: #aaf0c4; background: #163a26; border: 1px solid #28593d; border-radius: .75rem; }
|
||||
.connection-banner.stale { color: #ffe1a0; background: #2a2111; border-color: #59451e; }
|
||||
.operations-summary-grid { display: grid; gap: .8rem; }
|
||||
.operations-activity-list { display: grid; gap: .8rem; padding: 0; margin: 0; list-style: none; }
|
||||
.operations-activity-list li { display: flex; gap: .75rem; }
|
||||
.operations-activity-list li div { display: grid; gap: .2rem; }
|
||||
.operations-activity-list small { color: var(--muted); }
|
||||
.sync-summary { display: grid; gap: 1rem; }
|
||||
.sync-summary > div:first-child { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
|
||||
.sync-progress-label { display: flex; justify-content: space-between; gap: .75rem; margin-bottom: .45rem; color: var(--muted); }
|
||||
.sync-summary progress { width: 100%; accent-color: var(--accent); }
|
||||
.sync-history { display: grid; gap: .5rem; margin: 0; }
|
||||
.sync-history div { display: flex; justify-content: space-between; gap: 1rem; padding-top: .6rem; border-top: 1px solid var(--border); }
|
||||
.sync-history dt { color: var(--muted); }
|
||||
.sync-history dd { margin: 0; }
|
||||
.console-count { color: var(--muted); font-size: .8rem; }
|
||||
.console-controls { display: grid; gap: .65rem; margin-bottom: .8rem; }
|
||||
.console-controls label { display: grid; gap: .3rem; color: var(--muted); font-size: .78rem; font-weight: 700; }
|
||||
.console-controls input, .console-controls select { min-height: 2.5rem; padding: .5rem .65rem; color: #e9f1ec; background: #09110e; border: 1px solid var(--border); border-radius: .55rem; }
|
||||
.console-controls .checkbox-control { display: flex; flex-direction: row; align-items: center; }
|
||||
.console-controls .checkbox-control input { min-height: auto; }
|
||||
.live-console { height: min(30rem, 55vh); overflow: auto; padding: .65rem; color: #cbd8d1; background: #050906; border: 1px solid var(--border); border-radius: .6rem; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .78rem; }
|
||||
.console-entry { display: grid; grid-template-columns: 11rem 3.5rem minmax(0, 1fr); gap: .65rem; padding: .35rem; border-bottom: 1px solid #142019; }
|
||||
.console-entry time { color: #708178; }
|
||||
.console-entry strong { color: #8da49a; }
|
||||
.console-entry.warn strong { color: var(--warning); }
|
||||
.console-entry.error strong { color: var(--danger); }
|
||||
.live-console > p { color: var(--muted); text-align: center; }
|
||||
@media (min-width: 56rem) {
|
||||
.operations-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.console-controls { grid-template-columns: auto minmax(8rem, .45fr) minmax(14rem, 1fr) auto; align-items: end; }
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { QueryKey } from '@tanstack/react-query';
|
||||
import {
|
||||
DashboardEventSchema,
|
||||
type DashboardEvent,
|
||||
} from '../../../../../packages/api-contracts/src/index.js';
|
||||
|
||||
export const DASHBOARD_EVENT_TYPES = [
|
||||
'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',
|
||||
] as const;
|
||||
|
||||
export interface DashboardEventSource {
|
||||
addEventListener(type: string, listener: (event: unknown) => void): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface RealtimeConnectionSnapshot {
|
||||
status: 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'closed';
|
||||
isStale: boolean;
|
||||
lastEventId?: string;
|
||||
}
|
||||
|
||||
export interface DashboardRealtimeClient {
|
||||
start(): void;
|
||||
stop(): void;
|
||||
getSnapshot(): RealtimeConnectionSnapshot;
|
||||
}
|
||||
|
||||
export interface DashboardRealtimeClientOptions {
|
||||
url?: string;
|
||||
sourceFactory(url: string): DashboardEventSource;
|
||||
onEvent(event: DashboardEvent): void;
|
||||
onStateChange?(snapshot: RealtimeConnectionSnapshot): void;
|
||||
onInvalidEvent?(value: unknown): void;
|
||||
}
|
||||
|
||||
function eventData(value: unknown): string | undefined {
|
||||
if (typeof value !== 'object' || value === null || !('data' in value)) return undefined;
|
||||
return typeof value.data === 'string' ? value.data : undefined;
|
||||
}
|
||||
|
||||
export function createDashboardRealtimeClient(
|
||||
options: DashboardRealtimeClientOptions,
|
||||
): DashboardRealtimeClient {
|
||||
let source: DashboardEventSource | undefined;
|
||||
let snapshot: RealtimeConnectionSnapshot = { status: 'idle', isStale: true };
|
||||
|
||||
function update(next: RealtimeConnectionSnapshot): void {
|
||||
snapshot = next;
|
||||
options.onStateChange?.(snapshot);
|
||||
}
|
||||
|
||||
function receive(value: unknown): void {
|
||||
const data = eventData(value);
|
||||
if (data === undefined) {
|
||||
options.onInvalidEvent?.(value);
|
||||
return;
|
||||
}
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(data);
|
||||
} catch {
|
||||
options.onInvalidEvent?.(data);
|
||||
return;
|
||||
}
|
||||
const result = DashboardEventSchema.safeParse(decoded);
|
||||
if (!result.success) {
|
||||
options.onInvalidEvent?.(decoded);
|
||||
return;
|
||||
}
|
||||
snapshot = { ...snapshot, lastEventId: result.data.id };
|
||||
options.onEvent(result.data);
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (source !== undefined) return;
|
||||
update({ ...snapshot, status: 'connecting', isStale: true });
|
||||
source = options.sourceFactory(options.url ?? '/api/events/stream');
|
||||
source.addEventListener('open', () => {
|
||||
update({ ...snapshot, status: 'connected', isStale: false });
|
||||
});
|
||||
source.addEventListener('error', () => {
|
||||
update({ ...snapshot, status: 'reconnecting', isStale: true });
|
||||
});
|
||||
for (const type of DASHBOARD_EVENT_TYPES) source.addEventListener(type, receive);
|
||||
},
|
||||
stop() {
|
||||
source?.close();
|
||||
source = undefined;
|
||||
update({ ...snapshot, status: 'closed', isStale: true });
|
||||
},
|
||||
getSnapshot() {
|
||||
return snapshot;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function queryKeysForDashboardEvent(event: DashboardEvent): readonly QueryKey[] {
|
||||
switch (event.type) {
|
||||
case 'bot.status.changed':
|
||||
return [['dashboard', 'stats']];
|
||||
case 'rate-limit.changed':
|
||||
return [['dashboard', 'stats'], ['dashboard', 'timers']];
|
||||
case 'message.sent':
|
||||
case 'message.failed':
|
||||
return [['conversations'], ['performance']];
|
||||
case 'conversation.updated':
|
||||
case 'reply.received':
|
||||
return [['conversations'], ['operations', 'activity']];
|
||||
case 'sync.started':
|
||||
case 'sync.progress':
|
||||
case 'sync.completed':
|
||||
case 'sync.failed':
|
||||
return [['operations', 'sync-status'], ['conversations'], ['media']];
|
||||
case 'reddit.session.changed':
|
||||
return [['reddit', 'session']];
|
||||
case 'log.created':
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function createBrowserEventSource(url: string): DashboardEventSource {
|
||||
const source = new EventSource(url);
|
||||
return {
|
||||
addEventListener(type, listener) {
|
||||
source.addEventListener(type, (event) => listener(event));
|
||||
},
|
||||
close() {
|
||||
source.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user