const socket = io(); const dashboard = document.getElementById('dashboard'); // Identify as a monitor socket.emit('join_monitor'); socket.on('monitor_update', ({ streams, scores, metrics }) => { updateDashboard(streams, scores, metrics || {}); }); function updateDashboard(streams, scores, metrics) { const activeStreamNames = Object.keys(streams); // 1. Remove streams that no longer exist const existingContainers = document.querySelectorAll('.stream-container'); existingContainers.forEach((container) => { const name = container.getAttribute('data-stream'); if (!streams[name]) { container.remove(); } }); // 2. Add or Update streams if (activeStreamNames.length === 0) { // If empty, show message (only if not already showing it) if (!dashboard.innerText.includes('No Active Streams')) { dashboard.innerHTML = "
No Active Streams
"; } return; } else { // FIX: Check if we are still showing a text message (Connecting, No Active Streams, etc) // If we have streams but no stream containers yet, clear the text. if (!dashboard.querySelector('.stream-container')) { dashboard.innerHTML = ''; } } activeStreamNames.forEach((name) => { let container = document.getElementById(`stream-${name}`); const chain = streams[name]; // Create Container if it doesn't exist if (!container) { container = document.createElement('div'); container.id = `stream-${name}`; container.className = 'stream-container'; container.setAttribute('data-stream', name); // Structure const title = document.createElement('div'); title.className = 'stream-title'; const wrapper = document.createElement('div'); wrapper.className = 'chain-wrapper'; const visual = document.createElement('div'); visual.className = 'chain-visual'; wrapper.appendChild(visual); container.appendChild(title); container.appendChild(wrapper); dashboard.appendChild(container); } // Update Title const titleEl = container.querySelector('.stream-title'); titleEl.innerText = `Stream: ${name} (${chain.length} nodes)`; // Update Nodes updateChainVisual(container.querySelector('.chain-visual'), chain, scores, metrics); }); } function updateChainVisual(visualContainer, chain, scores, metrics) { const existingNodes = Array.from(visualContainer.querySelectorAll('.node-wrapper')); const nodeMap = {}; existingNodes.forEach((el) => (nodeMap[el.getAttribute('data-id')] = el)); const processedIds = new Set(); chain.forEach((socketId, index) => { processedIds.add(socketId); const score = scores[socketId] !== undefined ? scores[socketId] : '??'; let healthClass = 'meh'; if (score >= 80) healthClass = 'healthy'; if (score < 50) healthClass = 'weak'; if (index === 0) healthClass = 'healthy'; const role = index === 0 ? 'SOURCE' : index === chain.length - 1 ? 'VIEWER' : 'RELAY'; const shortId = socketId.substring(0, 4); const metric = metrics[socketId] || {}; let nodeWrapper = nodeMap[socketId]; // --- CREATE --- if (!nodeWrapper) { nodeWrapper = document.createElement('div'); nodeWrapper.className = 'node-wrapper'; nodeWrapper.setAttribute('data-id', socketId); nodeWrapper.innerHTML = `
`; visualContainer.appendChild(nodeWrapper); } // --- UPDATE --- // 1. Order (Preserve Scroll) if (visualContainer.children[index] !== nodeWrapper) { visualContainer.insertBefore(nodeWrapper, visualContainer.children[index]); } // 2. Arrow Visibility const arrow = nodeWrapper.querySelector('.arrow'); arrow.style.opacity = index === 0 ? '0' : '1'; // 3. Data const nodeEl = nodeWrapper.querySelector('.node'); nodeEl.className = `node ${healthClass}`; nodeWrapper.querySelector('.node-role').innerText = role; nodeWrapper.querySelector('.node-id').innerText = shortId; nodeWrapper.querySelector('.node-score').innerText = score; const metricsBox = nodeWrapper.querySelector('.node-metrics'); metricsBox.innerHTML = renderMetricsLines(metric.inbound, metric.outbound); }); // --- REMOVE --- existingNodes.forEach((el) => { const id = el.getAttribute('data-id'); if (!processedIds.has(id)) { el.remove(); } }); } function renderMetricsLines(inbound, outbound) { const rows = []; rows.push(metricRow('Inbound', formatMetricLines(inbound, true))); rows.push(metricRow('Outbound', formatMetricLines(outbound, false))); return `
${rows.join('')}
`; } function metricRow(label, valueLines) { const valueHtml = valueLines.map((v) => `${v}`).join(''); return `
${label}${valueHtml}
`; } function formatMetricLines(m, isInbound) { if (!m) return ['—']; const lines = []; if (m.state) lines.push(m.state); if (Number.isFinite(m.bitrateKbps)) lines.push(fmtBitrate(m.bitrateKbps)); if (Number.isFinite(m.packetLossPct)) lines.push(`loss ${fmtPercent(m.packetLossPct)}`); if (Number.isFinite(m.fps)) lines.push(`fps ${fmtNumber(m.fps)}`); if (m.resolution) lines.push(m.resolution); if (Number.isFinite(m.frames)) lines.push(`frames ${fmtCount(m.frames)}`); if (Number.isFinite(m.bytes)) lines.push(`bytes ${fmtBytes(m.bytes)}`); if (!isInbound && m.qualityLimit) lines.push(`limit ${m.qualityLimit}`); return lines.length ? lines : ['—']; } function fmtNumber(v) { return Number.isFinite(v) ? v.toFixed(1) : '--'; } function fmtBitrate(kbps) { return Number.isFinite(kbps) ? `${kbps.toFixed(0)} kbps` : '--'; } function fmtPercent(v) { return Number.isFinite(v) ? `${v.toFixed(1)}%` : '--'; } function fmtCount(v) { if (!Number.isFinite(v)) return '--'; if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(2)}M`; else if (v >= 1000) return `${(v / 1000).toFixed(1)}k`; return `${v}`; } function fmtBytes(v) { if (!Number.isFinite(v)) return '--'; if (v >= 1e9) return `${(v / 1e9).toFixed(2)} GB`; if (v >= 1e6) return `${(v / 1e6).toFixed(2)} MB`; if (v >= 1e3) return `${(v / 1e3).toFixed(1)} kB`; return `${v} B`; }