diff --git a/apps/dashboard/src/features/operations/live-console-model.ts b/apps/dashboard/src/features/operations/live-console-model.ts new file mode 100644 index 0000000..cc5a1ff --- /dev/null +++ b/apps/dashboard/src/features/operations/live-console-model.ts @@ -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); + }); +} diff --git a/apps/dashboard/src/features/operations/operations-api.ts b/apps/dashboard/src/features/operations/operations-api.ts new file mode 100644 index 0000000..6682eff --- /dev/null +++ b/apps/dashboard/src/features/operations/operations-api.ts @@ -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; + +async function parseResponse( + response: Response, + schema: { parse(value: unknown): T }, +): Promise { + 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 { + 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(); diff --git a/apps/dashboard/src/features/operations/operations-page.tsx b/apps/dashboard/src/features/operations/operations-page.tsx new file mode 100644 index 0000000..6ab3005 --- /dev/null +++ b/apps/dashboard/src/features/operations/operations-page.tsx @@ -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 ? ( +

No recent operator activity.

+ ) : ( +
    + {items.map((item) => ( +
  1. +
  2. + ))} +
+ ); +} + +function SyncSummary({ status }: { status: ChatSyncStatus }) { + const progress = status.progress; + return ( +
+
{status.syncing ? `${status.syncingKind ?? 'Unknown'} sync running` : 'Idle'}{status.cachedChats} cached conversations
+ {progress ? ( +
+
{progress.label ?? progress.phase}{progress.current} of {progress.total}
+ +
+ ) : null} +
+
Last delta sync
{status.lastDeltaSyncAt ? formatTimestamp(status.lastDeltaSyncAt) : 'Never'}
+
Last full sync
{status.lastFullSyncAt ? formatTimestamp(status.lastFullSyncAt) : 'Never'}
+
+
+ ); +} + +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({ + status: 'idle', + isStale: true, + }); + const consoleEnd = useRef(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 ( +
+
Observability

Operations

Human-readable activity, background work, and raw diagnostic output in distinct views.

+
+ {connectionLabel(connection)} + {connection.isStale ? State may be stale : Events are updating live} +
+
+
+
Readable history

Activity

+ {activityQuery.isPending ?

Loading activity…

: activityQuery.isError ?

{activityQuery.error.message}

: } +
+
+
Background work

Sync jobs

+ {syncQuery.isPending ?

Loading sync state…

: syncQuery.isError ?

{syncQuery.error.message}

: } +
+
+
+
Raw diagnostics

Live console

{consoleState.entries.length} retained
+
+ + + + +
+
+ {logsQuery.isPending ?

Loading logs…

: visibleEntries.length === 0 ?

No logs match the current filters.

: visibleEntries.map((entry) => ( +
{entry.level.toUpperCase()}{entry.message}
+ ))} +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/features/operations/operations-queries.ts b/apps/dashboard/src/features/operations/operations-queries.ts new file mode 100644 index 0000000..9cb3f5f --- /dev/null +++ b/apps/dashboard/src/features/operations/operations-queries.ts @@ -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 }); +} diff --git a/apps/dashboard/src/features/operations/operations.css b/apps/dashboard/src/features/operations/operations.css new file mode 100644 index 0000000..9bae0fa --- /dev/null +++ b/apps/dashboard/src/features/operations/operations.css @@ -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; } +} diff --git a/apps/dashboard/src/features/operations/realtime-client.ts b/apps/dashboard/src/features/operations/realtime-client.ts new file mode 100644 index 0000000..daf1615 --- /dev/null +++ b/apps/dashboard/src/features/operations/realtime-client.ts @@ -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(); + }, + }; +} diff --git a/apps/dashboard/src/features/performance/performance-api.ts b/apps/dashboard/src/features/performance/performance-api.ts new file mode 100644 index 0000000..cac2b5e --- /dev/null +++ b/apps/dashboard/src/features/performance/performance-api.ts @@ -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; +type StrategyPerformanceResponse = z.infer; +type DailyTrendResponse = z.infer; +type SuccessfulExamplesResponse = z.infer; + +async function parseResponse( + response: Response, + schema: { parse(value: unknown): T }, +): Promise { + 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; + getStrategyPerformance(): Promise; + getDailyTrend(days: number): Promise; + getSuccessfulExamples(): Promise; +} + +export function createPerformanceApi( + fetchImplementation?: typeof fetch, +) { + function request(path: string): Promise { + 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(); diff --git a/apps/dashboard/src/features/performance/performance-page.tsx b/apps/dashboard/src/features/performance/performance-page.tsx new file mode 100644 index 0000000..e8f624c --- /dev/null +++ b/apps/dashboard/src/features/performance/performance-page.tsx @@ -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[number]; +type StrategyPerformance = z.infer[number]; +type DailyTrend = z.infer[number]; + +function SampleSize({ totalSent }: { totalSent: number }) { + const isLowConfidence = totalSent < LOW_CONFIDENCE_SAMPLE_SIZE; + return ( + + n = {totalSent} + {isLowConfidence ? Low confidence : null} + + ); +} + +function PerformanceTable({ + label, + rows, + rowLabel, +}: { + label: string; + rows: readonly (SubredditPerformance | StrategyPerformance)[]; + rowLabel: (row: SubredditPerformance | StrategyPerformance) => string; +}) { + return ( +
+ + + + {rows.map((row) => ( + + + + + + + ))} + +
NameResponsesRateSample
+ {rowLabel(row)} + {'configured' in row && row.configured ? Configured : null} + {row.totalResponses}{row.responseRate}%
+
+ ); +} + +function Trend({ rows }: { rows: readonly DailyTrend[] }) { + return ( +
    + {rows.map((row) => ( +
  1. + +
  2. + ))} +
+ ); +} + +function SuccessfulExamples({ examples }: { examples: readonly SuccessfulExample[] }) { + if (examples.length === 0) return

No successful examples have been recorded yet.

; + return ( +
+ {examples.map((example) => { + const source = example.subreddit ?? 'unknown source'; + return ( +
+
Response received{source}
+ {example.postTitle ?

{example.postTitle}

: null} +
{example.originalMessage}
+ {example.responseSnippet ?

Reply: {example.responseSnippet}

: null} +
+ ); + })} +
+ ); +} + +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
Loading performance…
; + } + const failed = queries.find((query) => query.isError); + if (failed?.error) { + return
Performance unavailable{failed.error.message}
; + } + + return ( +
+
Decision support

Performance

Compare response outcomes without hiding the sample sizes behind the percentages.

+
Last 30 days

Response trend

+
+

By subreddit

'subreddit' in row ? row.subreddit : row.strategyId} />
+

By strategy

'strategyId' in row ? row.strategyId : row.subreddit} />
+
+
What worked

