diff --git a/README.md b/README.md
index ef4fd17..ab44251 100644
--- a/README.md
+++ b/README.md
@@ -19,11 +19,13 @@ A Proxmox/Ceph cluster lives or dies by its network, and network problems are sn
## Gallery
-**Dashboard**: alert queue with linked tickets, a live topology, per-host interface status and the UniFi device inventory.
+**Dashboard**: alert queue with linked tickets, per-host interface status and the UniFi device inventory.

-
+**Topology**: every device drawn from its real faceplate or chassis, with each cable leaving the port it is plugged into, styled by medium and speed, with live traffic.
+
+

@@ -105,9 +107,8 @@ Ping-only (no node_exporter): **pbs** (10.10.10.3)
### Dashboard (`/`)
- Device search (see Device pages below)
-- Active alerts first, then the WAN / Fiber panel, hosts, UniFi devices, topology, and recently resolved alerts
+- Active alerts first, then the WAN / Fiber panel, hosts, UniFi devices, and recently resolved alerts
- Real-time host status grid with per-NIC link state (UP / DOWN / degraded)
-- Network topology diagram (Internet → Modem → Gateway → Switches → Hosts)
- WAN / Fiber panel: WAN and PON state, Rx/Tx power, optics temperature, throughput, errors, gateway uptime and last restart, 7-day Rx trend, recent WAN outages
- UniFi device table (switches, APs, gateway)
- Active alerts table with severity, target, consecutive failures, ticket link
@@ -180,6 +181,47 @@ The rack front, drawn from the rack units set on cutsheet items, top first.
claim the same top unit, the one recorded first is drawn. Items without a rack unit are listed below.
- The rack keeps its own dark "hardware" palette in both themes.
+### Topology (`/topology`)
+The whole network as the switches see it now, top down: the internet, the modem, the gateway, then
+each switch by how many hops it is from the gateway, the gear and unknown devices below them, and the
+servers along the bottom. Power gear (PDU, UPS, power strip) sits in a rail on the right.
+- **What needs a look** is listed above the diagram: devices down, degraded or with active alerts,
+ cables down or last seen elsewhere, ports named after the wrong device, and cables with errors.
+ Click one to jump to it. With nothing to report it says all clear. Problem cables are drawn in full
+ colour with a glow and always shown, whatever the filters; healthy cables are drawn quietly.
+- **A card per device**, all the same height so the whole network reads at a glance: name, state
+ (coloured edge and dot), and a picture: UniFi gear shows its real faceplate (the same drawings as the
+ port inspector and rack page), cropped to the ports; servers show the **Front drawing** of their
+ cutsheet item and a socket per physical NIC, so every cable lands on the NIC it is plugged into
+ (NICs marked unused are dimmed). The internet, modem and gateway sit side by side along the top.
+- **Cables run like cable management**: each leaves the card edge right below its port (a short tail
+ shows the port), drops into a tray above the row it goes to, runs along its own lane (10G on the
+ upper lanes), and drops straight into its socket, so cables never lie on top of each other and only cross at right angles. Devices are placed clear of
+ the cables passing down through their row, and each sits under its fastest cable.
+- **One line per physical cable**, leaving the exact port: a switch-to-switch cable is reported by both
+ switches and drawn once. A server with two links (10G and 1G) has two cables. Colour is the cable
+ type (DAC, multimode fibre, the provider's fibre, an SFP+ module that doesn't say which, copper) and
+ thickness is the speed. A cable that is down is dashed red.
+- **Last seen**: a device with no live cable is drawn on the port it was last seen on, dotted red.
+- **Unknown devices**: something plugged into a switch port that Gandalf can't name is a grey stub,
+ labelled with the port name (or its address), linking to that port. By default each switch's unknown
+ devices are one block with one bundled cable ("10 unknown devices"); click it to show them one by
+ one, or pick "one by one" or "hidden" in the toolbar.
+- **Port names**: a ⚠ marks a port named after one device with a different one plugged in (a port
+ called "Large1 Management" with the AP on it). Generic names ("Port 4", "SFP+ 2", "Uplink") and
+ "Downlink to X" with X on the other end never flag.
+- **Alerts**: a device with active alerts (not suppressed) gets a ring and a count in its worst
+ severity colour, and the NIC it is about is marked.
+- Click a device for its page, or a port on a faceplate for that port. Click a cable for its details:
+ both ends, speed, cable type and module, traffic each way and how busy it is, errors and drops, and
+ its cutsheet record. Hovering a device traces its path to the internet.
+- Drag to pan, scroll or pinch to zoom, **Fit** (or `0`) to see everything, `+` and `-` to zoom. Show
+ unknown devices grouped, one by one or not at all. **Server 1G links** (off by default) adds each
+ server's slower second cable, its management link; **Power gear** (off by default) adds the PDU, UPS
+ and power strip. Traffic labels and dimming everything without an alert can be switched too. The
+ filters are remembered per browser. Refreshes every 30 seconds without moving the view.
+- API: `GET /api/topology` returns `{nodes, links, warnings, updated}` (see `topology.py`).
+
### Health check (`/health`)
No login needed. Returns 200 when the database answers and the monitor has completed a check recently,
otherwise 503 with the failing check: `{"status": "degraded", "checks": {"db": "ok", "monitor": "stale",
@@ -738,6 +780,7 @@ gandalf/
├── monitor.py # Background daemon (Prometheus, UniFi, Pulse, alert logic)
├── modem.py # Read-only AT&T gateway status page client and parser
├── devices.py # Device inventory for search and device pages
+├── topology.py # The topology graph: devices, cables, depth, last-seen links
├── cutsheet.py # Cutsheet validation, path tracing, plan-vs-actual checks, CSV
├── ticket_text.py # Markdown for tickets and recovery notes
├── db.py # Database operations (MariaDB via pymysql, thread-local conn reuse)
@@ -751,6 +794,8 @@ gandalf/
│ ├── cutsheet.js # Cutsheet page
│ ├── events.js # Alert history page
│ ├── rack.js # Rack drawing
+│ ├── topology.js # Topology page: layout, cables, pan and zoom
+│ ├── faceplates.js # UniFi faceplates drawn from each model's real layout
│ ├── fronts.js # Drawn fronts for gear without a live faceplate (NVR, server cases)
│ └── port-inspector.js # Switch front panel, port panel, path check, diagnostics
└── templates/
@@ -760,6 +805,7 @@ gandalf/
├── cutsheet.html # Cutsheet page and its editors
├── events.html # Alert history page
├── rack.html # Rack drawing
+ ├── topology.html # Topology page
├── _modem_panel.html # WAN / Fiber panel, shared by the dashboard and modem page
└── suppressions.html # Suppression management page
```
diff --git a/app.py b/app.py
index b5245f9..87aa948 100644
--- a/app.py
+++ b/app.py
@@ -26,6 +26,7 @@ import cutsheet
import db
import devices
import diagnose
+import topology
from monitor import PulseClient, UnifiClient
logging.basicConfig(
@@ -1195,17 +1196,7 @@ def _racks(items: list) -> list:
return racks
-_VIRTUAL_IFACE = ('lo', 'vmbr', 'bond', 'docker', 'veth', 'tap', 'fwbr', 'fwpr', 'fwln', 'br-', 'virbr', 'wg', 'tun')
-
-
-def _physical_interfaces(name: str, snapshot: dict) -> list:
- """A host's physical NICs in name order, with their state: [{'name', 'up'}].
-
- Drawn fronts light one RJ45 per NIC in this order (first NIC on the leftmost jack).
- """
- ifaces = ((snapshot.get('hosts') or {}).get(name) or {}).get('interfaces') or {}
- return [{'name': n, 'up': str(state).lower() == 'up', 'unused': str(state).lower() == 'unused'}
- for n, state in sorted(ifaces.items()) if not n.startswith(_VIRTUAL_IFACE)]
+_physical_interfaces = devices.physical_interfaces
def _device_status(name: str, snapshot: dict) -> str:
@@ -1314,6 +1305,35 @@ def api_rack():
})
+# ---------------------------------------------------------------------------
+# Topology
+# ---------------------------------------------------------------------------
+
+@app.route('/topology')
+@require_auth
+def topology_page():
+ return render_template('topology.html', user=_get_user())
+
+
+@app.route('/api/topology')
+@require_auth
+def api_topology():
+ """Every device and physical cable, for the topology page (see topology.build_graph)."""
+ events = db.get_active_events(limit=_PAGE_LIMIT)
+ _annotate_suppressions(events, db.get_active_suppressions())
+ try:
+ _ensure_cutsheet_schema()
+ sheet = db.cutsheet_load()
+ except Exception as e:
+ logger.error(f'Cutsheet unavailable for topology: {e}')
+ sheet = {'items': [], 'cables': []}
+ graph = topology.build_graph(
+ _state_json('network_snapshot'), _state_json('link_stats'), _state_json('modem_stats'),
+ events, sheet['items'], sheet['cables'], _state_json('known_uplinks'),
+ )
+ return jsonify(graph)
+
+
@app.route('/api/cutsheet/racks', methods=['POST'])
@require_auth
@require_admin
diff --git a/devices.py b/devices.py
index 6346a75..5a49cc7 100644
--- a/devices.py
+++ b/devices.py
@@ -14,6 +14,19 @@ _KIND_BY_TYPE = {
}
+_VIRTUAL_IFACE = ('lo', 'vmbr', 'bond', 'docker', 'veth', 'tap', 'fwbr', 'fwpr', 'fwln', 'br-', 'virbr', 'wg', 'tun')
+
+
+def physical_interfaces(name: str, snapshot: dict) -> List[dict]:
+ """A host's physical NICs in name order, with their state: [{'name', 'up', 'unused'}].
+
+ Drawn fronts light one RJ45 per NIC in this order (first NIC on the leftmost jack).
+ """
+ ifaces = (((snapshot or {}).get('hosts') or {}).get(name) or {}).get('interfaces') or {}
+ return [{'name': n, 'up': str(state).lower() == 'up', 'unused': str(state).lower() == 'unused'}
+ for n, state in sorted(ifaces.items()) if not n.startswith(_VIRTUAL_IFACE)]
+
+
def unifi_kind(unifi_type: str, model: str) -> str:
if model in _PDU_MODELS:
return 'pdu'
diff --git a/docs/img/topology.png b/docs/img/topology.png
index c19aeaa..a101532 100644
Binary files a/docs/img/topology.png and b/docs/img/topology.png differ
diff --git a/monitor.py b/monitor.py
index 86f815a..c525a5e 100644
--- a/monitor.py
+++ b/monitor.py
@@ -929,17 +929,21 @@ class LinkStatsCollector:
logger.warning(f'UniFi switch port collection failed: {e}')
unifi_devices = (self.unifi.get_device_details() or {}) if self.unifi else {}
- self._resolve_neighbors(unifi_switches, unifi_devices, instance_map)
+ host_macs = self._resolve_neighbors(unifi_switches, unifi_devices, instance_map)
return {
'hosts': result_hosts,
'unifi_switches': unifi_switches,
'unifi_devices': unifi_devices,
+ # Which host NIC owns each MAC, for matching outlets and neighbours named by MAC
+ 'host_macs': {mac: list(owner) for mac, owner in host_macs.items()},
'updated': datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC'),
}
- def _resolve_neighbors(self, switches: dict, unifi_devices: dict, instance_map: Dict[str, str]) -> None:
- """Name port neighbors: UniFi's LLDP data has only MACs, and many servers send none."""
+ def _resolve_neighbors(self, switches: dict, unifi_devices: dict, instance_map: Dict[str, str]) -> dict:
+ """Name port neighbors: UniFi's LLDP data has only MACs, and many servers send none.
+
+ Returns {mac: (host, interface)} for every physical host NIC Prometheus reports."""
host_macs: Dict[str, tuple] = {}
host_ips: Dict[str, str] = {}
# Bonds share their MAC with their member NICs, and LLDP runs on the NIC.
@@ -962,6 +966,7 @@ class LinkStatsCollector:
}
devices.resolve_lldp_neighbors(switches, host_macs, device_macs, host_ips)
devices.apply_client_locations(switches, self.unifi.get_wired_clients(), host_macs, device_macs)
+ return host_macs
def _compute_unifi_rates(self, raw: Dict[str, dict], now: float) -> Dict[str, dict]:
"""Compute per-port byte/error rates from delta against previous snapshot."""
diff --git a/static/app.js b/static/app.js
index d004f65..4c50b1b 100644
--- a/static/app.js
+++ b/static/app.js
@@ -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 = `
Modem status unavailable: ${lt.escHtml(data.error || 'unknown error')}
Last attempt ${lt.escHtml(data.updated || '-')}
@@ -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(' · ');
diff --git a/static/faceplates.js b/static/faceplates.js
index 3f04150..682231b 100644
--- a/static/faceplates.js
+++ b/static/faceplates.js
@@ -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];
diff --git a/static/style.css b/static/style.css
index 131954a..04d3e4b 100644
--- a/static/style.css
+++ b/static/style.css
@@ -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,226 @@ 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; white-space: nowrap; }
+.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; }
+.tp-stub-jack.tp-off { opacity: .4; }
+.tp-group-body { stroke-dasharray: 10 8; stroke: #5d6670; }
+.tp-group-hint { font-family: var(--font); font-size: 20px; fill: #9aa3ad; }
+.tp-select { font-size: .95em; padding: 1px 4px; margin-left: 4px; }
+.tp-tag-bundle rect { stroke: #3b82f6; }
+
+/* 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: 17px; 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; }
+/* A group's cables, bundled: one thick run */
+.tp-link.tp-bundle .tp-casing { stroke-width: 28; stroke: #0f2b57; } .tp-link.tp-bundle .tp-core { stroke-width: 20; }
+.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-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; }
+
+/* Topology cards */
+.tp-card { fill: #121821; stroke: #2a3440; stroke-width: 3; }
+[data-theme="light"] .tp-card { fill: #ffffff; stroke: #c9d1da; }
+.tp-band { fill: #0a0e13; stroke: #1c242e; stroke-width: 2; }
+[data-theme="light"] .tp-band { fill: #eef1f4; stroke: #dde3ea; }
+.tp-card-name { font-family: var(--font); font-size: 44px; font-weight: 700; fill: var(--text); letter-spacing: .01em; }
+.tp-card-sub { font-family: var(--font); font-size: 28px; fill: var(--text-muted); }
+.tp-card-stripe { fill: #6b737d; }
+.tp-stripe-up { fill: #2fd36b; }
+.tp-stripe-degraded { fill: #ffb300; }
+.tp-stripe-down { fill: #e0312b; }
+.tp-node-link:hover .tp-card, .tp-node-link:focus-visible .tp-card { stroke: var(--amber); }
+.tp-kind-stub .tp-card, .tp-kind-group .tp-card { stroke-dasharray: 14 10; }
+.tp-kind-group .tp-node-link { cursor: pointer; }
+.tp-pigtail { stroke-width: 5 !important; stroke-dasharray: none !important; }
+[data-theme="light"] .tp-nic-name { fill: #4b5562; }
+
+/* Healthy cables are quiet; problems are loud */
+.tp-links .tp-link:not(.tp-problem):not(.tp-hot):not(.tp-selected) { opacity: .6; }
+.tp-halo { stroke: rgba(224,49,43,.28); stroke-width: 46; fill: none; stroke-linecap: round; }
+.tp-problem-mismatch .tp-halo, .tp-problem-errors .tp-halo { stroke: rgba(255,179,0,.28); }
+.tp-node-problem .tp-card { stroke-width: 5; }
+.tp-node-problem.tp-st-degraded .tp-card { stroke: #ffb300; }
+.tp-node-problem.tp-st-down .tp-card { stroke: #e0312b; }
+.tp-summary { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin: 0 0 10px; padding: 8px 12px;
+ border: 1px solid var(--border-color); background: var(--bg2); font-size: .8em; }
+.tp-summary:empty { display: none; }
+.tp-summary-head { font-weight: 700; letter-spacing: .04em; }
+.tp-summary-ok { border-color: var(--green-muted); }
+.tp-summary-ok .tp-summary-head { color: var(--green); }
+.tp-summary-text { color: var(--text-dim); }
+.tp-summary-bad { border-color: var(--red); }
+.tp-summary-bad .tp-summary-head { color: var(--red); }
+.tp-summary-warn { border-color: var(--amber); }
+.tp-summary-warn .tp-summary-head { color: var(--amber); }
+.tp-problem-chip { font: inherit; font-size: .95em; padding: 3px 9px; border: 1px solid; background: transparent; cursor: pointer; text-align: left; }
+.tp-chip-bad { color: var(--red); border-color: var(--red); background: var(--red-dim); }
+.tp-chip-warn { color: var(--amber); border-color: var(--amber); background: var(--amber-dim); }
+.tp-problem-chip:hover, .tp-problem-chip:focus-visible { filter: brightness(1.25); outline: none; }
diff --git a/static/topology.js b/static/topology.js
new file mode 100644
index 0000000..9764d8d
--- /dev/null
+++ b/static/topology.js
@@ -0,0 +1,1218 @@
+'use strict';
+/* global FACEPLATES, faceplateSvg, faceplatePortAnchor, RACK_FRONTS, SHELF_GEAR, DESK_FACEPLATES, CHASSIS_FACEPLATES, rackFrontSvg */
+// Topology page: a card per device with its real faceplate or chassis, and each cable leaving the
+// exact port it is plugged into. Data comes from /api/topology (topology.build_graph).
+
+const esc = s => lt.escHtml(s == null ? '' : String(s));
+const PER_MM = 1100 / 482.6;
+const NODE_GAP = 90;
+const REFRESH_MS = 30000;
+const FILTER_KEY = 'gandalf.topology.filters';
+const DEFAULT_FILTERS = { stubs: 'grouped', power: false, traffic: true, mgmt: false, alerts: false };
+const STUB_MODES = ['grouped', 'all', 'none'];
+
+const state = {
+ data: null,
+ layout: null,
+ view: { k: 1, tx: 0, ty: 0 },
+ fitted: false,
+ filters: loadFilters(),
+ expanded: new Set(), // switches whose unknown devices are shown one by one
+ selected: null,
+};
+
+function loadFilters() {
+ try {
+ const saved = Object.assign({}, DEFAULT_FILTERS, JSON.parse(localStorage.getItem(FILTER_KEY) || '{}'));
+ if (!STUB_MODES.includes(saved.stubs)) saved.stubs = saved.stubs === false ? 'none' : 'grouped';
+ delete saved.tenG;
+ return saved;
+ } 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 === 'group') return '';
+ if (node.kind === 'stub') return `${deviceHref(node.switch)}#port-${encodeURIComponent(node.port_idx)}`;
+ if (node.kind === 'internet') return '';
+ return deviceHref(node.name);
+}
+
+// ── Problems ────────────────────────────────────────────────────────────
+// Anything that needs a look: drawn in full colour, always shown whatever the filters, and listed
+// above the diagram. Healthy cables are drawn quietly.
+function linkProblem(link) {
+ if (link.bundle) return '';
+ if (link.ghost) return 'ghost';
+ if (!link.up) return 'down';
+ if (link.name_mismatch) return 'mismatch';
+ if ((link.errors || 0) > 0) return 'errors';
+ return '';
+}
+
+function nodeProblem(node) {
+ if (['stub', 'group', 'internet'].includes(node.kind) && !node.alert_count) return node.kind === 'internet' && node.status === 'down' ? 'down' : '';
+ if (node.status === 'down' || node.status === 'degraded') return node.status;
+ return node.alert_count ? 'alert' : '';
+}
+
+function endText(e, byId) {
+ const n = byId[e.node];
+ const name = n ? n.name : e.node;
+ if (e.port_idx != null) return `${name} port ${e.port_idx}`;
+ return e.iface ? `${name} ${e.iface}` : name;
+}
+
+function problemsOf(data) {
+ const byId = Object.fromEntries(data.nodes.map(n => [n.id, n]));
+ const out = [];
+ data.nodes.forEach(n => {
+ const p = nodeProblem(n);
+ if (!p) return;
+ const bits = [];
+ if (n.status === 'down' || n.status === 'degraded') bits.push(n.status);
+ if (n.alert_count) bits.push(`${n.alert_count} alert${n.alert_count > 1 ? 's' : ''}`);
+ out.push({ kind: 'node', id: n.id, severity: n.status === 'down' || n.severity === 'critical' ? 'bad' : 'warn',
+ text: `${n.name}: ${bits.join(', ')}` });
+ });
+ data.links.forEach(l => {
+ const p = linkProblem(l);
+ if (!p) return;
+ // A missing device is one problem, not two: say where it was last seen on its own entry
+ const listed = out.find(o => o.kind === 'node' && o.id === l.b.node);
+ if (p === 'ghost' && listed) {
+ listed.text += `, last seen on ${endText(l.a, byId)}`;
+ return;
+ }
+ const host = byId[l.b.node] && byId[l.b.node].kind === 'stub' ? `${byId[l.b.node].name} on ${endText(l.a, byId)}` : null;
+ let text;
+ if (p === 'ghost') text = `${byId[l.b.node] ? byId[l.b.node].name : l.b.node} not seen, last on ${endText(l.a, byId)}`;
+ else if (p === 'down') text = `Cable down: ${host || `${endText(l.a, byId)} to ${endText(l.b, byId)}`}`;
+ else if (p === 'mismatch') {
+ const m = l.name_mismatch;
+ const end = m.end === 'b' ? l.b : l.a;
+ text = `${endText(end, byId)} is named after ${m.named}, but ${m.actual} is plugged in`;
+ } else text = `Errors on ${endText(l.a, byId)} to ${endText(l.b, byId)} (${l.errors}/s)`;
+ out.push({ kind: 'link', id: l.id, severity: p === 'mismatch' || p === 'errors' ? 'warn' : 'bad', text });
+ });
+ return out;
+}
+
+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* {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];
+ let box = (opts.rack && spec.rackView) || [0, 0, spec.width, spec.height];
+ if (opts.crop && spec.ports.length > 2) {
+ // Just the port area, so the ports are big enough to read in a card
+ const left = spec.ports.map(q => (q.type === 'sfp' ? q.x : q.x - 20 * (q.scale || 1)));
+ const right = spec.ports.map(q => (q.type === 'sfp' ? q.x + q.w : q.x + 20 * (q.scale || 1)));
+ const x0 = Math.max(0, Math.min(...left) - 24);
+ const x1 = Math.min(spec.width, Math.max(...right) + 24);
+ box = [x0, 0, x1 - x0, 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).replace(/viewBox="[^"]*"/, `viewBox="${box.join(' ')}"`);
+ 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 `${portTitle(p, idx)}
+ ${idx} `;
+ }).join('');
+ return {
+ w, h,
+ svg: `
+ ${esc(node.model || 'switch')} ${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: `
+ INTERNET `,
+ 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 `${name} `;
+ }).join('');
+ return {
+ w, h,
+ svg: `
+
+
+
+ ${leds}
+ ${esc((node.model || '').split('-')[0])} `,
+ // 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: `
+
+ `,
+ 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 `${esc(title)}
+
+
+ `;
+ }).join('');
+ return {
+ w: Math.max(w, 90 + n * 95), h,
+ svg: ` ${sockets}`,
+ anchor: () => null,
+ };
+}
+
+function groupDrawing(node) {
+ const n = node.members.length;
+ const cols = Math.min(n, 7);
+ const w = Math.max(360, cols * 64 + 60), h = 150;
+ const jacks = node.members.slice(0, 14).map((m, i) => {
+ const x = 30 + (i % cols) * 64, y = 22 + Math.floor(i / cols) * 46;
+ return ` `;
+ }).join('');
+ return {
+ w, h,
+ svg: ` ${jacks}
+ click to show `,
+ anchor: () => ({ x: w / 2, y: 4, face: 'top' }),
+ };
+}
+
+function boxDrawing(node, text) {
+ const w = 460, h = 150;
+ return {
+ w, h,
+ svg: `
+ ${esc(text)} `,
+ anchor: () => null,
+ };
+}
+
+function stubDrawing() {
+ const w = 160, h = 100;
+ return {
+ w, h,
+ svg: `
+
+ ? `,
+ anchor: () => ({ x: w / 2, y: 10, face: 'top' }),
+ };
+}
+
+// A host's drawn front, from its cutsheet item's Front drawing
+function hostFront(node) {
+ const ctx = { name: node.name, status: node.status, interfaces: node.interfaces || [] };
+ if (RACK_FRONTS[node.chassis]) {
+ const spec = RACK_FRONTS[node.chassis];
+ return { w: spec.width, h: spec.height,
+ svg: nested(rackFrontSvg(node.chassis, ctx)).replace('${gear.draw(ctx)} ` };
+ }
+ if (CHASSIS_FACEPLATES[node.chassis]) return faceplateDrawing(node, CHASSIS_FACEPLATES[node.chassis], 1100);
+ return null;
+}
+
+// The picture inside a card, for everything but hosts
+function artFor(node) {
+ const spec = FACEPLATES[node.model];
+ switch (node.kind) {
+ case 'internet': return internetDrawing();
+ case 'modem': return modemDrawing(node);
+ case 'stub': return stubDrawing();
+ case 'group': return groupDrawing(node);
+ 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', crop: !node.power });
+ if (node.kind === 'switch' || node.kind === 'router') return genericSwitch(node);
+ return boxDrawing(node, node.model || node.kind);
+ }
+}
+
+// ── Cards ───────────────────────────────────────────────────────────────
+// Every device is a card of the same height: its name and state on top, and below that its real
+// faceplate (or, for a server, its front and a socket per NIC) shrunk to fit. Cables meet the card
+// edge right above or below the port they are plugged into.
+const CARD_W = 640;
+const CARD_SMALL_W = 460;
+const CARD_H = 300;
+const BAND_Y = 132;
+const BAND_H = 140;
+const NIC_W = 104;
+
+function fitArt(art, x, y, w, h) {
+ const k = Math.min(w / art.w, h / art.h);
+ return { k, ox: x + (w - art.w * k) / 2, oy: y + (h - art.h * k) / 2 };
+}
+
+function nicSockets(node, linksByIface, x0) {
+ 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 pos = {};
+ const svg = nics.map((nic, i) => {
+ const link = linksByIface[nic.name];
+ const cx = x0 + i * NIC_W + NIC_W / 2;
+ const cy = BAND_Y + 40;
+ pos[nic.name] = { x: cx, y: cy - 20 };
+ 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
+ ? ` `
+ : ` `;
+ const label = nic.name.length > 9 ? `${nic.name.slice(0, 8)}…` : nic.name;
+ return `${esc(title)}
+ ${shape}
+ ${esc(label)} `;
+ }).join('');
+ return { svg, pos, w: nics.length * NIC_W };
+}
+
+function cardFor(node, linksByIface) {
+ let w, band, anchorAt;
+ if (node.kind === 'host') {
+ const front = hostFront(node);
+ const sockets0 = nicSockets(node, linksByIface, 0);
+ const frontW = front ? 220 : 0;
+ w = Math.max(CARD_W, 56 + frontW + (front ? 24 : 0) + sockets0.w, Math.min(node.name.length * 27 + 130, 980));
+ const sx = 28 + (front ? frontW + 24 : (w - 56 - sockets0.w) / 2);
+ const sockets = nicSockets(node, linksByIface, sx);
+ let art = '';
+ if (front) {
+ const f = fitArt(front, 28, BAND_Y, frontW, BAND_H);
+ art = `${front.svg} `;
+ }
+ band = `${art}${sockets.svg}`;
+ anchorAt = end => (end.iface && sockets.pos[end.iface]) || null;
+ } else {
+ const small = ['internet', 'modem', 'stub', 'group', 'ap', 'strip'].includes(node.kind);
+ w = Math.max(small ? CARD_SMALL_W : CARD_W, Math.min(node.name.length * 27 + 130, 980));
+ const art = artFor(node);
+ const f = fitArt(art, 28, BAND_Y, w - 56, BAND_H);
+ band = `${art.svg} `;
+ anchorAt = end => {
+ const a = art.anchor(end);
+ return a ? { x: f.ox + a.x * f.k, y: f.oy + a.y * f.k } : null;
+ };
+ }
+ const maxChars = Math.floor((w - 130) / 27);
+ const name = node.name.length > maxChars ? `${node.name.slice(0, maxChars - 1)}…` : node.name;
+ let sub;
+ if (node.kind === 'group') sub = `on ${node.switch}`;
+ else if (node.kind === 'stub') sub = node.ip ? `${node.switch} port ${node.port_idx}` : (node.mac || `${node.switch} port ${node.port_idx}`);
+ else if (node.kind === 'host') sub = node.status === 'up' ? 'server' : `server · ${node.status}`;
+ else if (node.kind === 'internet') sub = node.status === 'up' ? 'connected' : node.status;
+ else sub = [node.model || node.kind, node.status !== 'up' ? node.status : ''].filter(Boolean).join(' · ');
+ const svg = `
+
+ ${esc(name)}
+ ${esc(sub)}
+
+
+ ${band}`;
+ return { w, h: CARD_H, svg, anchorAt };
+}
+
+// ── Layout ──────────────────────────────────────────────────────────────
+// Rows top down by hops from the internet. Cables are routed like cable management: each drops
+// straight down from its port into the tray above the row it is going to, runs along its own lane
+// there, then drops straight into its socket. Lanes never share, so cables never lie on top of one
+// another and only ever cross at right angles. Power gear hangs in a rail on the right.
+const TRAY_PAD = 70;
+const LANE = 30;
+const TRAY_MIN = 200;
+const CORRIDOR_PAD = 60;
+const CORNER = 26;
+
+// Unknown devices on one switch are grouped into a single block with one bundled cable, unless
+// the viewer opens that switch's group (or shows them all)
+function visibleGraph(data, filters, expanded) {
+ const mode = filters.stubs;
+ const all = Object.fromEntries(data.nodes.map(n => [n.id, n]));
+ let nodes = data.nodes.filter(n => (mode !== 'none' || n.kind !== 'stub') && (filters.power || !n.power));
+ let links = data.links.slice();
+ if (!filters.mgmt) {
+ // A server's slower second link (its 1G management cable) is hidden while it is healthy and
+ // the server has a faster one; a problem cable is always drawn
+ links = links.filter(l => {
+ const host = [l.a, l.b].find(e => (all[e.node] || {}).kind === 'host');
+ if (!host || linkProblem(l)) return true;
+ const faster = data.links.some(o => o !== l && [o.a.node, o.b.node].includes(host.node) && (o.speed_mbps || 0) > (l.speed_mbps || 0));
+ return !faster;
+ });
+ }
+ if (mode === 'grouped') {
+ const bySwitch = {};
+ nodes.filter(n => n.kind === 'stub').forEach(n => (bySwitch[n.switch] = bySwitch[n.switch] || []).push(n));
+ Object.entries(bySwitch).forEach(([sw, stubs]) => {
+ if (stubs.length < 2 || expanded.has(sw)) return;
+ const ids = new Set(stubs.map(n => n.id));
+ const cables = links.filter(l => ids.has(l.b.node));
+ const id = `group:${sw}`;
+ nodes = nodes.filter(n => !ids.has(n.id)).concat([{
+ id, name: `${stubs.length} unknown devices`, kind: 'group', status: 'up', depth: stubs[0].depth,
+ switch: sw, members: stubs, alerts: [], alert_count: 0, severity: '', power: false,
+ }]);
+ links = links.filter(l => !ids.has(l.b.node)).concat([{
+ id: `bundle:${sw}`, bundle: true, count: cables.length, ghost: false, wan: false, name_mismatch: null,
+ a: { node: sw, port_idx: null, port_name: '', iface: '', ports: cables.map(l => l.a.port_idx) },
+ b: { node: id, port_idx: null, port_name: '', iface: '' },
+ up: cables.some(l => l.up), medium: 'copper', speed_mbps: Math.max(...cables.map(l => l.speed_mbps || 0)),
+ a_to_b_bps: cables.reduce((t, l) => t + (l.a_to_b_bps || 0), 0),
+ b_to_a_bps: cables.reduce((t, l) => t + (l.b_to_a_bps || 0), 0),
+ }]);
+ });
+ }
+ const ids = new Set(nodes.map(n => n.id));
+ return { nodes, links: links.filter(l => ids.has(l.a.node) && ids.has(l.b.node)) };
+}
+
+// The internet, modem and gateway share the top row, left to right; then a row per hop, with
+// unknown devices beside the gear on their switch; servers along the bottom
+function rowKey(node) {
+ if (node.kind === 'host') return 1e6;
+ if (node.depth == null) return 1e5;
+ if (['internet', 'modem', 'router'].includes(node.kind) && node.depth <= 2) return 0;
+ return node.depth;
+}
+
+// Where a cable meets a card: on its bottom edge (leaving) or top edge (arriving), right above or
+// below the port it is plugged into, which is given as `port` for the short tail drawn to it
+function anchorOf(lay, end, asParent) {
+ const p = lay.pos[end.node];
+ const d = lay.drawings[end.node];
+ if (p.rail) return { x: p.x, y: p.y + d.h / 2, face: 'left', port: null };
+ let a = null;
+ if (end.ports) {
+ // A bundle leaves from the middle of the ports it gathers
+ const xs = end.ports.map(idx => d.anchorAt({ port_idx: idx })).filter(Boolean);
+ if (xs.length) a = { x: xs.reduce((t, q) => t + q.x, 0) / xs.length, y: Math.max(...xs.map(q => q.y)) };
+ } else {
+ a = d.anchorAt(end);
+ }
+ const x = p.x + (a ? a.x : d.w / 2);
+ return { x, y: p.y + (asParent ? d.h : 0), face: asParent ? 'bottom' : 'top',
+ port: a ? { x, y: p.y + a.y } : null };
+}
+
+// Give each horizontal run in a tray a lane: fastest cables on the upper lanes, and two runs
+// share a lane only when they don't overlap
+function assignLanes(runs) {
+ const lanes = [];
+ runs.sort((a, b) => (b.speed - a.speed) || (a.x0 - b.x0));
+ runs.forEach(run => {
+ let i = lanes.findIndex(lane => lane.every(o => run.x1 + 40 < o.x0 || run.x0 > o.x1 + 40));
+ if (i < 0) {
+ lanes.push([]);
+ i = lanes.length - 1;
+ }
+ lanes[i].push(run);
+ run.lane = i;
+ });
+ return lanes.length;
+}
+
+// Straight runs with rounded corners
+function roundedPath(points) {
+ const pts = points.filter((p, i) => i === 0 || Math.hypot(p.x - points[i - 1].x, p.y - points[i - 1].y) > 0.5);
+ let d = `M${pts[0].x} ${pts[0].y}`;
+ for (let i = 1; i < pts.length - 1; i++) {
+ const [p, c, n] = [pts[i - 1], pts[i], pts[i + 1]];
+ const r = Math.min(CORNER, Math.hypot(c.x - p.x, c.y - p.y) / 2, Math.hypot(n.x - c.x, n.y - c.y) / 2);
+ const u = (q, s) => ({ x: c.x + (q.x - c.x) / Math.hypot(q.x - c.x, q.y - c.y) * s, y: c.y + (q.y - c.y) / Math.hypot(q.x - c.x, q.y - c.y) * s });
+ const a = u(p, r), b = u(n, r);
+ d += ` L${a.x} ${a.y} Q${c.x} ${c.y} ${b.x} ${b.y}`;
+ }
+ const last = pts[pts.length - 1];
+ return `${d} L${last.x} ${last.y}`;
+}
+
+function layout(data, filters, expanded) {
+ const { nodes, links } = visibleGraph(data, filters, expanded);
+ const byId = Object.fromEntries(nodes.map(n => [n.id, n]));
+ const drawings = {};
+ const sizes = {};
+ nodes.forEach(n => {
+ const byIface = {};
+ data.links.forEach(l => [l.a, l.b].forEach(e => { if (e.node === n.id && e.iface) byIface[e.iface] = l; }));
+ const d = cardFor(n, byIface);
+ drawings[n.id] = d;
+ sizes[n.id] = { w: d.w, h: d.h };
+ });
+
+ const lay = { pos: {}, drawings, nodes, links, byId, routes: {} };
+ 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));
+ const rowOf = {};
+ rows.forEach((row, r) => row.forEach(n => { rowOf[n.id] = r; }));
+ const ax = end => anchorOf(lay, end, true).x;
+
+ // ── Across: each row in order of the ports feeding it, clear of cables passing down through it
+ rows.forEach((row, r) => {
+ const corridors = links
+ .filter(l => rowOf[l.a.node] < r && rowOf[l.b.node] > r && lay.pos[l.a.node])
+ .map(l => ax(l.a)).sort((a, b) => a - b);
+ const items = row.map(n => {
+ // Line a device up under its fastest cable, so that one drops straight in
+ const feeds = links.filter(l => l.b.node === n.id && lay.pos[l.a.node])
+ .sort((a, b) => (b.speed_mbps || 0) - (a.speed_mbps || 0));
+ const xs = feeds.length ? [ax(feeds[0].a)] : [];
+ return { n, w: sizes[n.id].w, bary: xs.length ? xs[0] : null };
+ });
+ let last = 0;
+ items.forEach(it => { if (it.bary == null) it.bary = last; else last = it.bary; });
+ if (r === 0) items.forEach(it => { it.bary = (it.n.depth || 0) * 1000; });
+ 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((t, it) => t + (it.bary - (it.x + it.w / 2)), 0) / items.length;
+ right = -Infinity;
+ items.forEach(it => {
+ let x = Math.max(it.x + shift, right + NODE_GAP);
+ let moved = true;
+ while (moved) {
+ moved = false;
+ for (const c of corridors) {
+ if (c > x - CORRIDOR_PAD && c < x + it.w + CORRIDOR_PAD) {
+ x = c + CORRIDOR_PAD;
+ moved = true;
+ }
+ }
+ }
+ lay.pos[it.n.id] = { x, y: 0, w: it.w, dx: 0 };
+ right = x + it.w;
+ });
+ });
+
+ // ── Trays: the runs in each one, and their lanes
+ const placed = Object.values(lay.pos);
+ const maxX = placed.length ? Math.max(...placed.map(p => p.x + p.w)) : 0;
+ const railNodes = nodes.filter(n => n.power);
+ const gutter0 = maxX + 160;
+ const trays = Array.from({ length: rows.length + 1 }, () => []);
+ const plan = {};
+ let gutterLane = 0;
+ links.forEach(l => {
+ if (!lay.pos[l.a.node]) return;
+ const xa = ax(l.a);
+ const toRail = byId[l.b.node].power;
+ if (!toRail && rowOf[l.a.node] === rowOf[l.b.node]) {
+ plan[l.id] = { across: true };
+ return;
+ }
+ let r, xb;
+ if (toRail) {
+ r = Math.min(rowOf[l.a.node] + 1, rows.length);
+ xb = gutter0 + (gutterLane++) * LANE;
+ } else {
+ if (rowOf[l.b.node] == null) return;
+ r = rowOf[l.b.node];
+ xb = anchorOf(lay, l.b, false).x;
+ }
+ const run = { id: l.id, x0: Math.min(xa, xb), x1: Math.max(xa, xb), speed: l.speed_mbps || 0, straight: Math.abs(xa - xb) < 2 };
+ plan[l.id] = { r, xa, xb, toRail, run };
+ if (!run.straight) trays[r].push(run);
+ });
+ const trayH = trays.map(runs => Math.max(TRAY_MIN, TRAY_PAD * 2 + assignLanes(runs) * LANE));
+
+ // ── Down: rows and trays stacked
+ const rowTop = [];
+ let y = 0;
+ rows.forEach((row, r) => {
+ if (r > 0) y += trayH[r];
+ rowTop[r] = y;
+ row.forEach(n => { lay.pos[n.id].y = y; });
+ y += Math.max(...row.map(n => sizes[n.id].h));
+ });
+ const trayTop = r => (r < rows.length ? rowTop[r] - trayH[r] : y);
+
+ // Power rail: each unit level with the lane its cable arrives on
+ const railX = gutter0 + Math.max(gutterLane, 1) * LANE + 260;
+ let railY = rows.length ? rowTop[0] : 0;
+ railNodes.map(n => {
+ const l = links.find(k => k.b.node === n.id && plan[k.id]);
+ const f = l ? trayTop(plan[l.id].r) + TRAY_PAD + (plan[l.id].run.lane || 0) * LANE : Infinity;
+ return { n, f };
+ }).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 top = Math.max(want, railY);
+ lay.pos[n.id] = { x: railX, y: top, w: sizes[n.id].w, dx: 0, rail: true };
+ railY = top + sizes[n.id].h + 120;
+ });
+
+ // ── Routes
+ links.forEach(l => {
+ const p = plan[l.id];
+ if (!p) return;
+ if (p.across) {
+ // Side by side in the top row: straight across from one card to the next
+ const [pa, pb] = [lay.pos[l.a.node], lay.pos[l.b.node]];
+ const left = pa.x < pb.x;
+ const A = { x: left ? pa.x + pa.w : pa.x, y: pa.y + CARD_H / 2, port: null };
+ const B = { x: left ? pb.x : pb.x + pb.w, y: pb.y + CARD_H / 2, port: null };
+ lay.routes[l.id] = { d: roundedPath([A, B]), mid: { x: (A.x + B.x) / 2, y: A.y - 40 }, A, B };
+ return;
+ }
+ const A = anchorOf(lay, l.a, true);
+ const B = anchorOf(lay, l.b, false);
+ const laneY = trayTop(p.r) + TRAY_PAD + (p.run.lane || 0) * LANE;
+ let pts;
+ if (p.toRail) pts = [A, { x: A.x, y: laneY }, { x: p.xb, y: laneY }, { x: p.xb, y: B.y }, B];
+ else if (p.run.straight) pts = [A, B];
+ else pts = [A, { x: A.x, y: laneY }, { x: B.x, y: laneY }, B];
+ // Traffic and "last seen" tags sit on the longest run
+ let mid = { x: (A.x + B.x) / 2, y: (A.y + B.y) / 2 };
+ let best = -1;
+ for (let i = 1; i < pts.length; i++) {
+ const len = Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y);
+ if (len > best) {
+ best = len;
+ mid = { x: (pts[i].x + pts[i - 1].x) / 2, y: (pts[i].y + pts[i - 1].y) / 2 };
+ }
+ }
+ lay.routes[l.id] = { d: roundedPath(pts), mid, A, B };
+ });
+
+ const all = Object.entries(lay.pos);
+ lay.bounds = {
+ x0: Math.min(...all.map(([, q]) => q.x)) - 80,
+ y0: Math.min(...all.map(([, q]) => q.y)) - 80,
+ x1: Math.max(...all.map(([, q]) => q.x + q.w)) + 80,
+ y1: Math.max(...all.map(([id, q]) => q.y + sizes[id].h)) + 80,
+ };
+ return lay;
+}
+
+// ── Rendering ───────────────────────────────────────────────────────────
+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 route = lay.routes[link.id];
+ if (!route) return { under: '', over: '' };
+ const { d, mid, A, B } = route;
+ const cls = ['tp-link', mediumClass(link), speedClass(link.speed_mbps)];
+ if (link.bundle) cls.push('tp-bundle');
+ if (link.ghost) cls.push('tp-ghost');
+ else if (!link.up) cls.push('tp-down');
+ if (link.wan) cls.push('tp-wan');
+ const problem = linkProblem(link);
+ if (problem) cls.push('tp-problem', `tp-problem-${problem}`);
+ 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
+ ? ` ` : '';
+ const label = link.bundle
+ ? `${link.count} cables `
+ : link.ghost
+ ? `last seen `
+ : (link.up ? `
+ ↓ ${esc(rateText(link.a_to_b_bps))} ↑ ${esc(rateText(link.b_to_a_bps))} ` : '');
+ 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 = `${esc(text)}
+ ! `;
+ }
+ // A short tail from the card edge to the port on the picture, ending in the plug
+ const plug = P => (['dac', 'fibre', 'sfp'].includes(link.medium)
+ ? ` `
+ : ` `);
+ const tail = E => (E.port
+ ? ` ${plug(E.port)}` : '');
+ const plugs = `${tail(A)}${tail(B)}`;
+ 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: `
+ ${esc(linkTitle(link, lay.byId))}
+ ${problem ? ` ` : ''} ${flow}
+ `,
+ over: `${plugs}${label}${warn} `,
+ };
+}
+
+function renderNode(node, lay) {
+ const p = lay.pos[node.id];
+ const d = lay.drawings[node.id];
+ const href = nodeHref(node);
+ const ring = node.severity
+ ? `
+ ${node.alert_count} `
+ : '';
+ 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 = node.kind === 'group'
+ ? ``
+ : (href ? `` : ``);
+ const close = open.startsWith('' : ' ';
+ return `
+ ${open}${esc(label)}
+ ${d.svg}
+ ${ring}
+ ${close}
+ `;
+}
+
+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-alerts-only', state.filters.alerts);
+ document.querySelectorAll('[data-filter]').forEach(el => {
+ if (el.tagName === 'SELECT') el.value = state.filters[el.dataset.filter];
+ else el.checked = !!state.filters[el.dataset.filter];
+ });
+}
+
+function render() {
+ const data = state.data;
+ if (!data) return;
+ state.layout = layout(data, state.filters, state.expanded);
+ const lay = state.layout;
+ const world = document.getElementById('tp-world');
+ const cables = lay.links.map(l => renderLink(l, lay));
+ world.innerHTML = `${cables.map(c => c.under).join('')}
+ ${lay.nodes.map(n => renderNode(n, lay)).join('')}
+ ${cables.map(c => c.over).join('')} `;
+ document.getElementById('tp-loading').hidden = true;
+ applyFilterClasses();
+ if (!state.fitted) {
+ sizeCanvas();
+ 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 ? ` ${esc(n.name)} ` : esc(n.name);
+ let where = '';
+ if (end.port_idx != null) {
+ where = `port ${esc(end.port_idx)} `;
+ 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(`${k} ${v} `);
+ row('From', endHtml(link.a, byId));
+ row('To', endHtml(link.b, byId));
+ row('State', link.ghost ? `not seen now · last seen ${esc(link.seen || 'unknown')}` : (link.up ? 'up' : 'down '));
+ 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 ? `S/N ${esc(link.sfp_serial)} ` : ''));
+ }
+ 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 ? `${esc(link.errors)}/s ` : 'none');
+ row('Drops', link.drops ? `${esc(link.drops)}/s ` : 'none');
+ }
+ if (link.cable) {
+ const label = link.cable.label || `cable #${link.cable.id}`;
+ row('Cutsheet', `${esc(label)} ${link.cable.type ? ` · ${esc(link.cable.type)}` : ''}${link.cable.color ? ` · ${esc(link.cable.color)}` : ''}`);
+ } else if (!link.wan) {
+ row('Cutsheet', 'not documented ');
+ }
+ const warn = link.name_mismatch
+ ? `⚠ The port is named after ${esc(link.name_mismatch.named)} , but ${esc(link.name_mismatch.actual)} is plugged into it. Rename the port in UniFi, or check the cable.
`
+ : '';
+ panel.innerHTML = `
+
Cable
+ ✕
+ ${warn}`;
+ 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;
+
+// On a narrow screen the diagram is much wider than tall: size the canvas to it instead of
+// leaving most of a tall canvas empty
+function sizeCanvas() {
+ const canvas = document.getElementById('tp-canvas');
+ if (!state.layout || canvas.clientWidth > 700) {
+ canvas.style.height = '';
+ return;
+ }
+ const { x0, y0, x1, y1 } = state.layout.bounds;
+ const want = canvas.clientWidth * (y1 - y0) / (x1 - x0);
+ canvas.style.height = `${Math.round(Math.max(440, Math.min(want + 24, window.innerHeight * 0.75)))}px`;
+}
+
+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 group = e.target.closest('[data-expand]');
+ const link = e.target.closest('.tp-link');
+ if (group || (link && link.dataset.link.startsWith('bundle:'))) {
+ state.expanded.add(group ? group.dataset.expand : link.dataset.link.slice('bundle:'.length));
+ render();
+ return;
+ }
+ 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 group = e.target.closest('[data-expand]');
+ if (group) {
+ e.preventDefault();
+ state.expanded.add(group.dataset.expand);
+ render();
+ 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-summary').addEventListener('click', e => {
+ const chip = e.target.closest('.tp-problem-chip');
+ if (!chip) return;
+ if (chip.dataset.focusNode) focusOn('node', chip.dataset.focusNode);
+ else focusOn('link', chip.dataset.focusLink);
+ });
+ document.getElementById('tp-panel').addEventListener('click', e => {
+ if (e.target.closest('[data-action="close-panel"]')) hidePanel();
+ });
+
+ document.querySelectorAll('[data-filter]').forEach(el => {
+ el.addEventListener('change', () => {
+ state.filters[el.dataset.filter] = el.tagName === 'SELECT' ? el.value : el.checked;
+ saveFilters();
+ if (el.dataset.filter === 'stubs') state.expanded.clear();
+ if (['stubs', 'power', 'mgmt'].includes(el.dataset.filter)) 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);
+ });
+}
+
+// ── Problem summary ─────────────────────────────────────────────────────
+function renderSummary() {
+ const box = document.getElementById('tp-summary');
+ const problems = problemsOf(state.data);
+ const devices = state.data.nodes.filter(n => !['stub', 'internet'].includes(n.kind)).length;
+ const cables = state.data.links.length;
+ if (!problems.length) {
+ box.className = 'tp-summary tp-summary-ok';
+ box.innerHTML = `✓ All clear ${devices} devices and ${cables} cables, nothing down or mislabelled `;
+ return;
+ }
+ box.className = `tp-summary ${problems.some(p => p.severity === 'bad') ? 'tp-summary-bad' : 'tp-summary-warn'}`;
+ box.innerHTML = `⚠ ${problems.length} to look at
+ ${problems.map(p => `${esc(p.text)} `).join('')}`;
+}
+
+// Centre the view on a device or cable and show what is wrong with it
+function focusOn(kind, id) {
+ const lay = state.layout;
+ const canvas = document.getElementById('tp-canvas');
+ let x, y;
+ if (kind === 'node') {
+ const p = lay.pos[id];
+ if (!p) return;
+ x = p.x + p.w / 2;
+ y = p.y + lay.drawings[id].h / 2;
+ } else {
+ const r = lay.routes[id];
+ if (!r) return;
+ x = r.mid.x;
+ y = r.mid.y;
+ }
+ const k = Math.max(state.view.k, 0.55);
+ state.view = { k, tx: canvas.clientWidth / 2 - x * k, ty: canvas.clientHeight / 2 - y * k };
+ applyView();
+ if (kind === 'link') {
+ const link = state.data.links.find(l => l.id === id);
+ if (link) showPanel(link);
+ } else {
+ hidePanel();
+ highlight(id);
+ }
+ canvas.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+}
+
+// ── 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();
+ renderSummary();
+ const warn = document.getElementById('tp-warnings');
+ warn.hidden = !(data.warnings && data.warnings.length);
+ warn.innerHTML = (data.warnings || []).map(w => `${esc(w)}
`).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);
+});
diff --git a/templates/base.html b/templates/base.html
index ac8a75f..a4617d2 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -46,6 +46,9 @@
Rack
+ Topology
{% if user.groups and 'admin' in user.groups %}
Rack
+
+ Topology
+
{% if user.groups and 'admin' in user.groups %}
{% endif %}
-
-
-
-
-
-
-
-
- {%- set topo_h = snapshot.hosts if snapshot.hosts else {} -%}
-
-
-
-
- ◈
- INTERNET
- WAN uplink
-
-
-
-
-
-
-
-
-
- ◈
- BGW320
- AT&T Gateway
- Rx -
-
-
-
-
-
-
-
-
-
- ⬡
- UDM-Pro
- Dream Machine Pro
- RU24
-
-
-
-
-
-
-
-
-
- ⬡
- USW-Agg
- Aggregation · RU22
- 8 × 10G SFP+
- VLAN90 · 10.10.90.x/24
-
-
-
-
-
-
-
-
-
- ⬡
- Pro 24 PoE
- 24-Port · RU23
- 24 × 1G PoE
- DHCP · mgmt
-
-
-
-
-
-
-
-
-
-
-
-
← USW-Agg · 10G SFP+ · VLAN90 →
-
-
-
-
-
-
← Pro 24 PoE · 1G · DHCP mgmt →
-
-
-
-
-
- {%- set all_defs = [
- ('compute-storage-gpu-01', 'csg-01', 'RU4-12', 'Ceph · VLAN90', False),
- ('compute-storage-01', 'cs-01', 'RU14-17', 'Ceph · VLAN90', False),
- ('storage-01', 'sto-01', 'rack', 'Ceph · VLAN90', False),
- ('monitor-01', 'mon-01', 'ZimaBoard', 'mgmt', False),
- ('monitor-02', 'mon-02', 'ZimaBoard', 'mgmt', False),
- ('large1', 'large1', 'off-rack', 'table', True),
- ] -%}
- {%- for hname, hlabel, hsub, hvlan, off_rack in all_defs -%}
- {%- set st = topo_h[hname].status if hname in topo_h else 'unknown' -%}
-
-
-
-
-
- ▣
- {{ hlabel }}
- {{ hsub }}
- {{ hvlan }}
- {{ st if st != 'unknown' else '-' }}
-
-
- {%- endfor -%}
-
-
-
-
-
-
-
WAN / uplink
-
10G SFP+ (Ceph / VLAN90)
-
1G DHCP (mgmt)
-
dashed border = off-rack
-
-
-
-
-
-
-
-
-
{% if recent_resolved %}
@@ -551,30 +381,6 @@
if (s.refreshInterval > 0) lt.autoRefresh.start(refreshAll, s.refreshInterval * 1000);
};
- // ── Topology collapse toggle ───────────────────────────────────
- (function() {
- const LS_KEY = 'gandalf_topo_collapsed';
- const btn = document.getElementById('topo-toggle-btn');
- const wrap = document.getElementById('topo-collapsible-wrap');
- if (!btn || !wrap) return;
-
- function setCollapsed(v) {
- wrap.classList.toggle('is-collapsed', v);
- wrap.setAttribute('aria-hidden', v ? 'true' : 'false');
- btn.setAttribute('aria-expanded', v ? 'false' : 'true');
- btn.textContent = v ? '▾ Expand' : '▴ Collapse';
- try { localStorage.setItem(LS_KEY, v ? '1' : '0'); } catch(_) {}
- }
-
- let saved = false;
- try { saved = localStorage.getItem(LS_KEY) === '1'; } catch(_) {}
- setCollapsed(saved);
-
- btn.addEventListener('click', function() {
- setCollapsed(!wrap.classList.contains('is-collapsed'));
- });
- })();
-
function updateEventAges() {
document.querySelectorAll('.event-age[data-ts]').forEach(el => {
el.textContent = lt.time.ago(_toIso(el.dataset.ts));
@@ -635,12 +441,6 @@
initDeviceSearch();
- // Topology hosts open their device page
- document.getElementById('topology-diagram')?.addEventListener('click', e => {
- const node = e.target.closest('.topo-host[data-host]');
- if (node) window.location.href = deviceUrl(node.dataset.host);
- });
-
// Stat card clicks: filter events table by severity
document.querySelectorAll('.lt-stat-card[data-stat-filter]').forEach(card => {
card.addEventListener('click', () => {
diff --git a/templates/topology.html b/templates/topology.html
new file mode 100644
index 0000000..04eef79
--- /dev/null
+++ b/templates/topology.html
@@ -0,0 +1,61 @@
+{% extends "base.html" %}
+{% block title %}Topology{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
Loading topology
+
+
+
+
+
DAC
+
Fibre (multimode)
+
Fibre (provider)
+
SFP+ module
+
Copper
+
Thicker = faster (10G, 1G, 100M)
+
Down
+
Last seen
+
⚠ Port name points at another device
+
+{% endblock %}
+
+{% block scripts %}
+
+
+
+{% endblock %}
diff --git a/tests/test_app.py b/tests/test_app.py
index 355fab3..6e06352 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -96,10 +96,15 @@ class TestPagesAndNav:
html = client.get('/', headers=AUTH).get_data(as_text=True)
order = [html.index(m) for m in (
'id="device-search"', 'Alert Queue', 'WAN / Fiber', 'g-section-title">Hosts<',
- 'Device Inventory', 'Network Topology',
+ 'Device Inventory',
)]
assert order == sorted(order)
+ def test_dashboard_topology_moved_to_its_own_page(self, client):
+ html = client.get('/', headers=AUTH).get_data(as_text=True)
+ assert 'topology-diagram' not in html and 'Network Topology' not in html
+ assert 'href="/topology"' in html
+
def test_old_links_url_redirects_home(self, client):
resp = client.get('/links', headers=AUTH)
assert resp.status_code == 302 and resp.headers['Location'].endswith('/')
@@ -385,7 +390,8 @@ class TestHealth:
class TestPageTitles:
@pytest.mark.parametrize('path,title', [('/', 'Dashboard | GANDALF'), ('/events', 'Alert History | GANDALF'),
- ('/cutsheet', 'Cutsheet | GANDALF')])
+ ('/cutsheet', 'Cutsheet | GANDALF'),
+ ('/topology', 'Topology | GANDALF')])
def test_name_appears_once(self, client, path, title):
gandalf._cutsheet_schema_ready = True
html = client.get(path, headers=AUTH).get_data(as_text=True)
@@ -703,3 +709,37 @@ class TestFollowUp:
html = client.get('/', headers=AUTH).get_data(as_text=True)
assert '' in html
assert 'TO FOLLOW UP' not in html
+
+
+class TestTopology:
+ def test_page_needs_auth(self, client):
+ assert client.get('/topology').status_code == 401
+ assert client.get('/api/topology').status_code == 401
+
+ def test_page_loads_the_drawings_first(self, client):
+ html = client.get('/topology', headers=AUTH).get_data(as_text=True)
+ assert 'id="tp-canvas"' in html
+ assert html.index('faceplates.js') < html.index('fronts.js') < html.index('topology.js')
+
+ def test_api_returns_the_graph(self, client):
+ gandalf._cutsheet_schema_ready = True
+ with patch.object(gandalf.db, 'cutsheet_load', return_value={'items': [], 'cables': []}):
+ data = client.get('/api/topology', headers=AUTH).get_json()
+ assert {'nodes', 'links', 'warnings', 'updated'} <= set(data)
+ names = {n['name'] for n in data['nodes']}
+ assert {'large1', 'USW Flex', 'Internet'} <= names
+
+ def test_api_survives_a_cutsheet_failure(self, client):
+ gandalf._cutsheet_schema_ready = True
+ with patch.object(gandalf.db, 'cutsheet_load', side_effect=RuntimeError('db down')):
+ assert client.get('/api/topology', headers=AUTH).status_code == 200
+
+ def test_suppressed_alerts_are_not_drawn(self, client):
+ gandalf._cutsheet_schema_ready = True
+ ev = {'id': 1, 'event_type': 'host_down', 'severity': 'critical', 'target_name': 'large1',
+ 'target_detail': '', 'description': ''}
+ with patch.object(gandalf.db, 'cutsheet_load', return_value={'items': [], 'cables': []}), \
+ patch.object(gandalf.db, 'get_active_events', return_value=[ev]), \
+ patch.object(gandalf.db, 'check_suppressed', return_value=True):
+ data = client.get('/api/topology', headers=AUTH).get_json()
+ assert next(n for n in data['nodes'] if n['name'] == 'large1')['alert_count'] == 0
diff --git a/tests/test_monitor.py b/tests/test_monitor.py
index db594a7..2e6882c 100644
--- a/tests/test_monitor.py
+++ b/tests/test_monitor.py
@@ -642,9 +642,11 @@ class TestLldpCollection:
{'metric': {'device': 'vmbr0', 'address': mac, 'instance': '10.0.0.1:9100'}},
]
switches = {'sw': {'ports': {'p': {'lldp': {'chassis_id': 'x', 'port_id': mac.upper()}}}}}
- collector._resolve_neighbors(switches, {}, {'10.0.0.1:9100': 'alpha'})
+ host_macs = collector._resolve_neighbors(switches, {}, {'10.0.0.1:9100': 'alpha'})
lldp = switches['sw']['ports']['p']['lldp']
assert (lldp['system_name'], lldp['port_id']) == ('alpha', 'eth0')
+ # Kept for the topology page: the physical NIC, not the bond or bridge
+ assert host_macs == {mac: ('alpha', 'eth0')}
def test_client_list_failure_is_empty(self):
c = monitor.UnifiClient({'controller': 'https://u.test', 'api_key': 'k'})
diff --git a/tests/test_topology.py b/tests/test_topology.py
new file mode 100644
index 0000000..2eade1a
--- /dev/null
+++ b/tests/test_topology.py
@@ -0,0 +1,338 @@
+"""Tests for topology.build_graph, on a small network shaped like the real one."""
+import os
+import sys
+
+import pytest
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
+
+import topology # noqa: E402
+
+
+def port(idx, name=None, up=True, speed=1000, neighbour='', port_id='', chassis='', uplink=False, sfp=None,
+ tx=0.0, rx=0.0, source=None):
+ lldp = {}
+ if neighbour or chassis:
+ lldp = {'system_name': neighbour, 'port_id': port_id, 'chassis_id': chassis, 'port_desc': '', 'mgmt_ips': []}
+ if source:
+ lldp['source'] = source
+ p = {'port_idx': idx, 'up': up, 'speed_mbps': speed if up else 0, 'is_uplink': uplink, 'media': 'GE',
+ 'lldp': lldp, 'tx_bytes_rate': tx, 'rx_bytes_rate': rx, 'rx_errs_rate': 0.0, 'tx_errs_rate': 0.0,
+ 'rx_drops_rate': 0.0, 'tx_drops_rate': 0.0}
+ if sfp:
+ p['sfp'] = dict({'found': True, 'vendor': 'OEM', 'serial': 'SN1'}, **sfp)
+ p['media'] = 'SFP+'
+ return (name or f'Port {idx}'), p
+
+
+def ports(*items):
+ return dict(items)
+
+
+def dac(peer=None, peer_port=None):
+ s = {'kind': 'dac', 'part': 'SFP-H10GB-CU1M'}
+ if peer:
+ s['cable_peer'] = {'switch': peer, 'port_idx': peer_port}
+ return s
+
+
+def network(poe_port_11='Port 11', pbs_up=True):
+ """Gateway -> aggregation -> PoE switch, two dual-homed hosts, an AP, a PDU and unknown devices."""
+ switches = {
+ 'Dream Machine Pro': {'model': 'UDMPRO', 'ports': ports(
+ port(1, neighbour='host-a', port_id='eno2', source='unifi-clients'),
+ port(8), # up, nothing reported: a cable to something Gandalf can't see
+ port(9, uplink=True, chassis='02:00:5e:00:00:09', port_id='02:00:5e:00:00:0a'),
+ port(11, 'Downlink to USW-Aggregation', speed=10000, neighbour='8SFP-AGG', port_id='local Port 7',
+ sfp=dac('8SFP-AGG', 7), tx=2000.0, rx=1000.0),
+ )},
+ '8SFP-AGG': {'model': 'USL8A', 'ports': ports(
+ port(1, 'SFP+ 1', speed=10000, neighbour='host-a', port_id='eno1', sfp={'kind': 'dac', 'part': 'X'}),
+ port(2, 'SFP+ 2', speed=10000, neighbour='host-b', port_id='enp1s0', sfp={'kind': '', 'part': 'L56'}),
+ port(5, 'Downlink to 24RJ45-POE', speed=10000, neighbour='24RJ45-POE', port_id='Port 26',
+ sfp=dac('24RJ45-POE', 26)),
+ port(7, 'SFP+ 7', speed=10000, neighbour='Dream Machine Pro', port_id='02:00:5e:00:00:11',
+ uplink=True, sfp=dac('Dream Machine Pro', 11), tx=1000.0, rx=2000.0),
+ port(8, 'SFP+ 8', speed=10000, neighbour='host-c', port_id='nic4', sfp={'kind': 'optic', 'part': 'SR'}),
+ )},
+ '24RJ45-POE': {'model': 'US24PRO', 'ports': ports(
+ port(1, 'Micro 2 Eth2', chassis='02:00:5e:00:01:01', port_id='02:00:5E:00:01:01'),
+ port(3, 'Living Room Downlink', neighbour='USW Flex', port_id='02:00:5E:00:02:01'),
+ port(4, neighbour='host-b', port_id='enp2s0', source='unifi-clients'),
+ port(8, neighbour='192.0.2.44', port_id='eth0', chassis='02:00:5e:00:01:08', source='unifi-clients'),
+ port(11, poe_port_11, neighbour='AP', port_id='02:00:5E:00:03:01'),
+ port(12, up=False),
+ port(15, 'PBS', up=pbs_up, neighbour='pbs' if pbs_up else '', port_id='enp0s25' if pbs_up else ''),
+ port(19, neighbour='RPDU', port_id='02:00:5E:00:04:01', speed=100),
+ port(26, 'SFP+ 2', speed=10000, neighbour='8SFP-AGG', port_id='Port 5', uplink=True,
+ sfp=dac('8SFP-AGG', 5)),
+ )},
+ 'USW Flex': {'model': 'USF5P', 'ports': ports(
+ port(1, uplink=True, neighbour='24RJ45-POE', port_id='Port 3'),
+ port(5, neighbour='UPS 2U Pro', port_id='Port 1', speed=100),
+ )},
+ 'RPDU': {'model': 'USPPDUP', 'ports': ports(port(1, uplink=True, speed=100))},
+ }
+ unifi = [
+ {'name': 'Dream Machine Pro', 'type': 'udm', 'model': 'UDMPRO', 'connected': True, 'mac': '', 'ip': ''},
+ {'name': '8SFP-AGG', 'type': 'usw', 'model': 'USL8A', 'connected': True, 'mac': '', 'ip': ''},
+ {'name': '24RJ45-POE', 'type': 'usw', 'model': 'US24PRO', 'connected': True, 'mac': '', 'ip': ''},
+ {'name': 'USW Flex', 'type': 'usw', 'model': 'USF5P', 'connected': True, 'mac': '', 'ip': ''},
+ {'name': 'AP', 'type': 'uap', 'model': 'UAL6', 'connected': True, 'mac': '', 'ip': ''},
+ {'name': 'RPDU', 'type': 'usw', 'model': 'USPPDUP', 'connected': True, 'mac': '', 'ip': ''},
+ {'name': 'UPS 2U Pro', 'type': 'usp', 'model': 'USPDA2B', 'connected': True, 'mac': '', 'ip': ''},
+ {'name': 'USP Strip', 'type': 'uap', 'model': 'UP6', 'connected': True, 'mac': '', 'ip': ''},
+ ]
+ hosts = {
+ 'host-a': {'status': 'up', 'interfaces': {'eno1': 'up', 'eno2': 'up', 'eno3': 'unused', 'vmbr0': 'up'}},
+ 'host-b': {'status': 'degraded',
+ 'interfaces': {'bond0': 'up', 'enp1s0': 'up', 'enp2s0': 'up', 'enp3s0': 'down'}},
+ 'host-c': {'status': 'up', 'interfaces': {'nic4': 'up'}},
+ 'pbs': {'status': 'up' if pbs_up else 'down', 'interfaces': {'enp0s25': 'up' if pbs_up else 'down'}},
+ }
+ snapshot = {'hosts': hosts, 'unifi': unifi}
+ link_stats = {
+ 'hosts': {'192.0.2.44': {'eth0': {}}}, # an IP-only Prometheus target
+ 'unifi_switches': switches,
+ 'unifi_devices': {'Dream Machine Pro': {'wan': {'wan1': {'up': True}}},
+ 'RPDU': {'power_w': 120.5, 'outlets': [{'index': 1, 'name': 'AT&T Modem', 'on': True}]}},
+ 'updated': '2026-11-14 12:00:00 UTC',
+ }
+ modem = {'name': 'att-modem', 'model': 'BGW320-505', 'reachable': True, 'broadband_state': 'Up',
+ 'source': 'FIBER', 'line_speed_mbps': 10000, 'rates': {'rx_bytes': 5000.0, 'tx_bytes': 700.0},
+ 'wan_problems': []}
+ return snapshot, link_stats, modem
+
+
+def build(events=(), items=(), cables=(), known=None, **kw):
+ snapshot, link_stats, modem = network(**kw)
+ return topology.build_graph(snapshot, link_stats, modem, list(events), list(items), list(cables), known)
+
+
+def find(graph, a, b):
+ """The links between two nodes, either way round."""
+ return [lk for lk in graph['links'] if {lk['a']['node'], lk['b']['node']} == {a, b}]
+
+
+def node(graph, name):
+ return next(n for n in graph['nodes'] if n['id'] == name)
+
+
+class TestSwitchCables:
+ def test_cable_seen_from_both_ends_is_drawn_once(self):
+ g = build()
+ assert len(find(g, 'Dream Machine Pro', '8SFP-AGG')) == 1
+ assert len(find(g, '8SFP-AGG', '24RJ45-POE')) == 1
+ assert len(find(g, '24RJ45-POE', 'USW Flex')) == 1
+
+ def test_both_ports_of_a_switch_cable_are_known(self):
+ g = build()
+ (link,) = find(g, 'Dream Machine Pro', '8SFP-AGG')
+ ends = {link['a']['node']: link['a']['port_idx'], link['b']['node']: link['b']['port_idx']}
+ assert ends == {'Dream Machine Pro': 11, '8SFP-AGG': 7}
+ (flex,) = find(g, '24RJ45-POE', 'USW Flex')
+ assert {flex['a']['port_idx'], flex['b']['port_idx']} == {3, 1}
+
+ def test_gear_without_lldp_lands_on_its_uplink(self):
+ (pdu,) = find(build(), '24RJ45-POE', 'RPDU')
+ assert pdu['b'] == {'node': 'RPDU', 'port_idx': 1, 'port_name': 'Port 1', 'iface': ''}
+
+ def test_cables_point_away_from_the_internet(self):
+ g = build()
+ (link,) = find(g, 'Dream Machine Pro', '8SFP-AGG')
+ assert link['a']['node'] == 'Dream Machine Pro'
+ # The gateway sends 2000 B/s down the cable and gets 1000 back, whichever end measured it
+ assert (link['a_to_b_bps'], link['b_to_a_bps']) == (2000.0, 1000.0)
+ (down,) = find(g, '8SFP-AGG', '24RJ45-POE')
+ assert down['a']['node'] == '8SFP-AGG'
+
+ def test_medium_and_module(self):
+ g = build()
+ assert find(g, 'Dream Machine Pro', '8SFP-AGG')[0]['medium'] == 'dac'
+ assert find(g, '8SFP-AGG', 'host-c')[0]['medium'] == 'fibre'
+ assert find(g, '8SFP-AGG', 'host-b')[0]['medium'] == 'sfp'
+ assert find(g, '24RJ45-POE', 'host-b')[0]['medium'] == 'copper'
+ assert find(g, 'Dream Machine Pro', '8SFP-AGG')[0]['sfp_part'] == 'SFP-H10GB-CU1M'
+
+
+class TestWanEdge:
+ def test_gateway_to_modem_to_internet(self):
+ g = build()
+ (wan,) = find(g, 'Dream Machine Pro', 'att-modem')
+ assert wan['wan'] and wan['b']['node'] == 'Dream Machine Pro' and wan['a']['node'] == 'att-modem'
+ assert wan['b']['port_idx'] == 9
+ (fibre,) = find(g, 'att-modem', topology.INTERNET)
+ assert fibre['medium'] == 'fibre' and fibre['speed_mbps'] == 10000 and fibre['up']
+ # Down from the internet is what the modem received
+ assert fibre['a_to_b_bps'] == 5000.0
+
+ def test_the_wan_port_is_not_a_stub(self):
+ assert 'stub:Dream Machine Pro:9' not in {n['id'] for n in build()['nodes']}
+
+ def test_without_a_modem_the_gateway_goes_straight_out(self):
+ snapshot, link_stats, _ = network()
+ g = topology.build_graph(snapshot, link_stats, None, [], [], [], None)
+ assert len(find(g, 'Dream Machine Pro', topology.INTERNET)) == 1
+
+ def test_internet_status_follows_the_gateway_wan(self):
+ snapshot, link_stats, modem = network()
+ link_stats['unifi_devices']['Dream Machine Pro']['wan']['wan1']['up'] = False
+ g = topology.build_graph(snapshot, link_stats, modem, [], [], [], None)
+ assert node(g, topology.INTERNET)['status'] == 'down'
+
+
+class TestHosts:
+ def test_dual_homed_host_has_two_cables(self):
+ g = build()
+ a = [lk for lk in g['links'] if 'host-a' in (lk['a']['node'], lk['b']['node'])]
+ assert sorted((lk['speed_mbps'], lk['b']['iface']) for lk in a) == [(1000, 'eno2'), (10000, 'eno1')]
+
+ def test_physical_nics_only_with_the_cables_on_them(self):
+ nics = {n['name']: n for n in node(build(), 'host-a')['interfaces']}
+ assert set(nics) == {'eno1', 'eno2', 'eno3'}
+ assert nics['eno3']['unused'] and 'links' not in nics['eno3']
+ assert len(nics['eno1']['links']) == 1
+
+ def test_ip_only_target_is_a_stub_not_a_host(self):
+ g = build()
+ assert '192.0.2.44' not in {n['id'] for n in g['nodes'] if n['kind'] != 'stub'}
+ stub = node(g, 'stub:24RJ45-POE:8')
+ assert stub['name'] == '192.0.2.44' and stub['ip'] == '192.0.2.44' and stub['iface'] == 'eth0'
+
+ def test_chassis_comes_from_the_cutsheet_item(self):
+ items = [{'name': 'Server A', 'device': 'host-a', 'chassis': 'sliger-cx4712-red'},
+ {'name': 'host-b', 'device': '', 'chassis': 'zimaboard-832-cx3'}]
+ g = build(items=items)
+ assert node(g, 'host-a')['chassis'] == 'sliger-cx4712-red'
+ assert node(g, 'host-b')['chassis'] == 'zimaboard-832-cx3'
+ assert node(g, 'host-c')['chassis'] == ''
+
+
+class TestStubs:
+ def test_unnamed_neighbour_is_a_stub_named_by_its_port(self):
+ stub = node(build(), 'stub:24RJ45-POE:1')
+ assert stub['kind'] == 'stub' and stub['name'] == 'Micro 2 Eth2'
+ assert stub['mac'] == '02:00:5e:00:01:01' and not stub['anonymous']
+
+ def test_link_with_no_neighbour_is_an_anonymous_stub(self):
+ stub = node(build(), 'stub:Dream Machine Pro:8')
+ assert stub['anonymous'] and stub['name'] == 'unknown device'
+
+ def test_ports_with_no_link_are_left_out(self):
+ assert 'stub:24RJ45-POE:12' not in {n['id'] for n in build()['nodes']}
+
+
+class TestNameMismatch:
+ def test_port_named_after_another_device(self):
+ (link,) = find(build(poe_port_11='Large1 Management'), '24RJ45-POE', 'AP')
+ assert link['name_mismatch'] is None # no device called large1 in this network
+
+ def test_flags_when_the_named_device_exists(self):
+ (link,) = find(build(poe_port_11='host-b management'), '24RJ45-POE', 'AP')
+ assert link['name_mismatch'] == {'named': 'host-b', 'actual': 'AP', 'end': 'a'}
+
+ def test_downlink_and_generic_names_are_fine(self):
+ g = build()
+ assert all(lk['name_mismatch'] is None for lk in g['links'])
+
+ @pytest.mark.parametrize('name', ['Port 4', 'SFP+ 2', 'Uplink', 'Downlink to 24RJ45-POE'])
+ def test_names_that_never_flag(self, name):
+ known = {topology._norm(n): n for n in ('24RJ45-POE', 'host-a')}
+ assert topology.name_mismatch(name, '24RJ45-POE', 'x', known) is None
+
+ def test_matches_ignore_case_spacing_and_dashes(self):
+ known = {topology._norm('compute-storage-01'): 'compute-storage-01'}
+ assert topology.name_mismatch('Compute Storage 01 mgmt', 'AP', 'sw', known)['named'] == 'compute-storage-01'
+
+ def test_a_stub_is_checked_against_what_was_seen(self):
+ snapshot, link_stats, modem = network()
+ link_stats['unifi_switches']['24RJ45-POE']['ports']['host-c'] = port(
+ 20, 'host-c', chassis='02:00:5e:00:09:09')[1]
+ g = topology.build_graph(snapshot, link_stats, modem, [], [], [], None)
+ (link,) = find(g, '24RJ45-POE', 'stub:24RJ45-POE:20')
+ assert link['name_mismatch']['named'] == 'host-c'
+
+
+class TestGhosts:
+ KNOWN = {'pbs': {'seen': '2026-11-14 20:49:08 UTC', 'uplinks': [
+ {'switch': '24RJ45-POE', 'port': 'PBS', 'port_idx': 15, 'host_interface': 'enp0s25'}]}}
+
+ def test_missing_host_is_drawn_where_last_seen(self):
+ g = build(known=self.KNOWN, pbs_up=False)
+ (ghost,) = find(g, '24RJ45-POE', 'pbs')
+ assert ghost['ghost'] and not ghost['up'] and ghost['seen'] == '2026-11-14 20:49:08 UTC'
+ assert ghost['b'] == {'node': 'pbs', 'port_idx': None, 'port_name': '', 'iface': 'enp0s25'}
+
+ def test_no_ghost_while_the_host_is_linked(self):
+ g = build(known=self.KNOWN)
+ assert [lk['ghost'] for lk in find(g, '24RJ45-POE', 'pbs')] == [False]
+
+ def test_no_ghost_onto_a_port_now_used_by_something_else(self):
+ known = {'pbs': {'seen': 'x', 'uplinks': [{'switch': '24RJ45-POE', 'port_idx': 4, 'host_interface': 'e'}]}}
+ assert find(build(known=known, pbs_up=False), '24RJ45-POE', 'pbs') == []
+
+
+class TestDepth:
+ def test_hops_from_the_internet(self):
+ g = build()
+ depths = {n['id']: n['depth'] for n in g['nodes']}
+ assert depths[topology.INTERNET] == 0
+ assert depths['att-modem'] == 1
+ assert depths['Dream Machine Pro'] == 2
+ assert depths['8SFP-AGG'] == 3
+ assert depths['24RJ45-POE'] == 4
+ assert depths['USW Flex'] == 5 and depths['AP'] == 5
+ assert depths['UPS 2U Pro'] == 6
+ # A host's depth is its nearest switch's plus one; hosts never pass the path on
+ assert depths['host-a'] == 3
+
+ def test_unlinked_gear_has_no_depth(self):
+ assert node(build(), 'USP Strip')['depth'] is None
+
+ def test_power_gear_is_marked(self):
+ g = build()
+ assert {n['id']: n['kind'] for n in g['nodes'] if n['power']} == {
+ 'RPDU': 'pdu', 'UPS 2U Pro': 'ups', 'USP Strip': 'strip'}
+ assert node(g, 'RPDU')['power_w'] == 120.5
+
+
+class TestAlerts:
+ def test_active_alerts_count_on_their_device(self):
+ events = [
+ {'id': 1, 'event_type': 'interface_down', 'severity': 'critical', 'target_name': 'host-b',
+ 'target_detail': 'enp3s0', 'description': ''},
+ {'id': 2, 'event_type': 'unifi_port_errors', 'severity': 'warning', 'target_name': '24RJ45-POE',
+ 'target_detail': 'Port 9', 'description': ''},
+ {'id': 3, 'event_type': 'host_down', 'severity': 'critical', 'target_name': 'host-a',
+ 'target_detail': '', 'description': '', 'is_suppressed': True},
+ ]
+ g = build(events=events)
+ b = node(g, 'host-b')
+ assert (b['alert_count'], b['severity']) == (1, 'critical')
+ assert next(n for n in b['interfaces'] if n['name'] == 'enp3s0')['alert'] == 'critical'
+ assert node(g, '24RJ45-POE')['severity'] == 'warning'
+ assert node(g, 'host-a')['alert_count'] == 0
+
+ def test_status_comes_from_the_snapshot(self):
+ assert node(build(), 'host-b')['status'] == 'degraded'
+
+
+class TestCutsheetLabels:
+ def test_cable_label_on_either_end(self):
+ items = [{'name': 'Patch', 'device': '', 'chassis': ''}, {'name': 'UDM-Pro', 'device': 'Dream Machine Pro'}]
+ cables = [{'id': 7, 'label': 'C07', 'a_device': 'UDM-Pro', 'a_port': '8', 'b_device': 'Patch',
+ 'b_port': '1', 'status': 'installed', 'cable_type': 'Cat6', 'color': 'Black'},
+ {'id': 8, 'label': 'C08', 'a_device': 'host-a', 'a_port': 'eno2', 'b_device': '',
+ 'b_port': '', 'status': 'retired'}]
+ g = build(items=items, cables=cables)
+ (stub,) = find(g, 'Dream Machine Pro', 'stub:Dream Machine Pro:8')
+ assert stub['cable'] == {'id': 7, 'label': 'C07', 'type': 'Cat6', 'color': 'Black'}
+ # Retired cables are not on the network any more
+ assert find(g, 'Dream Machine Pro', 'host-a')[0]['cable'] is None
+
+
+class TestEmpty:
+ def test_no_data_yet(self):
+ g = topology.build_graph({}, {}, None, [], [], [], None)
+ assert [n['id'] for n in g['nodes']] == [topology.INTERNET]
+ assert g['links'] == [] and g['warnings']
diff --git a/topology.py b/topology.py
new file mode 100644
index 0000000..90fcbf9
--- /dev/null
+++ b/topology.py
@@ -0,0 +1,411 @@
+"""The live network topology: every device and every physical cable between them.
+
+Pure functions with no I/O. app.py passes in the monitor's cached state and the
+cutsheet, and the /topology page draws what build_graph returns.
+
+The graph comes from the switches' point of view: each UniFi port with a neighbour
+is one end of a cable. The same switch-to-switch cable is seen from both ends, so
+those are merged into one link.
+"""
+import re
+from collections import deque
+from typing import Dict, List, Optional, Tuple
+
+import devices
+
+INTERNET = 'internet'
+
+# How UniFi devices are drawn and grouped. Power gear goes in its own rail.
+_POWER_MODELS = {'USPPDUP': 'pdu', 'USPDA2B': 'ups', 'UP6': 'strip'}
+POWER_KINDS = ('pdu', 'ups', 'strip')
+
+_SEVERITY_RANK = {'critical': 3, 'warning': 2, 'info': 1}
+_PORT_REF_RE = re.compile(r'(?:^|\s)port\s*(\d+)$', re.IGNORECASE)
+_TOKEN_RE = re.compile(r'[a-z0-9]+')
+
+
+def _norm(name: str) -> str:
+ """A name with case, spaces, dashes and underscores ignored: "Large1" == "large-1"."""
+ return ''.join(_TOKEN_RE.findall((name or '').lower()))
+
+
+def _node_kind(entry: dict) -> str:
+ if entry['kind'] == 'pdu':
+ return _POWER_MODELS.get(entry.get('model', ''), 'pdu')
+ return entry['kind']
+
+
+def _medium(port: dict) -> str:
+ """dac, fibre, sfp (a module that doesn't say which) or copper."""
+ sfp = port.get('sfp') or {}
+ if sfp.get('found') or sfp.get('part'):
+ kind = sfp.get('kind') or ''
+ if kind == 'dac':
+ return 'dac'
+ if kind == 'optic':
+ return 'fibre'
+ return 'sfp'
+ if str(port.get('media') or '').upper().startswith('SFP'):
+ return 'sfp' if port.get('up') else 'copper'
+ return 'copper'
+
+
+def _rate(value) -> float:
+ try:
+ return max(float(value or 0), 0.0)
+ except (TypeError, ValueError):
+ return 0.0
+
+
+def _utilisation(a_to_b: float, b_to_a: float, speed_mbps) -> Optional[float]:
+ try:
+ speed = float(speed_mbps or 0)
+ except (TypeError, ValueError):
+ speed = 0.0
+ if speed <= 0:
+ return None
+ return round(min(max(a_to_b, b_to_a) * 8 / (speed * 1e6), 1.0), 4)
+
+
+def _port_stats(port: dict) -> dict:
+ sfp = port.get('sfp') or {}
+ return {
+ 'speed_mbps': port.get('speed_mbps') or 0,
+ 'up': bool(port.get('up')),
+ 'medium': _medium(port),
+ 'sfp_part': sfp.get('part') or '',
+ 'sfp_vendor': sfp.get('vendor') or '',
+ 'sfp_serial': sfp.get('serial') or '',
+ 'errors': round(_rate(port.get('rx_errs_rate')) + _rate(port.get('tx_errs_rate')), 3),
+ 'drops': round(_rate(port.get('rx_drops_rate')) + _rate(port.get('tx_drops_rate')), 3),
+ }
+
+
+def _end(node: str, port_name: str = '', port_idx=None, iface: str = '') -> dict:
+ return {'node': node, 'port_idx': port_idx, 'port_name': port_name, 'iface': iface}
+
+
+def _far_port(switches: dict, here: str, port: dict, neighbour: str) -> Optional[int]:
+ """Which port on a neighbouring switch the cable from `port` lands on, if it can be told."""
+ peer = (port.get('sfp') or {}).get('cable_peer') or {}
+ if peer.get('switch') == neighbour and peer.get('port_idx') is not None:
+ return peer['port_idx']
+ far = switches.get(neighbour)
+ if not far:
+ return None
+ ports = [p for p in (far.get('ports') or {}).values() if p.get('port_idx') is not None]
+ # LLDP's port ID names the neighbour's own port ("Port 3", "local Port 7")
+ m = _PORT_REF_RE.search(str((port.get('lldp') or {}).get('port_id') or '').strip())
+ if m and any(p['port_idx'] == int(m.group(1)) for p in ports):
+ return int(m.group(1))
+ back = [p['port_idx'] for p in ports if (p.get('lldp') or {}).get('system_name') == here]
+ if len(back) == 1:
+ return back[0]
+ # Gear that sends no LLDP of its own (a PDU): its single uplink
+ uplinks = [p['port_idx'] for p in ports if p.get('is_uplink')]
+ return uplinks[0] if len(uplinks) == 1 else None
+
+
+def _is_generic_port_name(name: str) -> bool:
+ return bool(re.fullmatch(r'(port|sfp\+?|uplink|downlink)\s*\d*', (name or '').strip(), re.IGNORECASE))
+
+
+def name_mismatch(port_name: str, neighbour: str, own: str, known: Dict[str, str]) -> Optional[dict]:
+ """A port named after a different device than the one plugged into it.
+
+ known: {normalised name: display name} of every device. Runs of up to four words of
+ the port name are compared with them, so "Large1 Management" names large1 and
+ "Downlink to 24RJ45-POE" names 24RJ45-POE. Ports named only "Port 4" never match.
+ """
+ if not port_name or _is_generic_port_name(port_name):
+ return None
+ words = _TOKEN_RE.findall(port_name.lower())
+ skip = {_norm(neighbour), _norm(own)}
+ for size in range(min(4, len(words)), 0, -1):
+ for i in range(len(words) - size + 1):
+ run = ''.join(words[i:i + size])
+ if run in known and run not in skip:
+ return {'named': known[run], 'actual': neighbour}
+ return None
+
+
+def _cable_labels(items: List[dict], cables: List[dict]) -> Dict[Tuple[str, str], dict]:
+ """{(device, port): cable} for cutsheet cables still in place. A cable can name the
+ device itself or a cutsheet item linked to it."""
+ device_of = {i['name']: (i.get('device') or i['name']) for i in items or []}
+ out: Dict[Tuple[str, str], dict] = {}
+ for c in cables or []:
+ if c.get('status') == 'retired':
+ continue
+ for end in ('a', 'b'):
+ dev = c.get(f'{end}_device') or ''
+ if not dev:
+ continue
+ ref = {'id': c.get('id'), 'label': c.get('label') or '', 'type': c.get('cable_type') or '',
+ 'color': c.get('color') or ''}
+ for name in {dev, device_of.get(dev, dev)}:
+ out[(name, str(c.get(f'{end}_port') or ''))] = ref
+ return out
+
+
+def _alerts_by_target(events: List[dict]) -> Dict[str, List[dict]]:
+ out: Dict[str, List[dict]] = {}
+ for ev in events or []:
+ if ev.get('is_suppressed'):
+ continue
+ out.setdefault(ev.get('target_name') or '', []).append({
+ 'id': ev.get('id'), 'severity': ev.get('severity') or 'info',
+ 'type': ev.get('event_type') or '', 'detail': ev.get('target_detail') or '',
+ 'description': ev.get('description') or '',
+ })
+ return out
+
+
+def _worst(alerts: List[dict]) -> str:
+ return max((a['severity'] for a in alerts), key=lambda s: _SEVERITY_RANK.get(s, 0), default='')
+
+
+def build_graph(snapshot: dict, link_stats: dict, modem: Optional[dict], events: List[dict],
+ cutsheet_items: List[dict], cutsheet_cables: List[dict],
+ known_uplinks: Optional[dict] = None) -> dict:
+ """Every device and cable, as {nodes, links, warnings, updated}.
+
+ snapshot, link_stats, modem: the monitor's saved state.
+ events: active events, with is_suppressed set (suppressed ones are left out).
+ known_uplinks: the monitor's record of where each device was last plugged in,
+ drawn as "last seen" links for a device with no live link.
+ """
+ snapshot = snapshot or {}
+ link_stats = link_stats or {}
+ switches = link_stats.get('unifi_switches') or {}
+ unifi_info = link_stats.get('unifi_devices') or {}
+ alerts = _alerts_by_target(events)
+ labels = _cable_labels(cutsheet_items, cutsheet_cables)
+ chassis = {}
+ for item in cutsheet_items or []:
+ chassis.setdefault(item.get('device') or item['name'], item.get('chassis') or '')
+ warnings: List[str] = []
+
+ # ── Nodes ────────────────────────────────────────────────────────────
+ nodes: Dict[str, dict] = {}
+ configured = set(snapshot.get('hosts') or {})
+ for entry in devices.build_index(snapshot, link_stats, modem):
+ name = entry['name']
+ if entry['kind'] == 'host' and name not in configured:
+ continue # IP-only Prometheus targets: shown only as a stub where a port sees them
+ kind = _node_kind(entry)
+ node = {
+ 'id': name, 'name': name, 'kind': kind, 'status': entry['status'], 'model': entry.get('model', ''),
+ 'ip': entry.get('ip', ''), 'url': f'/device/{name}', 'power': kind in POWER_KINDS,
+ }
+ if kind == 'host':
+ node['chassis'] = chassis.get(name, '')
+ node['interfaces'] = devices.physical_interfaces(name, snapshot)
+ for nic in node['interfaces']:
+ nic_alerts = [a for a in alerts.get(name, []) if a['detail'] == nic['name']]
+ nic['alert'] = _worst(nic_alerts)
+ elif name in switches or kind in ('router', 'switch'):
+ node['ports'] = {
+ str(p['port_idx']): {'name': pname, 'up': bool(p.get('up')), 'speed_mbps': p.get('speed_mbps') or 0,
+ 'is_uplink': bool(p.get('is_uplink')), 'media': p.get('media') or '',
+ 'poe_power': p.get('poe_power'), 'sfp': p.get('sfp') or None,
+ 'tx_bytes_rate': _rate(p.get('tx_bytes_rate')),
+ 'rx_bytes_rate': _rate(p.get('rx_bytes_rate')),
+ 'neighbour': (p.get('lldp') or {}).get('system_name') or ''}
+ for pname, p in ((switches.get(name) or {}).get('ports') or {}).items()
+ if p.get('port_idx') is not None
+ }
+ if kind in POWER_KINDS:
+ info = unifi_info.get(name) or {}
+ node['power_w'] = info.get('power_w')
+ node['outlets'] = info.get('outlets') or []
+ node_alerts = alerts.get(name, [])
+ node['alerts'] = node_alerts[:10]
+ node['alert_count'] = len(node_alerts)
+ node['severity'] = _worst(node_alerts)
+ nodes[name] = node
+
+ router = next((n for n in nodes.values() if n['kind'] == 'router'), None)
+ modem_name = (modem or {}).get('name') if (modem or {}).get('name') in nodes else None
+ wan = ((unifi_info.get(router['name']) or {}).get('wan') or {}) if router else {}
+ wan_up = any(w.get('up') for w in wan.values()) if wan else None
+ if modem_name:
+ wan_up = (modem or {}).get('broadband_state', '').lower() == 'up' if wan_up is None else wan_up
+ nodes[INTERNET] = {
+ 'id': INTERNET, 'name': 'Internet', 'kind': INTERNET, 'model': '', 'ip': '', 'url': '',
+ 'status': 'unknown' if wan_up is None else ('up' if wan_up else 'down'),
+ 'alerts': alerts.get('WAN', [])[:10], 'alert_count': len(alerts.get('WAN', [])),
+ 'severity': _worst(alerts.get('WAN', [])), 'power': False,
+ }
+ known_names = {_norm(n['name']): n['name'] for n in nodes.values() if n['id'] != INTERNET}
+
+ # ── Links ────────────────────────────────────────────────────────────
+ links: List[dict] = []
+ claimed: Dict[Tuple[str, int], dict] = {}
+
+ def add_link(a: dict, b: dict, port: dict, **extra) -> dict:
+ link = {'id': '', 'a': a, 'b': b, 'ghost': False, 'name_mismatch': None,
+ 'cable': labels.get((a['node'], str(a['port_idx'] if a['port_idx'] is not None else a['iface'])))
+ or labels.get((b['node'], str(b['port_idx'] if b['port_idx'] is not None else b['iface']))),
+ **_port_stats(port),
+ '_tx': _rate(port.get('tx_bytes_rate')), '_rx': _rate(port.get('rx_bytes_rate'))}
+ link.update(extra)
+ links.append(link)
+ for end in (a, b):
+ if end['port_idx'] is not None and end['node'] in switches:
+ claimed[(end['node'], end['port_idx'])] = link
+ return link
+
+ def ordered_ports(sw: dict):
+ return sorted(((pn, p) for pn, p in (sw.get('ports') or {}).items() if p.get('port_idx') is not None),
+ key=lambda kv: kv[1]['port_idx'])
+
+ # The router's internet port goes to the modem, then the modem to the internet
+ wan_port = None
+ if router and router['name'] in switches:
+ wan_port = next(((pn, p) for pn, p in ordered_ports(switches[router['name']]) if p.get('is_uplink')), None)
+ upstream = modem_name or INTERNET
+ if wan_port:
+ pn, p = wan_port
+ add_link(_end(router['name'], pn, p['port_idx']), _end(upstream, 'WAN' if modem_name else ''), p, wan=True)
+ elif router:
+ warnings.append(f'No internet port found on {router["name"]}')
+ if modem_name:
+ m = modem or {}
+ rates = m.get('rates') or {}
+ up = str(m.get('broadband_state') or '').lower() == 'up'
+ links.append({
+ 'id': '', 'a': _end(INTERNET), 'b': _end(modem_name, str(m.get('source') or 'WAN').title()),
+ 'ghost': False, 'name_mismatch': None, 'cable': None, 'wan': True,
+ 'speed_mbps': m.get('line_speed_mbps') or 0, 'up': up,
+ 'medium': 'fibre' if str(m.get('source') or '').upper() == 'FIBER' else 'copper',
+ 'sfp_part': m.get('optics_part_no') or '', 'sfp_vendor': '', 'sfp_serial': '',
+ 'errors': round(_rate(rates.get('rx_errors')) + _rate(rates.get('tx_errors')), 3),
+ 'drops': round(_rate(rates.get('rx_drops')) + _rate(rates.get('tx_drops')), 3),
+ # Seen from the modem's broadband side: rx comes down from the internet
+ '_tx': _rate(rates.get('rx_bytes')), '_rx': _rate(rates.get('tx_bytes')),
+ })
+ elif not router:
+ warnings.append('No UniFi gateway reported yet, so the internet edge is not drawn')
+
+ # Device-to-device and device-to-host cables, from every switch port with a neighbour
+ for sw_name in sorted(switches):
+ for pname, port in ordered_ports(switches[sw_name]):
+ idx = port['port_idx']
+ if (sw_name, idx) in claimed:
+ continue
+ lldp = port.get('lldp') or {}
+ neighbour = lldp.get('system_name') or ''
+ here = _end(sw_name, pname, idx)
+ if neighbour in nodes and neighbour != sw_name:
+ nb = nodes[neighbour]
+ if nb['kind'] == 'host':
+ far = _end(neighbour, iface=lldp.get('port_id') or '')
+ else:
+ far_idx = _far_port(switches, sw_name, port, neighbour)
+ if far_idx is not None and (neighbour, far_idx) in claimed:
+ continue # the other end already drew this cable
+ far_port = next((pn for pn, p in ((switches.get(neighbour) or {}).get('ports') or {}).items()
+ if p.get('port_idx') == far_idx), '') if far_idx is not None else ''
+ far = _end(neighbour, far_port, far_idx)
+ link = add_link(here, far, port)
+ link['name_mismatch'] = name_mismatch(pname, neighbour, sw_name, known_names)
+ if link['name_mismatch'] is None and far['port_name']:
+ link['name_mismatch'] = name_mismatch(far['port_name'], sw_name, neighbour, known_names)
+ if link['name_mismatch']:
+ link['name_mismatch']['end'] = 'b'
+ elif link['name_mismatch']:
+ link['name_mismatch']['end'] = 'a'
+ continue
+ if port.get('is_uplink') and not neighbour:
+ continue # leads upstream to gear already drawn (a PDU's management port)
+ if not port.get('up') and not lldp:
+ continue
+ # Something is plugged in that Gandalf can't name: a grey stub
+ stub_id = f'stub:{sw_name}:{idx}'
+ mac = lldp.get('chassis_id') or ''
+ label = neighbour or ('' if _is_generic_port_name(pname) else pname) or mac or 'unknown device'
+ nodes[stub_id] = {
+ 'id': stub_id, 'name': label, 'kind': 'stub', 'status': 'up' if port.get('up') else 'down',
+ 'model': '', 'ip': neighbour if re.fullmatch(r'[\d.]+', neighbour) else '', 'mac': mac,
+ 'url': f'/device/{sw_name}#port-{idx}', 'switch': sw_name, 'port_idx': idx, 'port_name': pname,
+ 'iface': (lldp.get('port_id') or '') if neighbour else '', 'anonymous': not lldp,
+ 'alerts': [], 'alert_count': 0, 'severity': '', 'power': False,
+ }
+ link = add_link(here, _end(stub_id, iface=nodes[stub_id]['iface']), port)
+ # A stub labelled by its port name would always "match" it, so compare with what was seen
+ link['name_mismatch'] = name_mismatch(pname, neighbour or mac or 'an unnamed device', sw_name, known_names)
+ if link['name_mismatch']:
+ link['name_mismatch']['end'] = 'a'
+
+ # Gear with no live cable: draw where it was last plugged in
+ linked = {e['node'] for link in links for e in (link['a'], link['b'])}
+ for name, entry in sorted((known_uplinks or {}).items()):
+ node = nodes.get(name)
+ if not node or name in linked or node['kind'] == 'stub':
+ continue
+ for up in entry.get('uplinks') or []:
+ sw, idx = up.get('switch'), up.get('port_idx')
+ if sw not in nodes or idx is None or (sw, idx) in claimed:
+ continue
+ port = next((p for p in ((switches.get(sw) or {}).get('ports') or {}).values()
+ if p.get('port_idx') == idx), {})
+ far = (_end(name, iface=up.get('host_interface') or '') if node['kind'] == 'host'
+ else _end(name, '', None))
+ link = add_link(_end(sw, up.get('port') or '', idx), far, port,
+ ghost=True, seen=entry.get('seen') or '')
+ link['up'] = False
+
+ # ── Depth: hops from the internet over device-to-device cables ───────
+ adjacency: Dict[str, set] = {}
+ for link in links:
+ a, b = link['a']['node'], link['b']['node']
+ adjacency.setdefault(a, set()).add(b)
+ adjacency.setdefault(b, set()).add(a)
+ depth: Dict[str, int] = {INTERNET: 0}
+ queue = deque([INTERNET])
+ while queue:
+ cur = queue.popleft()
+ for nxt in sorted(adjacency.get(cur, ())):
+ if nxt in depth:
+ continue
+ depth[nxt] = depth[cur] + 1
+ # Hosts and stubs are leaves: they never carry the path on to another device
+ if nodes[nxt]['kind'] not in ('host', 'stub'):
+ queue.append(nxt)
+ for node in nodes.values():
+ node['depth'] = depth.get(node['id'])
+
+ # Orient each cable from the internet side (a) to the far side (b), and count traffic that way
+ for i, link in enumerate(links):
+ da = depth.get(link['a']['node'], 1 << 20)
+ db_ = depth.get(link['b']['node'], 1 << 20)
+ # Rates were measured at end a as added: the reporting switch port, or the internet side
+ tx, rx = link.pop('_tx'), link.pop('_rx')
+ if db_ < da:
+ link['a'], link['b'] = link['b'], link['a']
+ if link.get('name_mismatch') and link['name_mismatch'].get('end'):
+ link['name_mismatch']['end'] = 'a' if link['name_mismatch']['end'] == 'b' else 'b'
+ tx, rx = rx, tx
+ link['a_to_b_bps'] = round(tx, 1)
+ link['b_to_a_bps'] = round(rx, 1)
+ link['utilisation'] = _utilisation(tx, rx, link['speed_mbps'])
+ link['id'] = f'l{i}'
+
+ # Which host NIC each cable lands on, so the drawing can light the right socket
+ for link in links:
+ for end in (link['a'], link['b']):
+ node = nodes[end['node']]
+ if node['kind'] == 'host' and end['iface']:
+ for nic in node.get('interfaces', []):
+ if nic['name'] == end['iface']:
+ nic.setdefault('links', []).append(link['id'])
+
+ if not switches:
+ warnings.append('No UniFi switch data yet: the monitor has not reported any ports')
+ return {
+ 'nodes': sorted(nodes.values(), key=lambda n: (99 if n['depth'] is None else n['depth'], n['name'].lower())),
+ 'links': links,
+ 'warnings': warnings,
+ 'updated': link_stats.get('updated'),
+ }