const socket = io(); const dashboard = document.getElementById('dashboard'); // Identify as a monitor socket.emit('join_monitor'); socket.on('monitor_update', ({ streams, scores }) => { updateDashboard(streams, scores); }); function updateDashboard(streams, scores) { 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); }); } function updateChainVisual(visualContainer, chain, scores) { 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); let nodeWrapper = nodeMap[socketId]; // --- CREATE --- if (!nodeWrapper) { nodeWrapper = document.createElement('div'); nodeWrapper.className = 'node-wrapper'; nodeWrapper.setAttribute('data-id', socketId); nodeWrapper.style.display = 'flex'; nodeWrapper.style.alignItems = 'center'; 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; }); // --- REMOVE --- existingNodes.forEach(el => { const id = el.getAttribute('data-id'); if (!processedIds.has(id)) { el.remove(); } }); }