Successful examples

+
+ ); +} diff --git a/apps/dashboard/src/features/performance/performance-queries.ts b/apps/dashboard/src/features/performance/performance-queries.ts new file mode 100644 index 0000000..8860ab9 --- /dev/null +++ b/apps/dashboard/src/features/performance/performance-queries.ts @@ -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, + }); +} diff --git a/apps/dashboard/src/features/performance/performance.css b/apps/dashboard/src/features/performance/performance.css new file mode 100644 index 0000000..f1f892e --- /dev/null +++ b/apps/dashboard/src/features/performance/performance.css @@ -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)); } } diff --git a/tests/dashboard/live-console-model.test.ts b/tests/dashboard/live-console-model.test.ts new file mode 100644 index 0000000..b2630a1 --- /dev/null +++ b/tests/dashboard/live-console-model.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { + createLiveConsoleState, + reduceLiveConsoleState, + selectVisibleConsoleEntries, +} from '../../apps/dashboard/src/features/operations/live-console-model.js'; + +function entry(id: string, level: 'debug' | 'info' | 'warn' | 'error', message: string) { + return { id, occurredAt: '2026-08-08T12:00:00.000Z', level, message }; +} + +describe('live console state', () => { + it('bounds entries, rejects duplicate IDs, and filters by level and search', () => { + let state = createLiveConsoleState(2); + state = reduceLiveConsoleState(state, { type: 'append', entry: entry('1', 'info', 'Sync started') }); + state = reduceLiveConsoleState(state, { type: 'append', entry: entry('2', 'error', 'Matrix failed') }); + state = reduceLiveConsoleState(state, { type: 'append', entry: entry('2', 'error', 'duplicate') }); + state = reduceLiveConsoleState(state, { type: 'append', entry: entry('3', 'warn', 'Sync slow') }); + state = reduceLiveConsoleState(state, { type: 'set-level', level: 'warn' }); + state = reduceLiveConsoleState(state, { type: 'set-search', search: 'sync' }); + + expect(state.entries.map(({ id }) => id)).toEqual(['2', '3']); + expect(selectVisibleConsoleEntries(state).map(({ id }) => id)).toEqual(['3']); + }); + + it('holds bounded pending entries while paused and merges them on resume', () => { + let state = createLiveConsoleState(2); + state = reduceLiveConsoleState(state, { type: 'append', entry: entry('1', 'info', 'one') }); + state = reduceLiveConsoleState(state, { type: 'set-paused', paused: true }); + state = reduceLiveConsoleState(state, { type: 'append', entry: entry('2', 'info', 'two') }); + state = reduceLiveConsoleState(state, { type: 'append', entry: entry('3', 'info', 'three') }); + state = reduceLiveConsoleState(state, { type: 'append', entry: entry('4', 'info', 'four') }); + + expect(state.entries.map(({ id }) => id)).toEqual(['1']); + expect(state.pendingEntries.map(({ id }) => id)).toEqual(['3', '4']); + + state = reduceLiveConsoleState(state, { type: 'set-paused', paused: false }); + expect(state.entries.map(({ id }) => id)).toEqual(['3', '4']); + expect(state.pendingEntries).toEqual([]); + }); +}); diff --git a/tests/dashboard/performance-operations.test.tsx b/tests/dashboard/performance-operations.test.tsx new file mode 100644 index 0000000..49dadd3 --- /dev/null +++ b/tests/dashboard/performance-operations.test.tsx @@ -0,0 +1,143 @@ +// @vitest-environment jsdom + +import '@testing-library/jest-dom/vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { HttpResponse, http } from 'msw'; +import { setupServer } from 'msw/node'; +import type { PropsWithChildren, ReactElement } from 'react'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { OperationsPage } from '../../apps/dashboard/src/features/operations/operations-page.js'; +import { PerformancePage } from '../../apps/dashboard/src/features/performance/performance-page.js'; + +const server = setupServer(); + +function TestProviders({ children }: PropsWithChildren) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return {children}; +} + +function renderPage(element: ReactElement) { + return render(element, { wrapper: TestProviders }); +} + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => { + cleanup(); + server.resetHandlers(); +}); +afterAll(() => server.close()); + +describe('PerformancePage', () => { + it('shows sample sizes, confidence warnings, useful comparisons, and successful examples', async () => { + server.use( + http.get('/api/analytics/by-subreddit', () => + HttpResponse.json([ + { subreddit: 'askuk', totalSent: 40, totalResponses: 12, responseRate: 30 }, + { subreddit: 'tiny', totalSent: 3, totalResponses: 2, responseRate: 67 }, + ]), + ), + http.get('/api/analytics/by-strategy', () => + HttpResponse.json([ + { strategyId: 'friendly', totalSent: 25, totalResponses: 10, responseRate: 40, configured: true }, + { strategyId: 'new-test', totalSent: 2, totalResponses: 1, responseRate: 50 }, + ]), + ), + http.get('/api/analytics/trend', ({ request }) => { + expect(new URL(request.url).searchParams.get('days')).toBe('30'); + return HttpResponse.json([ + { date: '2026-08-07', totalSent: 10, totalResponses: 2, responseRate: 20 }, + { date: '2026-08-08', totalSent: 8, totalResponses: 4, responseRate: 50 }, + ]); + }), + http.get('/api/successful', () => + HttpResponse.json([ + { + id: 7, + sentMessageId: 22, + originalMessage: 'A thoughtful opener', + imageAnalysis: null, + postTitle: 'Weekend plans', + responseSnippet: 'That sounds lovely!', + subreddit: 'askuk', + createdAt: Date.parse('2026-08-08T10:00:00Z'), + }, + ]), + ), + ); + + renderPage(); + + expect(await screen.findByRole('heading', { name: 'Performance' })).toBeInTheDocument(); + const subredditTable = screen.getByRole('table', { name: 'Subreddit performance' }); + expect(within(subredditTable).getByText('n = 40')).toBeInTheDocument(); + expect(within(subredditTable).getByText('n = 3')).toBeInTheDocument(); + expect(within(subredditTable).getByText('Low confidence')).toBeInTheDocument(); + const strategyTable = screen.getByRole('table', { name: 'Strategy performance' }); + expect(within(strategyTable).getByText('n = 25')).toBeInTheDocument(); + expect(within(strategyTable).getByText('Configured')).toBeInTheDocument(); + expect(screen.getByRole('list', { name: '30 day response trend' })).toHaveTextContent('50%'); + expect(screen.getByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('A thoughtful opener'); + expect(screen.getByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('That sounds lovely!'); + }); +}); + +describe('OperationsPage', () => { + it('keeps readable activity, job state, and raw searchable logs separate', async () => { + server.use( + http.get('/api/activity', () => + HttpResponse.json([ + { timestamp: Date.parse('2026-08-08T12:00:00Z'), type: 'response', message: 'Reply from u/alex', recipient: 'alex' }, + ]), + ), + http.get('/api/logs', ({ request }) => { + expect(new URL(request.url).searchParams.get('limit')).toBe('200'); + return HttpResponse.json([ + { timestamp: Date.parse('2026-08-08T12:00:01Z'), level: 'info', message: 'Sync complete' }, + { timestamp: Date.parse('2026-08-08T12:00:02Z'), level: 'error', message: 'Matrix request failed' }, + ]); + }), + http.get('/api/chats/sync', () => + HttpResponse.json({ + syncing: true, + syncingKind: 'delta', + lastFullSyncAt: null, + lastDeltaSyncAt: null, + hasEverFullSynced: false, + cachedChats: 18, + progress: { + kind: 'delta', + phase: 'loading-details', + current: 4, + total: 10, + label: 'Loading room history', + startedAt: Date.parse('2026-08-08T11:59:00Z'), + }, + }), + ), + ); + const user = userEvent.setup(); + renderPage(); + + expect(await screen.findByRole('heading', { name: 'Operations' })).toBeInTheDocument(); + const activityRegion = screen.getByRole('region', { name: 'Activity' }); + expect(await within(activityRegion).findByText('Reply from u/alex')).toBeInTheDocument(); + const jobsRegion = screen.getByRole('region', { name: 'Sync jobs' }); + expect(await within(jobsRegion).findByText('4 of 10')).toBeInTheDocument(); + expect(screen.getByRole('status', { name: 'Realtime connection' })).toHaveTextContent('State may be stale'); + + const consoleRegion = screen.getByRole('region', { name: 'Live console' }); + expect(consoleRegion).toHaveTextContent('Sync complete'); + expect(consoleRegion).toHaveTextContent('Matrix request failed'); + await user.selectOptions(within(consoleRegion).getByLabelText('Log level'), 'error'); + expect(consoleRegion).not.toHaveTextContent('Sync complete'); + expect(consoleRegion).toHaveTextContent('Matrix request failed'); + await user.type(within(consoleRegion).getByLabelText('Search logs'), 'no match'); + expect(consoleRegion).toHaveTextContent('No logs match the current filters.'); + expect(within(consoleRegion).getByRole('button', { name: 'Pause console' })).toHaveAttribute('aria-pressed', 'false'); + expect(within(consoleRegion).getByLabelText('Auto-scroll')).toBeChecked(); + }); +}); diff --git a/tests/dashboard/realtime-client.test.ts b/tests/dashboard/realtime-client.test.ts new file mode 100644 index 0000000..6003141 --- /dev/null +++ b/tests/dashboard/realtime-client.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createDashboardRealtimeClient, + queryKeysForDashboardEvent, + type DashboardEventSource, +} from '../../apps/dashboard/src/features/operations/realtime-client.js'; + +class FakeEventSource implements DashboardEventSource { + readonly listeners = new Map void>>(); + readonly close = vi.fn(); + + addEventListener(type: string, listener: (event: unknown) => void): void { + const listeners = this.listeners.get(type) ?? []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + emit(type: string, event: unknown = {}): void { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } +} + +describe('dashboard realtime client', () => { + it('tracks connect/reconnect state and parses typed events before delivery', () => { + const source = new FakeEventSource(); + const states: string[] = []; + const events = vi.fn(); + const invalid = vi.fn(); + const client = createDashboardRealtimeClient({ + sourceFactory: () => source, + onEvent: events, + onStateChange: (state) => states.push(`${state.status}:${state.isStale}`), + onInvalidEvent: invalid, + }); + + client.start(); + source.emit('open'); + source.emit('log.created', { + data: JSON.stringify({ + id: 'event-1', + occurredAt: '2026-08-08T12:00:00.000Z', + type: 'log.created', + payload: { level: 'info', message: 'Connected' }, + }), + }); + source.emit('log.created', { data: '{bad json' }); + source.emit('error'); + + expect(states).toEqual(['connecting:true', 'connected:false', 'reconnecting:true']); + expect(events).toHaveBeenCalledOnce(); + expect(events).toHaveBeenCalledWith(expect.objectContaining({ id: 'event-1', type: 'log.created' })); + expect(invalid).toHaveBeenCalledOnce(); + expect(client.getSnapshot()).toMatchObject({ status: 'reconnecting', isStale: true, lastEventId: 'event-1' }); + + client.stop(); + expect(source.close).toHaveBeenCalledOnce(); + expect(client.getSnapshot()).toMatchObject({ status: 'closed', isStale: true }); + }); + + it('maps events to the queries whose authoritative snapshots need refresh', () => { + expect( + queryKeysForDashboardEvent({ + id: '1', + occurredAt: '2026-08-08T12:00:00.000Z', + type: 'sync.progress', + payload: { syncId: 'sync-1', kind: 'media', completed: 2, total: 5 }, + }), + ).toEqual([ + ['operations', 'sync-status'], + ['conversations'], + ['media'], + ]); + expect( + queryKeysForDashboardEvent({ + id: '2', + occurredAt: '2026-08-08T12:00:00.000Z', + type: 'rate-limit.changed', + payload: { limited: true }, + }), + ).toEqual([ + ['dashboard', 'stats'], + ['dashboard', 'timers'], + ]); + }); +});