1711 lines
58 KiB
HTML
1711 lines
58 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: system-ui, sans-serif;
|
|
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 {
|
|
position: absolute;
|
|
top: -8px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
font-size: 9px;
|
|
font-weight: normal;
|
|
background: #444;
|
|
color: #fff;
|
|
padding: 1px 6px;
|
|
border-radius: 8px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.node .switch-port.external {
|
|
border: 1px dashed #c9f;
|
|
}
|
|
|
|
.node .switch-port .error-info,
|
|
.node .uplink .error-info {
|
|
display: none;
|
|
font-size: 8px;
|
|
opacity: 0.8;
|
|
white-space: pre;
|
|
text-align: left;
|
|
margin-top: 2px;
|
|
padding-bottom: 2px;
|
|
}
|
|
|
|
.node .switch-port:hover .error-info,
|
|
.node .uplink:hover .error-info {
|
|
display: block;
|
|
will-change: transform;
|
|
}
|
|
|
|
.node .switch-port:hover,
|
|
.node .uplink:hover {
|
|
z-index: 100;
|
|
will-change: transform;
|
|
}
|
|
|
|
.node .uplink {
|
|
position: absolute;
|
|
top: -8px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
font-size: 9px;
|
|
font-weight: normal;
|
|
background: #444;
|
|
color: #fff;
|
|
padding: 1px 6px;
|
|
border-radius: 8px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.node .root-label {
|
|
position: absolute;
|
|
top: -8px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
font-size: 9px;
|
|
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;
|
|
}
|
|
|
|
#mode-selector {
|
|
position: fixed;
|
|
top: 10px;
|
|
right: 10px;
|
|
z-index: 1000;
|
|
display: flex;
|
|
gap: 0;
|
|
background: #333;
|
|
border-radius: 6px;
|
|
overflow: hidden;
|
|
border: 1px solid #555;
|
|
}
|
|
|
|
#mode-selector button {
|
|
padding: 8px 16px;
|
|
border: none;
|
|
background: #333;
|
|
color: #aaa;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
#mode-selector button:hover {
|
|
background: #444;
|
|
}
|
|
|
|
#mode-selector button.active {
|
|
background: #555;
|
|
color: #fff;
|
|
}
|
|
|
|
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;
|
|
position: absolute;
|
|
top: -8px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
font-size: 9px;
|
|
font-weight: normal;
|
|
padding: 1px 6px;
|
|
border-radius: 8px;
|
|
white-space: nowrap;
|
|
max-width: 100px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
z-index: 10;
|
|
cursor: default;
|
|
}
|
|
|
|
.node:has(.dante-info:hover) {
|
|
z-index: 1000;
|
|
}
|
|
|
|
.node .dante-info:hover {
|
|
white-space: pre;
|
|
max-width: none;
|
|
width: max-content;
|
|
z-index: 100;
|
|
padding: 4px 8px;
|
|
}
|
|
|
|
.node .dante-info.tx-info {
|
|
background: #753;
|
|
color: #fff;
|
|
}
|
|
|
|
.node .dante-info.rx-info {
|
|
background: #357;
|
|
color: #fff;
|
|
}
|
|
|
|
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-info.tx-info {
|
|
top: -8px;
|
|
}
|
|
|
|
body.dante-mode .node.dante-tx.dante-rx .dante-info.rx-info {
|
|
top: auto;
|
|
bottom: -8px;
|
|
}
|
|
|
|
body.artnet-mode .node {
|
|
opacity: 0.3;
|
|
}
|
|
|
|
body.artnet-mode .node.artnet-out {
|
|
opacity: 1;
|
|
background: #2a2;
|
|
}
|
|
|
|
body.artnet-mode .node.artnet-in {
|
|
opacity: 1;
|
|
background: #26d;
|
|
}
|
|
|
|
body.artnet-mode .node.artnet-out.artnet-in {
|
|
background: linear-gradient(135deg, #2a2 50%, #26d 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;
|
|
position: absolute;
|
|
top: -8px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
font-size: 9px;
|
|
font-weight: normal;
|
|
padding: 1px 6px;
|
|
border-radius: 8px;
|
|
white-space: nowrap;
|
|
max-width: 100px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
z-index: 10;
|
|
cursor: default;
|
|
}
|
|
|
|
.node:has(.artnet-info:hover) {
|
|
z-index: 1000;
|
|
}
|
|
|
|
.node .artnet-info:hover {
|
|
white-space: pre;
|
|
max-width: none;
|
|
width: max-content;
|
|
z-index: 100;
|
|
padding: 4px 8px;
|
|
}
|
|
|
|
.node .artnet-info.out-info {
|
|
background: #375;
|
|
color: #fff;
|
|
}
|
|
|
|
.node .artnet-info.in-info {
|
|
background: #357;
|
|
color: #fff;
|
|
}
|
|
|
|
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-info.out-info {
|
|
top: -8px;
|
|
}
|
|
|
|
body.artnet-mode .node.artnet-out.artnet-in .artnet-info.in-info {
|
|
top: auto;
|
|
bottom: -8px;
|
|
}
|
|
|
|
.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 {
|
|
display: none;
|
|
position: absolute;
|
|
top: 100%;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
margin-top: 4px;
|
|
background: #333;
|
|
border: 1px solid #555;
|
|
border-radius: 6px;
|
|
padding: 8px;
|
|
font-size: 10px;
|
|
white-space: nowrap;
|
|
z-index: 1000;
|
|
text-align: left;
|
|
}
|
|
|
|
.node .node-info::before {
|
|
content: '';
|
|
position: absolute;
|
|
bottom: 100%;
|
|
left: 0;
|
|
right: 0;
|
|
height: 8px;
|
|
}
|
|
|
|
.node:hover .node-info {
|
|
display: block;
|
|
will-change: transform;
|
|
}
|
|
|
|
body.dante-mode .node:not(.dante-tx):not(.dante-rx):hover .node-info {
|
|
display: none;
|
|
}
|
|
|
|
body.artnet-mode .node:not(.artnet-out):not(.artnet-in):hover .node-info {
|
|
display: none;
|
|
}
|
|
|
|
.node:has(.switch-port:hover) .node-info,
|
|
.node:has(.uplink:hover) .node-info,
|
|
.node:has(.dante-info:hover) .node-info,
|
|
.node:has(.artnet-info:hover) .node-info {
|
|
display: none;
|
|
}
|
|
|
|
.node .node-info .info-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 2px 0;
|
|
}
|
|
|
|
.node .node-info .info-label {
|
|
color: #888;
|
|
min-width: 28px;
|
|
}
|
|
|
|
.node .node-info .info-value {
|
|
color: #eee;
|
|
font-family: monospace;
|
|
}
|
|
|
|
.node .node-info .copy-btn {
|
|
padding: 2px 4px;
|
|
border: none;
|
|
background: transparent;
|
|
color: #888;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
line-height: 1;
|
|
width: 20px;
|
|
text-align: center;
|
|
}
|
|
|
|
.node .node-info .copy-btn:hover {
|
|
color: #ccc;
|
|
}
|
|
|
|
.node .node-info .copy-btn.copied {
|
|
color: #4f4;
|
|
}
|
|
|
|
.node .node-info .info-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 2px 0;
|
|
}
|
|
|
|
.node .node-info .info-label {
|
|
color: #888;
|
|
min-width: 28px;
|
|
}
|
|
|
|
.node .node-info .info-value {
|
|
color: #eee;
|
|
font-family: monospace;
|
|
}
|
|
|
|
.node .node-info .copy-btn {
|
|
padding: 2px 4px;
|
|
border: none;
|
|
background: transparent;
|
|
color: #888;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
line-height: 1;
|
|
}
|
|
|
|
.node .node-info .copy-btn:hover {
|
|
color: #ccc;
|
|
}
|
|
|
|
.node .node-info .copy-btn.copied {
|
|
color: #4f4;
|
|
}
|
|
|
|
#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="mode-selector">
|
|
<button id="mode-network" class="active">Network</button>
|
|
<button id="mode-dante">Dante</button>
|
|
<button id="mode-artnet">Art-Net</button>
|
|
</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="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 formatPps(pps) {
|
|
return Math.round(pps).toLocaleString() + ' pps';
|
|
}
|
|
|
|
function formatLinkSpeed(bps) {
|
|
if (!bps) return '?';
|
|
const mbps = bps / 1000000;
|
|
return mbps.toLocaleString() + ' Mbit/s';
|
|
}
|
|
|
|
let anonCounter = 0;
|
|
|
|
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, hasError, isUnreachable) {
|
|
const div = document.createElement('div');
|
|
div.className = 'node' + (isSwitch(node) ? ' switch' : '');
|
|
div.dataset.typeid = node.typeid;
|
|
if (hasError) div.classList.add('has-error');
|
|
if (isUnreachable) div.classList.add('unreachable');
|
|
|
|
if (danteInfo) {
|
|
if (danteInfo.isTx) div.classList.add('dante-tx');
|
|
if (danteInfo.isRx) div.classList.add('dante-rx');
|
|
}
|
|
|
|
if (artnetInfo) {
|
|
if (artnetInfo.isOut) div.classList.add('artnet-out');
|
|
if (artnetInfo.isIn) div.classList.add('artnet-in');
|
|
}
|
|
|
|
if (!isSwitch(node) && switchConnection) {
|
|
const portEl = document.createElement('div');
|
|
portEl.className = 'switch-port';
|
|
if (switchConnection.external) {
|
|
portEl.classList.add('external');
|
|
}
|
|
if (switchConnection.showSwitchName) {
|
|
portEl.textContent = switchConnection.switchName + ':' + switchConnection.port;
|
|
} else {
|
|
portEl.textContent = switchConnection.port;
|
|
}
|
|
const speedClass = getSpeedClass(switchConnection.speed);
|
|
if (speedClass) portEl.classList.add(speedClass);
|
|
const errIn = switchConnection.errors?.in || 0;
|
|
const errOut = switchConnection.errors?.out || 0;
|
|
const statsInfo = document.createElement('div');
|
|
statsInfo.className = 'error-info';
|
|
let statsText = 'link: ' + formatLinkSpeed(switchConnection.speed);
|
|
statsText += '\nerr: rx ' + errIn + ' / tx ' + errOut;
|
|
if (switchConnection.rates) {
|
|
const r = switchConnection.rates;
|
|
statsText += '\nrx: ' + formatMbps(r.outBytes) + ' (' + formatPps(r.outPkts) + ')';
|
|
statsText += '\ntx: ' + formatMbps(r.inBytes) + ' (' + formatPps(r.inPkts) + ')';
|
|
}
|
|
statsInfo.textContent = statsText;
|
|
portEl.appendChild(statsInfo);
|
|
div.appendChild(portEl);
|
|
}
|
|
|
|
const labelEl = document.createElement('span');
|
|
labelEl.textContent = getLabel(node);
|
|
div.appendChild(labelEl);
|
|
|
|
const nodeInfo = document.createElement('div');
|
|
nodeInfo.className = 'node-info';
|
|
if (node.interfaces) {
|
|
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();
|
|
ips.forEach(ip => {
|
|
const row = document.createElement('div');
|
|
row.className = 'info-row';
|
|
row.innerHTML = '<span class="info-label">IP</span><span class="info-value">' + ip + '</span>';
|
|
const btn = document.createElement('button');
|
|
btn.className = 'copy-btn';
|
|
btn.textContent = '⧉';
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
navigator.clipboard.writeText(ip).then(() => {
|
|
btn.classList.add('copied');
|
|
btn.textContent = '✓';
|
|
setTimeout(() => { btn.classList.remove('copied'); btn.textContent = '⧉'; }, 500);
|
|
});
|
|
});
|
|
row.appendChild(btn);
|
|
nodeInfo.appendChild(row);
|
|
});
|
|
macs.forEach(mac => {
|
|
const row = document.createElement('div');
|
|
row.className = 'info-row';
|
|
row.innerHTML = '<span class="info-label">MAC</span><span class="info-value">' + mac + '</span>';
|
|
const btn = document.createElement('button');
|
|
btn.className = 'copy-btn';
|
|
btn.textContent = '⧉';
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
navigator.clipboard.writeText(mac).then(() => {
|
|
btn.classList.add('copied');
|
|
btn.textContent = '✓';
|
|
setTimeout(() => { btn.classList.remove('copied'); btn.textContent = '⧉'; }, 500);
|
|
});
|
|
});
|
|
row.appendChild(btn);
|
|
nodeInfo.appendChild(row);
|
|
});
|
|
}
|
|
if (nodeInfo.children.length > 0) {
|
|
div.appendChild(nodeInfo);
|
|
}
|
|
|
|
if (isSwitch(node) && uplinkInfo === 'ROOT') {
|
|
const rootEl = document.createElement('div');
|
|
rootEl.className = 'root-label';
|
|
rootEl.textContent = 'ROOT';
|
|
div.appendChild(rootEl);
|
|
} else if (isSwitch(node) && uplinkInfo) {
|
|
const uplinkEl = document.createElement('div');
|
|
uplinkEl.className = 'uplink';
|
|
uplinkEl.textContent = uplinkInfo.localPort + ' → ' + uplinkInfo.parentName + ':' + uplinkInfo.remotePort;
|
|
const speedClass = getSpeedClass(uplinkInfo.speed);
|
|
if (speedClass) uplinkEl.classList.add(speedClass);
|
|
const errIn = uplinkInfo.errors?.in || 0;
|
|
const errOut = uplinkInfo.errors?.out || 0;
|
|
const statsInfo = document.createElement('div');
|
|
statsInfo.className = 'error-info';
|
|
let statsText = 'link: ' + formatLinkSpeed(uplinkInfo.speed);
|
|
statsText += '\nerr: rx ' + errIn + ' / tx ' + errOut;
|
|
if (uplinkInfo.rates) {
|
|
const r = uplinkInfo.rates;
|
|
statsText += '\nrx: ' + formatMbps(r.inBytes) + ' (' + formatPps(r.inPkts) + ')';
|
|
statsText += '\ntx: ' + formatMbps(r.outBytes) + ' (' + formatPps(r.outPkts) + ')';
|
|
}
|
|
statsInfo.textContent = statsText;
|
|
uplinkEl.appendChild(statsInfo);
|
|
div.appendChild(uplinkEl);
|
|
}
|
|
|
|
if (danteInfo && danteInfo.isTx) {
|
|
const txEl = document.createElement('div');
|
|
txEl.className = 'dante-info tx-info';
|
|
txEl.textContent = '→ ' + danteInfo.txTo.join('\n\n→ ');
|
|
div.appendChild(txEl);
|
|
}
|
|
|
|
if (danteInfo && danteInfo.isRx) {
|
|
const rxEl = document.createElement('div');
|
|
rxEl.className = 'dante-info rx-info';
|
|
rxEl.textContent = '← ' + danteInfo.rxFrom.join('\n\n← ');
|
|
div.appendChild(rxEl);
|
|
}
|
|
|
|
if (artnetInfo && artnetInfo.isOut) {
|
|
const outEl = document.createElement('div');
|
|
outEl.className = 'artnet-info out-info';
|
|
outEl.textContent = '→ ' + artnetInfo.outputs.join('\n→ ');
|
|
div.appendChild(outEl);
|
|
}
|
|
|
|
if (artnetInfo && artnetInfo.isIn) {
|
|
const inEl = document.createElement('div');
|
|
inEl.className = 'artnet-info in-info';
|
|
inEl.textContent = '← ' + artnetInfo.inputs.join('\n← ');
|
|
div.appendChild(inEl);
|
|
}
|
|
|
|
div.addEventListener('click', () => {
|
|
const json = JSON.stringify(node, null, 2);
|
|
navigator.clipboard.writeText(json).then(() => {
|
|
div.classList.add('copied');
|
|
setTimeout(() => div.classList.remove('copied'), 300);
|
|
});
|
|
});
|
|
return div;
|
|
}
|
|
|
|
function renderLocation(loc, assignedNodes, isTopLevel, switchConnections, switchUplinks, danteNodes, artnetNodes, 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, errorNodeIds, unreachableNodeIds))
|
|
.filter(el => el !== null);
|
|
|
|
if (!hasNodes && childElements.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const container = document.createElement('div');
|
|
let classes = 'location';
|
|
if (loc.anonymous) classes += ' anonymous';
|
|
if (isTopLevel) classes += ' top-level';
|
|
container.className = classes;
|
|
|
|
const nameEl = document.createElement('div');
|
|
nameEl.className = 'location-name';
|
|
nameEl.textContent = loc.name;
|
|
container.appendChild(nameEl);
|
|
|
|
if (hasNodes) {
|
|
const switches = nodes.filter(n => isSwitch(n));
|
|
const nonSwitches = nodes.filter(n => !isSwitch(n));
|
|
|
|
if (switches.length > 0) {
|
|
const switchRow = document.createElement('div');
|
|
switchRow.className = 'node-row';
|
|
switches.forEach(node => {
|
|
const uplink = switchUplinks.get(node.typeid);
|
|
const danteInfo = danteNodes.get(node.typeid);
|
|
const artnetInfo = artnetNodes.get(node.typeid);
|
|
const hasError = errorNodeIds.has(node.typeid);
|
|
const isUnreachable = unreachableNodeIds.has(node.typeid);
|
|
switchRow.appendChild(createNodeElement(node, null, loc, uplink, danteInfo, artnetInfo, hasError, isUnreachable));
|
|
});
|
|
container.appendChild(switchRow);
|
|
}
|
|
|
|
if (nonSwitches.length > 0) {
|
|
const nodeRow = document.createElement('div');
|
|
nodeRow.className = 'node-row';
|
|
nonSwitches.forEach(node => {
|
|
const conn = switchConnections.get(node.typeid);
|
|
const danteInfo = danteNodes.get(node.typeid);
|
|
const artnetInfo = artnetNodes.get(node.typeid);
|
|
const hasError = errorNodeIds.has(node.typeid);
|
|
const isUnreachable = unreachableNodeIds.has(node.typeid);
|
|
nodeRow.appendChild(createNodeElement(node, conn, loc, null, danteInfo, artnetInfo, hasError, isUnreachable));
|
|
});
|
|
container.appendChild(nodeRow);
|
|
}
|
|
}
|
|
|
|
if (childElements.length > 0) {
|
|
const childrenContainer = document.createElement('div');
|
|
childrenContainer.className = 'children ' + loc.direction;
|
|
childElements.forEach(el => childrenContainer.appendChild(el));
|
|
container.appendChild(childrenContainer);
|
|
}
|
|
|
|
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_typeid;
|
|
nodeEl.addEventListener('click', () => scrollToNode(err.node_typeid));
|
|
item.appendChild(nodeEl);
|
|
|
|
if (err.error_type === 'unreachable') {
|
|
const typeEl = document.createElement('div');
|
|
typeEl.className = 'error-type';
|
|
typeEl.textContent = 'Unreachable';
|
|
item.appendChild(typeEl);
|
|
} else if (err.error_type === 'high_utilization') {
|
|
const portEl = document.createElement('div');
|
|
portEl.className = 'error-port';
|
|
portEl.textContent = 'Port: ' + err.port_name;
|
|
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_name;
|
|
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 = err.error_type === 'startup' ? 'Present at startup' : '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-typeid="' + 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');
|
|
|
|
evtSource.addEventListener('status', async (event) => {
|
|
const data = JSON.parse(event.data);
|
|
if (!currentConfig) {
|
|
const configResp = await fetch('/api/config');
|
|
currentConfig = await configResp.json();
|
|
}
|
|
render(data, currentConfig);
|
|
});
|
|
|
|
evtSource.onopen = () => {
|
|
setConnectionStatus(true);
|
|
};
|
|
|
|
evtSource.onerror = () => {
|
|
setConnectionStatus(false);
|
|
evtSource.close();
|
|
setTimeout(connectSSE, 2000);
|
|
};
|
|
}
|
|
|
|
function render(data, config) {
|
|
anonCounter = 0;
|
|
|
|
const nodes = data.nodes || [];
|
|
const links = data.links || [];
|
|
|
|
portErrors = data.port_errors || [];
|
|
const unreachableNodeIds = new Set(data.unreachable_nodes || []);
|
|
const errorNodeIds = new Set(portErrors.filter(e => e.error_type !== 'unreachable').map(e => e.node_typeid));
|
|
|
|
|
|
const locationTree = buildLocationTree(config.locations || [], null);
|
|
const nodeIndex = new Map();
|
|
buildNodeIndex(locationTree, nodeIndex);
|
|
|
|
const nodesByTypeId = new Map();
|
|
nodes.forEach(node => {
|
|
nodesByTypeId.set(node.typeid, node);
|
|
});
|
|
|
|
const nodeLocations = new Map();
|
|
const assignedNodes = new Map();
|
|
const unassignedNodes = [];
|
|
|
|
nodes.forEach(node => {
|
|
const loc = findLocationForNode(node, nodeIndex);
|
|
if (loc) {
|
|
nodeLocations.set(node.typeid, 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?.typeid);
|
|
const nodeB = nodesByTypeId.get(link.node_b?.typeid);
|
|
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.typeid);
|
|
const effectiveSwitch = findEffectiveSwitch(nodeLoc, assignedNodes);
|
|
const isLocalSwitch = effectiveSwitch && effectiveSwitch.typeid === nodeA.typeid;
|
|
switchConnections.set(nodeB.typeid, {
|
|
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.typeid);
|
|
const effectiveSwitch = findEffectiveSwitch(nodeLoc, assignedNodes);
|
|
const isLocalSwitch = effectiveSwitch && effectiveSwitch.typeid === nodeB.typeid;
|
|
switchConnections.set(nodeA.typeid, {
|
|
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 danteFlows = data.dante_flows || [];
|
|
const danteNodes = new Map();
|
|
|
|
danteFlows.forEach(flow => {
|
|
const sourceId = flow.source?.typeid;
|
|
if (!sourceId) return;
|
|
|
|
if (!danteNodes.has(sourceId)) {
|
|
danteNodes.set(sourceId, { isTx: false, isRx: false, txTo: [], rxFrom: [] });
|
|
}
|
|
const sourceInfo = danteNodes.get(sourceId);
|
|
sourceInfo.isTx = true;
|
|
|
|
(flow.subscribers || []).forEach(sub => {
|
|
const subId = sub.node?.typeid;
|
|
if (!subId) return;
|
|
|
|
const subName = getShortLabel(sub.node);
|
|
const channels = sub.channels || [];
|
|
const channelSummary = channels.length > 0 ? '\n ' + channels.join('\n ') : '';
|
|
const txEntry = subName + channelSummary;
|
|
if (!sourceInfo.txTo.some(e => e.startsWith(subName))) {
|
|
sourceInfo.txTo.push(txEntry);
|
|
}
|
|
|
|
if (!danteNodes.has(subId)) {
|
|
danteNodes.set(subId, { isTx: false, isRx: false, txTo: [], rxFrom: [] });
|
|
}
|
|
const subInfo = danteNodes.get(subId);
|
|
subInfo.isRx = true;
|
|
|
|
const sourceName = getShortLabel(flow.source);
|
|
const rxChannelSummary = channels.length > 0 ? '\n ' + channels.join('\n ') : '';
|
|
const rxEntry = sourceName + rxChannelSummary;
|
|
if (!subInfo.rxFrom.some(e => e.startsWith(sourceName))) {
|
|
subInfo.rxFrom.push(rxEntry);
|
|
}
|
|
});
|
|
});
|
|
|
|
const artnetData = data.artnet_nodes || [];
|
|
const artnetNodes = new Map();
|
|
|
|
artnetData.forEach(an => {
|
|
const nodeId = an.node?.typeid;
|
|
if (!nodeId) return;
|
|
|
|
const formatUniverse = (u) => {
|
|
const net = (u >> 8) & 0x7f;
|
|
const subnet = (u >> 4) & 0x0f;
|
|
const universe = u & 0x0f;
|
|
return net + ':' + subnet + ':' + universe;
|
|
};
|
|
|
|
const inputs = (an.inputs || []).map(formatUniverse);
|
|
const outputs = (an.outputs || []).map(formatUniverse);
|
|
|
|
artnetNodes.set(nodeId, {
|
|
isOut: outputs.length > 0,
|
|
isIn: inputs.length > 0,
|
|
outputs: outputs,
|
|
inputs: inputs
|
|
});
|
|
});
|
|
|
|
const switchUplinks = new Map();
|
|
if (allSwitches.length > 0 && switchLinks.length > 0) {
|
|
const adjacency = new Map();
|
|
allSwitches.forEach(sw => adjacency.set(sw.typeid, []));
|
|
|
|
switchLinks.forEach(link => {
|
|
adjacency.get(link.switchA.typeid).push({
|
|
neighbor: link.switchB,
|
|
localPort: link.portA,
|
|
remotePort: link.portB,
|
|
localSpeed: link.speedA,
|
|
localErrors: link.errorsA,
|
|
localRates: link.ratesA
|
|
});
|
|
adjacency.get(link.switchB.typeid).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.typeid]);
|
|
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.typeid) || []) {
|
|
if (!visited.has(edge.neighbor.typeid)) {
|
|
visited.add(edge.neighbor.typeid);
|
|
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;
|
|
}
|
|
}
|
|
|
|
switchUplinks.set(bestRoot.typeid, 'ROOT');
|
|
|
|
const visited = new Set([bestRoot.typeid]);
|
|
const queue = [bestRoot];
|
|
|
|
while (queue.length > 0) {
|
|
const current = queue.shift();
|
|
for (const edge of adjacency.get(current.typeid) || []) {
|
|
if (!visited.has(edge.neighbor.typeid)) {
|
|
visited.add(edge.neighbor.typeid);
|
|
const reverseEdge = adjacency.get(edge.neighbor.typeid).find(e => e.neighbor.typeid === current.typeid);
|
|
switchUplinks.set(edge.neighbor.typeid, {
|
|
localPort: edge.remotePort,
|
|
remotePort: edge.localPort,
|
|
parentName: getLabel(current),
|
|
speed: reverseEdge?.localSpeed || 0,
|
|
errors: reverseEdge?.localErrors || null,
|
|
rates: reverseEdge?.localRates || null
|
|
});
|
|
queue.push(edge.neighbor);
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
const container = document.getElementById('container');
|
|
container.innerHTML = '';
|
|
|
|
locationTree.forEach(loc => {
|
|
const el = renderLocation(loc, assignedNodes, true, switchConnections, switchUplinks, danteNodes, artnetNodes, errorNodeIds, unreachableNodeIds);
|
|
if (el) container.appendChild(el);
|
|
});
|
|
|
|
if (unassignedNodes.length > 0) {
|
|
const unassignedLoc = document.createElement('div');
|
|
unassignedLoc.className = 'location top-level';
|
|
|
|
const nameEl = document.createElement('div');
|
|
nameEl.className = 'location-name';
|
|
nameEl.textContent = 'Unassigned';
|
|
unassignedLoc.appendChild(nameEl);
|
|
|
|
const switches = unassignedNodes.filter(n => isSwitch(n));
|
|
const nonSwitches = unassignedNodes.filter(n => !isSwitch(n));
|
|
|
|
if (switches.length > 0) {
|
|
const switchRow = document.createElement('div');
|
|
switchRow.className = 'node-row';
|
|
switches.forEach(node => {
|
|
const uplink = switchUplinks.get(node.typeid);
|
|
const danteInfo = danteNodes.get(node.typeid);
|
|
const artnetInfo = artnetNodes.get(node.typeid);
|
|
const hasError = errorNodeIds.has(node.typeid);
|
|
const isUnreachable = unreachableNodeIds.has(node.typeid);
|
|
switchRow.appendChild(createNodeElement(node, null, null, uplink, danteInfo, artnetInfo, hasError, isUnreachable));
|
|
});
|
|
unassignedLoc.appendChild(switchRow);
|
|
}
|
|
|
|
if (nonSwitches.length > 0) {
|
|
const nodeRow = document.createElement('div');
|
|
nodeRow.className = 'node-row';
|
|
nonSwitches.forEach(node => {
|
|
const conn = switchConnections.get(node.typeid);
|
|
const danteInfo = danteNodes.get(node.typeid);
|
|
const artnetInfo = artnetNodes.get(node.typeid);
|
|
const hasError = errorNodeIds.has(node.typeid);
|
|
const isUnreachable = unreachableNodeIds.has(node.typeid);
|
|
nodeRow.appendChild(createNodeElement(node, conn, null, null, danteInfo, artnetInfo, hasError, isUnreachable));
|
|
});
|
|
unassignedLoc.appendChild(nodeRow);
|
|
}
|
|
|
|
container.appendChild(unassignedLoc);
|
|
}
|
|
|
|
updateErrorPanel();
|
|
updateBroadcastStats(data.broadcast_stats);
|
|
}
|
|
|
|
connectSSE();
|
|
|
|
function setMode(mode) {
|
|
document.body.classList.remove('dante-mode', 'artnet-mode');
|
|
document.getElementById('mode-network').classList.remove('active');
|
|
document.getElementById('mode-dante').classList.remove('active');
|
|
document.getElementById('mode-artnet').classList.remove('active');
|
|
|
|
if (mode === 'dante') {
|
|
document.body.classList.add('dante-mode');
|
|
document.getElementById('mode-dante').classList.add('active');
|
|
window.location.hash = 'dante';
|
|
} else if (mode === 'artnet') {
|
|
document.body.classList.add('artnet-mode');
|
|
document.getElementById('mode-artnet').classList.add('active');
|
|
window.location.hash = 'artnet';
|
|
} else {
|
|
document.getElementById('mode-network').classList.add('active');
|
|
window.location.hash = '';
|
|
}
|
|
}
|
|
|
|
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('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';
|
|
}
|
|
});
|
|
|
|
if (window.location.hash === '#dante') {
|
|
setMode('dante');
|
|
} else if (window.location.hash === '#artnet') {
|
|
setMode('artnet');
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|