diff --git a/tests/soak/dashboard/dashboard.css b/tests/soak/dashboard/dashboard.css new file mode 100644 index 0000000..8914eb8 --- /dev/null +++ b/tests/soak/dashboard/dashboard.css @@ -0,0 +1,173 @@ +:root { + --bg: #0a0e16; + --panel: #0e1420; + --border: #1a2230; + --text: #c8d4e4; + --accent: #7fbaff; + --good: #6fd08f; + --warn: #ffb84d; + --err: #ff5c6c; + --muted: #556577; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: -apple-system, system-ui, 'SF Mono', Consolas, monospace; + background: var(--bg); + color: var(--text); +} + +.dash-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 20px; + background: linear-gradient(135deg, #0f1823, #0a1018); + border-bottom: 1px solid var(--border); +} +.dash-header h1 { margin: 0; font-size: 16px; color: var(--accent); } +.dash-header .meta { font-size: 11px; color: var(--muted); } +.dash-header .meta span + span { margin-left: 12px; } + +.meta-bar { + display: flex; + gap: 24px; + padding: 10px 20px; + background: #0c131d; + border-bottom: 1px solid var(--border); + font-size: 12px; +} +.meta-bar .stat .label { color: var(--muted); margin-right: 6px; } +.meta-bar .stat span:last-child { color: #fff; font-weight: 600; } + +.rooms { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; + background: var(--border); +} +.room { + background: var(--panel); + padding: 14px 18px; + min-height: 180px; +} +.room-title { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} +.room-title .name { font-size: 13px; color: var(--accent); font-weight: 600; } +.room-title .phase { + font-size: 10px; + padding: 2px 8px; + border-radius: 10px; + background: #1a3a2a; + color: var(--good); +} +.room-title .phase.lobby { background: #3a2a1a; color: var(--warn); } +.room-title .phase.err { background: #3a1a1a; color: var(--err); } + +.players { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 4px; + font-size: 11px; + margin-bottom: 8px; +} +.player { + display: flex; + justify-content: space-between; + padding: 4px 8px; + background: #0a0f18; + border-radius: 3px; + cursor: pointer; + border: 1px solid transparent; +} +.player:hover { border-color: var(--accent); } +.player.active { + background: #1a2a40; + border-left: 2px solid var(--accent); +} +.player .score { color: var(--muted); } + +.progress-bar { + height: 4px; + background: var(--border); + border-radius: 2px; + overflow: hidden; + margin-top: 6px; +} +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent), var(--good)); + transition: width 0.3s; +} +.room-meta { + font-size: 10px; + color: var(--muted); + display: flex; + gap: 12px; + margin-top: 6px; +} + +.log { + border-top: 1px solid var(--border); + background: #080c13; + max-height: 160px; + overflow-y: auto; +} +.log .log-header { + padding: 6px 20px; + font-size: 10px; + text-transform: uppercase; + color: var(--muted); + border-bottom: 1px solid var(--border); +} +.log ul { list-style: none; margin: 0; padding: 4px 20px; font-size: 10px; } +.log li { line-height: 1.5; font-family: monospace; color: var(--muted); } +.log li.warn { color: var(--warn); } +.log li.error { color: var(--err); } + +.video-modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} +.video-modal.hidden { display: none; } +.video-modal-content { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 6px; + padding: 16px; + max-width: 90vw; + max-height: 90vh; +} +.video-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + color: var(--accent); + font-size: 13px; +} +.video-modal-header button { + background: var(--border); + color: var(--text); + border: none; + padding: 4px 12px; + border-radius: 3px; + cursor: pointer; +} +#video-frame { + display: block; + max-width: 100%; + max-height: 70vh; + border: 1px solid var(--border); +} diff --git a/tests/soak/dashboard/dashboard.js b/tests/soak/dashboard/dashboard.js new file mode 100644 index 0000000..44000bc --- /dev/null +++ b/tests/soak/dashboard/dashboard.js @@ -0,0 +1,166 @@ +// 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(); + }); +})(); diff --git a/tests/soak/dashboard/index.html b/tests/soak/dashboard/index.html new file mode 100644 index 0000000..7a64b8a --- /dev/null +++ b/tests/soak/dashboard/index.html @@ -0,0 +1,47 @@ + + + + + +Golf Soak Dashboard + + + +
+

⛳ Golf Soak Dashboard

+
+ run — + 00:00:00 +
+
+ +
+
Games0
+
Moves0
+
Errors0
+
WSconnecting
+
+ +
+ +
+ +
+
Activity Log
+ +
+ + + + + + +