Topology: a live page drawn from the real gear
Lint / JS (eslint) (push) Successful in 21s
Lint / Python (flake8) (push) Successful in 1m20s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m0s
Test / Python Tests (pytest) (push) Successful in 1m48s
Lint / Python (flake8) (pull_request) Successful in 1m44s
Lint / JS (eslint) (pull_request) Successful in 30s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 1m42s
Test / Python Tests (pytest) (pull_request) Successful in 1m16s

The dashboard's hand-drawn diagram was out of date. /topology replaces it
with the network as the switches report it, every device drawn to scale
from its faceplate or cutsheet front, and one cable per physical link
leaving the port it is plugged into.

- topology.py builds the graph: switch-to-switch cables seen from both ends
  are merged, servers get one cable per NIC, the gateway's internet port
  goes to the modem and on to the internet, unknown neighbours are stubs,
  and gear with no live cable is drawn where it was last seen.
- Ports named after a different device than the one plugged in are flagged.
- Cables show medium, speed, state and traffic; click one for its details,
  hover a device to trace its path to the internet; pan, zoom and filters.
- The monitor now keeps host_macs (MAC to host NIC) in link_stats.
- The old dashboard diagram and its JS and CSS are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-14 19:30:59 -05:00
co-authored by Claude Opus 5.5
parent 390748173c
commit aa7341af06
16 changed files with 1982 additions and 519 deletions
-28
View File
@@ -48,7 +48,6 @@ async function refreshAll() {
const net = netResult.value;
updateHostGrid(net.hosts || {});
updateUnifiTable(net.unifi || []);
updateTopology(net.hosts || {});
} else {
showToast('Network data unavailable', 'warning');
}
@@ -167,26 +166,6 @@ function updateHostGrid(hosts) {
}
}
function updateTopology(hosts) {
document.querySelectorAll('.topo-host').forEach(node => {
const name = node.dataset.host;
const host = hosts[name];
if (!host) return;
node.className = node.className.replace(/topo-v2-status-(up|down|degraded|unknown)/g, '');
node.classList.add(`topo-v2-status-${host.status}`);
const badge = node.querySelector('.topo-badge');
if (badge) {
badge.className = `topo-badge topo-badge-${host.status}`;
badge.textContent = host.status;
}
// Animate the 10G drop-wire red+dashed when host is down
document.querySelectorAll(`.topo-v2-wire-10g[data-host="${CSS.escape(name)}"]`).forEach(wire => {
wire.classList.toggle('wire-down', host.status === 'down');
});
});
}
// ── Device search ─────────────────────────────────────────────────────
function deviceUrl(name) {
return `/device/${encodeURIComponent(name)}`;
@@ -396,13 +375,9 @@ function updateModem(data) {
detailsLink.hidden = false;
}
const rxLabel = document.getElementById('topo-modem-rx');
const topo = document.getElementById('topo-modem');
const title = document.getElementById('modem-title');
if (!data.reachable) {
if (rxLabel) rxLabel.textContent = 'unreachable';
if (topo) topo.classList.add('topo-v2-status-down');
body.innerHTML = `
<div class="modem-error">Modem status unavailable: ${lt.escHtml(data.error || 'unknown error')}
<span class="modem-stat-note">Last attempt ${lt.escHtml(data.updated || '-')}</span></div>
@@ -415,10 +390,7 @@ function updateModem(data) {
const status = data.optics_status || {};
const counters = data.counters || {};
const rates = data.rates || {};
const rx = optics.rx_power ? optics.rx_power.value : null;
if (topo) topo.classList.toggle('topo-v2-status-down', problems.length > 0);
if (rxLabel) rxLabel.textContent = (rx === null || rx === undefined) ? 'Rx -' : `Rx ${rx} dBm`;
if (title) {
title.textContent = [data.model || 'AT&T Gateway', data.firmware && `fw ${data.firmware}`]
.filter(Boolean).join(' · ');
+12 -1
View File
@@ -1,5 +1,5 @@
'use strict';
/* exported renderFaceplate, faceplateScrollLeft, FACEPLATES */
/* exported renderFaceplate, faceplateScrollLeft, faceplatePortAnchor, FACEPLATES */
// Realistic front panels, drawn as SVG from each model's real geometry.
// Coordinates are traced from reference images. Keys match UniFi's model code.
@@ -639,6 +639,17 @@ function faceplateSvg(model, ctx, attrs = '') {
return { svg, marks };
}
// Where a port's cable plugs in, in the faceplate's viewBox units: { x, y, face }, face 'top' for a
// port the right way up and 'bottom' for a flipped one (the lower row). null when there is no such
// port. A model whose one port takes any index (the PDU's management port) answers for every index.
function faceplatePortAnchor(spec, idx) {
const p = spec.ports.find(q => q.idx === idx) || spec.ports.find(q => q.anyPort);
if (!p) return null;
const face = p.flip ? 'bottom' : 'top';
if (p.type === 'sfp') return { x: p.x + p.w / 2, y: p.y + p.h / 2, face };
return { x: p.x, y: p.y, face };
}
// Where an fp-scroll panel should start: its port area
function faceplateScrollLeft(model, frame) {
const spec = FACEPLATES[model];
+181 -269
View File
@@ -60,12 +60,6 @@
--glow-cyan: none;
--glow-xl: none;
}
[data-theme="light"] .topology {
background-image: radial-gradient(circle, rgba(0,100,160,0.07) 1px, transparent 1px);
}
[data-theme="light"] .topo-vc-label {
background: rgba(235,238,242,.88);
}
/* ── Header overlap fix ───────────────────────────────────────────
.lt-container's padding shorthand resets padding-top, defeating
@@ -151,23 +145,6 @@
/* ── Stats grid spacing variant ──────────────────────────────────── */
.lt-stats-grid--mb { margin-bottom: 16px; }
/* ── Topology section collapse toggle ────────────────────────────── */
.topo-collapse-btn {
margin-left: auto;
font-size: .7em;
color: var(--text-muted);
background: transparent;
border: 1px solid var(--border-color);
padding: 2px 8px;
cursor: pointer;
font-family: var(--font);
letter-spacing: .04em;
transition: border-color .15s, color .15s;
}
.topo-collapse-btn:hover { border-color: var(--amber); color: var(--amber); }
.topo-collapsible { overflow: hidden; transition: max-height .25s ease; }
.topo-collapsible.is-collapsed { display: none; }
/* ── Animations used by custom components ─────────────────────────── */
@keyframes pulse-red {
0%,100% { box-shadow: 0 0 0 0 rgba(255,45,85,.5); }
@@ -276,7 +253,7 @@
border: 1px solid;
letter-spacing: .04em;
}
.chip-critical { color: var(--red); border-color: var(--red); text-shadow: var(--glow-red); animation: topo-pulse-down 2s ease-in-out infinite; }
.chip-critical { color: var(--red); border-color: var(--red); text-shadow: var(--glow-red); animation: pulse-down 2s ease-in-out infinite; }
.chip-warning { color: var(--orange); border-color: var(--orange); }
.chip-ok { color: var(--green); border-color: var(--green-muted); text-shadow: var(--glow); }
.status-meta { display: flex; align-items: center; gap: 10px; white-space: nowrap; }
@@ -418,252 +395,12 @@
.target-ifaces{ display: flex; flex-wrap: wrap; gap: 3px; }
.iface-chip { font-family: var(--font); font-size: .65em; background: var(--bg3); border: 1px solid var(--border-color); padding: 1px 5px; color: var(--text-dim); }
/* ── Topology diagram ─────────────────────────────────────────────── */
.topology {
background-color: var(--bg2);
background-image: radial-gradient(circle, rgba(0,212,255,0.07) 1px, transparent 1px);
background-size: 22px 22px;
border: 1px solid var(--border-color);
padding: 20px 16px 16px;
margin-bottom: 16px;
text-align: center;
overflow-x: auto;
}
/* topo-v2 outer wrapper */
.topo-v2 {
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
min-width: 860px;
padding: 20px 24px 24px;
}
.topo-tier {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
.topo-vc {
display: flex;
justify-content: center;
align-items: flex-start;
width: 100%;
position: relative;
height: 40px;
}
.topo-vc-wire {
position: absolute;
left: 50%;
top: 0;
transform: translateX(-50%);
width: 2px;
height: 100%;
background: linear-gradient(to bottom, var(--cyan), var(--green));
opacity: .7;
}
.topo-vc-wire--wan { background: linear-gradient(to bottom, var(--cyan), rgba(0,212,255,.3)); opacity: .7; }
.topo-vc-wire--10g { background: var(--amber); opacity: .6; }
.topo-vc-wire--mgmt { background: var(--border-color); opacity: .5; }
/* Blurred copy of the wire for a soft glow halo */
.topo-vc-wire::before {
content: '';
position: absolute;
inset: 0;
background: inherit;
filter: blur(5px);
opacity: .5;
}
.topo-vc-label {
position: absolute;
left: calc(50% + 7px);
top: 50%;
transform: translateY(-50%);
font-size: .58em;
color: var(--amber);
white-space: nowrap;
letter-spacing: .06em;
font-family: var(--font);
background: rgba(3,5,8,.7);
padding: 1px 4px;
}
.topo-v2-node {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
padding: 8px 16px;
border: 1px solid var(--border-color);
background: var(--bg3);
position: relative;
font-size: .75em;
font-family: var(--font);
min-width: 110px;
text-align: center;
transition: border-color .2s, box-shadow .2s;
overflow: hidden;
}
/* Top highlight strip: color matches node type / status */
.topo-v2-node::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: currentColor;
opacity: .4;
}
.topo-v2-status-up.topo-v2-node::before { background: var(--green); opacity: .65; }
.topo-v2-status-down.topo-v2-node::before { background: var(--red); opacity: .75; }
.topo-v2-status-degraded.topo-v2-node::before { background: var(--orange); opacity: .65; }
.topo-v2-status-unknown.topo-v2-node::before { opacity: .15; }
.topo-v2-icon { font-size: 1.3em; line-height: 1; }
.topo-v2-label { font-weight: bold; letter-spacing: .04em; }
.topo-v2-sub { font-size: .58em; color: var(--text-muted); letter-spacing: .02em; }
.topo-v2-vlan { font-size: .54em; color: var(--cyan); opacity: .75; }
.topo-v2-host--bus { min-width: 80px; max-width: 96px; }
.topo-v2-internet { border-color: var(--cyan); color: var(--cyan); text-shadow: var(--glow-cyan); box-shadow: 0 0 12px rgba(0,212,255,.12); }
.topo-v2-router { border-color: var(--cyan); color: var(--cyan); text-shadow: var(--glow-cyan); box-shadow: 0 0 12px rgba(0,212,255,.14); }
.topo-v2-switch { border-color: var(--amber); color: var(--amber); text-shadow: var(--glow-amber); box-shadow: 0 0 12px rgba(255,179,0,.12); }
.topo-v2-host { border-color: var(--border-color); color: var(--text); cursor: default; }
@keyframes topo-pulse-down {
/* Pulsing red glow for critical chips and down states */
@keyframes pulse-down {
0%,100% { box-shadow: 0 0 6px rgba(255,45,85,.3); }
50% { box-shadow: 0 0 18px rgba(255,45,85,.75), 0 0 30px rgba(255,45,85,.2); }
}
.topo-v2-status-up { border-color: var(--green); box-shadow: 0 0 8px rgba(0,255,136,.2); }
.topo-v2-status-down { border-color: var(--red); animation: topo-pulse-down 2s ease-in-out infinite; }
.topo-v2-status-degraded { border-color: var(--orange); box-shadow: 0 0 8px rgba(255,107,0,.2); }
.topo-v2-status-unknown { border-color: var(--border-color); }
.topo-v2-offrack { border-style: dashed !important; }
.topo-badge { font-size: .68em; padding: 1px 5px; border: 1px solid; letter-spacing: .03em; }
.topo-badge-up { color: var(--green); border-color: var(--green); text-shadow: var(--glow); }
.topo-badge-down { color: var(--red); border-color: var(--red); animation: topo-pulse-down 1.5s ease-in-out infinite; }
.topo-badge-degraded { color: var(--orange); border-color: var(--orange); }
.topo-badge-unknown { color: var(--text-muted); border-color: var(--border-color); }
/* Host wrap + dual-homing wires */
.topo-v2-host-wrap {
display: flex;
flex-direction: column;
align-items: center;
}
.topo-v2-host-wires {
display: flex;
gap: 6px;
height: 28px;
align-items: flex-start;
}
.topo-v2-wire-10g { width: 2px; height: 100%; background: var(--green); opacity: .55; transition: background .3s, opacity .3s; }
.topo-v2-wire-1g { width: 0; height: 100%; border-left: 2px dashed var(--amber); opacity: .45; }
@keyframes wire-dash-anim { to { background-position: 0 -20px; } }
.topo-v2-wire-10g.wire-down {
background: repeating-linear-gradient(to bottom, var(--red) 0 6px, transparent 6px 10px) !important;
background-size: 2px 10px !important;
opacity: .9 !important;
transition: none !important;
animation: wire-dash-anim .7s linear infinite;
}
/* Bus rails */
.topo-bus-section {
width: 100%;
max-width: 860px;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0;
}
.topo-bus-10g {
display: flex;
align-items: center;
position: relative;
height: 20px;
}
.topo-bus-10g-line {
flex: 1;
height: 2px;
background: var(--green);
opacity: .55;
margin: 0 4px;
box-shadow: 0 0 6px rgba(0,255,136,.4);
}
.topo-bus-10g-label {
font-size: .56em;
color: var(--green);
white-space: nowrap;
letter-spacing: .05em;
font-family: var(--font);
opacity: .85;
padding: 0 8px;
}
.topo-bus-1g {
display: flex;
align-items: center;
position: relative;
height: 18px;
}
.topo-bus-1g-line {
flex: 1;
height: 0;
border-top: 2px dashed var(--amber);
opacity: .35;
margin: 0 4px;
}
.topo-bus-1g-label {
font-size: .56em;
color: var(--amber);
white-space: nowrap;
letter-spacing: .05em;
font-family: var(--font);
opacity: .8;
padding: 0 8px;
}
/* Hosts row */
.topo-v2-hosts {
display: flex;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
padding-top: 4px;
width: 100%;
}
/* Topology legend */
.topo-legend {
display: flex;
gap: 18px;
align-items: center;
margin-top: 14px;
padding-top: 10px;
border-top: 1px solid rgba(255,107,0,.12);
flex-wrap: wrap;
justify-content: center;
}
.topo-legend-item {
display: flex;
align-items: center;
gap: 5px;
font-size: .58em;
color: var(--text-muted);
font-family: var(--font);
}
.topo-legend-item--offrack {
border: 1px dashed var(--border-color);
padding: 1px 5px;
}
.topo-legend-line-10g { width: 24px; height: 2px; background: var(--green); display: inline-block; box-shadow: 0 0 4px rgba(0,255,136,.5); }
.topo-legend-line-1g { width: 24px; height: 0; border-top: 2px dashed var(--amber); display: inline-block; }
.topo-legend-line-wan { width: 24px; height: 2px; background: linear-gradient(to right, var(--cyan), var(--green)); display: inline-block; }
/* ── Link debug page ──────────────────────────────────────────────── */
.link-host-list { display: flex; flex-direction: column; gap: 18px; }
.link-host-panel {
@@ -1067,7 +804,7 @@
.lt-stat-card--alert {
border-color: var(--red) !important;
box-shadow: 0 0 8px rgba(255,45,85,.25) !important;
animation: topo-pulse-down 2s ease-in-out infinite;
animation: pulse-down 2s ease-in-out infinite;
}
.lt-stat-card--alert::before { background: var(--red); box-shadow: var(--glow-red); }
@@ -1077,7 +814,6 @@
/* ── Responsive ───────────────────────────────────────────────────── */
@media (max-width: 768px) {
.host-grid { grid-template-columns: 1fr; }
.topology { display: none; }
.form-row { flex-direction: column; }
.status-bar { flex-direction: column; align-items: flex-start; }
.link-ifaces-grid { grid-template-columns: 1fr; }
@@ -1150,7 +886,6 @@ a.device-link:hover { color: var(--text-accent); border-bottom-color: current
.device-row-sub { font-size: .72em; color: var(--text-muted); }
.device-row-toggle { background: none; border: 0; color: inherit; cursor: pointer; font: inherit; padding: 2px 6px; }
.device-row-detail > td { background: var(--bg-tertiary); padding: 10px 14px; }
.topo-host[data-host] { cursor: pointer; }
/* WAN outage list in the modem panel */
.modem-outages { padding: 0 12px 8px; }
@@ -1678,3 +1413,180 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; }
.device-row--unused td { opacity: .6; }
.device-row--unused td:last-child { opacity: 1; }
.lt-btn-xs { padding: 1px 6px; font-size: .68rem; margin-left: 6px; }
/* ── Topology page ─────────────────────────────────────────────────── */
.tp-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; margin: 8px 0 10px; }
.tp-filters { display: flex; flex-wrap: wrap; gap: 6px 14px; }
.tp-filter { display: inline-flex; align-items: center; gap: 6px; font-size: .78em; color: var(--text-dim); cursor: pointer; }
.tp-zoom { display: inline-flex; gap: 4px; }
.tp-updated { margin-left: auto; font-size: .72em; color: var(--text-muted); }
.tp-warnings { border: 1px solid var(--amber); color: var(--amber); background: var(--amber-dim); padding: 8px 12px; margin-bottom: 10px; font-size: .8em; }
.tp-canvas {
position: relative; overflow: hidden; touch-action: none; cursor: grab;
height: clamp(420px, calc(100vh - 300px), 1400px);
border: 1px solid var(--border-color);
background-color: var(--bg2);
background-image: radial-gradient(circle, rgba(0,212,255,.07) 1px, transparent 1px);
background-size: 22px 22px;
}
[data-theme="light"] .tp-canvas { background-image: radial-gradient(circle, rgba(0,100,160,.08) 1px, transparent 1px); }
.tp-canvas:focus-visible { outline: 2px solid var(--amber); outline-offset: 2px; }
.tp-canvas.tp-dragging { cursor: grabbing; }
.tp-svg { display: block; width: 100%; height: 100%; user-select: none; }
.tp-loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
.tp-loading[hidden] { display: none; }
/* Devices */
.tp-node-link { cursor: pointer; outline: none; }
.tp-node-hit { fill: transparent; stroke: transparent; stroke-width: 4; }
.tp-node-link:hover .tp-node-hit, .tp-node-link:focus-visible .tp-node-hit { stroke: var(--amber); fill: rgba(255,179,0,.04); }
.tp-name { font-family: var(--font); font-size: 40px; font-weight: 700; fill: var(--text); letter-spacing: .02em;
paint-order: stroke; stroke: var(--bg2); stroke-width: 10px; stroke-linejoin: round; }
.tp-sub { font-family: var(--font); font-size: 28px; fill: var(--text-muted);
paint-order: stroke; stroke: var(--bg2); stroke-width: 8px; stroke-linejoin: round; }
.tp-dot { stroke: rgba(0,0,0,.35); stroke-width: 2; }
.tp-dot-up { fill: #2fd36b; }
.tp-dot-degraded { fill: #ffb300; }
.tp-dot-down { fill: #e0312b; }
.tp-dot-unknown { fill: #6b737d; }
.tp-ring { fill: none; stroke-width: 7; }
.tp-ring-critical { stroke: #e0312b; }
.tp-ring-warning { stroke: #ffb300; }
.tp-ring-info { stroke: #34a8ff; }
.tp-badge circle { stroke: var(--bg2); stroke-width: 5; }
.tp-badge text { font-family: var(--font); font-size: 34px; font-weight: 700; fill: #fff; }
.tp-badge-critical circle { fill: #e0312b; }
.tp-badge-warning circle { fill: #c98a00; }
.tp-badge-info circle { fill: #1f7ad6; }
.tp-kind-stub .tp-name { font-size: 32px; font-weight: 400; fill: var(--text-dim); }
.tp-kind-stub .tp-sub { font-size: 24px; }
.tp-cloud { fill: #dfe7ef; stroke: #8fa3b5; stroke-width: 4; }
.tp-cloud-text { font-family: var(--font); font-size: 44px; font-weight: 700; fill: #34495e; letter-spacing: .12em; }
.tp-st-down .tp-cloud { fill: #f3d6d4; stroke: #e0312b; }
.tp-modem-body { fill: #16181b; stroke: #050506; stroke-width: 3; }
.tp-modem-edge { fill: none; stroke: #2c3035; stroke-width: 3; }
.tp-modem-globe { fill: #1f6fd1; }
.tp-modem-globe-line { fill: none; stroke: #9cc6ff; stroke-width: 3; }
.tp-modem-model { font-family: var(--font); font-size: 22px; fill: #8b929b; }
.tp-led { stroke: #000; stroke-width: 2; }
.tp-led-on { fill: #2fd36b; }
.tp-led-off { fill: #3a3f45; }
.tp-led-bad { fill: #e0312b; }
.tp-ap-body { fill: #f2f3f4; stroke: #c3c6ca; stroke-width: 4; }
.tp-ap-ring { fill: none; stroke-width: 10; stroke: #6b737d; }
.tp-ap-up { stroke: #2a8cff; }
.tp-ap-down { stroke: #e0312b; }
.tp-ap-dot { fill: #d7dadd; }
.tp-strip-body { fill: #f0f1f2; stroke: #c3c6ca; stroke-width: 4; }
.tp-strip-outlet rect:first-of-type { fill: #fafafa; stroke: #b9bcc0; stroke-width: 3; }
.tp-strip-slot { fill: #2b2d31; }
.tp-strip-outlet.tp-off rect:first-of-type { fill: #d9dadc; }
.tp-box, .tp-host-box { fill: #22272e; stroke: #3a434e; stroke-width: 3; }
.tp-box-text { font-family: var(--font); font-size: 34px; fill: #8b97a5; }
.tp-host-led { stroke: #000; stroke-width: 2; }
.tp-gen-body { fill: #2a2f36; stroke: #12151a; stroke-width: 3; }
.tp-gen-model { font-family: var(--font); font-size: 30px; fill: #c9d1d9; }
.tp-gen-port { fill: #111418; stroke: #4b5562; stroke-width: 2; }
.tp-gen-up { stroke: #2fd36b; }
.tp-gen-num { font-family: var(--font); font-size: 16px; fill: #8b97a5; }
.tp-stub-body { fill: #3a3f46; stroke: #23272c; stroke-width: 3; }
.tp-stub-jack { fill: #15181c; }
.tp-stub-q { font-family: var(--font); font-size: 30px; font-weight: 700; fill: #9aa3ad; }
.tp-st-down .tp-stub-body { stroke: #e0312b; }
/* Host NIC sockets */
.tp-strip { fill: #1b1f24; stroke: #2f363f; stroke-width: 3; }
.tp-nic-rj45, .tp-nic-sfp { fill: #0b0d10; stroke: #59626e; stroke-width: 3; }
.tp-nic-sfp-in { fill: #23272d; }
.tp-nic-name { font-family: var(--font); font-size: 18px; fill: #aab4bf; }
.tp-nic-led { fill: #3a3f45; }
.tp-nic-up .tp-nic-led { fill: #2fd36b; }
.tp-nic-up .tp-nic-rj45, .tp-nic-up .tp-nic-sfp { stroke: #8f9aa6; }
.tp-nic-down .tp-nic-led { fill: #e0312b; }
.tp-nic-down .tp-nic-rj45, .tp-nic-down .tp-nic-sfp { stroke: #e0312b; }
.tp-nic-unused { opacity: .45; }
.tp-nic-unused .tp-nic-rj45, .tp-nic-unused .tp-nic-sfp { stroke-dasharray: 6 5; }
.tp-nic-alert-critical .tp-nic-name { fill: #ff6b5e; }
.tp-nic-alert-warning .tp-nic-name { fill: #ffb300; }
/* Cables: colours are the real jackets, the same in both themes */
.tp-link { cursor: pointer; outline: none; }
.tp-link path { fill: none; stroke-linecap: round; }
.tp-casing { stroke: #050607; }
.tp-core { stroke: #2f343b; }
.tp-hit { stroke: transparent; stroke-width: 40; }
.tp-s-10g .tp-casing { stroke-width: 17; } .tp-s-10g .tp-core { stroke-width: 11; }
.tp-s-1g .tp-casing { stroke-width: 12; } .tp-s-1g .tp-core { stroke-width: 7; }
.tp-s-100m .tp-casing { stroke-width: 8; } .tp-s-100m .tp-core { stroke-width: 4.5; }
.tp-m-dac .tp-casing { stroke: #8a929c; } .tp-m-dac .tp-core { stroke: #16181c; }
.tp-m-fibre .tp-casing { stroke: #13757c; } .tp-m-fibre .tp-core { stroke: #38c9d3; }
.tp-m-wanfibre .tp-casing { stroke: #8a6a00; } .tp-m-wanfibre .tp-core { stroke: #f2c230; }
.tp-m-sfp .tp-casing { stroke: #23272c; } .tp-m-sfp .tp-core { stroke: #69717c; }
.tp-m-copper .tp-casing { stroke: #173f80; } .tp-m-copper .tp-core { stroke: #3b82f6; }
.tp-down .tp-casing { stroke: transparent; }
.tp-down .tp-core { stroke: #e0312b; stroke-dasharray: 28 18; }
.tp-ghost { opacity: .8; }
.tp-ghost .tp-casing { stroke: transparent; }
.tp-ghost .tp-core { stroke: #e0312b; stroke-dasharray: 2 18; stroke-width: 10; }
.tp-plug { fill: #c9ccd1; stroke: #5d636b; stroke-width: 2; }
.tp-boot { fill: #1e5bbf; stroke: #0d2f66; stroke-width: 2; }
.tp-links-over { pointer-events: none; }
.tp-links-over .tp-warn { pointer-events: auto; }
.tp-flow { stroke: rgba(255,255,255,.4); stroke-width: 3; stroke-dasharray: 3 60; animation: tp-flow linear infinite; pointer-events: none; }
.tp-s-100m .tp-flow { stroke-width: 2; }
@keyframes tp-flow { to { stroke-dashoffset: -63; } }
@media (prefers-reduced-motion: reduce) { .tp-flow { animation: none; display: none; } }
.tp-link:hover .tp-casing, .tp-link:focus-visible .tp-casing, .tp-selected .tp-casing { stroke: var(--amber); }
.tp-tag rect { fill: var(--bg-primary); stroke: var(--border-color); stroke-width: 2; opacity: .92; }
.tp-tag text { font-family: var(--font); font-size: 24px; fill: var(--text); }
.tp-tag-ghost rect { stroke: #e0312b; }
.tp-tag-ghost text { fill: #ff6b5e; }
.tp-rate { display: none; pointer-events: none; }
.tp-near .tp-rate, .tp-hot .tp-rate, .tp-selected .tp-rate { display: inline; }
.tp-warn path { fill: #ffb300; stroke: #5c4100; stroke-width: 3; }
.tp-warn text { font-family: var(--font); font-size: 30px; font-weight: 700; fill: #2b1f00; }
/* Filters and the traced path */
.tp-no-traffic .tp-rate, .tp-no-traffic .tp-flow { display: none !important; }
.tp-only-10g .tp-slow { display: none; }
.tp-alerts-only .tp-node:not(.tp-has-alert) { opacity: .25; }
.tp-alerts-only .tp-link:not(.tp-touches-alert) { opacity: .2; }
.tp-tracing .tp-node:not(.tp-hot), .tp-tracing .tp-link:not(.tp-hot) { opacity: .18; transition: opacity .15s; }
.tp-tracing .tp-link.tp-hot .tp-casing { stroke: var(--amber); }
.tp-far .tp-sub, .tp-far .tp-nic-name { display: none; }
/* Cable panel: a side panel, a bottom sheet on phones */
.tp-panel {
position: absolute; top: 12px; right: 12px; width: min(360px, calc(100% - 24px));
max-height: calc(100% - 24px); overflow: auto; z-index: 2;
background: var(--bg-primary); border: 1px solid var(--border-color); box-shadow: 0 8px 24px rgba(0,0,0,.35);
padding: 10px 14px 14px; cursor: auto; font-size: .8em;
}
.tp-panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
.tp-panel-title { font-size: 1em; margin: 0; letter-spacing: .06em; text-transform: uppercase; color: var(--amber); }
.tp-panel-table { width: 100%; border-collapse: collapse; }
.tp-panel-table th { text-align: left; font-weight: 400; color: var(--text-muted); padding: 4px 10px 4px 0; vertical-align: top; width: 42%; }
.tp-panel-table td { padding: 4px 0; overflow-wrap: anywhere; }
.tp-panel-warn { border: 1px solid var(--amber); background: var(--amber-dim); color: var(--text); padding: 6px 8px; margin: 0 0 8px; }
.tp-bad { color: var(--red); }
.tp-warn-text { color: var(--amber); }
.tp-dim-text { color: var(--text-muted); }
@media (max-width: 640px) {
.tp-panel { top: auto; left: 0; right: 0; bottom: 0; width: auto; max-height: 55%; border-width: 1px 0 0; }
.tp-updated { margin-left: 0; width: 100%; }
.tp-canvas { height: clamp(380px, calc(100vh - 260px), 900px); }
}
.tp-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; margin: 10px 0 4px; font-size: .74em; color: var(--text-dim); }
.tp-key { display: inline-flex; align-items: center; gap: 6px; }
.tp-key svg { width: 34px; height: 12px; }
.tp-key-line { fill: none; stroke-width: 5; stroke-linecap: round; }
.tp-key-line.tp-m-dac { stroke: #2b2f35; }
.tp-key-line.tp-m-fibre { stroke: #38c9d3; }
.tp-key-line.tp-m-wanfibre { stroke: #f2c230; }
.tp-key-line.tp-m-sfp { stroke: #69717c; }
.tp-key-line.tp-m-copper { stroke: #3b82f6; }
.tp-key-down { stroke: #e0312b; stroke-dasharray: 6 4; }
.tp-key-ghost { stroke: #e0312b; stroke-dasharray: 1 5; }
.tp-key-warn { color: #ffb300; font-weight: 700; }
+842
View File
@@ -0,0 +1,842 @@
'use strict';
/* global FACEPLATES, faceplateSvg, faceplatePortAnchor, RACK_FRONTS, SHELF_GEAR, DESK_FACEPLATES, CHASSIS_FACEPLATES, rackFrontSvg */
// Topology page: every device drawn as its real faceplate or chassis, with each cable leaving
// the exact port it is plugged into. Data comes from /api/topology (topology.build_graph).
//
// World units: a 19" panel is 1100 wide (as on the rack page), so everything is to scale.
const esc = s => lt.escHtml(s == null ? '' : String(s));
const PER_MM = 1100 / 482.6;
const ROW_GAP = 330;
const NODE_GAP = 120;
const LABEL_H = 104;
const RAIL_GAP = 520;
const STRIP_H = 104;
const SOCKET_W = 118;
const REFRESH_MS = 30000;
const FILTER_KEY = 'gandalf.topology.filters';
const DEFAULT_FILTERS = { stubs: true, power: true, traffic: true, tenG: false, alerts: false };
const state = {
data: null,
layout: null,
view: { k: 1, tx: 0, ty: 0 },
fitted: false,
filters: loadFilters(),
selected: null,
};
function loadFilters() {
try {
return Object.assign({}, DEFAULT_FILTERS, JSON.parse(localStorage.getItem(FILTER_KEY) || '{}'));
} catch (_) {
return Object.assign({}, DEFAULT_FILTERS);
}
}
function saveFilters() {
try { localStorage.setItem(FILTER_KEY, JSON.stringify(state.filters)); } catch (_) { /* private mode */ }
}
// ── Formatting ──────────────────────────────────────────────────────────
function speedText(mbps) {
if (!mbps) return '';
return mbps >= 1000 ? `${+(mbps / 1000).toFixed(1)}G` : `${mbps}M`;
}
function rateText(bytesPerSec) {
const bits = (bytesPerSec || 0) * 8;
if (bits >= 1e9) return `${(bits / 1e9).toFixed(2)} Gb/s`;
if (bits >= 1e6) return `${(bits / 1e6).toFixed(1)} Mb/s`;
if (bits >= 1e3) return `${(bits / 1e3).toFixed(0)} kb/s`;
return `${bits.toFixed(0)} b/s`;
}
const MEDIUM_TEXT = { dac: 'DAC (twinax)', fibre: 'Fibre', sfp: 'SFP+ module', copper: 'Copper' };
function deviceHref(name) {
return `/device/${encodeURIComponent(name)}`;
}
function nodeHref(node) {
if (node.kind === 'stub') return `${deviceHref(node.switch)}#port-${encodeURIComponent(node.port_idx)}`;
if (node.kind === 'internet') return '';
return deviceHref(node.name);
}
function speedClass(mbps) {
if (mbps >= 10000) return 'tp-s-10g';
if (mbps >= 1000) return 'tp-s-1g';
return 'tp-s-100m';
}
function mediumClass(link) {
if (link.wan && link.medium === 'fibre') return 'tp-m-wanfibre';
return `tp-m-${link.medium || 'copper'}`;
}
// ── Device drawings ─────────────────────────────────────────────────────
// The shared drawings size themselves to their page with CSS (width: 100%), which would override
// the width and height given here, so they are drawn under a class of our own.
function nested(svg) {
return svg.replace(/^(\s*<svg class=")(fp-svg|rack-svg rack-front)"/, '$1tp-inner"');
}
// Each returns { w, h, svg: markup at (0,0), anchor(end) -> {x, y, face} | null } in local units.
function portMapOf(node) {
const map = {};
Object.entries(node.ports || {}).forEach(([idx, p]) => {
map[idx] = Object.assign({ port_idx: Number(idx), lldp: { system_name: p.neighbour } }, p);
});
return map;
}
function portTitle(port, idx, role) {
const parts = [port && port.name && port.name !== `Port ${idx}` ? `Port ${idx} "${port.name}"` : `Port ${idx}`];
if (role) parts.push(role);
if (port) {
parts.push(port.up ? (speedText(port.speed_mbps) || 'up') : 'down');
if (port.neighbour) parts.push(`to ${port.neighbour}`);
}
return esc(parts.join(' · '));
}
function faceplateDrawing(node, model, width, opts = {}) {
const spec = FACEPLATES[model];
const box = (opts.rack && spec.rackView) || [0, 0, spec.width, spec.height];
const k = width / box[2];
const h = box[3] * k;
// A rack view (the UPS's front without its rear) is a viewBox onto part of the drawing
const svg = faceplateSvg(model, {
swName: node.name, portMap: portMapOf(node), outlets: node.outlets || [], online: node.status === 'up',
rack: !!opts.rack, titleFor: portTitle,
}, `x="${opts.offsetX || 0}" y="0" width="${width}" height="${h}"`).svg;
const drawn = nested(svg);
return {
w: width, h, svg: drawn,
anchor(end) {
if (end.port_idx == null) return null;
const a = faceplatePortAnchor(spec, end.port_idx);
return a && { x: (opts.offsetX || 0) + (a.x - box[0]) * k, y: (a.y - box[1]) * k, face: a.face };
},
};
}
function deskFaceplate(node) {
const desk = DESK_FACEPLATES[node.model];
const spec = FACEPLATES[node.model];
const k = (desk.mm * PER_MM) / (desk.body[1] - desk.body[0]);
return faceplateDrawing(node, node.model, spec.width * k);
}
function genericSwitch(node) {
const ports = Object.keys(node.ports || {}).map(Number).sort((a, b) => a - b);
const w = 1100, h = 110;
const pitch = Math.min(60, 900 / Math.max(ports.length, 1));
const x0 = w - 60 - pitch * ports.length;
const pos = {};
const jacks = ports.map((idx, i) => {
const p = node.ports[idx];
const x = x0 + i * pitch;
pos[idx] = x + pitch / 2;
return `<g><title>${portTitle(p, idx)}</title><rect class="tp-gen-port${p.up ? ' tp-gen-up' : ''}" x="${x + 4}" y="38" width="${pitch - 8}" height="34" rx="3"/>
<text class="tp-gen-num" x="${x + pitch / 2}" y="92" text-anchor="middle">${idx}</text></g>`;
}).join('');
return {
w, h,
svg: `<rect class="tp-gen-body" width="${w}" height="${h}" rx="6"/>
<text class="tp-gen-model" x="40" y="62">${esc(node.model || 'switch')}</text>${jacks}`,
anchor: end => (end.port_idx != null && pos[end.port_idx] != null ? { x: pos[end.port_idx], y: 55, face: 'top' } : null),
};
}
function internetDrawing() {
const w = 560, h = 250;
return {
w, h,
svg: `<path class="tp-cloud" d="M120 230 C40 230 20 150 90 128 C80 60 170 30 220 74 C250 14 370 10 400 84 C470 60 540 110 500 168 C550 200 520 236 450 230 Z"/>
<text class="tp-cloud-text" x="280" y="168" text-anchor="middle">INTERNET</text>`,
anchor: () => ({ x: w / 2, y: h - 22, face: 'bottom' }),
};
}
function modemDrawing(node) {
// An upright fibre gateway seen from the front, its status lights down the face
const w = 170, h = 520;
const leds = ['Power', 'Broadband', 'Service', 'Ethernet', 'Wi-Fi'].map((name, i) => {
const cls = node.status === 'up' ? 'tp-led-on' : (i === 0 ? 'tp-led-on' : (node.status === 'down' ? 'tp-led-bad' : 'tp-led-off'));
return `<circle class="tp-led ${cls}" cx="${w / 2}" cy="${230 + i * 46}" r="7"><title>${name}</title></circle>`;
}).join('');
return {
w, h,
svg: `<rect class="tp-modem-body" width="${w}" height="${h}" rx="26"/>
<rect class="tp-modem-edge" x="8" y="8" width="${w - 16}" height="${h - 16}" rx="20"/>
<circle class="tp-modem-globe" cx="${w / 2}" cy="110" r="38"/>
<path class="tp-modem-globe-line" d="M${w / 2 - 38} 110 h76 M${w / 2} 72 v76 M${w / 2 - 30} 90 h60 M${w / 2 - 30} 130 h60"/>
${leds}
<text class="tp-modem-model" x="${w / 2}" y="${h - 34}" text-anchor="middle">${esc((node.model || '').split('-')[0])}</text>`,
// Fibre comes in at the top, the LAN cable leaves at the bottom
anchor: end => (end.port_name === 'WAN' ? { x: w / 2, y: h - 8, face: 'bottom' } : { x: w / 2, y: 10, face: 'top' }),
};
}
function apDrawing(node) {
const d = 160 * PER_MM;
const r = d / 2;
return {
w: d, h: d,
svg: `<circle class="tp-ap-body" cx="${r}" cy="${r}" r="${r - 2}"/>
<circle class="tp-ap-ring tp-ap-${esc(node.status)}" cx="${r}" cy="${r}" r="${r * 0.42}"/>
<circle class="tp-ap-dot" cx="${r}" cy="${r}" r="${r * 0.12}"/>`,
anchor: () => ({ x: r, y: 6, face: 'top' }),
};
}
function stripDrawing(node) {
const w = 700, h = 140;
const outlets = node.outlets || [];
const n = Math.max(outlets.length, 6);
const sockets = Array.from({ length: n }, (_, i) => {
const o = outlets[i];
const off = o && o.on === false;
const title = o ? `${o.name || `Outlet ${i + 1}`} · ${off ? 'off' : 'on'}${o.power_w != null ? ` · ${o.power_w.toFixed(1)} W` : ''}` : `Outlet ${i + 1}`;
return `<g class="tp-strip-outlet${off ? ' tp-off' : ''}"><title>${esc(title)}</title>
<rect x="${70 + i * 95}" y="34" width="72" height="72" rx="14"/>
<rect class="tp-strip-slot" x="${88 + i * 95}" y="56" width="6" height="22" rx="2"/>
<rect class="tp-strip-slot" x="${118 + i * 95}" y="56" width="6" height="22" rx="2"/></g>`;
}).join('');
return {
w: Math.max(w, 90 + n * 95), h,
svg: `<rect class="tp-strip-body" width="${Math.max(w, 90 + n * 95)}" height="${h}" rx="30"/>${sockets}`,
anchor: () => null,
};
}
function boxDrawing(node, text) {
const w = 460, h = 150;
return {
w, h,
svg: `<rect class="tp-box" width="${w}" height="${h}" rx="10"/>
<text class="tp-box-text" x="${w / 2}" y="${h / 2 + 12}" text-anchor="middle">${esc(text)}</text>`,
anchor: () => null,
};
}
function stubDrawing() {
const w = 160, h = 100;
return {
w, h,
svg: `<rect class="tp-stub-body" x="20" y="8" width="120" height="84" rx="10"/>
<path class="tp-stub-jack" d="M48 34 h20 v-8 h24 v8 h20 v40 h-64 Z"/>
<text class="tp-stub-q" x="80" y="64" text-anchor="middle">?</text>`,
anchor: () => ({ x: w / 2, y: 10, face: 'top' }),
};
}
// A host: its drawn front (from the cutsheet's Front drawing) under a strip of its NIC sockets,
// one per physical interface, so each cable lands on the NIC it is plugged into.
function hostDrawing(node, linksByIface) {
let front;
const ctx = { name: node.name, status: node.status, interfaces: node.interfaces || [] };
if (RACK_FRONTS[node.chassis]) {
const spec = RACK_FRONTS[node.chassis];
front = {
w: spec.width, h: spec.height,
svg: nested(rackFrontSvg(node.chassis, ctx)).replace('<svg ', `<svg width="${spec.width}" height="${spec.height}" `),
};
} else if (SHELF_GEAR[node.chassis]) {
const gear = SHELF_GEAR[node.chassis];
const w = gear.mm * PER_MM;
const h = gear.height * (w / gear.width);
front = {
w, h,
svg: `<svg width="${w}" height="${h}" viewBox="0 0 ${gear.width} ${gear.height}"><title>${esc(`${node.name} · ${gear.label}`)}</title>${gear.draw(ctx)}</svg>`,
};
} else if (CHASSIS_FACEPLATES[node.chassis]) {
front = faceplateDrawing(node, CHASSIS_FACEPLATES[node.chassis], 1100);
} else {
front = {
w: 560, h: 150,
svg: `<rect class="tp-host-box" width="560" height="150" rx="8"/>
<circle class="tp-host-led tp-led-${node.status === 'up' ? 'on' : (node.status === 'down' ? 'bad' : 'off')}" cx="520" cy="75" r="12"/>
<text class="tp-box-text" x="250" y="88" text-anchor="middle">server</text>
<title>${esc(node.name)}: no front drawing on the cutsheet</title>`,
};
}
const nics = (node.interfaces || []).slice();
Object.keys(linksByIface).forEach(iface => {
if (iface && !nics.some(n => n.name === iface)) nics.push({ name: iface, up: true, unused: false, alert: '' });
});
const stripW = Math.max(nics.length * SOCKET_W + 24, 200);
const w = Math.max(front.w, stripW);
const sx = (w - stripW) / 2;
const fx = (w - front.w) / 2;
const pos = {};
const sockets = nics.map((nic, i) => {
const link = linksByIface[nic.name];
const cx = sx + 12 + i * SOCKET_W + SOCKET_W / 2;
pos[nic.name] = cx;
const sfp = link && ['dac', 'fibre', 'sfp'].includes(link.medium);
let st = nic.unused ? 'unused' : (nic.up ? 'up' : 'down');
if (link && !link.up) st = 'down';
const title = `${nic.name} · ${nic.unused ? 'marked unused' : (nic.up ? 'up' : 'down')}${link ? ` · ${speedText(link.speed_mbps)} ${MEDIUM_TEXT[link.medium] || ''}` : ' · no cable seen'}`;
const shape = sfp
? `<rect class="tp-nic-sfp" x="${cx - 30}" y="14" width="60" height="34" rx="3"/><rect class="tp-nic-sfp-in" x="${cx - 22}" y="20" width="44" height="22" rx="2"/>`
: `<path class="tp-nic-rj45" d="M${cx - 28} 20 h16 v-8 h24 v8 h16 v32 h-56 Z"/>`;
return `<g class="tp-nic tp-nic-${st}${nic.alert ? ` tp-nic-alert-${esc(nic.alert)}` : ''}"><title>${esc(title)}</title>
${shape}<circle class="tp-nic-led" cx="${cx + 22}" cy="60" r="5"/>
<text class="tp-nic-name" x="${cx}" y="88" text-anchor="middle">${esc(nic.name.length > 11 ? `${nic.name.slice(0, 10)}…` : nic.name)}</text></g>`;
}).join('');
return {
w, h: STRIP_H + 14 + front.h,
svg: `<rect class="tp-strip" x="${sx}" y="0" width="${stripW}" height="${STRIP_H}" rx="10"/>${sockets}<g transform="translate(${fx} ${STRIP_H + 14})">${front.svg}</g>`,
anchor: end => (end.iface && pos[end.iface] != null ? { x: pos[end.iface], y: 14, face: 'top' } : null),
};
}
function drawingFor(node, linksByIface) {
const spec = FACEPLATES[node.model];
switch (node.kind) {
case 'internet': return internetDrawing();
case 'modem': return modemDrawing(node);
case 'host': return hostDrawing(node, linksByIface);
case 'stub': return stubDrawing();
case 'ap': return apDrawing(node);
case 'strip': return stripDrawing(node);
default:
if (spec && DESK_FACEPLATES[node.model]) return deskFaceplate(node);
if (spec) return faceplateDrawing(node, node.model, 1100, { rack: node.kind === 'ups' });
if (node.kind === 'switch' || node.kind === 'router') return genericSwitch(node);
return boxDrawing(node, node.model || node.kind);
}
}
// ── Layout ──────────────────────────────────────────────────────────────
function visibleGraph(data, filters) {
const nodes = data.nodes.filter(n => (filters.stubs || n.kind !== 'stub') && (filters.power || !n.power));
const ids = new Set(nodes.map(n => n.id));
const links = data.links.filter(l => ids.has(l.a.node) && ids.has(l.b.node));
return { nodes, links };
}
function rowKey(node) {
if (node.kind === 'host') return 1e6;
if (node.depth == null) return 1e5;
return node.kind === 'stub' ? node.depth + 0.5 : node.depth;
}
function anchorOf(lay, end, asParent) {
const p = lay.pos[end.node];
const d = lay.drawings[end.node];
// Power gear sits in a rail on the right: its cable comes in from the left
if (p.rail) return { x: p.x + p.dx, y: p.y + d.h / 2, face: 'left' };
const a = d.anchor(end) || { x: d.w / 2, y: asParent ? d.h : 0, face: asParent ? 'bottom' : 'top' };
return { x: p.x + p.dx + a.x, y: p.y + a.y, face: a.face };
}
function layout(data, filters) {
const { nodes, links } = visibleGraph(data, filters);
const byId = Object.fromEntries(nodes.map(n => [n.id, n]));
const drawings = {};
const sizes = {};
nodes.forEach(n => {
const byIface = {};
links.forEach(l => [l.a, l.b].forEach(e => { if (e.node === n.id && e.iface) byIface[e.iface] = l; }));
const d = drawingFor(n, byIface);
drawings[n.id] = d;
const labelW = Math.min(Math.max(n.name.length * 25, 120), 900);
sizes[n.id] = { w: Math.max(d.w, labelW), h: d.h + LABEL_H };
});
const lay = { pos: {}, drawings, nodes, links, byId };
const rowsMap = new Map();
nodes.filter(n => !n.power).forEach(n => {
const key = rowKey(n);
if (!rowsMap.has(key)) rowsMap.set(key, []);
rowsMap.get(key).push(n);
});
const rows = [...rowsMap.keys()].sort((a, b) => a - b).map(k => rowsMap.get(k));
let y = 0;
rows.forEach(row => {
const items = row.map(n => {
const xs = links.filter(l => (l.b.node === n.id && lay.pos[l.a.node]) || (l.a.node === n.id && lay.pos[l.b.node]))
.map(l => anchorOf(lay, l.b.node === n.id ? l.a : l.b, true).x);
return { n, w: sizes[n.id].w, bary: xs.length ? xs.reduce((s, v) => s + v, 0) / xs.length : null };
});
let last = 0;
items.forEach(it => { if (it.bary == null) it.bary = last; else last = it.bary; });
items.sort((a, b) => a.bary - b.bary || a.n.name.localeCompare(b.n.name));
let right = -Infinity;
items.forEach(it => {
it.x = Math.max(it.bary - it.w / 2, right + NODE_GAP);
right = it.x + it.w;
});
const shift = items.reduce((s, it) => s + (it.bary - (it.x + it.w / 2)), 0) / items.length;
const rowH = Math.max(...items.map(it => sizes[it.n.id].h));
items.forEach(it => {
lay.pos[it.n.id] = { x: it.x + shift, y, w: it.w, dx: (it.w - drawings[it.n.id].w) / 2 };
});
y += rowH + ROW_GAP;
});
// Power gear: a rail down the right, each unit level with the port that feeds it
const placed = Object.values(lay.pos);
const maxX = placed.length ? Math.max(...placed.map(p => p.x + p.w)) : 0;
const top = placed.length ? Math.min(...placed.map(p => p.y)) : 0;
const feederY = n => {
const l = links.find(k => k.b.node === n.id && lay.pos[k.a.node]);
return l ? anchorOf(lay, l.a, true).y : Infinity;
};
let railY = top;
nodes.filter(n => n.power).map(n => ({ n, f: feederY(n) }))
.sort((a, b) => a.f - b.f || a.n.name.localeCompare(b.n.name)).forEach(({ n, f }) => {
const want = Number.isFinite(f) ? f - drawings[n.id].h / 2 : railY;
const y = Math.max(want, railY);
lay.pos[n.id] = { x: maxX + RAIL_GAP, y, w: sizes[n.id].w, dx: 0, rail: true };
railY = y + sizes[n.id].h + 120;
});
const all = Object.entries(lay.pos);
lay.bounds = {
x0: Math.min(...all.map(([, p]) => p.x)) - 80,
y0: Math.min(...all.map(([, p]) => p.y)) - 80,
x1: Math.max(...all.map(([, p]) => p.x + p.w)) + 80,
y1: Math.max(...all.map(([id, p]) => p.y + sizes[id].h)) + 80,
};
return lay;
}
// ── Rendering ───────────────────────────────────────────────────────────
// A cable leaves each end square to the face it plugs into: up or down from a faceplate, or
// sideways into the power rail
function cablePath(A, B) {
const dir = (P, Q) => {
if (P.face === 'left') return [-1, 0];
if (P.face === 'right') return [1, 0];
return [0, Q.y >= P.y ? 1 : -1];
};
const c = Math.min(900, Math.max(90, Math.hypot(B.x - A.x, B.y - A.y) * 0.4));
const [ax, ay] = dir(A, B);
const [bx, by] = dir(B, A);
const c1 = { x: A.x + ax * c, y: A.y + ay * c };
const c2 = { x: B.x + bx * c, y: B.y + by * c };
const mid = { x: (A.x + 3 * c1.x + 3 * c2.x + B.x) / 8, y: (A.y + 3 * c1.y + 3 * c2.y + B.y) / 8 };
return { d: `M${A.x} ${A.y} C${c1.x} ${c1.y} ${c2.x} ${c2.y} ${B.x} ${B.y}`, mid };
}
function linkTitle(link, byId) {
const end = e => {
const n = byId[e.node];
const name = n ? n.name : e.node;
if (e.port_idx != null) return `${name} port ${e.port_idx}${e.port_name && e.port_name !== `Port ${e.port_idx}` ? ` "${e.port_name}"` : ''}`;
return e.iface ? `${name} ${e.iface}` : name;
};
const bits = [`${end(link.a)} to ${end(link.b)}`, speedText(link.speed_mbps), MEDIUM_TEXT[link.medium] || ''];
if (link.ghost) bits.push(`last seen ${link.seen || ''}`);
else if (!link.up) bits.push('down');
return bits.filter(Boolean).join(' · ');
}
function renderLink(link, lay) {
const A = anchorOf(lay, link.a, true);
const B = anchorOf(lay, link.b, false);
const { d, mid } = cablePath(A, B);
const cls = ['tp-link', mediumClass(link), speedClass(link.speed_mbps)];
if (link.ghost) cls.push('tp-ghost');
else if (!link.up) cls.push('tp-down');
if (link.wan) cls.push('tp-wan');
if (!link.wan && link.speed_mbps < 10000) cls.push('tp-slow');
const busy = (link.a_to_b_bps || 0) + (link.b_to_a_bps || 0);
const alerting = [link.a.node, link.b.node].some(id => (lay.byId[id] || {}).alert_count);
if (alerting) cls.push('tp-touches-alert');
const flowDur = Math.max(0.6, 6 - Math.log10(Math.max(busy, 1)) * 0.8).toFixed(2);
const flow = link.up && !link.ghost && busy > 1000
? `<path class="tp-flow" d="${d}" style="animation-duration:${flowDur}s"/>` : '';
const label = link.ghost
? `<g class="tp-tag tp-tag-ghost" transform="translate(${mid.x} ${mid.y})"><rect x="-110" y="-26" width="220" height="44" rx="22"/><text y="6" text-anchor="middle">last seen</text></g>`
: (link.up ? `<g class="tp-tag tp-rate" transform="translate(${mid.x} ${mid.y})"><rect x="-190" y="-26" width="380" height="44" rx="22"/>
<text y="6" text-anchor="middle">↓ ${esc(rateText(link.a_to_b_bps))} ↑ ${esc(rateText(link.b_to_a_bps))}</text></g>` : '');
let warn = '';
if (link.name_mismatch) {
const at = link.name_mismatch.end === 'b' ? B : A;
const m = link.name_mismatch;
const text = `Port name says ${m.named}, but ${m.actual} is plugged in`;
warn = `<g class="tp-warn" transform="translate(${at.x + 34} ${at.y + (at.face === 'bottom' ? 40 : -40)})"><title>${esc(text)}</title>
<path d="M0 -26 L26 20 H-26 Z"/><text y="14" text-anchor="middle">!</text></g>`;
}
const plugs = ['dac', 'fibre', 'sfp'].includes(link.medium)
? `<circle class="tp-plug" cx="${A.x}" cy="${A.y}" r="9"/><circle class="tp-plug" cx="${B.x}" cy="${B.y}" r="9"/>`
: `<rect class="tp-boot" x="${A.x - 9}" y="${A.y - 9}" width="18" height="18" rx="3"/><rect class="tp-boot" x="${B.x - 9}" y="${B.y - 9}" width="18" height="18" rx="3"/>`;
const attrs = `class="${cls.join(' ')}" data-link="${esc(link.id)}"`;
return {
// The cable runs behind the devices; its plugs, traffic and warning sit on top
under: `<g ${attrs} tabindex="0" role="button" aria-label="${esc(linkTitle(link, lay.byId))}">
<title>${esc(linkTitle(link, lay.byId))}</title>
<path class="tp-casing" d="${d}"/><path class="tp-core" d="${d}"/>${flow}<path class="tp-hit" d="${d}"/>
</g>`,
over: `<g ${attrs} aria-hidden="true">${plugs}${label}${warn}</g>`,
};
}
function renderNode(node, lay) {
const p = lay.pos[node.id];
const d = lay.drawings[node.id];
const href = nodeHref(node);
const sub = node.kind === 'stub'
? [node.ip ? '' : (node.mac || ''), `${node.switch} port ${node.port_idx}`].filter(Boolean).join(' · ')
: [node.kind === 'host' ? node.status : (node.model || node.kind), node.kind !== 'host' && node.status !== 'up' ? node.status : ''].filter(Boolean).join(' · ');
const ring = node.severity
? `<rect class="tp-ring tp-ring-${esc(node.severity)}" x="${p.dx - 18}" y="-18" width="${d.w + 36}" height="${d.h + 36}" rx="16"/>
<g class="tp-badge tp-badge-${esc(node.severity)}" transform="translate(${p.dx + d.w + 10} -14)"><circle r="30"/><text y="11" text-anchor="middle">${node.alert_count}</text></g>`
: '';
const alertText = (node.alerts || []).map(a => `${a.severity}: ${a.type.replace(/_/g, ' ')}${a.detail ? ` ${a.detail}` : ''}`).join('; ');
const label = `${node.name}${node.kind === 'stub' ? ' (not identified)' : ''} · ${node.status}${alertText ? ` · ${alertText}` : ''}`;
const open = href ? `<a class="tp-node-link" href="${esc(href)}" aria-label="${esc(label)}">` : `<g class="tp-node-link" tabindex="0" aria-label="${esc(label)}">`;
const close = href ? '</a>' : '</g>';
return `<g class="tp-node tp-kind-${esc(node.kind)} tp-st-${esc(node.status)}${node.alert_count ? ' tp-has-alert' : ''}" data-node="${esc(node.id)}" transform="translate(${p.x} ${p.y})">
${open}<title>${esc(label)}</title>
<rect class="tp-node-hit" x="${p.dx - 10}" y="-10" width="${d.w + 20}" height="${d.h + LABEL_H}" rx="12"/>
${ring}
<g transform="translate(${p.dx} 0)">${d.svg}</g>
${node.kind === 'internet' ? '' : `<circle class="tp-dot tp-dot-${esc(node.status)}" cx="${p.w / 2 - node.name.length * 12 - 34}" cy="${d.h + 46}" r="10"/>`}
${node.kind === 'internet' ? '' : `<text class="tp-name" x="${p.w / 2}" y="${d.h + 58}" text-anchor="middle">${esc(node.name)}</text>`}
<text class="tp-sub" x="${p.w / 2}" y="${d.h + 96}" text-anchor="middle">${esc(sub)}</text>
${close}
</g>`;
}
function applyView() {
const { k, tx, ty } = state.view;
document.getElementById('tp-world').setAttribute('transform', `translate(${tx} ${ty}) scale(${k})`);
const canvas = document.getElementById('tp-canvas');
canvas.classList.toggle('tp-far', k < 0.16);
canvas.classList.toggle('tp-near', k >= 0.6);
}
function applyFilterClasses() {
const canvas = document.getElementById('tp-canvas');
canvas.classList.toggle('tp-no-traffic', !state.filters.traffic);
canvas.classList.toggle('tp-only-10g', state.filters.tenG);
canvas.classList.toggle('tp-alerts-only', state.filters.alerts);
document.querySelectorAll('[data-filter]').forEach(cb => { cb.checked = !!state.filters[cb.dataset.filter]; });
}
function render() {
const data = state.data;
if (!data) return;
state.layout = layout(data, state.filters);
const lay = state.layout;
const world = document.getElementById('tp-world');
const cables = lay.links.map(l => renderLink(l, lay));
world.innerHTML = `<g class="tp-links">${cables.map(c => c.under).join('')}</g>
<g class="tp-nodes">${lay.nodes.map(n => renderNode(n, lay)).join('')}</g>
<g class="tp-links-over">${cables.map(c => c.over).join('')}</g>`;
document.getElementById('tp-loading').hidden = true;
applyFilterClasses();
if (!state.fitted) {
fit();
state.fitted = true;
} else {
applyView();
}
if (state.selected) {
const link = data.links.find(l => l.id === state.selected);
if (link) showPanel(link);
else hidePanel();
}
}
// ── Cable panel ─────────────────────────────────────────────────────────
function endHtml(end, byId) {
const n = byId[end.node] || { name: end.node, kind: '' };
const href = n.kind && n.kind !== 'internet' ? nodeHref(n) : '';
const name = href ? `<a class="device-link" href="${esc(href)}">${esc(n.name)}</a>` : esc(n.name);
let where = '';
if (end.port_idx != null) {
where = `<a class="device-link" href="${esc(deviceHref(n.kind === 'stub' ? n.switch : n.name))}#port-${esc(end.port_idx)}">port ${esc(end.port_idx)}</a>`;
if (end.port_name && end.port_name !== `Port ${end.port_idx}`) where += ` "${esc(end.port_name)}"`;
} else if (end.iface) {
where = esc(end.iface);
} else if (end.port_name) {
where = esc(end.port_name);
}
return `${name}${where ? ` · ${where}` : ''}`;
}
function showPanel(link) {
const byId = Object.fromEntries(state.data.nodes.map(n => [n.id, n]));
const panel = document.getElementById('tp-panel');
const rows = [];
const row = (k, v) => rows.push(`<tr><th scope="row">${k}</th><td>${v}</td></tr>`);
row('From', endHtml(link.a, byId));
row('To', endHtml(link.b, byId));
row('State', link.ghost ? `<span class="tp-bad">not seen now</span> · last seen ${esc(link.seen || 'unknown')}` : (link.up ? 'up' : '<span class="tp-bad">down</span>'));
if (link.speed_mbps) row('Speed', esc(speedText(link.speed_mbps)));
row('Cable', esc(MEDIUM_TEXT[link.medium] || link.medium));
if (link.sfp_part || link.sfp_vendor) {
row('Module', esc([link.sfp_vendor, link.sfp_part].filter(Boolean).join(' ')) + (link.sfp_serial ? `<br><span class="tp-dim-text">S/N ${esc(link.sfp_serial)}</span>` : ''));
}
if (!link.ghost && link.up) {
row('Down (from the internet side)', esc(rateText(link.a_to_b_bps)));
row('Up', esc(rateText(link.b_to_a_bps)));
if (link.utilisation != null) row('Busiest direction', `${(link.utilisation * 100).toFixed(link.utilisation < 0.01 ? 2 : 1)}% of ${esc(speedText(link.speed_mbps))}`);
row('Errors', link.errors ? `<span class="tp-bad">${esc(link.errors)}/s</span>` : 'none');
row('Drops', link.drops ? `<span class="tp-warn-text">${esc(link.drops)}/s</span>` : 'none');
}
if (link.cable) {
const label = link.cable.label || `cable #${link.cable.id}`;
row('Cutsheet', `<a class="device-link" href="/cutsheet#cable-${esc(link.cable.id)}">${esc(label)}</a>${link.cable.type ? ` · ${esc(link.cable.type)}` : ''}${link.cable.color ? ` · ${esc(link.cable.color)}` : ''}`);
} else if (!link.wan) {
row('Cutsheet', '<span class="tp-dim-text">not documented</span>');
}
const warn = link.name_mismatch
? `<p class="tp-panel-warn">⚠ The port is named after <strong>${esc(link.name_mismatch.named)}</strong>, but <strong>${esc(link.name_mismatch.actual)}</strong> is plugged into it. Rename the port in UniFi, or check the cable.</p>`
: '';
panel.innerHTML = `<div class="tp-panel-head">
<h2 class="tp-panel-title">Cable</h2>
<button type="button" class="lt-modal-close" data-action="close-panel" aria-label="Close cable details">&#x2715;</button>
</div>${warn}<table class="tp-panel-table"><tbody>${rows.join('')}</tbody></table>`;
panel.hidden = false;
state.selected = link.id;
document.querySelectorAll('.tp-link.tp-selected').forEach(el => el.classList.remove('tp-selected'));
document.querySelectorAll(`.tp-link[data-link="${CSS.escape(link.id)}"]`).forEach(el => el.classList.add('tp-selected'));
}
function hidePanel() {
document.getElementById('tp-panel').hidden = true;
state.selected = null;
document.querySelectorAll('.tp-link.tp-selected').forEach(el => el.classList.remove('tp-selected'));
}
// ── Path to the internet ────────────────────────────────────────────────
function upstream(id) {
const links = state.layout.links;
const nodes = new Set([id]);
const hot = new Set();
const queue = [id];
while (queue.length) {
const cur = queue.shift();
links.forEach(l => {
if (l.b.node === cur && !l.ghost) {
hot.add(l.id);
if (!nodes.has(l.a.node)) {
nodes.add(l.a.node);
queue.push(l.a.node);
}
}
});
}
return { nodes, hot };
}
function highlight(id) {
const canvas = document.getElementById('tp-canvas');
document.querySelectorAll('.tp-hot').forEach(el => el.classList.remove('tp-hot'));
if (!id || !state.layout) {
canvas.classList.remove('tp-tracing');
return;
}
const { nodes, hot } = upstream(id);
nodes.forEach(n => document.querySelector(`.tp-node[data-node="${CSS.escape(n)}"]`)?.classList.add('tp-hot'));
hot.forEach(l => document.querySelectorAll(`.tp-link[data-link="${CSS.escape(l)}"]`).forEach(el => el.classList.add('tp-hot')));
canvas.classList.add('tp-tracing');
}
// ── Pan and zoom ────────────────────────────────────────────────────────
const K_MIN = 0.03;
const K_MAX = 3;
function fit() {
if (!state.layout) return;
const canvas = document.getElementById('tp-canvas');
const { x0, y0, x1, y1 } = state.layout.bounds;
const cw = canvas.clientWidth, ch = canvas.clientHeight;
const k = Math.max(K_MIN, Math.min(K_MAX, Math.min(cw / (x1 - x0), ch / (y1 - y0))));
state.view = { k, tx: (cw - (x1 - x0) * k) / 2 - x0 * k, ty: (ch - (y1 - y0) * k) / 2 - y0 * k };
applyView();
}
function zoomAt(factor, px, py) {
const v = state.view;
const k = Math.max(K_MIN, Math.min(K_MAX, v.k * factor));
const wx = (px - v.tx) / v.k, wy = (py - v.ty) / v.k;
state.view = { k, tx: px - wx * k, ty: py - wy * k };
applyView();
}
function zoomCentre(factor) {
const canvas = document.getElementById('tp-canvas');
zoomAt(factor, canvas.clientWidth / 2, canvas.clientHeight / 2);
}
function initPanZoom() {
const canvas = document.getElementById('tp-canvas');
const svg = document.getElementById('tp-svg');
const pointers = new Map();
let drag = null;
let pinch = null;
let suppressClick = false;
const local = e => {
const r = canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
};
svg.addEventListener('pointerdown', e => {
if (e.button !== 0 && e.pointerType === 'mouse') return;
pointers.set(e.pointerId, local(e));
if (pointers.size === 1) {
drag = { start: local(e), view: Object.assign({}, state.view), id: e.pointerId, moved: false };
} else if (pointers.size === 2) {
const [p, q] = [...pointers.values()];
pinch = { dist: Math.hypot(p.x - q.x, p.y - q.y), k: state.view.k };
drag = null;
}
});
svg.addEventListener('pointermove', e => {
if (!pointers.has(e.pointerId)) return;
pointers.set(e.pointerId, local(e));
if (pinch && pointers.size === 2) {
const [p, q] = [...pointers.values()];
const dist = Math.hypot(p.x - q.x, p.y - q.y);
zoomAt((pinch.k * dist / pinch.dist) / state.view.k, (p.x + q.x) / 2, (p.y + q.y) / 2);
suppressClick = true;
return;
}
if (!drag || drag.id !== e.pointerId) return;
const pt = local(e);
const dx = pt.x - drag.start.x, dy = pt.y - drag.start.y;
if (!drag.moved && Math.hypot(dx, dy) < 5) return;
if (!drag.moved) {
drag.moved = true;
svg.setPointerCapture(e.pointerId);
canvas.classList.add('tp-dragging');
}
state.view = { k: drag.view.k, tx: drag.view.tx + dx, ty: drag.view.ty + dy };
applyView();
});
const end = e => {
pointers.delete(e.pointerId);
if (drag && drag.id === e.pointerId && drag.moved) suppressClick = true;
if (pointers.size < 2) pinch = null;
if (!pointers.size) drag = null;
canvas.classList.remove('tp-dragging');
};
svg.addEventListener('pointerup', end);
svg.addEventListener('pointercancel', end);
// A drag that ends over a device is not a click on it
svg.addEventListener('click', e => {
if (suppressClick) {
e.preventDefault();
e.stopPropagation();
suppressClick = false;
}
}, true);
svg.addEventListener('wheel', e => {
e.preventDefault();
const pt = local(e);
zoomAt(Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0015)), pt.x, pt.y);
}, { passive: false });
canvas.addEventListener('keydown', e => {
if (e.target.closest('input, select, textarea, .tp-panel')) return;
if (e.key === '+' || e.key === '=') { zoomCentre(1.25); e.preventDefault(); }
else if (e.key === '-' || e.key === '_') { zoomCentre(0.8); e.preventDefault(); }
else if (e.key === '0') { fit(); e.preventDefault(); }
else if (e.key === 'Escape') hidePanel();
});
}
// ── Events ──────────────────────────────────────────────────────────────
function initEvents() {
const svg = document.getElementById('tp-svg');
svg.addEventListener('click', e => {
const port = e.target.closest('[data-action="select-port"]');
if (port) {
e.preventDefault();
window.location.href = `${deviceHref(port.dataset.switch)}#port-${encodeURIComponent(port.dataset.portIdx)}`;
return;
}
const link = e.target.closest('.tp-link');
if (link) {
const found = state.data.links.find(l => l.id === link.dataset.link);
if (found) showPanel(found);
return;
}
if (!e.target.closest('.tp-node')) hidePanel();
});
svg.addEventListener('keydown', e => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const link = e.target.closest('.tp-link');
if (link) {
e.preventDefault();
const found = state.data.links.find(l => l.id === link.dataset.link);
if (found) showPanel(found);
}
});
const trace = e => {
const node = e.target.closest('.tp-node');
highlight(node ? node.dataset.node : null);
};
svg.addEventListener('mouseover', trace);
svg.addEventListener('focusin', trace);
svg.addEventListener('mouseleave', () => highlight(null));
svg.addEventListener('focusout', e => { if (!svg.contains(e.relatedTarget)) highlight(null); });
document.getElementById('tp-panel').addEventListener('click', e => {
if (e.target.closest('[data-action="close-panel"]')) hidePanel();
});
document.querySelectorAll('[data-filter]').forEach(cb => {
cb.addEventListener('change', () => {
state.filters[cb.dataset.filter] = cb.checked;
saveFilters();
if (cb.dataset.filter === 'stubs' || cb.dataset.filter === 'power') render();
else applyFilterClasses();
});
});
document.querySelectorAll('[data-zoom]').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.dataset.zoom === 'fit') fit();
else zoomCentre(btn.dataset.zoom === 'in' ? 1.25 : 0.8);
});
});
let resizeTimer = null;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(applyView, 150);
});
}
// ── Data ────────────────────────────────────────────────────────────────
async function refresh() {
try {
const data = await lt.api.get('/api/topology');
state.data = data;
// The layout follows from the devices and cables alone, so a redraw with the same ones keeps
// everything where it was; the view (pan and zoom) is kept either way
render();
const warn = document.getElementById('tp-warnings');
warn.hidden = !(data.warnings && data.warnings.length);
warn.innerHTML = (data.warnings || []).map(w => `<div>${esc(w)}</div>`).join('');
document.getElementById('tp-updated').textContent = data.updated ? `Port data from ${data.updated}` : '';
} catch (e) {
document.getElementById('tp-loading').textContent = `Topology unavailable: ${e.message || e}`;
document.getElementById('tp-loading').hidden = false;
}
}
document.addEventListener('DOMContentLoaded', () => {
initPanZoom();
initEvents();
applyFilterClasses();
refresh();
setInterval(() => { if (!document.hidden) refresh(); }, REFRESH_MS);
});