diff --git a/README.md b/README.md index ef4fd17..7a48efb 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. ![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,34 @@ 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. +- **Drawn to scale from the real gear**: UniFi devices use their faceplates (the same drawings as the + port inspector and rack page), and servers use the **Front drawing** of their cutsheet item. Servers + without one are a plain box. Above each server is a strip of its physical NICs, so every cable lands + on the NIC it is plugged into; NICs marked unused are dimmed. +- **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. +- **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 + or hide unknown devices, power gear, traffic, cables slower than 10G, and everything without an + alert. 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 +767,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 +781,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 +792,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..d631607 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..7e8e4d2 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,180 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; } .device-row--unused td { opacity: .6; } .device-row--unused td:last-child { opacity: 1; } .lt-btn-xs { padding: 1px 6px; font-size: .68rem; margin-left: 6px; } + +/* ── Topology page ─────────────────────────────────────────────────── */ +.tp-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; margin: 8px 0 10px; } +.tp-filters { display: flex; flex-wrap: wrap; gap: 6px 14px; } +.tp-filter { display: inline-flex; align-items: center; gap: 6px; font-size: .78em; color: var(--text-dim); cursor: pointer; } +.tp-zoom { display: inline-flex; gap: 4px; } +.tp-updated { margin-left: auto; font-size: .72em; color: var(--text-muted); } +.tp-warnings { border: 1px solid var(--amber); color: var(--amber); background: var(--amber-dim); padding: 8px 12px; margin-bottom: 10px; font-size: .8em; } +.tp-canvas { + position: relative; overflow: hidden; touch-action: none; cursor: grab; + height: clamp(420px, calc(100vh - 300px), 1400px); + border: 1px solid var(--border-color); + background-color: var(--bg2); + background-image: radial-gradient(circle, rgba(0,212,255,.07) 1px, transparent 1px); + background-size: 22px 22px; +} +[data-theme="light"] .tp-canvas { background-image: radial-gradient(circle, rgba(0,100,160,.08) 1px, transparent 1px); } +.tp-canvas:focus-visible { outline: 2px solid var(--amber); outline-offset: 2px; } +.tp-canvas.tp-dragging { cursor: grabbing; } +.tp-svg { display: block; width: 100%; height: 100%; user-select: none; } +.tp-loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; } +.tp-loading[hidden] { display: none; } + +/* Devices */ +.tp-node-link { cursor: pointer; outline: none; } +.tp-node-hit { fill: transparent; stroke: transparent; stroke-width: 4; } +.tp-node-link:hover .tp-node-hit, .tp-node-link:focus-visible .tp-node-hit { stroke: var(--amber); fill: rgba(255,179,0,.04); } +.tp-name { font-family: var(--font); font-size: 40px; font-weight: 700; fill: var(--text); letter-spacing: .02em; + paint-order: stroke; stroke: var(--bg2); stroke-width: 10px; stroke-linejoin: round; } +.tp-sub { font-family: var(--font); font-size: 28px; fill: var(--text-muted); + paint-order: stroke; stroke: var(--bg2); stroke-width: 8px; stroke-linejoin: round; } +.tp-dot { stroke: rgba(0,0,0,.35); stroke-width: 2; } +.tp-dot-up { fill: #2fd36b; } +.tp-dot-degraded { fill: #ffb300; } +.tp-dot-down { fill: #e0312b; } +.tp-dot-unknown { fill: #6b737d; } +.tp-ring { fill: none; stroke-width: 7; } +.tp-ring-critical { stroke: #e0312b; } +.tp-ring-warning { stroke: #ffb300; } +.tp-ring-info { stroke: #34a8ff; } +.tp-badge circle { stroke: var(--bg2); stroke-width: 5; } +.tp-badge text { font-family: var(--font); font-size: 34px; font-weight: 700; fill: #fff; } +.tp-badge-critical circle { fill: #e0312b; } +.tp-badge-warning circle { fill: #c98a00; } +.tp-badge-info circle { fill: #1f7ad6; } +.tp-kind-stub .tp-name { font-size: 32px; font-weight: 400; fill: var(--text-dim); } +.tp-kind-stub .tp-sub { font-size: 24px; } + +.tp-cloud { fill: #dfe7ef; stroke: #8fa3b5; stroke-width: 4; } +.tp-cloud-text { font-family: var(--font); font-size: 44px; font-weight: 700; fill: #34495e; letter-spacing: .12em; } +.tp-st-down .tp-cloud { fill: #f3d6d4; stroke: #e0312b; } +.tp-modem-body { fill: #16181b; stroke: #050506; stroke-width: 3; } +.tp-modem-edge { fill: none; stroke: #2c3035; stroke-width: 3; } +.tp-modem-globe { fill: #1f6fd1; } +.tp-modem-globe-line { fill: none; stroke: #9cc6ff; stroke-width: 3; } +.tp-modem-model { font-family: var(--font); font-size: 22px; fill: #8b929b; } +.tp-led { stroke: #000; stroke-width: 2; } +.tp-led-on { fill: #2fd36b; } +.tp-led-off { fill: #3a3f45; } +.tp-led-bad { fill: #e0312b; } +.tp-ap-body { fill: #f2f3f4; stroke: #c3c6ca; stroke-width: 4; } +.tp-ap-ring { fill: none; stroke-width: 10; stroke: #6b737d; } +.tp-ap-up { stroke: #2a8cff; } +.tp-ap-down { stroke: #e0312b; } +.tp-ap-dot { fill: #d7dadd; } +.tp-strip-body { fill: #f0f1f2; stroke: #c3c6ca; stroke-width: 4; } +.tp-strip-outlet rect:first-of-type { fill: #fafafa; stroke: #b9bcc0; stroke-width: 3; } +.tp-strip-slot { fill: #2b2d31; } +.tp-strip-outlet.tp-off rect:first-of-type { fill: #d9dadc; } +.tp-box, .tp-host-box { fill: #22272e; stroke: #3a434e; stroke-width: 3; } +.tp-box-text { font-family: var(--font); font-size: 34px; fill: #8b97a5; } +.tp-host-led { stroke: #000; stroke-width: 2; } +.tp-gen-body { fill: #2a2f36; stroke: #12151a; stroke-width: 3; } +.tp-gen-model { font-family: var(--font); font-size: 30px; fill: #c9d1d9; } +.tp-gen-port { fill: #111418; stroke: #4b5562; stroke-width: 2; } +.tp-gen-up { stroke: #2fd36b; } +.tp-gen-num { font-family: var(--font); font-size: 16px; fill: #8b97a5; } +.tp-stub-body { fill: #3a3f46; stroke: #23272c; stroke-width: 3; } +.tp-stub-jack { fill: #15181c; } +.tp-stub-q { font-family: var(--font); font-size: 30px; font-weight: 700; fill: #9aa3ad; } +.tp-st-down .tp-stub-body { stroke: #e0312b; } + +/* Host NIC sockets */ +.tp-strip { fill: #1b1f24; stroke: #2f363f; stroke-width: 3; } +.tp-nic-rj45, .tp-nic-sfp { fill: #0b0d10; stroke: #59626e; stroke-width: 3; } +.tp-nic-sfp-in { fill: #23272d; } +.tp-nic-name { font-family: var(--font); font-size: 18px; fill: #aab4bf; } +.tp-nic-led { fill: #3a3f45; } +.tp-nic-up .tp-nic-led { fill: #2fd36b; } +.tp-nic-up .tp-nic-rj45, .tp-nic-up .tp-nic-sfp { stroke: #8f9aa6; } +.tp-nic-down .tp-nic-led { fill: #e0312b; } +.tp-nic-down .tp-nic-rj45, .tp-nic-down .tp-nic-sfp { stroke: #e0312b; } +.tp-nic-unused { opacity: .45; } +.tp-nic-unused .tp-nic-rj45, .tp-nic-unused .tp-nic-sfp { stroke-dasharray: 6 5; } +.tp-nic-alert-critical .tp-nic-name { fill: #ff6b5e; } +.tp-nic-alert-warning .tp-nic-name { fill: #ffb300; } + +/* Cables: colours are the real jackets, the same in both themes */ +.tp-link { cursor: pointer; outline: none; } +.tp-link path { fill: none; stroke-linecap: round; } +.tp-casing { stroke: #050607; } +.tp-core { stroke: #2f343b; } +.tp-hit { stroke: transparent; stroke-width: 40; } +.tp-s-10g .tp-casing { stroke-width: 17; } .tp-s-10g .tp-core { stroke-width: 11; } +.tp-s-1g .tp-casing { stroke-width: 12; } .tp-s-1g .tp-core { stroke-width: 7; } +.tp-s-100m .tp-casing { stroke-width: 8; } .tp-s-100m .tp-core { stroke-width: 4.5; } +.tp-m-dac .tp-casing { stroke: #8a929c; } .tp-m-dac .tp-core { stroke: #16181c; } +.tp-m-fibre .tp-casing { stroke: #13757c; } .tp-m-fibre .tp-core { stroke: #38c9d3; } +.tp-m-wanfibre .tp-casing { stroke: #8a6a00; } .tp-m-wanfibre .tp-core { stroke: #f2c230; } +.tp-m-sfp .tp-casing { stroke: #23272c; } .tp-m-sfp .tp-core { stroke: #69717c; } +.tp-m-copper .tp-casing { stroke: #173f80; } .tp-m-copper .tp-core { stroke: #3b82f6; } +.tp-down .tp-casing { stroke: transparent; } +.tp-down .tp-core { stroke: #e0312b; stroke-dasharray: 28 18; } +.tp-ghost { opacity: .8; } +.tp-ghost .tp-casing { stroke: transparent; } +.tp-ghost .tp-core { stroke: #e0312b; stroke-dasharray: 2 18; stroke-width: 10; } +.tp-plug { fill: #c9ccd1; stroke: #5d636b; stroke-width: 2; } +.tp-boot { fill: #1e5bbf; stroke: #0d2f66; stroke-width: 2; } +.tp-links-over { pointer-events: none; } +.tp-links-over .tp-warn { pointer-events: auto; } +.tp-flow { stroke: rgba(255,255,255,.4); stroke-width: 3; stroke-dasharray: 3 60; animation: tp-flow linear infinite; pointer-events: none; } +.tp-s-100m .tp-flow { stroke-width: 2; } +@keyframes tp-flow { to { stroke-dashoffset: -63; } } +@media (prefers-reduced-motion: reduce) { .tp-flow { animation: none; display: none; } } +.tp-link:hover .tp-casing, .tp-link:focus-visible .tp-casing, .tp-selected .tp-casing { stroke: var(--amber); } +.tp-tag rect { fill: var(--bg-primary); stroke: var(--border-color); stroke-width: 2; opacity: .92; } +.tp-tag text { font-family: var(--font); font-size: 24px; fill: var(--text); } +.tp-tag-ghost rect { stroke: #e0312b; } +.tp-tag-ghost text { fill: #ff6b5e; } +.tp-rate { display: none; pointer-events: none; } +.tp-near .tp-rate, .tp-hot .tp-rate, .tp-selected .tp-rate { display: inline; } +.tp-warn path { fill: #ffb300; stroke: #5c4100; stroke-width: 3; } +.tp-warn text { font-family: var(--font); font-size: 30px; font-weight: 700; fill: #2b1f00; } + +/* Filters and the traced path */ +.tp-no-traffic .tp-rate, .tp-no-traffic .tp-flow { display: none !important; } +.tp-only-10g .tp-slow { display: none; } +.tp-alerts-only .tp-node:not(.tp-has-alert) { opacity: .25; } +.tp-alerts-only .tp-link:not(.tp-touches-alert) { opacity: .2; } +.tp-tracing .tp-node:not(.tp-hot), .tp-tracing .tp-link:not(.tp-hot) { opacity: .18; transition: opacity .15s; } +.tp-tracing .tp-link.tp-hot .tp-casing { stroke: var(--amber); } +.tp-far .tp-sub, .tp-far .tp-nic-name { display: none; } + +/* Cable panel: a side panel, a bottom sheet on phones */ +.tp-panel { + position: absolute; top: 12px; right: 12px; width: min(360px, calc(100% - 24px)); + max-height: calc(100% - 24px); overflow: auto; z-index: 2; + background: var(--bg-primary); border: 1px solid var(--border-color); box-shadow: 0 8px 24px rgba(0,0,0,.35); + padding: 10px 14px 14px; cursor: auto; font-size: .8em; +} +.tp-panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; } +.tp-panel-title { font-size: 1em; margin: 0; letter-spacing: .06em; text-transform: uppercase; color: var(--amber); } +.tp-panel-table { width: 100%; border-collapse: collapse; } +.tp-panel-table th { text-align: left; font-weight: 400; color: var(--text-muted); padding: 4px 10px 4px 0; vertical-align: top; width: 42%; } +.tp-panel-table td { padding: 4px 0; overflow-wrap: anywhere; } +.tp-panel-warn { border: 1px solid var(--amber); background: var(--amber-dim); color: var(--text); padding: 6px 8px; margin: 0 0 8px; } +.tp-bad { color: var(--red); } +.tp-warn-text { color: var(--amber); } +.tp-dim-text { color: var(--text-muted); } +@media (max-width: 640px) { + .tp-panel { top: auto; left: 0; right: 0; bottom: 0; width: auto; max-height: 55%; border-width: 1px 0 0; } + .tp-updated { margin-left: 0; width: 100%; } + .tp-canvas { height: clamp(380px, calc(100vh - 260px), 900px); } +} + +.tp-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; margin: 10px 0 4px; font-size: .74em; color: var(--text-dim); } +.tp-key { display: inline-flex; align-items: center; gap: 6px; } +.tp-key svg { width: 34px; height: 12px; } +.tp-key-line { fill: none; stroke-width: 5; stroke-linecap: round; } +.tp-key-line.tp-m-dac { stroke: #2b2f35; } +.tp-key-line.tp-m-fibre { stroke: #38c9d3; } +.tp-key-line.tp-m-wanfibre { stroke: #f2c230; } +.tp-key-line.tp-m-sfp { stroke: #69717c; } +.tp-key-line.tp-m-copper { stroke: #3b82f6; } +.tp-key-down { stroke: #e0312b; stroke-dasharray: 6 4; } +.tp-key-ghost { stroke: #e0312b; stroke-dasharray: 1 5; } +.tp-key-warn { color: #ffb300; font-weight: 700; } diff --git a/static/topology.js b/static/topology.js new file mode 100644 index 0000000..b03857c --- /dev/null +++ b/static/topology.js @@ -0,0 +1,842 @@ +'use strict'; +/* global FACEPLATES, faceplateSvg, faceplatePortAnchor, RACK_FRONTS, SHELF_GEAR, DESK_FACEPLATES, CHASSIS_FACEPLATES, rackFrontSvg */ +// Topology page: every device drawn as its real faceplate or chassis, with each cable leaving +// the exact port it is plugged into. Data comes from /api/topology (topology.build_graph). +// +// World units: a 19" panel is 1100 wide (as on the rack page), so everything is to scale. + +const esc = s => lt.escHtml(s == null ? '' : String(s)); +const PER_MM = 1100 / 482.6; +const ROW_GAP = 330; +const NODE_GAP = 120; +const LABEL_H = 104; +const RAIL_GAP = 520; +const STRIP_H = 104; +const SOCKET_W = 118; +const REFRESH_MS = 30000; +const FILTER_KEY = 'gandalf.topology.filters'; +const DEFAULT_FILTERS = { stubs: true, power: true, traffic: true, tenG: false, alerts: false }; + +const state = { + data: null, + layout: null, + view: { k: 1, tx: 0, ty: 0 }, + fitted: false, + filters: loadFilters(), + selected: null, +}; + +function loadFilters() { + try { + return Object.assign({}, DEFAULT_FILTERS, JSON.parse(localStorage.getItem(FILTER_KEY) || '{}')); + } catch (_) { + return Object.assign({}, DEFAULT_FILTERS); + } +} + +function saveFilters() { + try { localStorage.setItem(FILTER_KEY, JSON.stringify(state.filters)); } catch (_) { /* private mode */ } +} + +// ── Formatting ────────────────────────────────────────────────────────── +function speedText(mbps) { + if (!mbps) return ''; + return mbps >= 1000 ? `${+(mbps / 1000).toFixed(1)}G` : `${mbps}M`; +} + +function rateText(bytesPerSec) { + const bits = (bytesPerSec || 0) * 8; + if (bits >= 1e9) return `${(bits / 1e9).toFixed(2)} Gb/s`; + if (bits >= 1e6) return `${(bits / 1e6).toFixed(1)} Mb/s`; + if (bits >= 1e3) return `${(bits / 1e3).toFixed(0)} kb/s`; + return `${bits.toFixed(0)} b/s`; +} + +const MEDIUM_TEXT = { dac: 'DAC (twinax)', fibre: 'Fibre', sfp: 'SFP+ module', copper: 'Copper' }; + +function deviceHref(name) { + return `/device/${encodeURIComponent(name)}`; +} + +function nodeHref(node) { + if (node.kind === 'stub') return `${deviceHref(node.switch)}#port-${encodeURIComponent(node.port_idx)}`; + if (node.kind === 'internet') return ''; + return deviceHref(node.name); +} + +function speedClass(mbps) { + if (mbps >= 10000) return 'tp-s-10g'; + if (mbps >= 1000) return 'tp-s-1g'; + return 'tp-s-100m'; +} + +function mediumClass(link) { + if (link.wan && link.medium === 'fibre') return 'tp-m-wanfibre'; + return `tp-m-${link.medium || 'copper'}`; +} + +// ── Device drawings ───────────────────────────────────────────────────── +// The shared drawings size themselves to their page with CSS (width: 100%), which would override +// the width and height given here, so they are drawn under a class of our own. +function nested(svg) { + return svg.replace(/^(\s* {x, y, face} | null } in local units. + +function portMapOf(node) { + const map = {}; + Object.entries(node.ports || {}).forEach(([idx, p]) => { + map[idx] = Object.assign({ port_idx: Number(idx), lldp: { system_name: p.neighbour } }, p); + }); + return map; +} + +function portTitle(port, idx, role) { + const parts = [port && port.name && port.name !== `Port ${idx}` ? `Port ${idx} "${port.name}"` : `Port ${idx}`]; + if (role) parts.push(role); + if (port) { + parts.push(port.up ? (speedText(port.speed_mbps) || 'up') : 'down'); + if (port.neighbour) parts.push(`to ${port.neighbour}`); + } + return esc(parts.join(' · ')); +} + +function faceplateDrawing(node, model, width, opts = {}) { + const spec = FACEPLATES[model]; + const box = (opts.rack && spec.rackView) || [0, 0, spec.width, spec.height]; + const k = width / box[2]; + const h = box[3] * k; + // A rack view (the UPS's front without its rear) is a viewBox onto part of the drawing + const svg = faceplateSvg(model, { + swName: node.name, portMap: portMapOf(node), outlets: node.outlets || [], online: node.status === 'up', + rack: !!opts.rack, titleFor: portTitle, + }, `x="${opts.offsetX || 0}" y="0" width="${width}" height="${h}"`).svg; + const drawn = nested(svg); + return { + w: width, h, svg: drawn, + anchor(end) { + if (end.port_idx == null) return null; + const a = faceplatePortAnchor(spec, end.port_idx); + return a && { x: (opts.offsetX || 0) + (a.x - box[0]) * k, y: (a.y - box[1]) * k, face: a.face }; + }, + }; +} + +function deskFaceplate(node) { + const desk = DESK_FACEPLATES[node.model]; + const spec = FACEPLATES[node.model]; + const k = (desk.mm * PER_MM) / (desk.body[1] - desk.body[0]); + return faceplateDrawing(node, node.model, spec.width * k); +} + +function genericSwitch(node) { + const ports = Object.keys(node.ports || {}).map(Number).sort((a, b) => a - b); + const w = 1100, h = 110; + const pitch = Math.min(60, 900 / Math.max(ports.length, 1)); + const x0 = w - 60 - pitch * ports.length; + const pos = {}; + const jacks = ports.map((idx, i) => { + const p = node.ports[idx]; + const x = x0 + i * pitch; + pos[idx] = x + pitch / 2; + return `${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 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: its drawn front (from the cutsheet's Front drawing) under a strip of its NIC sockets, +// one per physical interface, so each cable lands on the NIC it is plugged into. +function hostDrawing(node, linksByIface) { + let front; + const ctx = { name: node.name, status: node.status, interfaces: node.interfaces || [] }; + if (RACK_FRONTS[node.chassis]) { + const spec = RACK_FRONTS[node.chassis]; + front = { + w: spec.width, h: spec.height, + svg: nested(rackFrontSvg(node.chassis, ctx)).replace('${esc(`${node.name} · ${gear.label}`)}${gear.draw(ctx)}`, + }; + } else if (CHASSIS_FACEPLATES[node.chassis]) { + front = faceplateDrawing(node, CHASSIS_FACEPLATES[node.chassis], 1100); + } else { + front = { + w: 560, h: 150, + svg: ` + + server + ${esc(node.name)}: no front drawing on the cutsheet`, + }; + } + + const nics = (node.interfaces || []).slice(); + Object.keys(linksByIface).forEach(iface => { + if (iface && !nics.some(n => n.name === iface)) nics.push({ name: iface, up: true, unused: false, alert: '' }); + }); + const stripW = Math.max(nics.length * SOCKET_W + 24, 200); + const w = Math.max(front.w, stripW); + const sx = (w - stripW) / 2; + const fx = (w - front.w) / 2; + const pos = {}; + const sockets = nics.map((nic, i) => { + const link = linksByIface[nic.name]; + const cx = sx + 12 + i * SOCKET_W + SOCKET_W / 2; + pos[nic.name] = cx; + const sfp = link && ['dac', 'fibre', 'sfp'].includes(link.medium); + let st = nic.unused ? 'unused' : (nic.up ? 'up' : 'down'); + if (link && !link.up) st = 'down'; + const title = `${nic.name} · ${nic.unused ? 'marked unused' : (nic.up ? 'up' : 'down')}${link ? ` · ${speedText(link.speed_mbps)} ${MEDIUM_TEXT[link.medium] || ''}` : ' · no cable seen'}`; + const shape = sfp + ? `` + : ``; + return `${esc(title)} + ${shape} + ${esc(nic.name.length > 11 ? `${nic.name.slice(0, 10)}…` : nic.name)}`; + }).join(''); + return { + w, h: STRIP_H + 14 + front.h, + svg: `${sockets}${front.svg}`, + anchor: end => (end.iface && pos[end.iface] != null ? { x: pos[end.iface], y: 14, face: 'top' } : null), + }; +} + +function drawingFor(node, linksByIface) { + const spec = FACEPLATES[node.model]; + switch (node.kind) { + case 'internet': return internetDrawing(); + case 'modem': return modemDrawing(node); + case 'host': return hostDrawing(node, linksByIface); + case 'stub': return stubDrawing(); + case 'ap': return apDrawing(node); + case 'strip': return stripDrawing(node); + default: + if (spec && DESK_FACEPLATES[node.model]) return deskFaceplate(node); + if (spec) return faceplateDrawing(node, node.model, 1100, { rack: node.kind === 'ups' }); + if (node.kind === 'switch' || node.kind === 'router') return genericSwitch(node); + return boxDrawing(node, node.model || node.kind); + } +} + +// ── Layout ────────────────────────────────────────────────────────────── +function visibleGraph(data, filters) { + const nodes = data.nodes.filter(n => (filters.stubs || n.kind !== 'stub') && (filters.power || !n.power)); + const ids = new Set(nodes.map(n => n.id)); + const links = data.links.filter(l => ids.has(l.a.node) && ids.has(l.b.node)); + return { nodes, links }; +} + +function rowKey(node) { + if (node.kind === 'host') return 1e6; + if (node.depth == null) return 1e5; + return node.kind === 'stub' ? node.depth + 0.5 : node.depth; +} + +function anchorOf(lay, end, asParent) { + const p = lay.pos[end.node]; + const d = lay.drawings[end.node]; + // Power gear sits in a rail on the right: its cable comes in from the left + if (p.rail) return { x: p.x + p.dx, y: p.y + d.h / 2, face: 'left' }; + const a = d.anchor(end) || { x: d.w / 2, y: asParent ? d.h : 0, face: asParent ? 'bottom' : 'top' }; + return { x: p.x + p.dx + a.x, y: p.y + a.y, face: a.face }; +} + +function layout(data, filters) { + const { nodes, links } = visibleGraph(data, filters); + const byId = Object.fromEntries(nodes.map(n => [n.id, n])); + const drawings = {}; + const sizes = {}; + nodes.forEach(n => { + const byIface = {}; + links.forEach(l => [l.a, l.b].forEach(e => { if (e.node === n.id && e.iface) byIface[e.iface] = l; })); + const d = drawingFor(n, byIface); + drawings[n.id] = d; + const labelW = Math.min(Math.max(n.name.length * 25, 120), 900); + sizes[n.id] = { w: Math.max(d.w, labelW), h: d.h + LABEL_H }; + }); + + const lay = { pos: {}, drawings, nodes, links, byId }; + const rowsMap = new Map(); + nodes.filter(n => !n.power).forEach(n => { + const key = rowKey(n); + if (!rowsMap.has(key)) rowsMap.set(key, []); + rowsMap.get(key).push(n); + }); + const rows = [...rowsMap.keys()].sort((a, b) => a - b).map(k => rowsMap.get(k)); + + let y = 0; + rows.forEach(row => { + const items = row.map(n => { + const xs = links.filter(l => (l.b.node === n.id && lay.pos[l.a.node]) || (l.a.node === n.id && lay.pos[l.b.node])) + .map(l => anchorOf(lay, l.b.node === n.id ? l.a : l.b, true).x); + return { n, w: sizes[n.id].w, bary: xs.length ? xs.reduce((s, v) => s + v, 0) / xs.length : null }; + }); + let last = 0; + items.forEach(it => { if (it.bary == null) it.bary = last; else last = it.bary; }); + items.sort((a, b) => a.bary - b.bary || a.n.name.localeCompare(b.n.name)); + let right = -Infinity; + items.forEach(it => { + it.x = Math.max(it.bary - it.w / 2, right + NODE_GAP); + right = it.x + it.w; + }); + const shift = items.reduce((s, it) => s + (it.bary - (it.x + it.w / 2)), 0) / items.length; + const rowH = Math.max(...items.map(it => sizes[it.n.id].h)); + items.forEach(it => { + lay.pos[it.n.id] = { x: it.x + shift, y, w: it.w, dx: (it.w - drawings[it.n.id].w) / 2 }; + }); + y += rowH + ROW_GAP; + }); + + // Power gear: a rail down the right, each unit level with the port that feeds it + const placed = Object.values(lay.pos); + const maxX = placed.length ? Math.max(...placed.map(p => p.x + p.w)) : 0; + const top = placed.length ? Math.min(...placed.map(p => p.y)) : 0; + const feederY = n => { + const l = links.find(k => k.b.node === n.id && lay.pos[k.a.node]); + return l ? anchorOf(lay, l.a, true).y : Infinity; + }; + let railY = top; + nodes.filter(n => n.power).map(n => ({ n, f: feederY(n) })) + .sort((a, b) => a.f - b.f || a.n.name.localeCompare(b.n.name)).forEach(({ n, f }) => { + const want = Number.isFinite(f) ? f - drawings[n.id].h / 2 : railY; + const y = Math.max(want, railY); + lay.pos[n.id] = { x: maxX + RAIL_GAP, y, w: sizes[n.id].w, dx: 0, rail: true }; + railY = y + sizes[n.id].h + 120; + }); + + const all = Object.entries(lay.pos); + lay.bounds = { + x0: Math.min(...all.map(([, p]) => p.x)) - 80, + y0: Math.min(...all.map(([, p]) => p.y)) - 80, + x1: Math.max(...all.map(([, p]) => p.x + p.w)) + 80, + y1: Math.max(...all.map(([id, p]) => p.y + sizes[id].h)) + 80, + }; + return lay; +} + +// ── Rendering ─────────────────────────────────────────────────────────── +// A cable leaves each end square to the face it plugs into: up or down from a faceplate, or +// sideways into the power rail +function cablePath(A, B) { + const dir = (P, Q) => { + if (P.face === 'left') return [-1, 0]; + if (P.face === 'right') return [1, 0]; + return [0, Q.y >= P.y ? 1 : -1]; + }; + const c = Math.min(900, Math.max(90, Math.hypot(B.x - A.x, B.y - A.y) * 0.4)); + const [ax, ay] = dir(A, B); + const [bx, by] = dir(B, A); + const c1 = { x: A.x + ax * c, y: A.y + ay * c }; + const c2 = { x: B.x + bx * c, y: B.y + by * c }; + const mid = { x: (A.x + 3 * c1.x + 3 * c2.x + B.x) / 8, y: (A.y + 3 * c1.y + 3 * c2.y + B.y) / 8 }; + return { d: `M${A.x} ${A.y} C${c1.x} ${c1.y} ${c2.x} ${c2.y} ${B.x} ${B.y}`, mid }; +} + +function linkTitle(link, byId) { + const end = e => { + const n = byId[e.node]; + const name = n ? n.name : e.node; + if (e.port_idx != null) return `${name} port ${e.port_idx}${e.port_name && e.port_name !== `Port ${e.port_idx}` ? ` "${e.port_name}"` : ''}`; + return e.iface ? `${name} ${e.iface}` : name; + }; + const bits = [`${end(link.a)} to ${end(link.b)}`, speedText(link.speed_mbps), MEDIUM_TEXT[link.medium] || '']; + if (link.ghost) bits.push(`last seen ${link.seen || ''}`); + else if (!link.up) bits.push('down'); + return bits.filter(Boolean).join(' · '); +} + +function renderLink(link, lay) { + const A = anchorOf(lay, link.a, true); + const B = anchorOf(lay, link.b, false); + const { d, mid } = cablePath(A, B); + const cls = ['tp-link', mediumClass(link), speedClass(link.speed_mbps)]; + if (link.ghost) cls.push('tp-ghost'); + else if (!link.up) cls.push('tp-down'); + if (link.wan) cls.push('tp-wan'); + if (!link.wan && link.speed_mbps < 10000) cls.push('tp-slow'); + const busy = (link.a_to_b_bps || 0) + (link.b_to_a_bps || 0); + const alerting = [link.a.node, link.b.node].some(id => (lay.byId[id] || {}).alert_count); + if (alerting) cls.push('tp-touches-alert'); + const flowDur = Math.max(0.6, 6 - Math.log10(Math.max(busy, 1)) * 0.8).toFixed(2); + const flow = link.up && !link.ghost && busy > 1000 + ? `` : ''; + const label = 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)} + !`; + } + const plugs = ['dac', 'fibre', 'sfp'].includes(link.medium) + ? `` + : ``; + 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))} + ${flow} + `, + over: ``, + }; +} + +function renderNode(node, lay) { + const p = lay.pos[node.id]; + const d = lay.drawings[node.id]; + const href = nodeHref(node); + const sub = node.kind === 'stub' + ? [node.ip ? '' : (node.mac || ''), `${node.switch} port ${node.port_idx}`].filter(Boolean).join(' · ') + : [node.kind === 'host' ? node.status : (node.model || node.kind), node.kind !== 'host' && node.status !== 'up' ? node.status : ''].filter(Boolean).join(' · '); + const ring = node.severity + ? ` + ${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 = href ? `` : ``; + const close = href ? '' : ''; + return ` + ${open}${esc(label)} + + ${ring} + ${d.svg} + ${node.kind === 'internet' ? '' : ``} + ${node.kind === 'internet' ? '' : `${esc(node.name)}`} + ${esc(sub)} + ${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-only-10g', state.filters.tenG); + canvas.classList.toggle('tp-alerts-only', state.filters.alerts); + document.querySelectorAll('[data-filter]').forEach(cb => { cb.checked = !!state.filters[cb.dataset.filter]; }); +} + +function render() { + const data = state.data; + if (!data) return; + state.layout = layout(data, state.filters); + const lay = state.layout; + const world = document.getElementById('tp-world'); + const cables = lay.links.map(l => renderLink(l, lay)); + world.innerHTML = `${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) { + 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}${rows.join('')}
`; + panel.hidden = false; + state.selected = link.id; + document.querySelectorAll('.tp-link.tp-selected').forEach(el => el.classList.remove('tp-selected')); + document.querySelectorAll(`.tp-link[data-link="${CSS.escape(link.id)}"]`).forEach(el => el.classList.add('tp-selected')); +} + +function hidePanel() { + document.getElementById('tp-panel').hidden = true; + state.selected = null; + document.querySelectorAll('.tp-link.tp-selected').forEach(el => el.classList.remove('tp-selected')); +} + +// ── Path to the internet ──────────────────────────────────────────────── +function upstream(id) { + const links = state.layout.links; + const nodes = new Set([id]); + const hot = new Set(); + const queue = [id]; + while (queue.length) { + const cur = queue.shift(); + links.forEach(l => { + if (l.b.node === cur && !l.ghost) { + hot.add(l.id); + if (!nodes.has(l.a.node)) { + nodes.add(l.a.node); + queue.push(l.a.node); + } + } + }); + } + return { nodes, hot }; +} + +function highlight(id) { + const canvas = document.getElementById('tp-canvas'); + document.querySelectorAll('.tp-hot').forEach(el => el.classList.remove('tp-hot')); + if (!id || !state.layout) { + canvas.classList.remove('tp-tracing'); + return; + } + const { nodes, hot } = upstream(id); + nodes.forEach(n => document.querySelector(`.tp-node[data-node="${CSS.escape(n)}"]`)?.classList.add('tp-hot')); + hot.forEach(l => document.querySelectorAll(`.tp-link[data-link="${CSS.escape(l)}"]`).forEach(el => el.classList.add('tp-hot'))); + canvas.classList.add('tp-tracing'); +} + +// ── Pan and zoom ──────────────────────────────────────────────────────── +const K_MIN = 0.03; +const K_MAX = 3; + +function fit() { + if (!state.layout) return; + const canvas = document.getElementById('tp-canvas'); + const { x0, y0, x1, y1 } = state.layout.bounds; + const cw = canvas.clientWidth, ch = canvas.clientHeight; + const k = Math.max(K_MIN, Math.min(K_MAX, Math.min(cw / (x1 - x0), ch / (y1 - y0)))); + state.view = { k, tx: (cw - (x1 - x0) * k) / 2 - x0 * k, ty: (ch - (y1 - y0) * k) / 2 - y0 * k }; + applyView(); +} + +function zoomAt(factor, px, py) { + const v = state.view; + const k = Math.max(K_MIN, Math.min(K_MAX, v.k * factor)); + const wx = (px - v.tx) / v.k, wy = (py - v.ty) / v.k; + state.view = { k, tx: px - wx * k, ty: py - wy * k }; + applyView(); +} + +function zoomCentre(factor) { + const canvas = document.getElementById('tp-canvas'); + zoomAt(factor, canvas.clientWidth / 2, canvas.clientHeight / 2); +} + +function initPanZoom() { + const canvas = document.getElementById('tp-canvas'); + const svg = document.getElementById('tp-svg'); + const pointers = new Map(); + let drag = null; + let pinch = null; + let suppressClick = false; + + const local = e => { + const r = canvas.getBoundingClientRect(); + return { x: e.clientX - r.left, y: e.clientY - r.top }; + }; + + svg.addEventListener('pointerdown', e => { + if (e.button !== 0 && e.pointerType === 'mouse') return; + pointers.set(e.pointerId, local(e)); + if (pointers.size === 1) { + drag = { start: local(e), view: Object.assign({}, state.view), id: e.pointerId, moved: false }; + } else if (pointers.size === 2) { + const [p, q] = [...pointers.values()]; + pinch = { dist: Math.hypot(p.x - q.x, p.y - q.y), k: state.view.k }; + drag = null; + } + }); + + svg.addEventListener('pointermove', e => { + if (!pointers.has(e.pointerId)) return; + pointers.set(e.pointerId, local(e)); + if (pinch && pointers.size === 2) { + const [p, q] = [...pointers.values()]; + const dist = Math.hypot(p.x - q.x, p.y - q.y); + zoomAt((pinch.k * dist / pinch.dist) / state.view.k, (p.x + q.x) / 2, (p.y + q.y) / 2); + suppressClick = true; + return; + } + if (!drag || drag.id !== e.pointerId) return; + const pt = local(e); + const dx = pt.x - drag.start.x, dy = pt.y - drag.start.y; + if (!drag.moved && Math.hypot(dx, dy) < 5) return; + if (!drag.moved) { + drag.moved = true; + svg.setPointerCapture(e.pointerId); + canvas.classList.add('tp-dragging'); + } + state.view = { k: drag.view.k, tx: drag.view.tx + dx, ty: drag.view.ty + dy }; + applyView(); + }); + + const end = e => { + pointers.delete(e.pointerId); + if (drag && drag.id === e.pointerId && drag.moved) suppressClick = true; + if (pointers.size < 2) pinch = null; + if (!pointers.size) drag = null; + canvas.classList.remove('tp-dragging'); + }; + svg.addEventListener('pointerup', end); + svg.addEventListener('pointercancel', end); + + // A drag that ends over a device is not a click on it + svg.addEventListener('click', e => { + if (suppressClick) { + e.preventDefault(); + e.stopPropagation(); + suppressClick = false; + } + }, true); + + svg.addEventListener('wheel', e => { + e.preventDefault(); + const pt = local(e); + zoomAt(Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0015)), pt.x, pt.y); + }, { passive: false }); + + canvas.addEventListener('keydown', e => { + if (e.target.closest('input, select, textarea, .tp-panel')) return; + if (e.key === '+' || e.key === '=') { zoomCentre(1.25); e.preventDefault(); } + else if (e.key === '-' || e.key === '_') { zoomCentre(0.8); e.preventDefault(); } + else if (e.key === '0') { fit(); e.preventDefault(); } + else if (e.key === 'Escape') hidePanel(); + }); +} + +// ── Events ────────────────────────────────────────────────────────────── +function initEvents() { + const svg = document.getElementById('tp-svg'); + svg.addEventListener('click', e => { + const port = e.target.closest('[data-action="select-port"]'); + if (port) { + e.preventDefault(); + window.location.href = `${deviceHref(port.dataset.switch)}#port-${encodeURIComponent(port.dataset.portIdx)}`; + return; + } + const link = e.target.closest('.tp-link'); + if (link) { + const found = state.data.links.find(l => l.id === link.dataset.link); + if (found) showPanel(found); + return; + } + if (!e.target.closest('.tp-node')) hidePanel(); + }); + svg.addEventListener('keydown', e => { + if (e.key !== 'Enter' && e.key !== ' ') return; + const link = e.target.closest('.tp-link'); + if (link) { + e.preventDefault(); + const found = state.data.links.find(l => l.id === link.dataset.link); + if (found) showPanel(found); + } + }); + const trace = e => { + const node = e.target.closest('.tp-node'); + highlight(node ? node.dataset.node : null); + }; + svg.addEventListener('mouseover', trace); + svg.addEventListener('focusin', trace); + svg.addEventListener('mouseleave', () => highlight(null)); + svg.addEventListener('focusout', e => { if (!svg.contains(e.relatedTarget)) highlight(null); }); + + document.getElementById('tp-panel').addEventListener('click', e => { + if (e.target.closest('[data-action="close-panel"]')) hidePanel(); + }); + + document.querySelectorAll('[data-filter]').forEach(cb => { + cb.addEventListener('change', () => { + state.filters[cb.dataset.filter] = cb.checked; + saveFilters(); + if (cb.dataset.filter === 'stubs' || cb.dataset.filter === 'power') render(); + else applyFilterClasses(); + }); + }); + document.querySelectorAll('[data-zoom]').forEach(btn => { + btn.addEventListener('click', () => { + if (btn.dataset.zoom === 'fit') fit(); + else zoomCentre(btn.dataset.zoom === 'in' ? 1.25 : 0.8); + }); + }); + let resizeTimer = null; + window.addEventListener('resize', () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(applyView, 150); + }); +} + +// ── Data ──────────────────────────────────────────────────────────────── +async function refresh() { + try { + const data = await lt.api.get('/api/topology'); + state.data = data; + // The layout follows from the devices and cables alone, so a redraw with the same ones keeps + // everything where it was; the view (pan and zoom) is kept either way + render(); + const warn = document.getElementById('tp-warnings'); + warn.hidden = !(data.warnings && data.warnings.length); + warn.innerHTML = (data.warnings || []).map(w => `
${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 %} - -
-
-

Network Topology

- -
-
- -
-
- - {%- set topo_h = snapshot.hosts if snapshot.hosts else {} -%} - - -
-
- ◈ - INTERNET - WAN uplink -
-
- - -
-
- AT&T Fiber -
- - -
-
- ◈ - BGW320 - AT&T Gateway - Rx - -
-
- - -
-
- WAN · Port 9 · 1G -
- - -
-
- ⬡ - UDM-Pro - Dream Machine Pro - RU24 -
-
- - -
-
- 10G SFP+ -
- - -
-
- ⬡ - USW-Agg - Aggregation · RU22 - 8 × 10G SFP+ - VLAN90 · 10.10.90.x/24 -
-
- - -
-
- 10G trunk -
- - -
-
- ⬡ - 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..9fa79c5 --- /dev/null +++ b/templates/topology.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block title %}Topology{% endblock %} + +{% block content %} +
+
+

Topology

+

Every device and cable as the switches see them right now, drawn from the real faceplates. Drag to pan, scroll or pinch to zoom. Hover a device to trace its path to the internet; click a cable for its details.

+
+
+ + + + + +
+ + + + + +
+ +
+ 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 '