202 lines
6.6 KiB
JavaScript
202 lines
6.6 KiB
JavaScript
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 = "<div style='text-align:center; color:#555; margin-top:50px;'>No Active Streams</div>";
|
|
}
|
|
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 = `
|
|
<div class="node-row">
|
|
<div class="arrow">➔</div>
|
|
<div class="node-card">
|
|
<div class="node">
|
|
<div class="node-role"></div>
|
|
<div class="node-id"></div>
|
|
<div class="node-score"></div>
|
|
</div>
|
|
<div class="node-metrics"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 `<div class="metrics-grid">${rows.join('')}</div>`;
|
|
}
|
|
|
|
function metricRow(label, valueLines) {
|
|
const valueHtml = valueLines.map((v) => `<span>${v}</span>`).join('');
|
|
return `<div class="metric-row"><span class="metric-label">${label}</span><span class="metric-value">${valueHtml}</span></div>`;
|
|
}
|
|
|
|
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`;
|
|
}
|