Files
tendrils/static/index.html
Ian Gulliver b7dd6b220c Improve network table and add multi-column sorting
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 12:21:11 -08:00

2799 lines
104 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tendrils Network</title>
<style>
* { box-sizing: border-box; }
body {
font-family: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
margin: 0;
padding: 10px;
background: #111;
color: #eee;
}
#error { color: #f66; padding: 20px; }
#connection-status {
position: fixed;
top: 10px;
left: 10px;
z-index: 1000;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: #222;
border-radius: 6px;
border: 1px solid #444;
font-size: 11px;
}
#connection-status .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #666;
}
#connection-status.connected .dot {
background: #4f4;
}
#connection-status.disconnected .dot {
background: #f44;
animation: pulse-dot 1s infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
#container {
display: flex;
flex-direction: column;
gap: 20px;
overflow: visible;
}
.location {
background: #222;
border: 1px solid #444;
border-radius: 8px;
padding: 10px;
overflow: visible;
}
.location.top-level {
width: 100%;
}
.location-name {
font-weight: bold;
font-size: 14px;
margin-bottom: 10px;
text-align: center;
}
.location.anonymous {
background: transparent;
border: none;
padding: 0;
}
.location.anonymous > .location-name {
display: none;
}
.node-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
overflow: visible;
}
.node-row + .node-row {
margin-top: 8px;
}
.node {
position: relative;
width: 120px;
min-height: 50px;
background: #a6d;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: 11px;
padding: 8px 4px 4px 4px;
cursor: pointer;
overflow: visible;
word-break: normal;
overflow-wrap: break-word;
white-space: pre-line;
margin-top: 8px;
z-index: 1;
}
.node .switch-port {
font-size: 10px;
font-weight: normal;
background: #444;
color: #fff;
padding: 1px 6px;
border-radius: 8px;
white-space: nowrap;
max-width: 114px;
overflow: hidden;
text-overflow: ellipsis;
}
.node .switch-port.external {
border: 1px dashed #c9f;
}
.node .port-hover,
.node .uplink-hover {
position: absolute;
top: -8px;
left: 50%;
transform: translateX(-50%);
}
.node .link-stats-wrapper {
display: none;
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding-bottom: 8px;
}
.node .link-stats {
font-size: 10px;
font-weight: normal;
white-space: pre;
text-align: left;
background: #333;
border: 1px solid #555;
border-radius: 6px;
padding: 6px 8px;
line-height: 1.4;
}
.link-stats .lbl,
.node-info .lbl,
.dante-info .lbl,
.dante-detail .lbl,
.artnet-info .lbl,
.artnet-detail .lbl {
color: #888;
}
.node .port-hover::after,
.node .uplink-hover::after {
content: '';
position: absolute;
top: 0;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
width: 120px;
}
.node .port-hover:hover .link-stats-wrapper,
.node .uplink-hover:hover .link-stats-wrapper {
display: block;
will-change: transform;
}
.node .port-hover:hover,
.node .uplink-hover:hover {
z-index: 100;
}
.node .uplink {
font-size: 10px;
font-weight: normal;
background: #444;
color: #fff;
padding: 1px 6px;
border-radius: 8px;
white-space: nowrap;
max-width: 114px;
overflow: hidden;
text-overflow: ellipsis;
}
.node .root-label {
position: absolute;
top: -8px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
font-weight: normal;
background: #753;
color: #fff;
padding: 1px 6px;
border-radius: 8px;
white-space: nowrap;
}
.node .switch-port.speed-10g,
.node .uplink.speed-10g {
background: #357;
}
.node .switch-port.speed-1g,
.node .uplink.speed-1g {
background: #375;
}
.node .switch-port.speed-100m,
.node .uplink.speed-100m {
background: #753;
}
.node .switch-port.speed-slow,
.node .uplink.speed-slow {
background: #733;
}
.node:hover {
filter: brightness(1.2);
}
.node.switch {
background: #2a2;
font-weight: bold;
}
.node.copied {
outline: 2px solid #fff;
}
.children {
display: flex;
gap: 15px;
margin-top: 10px;
overflow: visible;
}
.children.horizontal {
flex-direction: row;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-evenly;
width: 100%;
}
.children.vertical {
flex-direction: column;
align-items: center;
}
#top-bar {
position: fixed;
top: 10px;
right: 10px;
z-index: 1000;
display: flex;
gap: 10px;
}
.selector-group {
display: flex;
gap: 0;
background: #333;
border-radius: 6px;
overflow: hidden;
border: 1px solid #555;
}
.selector-group button {
padding: 8px 16px;
border: none;
background: #333;
color: #aaa;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
.selector-group button:hover {
background: #444;
}
.selector-group button.active {
background: #555;
color: #fff;
}
#table-container {
display: none;
padding: 10px;
}
body.table-view #container {
display: none;
}
body.table-view #table-container {
display: block;
}
.data-table {
border-collapse: collapse;
background: #222;
border-radius: 8px;
overflow: hidden;
font-size: 11px;
margin: 0 auto;
}
.data-table th, .data-table td {
padding: 6px 20px;
text-align: left;
border-bottom: 1px solid #333;
}
.data-table th {
background: #333;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.data-table th:hover {
background: #444;
}
.data-table th.sorted-asc::after {
content: ' ▲';
font-size: 10px;
}
.data-table th.sorted-desc::after {
content: ' ▼';
font-size: 10px;
}
.data-table tr:hover {
background: #2a2a2a;
}
.data-table td.numeric {
text-align: right;
font-variant-numeric: tabular-nums;
}
.data-table .status-ok {
color: #4f4;
}
.data-table .status-warn {
color: #fa0;
}
.data-table .status-error {
color: #f44;
}
body.dante-mode .node {
opacity: 0.3;
}
body.dante-mode .node.dante-tx {
opacity: 1;
background: #d62;
}
body.dante-mode .node.dante-rx {
opacity: 1;
background: #26d;
}
body.dante-mode .node.dante-tx.dante-rx {
background: linear-gradient(135deg, #d62 50%, #26d 50%);
}
body.dante-mode .node .switch-port,
body.dante-mode .node .uplink,
body.dante-mode .node .root-label {
display: none;
}
.node .dante-info {
display: none;
font-size: 10px;
font-weight: normal;
padding: 1px 6px;
border-radius: 8px;
white-space: nowrap;
color: #fff;
max-width: 114px;
overflow: hidden;
text-overflow: ellipsis;
}
.node .dante-info.tx-info {
background: #853;
}
.node .dante-info.rx-info {
background: #358;
}
.node .dante-hover {
position: absolute;
top: -8px;
left: 50%;
transform: translateX(-50%);
}
.node .dante-detail-wrapper {
display: none;
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding-bottom: 8px;
}
.node .dante-detail {
font-size: 10px;
font-weight: normal;
white-space: pre;
text-align: left;
background: #333;
border: 1px solid #555;
border-radius: 6px;
padding: 6px 8px;
line-height: 1.4;
}
.node .dante-hover::after {
content: '';
position: absolute;
top: 0;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
width: 120px;
}
body.dante-mode .node .dante-hover:hover {
z-index: 100;
}
body.dante-mode .node .dante-hover:hover .dante-detail-wrapper {
display: block;
will-change: transform;
}
body.dante-mode .node.dante-tx .dante-info,
body.dante-mode .node.dante-rx .dante-info {
display: block;
}
body.dante-mode .node.dante-tx.dante-rx .dante-rx-hover {
top: auto;
bottom: -8px;
}
body.dante-mode .node.dante-tx.dante-rx .dante-rx-hover .dante-detail-wrapper {
bottom: auto;
top: 100%;
padding-bottom: 0;
padding-top: 8px;
}
body.artnet-mode .node {
opacity: 0.3;
}
body.artnet-mode .node.artnet-out {
opacity: 1;
background: #287;
}
body.artnet-mode .node.artnet-in {
opacity: 1;
background: #268;
}
body.artnet-mode .node.artnet-out.artnet-in {
background: linear-gradient(135deg, #287 50%, #268 50%);
}
body.artnet-mode .node .switch-port,
body.artnet-mode .node .uplink,
body.artnet-mode .node .root-label {
display: none;
}
.node .artnet-info {
display: none;
font-size: 10px;
font-weight: normal;
padding: 1px 6px;
border-radius: 8px;
white-space: nowrap;
color: #fff;
max-width: 114px;
overflow: hidden;
text-overflow: ellipsis;
}
.node .artnet-info.out-info {
background: #254;
}
.node .artnet-info.in-info {
background: #245;
}
.node .artnet-hover {
position: absolute;
top: -8px;
left: 50%;
transform: translateX(-50%);
}
.node .artnet-detail-wrapper {
display: none;
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding-bottom: 8px;
}
.node .artnet-detail {
font-size: 10px;
font-weight: normal;
white-space: pre;
text-align: left;
background: #333;
border: 1px solid #555;
border-radius: 6px;
padding: 6px 8px;
line-height: 1.4;
}
.node .artnet-hover::after {
content: '';
position: absolute;
top: 0;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
width: 120px;
}
body.artnet-mode .node .artnet-hover:hover {
z-index: 100;
}
body.artnet-mode .node .artnet-hover:hover .artnet-detail-wrapper {
display: block;
will-change: transform;
}
body.artnet-mode .node.artnet-out .artnet-info,
body.artnet-mode .node.artnet-in .artnet-info {
display: block;
}
body.artnet-mode .node.artnet-out.artnet-in .artnet-in-hover {
top: auto;
bottom: -8px;
}
body.artnet-mode .node.artnet-out.artnet-in .artnet-in-hover .artnet-detail-wrapper {
bottom: auto;
top: 100%;
padding-bottom: 0;
padding-top: 8px;
}
body.sacn-mode .node {
opacity: 0.3;
}
body.sacn-mode .node.sacn-out {
opacity: 1;
background: #287;
}
body.sacn-mode .node.sacn-in {
opacity: 1;
background: #268;
}
body.sacn-mode .node.sacn-out.sacn-in {
background: linear-gradient(135deg, #287 50%, #268 50%);
}
body.sacn-mode .node .switch-port,
body.sacn-mode .node .uplink,
body.sacn-mode .node .root-label {
display: none;
}
.node .sacn-info {
display: none;
font-size: 10px;
font-weight: normal;
padding: 1px 6px;
border-radius: 8px;
white-space: nowrap;
color: #fff;
max-width: 114px;
overflow: hidden;
text-overflow: ellipsis;
}
.node .sacn-info.out-info {
background: #254;
}
.node .sacn-info.in-info {
background: #245;
}
.node .sacn-hover {
position: absolute;
top: -8px;
left: 50%;
transform: translateX(-50%);
}
.node .sacn-detail-wrapper {
display: none;
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding-bottom: 8px;
}
.node .sacn-detail {
font-size: 10px;
font-weight: normal;
white-space: pre;
text-align: left;
background: #333;
border: 1px solid #555;
border-radius: 6px;
padding: 6px 8px;
line-height: 1.4;
}
.node .sacn-hover::after {
content: '';
position: absolute;
top: 0;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
width: 120px;
}
body.sacn-mode .node .sacn-hover:hover {
z-index: 100;
}
body.sacn-mode .node .sacn-hover:hover .sacn-detail-wrapper {
display: block;
will-change: transform;
}
body.sacn-mode .node.sacn-out .sacn-info,
body.sacn-mode .node.sacn-in .sacn-info {
display: block;
}
body.sacn-mode .node.sacn-out.sacn-in .sacn-in-hover {
top: auto;
bottom: -8px;
}
body.sacn-mode .node.sacn-out.sacn-in .sacn-in-hover .sacn-detail-wrapper {
bottom: auto;
top: 100%;
padding-bottom: 0;
padding-top: 8px;
}
.sacn-info .lbl,
.sacn-detail .lbl {
color: #888;
}
.node.has-error {
box-shadow: 0 0 0 3px #f66;
}
.node.unreachable {
box-shadow: 0 0 0 3px #f90;
}
.node.has-error.unreachable {
box-shadow: 0 0 0 3px #f66, 0 0 0 6px #f90;
}
.node:hover {
z-index: 100;
}
.node .node-info-wrapper {
display: none;
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
padding-top: 8px;
z-index: 1000;
}
.node .node-info {
background: #333;
border: 1px solid #555;
border-radius: 6px;
padding: 6px 8px;
font-size: 10px;
font-weight: normal;
white-space: pre;
text-align: left;
line-height: 1.4;
cursor: pointer;
}
.clickable-value, .node-name {
cursor: pointer;
}
.node:hover .node-info-wrapper {
display: block;
will-change: transform;
}
body.dante-mode .node:not(.dante-tx):not(.dante-rx):hover .node-info-wrapper {
display: none;
}
body.artnet-mode .node:not(.artnet-out):not(.artnet-in):hover .node-info-wrapper {
display: none;
}
body.sacn-mode .node:not(.sacn-out):not(.sacn-in):hover .node-info-wrapper {
display: none;
}
.node:has(.port-hover:hover) .node-info-wrapper,
.node:has(.uplink-hover:hover) .node-info-wrapper,
.node:has(.dante-hover:hover) .node-info-wrapper,
.node:has(.artnet-hover:hover) .node-info-wrapper,
.node:has(.sacn-hover:hover) .node-info-wrapper {
display: none;
}
#error-panel {
position: fixed;
top: 50px;
right: 10px;
z-index: 1000;
background: #2a1a1a;
border: 1px solid #f66;
border-radius: 6px;
max-width: 500px;
max-height: 400px;
overflow: hidden;
display: none;
}
#error-panel.has-errors {
display: block;
}
#error-panel.collapsed #error-list {
display: none;
}
#error-header {
padding: 8px 12px;
background: #3a2a2a;
display: flex;
gap: 8px;
align-items: center;
}
#error-count {
flex: 1;
color: #f99;
font-weight: bold;
}
#error-header button {
padding: 4px 8px;
border: none;
background: #444;
color: #ccc;
cursor: pointer;
border-radius: 4px;
font-size: 11px;
}
#error-header button:hover {
background: #555;
}
#error-list {
max-height: 300px;
overflow-y: auto;
padding: 8px;
}
.error-item {
background: #3a2a2a;
border-radius: 4px;
padding: 8px;
margin-bottom: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.error-item .error-node {
color: #faa;
font-weight: bold;
cursor: pointer;
}
.error-item .error-node:hover {
text-decoration: underline;
}
.error-item .error-port {
color: #ccc;
font-size: 11px;
}
.error-item .error-counts {
color: #f66;
font-size: 11px;
}
.error-item .error-type {
font-size: 9px;
color: #888;
}
.error-item button {
align-self: flex-end;
padding: 2px 6px;
border: none;
background: #555;
color: #ccc;
cursor: pointer;
border-radius: 3px;
font-size: 10px;
}
.error-item button:hover {
background: #666;
}
.node.scroll-highlight {
outline: 3px solid white;
}
#broadcast-stats {
position: fixed;
bottom: 10px;
left: 10px;
z-index: 1000;
padding: 8px 12px;
background: #222;
border-radius: 6px;
border: 1px solid #444;
font-size: 11px;
}
#broadcast-stats.warning {
border-color: #f90;
background: #332a1a;
}
#broadcast-stats.critical {
border-color: #f44;
background: #331a1a;
}
#broadcast-stats .label {
color: #888;
margin-right: 4px;
}
#broadcast-stats .value {
color: #eee;
font-weight: bold;
}
#broadcast-stats .rate-row {
display: flex;
gap: 12px;
}
#broadcast-stats .rate-item {
display: flex;
align-items: center;
}
#broadcast-stats .buckets {
display: none;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #444;
}
#broadcast-stats:hover .buckets {
display: block;
}
#broadcast-stats .bucket {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 2px 0;
}
#broadcast-stats .bucket-name {
color: #aaa;
}
#broadcast-stats .bucket-rate {
color: #eee;
}
</style>
</head>
<body>
<div id="connection-status" class="disconnected">
<div class="dot"></div>
<span class="text">Connecting...</span>
</div>
<div id="broadcast-stats">
<div class="rate-row">
<div class="rate-item">
<span class="label">Broadcast:</span>
<span class="value" id="broadcast-pps">0 pps</span>
</div>
<div class="rate-item">
<span class="value" id="broadcast-bps">0 B/s</span>
</div>
</div>
<div class="buckets" id="broadcast-buckets"></div>
</div>
<div id="top-bar">
<div class="selector-group" id="mode-selector">
<button id="mode-network" class="active">Network</button>
<button id="mode-dante">Dante</button>
<button id="mode-artnet">Art-Net</button>
<button id="mode-sacn">sACN</button>
</div>
<div class="selector-group" id="view-selector">
<button id="view-map" class="active">Map</button>
<button id="view-table">Table</button>
</div>
</div>
<div id="error-panel">
<div id="error-header">
<span id="error-count">0 Errors</span>
<button id="clear-all-errors">Clear All</button>
<button id="toggle-errors">Hide</button>
</div>
<div id="error-list"></div>
</div>
<div id="error"></div>
<div id="table-container"></div>
<div id="container"></div>
<script>
function formatBytes(bytes) {
if (bytes < 1024) return bytes.toFixed(0) + ' B/s';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB/s';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB/s';
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB/s';
}
function formatPackets(pps) {
if (pps < 1000) return pps.toFixed(0) + ' pps';
if (pps < 1000000) return (pps / 1000).toFixed(1) + 'K pps';
return (pps / 1000000).toFixed(1) + 'M pps';
}
function updateBroadcastStats(stats) {
const panel = document.getElementById('broadcast-stats');
const ppsEl = document.getElementById('broadcast-pps');
const bpsEl = document.getElementById('broadcast-bps');
const bucketsEl = document.getElementById('broadcast-buckets');
if (!stats) {
ppsEl.textContent = '0 pps';
bpsEl.textContent = '0 B/s';
bucketsEl.innerHTML = '';
panel.className = '';
return;
}
ppsEl.textContent = formatPackets(stats.packets_per_s);
bpsEl.textContent = formatBytes(stats.bytes_per_s);
panel.classList.remove('warning', 'critical');
if (stats.packets_per_s > 1000) {
panel.classList.add('critical');
} else if (stats.packets_per_s > 100) {
panel.classList.add('warning');
}
bucketsEl.innerHTML = '';
if (stats.buckets && stats.buckets.length > 0) {
stats.buckets.filter(b => b.packets_per_s >= 0.5).forEach(bucket => {
const div = document.createElement('div');
div.className = 'bucket';
div.innerHTML = '<span class="bucket-name">' + bucket.name + '</span>' +
'<span class="bucket-rate">' + formatPackets(bucket.packets_per_s) + '</span>';
bucketsEl.appendChild(div);
});
}
}
function getLabel(node) {
if (node.names && node.names.length > 0) return node.names.join('\n');
if (node.interfaces && node.interfaces.length > 0) {
const ips = [];
node.interfaces.forEach(iface => {
if (iface.ips) iface.ips.forEach(ip => ips.push(ip));
});
if (ips.length > 0) return ips.join('\n');
const macs = [];
node.interfaces.forEach(iface => {
if (iface.mac) macs.push(iface.mac);
});
if (macs.length > 0) return macs.join('\n');
}
return '??';
}
function getShortLabel(node) {
if (node.names && node.names.length > 0) return node.names.join('\n');
if (node.interfaces && node.interfaces.length > 0) {
const ips = [];
node.interfaces.forEach(iface => {
if (iface.ips) iface.ips.forEach(ip => ips.push(ip));
});
if (ips.length > 0) return ips.join('\n');
const macs = [];
node.interfaces.forEach(iface => {
if (iface.mac) macs.push(iface.mac);
});
if (macs.length > 0) return macs.join('\n');
}
return '??';
}
function getNodeIdentifiers(node) {
const ids = [];
if (node.names) {
node.names.forEach(n => ids.push(n.toLowerCase()));
}
if (node.interfaces) {
node.interfaces.forEach(iface => {
if (iface.mac) ids.push(iface.mac.toLowerCase());
});
}
return ids;
}
function isSwitch(node) {
return !!(node.poe_budget);
}
function getSpeedClass(speed) {
if (!speed || speed === 0) return '';
if (speed >= 10000000000) return 'speed-10g';
if (speed >= 1000000000) return 'speed-1g';
if (speed >= 100000000) return 'speed-100m';
return 'speed-slow';
}
function getInterfaceSpeed(node) {
if (!node.interfaces || node.interfaces.length === 0) return 0;
const iface = node.interfaces[0];
return iface.stats?.speed || 0;
}
function getInterfaceErrors(node) {
if (!node.interfaces || node.interfaces.length === 0) return null;
const iface = node.interfaces[0];
if (!iface.stats) return null;
const inErr = iface.stats.in_errors || 0;
const outErr = iface.stats.out_errors || 0;
if (inErr === 0 && outErr === 0) return null;
return { in: inErr, out: outErr };
}
function getInterfaceRates(node) {
if (!node.interfaces || node.interfaces.length === 0) return null;
const iface = node.interfaces[0];
if (!iface.stats) return null;
return {
inPkts: iface.stats.in_pkts_rate || 0,
outPkts: iface.stats.out_pkts_rate || 0,
inBytes: iface.stats.in_bytes_rate || 0,
outBytes: iface.stats.out_bytes_rate || 0
};
}
function formatMbps(bytesPerSec) {
const mbps = (bytesPerSec * 8) / 1000000;
return Math.round(mbps).toLocaleString() + ' Mbit/s';
}
function buildSwitchUplinks(allSwitches, switchLinks) {
const uplinks = new Map();
if (allSwitches.length === 0 || switchLinks.length === 0) return uplinks;
const adjacency = new Map();
allSwitches.forEach(sw => adjacency.set(sw.id, []));
switchLinks.forEach(link => {
adjacency.get(link.switchA.id).push({
neighbor: link.switchB,
localPort: link.portA,
remotePort: link.portB,
localSpeed: link.speedA,
localErrors: link.errorsA,
localRates: link.ratesA
});
adjacency.get(link.switchB.id).push({
neighbor: link.switchA,
localPort: link.portB,
remotePort: link.portA,
localSpeed: link.speedB,
localErrors: link.errorsB,
localRates: link.ratesB
});
});
for (const edges of adjacency.values()) {
edges.sort((a, b) => getLabel(a.neighbor).localeCompare(getLabel(b.neighbor)));
}
const sortedSwitches = [...allSwitches].sort((a, b) =>
getLabel(a).localeCompare(getLabel(b)));
let bestRoot = sortedSwitches[0];
let bestReachable = 0;
let bestMaxDepth = Infinity;
for (const candidate of sortedSwitches) {
const visited = new Set([candidate.id]);
const queue = [{ sw: candidate, depth: 0 }];
let maxDepth = 0;
while (queue.length > 0) {
const { sw, depth } = queue.shift();
maxDepth = Math.max(maxDepth, depth);
for (const edge of adjacency.get(sw.id) || []) {
if (!visited.has(edge.neighbor.id)) {
visited.add(edge.neighbor.id);
queue.push({ sw: edge.neighbor, depth: depth + 1 });
}
}
}
const reachable = visited.size;
if (reachable > bestReachable ||
(reachable === bestReachable && maxDepth < bestMaxDepth)) {
bestReachable = reachable;
bestMaxDepth = maxDepth;
bestRoot = candidate;
}
}
uplinks.set(bestRoot.id, 'ROOT');
const visited = new Set([bestRoot.id]);
const queue = [bestRoot];
while (queue.length > 0) {
const current = queue.shift();
for (const edge of adjacency.get(current.id) || []) {
if (!visited.has(edge.neighbor.id)) {
visited.add(edge.neighbor.id);
const reverseEdge = adjacency.get(edge.neighbor.id).find(e => e.neighbor.id === current.id);
uplinks.set(edge.neighbor.id, {
localPort: reverseEdge?.localPort || '?',
remotePort: reverseEdge?.remotePort || '?',
parentNode: current,
parentName: getLabel(current),
speed: reverseEdge?.localSpeed || 0,
errors: reverseEdge?.localErrors || null,
rates: reverseEdge?.localRates || null
});
queue.push(edge.neighbor);
}
}
}
return uplinks;
}
function formatPps(pps) {
return Math.round(pps).toLocaleString() + ' pps';
}
function addClickableValue(container, label, value, plainLines, plainFormat) {
const lbl = document.createElement('span');
lbl.className = 'lbl';
lbl.textContent = label;
container.appendChild(lbl);
container.appendChild(document.createTextNode(' '));
const val = document.createElement('span');
val.className = 'clickable-value';
val.textContent = value;
val.addEventListener('click', (e) => {
e.stopPropagation();
navigator.clipboard.writeText(value);
});
container.appendChild(val);
plainLines.push(plainFormat ? plainFormat(label, value) : label + ': ' + value);
}
function buildClickableList(container, items, label, plainFormat) {
const plainLines = [];
items.forEach((item, idx) => {
if (idx > 0) container.appendChild(document.createTextNode('\n'));
addClickableValue(container, label, item, plainLines, plainFormat);
});
container.addEventListener('click', (e) => {
e.stopPropagation();
navigator.clipboard.writeText(plainLines.join('\n'));
});
}
function buildLinkStats(container, portLabel, speed, errIn, errOut, rates) {
const plainLines = [];
if (portLabel) {
addClickableValue(container, 'PORT', portLabel, plainLines);
container.appendChild(document.createTextNode('\n'));
}
addClickableValue(container, 'LINK', formatLinkSpeed(speed), plainLines);
container.appendChild(document.createTextNode('\n'));
addClickableValue(container, 'ERR', 'RX ' + errIn + ' / TX ' + errOut, plainLines);
if (rates) {
container.appendChild(document.createTextNode('\n'));
addClickableValue(container, 'RX', formatMbps(rates.rxBytes) + ' (' + formatPps(rates.rxPkts) + ')', plainLines);
container.appendChild(document.createTextNode('\n'));
addClickableValue(container, 'TX', formatMbps(rates.txBytes) + ' (' + formatPps(rates.txPkts) + ')', plainLines);
}
container.addEventListener('click', (e) => {
e.stopPropagation();
navigator.clipboard.writeText(plainLines.join('\n'));
});
}
function buildDanteDetail(container, entries, arrow) {
const plainLines = [];
entries.forEach((entry, entryIdx) => {
entry.split('\n').forEach((line, lineIdx) => {
if (entryIdx > 0 && lineIdx === 0) {
container.appendChild(document.createTextNode('\n\n'));
plainLines.push('');
} else if (container.childNodes.length > 0) {
container.appendChild(document.createTextNode('\n'));
}
if (line.startsWith(' ')) {
container.appendChild(document.createTextNode(' ' + line.trim()));
plainLines.push(' ' + line.trim());
} else {
addClickableValue(container, arrow, line, plainLines, (l, v) => l + ' ' + v);
}
});
});
container.addEventListener('click', (e) => {
e.stopPropagation();
navigator.clipboard.writeText(plainLines.join('\n'));
});
}
function formatLinkSpeed(bps) {
if (!bps) return '?';
const mbps = bps / 1000000;
return mbps.toLocaleString() + ' Mbit/s';
}
let anonCounter = 0;
const nodeElements = new Map();
const locationElements = new Map();
let usedNodeIds = new Set();
let usedLocationIds = new Set();
function buildLocationTree(locations, parent) {
if (!locations) return [];
return locations.map((loc, idx) => {
let locId;
let anonymous = false;
if (loc.name) {
locId = 'loc_' + loc.name.replace(/[^a-zA-Z0-9]/g, '_');
} else {
locId = 'loc_anon_' + (anonCounter++);
anonymous = true;
}
const locObj = {
id: locId,
name: loc.name || '',
anonymous: anonymous,
direction: loc.direction || 'horizontal',
nodeRefs: (loc.nodes || []).map(n => n.toLowerCase()),
parent: parent,
children: []
};
locObj.children = buildLocationTree(loc.children, locObj);
return locObj;
});
}
function getSwitchesInLocation(loc, assignedNodes) {
const switches = [];
const nodes = assignedNodes.get(loc) || [];
nodes.forEach(n => {
if (isSwitch(n)) switches.push(n);
});
loc.children.forEach(child => {
if (child.anonymous) {
switches.push(...getSwitchesInLocation(child, assignedNodes));
}
});
return switches;
}
function findEffectiveSwitch(loc, assignedNodes) {
if (!loc) return null;
const switches = getSwitchesInLocation(loc, assignedNodes);
if (switches.length === 1) {
return switches[0];
}
if (loc.parent) {
return findEffectiveSwitch(loc.parent, assignedNodes);
}
return null;
}
function buildNodeIndex(locations, index) {
locations.forEach(loc => {
loc.nodeRefs.forEach(ref => {
index.set(ref, loc);
});
buildNodeIndex(loc.children, index);
});
}
function findLocationForNode(node, nodeIndex) {
const identifiers = getNodeIdentifiers(node);
for (const ident of identifiers) {
if (nodeIndex.has(ident)) {
return nodeIndex.get(ident);
}
}
return null;
}
function createNodeElement(node, switchConnection, nodeLocation, uplinkInfo, danteInfo, artnetInfo, sacnInfo, hasError, isUnreachable) {
let div = nodeElements.get(node.id);
if (!div) {
div = document.createElement('div');
div.dataset.id = node.id;
div.addEventListener('click', () => {
const nodeData = div._nodeData;
if (!nodeData) return;
let copyText = nodeData.names?.length > 0 ? nodeData.names.join('\n') : getLabel(nodeData);
navigator.clipboard.writeText(copyText).then(() => {
div.classList.add('copied');
setTimeout(() => div.classList.remove('copied'), 300);
});
});
nodeElements.set(node.id, div);
}
div._nodeData = node;
div.className = 'node' + (isSwitch(node) ? ' switch' : '');
if (hasError) div.classList.add('has-error');
if (isUnreachable) div.classList.add('unreachable');
if (danteInfo?.isTx) div.classList.add('dante-tx');
if (danteInfo?.isRx) div.classList.add('dante-rx');
if (artnetInfo?.isOut) div.classList.add('artnet-out');
if (artnetInfo?.isIn) div.classList.add('artnet-in');
if (sacnInfo?.isOut) div.classList.add('sacn-out');
if (sacnInfo?.isIn) div.classList.add('sacn-in');
// Switch port connection
if (!isSwitch(node) && switchConnection) {
let container = div.querySelector(':scope > .port-hover');
if (!container) {
container = document.createElement('div');
container.className = 'port-hover';
container.innerHTML = '<div class="switch-port"></div><div class="link-stats-wrapper"><div class="link-stats"></div></div>';
div.appendChild(container);
}
const portEl = container.querySelector('.switch-port');
portEl.className = 'switch-port';
if (switchConnection.external) portEl.classList.add('external');
const speedClass = getSpeedClass(switchConnection.speed);
if (speedClass) portEl.classList.add(speedClass);
const portLabel = switchConnection.showSwitchName
? switchConnection.switchName + ':' + switchConnection.port
: switchConnection.port;
portEl.textContent = portLabel;
const statsEl = container.querySelector('.link-stats');
statsEl.innerHTML = '';
const errIn = switchConnection.errors?.in || 0;
const errOut = switchConnection.errors?.out || 0;
const r = switchConnection.rates;
buildLinkStats(statsEl, portLabel, switchConnection.speed, errIn, errOut,
r ? {rxBytes: r.outBytes, rxPkts: r.outPkts, txBytes: r.inBytes, txPkts: r.inPkts} : null);
} else {
const container = div.querySelector(':scope > .port-hover');
if (container) container.remove();
}
// Label
let labelEl = div.querySelector(':scope > .node-label');
if (!labelEl) {
labelEl = document.createElement('span');
labelEl.className = 'node-label';
div.appendChild(labelEl);
}
labelEl.innerHTML = '';
if (node.names && node.names.length > 0) {
node.names.forEach((name, idx) => {
if (idx > 0) labelEl.appendChild(document.createTextNode('\n'));
const nameSpan = document.createElement('span');
nameSpan.className = 'node-name';
nameSpan.textContent = name;
nameSpan.addEventListener('click', (e) => {
e.stopPropagation();
navigator.clipboard.writeText(name).then(() => {
div.classList.add('copied');
setTimeout(() => div.classList.remove('copied'), 300);
});
});
labelEl.appendChild(nameSpan);
});
} else {
labelEl.textContent = getLabel(node);
}
// Node info popup
const hasNodeInfo = node.interfaces && (
node.interfaces.some(i => i.ips?.length > 0) ||
node.interfaces.some(i => i.mac)
);
if (hasNodeInfo) {
let wrapper = div.querySelector(':scope > .node-info-wrapper');
if (!wrapper) {
wrapper = document.createElement('div');
wrapper.className = 'node-info-wrapper';
wrapper.innerHTML = '<div class="node-info"></div>';
div.appendChild(wrapper);
}
const nodeInfo = wrapper.querySelector('.node-info');
nodeInfo.innerHTML = '';
const ips = [];
const macs = [];
node.interfaces.forEach(iface => {
if (iface.ips) iface.ips.forEach(ip => { if (!ips.includes(ip)) ips.push(ip); });
if (iface.mac && !macs.includes(iface.mac)) macs.push(iface.mac);
});
ips.sort();
macs.sort();
const plainLines = [];
ips.forEach((ip, idx) => {
if (idx > 0) nodeInfo.appendChild(document.createTextNode('\n'));
addClickableValue(nodeInfo, 'IP', ip, plainLines);
});
macs.forEach((mac, idx) => {
if (ips.length > 0 || idx > 0) nodeInfo.appendChild(document.createTextNode('\n'));
addClickableValue(nodeInfo, 'MAC', mac, plainLines);
});
if (plainLines.length > 0) {
nodeInfo.onclick = (e) => {
e.stopPropagation();
navigator.clipboard.writeText(plainLines.join('\n'));
};
}
} else {
const wrapper = div.querySelector(':scope > .node-info-wrapper');
if (wrapper) wrapper.remove();
}
// Switch uplink / root label
if (isSwitch(node) && uplinkInfo === 'ROOT') {
const container = div.querySelector(':scope > .uplink-hover');
if (container) container.remove();
let rootEl = div.querySelector(':scope > .root-label');
if (!rootEl) {
rootEl = document.createElement('div');
rootEl.className = 'root-label';
rootEl.textContent = 'ROOT';
div.appendChild(rootEl);
}
} else if (isSwitch(node) && uplinkInfo) {
const rootEl = div.querySelector(':scope > .root-label');
if (rootEl) rootEl.remove();
let container = div.querySelector(':scope > .uplink-hover');
if (!container) {
container = document.createElement('div');
container.className = 'uplink-hover';
container.innerHTML = '<div class="uplink"></div><div class="link-stats-wrapper"><div class="link-stats"></div></div>';
div.appendChild(container);
}
const uplinkEl = container.querySelector('.uplink');
uplinkEl.className = 'uplink';
const speedClass = getSpeedClass(uplinkInfo.speed);
if (speedClass) uplinkEl.classList.add(speedClass);
const uplinkLabel = uplinkInfo.localPort + ' → ' + uplinkInfo.parentName + ':' + uplinkInfo.remotePort;
uplinkEl.textContent = uplinkLabel;
const statsEl = container.querySelector('.link-stats');
statsEl.innerHTML = '';
const errIn = uplinkInfo.errors?.in || 0;
const errOut = uplinkInfo.errors?.out || 0;
const r = uplinkInfo.rates;
buildLinkStats(statsEl, uplinkLabel, uplinkInfo.speed, errIn, errOut,
r ? {rxBytes: r.inBytes, rxPkts: r.inPkts, txBytes: r.outBytes, txPkts: r.outPkts} : null);
} else {
const rootEl = div.querySelector(':scope > .root-label');
if (rootEl) rootEl.remove();
const container = div.querySelector(':scope > .uplink-hover');
if (container) container.remove();
}
// Dante TX
if (danteInfo?.isTx) {
let container = div.querySelector(':scope > .dante-tx-hover');
if (!container) {
container = document.createElement('div');
container.className = 'dante-hover dante-tx-hover';
container.innerHTML = '<div class="dante-info tx-info"><span class="lbl">→</span> <span class="dante-pill-text"></span></div><div class="dante-detail-wrapper"><div class="dante-detail"></div></div>';
div.appendChild(container);
}
const textEl = container.querySelector('.dante-pill-text');
const firstDest = danteInfo.txTo[0].split('\n')[0];
const txMore = danteInfo.txTo.length > 1 ? ', ...' : '';
textEl.textContent = firstDest + txMore;
const detail = container.querySelector('.dante-detail');
detail.innerHTML = '';
buildDanteDetail(detail, danteInfo.txTo, '→');
} else {
const container = div.querySelector(':scope > .dante-tx-hover');
if (container) container.remove();
}
// Dante RX
if (danteInfo?.isRx) {
let container = div.querySelector(':scope > .dante-rx-hover');
if (!container) {
container = document.createElement('div');
container.className = 'dante-hover dante-rx-hover';
container.innerHTML = '<div class="dante-info rx-info"><span class="lbl">←</span> <span class="dante-pill-text"></span></div><div class="dante-detail-wrapper"><div class="dante-detail"></div></div>';
div.appendChild(container);
}
const textEl = container.querySelector('.dante-pill-text');
const firstSource = danteInfo.rxFrom[0].split('\n')[0];
const rxMore = danteInfo.rxFrom.length > 1 ? ', ...' : '';
textEl.textContent = firstSource + rxMore;
const detail = container.querySelector('.dante-detail');
detail.innerHTML = '';
buildDanteDetail(detail, danteInfo.rxFrom, '←');
} else {
const container = div.querySelector(':scope > .dante-rx-hover');
if (container) container.remove();
}
// Art-Net out
if (artnetInfo?.isOut) {
let container = div.querySelector(':scope > .artnet-out-hover');
if (!container) {
container = document.createElement('div');
container.className = 'artnet-hover artnet-out-hover';
container.innerHTML = '<div class="artnet-info out-info"><span class="lbl">←</span> <span class="artnet-pill-text"></span></div><div class="artnet-detail-wrapper"><div class="artnet-detail"></div></div>';
div.appendChild(container);
}
const textEl = container.querySelector('.artnet-pill-text');
const firstOut = artnetInfo.outputs[0];
const outLabel = firstOut.firstTarget || firstOut.display;
const outMore = artnetInfo.outputs.length > 1 ? ', ...' : '';
textEl.textContent = outLabel + outMore;
const detail = container.querySelector('.artnet-detail');
detail.innerHTML = '';
buildClickableList(detail, artnetInfo.outputs.map(o => o.display), '←', (l, v) => l + ' ' + v);
} else {
const container = div.querySelector(':scope > .artnet-out-hover');
if (container) container.remove();
}
// Art-Net in
if (artnetInfo?.isIn) {
let container = div.querySelector(':scope > .artnet-in-hover');
if (!container) {
container = document.createElement('div');
container.className = 'artnet-hover artnet-in-hover';
container.innerHTML = '<div class="artnet-info in-info"><span class="lbl">→</span> <span class="artnet-pill-text"></span></div><div class="artnet-detail-wrapper"><div class="artnet-detail"></div></div>';
div.appendChild(container);
}
const textEl = container.querySelector('.artnet-pill-text');
const firstIn = artnetInfo.inputs[0];
const inLabel = firstIn.firstTarget || firstIn.display;
const inMore = artnetInfo.inputs.length > 1 ? ', ...' : '';
textEl.textContent = inLabel + inMore;
const detail = container.querySelector('.artnet-detail');
detail.innerHTML = '';
buildClickableList(detail, artnetInfo.inputs.map(i => i.display), '→', (l, v) => l + ' ' + v);
} else {
const container = div.querySelector(':scope > .artnet-in-hover');
if (container) container.remove();
}
// sACN out
if (sacnInfo?.isOut) {
let container = div.querySelector(':scope > .sacn-out-hover');
if (!container) {
container = document.createElement('div');
container.className = 'sacn-hover sacn-out-hover';
container.innerHTML = '<div class="sacn-info out-info"><span class="lbl">←</span> <span class="sacn-pill-text"></span></div><div class="sacn-detail-wrapper"><div class="sacn-detail"></div></div>';
div.appendChild(container);
}
const textEl = container.querySelector('.sacn-pill-text');
const firstOut = sacnInfo.outputs[0];
const outLabel = firstOut.firstTarget || firstOut.display;
const outMore = sacnInfo.outputs.length > 1 ? ', ...' : '';
textEl.textContent = outLabel + outMore;
const detail = container.querySelector('.sacn-detail');
detail.innerHTML = '';
buildClickableList(detail, sacnInfo.outputs.map(o => o.display), '←', (l, v) => l + ' ' + v);
} else {
const container = div.querySelector(':scope > .sacn-out-hover');
if (container) container.remove();
}
// sACN in
if (sacnInfo?.isIn) {
let container = div.querySelector(':scope > .sacn-in-hover');
if (!container) {
container = document.createElement('div');
container.className = 'sacn-hover sacn-in-hover';
container.innerHTML = '<div class="sacn-info in-info"><span class="lbl">→</span> <span class="sacn-pill-text"></span></div><div class="sacn-detail-wrapper"><div class="sacn-detail"></div></div>';
div.appendChild(container);
}
const textEl = container.querySelector('.sacn-pill-text');
const firstIn = sacnInfo.inputs[0];
const inLabel = firstIn.firstTarget || firstIn.display;
const inMore = sacnInfo.inputs.length > 1 ? ', ...' : '';
textEl.textContent = inLabel + inMore;
const detail = container.querySelector('.sacn-detail');
detail.innerHTML = '';
buildClickableList(detail, sacnInfo.inputs.map(i => i.display), '→', (l, v) => l + ' ' + v);
} else {
const container = div.querySelector(':scope > .sacn-in-hover');
if (container) container.remove();
}
return div;
}
function renderLocation(loc, assignedNodes, isTopLevel, switchConnections, switchUplinks, danteNodes, artnetNodes, sacnNodes, errorNodeIds, unreachableNodeIds) {
const nodes = assignedNodes.get(loc) || [];
const hasNodes = nodes.length > 0;
const childElements = loc.children
.map(child => renderLocation(child, assignedNodes, false, switchConnections, switchUplinks, danteNodes, artnetNodes, sacnNodes, errorNodeIds, unreachableNodeIds))
.filter(el => el !== null);
if (!hasNodes && childElements.length === 0) {
return null;
}
usedLocationIds.add(loc.id);
let container = locationElements.get(loc.id);
if (!container) {
container = document.createElement('div');
container.dataset.locid = loc.id;
locationElements.set(loc.id, container);
}
let classes = 'location';
if (loc.anonymous) classes += ' anonymous';
if (isTopLevel) classes += ' top-level';
container.className = classes;
let nameEl = container.querySelector(':scope > .location-name');
if (!nameEl) {
nameEl = document.createElement('div');
nameEl.className = 'location-name';
container.insertBefore(nameEl, container.firstChild);
}
nameEl.textContent = loc.name;
const switchRowId = loc.id + '_sw';
const nodeRowId = loc.id + '_nd';
if (hasNodes) {
const switches = nodes.filter(n => isSwitch(n));
const nonSwitches = nodes.filter(n => !isSwitch(n));
if (switches.length > 0) {
let switchRow = container.querySelector(':scope > .node-row[data-rowid="' + switchRowId + '"]');
if (!switchRow) {
switchRow = document.createElement('div');
switchRow.className = 'node-row';
switchRow.dataset.rowid = switchRowId;
const insertPt = container.querySelector(':scope > .node-row, :scope > .children');
container.insertBefore(switchRow, insertPt);
}
const currentIds = new Set(switches.map(n => n.id));
Array.from(switchRow.children).forEach(ch => {
if (!currentIds.has(ch.dataset.id)) ch.remove();
});
switches.forEach(node => {
usedNodeIds.add(node.id);
const uplink = switchUplinks.get(node.id);
const danteInfo = danteNodes.get(node.id);
const artnetInfo = artnetNodes.get(node.id);
const sacnInfo = sacnNodes.get(node.id);
const hasError = errorNodeIds.has(node.id);
const isUnreachable = unreachableNodeIds.has(node.id);
const el = createNodeElement(node, null, loc, uplink, danteInfo, artnetInfo, sacnInfo, hasError, isUnreachable);
if (el.parentNode !== switchRow) switchRow.appendChild(el);
});
} else {
const switchRow = container.querySelector(':scope > .node-row[data-rowid="' + switchRowId + '"]');
if (switchRow) switchRow.remove();
}
if (nonSwitches.length > 0) {
let nodeRow = container.querySelector(':scope > .node-row[data-rowid="' + nodeRowId + '"]');
if (!nodeRow) {
nodeRow = document.createElement('div');
nodeRow.className = 'node-row';
nodeRow.dataset.rowid = nodeRowId;
const insertPt = container.querySelector(':scope > .children');
container.insertBefore(nodeRow, insertPt);
}
const currentIds = new Set(nonSwitches.map(n => n.id));
Array.from(nodeRow.children).forEach(ch => {
if (!currentIds.has(ch.dataset.id)) ch.remove();
});
nonSwitches.forEach(node => {
usedNodeIds.add(node.id);
const conn = switchConnections.get(node.id);
const danteInfo = danteNodes.get(node.id);
const artnetInfo = artnetNodes.get(node.id);
const sacnInfo = sacnNodes.get(node.id);
const hasError = errorNodeIds.has(node.id);
const isUnreachable = unreachableNodeIds.has(node.id);
const el = createNodeElement(node, conn, loc, null, danteInfo, artnetInfo, sacnInfo, hasError, isUnreachable);
if (el.parentNode !== nodeRow) nodeRow.appendChild(el);
});
} else {
const nodeRow = container.querySelector(':scope > .node-row[data-rowid="' + nodeRowId + '"]');
if (nodeRow) nodeRow.remove();
}
} else {
const switchRow = container.querySelector(':scope > .node-row[data-rowid="' + switchRowId + '"]');
if (switchRow) switchRow.remove();
const nodeRow = container.querySelector(':scope > .node-row[data-rowid="' + nodeRowId + '"]');
if (nodeRow) nodeRow.remove();
}
if (childElements.length > 0) {
let childrenContainer = container.querySelector(':scope > .children');
if (!childrenContainer) {
childrenContainer = document.createElement('div');
container.appendChild(childrenContainer);
}
childrenContainer.className = 'children ' + loc.direction;
childElements.forEach(el => {
if (el.parentNode !== childrenContainer) childrenContainer.appendChild(el);
});
} else {
const childrenContainer = container.querySelector(':scope > .children');
if (childrenContainer) childrenContainer.remove();
}
return container;
}
let portErrors = [];
let errorPanelCollapsed = false;
function updateErrorPanel() {
const panel = document.getElementById('error-panel');
const countEl = document.getElementById('error-count');
const listEl = document.getElementById('error-list');
if (portErrors.length === 0) {
panel.classList.remove('has-errors');
return;
}
panel.classList.add('has-errors');
countEl.textContent = portErrors.length + ' Error' + (portErrors.length !== 1 ? 's' : '');
listEl.innerHTML = '';
portErrors.forEach(err => {
const item = document.createElement('div');
item.className = 'error-item';
const nodeEl = document.createElement('div');
nodeEl.className = 'error-node';
nodeEl.textContent = err.node_name || err.node_id;
nodeEl.addEventListener('click', () => scrollToNode(err.node_id));
item.appendChild(nodeEl);
if (err.type === 'unreachable') {
const typeEl = document.createElement('div');
typeEl.className = 'error-type';
typeEl.textContent = 'Unreachable';
item.appendChild(typeEl);
} else if (err.type === 'high_utilization') {
const portEl = document.createElement('div');
portEl.className = 'error-port';
portEl.textContent = 'Port: ' + err.port;
item.appendChild(portEl);
const countsEl = document.createElement('div');
countsEl.className = 'error-counts';
countsEl.textContent = 'Utilization: ' + (err.utilization || 0).toFixed(0) + '%';
item.appendChild(countsEl);
const typeEl = document.createElement('div');
typeEl.className = 'error-type';
typeEl.textContent = 'High link utilization';
item.appendChild(typeEl);
} else {
const portEl = document.createElement('div');
portEl.className = 'error-port';
portEl.textContent = 'Port: ' + err.port;
item.appendChild(portEl);
const countsEl = document.createElement('div');
countsEl.className = 'error-counts';
countsEl.textContent = 'rx: ' + err.in_errors + ' (+' + (err.in_delta || 0) + ') / tx: ' + err.out_errors + ' (+' + (err.out_delta || 0) + ')';
item.appendChild(countsEl);
const typeEl = document.createElement('div');
typeEl.className = 'error-type';
typeEl.textContent = 'New errors detected';
item.appendChild(typeEl);
}
const dismissBtn = document.createElement('button');
dismissBtn.textContent = 'Dismiss';
dismissBtn.addEventListener('click', () => clearError(err.id));
item.appendChild(dismissBtn);
listEl.appendChild(item);
});
}
function scrollToNode(typeid) {
const nodeEl = document.querySelector('.node[data-id="' + typeid + '"]');
if (nodeEl) {
nodeEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
nodeEl.classList.add('scroll-highlight');
setTimeout(() => nodeEl.classList.remove('scroll-highlight'), 1000);
}
}
async function clearError(id) {
await fetch('/api/errors/clear?id=' + encodeURIComponent(id), { method: 'POST' });
}
async function clearAllErrors() {
await fetch('/api/errors/clear?all=true', { method: 'POST' });
}
let currentConfig = null;
function setConnectionStatus(connected) {
const el = document.getElementById('connection-status');
const textEl = el.querySelector('.text');
if (connected) {
el.className = 'connected';
textEl.textContent = 'Connected';
} else {
el.className = 'disconnected';
textEl.textContent = 'Disconnected';
}
}
function connectSSE() {
const evtSource = new EventSource('/api/status/stream');
let heartbeatTimeout = null;
function resetHeartbeat() {
if (heartbeatTimeout) clearTimeout(heartbeatTimeout);
heartbeatTimeout = setTimeout(() => {
setConnectionStatus(false);
evtSource.close();
setTimeout(connectSSE, 2000);
}, 10000);
}
evtSource.addEventListener('status', (event) => {
resetHeartbeat();
const data = JSON.parse(event.data);
currentConfig = data.config || {};
render(data, currentConfig);
});
evtSource.onopen = () => {
setConnectionStatus(true);
resetHeartbeat();
};
evtSource.onerror = () => {
if (heartbeatTimeout) clearTimeout(heartbeatTimeout);
setConnectionStatus(false);
evtSource.close();
setTimeout(connectSSE, 2000);
};
}
function render(data, config) {
anonCounter = 0;
const nodes = data.nodes || [];
const links = data.links || [];
portErrors = data.errors || [];
const unreachableNodeIds = new Set(nodes.filter(n => n.unreachable).map(n => n.id));
const errorNodeIds = new Set(portErrors.filter(e => e.type !== 'unreachable').map(e => e.node_id));
const locationTree = buildLocationTree(config.locations || [], null);
const nodeIndex = new Map();
buildNodeIndex(locationTree, nodeIndex);
const nodesByTypeId = new Map();
nodes.forEach(node => {
nodesByTypeId.set(node.id, node);
});
const nodeLocations = new Map();
const assignedNodes = new Map();
const unassignedNodes = [];
nodes.forEach(node => {
const loc = findLocationForNode(node, nodeIndex);
if (loc) {
nodeLocations.set(node.id, loc);
if (!assignedNodes.has(loc)) {
assignedNodes.set(loc, []);
}
assignedNodes.get(loc).push(node);
} else {
unassignedNodes.push(node);
}
});
const switchConnections = new Map();
const switchLinks = [];
const allSwitches = nodes.filter(n => isSwitch(n));
links.forEach(link => {
const nodeA = nodesByTypeId.get(link.node_a?.id);
const nodeB = nodesByTypeId.get(link.node_b?.id);
if (!nodeA || !nodeB) return;
const aIsSwitch = isSwitch(nodeA);
const bIsSwitch = isSwitch(nodeB);
if (aIsSwitch && bIsSwitch) {
switchLinks.push({
switchA: nodeA,
switchB: nodeB,
portA: link.interface_a || '?',
portB: link.interface_b || '?',
speedA: getInterfaceSpeed(link.node_a),
speedB: getInterfaceSpeed(link.node_b),
errorsA: getInterfaceErrors(link.node_a),
errorsB: getInterfaceErrors(link.node_b),
ratesA: getInterfaceRates(link.node_a),
ratesB: getInterfaceRates(link.node_b)
});
} else if (aIsSwitch && !bIsSwitch) {
const nodeLoc = nodeLocations.get(nodeB.id);
const effectiveSwitch = findEffectiveSwitch(nodeLoc, assignedNodes);
const isLocalSwitch = effectiveSwitch && effectiveSwitch.id === nodeA.id;
switchConnections.set(nodeB.id, {
port: link.interface_a || '?',
switchName: getLabel(nodeA),
showSwitchName: !isLocalSwitch,
external: effectiveSwitch && !isLocalSwitch,
speed: getInterfaceSpeed(link.node_a),
errors: getInterfaceErrors(link.node_a),
rates: getInterfaceRates(link.node_a)
});
} else if (bIsSwitch && !aIsSwitch) {
const nodeLoc = nodeLocations.get(nodeA.id);
const effectiveSwitch = findEffectiveSwitch(nodeLoc, assignedNodes);
const isLocalSwitch = effectiveSwitch && effectiveSwitch.id === nodeB.id;
switchConnections.set(nodeA.id, {
port: link.interface_b || '?',
switchName: getLabel(nodeB),
showSwitchName: !isLocalSwitch,
external: effectiveSwitch && !isLocalSwitch,
speed: getInterfaceSpeed(link.node_b),
errors: getInterfaceErrors(link.node_b),
rates: getInterfaceRates(link.node_b)
});
}
});
const danteNodes = new Map();
const formatDanteChannel = (ch) => {
let str = ch.tx_channel + ' → ' + String(ch.rx_channel).padStart(2, '0');
if (ch.type) str += ' [' + ch.type + ']';
if (ch.status === 'no-source') str += ' ⚠';
return str;
};
nodes.forEach(node => {
const nodeId = node.id;
const danteTx = node.dante_flows?.tx || [];
const danteRx = node.dante_flows?.rx || [];
if (danteTx.length === 0 && danteRx.length === 0) return;
const txTo = danteTx.map(peer => {
const peerName = getShortLabel(peer.node);
const channels = (peer.channels || []).map(formatDanteChannel);
const channelSummary = channels.length > 0 ? '\n ' + channels.join('\n ') : '';
return peerName + channelSummary;
});
const rxFrom = danteRx.map(peer => {
const peerName = getShortLabel(peer.node);
const channels = (peer.channels || []).map(formatDanteChannel);
const channelSummary = channels.length > 0 ? '\n ' + channels.join('\n ') : '';
return peerName + channelSummary;
});
txTo.sort((a, b) => a.split('\n')[0].localeCompare(b.split('\n')[0]));
rxFrom.sort((a, b) => a.split('\n')[0].localeCompare(b.split('\n')[0]));
danteNodes.set(nodeId, {
isTx: danteTx.length > 0,
isRx: danteRx.length > 0,
txTo: txTo,
rxFrom: rxFrom
});
});
const artnetNodes = new Map();
const formatUniverse = (u) => {
const net = (u >> 8) & 0x7f;
const subnet = (u >> 4) & 0x0f;
const universe = u & 0x0f;
return net + ':' + subnet + ':' + universe + ' (' + u + ')';
};
const universeInputs = new Map();
const universeOutputs = new Map();
nodes.forEach(node => {
const name = getShortLabel(node);
(node.artnet_inputs || []).forEach(u => {
if (!universeInputs.has(u)) universeInputs.set(u, []);
universeInputs.get(u).push(name);
});
(node.artnet_outputs || []).forEach(u => {
if (!universeOutputs.has(u)) universeOutputs.set(u, []);
universeOutputs.get(u).push(name);
});
});
const collapseNames = (names) => {
const counts = {};
names.forEach(n => counts[n] = (counts[n] || 0) + 1);
return Object.entries(counts).map(([name, count]) => count > 1 ? name + ' x' + count : name);
};
nodes.forEach(node => {
const nodeId = node.id;
const artnetInputs = node.artnet_inputs || [];
const artnetOutputs = node.artnet_outputs || [];
if (artnetInputs.length === 0 && artnetOutputs.length === 0) return;
const inputs = artnetInputs.slice().sort((a, b) => a - b).map(u => {
const sources = collapseNames(universeOutputs.get(u) || []);
const uniStr = formatUniverse(u);
if (sources.length > 0) {
return { display: sources[0] + ' [' + uniStr + ']', firstTarget: sources[0] };
}
return { display: uniStr, firstTarget: null };
});
const outputs = artnetOutputs.slice().sort((a, b) => a - b).map(u => {
const dests = collapseNames(universeInputs.get(u) || []);
const uniStr = formatUniverse(u);
if (dests.length > 0) {
return { display: dests[0] + ' [' + uniStr + ']', firstTarget: dests[0] };
}
return { display: uniStr, firstTarget: null };
});
artnetNodes.set(nodeId, {
isOut: outputs.length > 0,
isIn: inputs.length > 0,
outputs: outputs,
inputs: inputs
});
});
const sacnNodes = new Map();
const sacnUniverseInputs = new Map();
const sacnUniverseOutputs = new Map();
function getSacnInputsFromMulticast(node) {
const groups = node.multicast_groups || [];
const inputs = [];
groups.forEach(g => {
if (typeof g === 'string' && g.startsWith('sacn:')) {
const u = parseInt(g.substring(5), 10);
if (!isNaN(u)) inputs.push(u);
}
});
return inputs;
}
nodes.forEach(node => {
const name = getShortLabel(node);
getSacnInputsFromMulticast(node).forEach(u => {
if (!sacnUniverseInputs.has(u)) sacnUniverseInputs.set(u, []);
sacnUniverseInputs.get(u).push(name);
});
(node.sacn_outputs || []).forEach(u => {
if (!sacnUniverseOutputs.has(u)) sacnUniverseOutputs.set(u, []);
sacnUniverseOutputs.get(u).push(name);
});
});
const sacnCollapseNames = (names) => {
const counts = {};
names.forEach(n => counts[n] = (counts[n] || 0) + 1);
return Object.entries(counts).map(([name, count]) => count > 1 ? name + ' x' + count : name);
};
nodes.forEach(node => {
const nodeId = node.id;
const sacnInputs = getSacnInputsFromMulticast(node);
const sacnOutputs = node.sacn_outputs || [];
if (sacnInputs.length === 0 && sacnOutputs.length === 0) return;
const inputs = sacnInputs.slice().sort((a, b) => a - b).map(u => {
const sources = sacnCollapseNames(sacnUniverseOutputs.get(u) || []);
if (sources.length > 0) {
return { display: sources[0] + ' [' + u + ']', firstTarget: sources[0] };
}
return { display: String(u), firstTarget: null };
});
const outputs = sacnOutputs.slice().sort((a, b) => a - b).map(u => {
const dests = sacnCollapseNames(sacnUniverseInputs.get(u) || []);
if (dests.length > 0) {
return { display: dests[0] + ' [' + u + ']', firstTarget: dests[0] };
}
return { display: String(u), firstTarget: null };
});
sacnNodes.set(nodeId, {
isOut: outputs.length > 0,
isIn: inputs.length > 0,
outputs: outputs,
inputs: inputs
});
});
const switchUplinks = buildSwitchUplinks(allSwitches, switchLinks);
const container = document.getElementById('container');
usedNodeIds = new Set();
usedLocationIds = new Set();
locationTree.forEach(loc => {
const el = renderLocation(loc, assignedNodes, true, switchConnections, switchUplinks, danteNodes, artnetNodes, sacnNodes, errorNodeIds, unreachableNodeIds);
if (el && el.parentNode !== container) container.appendChild(el);
});
let unassignedLoc = locationElements.get('__unassigned__');
if (unassignedNodes.length > 0) {
if (!unassignedLoc) {
unassignedLoc = document.createElement('div');
unassignedLoc.className = 'location top-level';
const nameEl = document.createElement('div');
nameEl.className = 'location-name';
nameEl.textContent = 'Unassigned';
unassignedLoc.appendChild(nameEl);
locationElements.set('__unassigned__', unassignedLoc);
}
const switches = unassignedNodes.filter(n => isSwitch(n));
const nonSwitches = unassignedNodes.filter(n => !isSwitch(n));
let switchRow = unassignedLoc.querySelector(':scope > .node-row.switch-row');
if (switches.length > 0) {
if (!switchRow) {
switchRow = document.createElement('div');
switchRow.className = 'node-row switch-row';
unassignedLoc.appendChild(switchRow);
}
const currentIds = new Set(switches.map(n => n.id));
Array.from(switchRow.children).forEach(ch => {
if (!currentIds.has(ch.dataset.id)) ch.remove();
});
switches.forEach(node => {
usedNodeIds.add(node.id);
const uplink = switchUplinks.get(node.id);
const danteInfo = danteNodes.get(node.id);
const artnetInfo = artnetNodes.get(node.id);
const sacnInfo = sacnNodes.get(node.id);
const hasError = errorNodeIds.has(node.id);
const isUnreachable = unreachableNodeIds.has(node.id);
const el = createNodeElement(node, null, null, uplink, danteInfo, artnetInfo, sacnInfo, hasError, isUnreachable);
if (el.parentNode !== switchRow) switchRow.appendChild(el);
});
} else if (switchRow) {
switchRow.remove();
}
let nodeRow = unassignedLoc.querySelector(':scope > .node-row:not(.switch-row)');
if (nonSwitches.length > 0) {
if (!nodeRow) {
nodeRow = document.createElement('div');
nodeRow.className = 'node-row';
unassignedLoc.appendChild(nodeRow);
}
const currentIds = new Set(nonSwitches.map(n => n.id));
Array.from(nodeRow.children).forEach(ch => {
if (!currentIds.has(ch.dataset.id)) ch.remove();
});
nonSwitches.forEach(node => {
usedNodeIds.add(node.id);
const conn = switchConnections.get(node.id);
const danteInfo = danteNodes.get(node.id);
const artnetInfo = artnetNodes.get(node.id);
const sacnInfo = sacnNodes.get(node.id);
const hasError = errorNodeIds.has(node.id);
const isUnreachable = unreachableNodeIds.has(node.id);
const el = createNodeElement(node, conn, null, null, danteInfo, artnetInfo, sacnInfo, hasError, isUnreachable);
if (el.parentNode !== nodeRow) nodeRow.appendChild(el);
});
} else if (nodeRow) {
nodeRow.remove();
}
if (unassignedLoc.parentNode !== container) container.appendChild(unassignedLoc);
usedLocationIds.add('__unassigned__');
} else if (unassignedLoc) {
unassignedLoc.remove();
}
locationElements.forEach((el, id) => {
if (!usedLocationIds.has(id) && el.parentNode) {
el.remove();
}
});
updateErrorPanel();
updateBroadcastStats(data.broadcast_stats);
tableData = data;
if (currentView === 'table') {
renderTable();
}
}
connectSSE();
let currentMode = 'network';
let currentView = 'map';
let tableData = null;
let tableSortKeys = [];
function updateHash() {
let hash = '';
if (currentMode !== 'network') hash = currentMode;
if (currentView === 'table') hash += (hash ? '-' : '') + 'table';
window.location.hash = hash;
}
function setMode(mode) {
currentMode = mode;
document.body.classList.remove('dante-mode', 'artnet-mode', 'sacn-mode');
document.getElementById('mode-network').classList.remove('active');
document.getElementById('mode-dante').classList.remove('active');
document.getElementById('mode-artnet').classList.remove('active');
document.getElementById('mode-sacn').classList.remove('active');
if (mode === 'dante') {
document.body.classList.add('dante-mode');
document.getElementById('mode-dante').classList.add('active');
} else if (mode === 'artnet') {
document.body.classList.add('artnet-mode');
document.getElementById('mode-artnet').classList.add('active');
} else if (mode === 'sacn') {
document.body.classList.add('sacn-mode');
document.getElementById('mode-sacn').classList.add('active');
} else {
document.getElementById('mode-network').classList.add('active');
}
updateHash();
tableSortKeys = [];
if (currentView === 'table') {
renderTable();
}
}
function setView(view) {
currentView = view;
document.getElementById('view-map').classList.toggle('active', view === 'map');
document.getElementById('view-table').classList.toggle('active', view === 'table');
document.body.classList.toggle('table-view', view === 'table');
updateHash();
if (view === 'table' && tableData) {
renderTable();
}
}
document.getElementById('mode-network').addEventListener('click', () => setMode('network'));
document.getElementById('mode-dante').addEventListener('click', () => setMode('dante'));
document.getElementById('mode-artnet').addEventListener('click', () => setMode('artnet'));
document.getElementById('mode-sacn').addEventListener('click', () => setMode('sacn'));
document.getElementById('view-map').addEventListener('click', () => setView('map'));
document.getElementById('view-table').addEventListener('click', () => setView('table'));
function sortTable(column) {
const existingIdx = tableSortKeys.findIndex(k => k.column === column);
if (existingIdx === 0) {
tableSortKeys[0].asc = !tableSortKeys[0].asc;
} else {
if (existingIdx > 0) {
tableSortKeys.splice(existingIdx, 1);
}
tableSortKeys.unshift({ column, asc: true });
}
renderTable();
}
function renderTable() {
if (!tableData) return;
const container = document.getElementById('table-container');
const mode = document.body.classList.contains('dante-mode') ? 'dante' :
document.body.classList.contains('artnet-mode') ? 'artnet' :
document.body.classList.contains('sacn-mode') ? 'sacn' : 'network';
let html = '';
if (mode === 'network') {
html = renderNetworkTable();
} else if (mode === 'dante') {
html = renderDanteTable();
} else if (mode === 'artnet') {
html = renderArtnetTable();
} else if (mode === 'sacn') {
html = renderSacnTable();
}
container.innerHTML = html;
container.querySelectorAll('th[data-sort]').forEach(th => {
th.addEventListener('click', () => sortTable(th.dataset.sort));
const sortKey = tableSortKeys.find(k => k.column === th.dataset.sort);
if (sortKey) {
th.classList.add(sortKey.asc ? 'sorted-asc' : 'sorted-desc');
}
});
}
function sortRows(rows, sortKeys) {
if (!sortKeys || sortKeys.length === 0) return rows;
const indexed = rows.map((r, i) => ({ r, i }));
indexed.sort((a, b) => {
for (const { column, asc } of sortKeys) {
let va = a.r[column];
let vb = b.r[column];
if (va == null) va = '';
if (vb == null) vb = '';
let cmp;
if (typeof va === 'number' && typeof vb === 'number') {
cmp = va - vb;
} else {
va = String(va).toLowerCase();
vb = String(vb).toLowerCase();
cmp = va.localeCompare(vb, undefined, { numeric: true, sensitivity: 'base' });
}
if (cmp !== 0) return asc ? cmp : -cmp;
}
return a.i - b.i;
});
return indexed.map(x => x.r);
}
function renderNetworkTable() {
const nodes = tableData.nodes || [];
const links = tableData.links || [];
const nodesByTypeId = new Map();
nodes.forEach(node => nodesByTypeId.set(node.id, node));
const upstreamConnections = new Map();
const allSwitches = nodes.filter(n => isSwitch(n));
const switchLinks = [];
links.forEach(link => {
const nodeA = nodesByTypeId.get(link.node_a?.id);
const nodeB = nodesByTypeId.get(link.node_b?.id);
if (!nodeA || !nodeB) return;
const aIsSwitch = isSwitch(nodeA);
const bIsSwitch = isSwitch(nodeB);
if (aIsSwitch && !bIsSwitch) {
upstreamConnections.set(nodeB.id, {
switchName: getLabel(nodeA),
port: link.interface_a || '?',
speed: getInterfaceSpeed(link.node_a),
errors: getInterfaceErrors(link.node_a),
rates: getInterfaceRates(link.node_a),
isLocalPort: false
});
} else if (bIsSwitch && !aIsSwitch) {
upstreamConnections.set(nodeA.id, {
switchName: getLabel(nodeB),
port: link.interface_b || '?',
speed: getInterfaceSpeed(link.node_b),
errors: getInterfaceErrors(link.node_b),
rates: getInterfaceRates(link.node_b),
isLocalPort: false
});
} else if (aIsSwitch && bIsSwitch) {
switchLinks.push({
switchA: nodeA,
switchB: nodeB,
portA: link.interface_a || '?',
portB: link.interface_b || '?',
speedA: getInterfaceSpeed(link.node_a),
speedB: getInterfaceSpeed(link.node_b),
errorsA: getInterfaceErrors(link.node_a),
errorsB: getInterfaceErrors(link.node_b),
ratesA: getInterfaceRates(link.node_a),
ratesB: getInterfaceRates(link.node_b)
});
}
});
const switchUplinks = buildSwitchUplinks(allSwitches, switchLinks);
for (const [switchId, uplink] of switchUplinks) {
if (uplink === 'ROOT') {
upstreamConnections.set(switchId, 'ROOT');
} else {
upstreamConnections.set(switchId, {
switchName: uplink.parentName,
port: uplink.localPort,
speed: uplink.speed,
errors: uplink.errors,
rates: uplink.rates,
isLocalPort: true
});
}
}
const formatMbps = (bytesPerSec) => {
const mbps = (bytesPerSec * 8) / 1000000;
return mbps.toFixed(1);
};
let rows = nodes.map(node => {
const name = getLabel(node);
const ips = [];
(node.interfaces || []).forEach(iface => {
if (iface.ips) iface.ips.forEach(ip => ips.push(ip));
});
const conn = upstreamConnections.get(node.id);
const isRoot = conn === 'ROOT';
const upstream = isRoot ? 'ROOT' : (conn ? conn.switchName + ':' + conn.port : '');
const speed = isRoot ? null : (conn?.speed || 0);
const errors = isRoot ? null : (conn?.errors || { in: 0, out: 0 });
const rates = isRoot ? null : (conn?.rates || { inBytes: 0, outBytes: 0 });
const useLocalPerspective = isRoot || conn?.isLocalPort;
const isUnreachable = node.unreachable;
const speedStr = speed == null ? '' : (speed >= 1e9 ? (speed/1e9)+'G' : speed >= 1e6 ? (speed/1e6)+'M' : speed > 0 ? speed : '0');
return {
name,
ip: ips[0] || '',
upstream,
speed,
speedStr,
inErrors: errors == null ? null : (useLocalPerspective ? errors.in : errors.out),
outErrors: errors == null ? null : (useLocalPerspective ? errors.out : errors.in),
inRate: rates == null ? null : (useLocalPerspective ? rates.inBytes : rates.outBytes),
outRate: rates == null ? null : (useLocalPerspective ? rates.outBytes : rates.inBytes),
status: isUnreachable ? 'unreachable' : (errors && (errors.in + errors.out) > 0 ? 'errors' : 'ok')
};
});
rows = sortRows(rows, tableSortKeys);
let html = '<table class="data-table"><thead><tr>';
html += '<th data-sort="name">Name</th>';
html += '<th data-sort="ip">IP</th>';
html += '<th data-sort="upstream">Upstream</th>';
html += '<th data-sort="speed">Speed</th>';
html += '<th data-sort="inErrors">In Err</th>';
html += '<th data-sort="outErrors">Out Err</th>';
html += '<th data-sort="inRate">In Mbit/s</th>';
html += '<th data-sort="outRate">Out Mbit/s</th>';
html += '<th data-sort="status">Status</th>';
html += '</tr></thead><tbody>';
rows.forEach(r => {
const statusClass = r.status === 'unreachable' ? 'status-error' : r.status === 'errors' ? 'status-warn' : 'status-ok';
html += '<tr>';
html += '<td>' + escapeHtml(r.name) + '</td>';
html += '<td>' + escapeHtml(r.ip) + '</td>';
html += '<td>' + escapeHtml(r.upstream) + '</td>';
html += '<td class="numeric">' + r.speedStr + '</td>';
html += '<td class="numeric">' + (r.inErrors == null ? '' : r.inErrors) + '</td>';
html += '<td class="numeric">' + (r.outErrors == null ? '' : r.outErrors) + '</td>';
html += '<td class="numeric">' + (r.inRate == null ? '' : formatMbps(r.inRate)) + '</td>';
html += '<td class="numeric">' + (r.outRate == null ? '' : formatMbps(r.outRate)) + '</td>';
html += '<td class="' + statusClass + '">' + r.status + '</td>';
html += '</tr>';
});
html += '</tbody></table>';
return html;
}
function renderDanteTable() {
const nodes = tableData.nodes || [];
let rows = [];
nodes.forEach(node => {
const name = getLabel(node);
const tx = node.dante_flows?.tx || [];
const rx = node.dante_flows?.rx || [];
tx.forEach(peer => {
const peerName = getLabel(peer.node);
(peer.channels || []).forEach(ch => {
rows.push({
source: name,
dest: peerName,
txChannel: ch.tx_channel,
rxChannel: ch.rx_channel,
type: ch.type || '',
status: ch.status || 'active'
});
});
if (!peer.channels || peer.channels.length === 0) {
rows.push({ source: name, dest: peerName, txChannel: '', rxChannel: 0, type: '', status: 'active' });
}
});
});
rows = sortRows(rows, tableSortKeys);
let html = '<table class="data-table"><thead><tr>';
html += '<th data-sort="source">Source</th>';
html += '<th data-sort="txChannel">TX Channel</th>';
html += '<th data-sort="dest">Destination</th>';
html += '<th data-sort="rxChannel">RX Channel</th>';
html += '<th data-sort="type">Type</th>';
html += '<th data-sort="status">Status</th>';
html += '</tr></thead><tbody>';
rows.forEach(r => {
const statusClass = r.status === 'no-source' ? 'status-warn' : 'status-ok';
html += '<tr>';
html += '<td>' + escapeHtml(r.source) + '</td>';
html += '<td>' + escapeHtml(r.txChannel) + '</td>';
html += '<td>' + escapeHtml(r.dest) + '</td>';
html += '<td class="numeric">' + (r.rxChannel || '') + '</td>';
html += '<td>' + escapeHtml(r.type) + '</td>';
html += '<td class="' + statusClass + '">' + escapeHtml(r.status) + '</td>';
html += '</tr>';
});
html += '</tbody></table>';
return html;
}
function renderArtnetTable() {
const nodes = tableData.nodes || [];
const txByUniverse = new Map();
const rxByUniverse = new Map();
nodes.forEach(node => {
const name = getLabel(node);
(node.artnet_inputs || []).forEach(u => {
if (!txByUniverse.has(u)) txByUniverse.set(u, []);
txByUniverse.get(u).push(name);
});
(node.artnet_outputs || []).forEach(u => {
if (!rxByUniverse.has(u)) rxByUniverse.set(u, []);
rxByUniverse.get(u).push(name);
});
});
const allUniverses = new Set([...txByUniverse.keys(), ...rxByUniverse.keys()]);
let rows = [];
allUniverses.forEach(u => {
const txNodes = txByUniverse.get(u) || [];
const rxNodes = rxByUniverse.get(u) || [];
const maxLen = Math.max(txNodes.length, rxNodes.length, 1);
for (let i = 0; i < maxLen; i++) {
rows.push({
universe: u,
universeStr: formatUniverse(u),
tx: txNodes[i] || '',
rx: rxNodes[i] || ''
});
}
});
rows = sortRows(rows, tableSortKeys);
let html = '<table class="data-table"><thead><tr>';
html += '<th data-sort="tx">TX</th>';
html += '<th data-sort="universe">Universe</th>';
html += '<th data-sort="rx">RX</th>';
html += '</tr></thead><tbody>';
rows.forEach(r => {
html += '<tr>';
html += '<td>' + escapeHtml(r.tx) + '</td>';
html += '<td>' + r.universeStr + '</td>';
html += '<td>' + escapeHtml(r.rx) + '</td>';
html += '</tr>';
});
html += '</tbody></table>';
return html;
}
function renderSacnTable() {
const nodes = tableData.nodes || [];
const txByUniverse = new Map();
const rxByUniverse = new Map();
nodes.forEach(node => {
const name = getLabel(node);
(node.sacn_outputs || []).forEach(u => {
if (!txByUniverse.has(u)) txByUniverse.set(u, []);
txByUniverse.get(u).push(name);
});
(node.multicast_groups || []).forEach(g => {
if (typeof g === 'string' && g.startsWith('sacn:')) {
const u = parseInt(g.substring(5), 10);
if (!isNaN(u)) {
if (!rxByUniverse.has(u)) rxByUniverse.set(u, []);
rxByUniverse.get(u).push(name);
}
}
});
});
const allUniverses = new Set([...txByUniverse.keys(), ...rxByUniverse.keys()]);
let rows = [];
allUniverses.forEach(u => {
const txNodes = txByUniverse.get(u) || [];
const rxNodes = rxByUniverse.get(u) || [];
const maxLen = Math.max(txNodes.length, rxNodes.length, 1);
for (let i = 0; i < maxLen; i++) {
rows.push({
universe: u,
tx: txNodes[i] || '',
rx: rxNodes[i] || ''
});
}
});
rows = sortRows(rows, tableSortKeys);
let html = '<table class="data-table"><thead><tr>';
html += '<th data-sort="tx">TX</th>';
html += '<th data-sort="universe">Universe</th>';
html += '<th data-sort="rx">RX</th>';
html += '</tr></thead><tbody>';
rows.forEach(r => {
html += '<tr>';
html += '<td>' + escapeHtml(r.tx) + '</td>';
html += '<td class="numeric">' + r.universe + '</td>';
html += '<td>' + escapeHtml(r.rx) + '</td>';
html += '</tr>';
});
html += '</tbody></table>';
return html;
}
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function formatUniverse(u) {
const net = (u >> 8) & 0x7f;
const subnet = (u >> 4) & 0x0f;
const universe = u & 0x0f;
return net + ':' + subnet + ':' + universe + ' (' + u + ')';
}
document.getElementById('clear-all-errors').addEventListener('click', clearAllErrors);
document.getElementById('toggle-errors').addEventListener('click', () => {
const panel = document.getElementById('error-panel');
const btn = document.getElementById('toggle-errors');
errorPanelCollapsed = !errorPanelCollapsed;
if (errorPanelCollapsed) {
panel.classList.add('collapsed');
btn.textContent = 'Show';
} else {
panel.classList.remove('collapsed');
btn.textContent = 'Hide';
}
});
const hash = window.location.hash.slice(1);
const hashParts = hash.split('-');
const hashMode = hashParts[0];
const hashView = hashParts.includes('table') ? 'table' : 'map';
if (hashMode === 'dante' || hashMode === 'artnet' || hashMode === 'sacn') {
setMode(hashMode);
}
if (hashView === 'table') {
setView('table');
}
</script>
</body>
</html>