Topology: a live page drawn from the real gear #39

Merged
jared merged 4 commits from topology into main 2026-11-14 20:10:20 -05:00
16 changed files with 2423 additions and 519 deletions
+50 -4
View File
@@ -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.
![Dashboard](docs/img/dashboard.png)
![Topology diagram](docs/img/topology.png)
**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.
![Topology](docs/img/topology.png)
![Host cards](docs/img/host-cards.png)
@@ -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
```
+31 -11
View File
@@ -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
+13
View File
@@ -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'
Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 163 KiB

+8 -3
View File
@@ -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."""
-28
View File
@@ -48,7 +48,6 @@ async function refreshAll() {
const net = netResult.value;
updateHostGrid(net.hosts || {});
updateUnifiTable(net.unifi || []);
updateTopology(net.hosts || {});
} else {
showToast('Network data unavailable', 'warning');
}
@@ -167,26 +166,6 @@ function updateHostGrid(hosts) {
}
}
function updateTopology(hosts) {
document.querySelectorAll('.topo-host').forEach(node => {
const name = node.dataset.host;
const host = hosts[name];
if (!host) return;
node.className = node.className.replace(/topo-v2-status-(up|down|degraded|unknown)/g, '');
node.classList.add(`topo-v2-status-${host.status}`);
const badge = node.querySelector('.topo-badge');
if (badge) {
badge.className = `topo-badge topo-badge-${host.status}`;
badge.textContent = host.status;
}
// Animate the 10G drop-wire red+dashed when host is down
document.querySelectorAll(`.topo-v2-wire-10g[data-host="${CSS.escape(name)}"]`).forEach(wire => {
wire.classList.toggle('wire-down', host.status === 'down');
});
});
}
// ── Device search ─────────────────────────────────────────────────────
function deviceUrl(name) {
return `/device/${encodeURIComponent(name)}`;
@@ -396,13 +375,9 @@ function updateModem(data) {
detailsLink.hidden = false;
}
const rxLabel = document.getElementById('topo-modem-rx');
const topo = document.getElementById('topo-modem');
const title = document.getElementById('modem-title');
if (!data.reachable) {
if (rxLabel) rxLabel.textContent = 'unreachable';
if (topo) topo.classList.add('topo-v2-status-down');
body.innerHTML = `
<div class="modem-error">Modem status unavailable: ${lt.escHtml(data.error || 'unknown error')}
<span class="modem-stat-note">Last attempt ${lt.escHtml(data.updated || '-')}</span></div>
@@ -415,10 +390,7 @@ function updateModem(data) {
const status = data.optics_status || {};
const counters = data.counters || {};
const rates = data.rates || {};
const rx = optics.rx_power ? optics.rx_power.value : null;
if (topo) topo.classList.toggle('topo-v2-status-down', problems.length > 0);
if (rxLabel) rxLabel.textContent = (rx === null || rx === undefined) ? 'Rx -' : `Rx ${rx} dBm`;
if (title) {
title.textContent = [data.model || 'AT&T Gateway', data.firmware && `fw ${data.firmware}`]
.filter(Boolean).join(' · ');
+12 -1
View File
@@ -1,5 +1,5 @@
'use strict';
/* exported renderFaceplate, faceplateScrollLeft, FACEPLATES */
/* exported renderFaceplate, faceplateScrollLeft, faceplatePortAnchor, FACEPLATES */
// Realistic front panels, drawn as SVG from each model's real geometry.
// Coordinates are traced from reference images. Keys match UniFi's model code.
@@ -639,6 +639,17 @@ function faceplateSvg(model, ctx, attrs = '') {
return { svg, marks };
}
// Where a port's cable plugs in, in the faceplate's viewBox units: { x, y, face }, face 'top' for a
// port the right way up and 'bottom' for a flipped one (the lower row). null when there is no such
// port. A model whose one port takes any index (the PDU's management port) answers for every index.
function faceplatePortAnchor(spec, idx) {
const p = spec.ports.find(q => q.idx === idx) || spec.ports.find(q => q.anyPort);
if (!p) return null;
const face = p.flip ? 'bottom' : 'top';
if (p.type === 'sfp') return { x: p.x + p.w / 2, y: p.y + p.h / 2, face };
return { x: p.x, y: p.y, face };
}
// Where an fp-scroll panel should start: its port area
function faceplateScrollLeft(model, frame) {
const spec = FACEPLATES[model];
+227 -269
View File
@@ -60,12 +60,6 @@
--glow-cyan: none;
--glow-xl: none;
}
[data-theme="light"] .topology {
background-image: radial-gradient(circle, rgba(0,100,160,0.07) 1px, transparent 1px);
}
[data-theme="light"] .topo-vc-label {
background: rgba(235,238,242,.88);
}
/* ── Header overlap fix ───────────────────────────────────────────
.lt-container's padding shorthand resets padding-top, defeating
@@ -151,23 +145,6 @@
/* ── Stats grid spacing variant ──────────────────────────────────── */
.lt-stats-grid--mb { margin-bottom: 16px; }
/* ── Topology section collapse toggle ────────────────────────────── */
.topo-collapse-btn {
margin-left: auto;
font-size: .7em;
color: var(--text-muted);
background: transparent;
border: 1px solid var(--border-color);
padding: 2px 8px;
cursor: pointer;
font-family: var(--font);
letter-spacing: .04em;
transition: border-color .15s, color .15s;
}
.topo-collapse-btn:hover { border-color: var(--amber); color: var(--amber); }
.topo-collapsible { overflow: hidden; transition: max-height .25s ease; }
.topo-collapsible.is-collapsed { display: none; }
/* ── Animations used by custom components ─────────────────────────── */
@keyframes pulse-red {
0%,100% { box-shadow: 0 0 0 0 rgba(255,45,85,.5); }
@@ -276,7 +253,7 @@
border: 1px solid;
letter-spacing: .04em;
}
.chip-critical { color: var(--red); border-color: var(--red); text-shadow: var(--glow-red); animation: topo-pulse-down 2s ease-in-out infinite; }
.chip-critical { color: var(--red); border-color: var(--red); text-shadow: var(--glow-red); animation: pulse-down 2s ease-in-out infinite; }
.chip-warning { color: var(--orange); border-color: var(--orange); }
.chip-ok { color: var(--green); border-color: var(--green-muted); text-shadow: var(--glow); }
.status-meta { display: flex; align-items: center; gap: 10px; white-space: nowrap; }
@@ -418,252 +395,12 @@
.target-ifaces{ display: flex; flex-wrap: wrap; gap: 3px; }
.iface-chip { font-family: var(--font); font-size: .65em; background: var(--bg3); border: 1px solid var(--border-color); padding: 1px 5px; color: var(--text-dim); }
/* ── Topology diagram ─────────────────────────────────────────────── */
.topology {
background-color: var(--bg2);
background-image: radial-gradient(circle, rgba(0,212,255,0.07) 1px, transparent 1px);
background-size: 22px 22px;
border: 1px solid var(--border-color);
padding: 20px 16px 16px;
margin-bottom: 16px;
text-align: center;
overflow-x: auto;
}
/* topo-v2 outer wrapper */
.topo-v2 {
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
min-width: 860px;
padding: 20px 24px 24px;
}
.topo-tier {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
.topo-vc {
display: flex;
justify-content: center;
align-items: flex-start;
width: 100%;
position: relative;
height: 40px;
}
.topo-vc-wire {
position: absolute;
left: 50%;
top: 0;
transform: translateX(-50%);
width: 2px;
height: 100%;
background: linear-gradient(to bottom, var(--cyan), var(--green));
opacity: .7;
}
.topo-vc-wire--wan { background: linear-gradient(to bottom, var(--cyan), rgba(0,212,255,.3)); opacity: .7; }
.topo-vc-wire--10g { background: var(--amber); opacity: .6; }
.topo-vc-wire--mgmt { background: var(--border-color); opacity: .5; }
/* Blurred copy of the wire for a soft glow halo */
.topo-vc-wire::before {
content: '';
position: absolute;
inset: 0;
background: inherit;
filter: blur(5px);
opacity: .5;
}
.topo-vc-label {
position: absolute;
left: calc(50% + 7px);
top: 50%;
transform: translateY(-50%);
font-size: .58em;
color: var(--amber);
white-space: nowrap;
letter-spacing: .06em;
font-family: var(--font);
background: rgba(3,5,8,.7);
padding: 1px 4px;
}
.topo-v2-node {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
padding: 8px 16px;
border: 1px solid var(--border-color);
background: var(--bg3);
position: relative;
font-size: .75em;
font-family: var(--font);
min-width: 110px;
text-align: center;
transition: border-color .2s, box-shadow .2s;
overflow: hidden;
}
/* Top highlight strip: color matches node type / status */
.topo-v2-node::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: currentColor;
opacity: .4;
}
.topo-v2-status-up.topo-v2-node::before { background: var(--green); opacity: .65; }
.topo-v2-status-down.topo-v2-node::before { background: var(--red); opacity: .75; }
.topo-v2-status-degraded.topo-v2-node::before { background: var(--orange); opacity: .65; }
.topo-v2-status-unknown.topo-v2-node::before { opacity: .15; }
.topo-v2-icon { font-size: 1.3em; line-height: 1; }
.topo-v2-label { font-weight: bold; letter-spacing: .04em; }
.topo-v2-sub { font-size: .58em; color: var(--text-muted); letter-spacing: .02em; }
.topo-v2-vlan { font-size: .54em; color: var(--cyan); opacity: .75; }
.topo-v2-host--bus { min-width: 80px; max-width: 96px; }
.topo-v2-internet { border-color: var(--cyan); color: var(--cyan); text-shadow: var(--glow-cyan); box-shadow: 0 0 12px rgba(0,212,255,.12); }
.topo-v2-router { border-color: var(--cyan); color: var(--cyan); text-shadow: var(--glow-cyan); box-shadow: 0 0 12px rgba(0,212,255,.14); }
.topo-v2-switch { border-color: var(--amber); color: var(--amber); text-shadow: var(--glow-amber); box-shadow: 0 0 12px rgba(255,179,0,.12); }
.topo-v2-host { border-color: var(--border-color); color: var(--text); cursor: default; }
@keyframes topo-pulse-down {
/* Pulsing red glow for critical chips and down states */
@keyframes pulse-down {
0%,100% { box-shadow: 0 0 6px rgba(255,45,85,.3); }
50% { box-shadow: 0 0 18px rgba(255,45,85,.75), 0 0 30px rgba(255,45,85,.2); }
}
.topo-v2-status-up { border-color: var(--green); box-shadow: 0 0 8px rgba(0,255,136,.2); }
.topo-v2-status-down { border-color: var(--red); animation: topo-pulse-down 2s ease-in-out infinite; }
.topo-v2-status-degraded { border-color: var(--orange); box-shadow: 0 0 8px rgba(255,107,0,.2); }
.topo-v2-status-unknown { border-color: var(--border-color); }
.topo-v2-offrack { border-style: dashed !important; }
.topo-badge { font-size: .68em; padding: 1px 5px; border: 1px solid; letter-spacing: .03em; }
.topo-badge-up { color: var(--green); border-color: var(--green); text-shadow: var(--glow); }
.topo-badge-down { color: var(--red); border-color: var(--red); animation: topo-pulse-down 1.5s ease-in-out infinite; }
.topo-badge-degraded { color: var(--orange); border-color: var(--orange); }
.topo-badge-unknown { color: var(--text-muted); border-color: var(--border-color); }
/* Host wrap + dual-homing wires */
.topo-v2-host-wrap {
display: flex;
flex-direction: column;
align-items: center;
}
.topo-v2-host-wires {
display: flex;
gap: 6px;
height: 28px;
align-items: flex-start;
}
.topo-v2-wire-10g { width: 2px; height: 100%; background: var(--green); opacity: .55; transition: background .3s, opacity .3s; }
.topo-v2-wire-1g { width: 0; height: 100%; border-left: 2px dashed var(--amber); opacity: .45; }
@keyframes wire-dash-anim { to { background-position: 0 -20px; } }
.topo-v2-wire-10g.wire-down {
background: repeating-linear-gradient(to bottom, var(--red) 0 6px, transparent 6px 10px) !important;
background-size: 2px 10px !important;
opacity: .9 !important;
transition: none !important;
animation: wire-dash-anim .7s linear infinite;
}
/* Bus rails */
.topo-bus-section {
width: 100%;
max-width: 860px;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0;
}
.topo-bus-10g {
display: flex;
align-items: center;
position: relative;
height: 20px;
}
.topo-bus-10g-line {
flex: 1;
height: 2px;
background: var(--green);
opacity: .55;
margin: 0 4px;
box-shadow: 0 0 6px rgba(0,255,136,.4);
}
.topo-bus-10g-label {
font-size: .56em;
color: var(--green);
white-space: nowrap;
letter-spacing: .05em;
font-family: var(--font);
opacity: .85;
padding: 0 8px;
}
.topo-bus-1g {
display: flex;
align-items: center;
position: relative;
height: 18px;
}
.topo-bus-1g-line {
flex: 1;
height: 0;
border-top: 2px dashed var(--amber);
opacity: .35;
margin: 0 4px;
}
.topo-bus-1g-label {
font-size: .56em;
color: var(--amber);
white-space: nowrap;
letter-spacing: .05em;
font-family: var(--font);
opacity: .8;
padding: 0 8px;
}
/* Hosts row */
.topo-v2-hosts {
display: flex;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
padding-top: 4px;
width: 100%;
}
/* Topology legend */
.topo-legend {
display: flex;
gap: 18px;
align-items: center;
margin-top: 14px;
padding-top: 10px;
border-top: 1px solid rgba(255,107,0,.12);
flex-wrap: wrap;
justify-content: center;
}
.topo-legend-item {
display: flex;
align-items: center;
gap: 5px;
font-size: .58em;
color: var(--text-muted);
font-family: var(--font);
}
.topo-legend-item--offrack {
border: 1px dashed var(--border-color);
padding: 1px 5px;
}
.topo-legend-line-10g { width: 24px; height: 2px; background: var(--green); display: inline-block; box-shadow: 0 0 4px rgba(0,255,136,.5); }
.topo-legend-line-1g { width: 24px; height: 0; border-top: 2px dashed var(--amber); display: inline-block; }
.topo-legend-line-wan { width: 24px; height: 2px; background: linear-gradient(to right, var(--cyan), var(--green)); display: inline-block; }
/* ── Link debug page ──────────────────────────────────────────────── */
.link-host-list { display: flex; flex-direction: column; gap: 18px; }
.link-host-panel {
@@ -1067,7 +804,7 @@
.lt-stat-card--alert {
border-color: var(--red) !important;
box-shadow: 0 0 8px rgba(255,45,85,.25) !important;
animation: topo-pulse-down 2s ease-in-out infinite;
animation: pulse-down 2s ease-in-out infinite;
}
.lt-stat-card--alert::before { background: var(--red); box-shadow: var(--glow-red); }
@@ -1077,7 +814,6 @@
/* ── Responsive ───────────────────────────────────────────────────── */
@media (max-width: 768px) {
.host-grid { grid-template-columns: 1fr; }
.topology { display: none; }
.form-row { flex-direction: column; }
.status-bar { flex-direction: column; align-items: flex-start; }
.link-ifaces-grid { grid-template-columns: 1fr; }
@@ -1150,7 +886,6 @@ a.device-link:hover { color: var(--text-accent); border-bottom-color: current
.device-row-sub { font-size: .72em; color: var(--text-muted); }
.device-row-toggle { background: none; border: 0; color: inherit; cursor: pointer; font: inherit; padding: 2px 6px; }
.device-row-detail > td { background: var(--bg-tertiary); padding: 10px 14px; }
.topo-host[data-host] { cursor: pointer; }
/* WAN outage list in the modem panel */
.modem-outages { padding: 0 12px 8px; }
@@ -1678,3 +1413,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; }
+1218
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -46,6 +46,9 @@
<a href="{{ url_for('rack_page') }}"
class="lt-nav-drawer-link{% if request.endpoint == 'rack_page' %} active{% endif %}"
{% if request.endpoint == 'rack_page' %}aria-current="page"{% endif %}>Rack</a>
<a href="{{ url_for('topology_page') }}"
class="lt-nav-drawer-link{% if request.endpoint == 'topology_page' %} active{% endif %}"
{% if request.endpoint == 'topology_page' %}aria-current="page"{% endif %}>Topology</a>
{% if user.groups and 'admin' in user.groups %}
<a href="{{ url_for('suppressions_page') }}"
class="lt-nav-drawer-link{% if request.endpoint == 'suppressions_page' %} active{% endif %}"
@@ -103,6 +106,11 @@
{% if request.endpoint == 'rack_page' %}aria-current="page"{% endif %}>
Rack
</a>
<a href="{{ url_for('topology_page') }}"
class="lt-nav-link{% if request.endpoint == 'topology_page' %} active{% endif %}"
{% if request.endpoint == 'topology_page' %}aria-current="page"{% endif %}>
Topology
</a>
{% if user.groups and 'admin' in user.groups %}
<a href="{{ url_for('suppressions_page') }}"
class="lt-nav-link{% if request.endpoint == 'suppressions_page' %} active{% endif %}"
@@ -399,6 +407,7 @@
{ id: 'nav-history', group: 'Navigate', icon: '⏱', label: 'Alert History', action: function() { window.location.href = '/events'; } },
{ id: 'nav-cutsheet', group: 'Navigate', icon: '🔌', label: 'Cutsheet', action: function() { window.location.href = '/cutsheet'; } },
{ id: 'nav-rack', group: 'Navigate', icon: '🗄', label: 'Rack', action: function() { window.location.href = '/rack'; } },
{ id: 'nav-topology', group: 'Navigate', icon: '⛓', label: 'Topology', action: function() { window.location.href = '/topology'; } },
{ id: 'nav-suppressions', group: 'Navigate', icon: '🔕', label: 'Suppressions', action: function() { window.location.href = '/suppressions'; } },
{ id: 'action-refresh', group: 'Actions', icon: '↻', label: 'Refresh Data', kbd: 'R', action: function() { lt.autoRefresh.now(); } },
{ id: 'action-suppress', group: 'Actions', icon: '🔕', label: 'New Suppression', kbd: 'S', action: function() { if (typeof openSuppressModal === 'function') openSuppressModal('host','',''); else window.location.href='/suppressions'; } },
-200
View File
@@ -335,176 +335,6 @@
</section>
{% endif %}
<!-- ── Network topology ───────────────────────────────── -->
<section class="g-section">
<div class="g-section-header">
<h2 class="g-section-title">Network Topology</h2>
<button type="button" class="topo-collapse-btn" id="topo-toggle-btn"
aria-expanded="true" aria-controls="topo-collapsible-wrap">&#x25B4; Collapse</button>
</div>
<div class="topo-collapsible" id="topo-collapsible-wrap">
<div class="topology" id="topology-diagram">
<div class="topo-v2">
{%- set topo_h = snapshot.hosts if snapshot.hosts else {} -%}
<!-- ══════════════════════════════════════════════════════════════
TIER 1: Internet (WAN edge)
══════════════════════════════════════════════════════════ -->
<div class="topo-tier">
<div class="topo-v2-node topo-v2-internet">
<span class="topo-v2-icon">◈</span>
<span class="topo-v2-label">INTERNET</span>
<span class="topo-v2-sub">WAN uplink</span>
</div>
</div>
<!-- Fiber wire: Internet → AT&T gateway -->
<div class="topo-vc">
<div class="topo-vc-wire topo-vc-wire--wan"></div>
<span class="topo-vc-label">AT&amp;T Fiber</span>
</div>
<!-- ══════════════════════════════════════════════════════════════
TIER 1b: AT&T gateway (Rx filled in by updateModem)
══════════════════════════════════════════════════════════ -->
<div class="topo-tier">
<div class="topo-v2-node topo-v2-router" id="topo-modem">
<span class="topo-v2-icon">◈</span>
<span class="topo-v2-label">BGW320</span>
<span class="topo-v2-sub">AT&amp;T Gateway</span>
<span class="topo-v2-sub" id="topo-modem-rx">Rx -</span>
</div>
</div>
<!-- WAN wire: gateway → UDM-Pro -->
<div class="topo-vc">
<div class="topo-vc-wire topo-vc-wire--wan"></div>
<span class="topo-vc-label">WAN · Port 9 · 1G</span>
</div>
<!-- ══════════════════════════════════════════════════════════════
TIER 2: Router, UDM-Pro
══════════════════════════════════════════════════════════ -->
<div class="topo-tier">
<div class="topo-v2-node topo-v2-router">
<span class="topo-v2-icon">⬡</span>
<span class="topo-v2-label">UDM-Pro</span>
<span class="topo-v2-sub">Dream Machine Pro</span>
<span class="topo-v2-sub">RU24</span>
</div>
</div>
<!-- UDM-Pro → USW-Agg (10G SFP+) -->
<div class="topo-vc">
<div class="topo-vc-wire topo-vc-wire--10g"></div>
<span class="topo-vc-label">10G SFP+</span>
</div>
<!-- ══════════════════════════════════════════════════════════════
TIER 3: USW-Aggregation
══════════════════════════════════════════════════════════ -->
<div class="topo-tier">
<div class="topo-v2-node topo-v2-switch" id="topo-switch-agg">
<span class="topo-v2-icon">⬡</span>
<span class="topo-v2-label">USW-Agg</span>
<span class="topo-v2-sub">Aggregation · RU22</span>
<span class="topo-v2-sub">8 × 10G SFP+</span>
<span class="topo-v2-vlan">VLAN90 · 10.10.90.x/24</span>
</div>
</div>
<!-- USW-Agg → Pro 24 PoE (10G trunk) -->
<div class="topo-vc">
<div class="topo-vc-wire topo-vc-wire--10g"></div>
<span class="topo-vc-label">10G trunk</span>
</div>
<!-- ══════════════════════════════════════════════════════════════
TIER 4: Pro 24 PoE
══════════════════════════════════════════════════════════ -->
<div class="topo-tier">
<div class="topo-v2-node topo-v2-switch" id="topo-switch-poe">
<span class="topo-v2-icon">⬡</span>
<span class="topo-v2-label">Pro 24 PoE</span>
<span class="topo-v2-sub">24-Port · RU23</span>
<span class="topo-v2-sub">24 × 1G PoE</span>
<span class="topo-v2-vlan">DHCP · mgmt</span>
</div>
</div>
<!-- Pro 24 PoE → host bus section -->
<div class="topo-vc">
<div class="topo-vc-wire topo-vc-wire--mgmt"></div>
</div>
<!-- ══════════════════════════════════════════════════════════════
TIER 4 connecting bus: two rails (10G green + 1G amber dashed)
showing dual-homing for all 6 servers
══════════════════════════════════════════════════════════ -->
<div class="topo-bus-section">
<!-- 10G storage bus (Agg → VLAN90) -->
<div class="topo-bus-10g">
<span class="topo-bus-10g-label">← USW-Agg · 10G SFP+ · VLAN90 →</span>
<div class="topo-bus-10g-line"></div>
</div>
<!-- 1G management bus (PoE → DHCP) -->
<div class="topo-bus-1g">
<span class="topo-bus-1g-label">← Pro 24 PoE · 1G · DHCP mgmt →</span>
<div class="topo-bus-1g-line"></div>
</div>
<!-- ── Host nodes with drop wires ── -->
<div class="topo-v2-hosts">
{%- 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' -%}
<div class="topo-v2-host-wrap">
<!-- dual-homing wires: 10G solid green + 1G dashed amber -->
<div class="topo-v2-host-wires">
<div class="topo-v2-wire-10g" data-host="{{ hname }}" title="10G SFP+ → USW-Agg"></div>
<div class="topo-v2-wire-1g" data-host="{{ hname }}" title="1G → Pro 24 PoE"></div>
</div>
<!-- host box -->
<div class="topo-v2-node topo-v2-host topo-host topo-v2-status-{{ st }}{{ ' topo-v2-offrack' if off_rack else '' }} topo-v2-host--bus"
data-host="{{ hname }}">
<span class="topo-v2-icon">▣</span>
<span class="topo-v2-label">{{ hlabel }}</span>
<span class="topo-v2-sub">{{ hsub }}</span>
<span class="topo-v2-vlan">{{ hvlan }}</span>
<span class="topo-badge topo-badge-{{ st }}">{{ st if st != 'unknown' else '-' }}</span>
</div>
</div>
{%- endfor -%}
</div>
</div><!-- /topo-bus-section -->
<!-- ── Legend ── -->
<div class="topo-legend">
<div class="topo-legend-item"><span class="topo-legend-line-wan"></span> WAN / uplink</div>
<div class="topo-legend-item"><span class="topo-legend-line-10g"></span> 10G SFP+ (Ceph / VLAN90)</div>
<div class="topo-legend-item"><span class="topo-legend-line-1g"></span> 1G DHCP (mgmt)</div>
<div class="topo-legend-item topo-legend-item--offrack">dashed border = off-rack</div>
</div>
</div><!-- /topo-v2 -->
</div>
</div><!-- /#topo-collapsible-wrap -->
</section>
<!-- ── Recently Resolved (last 24h) ───────────────────────────────── -->
{% if recent_resolved %}
<section class="g-section">
@@ -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', () => {
+61
View File
@@ -0,0 +1,61 @@
{% extends "base.html" %}
{% block title %}Topology{% endblock %}
{% block content %}
<div class="lt-page-header">
<div>
<h1 class="lt-page-title">Topology</h1>
<p class="g-page-sub">Every device and cable as the switches see them right now. Drag to pan, scroll or pinch to zoom. Hover a device to trace its path to the internet; click a cable for its details, or a group of unknown devices to open it.</p>
</div>
</div>
<div class="tp-toolbar no-print" role="toolbar" aria-label="Topology view">
<div class="tp-filters" role="group" aria-label="Show">
<label class="tp-filter">Unknown devices
<select class="lt-select tp-select" data-filter="stubs" aria-label="Unknown devices">
<option value="grouped">grouped per switch</option>
<option value="all">one by one</option>
<option value="none">hidden</option>
</select></label>
<label class="tp-filter"><input type="checkbox" data-filter="power"> Power gear</label>
<label class="tp-filter"><input type="checkbox" data-filter="traffic"> Traffic</label>
<label class="tp-filter" title="Each server's slower second cable. A problem cable is always shown."><input type="checkbox" data-filter="mgmt"> Server 1G links</label>
<label class="tp-filter"><input type="checkbox" data-filter="alerts"> Alerts only</label>
</div>
<div class="tp-zoom" role="group" aria-label="Zoom">
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-zoom="out" aria-label="Zoom out" title="Zoom out (-)">&minus;</button>
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-zoom="fit" title="Fit everything (0)">Fit</button>
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-zoom="in" aria-label="Zoom in" title="Zoom in (+)">+</button>
</div>
<span class="tp-updated" id="tp-updated" aria-live="polite"></span>
</div>
<div class="tp-warnings" id="tp-warnings" role="status" hidden></div>
<div class="tp-summary" id="tp-summary" aria-live="polite"></div>
<div class="tp-canvas" id="tp-canvas" tabindex="0" aria-label="Network topology diagram. Use plus, minus and zero to zoom, and Tab to move between devices and cables.">
<svg class="tp-svg" id="tp-svg" xmlns="http://www.w3.org/2000/svg" role="group" aria-label="Network topology">
<g id="tp-world"></g>
</svg>
<div class="link-loading tp-loading" id="tp-loading">Loading topology</div>
<aside class="tp-panel" id="tp-panel" hidden aria-live="polite" aria-label="Cable details"></aside>
</div>
<div class="tp-legend" aria-label="Legend">
<span class="tp-key"><svg viewBox="0 0 40 12" aria-hidden="true"><path class="tp-key-line tp-m-dac" d="M2 6 H38"/></svg>DAC</span>
<span class="tp-key"><svg viewBox="0 0 40 12" aria-hidden="true"><path class="tp-key-line tp-m-fibre" d="M2 6 H38"/></svg>Fibre (multimode)</span>
<span class="tp-key"><svg viewBox="0 0 40 12" aria-hidden="true"><path class="tp-key-line tp-m-wanfibre" d="M2 6 H38"/></svg>Fibre (provider)</span>
<span class="tp-key"><svg viewBox="0 0 40 12" aria-hidden="true"><path class="tp-key-line tp-m-sfp" d="M2 6 H38"/></svg>SFP+ module</span>
<span class="tp-key"><svg viewBox="0 0 40 12" aria-hidden="true"><path class="tp-key-line tp-m-copper" d="M2 6 H38"/></svg>Copper</span>
<span class="tp-key">Thicker = faster (10G, 1G, 100M)</span>
<span class="tp-key"><svg viewBox="0 0 40 12" aria-hidden="true"><path class="tp-key-line tp-key-down" d="M2 6 H38"/></svg>Down</span>
<span class="tp-key"><svg viewBox="0 0 40 12" aria-hidden="true"><path class="tp-key-line tp-key-ghost" d="M2 6 H38"/></svg>Last seen</span>
<span class="tp-key"><span class="tp-key-warn" aria-hidden="true">⚠</span>Port name points at another device</span>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='faceplates.js') }}"></script>
<script src="{{ url_for('static', filename='fronts.js') }}"></script>
<script src="{{ url_for('static', filename='topology.js') }}"></script>
{% endblock %}
+42 -2
View File
@@ -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 '<section class="g-section" id="followup" hidden>' 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
+3 -1
View File
@@ -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'})
+338
View File
@@ -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']
+411
View File
@@ -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'),
}