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) <noreply@anthropic.com>
155 lines
5.2 KiB
TypeScript
155 lines
5.2 KiB
TypeScript
/**
|
|
* 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<RoomState> }
|
|
| { 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<WebSocket>();
|
|
private metrics: Record<string, number> = {};
|
|
private roomStates: Record<string, Partial<RoomState>> = {};
|
|
|
|
constructor(
|
|
private port: number,
|
|
private logger: Logger,
|
|
private handlers: DashboardHandlers = {},
|
|
) {}
|
|
|
|
async start(): Promise<void> {
|
|
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<void>((resolve) => {
|
|
this.httpServer.listen(this.port, () => resolve());
|
|
});
|
|
this.logger.info('dashboard_listening', { url: `http://localhost:${this.port}` });
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
for (const ws of this.clients) {
|
|
try {
|
|
ws.close();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
this.clients.clear();
|
|
await new Promise<void>((resolve) => {
|
|
this.wsServer.close(() => resolve());
|
|
});
|
|
await new Promise<void>((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] });
|
|
},
|
|
};
|
|
}
|
|
}
|