// tests/soak/dashboard/dashboard.js // Dashboard client: connects to the runner's WS server, renders the // room grid, updates tiles on each room_state message, appends logs, // handles click-to-watch for live screencasts (Task 23 wires frames). (() => { const ws = new WebSocket(`ws://${location.host}`); const roomsEl = document.getElementById('rooms'); const logEl = document.getElementById('log-list'); const wsStatusEl = document.getElementById('ws-status'); const metricGames = document.getElementById('metric-games'); const metricMoves = document.getElementById('metric-moves'); const metricErrors = document.getElementById('metric-errors'); const elapsedEl = document.getElementById('elapsed'); const roomTiles = new Map(); const startTime = Date.now(); let currentWatchedKey = null; // Video modal const videoModal = document.getElementById('video-modal'); const videoFrame = document.getElementById('video-frame'); const videoTitle = document.getElementById('video-modal-title'); const videoClose = document.getElementById('video-modal-close'); function fmtElapsed(ms) { const s = Math.floor(ms / 1000); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`; } setInterval(() => { elapsedEl.textContent = fmtElapsed(Date.now() - startTime); }, 1000); function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }[c])); } function ensureRoomTile(roomId) { if (roomTiles.has(roomId)) return roomTiles.get(roomId); const tile = document.createElement('div'); tile.className = 'room'; tile.innerHTML = `
${escapeHtml(roomId)}
waiting
0 moves game —
`; roomsEl.appendChild(tile); roomTiles.set(roomId, tile); return tile; } function renderRoomState(roomId, state) { const tile = ensureRoomTile(roomId); if (state.phase !== undefined) { const phaseEl = tile.querySelector('.phase'); phaseEl.textContent = state.phase; phaseEl.classList.toggle('lobby', state.phase === 'lobby' || state.phase === 'waiting'); phaseEl.classList.toggle('err', state.phase === 'error'); } if (state.players !== undefined) { const playersEl = tile.querySelector('.players'); playersEl.innerHTML = state.players .map( (p) => `
${p.isActive ? '▶ ' : ''}${escapeHtml(p.key)} ${p.score ?? '—'}
`, ) .join(''); } if (state.hole !== undefined && state.totalHoles !== undefined) { const fill = tile.querySelector('.progress-fill'); const pct = state.totalHoles > 0 ? Math.round((state.hole / state.totalHoles) * 100) : 0; fill.style.width = `${pct}%`; } if (state.moves !== undefined) { tile.querySelector('.moves').textContent = `${state.moves} moves`; } if (state.game !== undefined && state.totalGames !== undefined) { tile.querySelector('.game').textContent = `game ${state.game}/${state.totalGames}`; } } function appendLog(level, msg, meta) { const li = document.createElement('li'); li.className = level; const ts = new Date().toLocaleTimeString(); li.textContent = `[${ts}] ${msg} ${meta ? JSON.stringify(meta) : ''}`; logEl.insertBefore(li, logEl.firstChild); while (logEl.children.length > 100) { logEl.removeChild(logEl.lastChild); } } function applyMetric(name, value) { if (name === 'games_completed') metricGames.textContent = value; else if (name === 'moves_total') metricMoves.textContent = value; else if (name === 'errors') metricErrors.textContent = value; } ws.addEventListener('open', () => { wsStatusEl.textContent = 'healthy'; wsStatusEl.style.color = 'var(--good)'; }); ws.addEventListener('close', () => { wsStatusEl.textContent = 'disconnected'; wsStatusEl.style.color = 'var(--err)'; }); ws.addEventListener('message', (event) => { let msg; try { msg = JSON.parse(event.data); } catch { return; } if (msg.type === 'room_state') { renderRoomState(msg.roomId, msg.state); } else if (msg.type === 'log') { appendLog(msg.level, msg.msg, msg.meta); } else if (msg.type === 'metric') { applyMetric(msg.name, msg.value); } else if (msg.type === 'frame') { if (msg.sessionKey === currentWatchedKey) { videoFrame.src = `data:image/jpeg;base64,${msg.jpegBase64}`; } } }); // Click-to-watch (screencasts start arriving after Task 22/23) roomsEl.addEventListener('click', (e) => { const playerEl = e.target.closest('.player'); if (!playerEl) return; const key = playerEl.dataset.session; if (!key) return; currentWatchedKey = key; videoTitle.textContent = `Watching ${key}`; videoModal.classList.remove('hidden'); ws.send(JSON.stringify({ type: 'start_stream', sessionKey: key })); }); function closeVideo() { if (currentWatchedKey) { ws.send(JSON.stringify({ type: 'stop_stream', sessionKey: currentWatchedKey })); } currentWatchedKey = null; videoModal.classList.add('hidden'); videoFrame.src = ''; } videoClose.addEventListener('click', closeVideo); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeVideo(); }); })();