From 9d1d4f899ba21ebf488cb0b767b6942ed2d25881 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 18:53:59 -0400 Subject: [PATCH] =?UTF-8?q?feat(soak):=20DashboardServer=20=E2=80=94=20van?= =?UTF-8?q?illa=20http=20+=20ws?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serves one static HTML page, accepts WS connections, broadcasts room_state/log/metric messages to all clients. Replays current state to late-joining clients so refreshing the dashboard during a run shows the right grid. Exposes a reporter() method that returns a DashboardReporter scenarios can call without knowing about sockets. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/soak/dashboard/server.ts | 154 +++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 tests/soak/dashboard/server.ts diff --git a/tests/soak/dashboard/server.ts b/tests/soak/dashboard/server.ts new file mode 100644 index 0000000..ebc1f14 --- /dev/null +++ b/tests/soak/dashboard/server.ts @@ -0,0 +1,154 @@ +/** + * DashboardServer — vanilla http + ws, serves one static HTML page + * and broadcasts room_state/log/metric events to all connected clients. + * + * Scenarios never touch this directly — they call the DashboardReporter + * returned by `server.reporter()`, which forwards over WS. + */ + +import * as http from 'http'; +import * as fs from 'fs'; +import * as path from 'path'; +import { WebSocketServer, WebSocket } from 'ws'; +import type { DashboardReporter, Logger, RoomState } from '../core/types'; + +export type DashboardIncoming = + | { type: 'start_stream'; sessionKey: string } + | { type: 'stop_stream'; sessionKey: string }; + +export type DashboardOutgoing = + | { type: 'room_state'; roomId: string; state: Partial } + | { type: 'log'; level: string; msg: string; meta?: object; timestamp: number } + | { type: 'metric'; name: string; value: number } + | { type: 'frame'; sessionKey: string; jpegBase64: string }; + +export interface DashboardHandlers { + onStartStream?(sessionKey: string): void; + onStopStream?(sessionKey: string): void; + onDisconnect?(): void; +} + +export class DashboardServer { + private httpServer!: http.Server; + private wsServer!: WebSocketServer; + private clients = new Set(); + private metrics: Record = {}; + private roomStates: Record> = {}; + + constructor( + private port: number, + private logger: Logger, + private handlers: DashboardHandlers = {}, + ) {} + + async start(): Promise { + const htmlPath = path.resolve(__dirname, 'index.html'); + const cssPath = path.resolve(__dirname, 'dashboard.css'); + const jsPath = path.resolve(__dirname, 'dashboard.js'); + + this.httpServer = http.createServer((req, res) => { + const url = req.url ?? '/'; + if (url === '/' || url === '/index.html') { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + fs.createReadStream(htmlPath).pipe(res); + } else if (url === '/dashboard.css') { + res.writeHead(200, { 'Content-Type': 'text/css' }); + fs.createReadStream(cssPath).pipe(res); + } else if (url === '/dashboard.js') { + res.writeHead(200, { 'Content-Type': 'application/javascript' }); + fs.createReadStream(jsPath).pipe(res); + } else { + res.writeHead(404); + res.end('not found'); + } + }); + + this.wsServer = new WebSocketServer({ server: this.httpServer }); + this.wsServer.on('connection', (ws) => { + this.clients.add(ws); + this.logger.info('dashboard_client_connected', { count: this.clients.size }); + + // Replay current state to the new client so late joiners see + // everything that has happened since the run started. + for (const [roomId, state] of Object.entries(this.roomStates)) { + ws.send( + JSON.stringify({ type: 'room_state', roomId, state } as DashboardOutgoing), + ); + } + for (const [name, value] of Object.entries(this.metrics)) { + ws.send(JSON.stringify({ type: 'metric', name, value } as DashboardOutgoing)); + } + + ws.on('message', (data) => { + try { + const parsed = JSON.parse(data.toString()) as DashboardIncoming; + if (parsed.type === 'start_stream' && this.handlers.onStartStream) { + this.handlers.onStartStream(parsed.sessionKey); + } else if (parsed.type === 'stop_stream' && this.handlers.onStopStream) { + this.handlers.onStopStream(parsed.sessionKey); + } + } catch (err) { + this.logger.warn('dashboard_ws_parse_error', { + error: err instanceof Error ? err.message : String(err), + }); + } + }); + + ws.on('close', () => { + this.clients.delete(ws); + this.logger.info('dashboard_client_disconnected', { count: this.clients.size }); + if (this.clients.size === 0 && this.handlers.onDisconnect) { + this.handlers.onDisconnect(); + } + }); + }); + + await new Promise((resolve) => { + this.httpServer.listen(this.port, () => resolve()); + }); + this.logger.info('dashboard_listening', { url: `http://localhost:${this.port}` }); + } + + async stop(): Promise { + for (const ws of this.clients) { + try { + ws.close(); + } catch { + // ignore + } + } + this.clients.clear(); + await new Promise((resolve) => { + this.wsServer.close(() => resolve()); + }); + await new Promise((resolve) => { + this.httpServer.close(() => resolve()); + }); + } + + broadcast(msg: DashboardOutgoing): void { + const payload = JSON.stringify(msg); + for (const ws of this.clients) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(payload); + } + } + } + + /** Create a DashboardReporter wired to this server. */ + reporter(): DashboardReporter { + return { + update: (roomId, state) => { + this.roomStates[roomId] = { ...this.roomStates[roomId], ...state }; + this.broadcast({ type: 'room_state', roomId, state }); + }, + log: (level, msg, meta) => { + this.broadcast({ type: 'log', level, msg, meta, timestamp: Date.now() }); + }, + incrementMetric: (name, by = 1) => { + this.metrics[name] = (this.metrics[name] ?? 0) + by; + this.broadcast({ type: 'metric', name, value: this.metrics[name] }); + }, + }; + } +}