// 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();
});
})();