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', inbound ? formatMetricLines(inbound, true) : ['—']));
rows.push(metricRow('Outbound', 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) {
const lines = [];
lines.push(fmtBitrate(m.bitrateKbps));
lines.push(`loss ${fmtPercent(m.packetLossPct)}`);
lines.push(`fps ${fmtNumber(m.fps)}`);
if (m.resolution) lines.push(m.resolution);
if (!isInbound && m.qualityLimit) lines.push(`limit ${m.qualityLimit}`);
return 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)}%` : '--';
}