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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
DailyTrendResponseSchema,
|
||||
StrategyPerformanceResponseSchema,
|
||||
SubredditPerformanceResponseSchema,
|
||||
SuccessfulExamplesResponseSchema,
|
||||
} from '../../../../../packages/api-contracts/src/index.js';
|
||||
import type { z } from 'zod';
|
||||
|
||||
type SubredditPerformanceResponse = z.infer<typeof SubredditPerformanceResponseSchema>;
|
||||
type StrategyPerformanceResponse = z.infer<typeof StrategyPerformanceResponseSchema>;
|
||||
type DailyTrendResponse = z.infer<typeof DailyTrendResponseSchema>;
|
||||
type SuccessfulExamplesResponse = z.infer<typeof SuccessfulExamplesResponseSchema>;
|
||||
|
||||
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 interface PerformanceApi {
|
||||
getSubredditPerformance(): Promise<SubredditPerformanceResponse>;
|
||||
getStrategyPerformance(): Promise<StrategyPerformanceResponse>;
|
||||
getDailyTrend(days: number): Promise<DailyTrendResponse>;
|
||||
getSuccessfulExamples(): Promise<SuccessfulExamplesResponse>;
|
||||
}
|
||||
|
||||
export function createPerformanceApi(
|
||||
fetchImplementation?: typeof fetch,
|
||||
) {
|
||||
function request(path: string): Promise<Response> {
|
||||
return (fetchImplementation ?? globalThis.fetch)(path);
|
||||
}
|
||||
return {
|
||||
async getSubredditPerformance() {
|
||||
return parseResponse(
|
||||
await request('/api/analytics/by-subreddit'),
|
||||
SubredditPerformanceResponseSchema,
|
||||
);
|
||||
},
|
||||
async getStrategyPerformance() {
|
||||
return parseResponse(
|
||||
await request('/api/analytics/by-strategy'),
|
||||
StrategyPerformanceResponseSchema,
|
||||
);
|
||||
},
|
||||
async getDailyTrend(days: number) {
|
||||
return parseResponse(
|
||||
await request(`/api/analytics/trend?days=${days}`),
|
||||
DailyTrendResponseSchema,
|
||||
);
|
||||
},
|
||||
async getSuccessfulExamples() {
|
||||
return parseResponse(
|
||||
await request('/api/successful'),
|
||||
SuccessfulExamplesResponseSchema,
|
||||
);
|
||||
},
|
||||
} satisfies PerformanceApi;
|
||||
}
|
||||
|
||||
export const performanceApi = createPerformanceApi();
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { z } from 'zod';
|
||||
import type {
|
||||
DailyTrendResponseSchema,
|
||||
StrategyPerformanceResponseSchema,
|
||||
SubredditPerformanceResponseSchema,
|
||||
SuccessfulExample,
|
||||
} from '../../../../../packages/api-contracts/src/index.js';
|
||||
import {
|
||||
useDailyTrendQuery,
|
||||
useStrategyPerformanceQuery,
|
||||
useSubredditPerformanceQuery,
|
||||
useSuccessfulExamplesQuery,
|
||||
} from './performance-queries.js';
|
||||
import './performance.css';
|
||||
|
||||
const LOW_CONFIDENCE_SAMPLE_SIZE = 20;
|
||||
type SubredditPerformance = z.infer<typeof SubredditPerformanceResponseSchema>[number];
|
||||
type StrategyPerformance = z.infer<typeof StrategyPerformanceResponseSchema>[number];
|
||||
type DailyTrend = z.infer<typeof DailyTrendResponseSchema>[number];
|
||||
|
||||
function SampleSize({ totalSent }: { totalSent: number }) {
|
||||
const isLowConfidence = totalSent < LOW_CONFIDENCE_SAMPLE_SIZE;
|
||||
return (
|
||||
<span className="sample-size">
|
||||
n = {totalSent}
|
||||
{isLowConfidence ? <span className="confidence-warning">Low confidence</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PerformanceTable({
|
||||
label,
|
||||
rows,
|
||||
rowLabel,
|
||||
}: {
|
||||
label: string;
|
||||
rows: readonly (SubredditPerformance | StrategyPerformance)[];
|
||||
rowLabel: (row: SubredditPerformance | StrategyPerformance) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="table-scroll">
|
||||
<table aria-label={label} className="performance-table">
|
||||
<thead><tr><th scope="col">Name</th><th scope="col">Responses</th><th scope="col">Rate</th><th scope="col">Sample</th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={rowLabel(row)}>
|
||||
<th scope="row">
|
||||
{rowLabel(row)}
|
||||
{'configured' in row && row.configured ? <span className="configured-label">Configured</span> : null}
|
||||
</th>
|
||||
<td>{row.totalResponses}</td>
|
||||
<td>{row.responseRate}%</td>
|
||||
<td><SampleSize totalSent={row.totalSent} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Trend({ rows }: { rows: readonly DailyTrend[] }) {
|
||||
return (
|
||||
<ol aria-label="30 day response trend" className="trend-list">
|
||||
{rows.map((row) => (
|
||||
<li key={row.date}>
|
||||
<time dateTime={row.date}>{row.date}</time>
|
||||
<span className="trend-track" aria-hidden="true"><span style={{ width: `${Math.max(2, Math.min(100, row.responseRate))}%` }} /></span>
|
||||
<strong>{row.responseRate}%</strong>
|
||||
<SampleSize totalSent={row.totalSent} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessfulExamples({ examples }: { examples: readonly SuccessfulExample[] }) {
|
||||
if (examples.length === 0) return <p className="feature-empty">No successful examples have been recorded yet.</p>;
|
||||
return (
|
||||
<div className="example-grid">
|
||||
{examples.map((example) => {
|
||||
const source = example.subreddit ?? 'unknown source';
|
||||
return (
|
||||
<article aria-label={`Successful example from ${source}`} className="successful-example" key={example.id}>
|
||||
<div><span className="pill success">Response received</span><small>{source}</small></div>
|
||||
{example.postTitle ? <h3>{example.postTitle}</h3> : null}
|
||||
<blockquote>{example.originalMessage}</blockquote>
|
||||
{example.responseSnippet ? <p><strong>Reply:</strong> {example.responseSnippet}</p> : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PerformancePage() {
|
||||
const subreddits = useSubredditPerformanceQuery();
|
||||
const strategies = useStrategyPerformanceQuery();
|
||||
const trend = useDailyTrendQuery(30);
|
||||
const examples = useSuccessfulExamplesQuery();
|
||||
const queries = [subreddits, strategies, trend, examples];
|
||||
if (queries.some((query) => query.isPending)) {
|
||||
return <div className="page-state" role="status">Loading performance…</div>;
|
||||
}
|
||||
const failed = queries.find((query) => query.isError);
|
||||
if (failed?.error) {
|
||||
return <div className="page-state error-state" role="alert"><strong>Performance unavailable</strong><span>{failed.error.message}</span></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page performance-page">
|
||||
<header className="page-header"><div><span className="eyebrow">Decision support</span><h1>Performance</h1><p>Compare response outcomes without hiding the sample sizes behind the percentages.</p></div></header>
|
||||
<section aria-labelledby="trend-heading" className="panel feature-panel"><div className="panel-heading"><div><span className="eyebrow">Last 30 days</span><h2 id="trend-heading">Response trend</h2></div></div><Trend rows={trend.data ?? []} /></section>
|
||||
<div className="performance-grid">
|
||||
<section aria-labelledby="subreddit-heading" className="panel feature-panel"><div className="panel-heading"><h2 id="subreddit-heading">By subreddit</h2></div><PerformanceTable label="Subreddit performance" rows={subreddits.data ?? []} rowLabel={(row) => 'subreddit' in row ? row.subreddit : row.strategyId} /></section>
|
||||
<section aria-labelledby="strategy-heading" className="panel feature-panel"><div className="panel-heading"><h2 id="strategy-heading">By strategy</h2></div><PerformanceTable label="Strategy performance" rows={strategies.data ?? []} rowLabel={(row) => 'strategyId' in row ? row.strategyId : row.subreddit} /></section>
|
||||
</div>
|
||||
<section aria-labelledby="examples-heading" className="panel feature-panel"><div className="panel-heading"><div><span className="eyebrow">What worked</span><h2 id="examples-heading">Successful examples</h2></div></div><SuccessfulExamples examples={examples.data ?? []} /></section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { performanceApi } from './performance-api.js';
|
||||
|
||||
export const performanceQueryKeys = {
|
||||
subreddits: ['performance', 'subreddits'] as const,
|
||||
strategies: ['performance', 'strategies'] as const,
|
||||
trend: (days: number) => ['performance', 'trend', days] as const,
|
||||
successfulExamples: ['performance', 'successful-examples'] as const,
|
||||
};
|
||||
|
||||
export function useSubredditPerformanceQuery() {
|
||||
return useQuery({
|
||||
queryKey: performanceQueryKeys.subreddits,
|
||||
queryFn: performanceApi.getSubredditPerformance,
|
||||
});
|
||||
}
|
||||
|
||||
export function useStrategyPerformanceQuery() {
|
||||
return useQuery({
|
||||
queryKey: performanceQueryKeys.strategies,
|
||||
queryFn: performanceApi.getStrategyPerformance,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDailyTrendQuery(days: number) {
|
||||
return useQuery({
|
||||
queryKey: performanceQueryKeys.trend(days),
|
||||
queryFn: () => performanceApi.getDailyTrend(days),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSuccessfulExamplesQuery() {
|
||||
return useQuery({
|
||||
queryKey: performanceQueryKeys.successfulExamples,
|
||||
queryFn: performanceApi.getSuccessfulExamples,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.performance-page { display: grid; gap: .8rem; }
|
||||
.feature-panel { min-width: 0; }
|
||||
.performance-grid { display: grid; gap: .8rem; }
|
||||
.table-scroll { overflow-x: auto; }
|
||||
.performance-table { width: 100%; border-collapse: collapse; text-align: left; }
|
||||
.performance-table th, .performance-table td { padding: .7rem .5rem; border-top: 1px solid var(--border); }
|
||||
.performance-table thead th { border-top: 0; color: var(--muted); font-size: .75rem; text-transform: uppercase; }
|
||||
.performance-table tbody th { min-width: 9rem; }
|
||||
.configured-label, .confidence-warning { display: block; margin-top: .2rem; color: var(--accent); font-size: .68rem; font-weight: 750; }
|
||||
.confidence-warning { color: var(--warning); }
|
||||
.sample-size { white-space: nowrap; color: var(--muted); }
|
||||
.trend-list { display: grid; gap: .65rem; padding: 0; margin: 0; list-style: none; }
|
||||
.trend-list li { display: grid; grid-template-columns: 6.5rem minmax(5rem, 1fr) 3rem 6rem; align-items: center; gap: .75rem; }
|
||||
.trend-track { height: .5rem; overflow: hidden; background: var(--border); border-radius: 999px; }
|
||||
.trend-track span { display: block; height: 100%; background: var(--accent); border-radius: inherit; }
|
||||
.example-grid { display: grid; gap: .75rem; }
|
||||
.successful-example { padding: 1rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem; }
|
||||
.successful-example > div { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
|
||||
.successful-example small { color: var(--muted); }
|
||||
.successful-example h3 { margin-bottom: .5rem; }
|
||||
.successful-example blockquote { margin: 1rem 0; padding-left: 1rem; border-left: 2px solid var(--accent); }
|
||||
.successful-example p { margin-bottom: 0; color: var(--muted); }
|
||||
.feature-empty { color: var(--muted); }
|
||||
@media (min-width: 64rem) { .performance-grid, .example-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
Reference in new issue
Block a user