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 = ` +