diff --git a/README.md b/README.md index ef4fd17..ab44251 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,13 @@ A Proxmox/Ceph cluster lives or dies by its network, and network problems are sn ## Gallery -**Dashboard**: alert queue with linked tickets, a live topology, per-host interface status and the UniFi device inventory. +**Dashboard**: alert queue with linked tickets, per-host interface status and the UniFi device inventory. ![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 ``` diff --git a/app.py b/app.py index b5245f9..87aa948 100644 --- a/app.py +++ b/app.py @@ -26,6 +26,7 @@ import cutsheet import db import devices import diagnose +import topology from monitor import PulseClient, UnifiClient logging.basicConfig( @@ -1195,17 +1196,7 @@ def _racks(items: list) -> list: return racks -_VIRTUAL_IFACE = ('lo', 'vmbr', 'bond', 'docker', 'veth', 'tap', 'fwbr', 'fwpr', 'fwln', 'br-', 'virbr', 'wg', 'tun') - - -def _physical_interfaces(name: str, snapshot: dict) -> list: - """A host's physical NICs in name order, with their state: [{'name', 'up'}]. - - Drawn fronts light one RJ45 per NIC in this order (first NIC on the leftmost jack). - """ - ifaces = ((snapshot.get('hosts') or {}).get(name) or {}).get('interfaces') or {} - return [{'name': n, 'up': str(state).lower() == 'up', 'unused': str(state).lower() == 'unused'} - for n, state in sorted(ifaces.items()) if not n.startswith(_VIRTUAL_IFACE)] +_physical_interfaces = devices.physical_interfaces def _device_status(name: str, snapshot: dict) -> str: @@ -1314,6 +1305,35 @@ def api_rack(): }) +# --------------------------------------------------------------------------- +# Topology +# --------------------------------------------------------------------------- + +@app.route('/topology') +@require_auth +def topology_page(): + return render_template('topology.html', user=_get_user()) + + +@app.route('/api/topology') +@require_auth +def api_topology(): + """Every device and physical cable, for the topology page (see topology.build_graph).""" + events = db.get_active_events(limit=_PAGE_LIMIT) + _annotate_suppressions(events, db.get_active_suppressions()) + try: + _ensure_cutsheet_schema() + sheet = db.cutsheet_load() + except Exception as e: + logger.error(f'Cutsheet unavailable for topology: {e}') + sheet = {'items': [], 'cables': []} + graph = topology.build_graph( + _state_json('network_snapshot'), _state_json('link_stats'), _state_json('modem_stats'), + events, sheet['items'], sheet['cables'], _state_json('known_uplinks'), + ) + return jsonify(graph) + + @app.route('/api/cutsheet/racks', methods=['POST']) @require_auth @require_admin diff --git a/devices.py b/devices.py index 6346a75..5a49cc7 100644 --- a/devices.py +++ b/devices.py @@ -14,6 +14,19 @@ _KIND_BY_TYPE = { } +_VIRTUAL_IFACE = ('lo', 'vmbr', 'bond', 'docker', 'veth', 'tap', 'fwbr', 'fwpr', 'fwln', 'br-', 'virbr', 'wg', 'tun') + + +def physical_interfaces(name: str, snapshot: dict) -> List[dict]: + """A host's physical NICs in name order, with their state: [{'name', 'up', 'unused'}]. + + Drawn fronts light one RJ45 per NIC in this order (first NIC on the leftmost jack). + """ + ifaces = (((snapshot or {}).get('hosts') or {}).get(name) or {}).get('interfaces') or {} + return [{'name': n, 'up': str(state).lower() == 'up', 'unused': str(state).lower() == 'unused'} + for n, state in sorted(ifaces.items()) if not n.startswith(_VIRTUAL_IFACE)] + + def unifi_kind(unifi_type: str, model: str) -> str: if model in _PDU_MODELS: return 'pdu' diff --git a/docs/img/topology.png b/docs/img/topology.png index c19aeaa..a101532 100644 Binary files a/docs/img/topology.png and b/docs/img/topology.png differ diff --git a/monitor.py b/monitor.py index 86f815a..c525a5e 100644 --- a/monitor.py +++ b/monitor.py @@ -929,17 +929,21 @@ class LinkStatsCollector: logger.warning(f'UniFi switch port collection failed: {e}') unifi_devices = (self.unifi.get_device_details() or {}) if self.unifi else {} - self._resolve_neighbors(unifi_switches, unifi_devices, instance_map) + host_macs = self._resolve_neighbors(unifi_switches, unifi_devices, instance_map) return { 'hosts': result_hosts, 'unifi_switches': unifi_switches, 'unifi_devices': unifi_devices, + # Which host NIC owns each MAC, for matching outlets and neighbours named by MAC + 'host_macs': {mac: list(owner) for mac, owner in host_macs.items()}, 'updated': datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC'), } - def _resolve_neighbors(self, switches: dict, unifi_devices: dict, instance_map: Dict[str, str]) -> None: - """Name port neighbors: UniFi's LLDP data has only MACs, and many servers send none.""" + def _resolve_neighbors(self, switches: dict, unifi_devices: dict, instance_map: Dict[str, str]) -> dict: + """Name port neighbors: UniFi's LLDP data has only MACs, and many servers send none. + + Returns {mac: (host, interface)} for every physical host NIC Prometheus reports.""" host_macs: Dict[str, tuple] = {} host_ips: Dict[str, str] = {} # Bonds share their MAC with their member NICs, and LLDP runs on the NIC. @@ -962,6 +966,7 @@ class LinkStatsCollector: } devices.resolve_lldp_neighbors(switches, host_macs, device_macs, host_ips) devices.apply_client_locations(switches, self.unifi.get_wired_clients(), host_macs, device_macs) + return host_macs def _compute_unifi_rates(self, raw: Dict[str, dict], now: float) -> Dict[str, dict]: """Compute per-port byte/error rates from delta against previous snapshot.""" diff --git a/static/app.js b/static/app.js index d004f65..4c50b1b 100644 --- a/static/app.js +++ b/static/app.js @@ -48,7 +48,6 @@ async function refreshAll() { const net = netResult.value; updateHostGrid(net.hosts || {}); updateUnifiTable(net.unifi || []); - updateTopology(net.hosts || {}); } else { showToast('Network data unavailable', 'warning'); } @@ -167,26 +166,6 @@ function updateHostGrid(hosts) { } } -function updateTopology(hosts) { - document.querySelectorAll('.topo-host').forEach(node => { - const name = node.dataset.host; - const host = hosts[name]; - if (!host) return; - node.className = node.className.replace(/topo-v2-status-(up|down|degraded|unknown)/g, ''); - node.classList.add(`topo-v2-status-${host.status}`); - const badge = node.querySelector('.topo-badge'); - if (badge) { - badge.className = `topo-badge topo-badge-${host.status}`; - badge.textContent = host.status; - } - - // Animate the 10G drop-wire red+dashed when host is down - document.querySelectorAll(`.topo-v2-wire-10g[data-host="${CSS.escape(name)}"]`).forEach(wire => { - wire.classList.toggle('wire-down', host.status === 'down'); - }); - }); -} - // ── Device search ───────────────────────────────────────────────────── function deviceUrl(name) { return `/device/${encodeURIComponent(name)}`; @@ -396,13 +375,9 @@ function updateModem(data) { detailsLink.hidden = false; } - const rxLabel = document.getElementById('topo-modem-rx'); - const topo = document.getElementById('topo-modem'); const title = document.getElementById('modem-title'); if (!data.reachable) { - if (rxLabel) rxLabel.textContent = 'unreachable'; - if (topo) topo.classList.add('topo-v2-status-down'); body.innerHTML = `
Modem status unavailable: ${lt.escHtml(data.error || 'unknown error')} Last attempt ${lt.escHtml(data.updated || '-')}
@@ -415,10 +390,7 @@ function updateModem(data) { const status = data.optics_status || {}; const counters = data.counters || {}; const rates = data.rates || {}; - const rx = optics.rx_power ? optics.rx_power.value : null; - if (topo) topo.classList.toggle('topo-v2-status-down', problems.length > 0); - if (rxLabel) rxLabel.textContent = (rx === null || rx === undefined) ? 'Rx -' : `Rx ${rx} dBm`; if (title) { title.textContent = [data.model || 'AT&T Gateway', data.firmware && `fw ${data.firmware}`] .filter(Boolean).join(' · '); diff --git a/static/faceplates.js b/static/faceplates.js index 3f04150..682231b 100644 --- a/static/faceplates.js +++ b/static/faceplates.js @@ -1,5 +1,5 @@ 'use strict'; -/* exported renderFaceplate, faceplateScrollLeft, FACEPLATES */ +/* exported renderFaceplate, faceplateScrollLeft, faceplatePortAnchor, FACEPLATES */ // Realistic front panels, drawn as SVG from each model's real geometry. // Coordinates are traced from reference images. Keys match UniFi's model code. @@ -639,6 +639,17 @@ function faceplateSvg(model, ctx, attrs = '') { return { svg, marks }; } +// Where a port's cable plugs in, in the faceplate's viewBox units: { x, y, face }, face 'top' for a +// port the right way up and 'bottom' for a flipped one (the lower row). null when there is no such +// port. A model whose one port takes any index (the PDU's management port) answers for every index. +function faceplatePortAnchor(spec, idx) { + const p = spec.ports.find(q => q.idx === idx) || spec.ports.find(q => q.anyPort); + if (!p) return null; + const face = p.flip ? 'bottom' : 'top'; + if (p.type === 'sfp') return { x: p.x + p.w / 2, y: p.y + p.h / 2, face }; + return { x: p.x, y: p.y, face }; +} + // Where an fp-scroll panel should start: its port area function faceplateScrollLeft(model, frame) { const spec = FACEPLATES[model]; diff --git a/static/style.css b/static/style.css index 131954a..04d3e4b 100644 --- a/static/style.css +++ b/static/style.css @@ -60,12 +60,6 @@ --glow-cyan: none; --glow-xl: none; } -[data-theme="light"] .topology { - background-image: radial-gradient(circle, rgba(0,100,160,0.07) 1px, transparent 1px); -} -[data-theme="light"] .topo-vc-label { - background: rgba(235,238,242,.88); -} /* ── Header overlap fix ─────────────────────────────────────────── .lt-container's padding shorthand resets padding-top, defeating @@ -151,23 +145,6 @@ /* ── Stats grid spacing variant ──────────────────────────────────── */ .lt-stats-grid--mb { margin-bottom: 16px; } -/* ── Topology section collapse toggle ────────────────────────────── */ -.topo-collapse-btn { - margin-left: auto; - font-size: .7em; - color: var(--text-muted); - background: transparent; - border: 1px solid var(--border-color); - padding: 2px 8px; - cursor: pointer; - font-family: var(--font); - letter-spacing: .04em; - transition: border-color .15s, color .15s; -} -.topo-collapse-btn:hover { border-color: var(--amber); color: var(--amber); } -.topo-collapsible { overflow: hidden; transition: max-height .25s ease; } -.topo-collapsible.is-collapsed { display: none; } - /* ── Animations used by custom components ─────────────────────────── */ @keyframes pulse-red { 0%,100% { box-shadow: 0 0 0 0 rgba(255,45,85,.5); } @@ -276,7 +253,7 @@ border: 1px solid; letter-spacing: .04em; } -.chip-critical { color: var(--red); border-color: var(--red); text-shadow: var(--glow-red); animation: topo-pulse-down 2s ease-in-out infinite; } +.chip-critical { color: var(--red); border-color: var(--red); text-shadow: var(--glow-red); animation: pulse-down 2s ease-in-out infinite; } .chip-warning { color: var(--orange); border-color: var(--orange); } .chip-ok { color: var(--green); border-color: var(--green-muted); text-shadow: var(--glow); } .status-meta { display: flex; align-items: center; gap: 10px; white-space: nowrap; } @@ -418,252 +395,12 @@ .target-ifaces{ display: flex; flex-wrap: wrap; gap: 3px; } .iface-chip { font-family: var(--font); font-size: .65em; background: var(--bg3); border: 1px solid var(--border-color); padding: 1px 5px; color: var(--text-dim); } -/* ── Topology diagram ─────────────────────────────────────────────── */ -.topology { - background-color: var(--bg2); - background-image: radial-gradient(circle, rgba(0,212,255,0.07) 1px, transparent 1px); - background-size: 22px 22px; - border: 1px solid var(--border-color); - padding: 20px 16px 16px; - margin-bottom: 16px; - text-align: center; - overflow-x: auto; -} - -/* topo-v2 outer wrapper */ -.topo-v2 { - display: flex; - flex-direction: column; - align-items: center; - gap: 0; - min-width: 860px; - padding: 20px 24px 24px; -} - -.topo-tier { - display: flex; - justify-content: center; - align-items: center; - width: 100%; -} - -.topo-vc { - display: flex; - justify-content: center; - align-items: flex-start; - width: 100%; - position: relative; - height: 40px; -} -.topo-vc-wire { - position: absolute; - left: 50%; - top: 0; - transform: translateX(-50%); - width: 2px; - height: 100%; - background: linear-gradient(to bottom, var(--cyan), var(--green)); - opacity: .7; -} -.topo-vc-wire--wan { background: linear-gradient(to bottom, var(--cyan), rgba(0,212,255,.3)); opacity: .7; } -.topo-vc-wire--10g { background: var(--amber); opacity: .6; } -.topo-vc-wire--mgmt { background: var(--border-color); opacity: .5; } -/* Blurred copy of the wire for a soft glow halo */ -.topo-vc-wire::before { - content: ''; - position: absolute; - inset: 0; - background: inherit; - filter: blur(5px); - opacity: .5; -} -.topo-vc-label { - position: absolute; - left: calc(50% + 7px); - top: 50%; - transform: translateY(-50%); - font-size: .58em; - color: var(--amber); - white-space: nowrap; - letter-spacing: .06em; - font-family: var(--font); - background: rgba(3,5,8,.7); - padding: 1px 4px; -} - -.topo-v2-node { - display: flex; - flex-direction: column; - align-items: center; - gap: 3px; - padding: 8px 16px; - border: 1px solid var(--border-color); - background: var(--bg3); - position: relative; - font-size: .75em; - font-family: var(--font); - min-width: 110px; - text-align: center; - transition: border-color .2s, box-shadow .2s; - overflow: hidden; -} -/* Top highlight strip: color matches node type / status */ -.topo-v2-node::before { - content: ''; - position: absolute; - top: 0; left: 0; right: 0; - height: 2px; - background: currentColor; - opacity: .4; -} -.topo-v2-status-up.topo-v2-node::before { background: var(--green); opacity: .65; } -.topo-v2-status-down.topo-v2-node::before { background: var(--red); opacity: .75; } -.topo-v2-status-degraded.topo-v2-node::before { background: var(--orange); opacity: .65; } -.topo-v2-status-unknown.topo-v2-node::before { opacity: .15; } -.topo-v2-icon { font-size: 1.3em; line-height: 1; } -.topo-v2-label { font-weight: bold; letter-spacing: .04em; } -.topo-v2-sub { font-size: .58em; color: var(--text-muted); letter-spacing: .02em; } -.topo-v2-vlan { font-size: .54em; color: var(--cyan); opacity: .75; } - -.topo-v2-host--bus { min-width: 80px; max-width: 96px; } -.topo-v2-internet { border-color: var(--cyan); color: var(--cyan); text-shadow: var(--glow-cyan); box-shadow: 0 0 12px rgba(0,212,255,.12); } -.topo-v2-router { border-color: var(--cyan); color: var(--cyan); text-shadow: var(--glow-cyan); box-shadow: 0 0 12px rgba(0,212,255,.14); } -.topo-v2-switch { border-color: var(--amber); color: var(--amber); text-shadow: var(--glow-amber); box-shadow: 0 0 12px rgba(255,179,0,.12); } -.topo-v2-host { border-color: var(--border-color); color: var(--text); cursor: default; } - -@keyframes topo-pulse-down { +/* Pulsing red glow for critical chips and down states */ +@keyframes pulse-down { 0%,100% { box-shadow: 0 0 6px rgba(255,45,85,.3); } 50% { box-shadow: 0 0 18px rgba(255,45,85,.75), 0 0 30px rgba(255,45,85,.2); } } -.topo-v2-status-up { border-color: var(--green); box-shadow: 0 0 8px rgba(0,255,136,.2); } -.topo-v2-status-down { border-color: var(--red); animation: topo-pulse-down 2s ease-in-out infinite; } -.topo-v2-status-degraded { border-color: var(--orange); box-shadow: 0 0 8px rgba(255,107,0,.2); } -.topo-v2-status-unknown { border-color: var(--border-color); } -.topo-v2-offrack { border-style: dashed !important; } - -.topo-badge { font-size: .68em; padding: 1px 5px; border: 1px solid; letter-spacing: .03em; } -.topo-badge-up { color: var(--green); border-color: var(--green); text-shadow: var(--glow); } -.topo-badge-down { color: var(--red); border-color: var(--red); animation: topo-pulse-down 1.5s ease-in-out infinite; } -.topo-badge-degraded { color: var(--orange); border-color: var(--orange); } -.topo-badge-unknown { color: var(--text-muted); border-color: var(--border-color); } - -/* Host wrap + dual-homing wires */ -.topo-v2-host-wrap { - display: flex; - flex-direction: column; - align-items: center; -} -.topo-v2-host-wires { - display: flex; - gap: 6px; - height: 28px; - align-items: flex-start; -} -.topo-v2-wire-10g { width: 2px; height: 100%; background: var(--green); opacity: .55; transition: background .3s, opacity .3s; } -.topo-v2-wire-1g { width: 0; height: 100%; border-left: 2px dashed var(--amber); opacity: .45; } - -@keyframes wire-dash-anim { to { background-position: 0 -20px; } } -.topo-v2-wire-10g.wire-down { - background: repeating-linear-gradient(to bottom, var(--red) 0 6px, transparent 6px 10px) !important; - background-size: 2px 10px !important; - opacity: .9 !important; - transition: none !important; - animation: wire-dash-anim .7s linear infinite; -} - -/* Bus rails */ -.topo-bus-section { - width: 100%; - max-width: 860px; - display: flex; - flex-direction: column; - align-items: stretch; - gap: 0; -} -.topo-bus-10g { - display: flex; - align-items: center; - position: relative; - height: 20px; -} -.topo-bus-10g-line { - flex: 1; - height: 2px; - background: var(--green); - opacity: .55; - margin: 0 4px; - box-shadow: 0 0 6px rgba(0,255,136,.4); -} -.topo-bus-10g-label { - font-size: .56em; - color: var(--green); - white-space: nowrap; - letter-spacing: .05em; - font-family: var(--font); - opacity: .85; - padding: 0 8px; -} -.topo-bus-1g { - display: flex; - align-items: center; - position: relative; - height: 18px; -} -.topo-bus-1g-line { - flex: 1; - height: 0; - border-top: 2px dashed var(--amber); - opacity: .35; - margin: 0 4px; -} -.topo-bus-1g-label { - font-size: .56em; - color: var(--amber); - white-space: nowrap; - letter-spacing: .05em; - font-family: var(--font); - opacity: .8; - padding: 0 8px; -} - -/* Hosts row */ -.topo-v2-hosts { - display: flex; - justify-content: center; - gap: 12px; - flex-wrap: wrap; - padding-top: 4px; - width: 100%; -} - -/* Topology legend */ -.topo-legend { - display: flex; - gap: 18px; - align-items: center; - margin-top: 14px; - padding-top: 10px; - border-top: 1px solid rgba(255,107,0,.12); - flex-wrap: wrap; - justify-content: center; -} -.topo-legend-item { - display: flex; - align-items: center; - gap: 5px; - font-size: .58em; - color: var(--text-muted); - font-family: var(--font); -} -.topo-legend-item--offrack { - border: 1px dashed var(--border-color); - padding: 1px 5px; -} -.topo-legend-line-10g { width: 24px; height: 2px; background: var(--green); display: inline-block; box-shadow: 0 0 4px rgba(0,255,136,.5); } -.topo-legend-line-1g { width: 24px; height: 0; border-top: 2px dashed var(--amber); display: inline-block; } -.topo-legend-line-wan { width: 24px; height: 2px; background: linear-gradient(to right, var(--cyan), var(--green)); display: inline-block; } - /* ── Link debug page ──────────────────────────────────────────────── */ .link-host-list { display: flex; flex-direction: column; gap: 18px; } .link-host-panel { @@ -1067,7 +804,7 @@ .lt-stat-card--alert { border-color: var(--red) !important; box-shadow: 0 0 8px rgba(255,45,85,.25) !important; - animation: topo-pulse-down 2s ease-in-out infinite; + animation: pulse-down 2s ease-in-out infinite; } .lt-stat-card--alert::before { background: var(--red); box-shadow: var(--glow-red); } @@ -1077,7 +814,6 @@ /* ── Responsive ───────────────────────────────────────────────────── */ @media (max-width: 768px) { .host-grid { grid-template-columns: 1fr; } - .topology { display: none; } .form-row { flex-direction: column; } .status-bar { flex-direction: column; align-items: flex-start; } .link-ifaces-grid { grid-template-columns: 1fr; } @@ -1150,7 +886,6 @@ a.device-link:hover { color: var(--text-accent); border-bottom-color: current .device-row-sub { font-size: .72em; color: var(--text-muted); } .device-row-toggle { background: none; border: 0; color: inherit; cursor: pointer; font: inherit; padding: 2px 6px; } .device-row-detail > td { background: var(--bg-tertiary); padding: 10px 14px; } -.topo-host[data-host] { cursor: pointer; } /* WAN outage list in the modem panel */ .modem-outages { padding: 0 12px 8px; } @@ -1678,3 +1413,226 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; } .device-row--unused td { opacity: .6; } .device-row--unused td:last-child { opacity: 1; } .lt-btn-xs { padding: 1px 6px; font-size: .68rem; margin-left: 6px; } + +/* ── Topology page ─────────────────────────────────────────────────── */ +.tp-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; margin: 8px 0 10px; } +.tp-filters { display: flex; flex-wrap: wrap; gap: 6px 14px; } +.tp-filter { display: inline-flex; align-items: center; gap: 6px; font-size: .78em; color: var(--text-dim); cursor: pointer; white-space: nowrap; } +.tp-zoom { display: inline-flex; gap: 4px; } +.tp-updated { margin-left: auto; font-size: .72em; color: var(--text-muted); } +.tp-warnings { border: 1px solid var(--amber); color: var(--amber); background: var(--amber-dim); padding: 8px 12px; margin-bottom: 10px; font-size: .8em; } +.tp-canvas { + position: relative; overflow: hidden; touch-action: none; cursor: grab; + height: clamp(420px, calc(100vh - 300px), 1400px); + border: 1px solid var(--border-color); + background-color: var(--bg2); + background-image: radial-gradient(circle, rgba(0,212,255,.07) 1px, transparent 1px); + background-size: 22px 22px; +} +[data-theme="light"] .tp-canvas { background-image: radial-gradient(circle, rgba(0,100,160,.08) 1px, transparent 1px); } +.tp-canvas:focus-visible { outline: 2px solid var(--amber); outline-offset: 2px; } +.tp-canvas.tp-dragging { cursor: grabbing; } +.tp-svg { display: block; width: 100%; height: 100%; user-select: none; } +.tp-loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; } +.tp-loading[hidden] { display: none; } + +/* Devices */ +.tp-node-link { cursor: pointer; outline: none; } +.tp-node-hit { fill: transparent; stroke: transparent; stroke-width: 4; } +.tp-node-link:hover .tp-node-hit, .tp-node-link:focus-visible .tp-node-hit { stroke: var(--amber); fill: rgba(255,179,0,.04); } +.tp-name { font-family: var(--font); font-size: 40px; font-weight: 700; fill: var(--text); letter-spacing: .02em; + paint-order: stroke; stroke: var(--bg2); stroke-width: 10px; stroke-linejoin: round; } +.tp-sub { font-family: var(--font); font-size: 28px; fill: var(--text-muted); + paint-order: stroke; stroke: var(--bg2); stroke-width: 8px; stroke-linejoin: round; } +.tp-dot { stroke: rgba(0,0,0,.35); stroke-width: 2; } +.tp-dot-up { fill: #2fd36b; } +.tp-dot-degraded { fill: #ffb300; } +.tp-dot-down { fill: #e0312b; } +.tp-dot-unknown { fill: #6b737d; } +.tp-ring { fill: none; stroke-width: 7; } +.tp-ring-critical { stroke: #e0312b; } +.tp-ring-warning { stroke: #ffb300; } +.tp-ring-info { stroke: #34a8ff; } +.tp-badge circle { stroke: var(--bg2); stroke-width: 5; } +.tp-badge text { font-family: var(--font); font-size: 34px; font-weight: 700; fill: #fff; } +.tp-badge-critical circle { fill: #e0312b; } +.tp-badge-warning circle { fill: #c98a00; } +.tp-badge-info circle { fill: #1f7ad6; } +.tp-kind-stub .tp-name { font-size: 32px; font-weight: 400; fill: var(--text-dim); } +.tp-kind-stub .tp-sub { font-size: 24px; } + +.tp-cloud { fill: #dfe7ef; stroke: #8fa3b5; stroke-width: 4; } +.tp-cloud-text { font-family: var(--font); font-size: 44px; font-weight: 700; fill: #34495e; letter-spacing: .12em; } +.tp-st-down .tp-cloud { fill: #f3d6d4; stroke: #e0312b; } +.tp-modem-body { fill: #16181b; stroke: #050506; stroke-width: 3; } +.tp-modem-edge { fill: none; stroke: #2c3035; stroke-width: 3; } +.tp-modem-globe { fill: #1f6fd1; } +.tp-modem-globe-line { fill: none; stroke: #9cc6ff; stroke-width: 3; } +.tp-modem-model { font-family: var(--font); font-size: 22px; fill: #8b929b; } +.tp-led { stroke: #000; stroke-width: 2; } +.tp-led-on { fill: #2fd36b; } +.tp-led-off { fill: #3a3f45; } +.tp-led-bad { fill: #e0312b; } +.tp-ap-body { fill: #f2f3f4; stroke: #c3c6ca; stroke-width: 4; } +.tp-ap-ring { fill: none; stroke-width: 10; stroke: #6b737d; } +.tp-ap-up { stroke: #2a8cff; } +.tp-ap-down { stroke: #e0312b; } +.tp-ap-dot { fill: #d7dadd; } +.tp-strip-body { fill: #f0f1f2; stroke: #c3c6ca; stroke-width: 4; } +.tp-strip-outlet rect:first-of-type { fill: #fafafa; stroke: #b9bcc0; stroke-width: 3; } +.tp-strip-slot { fill: #2b2d31; } +.tp-strip-outlet.tp-off rect:first-of-type { fill: #d9dadc; } +.tp-box, .tp-host-box { fill: #22272e; stroke: #3a434e; stroke-width: 3; } +.tp-box-text { font-family: var(--font); font-size: 34px; fill: #8b97a5; } +.tp-host-led { stroke: #000; stroke-width: 2; } +.tp-gen-body { fill: #2a2f36; stroke: #12151a; stroke-width: 3; } +.tp-gen-model { font-family: var(--font); font-size: 30px; fill: #c9d1d9; } +.tp-gen-port { fill: #111418; stroke: #4b5562; stroke-width: 2; } +.tp-gen-up { stroke: #2fd36b; } +.tp-gen-num { font-family: var(--font); font-size: 16px; fill: #8b97a5; } +.tp-stub-body { fill: #3a3f46; stroke: #23272c; stroke-width: 3; } +.tp-stub-jack { fill: #15181c; } +.tp-stub-q { font-family: var(--font); font-size: 30px; font-weight: 700; fill: #9aa3ad; } +.tp-st-down .tp-stub-body { stroke: #e0312b; } +.tp-stub-jack.tp-off { opacity: .4; } +.tp-group-body { stroke-dasharray: 10 8; stroke: #5d6670; } +.tp-group-hint { font-family: var(--font); font-size: 20px; fill: #9aa3ad; } +.tp-select { font-size: .95em; padding: 1px 4px; margin-left: 4px; } +.tp-tag-bundle rect { stroke: #3b82f6; } + +/* Host NIC sockets */ +.tp-strip { fill: #1b1f24; stroke: #2f363f; stroke-width: 3; } +.tp-nic-rj45, .tp-nic-sfp { fill: #0b0d10; stroke: #59626e; stroke-width: 3; } +.tp-nic-sfp-in { fill: #23272d; } +.tp-nic-name { font-family: var(--font); font-size: 17px; fill: #aab4bf; } +.tp-nic-led { fill: #3a3f45; } +.tp-nic-up .tp-nic-led { fill: #2fd36b; } +.tp-nic-up .tp-nic-rj45, .tp-nic-up .tp-nic-sfp { stroke: #8f9aa6; } +.tp-nic-down .tp-nic-led { fill: #e0312b; } +.tp-nic-down .tp-nic-rj45, .tp-nic-down .tp-nic-sfp { stroke: #e0312b; } +.tp-nic-unused { opacity: .45; } +.tp-nic-unused .tp-nic-rj45, .tp-nic-unused .tp-nic-sfp { stroke-dasharray: 6 5; } +.tp-nic-alert-critical .tp-nic-name { fill: #ff6b5e; } +.tp-nic-alert-warning .tp-nic-name { fill: #ffb300; } + +/* Cables: colours are the real jackets, the same in both themes */ +.tp-link { cursor: pointer; outline: none; } +.tp-link path { fill: none; stroke-linecap: round; } +.tp-casing { stroke: #050607; } +.tp-core { stroke: #2f343b; } +.tp-hit { stroke: transparent; stroke-width: 40; } +.tp-s-10g .tp-casing { stroke-width: 17; } .tp-s-10g .tp-core { stroke-width: 11; } +.tp-s-1g .tp-casing { stroke-width: 12; } .tp-s-1g .tp-core { stroke-width: 7; } +.tp-s-100m .tp-casing { stroke-width: 8; } .tp-s-100m .tp-core { stroke-width: 4.5; } +.tp-m-dac .tp-casing { stroke: #8a929c; } .tp-m-dac .tp-core { stroke: #16181c; } +.tp-m-fibre .tp-casing { stroke: #13757c; } .tp-m-fibre .tp-core { stroke: #38c9d3; } +.tp-m-wanfibre .tp-casing { stroke: #8a6a00; } .tp-m-wanfibre .tp-core { stroke: #f2c230; } +.tp-m-sfp .tp-casing { stroke: #23272c; } .tp-m-sfp .tp-core { stroke: #69717c; } +.tp-m-copper .tp-casing { stroke: #173f80; } .tp-m-copper .tp-core { stroke: #3b82f6; } +/* A group's cables, bundled: one thick run */ +.tp-link.tp-bundle .tp-casing { stroke-width: 28; stroke: #0f2b57; } .tp-link.tp-bundle .tp-core { stroke-width: 20; } +.tp-down .tp-casing { stroke: transparent; } +.tp-down .tp-core { stroke: #e0312b; stroke-dasharray: 28 18; } +.tp-ghost { opacity: .8; } +.tp-ghost .tp-casing { stroke: transparent; } +.tp-ghost .tp-core { stroke: #e0312b; stroke-dasharray: 2 18; stroke-width: 10; } +.tp-plug { fill: #c9ccd1; stroke: #5d636b; stroke-width: 2; } +.tp-boot { fill: #1e5bbf; stroke: #0d2f66; stroke-width: 2; } +.tp-links-over { pointer-events: none; } +.tp-links-over .tp-warn { pointer-events: auto; } +.tp-flow { stroke: rgba(255,255,255,.4); stroke-width: 3; stroke-dasharray: 3 60; animation: tp-flow linear infinite; pointer-events: none; } +.tp-s-100m .tp-flow { stroke-width: 2; } +@keyframes tp-flow { to { stroke-dashoffset: -63; } } +@media (prefers-reduced-motion: reduce) { .tp-flow { animation: none; display: none; } } +.tp-link:hover .tp-casing, .tp-link:focus-visible .tp-casing, .tp-selected .tp-casing { stroke: var(--amber); } +.tp-tag rect { fill: var(--bg-primary); stroke: var(--border-color); stroke-width: 2; opacity: .92; } +.tp-tag text { font-family: var(--font); font-size: 24px; fill: var(--text); } +.tp-tag-ghost rect { stroke: #e0312b; } +.tp-tag-ghost text { fill: #ff6b5e; } +.tp-rate { display: none; pointer-events: none; } +.tp-near .tp-rate, .tp-hot .tp-rate, .tp-selected .tp-rate { display: inline; } +.tp-warn path { fill: #ffb300; stroke: #5c4100; stroke-width: 3; } +.tp-warn text { font-family: var(--font); font-size: 30px; font-weight: 700; fill: #2b1f00; } + +/* Filters and the traced path */ +.tp-no-traffic .tp-rate, .tp-no-traffic .tp-flow { display: none !important; } +.tp-alerts-only .tp-node:not(.tp-has-alert) { opacity: .25; } +.tp-alerts-only .tp-link:not(.tp-touches-alert) { opacity: .2; } +.tp-tracing .tp-node:not(.tp-hot), .tp-tracing .tp-link:not(.tp-hot) { opacity: .18; transition: opacity .15s; } +.tp-tracing .tp-link.tp-hot .tp-casing { stroke: var(--amber); } +.tp-far .tp-sub, .tp-far .tp-nic-name { display: none; } + +/* Cable panel: a side panel, a bottom sheet on phones */ +.tp-panel { + position: absolute; top: 12px; right: 12px; width: min(360px, calc(100% - 24px)); + max-height: calc(100% - 24px); overflow: auto; z-index: 2; + background: var(--bg-primary); border: 1px solid var(--border-color); box-shadow: 0 8px 24px rgba(0,0,0,.35); + padding: 10px 14px 14px; cursor: auto; font-size: .8em; +} +.tp-panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; } +.tp-panel-title { font-size: 1em; margin: 0; letter-spacing: .06em; text-transform: uppercase; color: var(--amber); } +.tp-panel-table { width: 100%; border-collapse: collapse; } +.tp-panel-table th { text-align: left; font-weight: 400; color: var(--text-muted); padding: 4px 10px 4px 0; vertical-align: top; width: 42%; } +.tp-panel-table td { padding: 4px 0; overflow-wrap: anywhere; } +.tp-panel-warn { border: 1px solid var(--amber); background: var(--amber-dim); color: var(--text); padding: 6px 8px; margin: 0 0 8px; } +.tp-bad { color: var(--red); } +.tp-warn-text { color: var(--amber); } +.tp-dim-text { color: var(--text-muted); } +@media (max-width: 640px) { + .tp-panel { top: auto; left: 0; right: 0; bottom: 0; width: auto; max-height: 55%; border-width: 1px 0 0; } + .tp-updated { margin-left: 0; width: 100%; } + .tp-canvas { height: clamp(380px, calc(100vh - 260px), 900px); } +} + +.tp-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; margin: 10px 0 4px; font-size: .74em; color: var(--text-dim); } +.tp-key { display: inline-flex; align-items: center; gap: 6px; } +.tp-key svg { width: 34px; height: 12px; } +.tp-key-line { fill: none; stroke-width: 5; stroke-linecap: round; } +.tp-key-line.tp-m-dac { stroke: #2b2f35; } +.tp-key-line.tp-m-fibre { stroke: #38c9d3; } +.tp-key-line.tp-m-wanfibre { stroke: #f2c230; } +.tp-key-line.tp-m-sfp { stroke: #69717c; } +.tp-key-line.tp-m-copper { stroke: #3b82f6; } +.tp-key-down { stroke: #e0312b; stroke-dasharray: 6 4; } +.tp-key-ghost { stroke: #e0312b; stroke-dasharray: 1 5; } +.tp-key-warn { color: #ffb300; font-weight: 700; } + +/* Topology cards */ +.tp-card { fill: #121821; stroke: #2a3440; stroke-width: 3; } +[data-theme="light"] .tp-card { fill: #ffffff; stroke: #c9d1da; } +.tp-band { fill: #0a0e13; stroke: #1c242e; stroke-width: 2; } +[data-theme="light"] .tp-band { fill: #eef1f4; stroke: #dde3ea; } +.tp-card-name { font-family: var(--font); font-size: 44px; font-weight: 700; fill: var(--text); letter-spacing: .01em; } +.tp-card-sub { font-family: var(--font); font-size: 28px; fill: var(--text-muted); } +.tp-card-stripe { fill: #6b737d; } +.tp-stripe-up { fill: #2fd36b; } +.tp-stripe-degraded { fill: #ffb300; } +.tp-stripe-down { fill: #e0312b; } +.tp-node-link:hover .tp-card, .tp-node-link:focus-visible .tp-card { stroke: var(--amber); } +.tp-kind-stub .tp-card, .tp-kind-group .tp-card { stroke-dasharray: 14 10; } +.tp-kind-group .tp-node-link { cursor: pointer; } +.tp-pigtail { stroke-width: 5 !important; stroke-dasharray: none !important; } +[data-theme="light"] .tp-nic-name { fill: #4b5562; } + +/* Healthy cables are quiet; problems are loud */ +.tp-links .tp-link:not(.tp-problem):not(.tp-hot):not(.tp-selected) { opacity: .6; } +.tp-halo { stroke: rgba(224,49,43,.28); stroke-width: 46; fill: none; stroke-linecap: round; } +.tp-problem-mismatch .tp-halo, .tp-problem-errors .tp-halo { stroke: rgba(255,179,0,.28); } +.tp-node-problem .tp-card { stroke-width: 5; } +.tp-node-problem.tp-st-degraded .tp-card { stroke: #ffb300; } +.tp-node-problem.tp-st-down .tp-card { stroke: #e0312b; } +.tp-summary { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin: 0 0 10px; padding: 8px 12px; + border: 1px solid var(--border-color); background: var(--bg2); font-size: .8em; } +.tp-summary:empty { display: none; } +.tp-summary-head { font-weight: 700; letter-spacing: .04em; } +.tp-summary-ok { border-color: var(--green-muted); } +.tp-summary-ok .tp-summary-head { color: var(--green); } +.tp-summary-text { color: var(--text-dim); } +.tp-summary-bad { border-color: var(--red); } +.tp-summary-bad .tp-summary-head { color: var(--red); } +.tp-summary-warn { border-color: var(--amber); } +.tp-summary-warn .tp-summary-head { color: var(--amber); } +.tp-problem-chip { font: inherit; font-size: .95em; padding: 3px 9px; border: 1px solid; background: transparent; cursor: pointer; text-align: left; } +.tp-chip-bad { color: var(--red); border-color: var(--red); background: var(--red-dim); } +.tp-chip-warn { color: var(--amber); border-color: var(--amber); background: var(--amber-dim); } +.tp-problem-chip:hover, .tp-problem-chip:focus-visible { filter: brightness(1.25); outline: none; } diff --git a/static/topology.js b/static/topology.js new file mode 100644 index 0000000..9764d8d --- /dev/null +++ b/static/topology.js @@ -0,0 +1,1218 @@ +'use strict'; +/* global FACEPLATES, faceplateSvg, faceplatePortAnchor, RACK_FRONTS, SHELF_GEAR, DESK_FACEPLATES, CHASSIS_FACEPLATES, rackFrontSvg */ +// Topology page: a card per device with its real faceplate or chassis, and each cable leaving the +// exact port it is plugged into. Data comes from /api/topology (topology.build_graph). + +const esc = s => lt.escHtml(s == null ? '' : String(s)); +const PER_MM = 1100 / 482.6; +const NODE_GAP = 90; +const REFRESH_MS = 30000; +const FILTER_KEY = 'gandalf.topology.filters'; +const DEFAULT_FILTERS = { stubs: 'grouped', power: false, traffic: true, mgmt: false, alerts: false }; +const STUB_MODES = ['grouped', 'all', 'none']; + +const state = { + data: null, + layout: null, + view: { k: 1, tx: 0, ty: 0 }, + fitted: false, + filters: loadFilters(), + expanded: new Set(), // switches whose unknown devices are shown one by one + selected: null, +}; + +function loadFilters() { + try { + const saved = Object.assign({}, DEFAULT_FILTERS, JSON.parse(localStorage.getItem(FILTER_KEY) || '{}')); + if (!STUB_MODES.includes(saved.stubs)) saved.stubs = saved.stubs === false ? 'none' : 'grouped'; + delete saved.tenG; + return saved; + } catch (_) { + return Object.assign({}, DEFAULT_FILTERS); + } +} + +function saveFilters() { + try { localStorage.setItem(FILTER_KEY, JSON.stringify(state.filters)); } catch (_) { /* private mode */ } +} + +// ── Formatting ────────────────────────────────────────────────────────── +function speedText(mbps) { + if (!mbps) return ''; + return mbps >= 1000 ? `${+(mbps / 1000).toFixed(1)}G` : `${mbps}M`; +} + +function rateText(bytesPerSec) { + const bits = (bytesPerSec || 0) * 8; + if (bits >= 1e9) return `${(bits / 1e9).toFixed(2)} Gb/s`; + if (bits >= 1e6) return `${(bits / 1e6).toFixed(1)} Mb/s`; + if (bits >= 1e3) return `${(bits / 1e3).toFixed(0)} kb/s`; + return `${bits.toFixed(0)} b/s`; +} + +const MEDIUM_TEXT = { dac: 'DAC (twinax)', fibre: 'Fibre', sfp: 'SFP+ module', copper: 'Copper' }; + +function deviceHref(name) { + return `/device/${encodeURIComponent(name)}`; +} + +function nodeHref(node) { + if (node.kind === 'group') return ''; + if (node.kind === 'stub') return `${deviceHref(node.switch)}#port-${encodeURIComponent(node.port_idx)}`; + if (node.kind === 'internet') return ''; + return deviceHref(node.name); +} + +// ── Problems ──────────────────────────────────────────────────────────── +// Anything that needs a look: drawn in full colour, always shown whatever the filters, and listed +// above the diagram. Healthy cables are drawn quietly. +function linkProblem(link) { + if (link.bundle) return ''; + if (link.ghost) return 'ghost'; + if (!link.up) return 'down'; + if (link.name_mismatch) return 'mismatch'; + if ((link.errors || 0) > 0) return 'errors'; + return ''; +} + +function nodeProblem(node) { + if (['stub', 'group', 'internet'].includes(node.kind) && !node.alert_count) return node.kind === 'internet' && node.status === 'down' ? 'down' : ''; + if (node.status === 'down' || node.status === 'degraded') return node.status; + return node.alert_count ? 'alert' : ''; +} + +function endText(e, byId) { + const n = byId[e.node]; + const name = n ? n.name : e.node; + if (e.port_idx != null) return `${name} port ${e.port_idx}`; + return e.iface ? `${name} ${e.iface}` : name; +} + +function problemsOf(data) { + const byId = Object.fromEntries(data.nodes.map(n => [n.id, n])); + const out = []; + data.nodes.forEach(n => { + const p = nodeProblem(n); + if (!p) return; + const bits = []; + if (n.status === 'down' || n.status === 'degraded') bits.push(n.status); + if (n.alert_count) bits.push(`${n.alert_count} alert${n.alert_count > 1 ? 's' : ''}`); + out.push({ kind: 'node', id: n.id, severity: n.status === 'down' || n.severity === 'critical' ? 'bad' : 'warn', + text: `${n.name}: ${bits.join(', ')}` }); + }); + data.links.forEach(l => { + const p = linkProblem(l); + if (!p) return; + // A missing device is one problem, not two: say where it was last seen on its own entry + const listed = out.find(o => o.kind === 'node' && o.id === l.b.node); + if (p === 'ghost' && listed) { + listed.text += `, last seen on ${endText(l.a, byId)}`; + return; + } + const host = byId[l.b.node] && byId[l.b.node].kind === 'stub' ? `${byId[l.b.node].name} on ${endText(l.a, byId)}` : null; + let text; + if (p === 'ghost') text = `${byId[l.b.node] ? byId[l.b.node].name : l.b.node} not seen, last on ${endText(l.a, byId)}`; + else if (p === 'down') text = `Cable down: ${host || `${endText(l.a, byId)} to ${endText(l.b, byId)}`}`; + else if (p === 'mismatch') { + const m = l.name_mismatch; + const end = m.end === 'b' ? l.b : l.a; + text = `${endText(end, byId)} is named after ${m.named}, but ${m.actual} is plugged in`; + } else text = `Errors on ${endText(l.a, byId)} to ${endText(l.b, byId)} (${l.errors}/s)`; + out.push({ kind: 'link', id: l.id, severity: p === 'mismatch' || p === 'errors' ? 'warn' : 'bad', text }); + }); + return out; +} + +function speedClass(mbps) { + if (mbps >= 10000) return 'tp-s-10g'; + if (mbps >= 1000) return 'tp-s-1g'; + return 'tp-s-100m'; +} + +function mediumClass(link) { + if (link.wan && link.medium === 'fibre') return 'tp-m-wanfibre'; + return `tp-m-${link.medium || 'copper'}`; +} + +// ── Device drawings ───────────────────────────────────────────────────── +// The shared drawings size themselves to their page with CSS (width: 100%), which would override +// the width and height given here, so they are drawn under a class of our own. +function nested(svg) { + return svg.replace(/^(\s* {x, y, face} | null } in local units. + +function portMapOf(node) { + const map = {}; + Object.entries(node.ports || {}).forEach(([idx, p]) => { + map[idx] = Object.assign({ port_idx: Number(idx), lldp: { system_name: p.neighbour } }, p); + }); + return map; +} + +function portTitle(port, idx, role) { + const parts = [port && port.name && port.name !== `Port ${idx}` ? `Port ${idx} "${port.name}"` : `Port ${idx}`]; + if (role) parts.push(role); + if (port) { + parts.push(port.up ? (speedText(port.speed_mbps) || 'up') : 'down'); + if (port.neighbour) parts.push(`to ${port.neighbour}`); + } + return esc(parts.join(' · ')); +} + +function faceplateDrawing(node, model, width, opts = {}) { + const spec = FACEPLATES[model]; + let box = (opts.rack && spec.rackView) || [0, 0, spec.width, spec.height]; + if (opts.crop && spec.ports.length > 2) { + // Just the port area, so the ports are big enough to read in a card + const left = spec.ports.map(q => (q.type === 'sfp' ? q.x : q.x - 20 * (q.scale || 1))); + const right = spec.ports.map(q => (q.type === 'sfp' ? q.x + q.w : q.x + 20 * (q.scale || 1))); + const x0 = Math.max(0, Math.min(...left) - 24); + const x1 = Math.min(spec.width, Math.max(...right) + 24); + box = [x0, 0, x1 - x0, spec.height]; + } + const k = width / box[2]; + const h = box[3] * k; + // A rack view (the UPS's front without its rear) is a viewBox onto part of the drawing + const svg = faceplateSvg(model, { + swName: node.name, portMap: portMapOf(node), outlets: node.outlets || [], online: node.status === 'up', + rack: !!opts.rack, titleFor: portTitle, + }, `x="${opts.offsetX || 0}" y="0" width="${width}" height="${h}"`).svg; + const drawn = nested(svg).replace(/viewBox="[^"]*"/, `viewBox="${box.join(' ')}"`); + return { + w: width, h, svg: drawn, + anchor(end) { + if (end.port_idx == null) return null; + const a = faceplatePortAnchor(spec, end.port_idx); + return a && { x: (opts.offsetX || 0) + (a.x - box[0]) * k, y: (a.y - box[1]) * k, face: a.face }; + }, + }; +} + +function deskFaceplate(node) { + const desk = DESK_FACEPLATES[node.model]; + const spec = FACEPLATES[node.model]; + const k = (desk.mm * PER_MM) / (desk.body[1] - desk.body[0]); + return faceplateDrawing(node, node.model, spec.width * k); +} + +function genericSwitch(node) { + const ports = Object.keys(node.ports || {}).map(Number).sort((a, b) => a - b); + const w = 1100, h = 110; + const pitch = Math.min(60, 900 / Math.max(ports.length, 1)); + const x0 = w - 60 - pitch * ports.length; + const pos = {}; + const jacks = ports.map((idx, i) => { + const p = node.ports[idx]; + const x = x0 + i * pitch; + pos[idx] = x + pitch / 2; + return `${portTitle(p, idx)} + ${idx}`; + }).join(''); + return { + w, h, + svg: ` + ${esc(node.model || 'switch')}${jacks}`, + anchor: end => (end.port_idx != null && pos[end.port_idx] != null ? { x: pos[end.port_idx], y: 55, face: 'top' } : null), + }; +} + +function internetDrawing() { + const w = 560, h = 250; + return { + w, h, + svg: ` + INTERNET`, + anchor: () => ({ x: w / 2, y: h - 22, face: 'bottom' }), + }; +} + +function modemDrawing(node) { + // An upright fibre gateway seen from the front, its status lights down the face + const w = 170, h = 520; + const leds = ['Power', 'Broadband', 'Service', 'Ethernet', 'Wi-Fi'].map((name, i) => { + const cls = node.status === 'up' ? 'tp-led-on' : (i === 0 ? 'tp-led-on' : (node.status === 'down' ? 'tp-led-bad' : 'tp-led-off')); + return `${name}`; + }).join(''); + return { + w, h, + svg: ` + + + + ${leds} + ${esc((node.model || '').split('-')[0])}`, + // Fibre comes in at the top, the LAN cable leaves at the bottom + anchor: end => (end.port_name === 'WAN' ? { x: w / 2, y: h - 8, face: 'bottom' } : { x: w / 2, y: 10, face: 'top' }), + }; +} + +function apDrawing(node) { + const d = 160 * PER_MM; + const r = d / 2; + return { + w: d, h: d, + svg: ` + + `, + anchor: () => ({ x: r, y: 6, face: 'top' }), + }; +} + +function stripDrawing(node) { + const w = 700, h = 140; + const outlets = node.outlets || []; + const n = Math.max(outlets.length, 6); + const sockets = Array.from({ length: n }, (_, i) => { + const o = outlets[i]; + const off = o && o.on === false; + const title = o ? `${o.name || `Outlet ${i + 1}`} · ${off ? 'off' : 'on'}${o.power_w != null ? ` · ${o.power_w.toFixed(1)} W` : ''}` : `Outlet ${i + 1}`; + return `${esc(title)} + + + `; + }).join(''); + return { + w: Math.max(w, 90 + n * 95), h, + svg: `${sockets}`, + anchor: () => null, + }; +} + +function groupDrawing(node) { + const n = node.members.length; + const cols = Math.min(n, 7); + const w = Math.max(360, cols * 64 + 60), h = 150; + const jacks = node.members.slice(0, 14).map((m, i) => { + const x = 30 + (i % cols) * 64, y = 22 + Math.floor(i / cols) * 46; + return ``; + }).join(''); + return { + w, h, + svg: `${jacks} + click to show`, + anchor: () => ({ x: w / 2, y: 4, face: 'top' }), + }; +} + +function boxDrawing(node, text) { + const w = 460, h = 150; + return { + w, h, + svg: ` + ${esc(text)}`, + anchor: () => null, + }; +} + +function stubDrawing() { + const w = 160, h = 100; + return { + w, h, + svg: ` + + ?`, + anchor: () => ({ x: w / 2, y: 10, face: 'top' }), + }; +} + +// A host's drawn front, from its cutsheet item's Front drawing +function hostFront(node) { + const ctx = { name: node.name, status: node.status, interfaces: node.interfaces || [] }; + if (RACK_FRONTS[node.chassis]) { + const spec = RACK_FRONTS[node.chassis]; + return { w: spec.width, h: spec.height, + svg: nested(rackFrontSvg(node.chassis, ctx)).replace('${gear.draw(ctx)}` }; + } + if (CHASSIS_FACEPLATES[node.chassis]) return faceplateDrawing(node, CHASSIS_FACEPLATES[node.chassis], 1100); + return null; +} + +// The picture inside a card, for everything but hosts +function artFor(node) { + const spec = FACEPLATES[node.model]; + switch (node.kind) { + case 'internet': return internetDrawing(); + case 'modem': return modemDrawing(node); + case 'stub': return stubDrawing(); + case 'group': return groupDrawing(node); + case 'ap': return apDrawing(node); + case 'strip': return stripDrawing(node); + default: + if (spec && DESK_FACEPLATES[node.model]) return deskFaceplate(node); + if (spec) return faceplateDrawing(node, node.model, 1100, { rack: node.kind === 'ups', crop: !node.power }); + if (node.kind === 'switch' || node.kind === 'router') return genericSwitch(node); + return boxDrawing(node, node.model || node.kind); + } +} + +// ── Cards ─────────────────────────────────────────────────────────────── +// Every device is a card of the same height: its name and state on top, and below that its real +// faceplate (or, for a server, its front and a socket per NIC) shrunk to fit. Cables meet the card +// edge right above or below the port they are plugged into. +const CARD_W = 640; +const CARD_SMALL_W = 460; +const CARD_H = 300; +const BAND_Y = 132; +const BAND_H = 140; +const NIC_W = 104; + +function fitArt(art, x, y, w, h) { + const k = Math.min(w / art.w, h / art.h); + return { k, ox: x + (w - art.w * k) / 2, oy: y + (h - art.h * k) / 2 }; +} + +function nicSockets(node, linksByIface, x0) { + const nics = (node.interfaces || []).slice(); + Object.keys(linksByIface).forEach(iface => { + if (iface && !nics.some(n => n.name === iface)) nics.push({ name: iface, up: true, unused: false, alert: '' }); + }); + const pos = {}; + const svg = nics.map((nic, i) => { + const link = linksByIface[nic.name]; + const cx = x0 + i * NIC_W + NIC_W / 2; + const cy = BAND_Y + 40; + pos[nic.name] = { x: cx, y: cy - 20 }; + const sfp = link && ['dac', 'fibre', 'sfp'].includes(link.medium); + let st = nic.unused ? 'unused' : (nic.up ? 'up' : 'down'); + if (link && !link.up) st = 'down'; + const title = `${nic.name} · ${nic.unused ? 'marked unused' : (nic.up ? 'up' : 'down')}${link ? ` · ${speedText(link.speed_mbps)} ${MEDIUM_TEXT[link.medium] || ''}` : ' · no cable seen'}`; + const shape = sfp + ? `` + : ``; + const label = nic.name.length > 9 ? `${nic.name.slice(0, 8)}…` : nic.name; + return `${esc(title)} + ${shape} + ${esc(label)}`; + }).join(''); + return { svg, pos, w: nics.length * NIC_W }; +} + +function cardFor(node, linksByIface) { + let w, band, anchorAt; + if (node.kind === 'host') { + const front = hostFront(node); + const sockets0 = nicSockets(node, linksByIface, 0); + const frontW = front ? 220 : 0; + w = Math.max(CARD_W, 56 + frontW + (front ? 24 : 0) + sockets0.w, Math.min(node.name.length * 27 + 130, 980)); + const sx = 28 + (front ? frontW + 24 : (w - 56 - sockets0.w) / 2); + const sockets = nicSockets(node, linksByIface, sx); + let art = ''; + if (front) { + const f = fitArt(front, 28, BAND_Y, frontW, BAND_H); + art = `${front.svg}`; + } + band = `${art}${sockets.svg}`; + anchorAt = end => (end.iface && sockets.pos[end.iface]) || null; + } else { + const small = ['internet', 'modem', 'stub', 'group', 'ap', 'strip'].includes(node.kind); + w = Math.max(small ? CARD_SMALL_W : CARD_W, Math.min(node.name.length * 27 + 130, 980)); + const art = artFor(node); + const f = fitArt(art, 28, BAND_Y, w - 56, BAND_H); + band = `${art.svg}`; + anchorAt = end => { + const a = art.anchor(end); + return a ? { x: f.ox + a.x * f.k, y: f.oy + a.y * f.k } : null; + }; + } + const maxChars = Math.floor((w - 130) / 27); + const name = node.name.length > maxChars ? `${node.name.slice(0, maxChars - 1)}…` : node.name; + let sub; + if (node.kind === 'group') sub = `on ${node.switch}`; + else if (node.kind === 'stub') sub = node.ip ? `${node.switch} port ${node.port_idx}` : (node.mac || `${node.switch} port ${node.port_idx}`); + else if (node.kind === 'host') sub = node.status === 'up' ? 'server' : `server · ${node.status}`; + else if (node.kind === 'internet') sub = node.status === 'up' ? 'connected' : node.status; + else sub = [node.model || node.kind, node.status !== 'up' ? node.status : ''].filter(Boolean).join(' · '); + const svg = ` + + ${esc(name)} + ${esc(sub)} + + + ${band}`; + return { w, h: CARD_H, svg, anchorAt }; +} + +// ── Layout ────────────────────────────────────────────────────────────── +// Rows top down by hops from the internet. Cables are routed like cable management: each drops +// straight down from its port into the tray above the row it is going to, runs along its own lane +// there, then drops straight into its socket. Lanes never share, so cables never lie on top of one +// another and only ever cross at right angles. Power gear hangs in a rail on the right. +const TRAY_PAD = 70; +const LANE = 30; +const TRAY_MIN = 200; +const CORRIDOR_PAD = 60; +const CORNER = 26; + +// Unknown devices on one switch are grouped into a single block with one bundled cable, unless +// the viewer opens that switch's group (or shows them all) +function visibleGraph(data, filters, expanded) { + const mode = filters.stubs; + const all = Object.fromEntries(data.nodes.map(n => [n.id, n])); + let nodes = data.nodes.filter(n => (mode !== 'none' || n.kind !== 'stub') && (filters.power || !n.power)); + let links = data.links.slice(); + if (!filters.mgmt) { + // A server's slower second link (its 1G management cable) is hidden while it is healthy and + // the server has a faster one; a problem cable is always drawn + links = links.filter(l => { + const host = [l.a, l.b].find(e => (all[e.node] || {}).kind === 'host'); + if (!host || linkProblem(l)) return true; + const faster = data.links.some(o => o !== l && [o.a.node, o.b.node].includes(host.node) && (o.speed_mbps || 0) > (l.speed_mbps || 0)); + return !faster; + }); + } + if (mode === 'grouped') { + const bySwitch = {}; + nodes.filter(n => n.kind === 'stub').forEach(n => (bySwitch[n.switch] = bySwitch[n.switch] || []).push(n)); + Object.entries(bySwitch).forEach(([sw, stubs]) => { + if (stubs.length < 2 || expanded.has(sw)) return; + const ids = new Set(stubs.map(n => n.id)); + const cables = links.filter(l => ids.has(l.b.node)); + const id = `group:${sw}`; + nodes = nodes.filter(n => !ids.has(n.id)).concat([{ + id, name: `${stubs.length} unknown devices`, kind: 'group', status: 'up', depth: stubs[0].depth, + switch: sw, members: stubs, alerts: [], alert_count: 0, severity: '', power: false, + }]); + links = links.filter(l => !ids.has(l.b.node)).concat([{ + id: `bundle:${sw}`, bundle: true, count: cables.length, ghost: false, wan: false, name_mismatch: null, + a: { node: sw, port_idx: null, port_name: '', iface: '', ports: cables.map(l => l.a.port_idx) }, + b: { node: id, port_idx: null, port_name: '', iface: '' }, + up: cables.some(l => l.up), medium: 'copper', speed_mbps: Math.max(...cables.map(l => l.speed_mbps || 0)), + a_to_b_bps: cables.reduce((t, l) => t + (l.a_to_b_bps || 0), 0), + b_to_a_bps: cables.reduce((t, l) => t + (l.b_to_a_bps || 0), 0), + }]); + }); + } + const ids = new Set(nodes.map(n => n.id)); + return { nodes, links: links.filter(l => ids.has(l.a.node) && ids.has(l.b.node)) }; +} + +// The internet, modem and gateway share the top row, left to right; then a row per hop, with +// unknown devices beside the gear on their switch; servers along the bottom +function rowKey(node) { + if (node.kind === 'host') return 1e6; + if (node.depth == null) return 1e5; + if (['internet', 'modem', 'router'].includes(node.kind) && node.depth <= 2) return 0; + return node.depth; +} + +// Where a cable meets a card: on its bottom edge (leaving) or top edge (arriving), right above or +// below the port it is plugged into, which is given as `port` for the short tail drawn to it +function anchorOf(lay, end, asParent) { + const p = lay.pos[end.node]; + const d = lay.drawings[end.node]; + if (p.rail) return { x: p.x, y: p.y + d.h / 2, face: 'left', port: null }; + let a = null; + if (end.ports) { + // A bundle leaves from the middle of the ports it gathers + const xs = end.ports.map(idx => d.anchorAt({ port_idx: idx })).filter(Boolean); + if (xs.length) a = { x: xs.reduce((t, q) => t + q.x, 0) / xs.length, y: Math.max(...xs.map(q => q.y)) }; + } else { + a = d.anchorAt(end); + } + const x = p.x + (a ? a.x : d.w / 2); + return { x, y: p.y + (asParent ? d.h : 0), face: asParent ? 'bottom' : 'top', + port: a ? { x, y: p.y + a.y } : null }; +} + +// Give each horizontal run in a tray a lane: fastest cables on the upper lanes, and two runs +// share a lane only when they don't overlap +function assignLanes(runs) { + const lanes = []; + runs.sort((a, b) => (b.speed - a.speed) || (a.x0 - b.x0)); + runs.forEach(run => { + let i = lanes.findIndex(lane => lane.every(o => run.x1 + 40 < o.x0 || run.x0 > o.x1 + 40)); + if (i < 0) { + lanes.push([]); + i = lanes.length - 1; + } + lanes[i].push(run); + run.lane = i; + }); + return lanes.length; +} + +// Straight runs with rounded corners +function roundedPath(points) { + const pts = points.filter((p, i) => i === 0 || Math.hypot(p.x - points[i - 1].x, p.y - points[i - 1].y) > 0.5); + let d = `M${pts[0].x} ${pts[0].y}`; + for (let i = 1; i < pts.length - 1; i++) { + const [p, c, n] = [pts[i - 1], pts[i], pts[i + 1]]; + const r = Math.min(CORNER, Math.hypot(c.x - p.x, c.y - p.y) / 2, Math.hypot(n.x - c.x, n.y - c.y) / 2); + const u = (q, s) => ({ x: c.x + (q.x - c.x) / Math.hypot(q.x - c.x, q.y - c.y) * s, y: c.y + (q.y - c.y) / Math.hypot(q.x - c.x, q.y - c.y) * s }); + const a = u(p, r), b = u(n, r); + d += ` L${a.x} ${a.y} Q${c.x} ${c.y} ${b.x} ${b.y}`; + } + const last = pts[pts.length - 1]; + return `${d} L${last.x} ${last.y}`; +} + +function layout(data, filters, expanded) { + const { nodes, links } = visibleGraph(data, filters, expanded); + const byId = Object.fromEntries(nodes.map(n => [n.id, n])); + const drawings = {}; + const sizes = {}; + nodes.forEach(n => { + const byIface = {}; + data.links.forEach(l => [l.a, l.b].forEach(e => { if (e.node === n.id && e.iface) byIface[e.iface] = l; })); + const d = cardFor(n, byIface); + drawings[n.id] = d; + sizes[n.id] = { w: d.w, h: d.h }; + }); + + const lay = { pos: {}, drawings, nodes, links, byId, routes: {} }; + const rowsMap = new Map(); + nodes.filter(n => !n.power).forEach(n => { + const key = rowKey(n); + if (!rowsMap.has(key)) rowsMap.set(key, []); + rowsMap.get(key).push(n); + }); + const rows = [...rowsMap.keys()].sort((a, b) => a - b).map(k => rowsMap.get(k)); + const rowOf = {}; + rows.forEach((row, r) => row.forEach(n => { rowOf[n.id] = r; })); + const ax = end => anchorOf(lay, end, true).x; + + // ── Across: each row in order of the ports feeding it, clear of cables passing down through it + rows.forEach((row, r) => { + const corridors = links + .filter(l => rowOf[l.a.node] < r && rowOf[l.b.node] > r && lay.pos[l.a.node]) + .map(l => ax(l.a)).sort((a, b) => a - b); + const items = row.map(n => { + // Line a device up under its fastest cable, so that one drops straight in + const feeds = links.filter(l => l.b.node === n.id && lay.pos[l.a.node]) + .sort((a, b) => (b.speed_mbps || 0) - (a.speed_mbps || 0)); + const xs = feeds.length ? [ax(feeds[0].a)] : []; + return { n, w: sizes[n.id].w, bary: xs.length ? xs[0] : null }; + }); + let last = 0; + items.forEach(it => { if (it.bary == null) it.bary = last; else last = it.bary; }); + if (r === 0) items.forEach(it => { it.bary = (it.n.depth || 0) * 1000; }); + items.sort((a, b) => a.bary - b.bary || a.n.name.localeCompare(b.n.name)); + let right = -Infinity; + items.forEach(it => { + it.x = Math.max(it.bary - it.w / 2, right + NODE_GAP); + right = it.x + it.w; + }); + const shift = items.reduce((t, it) => t + (it.bary - (it.x + it.w / 2)), 0) / items.length; + right = -Infinity; + items.forEach(it => { + let x = Math.max(it.x + shift, right + NODE_GAP); + let moved = true; + while (moved) { + moved = false; + for (const c of corridors) { + if (c > x - CORRIDOR_PAD && c < x + it.w + CORRIDOR_PAD) { + x = c + CORRIDOR_PAD; + moved = true; + } + } + } + lay.pos[it.n.id] = { x, y: 0, w: it.w, dx: 0 }; + right = x + it.w; + }); + }); + + // ── Trays: the runs in each one, and their lanes + const placed = Object.values(lay.pos); + const maxX = placed.length ? Math.max(...placed.map(p => p.x + p.w)) : 0; + const railNodes = nodes.filter(n => n.power); + const gutter0 = maxX + 160; + const trays = Array.from({ length: rows.length + 1 }, () => []); + const plan = {}; + let gutterLane = 0; + links.forEach(l => { + if (!lay.pos[l.a.node]) return; + const xa = ax(l.a); + const toRail = byId[l.b.node].power; + if (!toRail && rowOf[l.a.node] === rowOf[l.b.node]) { + plan[l.id] = { across: true }; + return; + } + let r, xb; + if (toRail) { + r = Math.min(rowOf[l.a.node] + 1, rows.length); + xb = gutter0 + (gutterLane++) * LANE; + } else { + if (rowOf[l.b.node] == null) return; + r = rowOf[l.b.node]; + xb = anchorOf(lay, l.b, false).x; + } + const run = { id: l.id, x0: Math.min(xa, xb), x1: Math.max(xa, xb), speed: l.speed_mbps || 0, straight: Math.abs(xa - xb) < 2 }; + plan[l.id] = { r, xa, xb, toRail, run }; + if (!run.straight) trays[r].push(run); + }); + const trayH = trays.map(runs => Math.max(TRAY_MIN, TRAY_PAD * 2 + assignLanes(runs) * LANE)); + + // ── Down: rows and trays stacked + const rowTop = []; + let y = 0; + rows.forEach((row, r) => { + if (r > 0) y += trayH[r]; + rowTop[r] = y; + row.forEach(n => { lay.pos[n.id].y = y; }); + y += Math.max(...row.map(n => sizes[n.id].h)); + }); + const trayTop = r => (r < rows.length ? rowTop[r] - trayH[r] : y); + + // Power rail: each unit level with the lane its cable arrives on + const railX = gutter0 + Math.max(gutterLane, 1) * LANE + 260; + let railY = rows.length ? rowTop[0] : 0; + railNodes.map(n => { + const l = links.find(k => k.b.node === n.id && plan[k.id]); + const f = l ? trayTop(plan[l.id].r) + TRAY_PAD + (plan[l.id].run.lane || 0) * LANE : Infinity; + return { n, f }; + }).sort((a, b) => a.f - b.f || a.n.name.localeCompare(b.n.name)).forEach(({ n, f }) => { + const want = Number.isFinite(f) ? f - drawings[n.id].h / 2 : railY; + const top = Math.max(want, railY); + lay.pos[n.id] = { x: railX, y: top, w: sizes[n.id].w, dx: 0, rail: true }; + railY = top + sizes[n.id].h + 120; + }); + + // ── Routes + links.forEach(l => { + const p = plan[l.id]; + if (!p) return; + if (p.across) { + // Side by side in the top row: straight across from one card to the next + const [pa, pb] = [lay.pos[l.a.node], lay.pos[l.b.node]]; + const left = pa.x < pb.x; + const A = { x: left ? pa.x + pa.w : pa.x, y: pa.y + CARD_H / 2, port: null }; + const B = { x: left ? pb.x : pb.x + pb.w, y: pb.y + CARD_H / 2, port: null }; + lay.routes[l.id] = { d: roundedPath([A, B]), mid: { x: (A.x + B.x) / 2, y: A.y - 40 }, A, B }; + return; + } + const A = anchorOf(lay, l.a, true); + const B = anchorOf(lay, l.b, false); + const laneY = trayTop(p.r) + TRAY_PAD + (p.run.lane || 0) * LANE; + let pts; + if (p.toRail) pts = [A, { x: A.x, y: laneY }, { x: p.xb, y: laneY }, { x: p.xb, y: B.y }, B]; + else if (p.run.straight) pts = [A, B]; + else pts = [A, { x: A.x, y: laneY }, { x: B.x, y: laneY }, B]; + // Traffic and "last seen" tags sit on the longest run + let mid = { x: (A.x + B.x) / 2, y: (A.y + B.y) / 2 }; + let best = -1; + for (let i = 1; i < pts.length; i++) { + const len = Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y); + if (len > best) { + best = len; + mid = { x: (pts[i].x + pts[i - 1].x) / 2, y: (pts[i].y + pts[i - 1].y) / 2 }; + } + } + lay.routes[l.id] = { d: roundedPath(pts), mid, A, B }; + }); + + const all = Object.entries(lay.pos); + lay.bounds = { + x0: Math.min(...all.map(([, q]) => q.x)) - 80, + y0: Math.min(...all.map(([, q]) => q.y)) - 80, + x1: Math.max(...all.map(([, q]) => q.x + q.w)) + 80, + y1: Math.max(...all.map(([id, q]) => q.y + sizes[id].h)) + 80, + }; + return lay; +} + +// ── Rendering ─────────────────────────────────────────────────────────── +function linkTitle(link, byId) { + const end = e => { + const n = byId[e.node]; + const name = n ? n.name : e.node; + if (e.port_idx != null) return `${name} port ${e.port_idx}${e.port_name && e.port_name !== `Port ${e.port_idx}` ? ` "${e.port_name}"` : ''}`; + return e.iface ? `${name} ${e.iface}` : name; + }; + const bits = [`${end(link.a)} to ${end(link.b)}`, speedText(link.speed_mbps), MEDIUM_TEXT[link.medium] || '']; + if (link.ghost) bits.push(`last seen ${link.seen || ''}`); + else if (!link.up) bits.push('down'); + return bits.filter(Boolean).join(' · '); +} + +function renderLink(link, lay) { + const route = lay.routes[link.id]; + if (!route) return { under: '', over: '' }; + const { d, mid, A, B } = route; + const cls = ['tp-link', mediumClass(link), speedClass(link.speed_mbps)]; + if (link.bundle) cls.push('tp-bundle'); + if (link.ghost) cls.push('tp-ghost'); + else if (!link.up) cls.push('tp-down'); + if (link.wan) cls.push('tp-wan'); + const problem = linkProblem(link); + if (problem) cls.push('tp-problem', `tp-problem-${problem}`); + const busy = (link.a_to_b_bps || 0) + (link.b_to_a_bps || 0); + const alerting = [link.a.node, link.b.node].some(id => (lay.byId[id] || {}).alert_count); + if (alerting) cls.push('tp-touches-alert'); + const flowDur = Math.max(0.6, 6 - Math.log10(Math.max(busy, 1)) * 0.8).toFixed(2); + const flow = link.up && !link.ghost && busy > 1000 + ? `` : ''; + const label = link.bundle + ? `${link.count} cables` + : link.ghost + ? `last seen` + : (link.up ? ` + ↓ ${esc(rateText(link.a_to_b_bps))} ↑ ${esc(rateText(link.b_to_a_bps))}` : ''); + let warn = ''; + if (link.name_mismatch) { + const at = link.name_mismatch.end === 'b' ? B : A; + const m = link.name_mismatch; + const text = `Port name says ${m.named}, but ${m.actual} is plugged in`; + warn = `${esc(text)} + !`; + } + // A short tail from the card edge to the port on the picture, ending in the plug + const plug = P => (['dac', 'fibre', 'sfp'].includes(link.medium) + ? `` + : ``); + const tail = E => (E.port + ? `${plug(E.port)}` : ''); + const plugs = `${tail(A)}${tail(B)}`; + const attrs = `class="${cls.join(' ')}" data-link="${esc(link.id)}"`; + return { + // The cable runs behind the devices; its plugs, traffic and warning sit on top + under: ` + ${esc(linkTitle(link, lay.byId))} + ${problem ? `` : ''}${flow} + `, + over: ``, + }; +} + +function renderNode(node, lay) { + const p = lay.pos[node.id]; + const d = lay.drawings[node.id]; + const href = nodeHref(node); + const ring = node.severity + ? ` + ${node.alert_count}` + : ''; + const alertText = (node.alerts || []).map(a => `${a.severity}: ${a.type.replace(/_/g, ' ')}${a.detail ? ` ${a.detail}` : ''}`).join('; '); + const label = `${node.name}${node.kind === 'stub' ? ' (not identified)' : ''} · ${node.status}${alertText ? ` · ${alertText}` : ''}`; + const open = node.kind === 'group' + ? `` + : (href ? `` : ``); + const close = open.startsWith('' : ''; + return ` + ${open}${esc(label)} + ${d.svg} + ${ring} + ${close} + `; +} + +function applyView() { + const { k, tx, ty } = state.view; + document.getElementById('tp-world').setAttribute('transform', `translate(${tx} ${ty}) scale(${k})`); + const canvas = document.getElementById('tp-canvas'); + canvas.classList.toggle('tp-far', k < 0.16); + canvas.classList.toggle('tp-near', k >= 0.6); +} + +function applyFilterClasses() { + const canvas = document.getElementById('tp-canvas'); + canvas.classList.toggle('tp-no-traffic', !state.filters.traffic); + canvas.classList.toggle('tp-alerts-only', state.filters.alerts); + document.querySelectorAll('[data-filter]').forEach(el => { + if (el.tagName === 'SELECT') el.value = state.filters[el.dataset.filter]; + else el.checked = !!state.filters[el.dataset.filter]; + }); +} + +function render() { + const data = state.data; + if (!data) return; + state.layout = layout(data, state.filters, state.expanded); + const lay = state.layout; + const world = document.getElementById('tp-world'); + const cables = lay.links.map(l => renderLink(l, lay)); + world.innerHTML = `${cables.map(c => c.under).join('')} + ${lay.nodes.map(n => renderNode(n, lay)).join('')} + ${cables.map(c => c.over).join('')}`; + document.getElementById('tp-loading').hidden = true; + applyFilterClasses(); + if (!state.fitted) { + sizeCanvas(); + fit(); + state.fitted = true; + } else { + applyView(); + } + if (state.selected) { + const link = data.links.find(l => l.id === state.selected); + if (link) showPanel(link); + else hidePanel(); + } +} + +// ── Cable panel ───────────────────────────────────────────────────────── +function endHtml(end, byId) { + const n = byId[end.node] || { name: end.node, kind: '' }; + const href = n.kind && n.kind !== 'internet' ? nodeHref(n) : ''; + const name = href ? `${esc(n.name)}` : esc(n.name); + let where = ''; + if (end.port_idx != null) { + where = `port ${esc(end.port_idx)}`; + if (end.port_name && end.port_name !== `Port ${end.port_idx}`) where += ` "${esc(end.port_name)}"`; + } else if (end.iface) { + where = esc(end.iface); + } else if (end.port_name) { + where = esc(end.port_name); + } + return `${name}${where ? ` · ${where}` : ''}`; +} + +function showPanel(link) { + const byId = Object.fromEntries(state.data.nodes.map(n => [n.id, n])); + const panel = document.getElementById('tp-panel'); + const rows = []; + const row = (k, v) => rows.push(`
${k}${v}
`); + row('From', endHtml(link.a, byId)); + row('To', endHtml(link.b, byId)); + row('State', link.ghost ? `
not seen now · last seen ${esc(link.seen || 'unknown')}` : (link.up ? 'up' : 'down')); + if (link.speed_mbps) row('Speed', esc(speedText(link.speed_mbps))); + row('Cable', esc(MEDIUM_TEXT[link.medium] || link.medium)); + if (link.sfp_part || link.sfp_vendor) { + row('Module', esc([link.sfp_vendor, link.sfp_part].filter(Boolean).join(' ')) + (link.sfp_serial ? `
S/N ${esc(link.sfp_serial)}` : '')); + } + if (!link.ghost && link.up) { + row('Down (from the internet side)', esc(rateText(link.a_to_b_bps))); + row('Up', esc(rateText(link.b_to_a_bps))); + if (link.utilisation != null) row('Busiest direction', `${(link.utilisation * 100).toFixed(link.utilisation < 0.01 ? 2 : 1)}% of ${esc(speedText(link.speed_mbps))}`); + row('Errors', link.errors ? `${esc(link.errors)}/s` : 'none'); + row('Drops', link.drops ? `${esc(link.drops)}/s` : 'none'); + } + if (link.cable) { + const label = link.cable.label || `cable #${link.cable.id}`; + row('Cutsheet', `${esc(label)}${link.cable.type ? ` · ${esc(link.cable.type)}` : ''}${link.cable.color ? ` · ${esc(link.cable.color)}` : ''}`); + } else if (!link.wan) { + row('Cutsheet', 'not documented'); + } + const warn = link.name_mismatch + ? `

⚠ The port is named after ${esc(link.name_mismatch.named)}, but ${esc(link.name_mismatch.actual)} is plugged into it. Rename the port in UniFi, or check the cable.

` + : ''; + panel.innerHTML = `
+

Cable

+ +
${warn}${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; + +// On a narrow screen the diagram is much wider than tall: size the canvas to it instead of +// leaving most of a tall canvas empty +function sizeCanvas() { + const canvas = document.getElementById('tp-canvas'); + if (!state.layout || canvas.clientWidth > 700) { + canvas.style.height = ''; + return; + } + const { x0, y0, x1, y1 } = state.layout.bounds; + const want = canvas.clientWidth * (y1 - y0) / (x1 - x0); + canvas.style.height = `${Math.round(Math.max(440, Math.min(want + 24, window.innerHeight * 0.75)))}px`; +} + +function fit() { + if (!state.layout) return; + const canvas = document.getElementById('tp-canvas'); + const { x0, y0, x1, y1 } = state.layout.bounds; + const cw = canvas.clientWidth, ch = canvas.clientHeight; + const k = Math.max(K_MIN, Math.min(K_MAX, Math.min(cw / (x1 - x0), ch / (y1 - y0)))); + state.view = { k, tx: (cw - (x1 - x0) * k) / 2 - x0 * k, ty: (ch - (y1 - y0) * k) / 2 - y0 * k }; + applyView(); +} + +function zoomAt(factor, px, py) { + const v = state.view; + const k = Math.max(K_MIN, Math.min(K_MAX, v.k * factor)); + const wx = (px - v.tx) / v.k, wy = (py - v.ty) / v.k; + state.view = { k, tx: px - wx * k, ty: py - wy * k }; + applyView(); +} + +function zoomCentre(factor) { + const canvas = document.getElementById('tp-canvas'); + zoomAt(factor, canvas.clientWidth / 2, canvas.clientHeight / 2); +} + +function initPanZoom() { + const canvas = document.getElementById('tp-canvas'); + const svg = document.getElementById('tp-svg'); + const pointers = new Map(); + let drag = null; + let pinch = null; + let suppressClick = false; + + const local = e => { + const r = canvas.getBoundingClientRect(); + return { x: e.clientX - r.left, y: e.clientY - r.top }; + }; + + svg.addEventListener('pointerdown', e => { + if (e.button !== 0 && e.pointerType === 'mouse') return; + pointers.set(e.pointerId, local(e)); + if (pointers.size === 1) { + drag = { start: local(e), view: Object.assign({}, state.view), id: e.pointerId, moved: false }; + } else if (pointers.size === 2) { + const [p, q] = [...pointers.values()]; + pinch = { dist: Math.hypot(p.x - q.x, p.y - q.y), k: state.view.k }; + drag = null; + } + }); + + svg.addEventListener('pointermove', e => { + if (!pointers.has(e.pointerId)) return; + pointers.set(e.pointerId, local(e)); + if (pinch && pointers.size === 2) { + const [p, q] = [...pointers.values()]; + const dist = Math.hypot(p.x - q.x, p.y - q.y); + zoomAt((pinch.k * dist / pinch.dist) / state.view.k, (p.x + q.x) / 2, (p.y + q.y) / 2); + suppressClick = true; + return; + } + if (!drag || drag.id !== e.pointerId) return; + const pt = local(e); + const dx = pt.x - drag.start.x, dy = pt.y - drag.start.y; + if (!drag.moved && Math.hypot(dx, dy) < 5) return; + if (!drag.moved) { + drag.moved = true; + svg.setPointerCapture(e.pointerId); + canvas.classList.add('tp-dragging'); + } + state.view = { k: drag.view.k, tx: drag.view.tx + dx, ty: drag.view.ty + dy }; + applyView(); + }); + + const end = e => { + pointers.delete(e.pointerId); + if (drag && drag.id === e.pointerId && drag.moved) suppressClick = true; + if (pointers.size < 2) pinch = null; + if (!pointers.size) drag = null; + canvas.classList.remove('tp-dragging'); + }; + svg.addEventListener('pointerup', end); + svg.addEventListener('pointercancel', end); + + // A drag that ends over a device is not a click on it + svg.addEventListener('click', e => { + if (suppressClick) { + e.preventDefault(); + e.stopPropagation(); + suppressClick = false; + } + }, true); + + svg.addEventListener('wheel', e => { + e.preventDefault(); + const pt = local(e); + zoomAt(Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0015)), pt.x, pt.y); + }, { passive: false }); + + canvas.addEventListener('keydown', e => { + if (e.target.closest('input, select, textarea, .tp-panel')) return; + if (e.key === '+' || e.key === '=') { zoomCentre(1.25); e.preventDefault(); } + else if (e.key === '-' || e.key === '_') { zoomCentre(0.8); e.preventDefault(); } + else if (e.key === '0') { fit(); e.preventDefault(); } + else if (e.key === 'Escape') hidePanel(); + }); +} + +// ── Events ────────────────────────────────────────────────────────────── +function initEvents() { + const svg = document.getElementById('tp-svg'); + svg.addEventListener('click', e => { + const port = e.target.closest('[data-action="select-port"]'); + if (port) { + e.preventDefault(); + window.location.href = `${deviceHref(port.dataset.switch)}#port-${encodeURIComponent(port.dataset.portIdx)}`; + return; + } + const group = e.target.closest('[data-expand]'); + const link = e.target.closest('.tp-link'); + if (group || (link && link.dataset.link.startsWith('bundle:'))) { + state.expanded.add(group ? group.dataset.expand : link.dataset.link.slice('bundle:'.length)); + render(); + return; + } + if (link) { + const found = state.data.links.find(l => l.id === link.dataset.link); + if (found) showPanel(found); + return; + } + if (!e.target.closest('.tp-node')) hidePanel(); + }); + svg.addEventListener('keydown', e => { + if (e.key !== 'Enter' && e.key !== ' ') return; + const group = e.target.closest('[data-expand]'); + if (group) { + e.preventDefault(); + state.expanded.add(group.dataset.expand); + render(); + return; + } + const link = e.target.closest('.tp-link'); + if (link) { + e.preventDefault(); + const found = state.data.links.find(l => l.id === link.dataset.link); + if (found) showPanel(found); + } + }); + const trace = e => { + const node = e.target.closest('.tp-node'); + highlight(node ? node.dataset.node : null); + }; + svg.addEventListener('mouseover', trace); + svg.addEventListener('focusin', trace); + svg.addEventListener('mouseleave', () => highlight(null)); + svg.addEventListener('focusout', e => { if (!svg.contains(e.relatedTarget)) highlight(null); }); + + document.getElementById('tp-summary').addEventListener('click', e => { + const chip = e.target.closest('.tp-problem-chip'); + if (!chip) return; + if (chip.dataset.focusNode) focusOn('node', chip.dataset.focusNode); + else focusOn('link', chip.dataset.focusLink); + }); + document.getElementById('tp-panel').addEventListener('click', e => { + if (e.target.closest('[data-action="close-panel"]')) hidePanel(); + }); + + document.querySelectorAll('[data-filter]').forEach(el => { + el.addEventListener('change', () => { + state.filters[el.dataset.filter] = el.tagName === 'SELECT' ? el.value : el.checked; + saveFilters(); + if (el.dataset.filter === 'stubs') state.expanded.clear(); + if (['stubs', 'power', 'mgmt'].includes(el.dataset.filter)) render(); + else applyFilterClasses(); + }); + }); + document.querySelectorAll('[data-zoom]').forEach(btn => { + btn.addEventListener('click', () => { + if (btn.dataset.zoom === 'fit') fit(); + else zoomCentre(btn.dataset.zoom === 'in' ? 1.25 : 0.8); + }); + }); + let resizeTimer = null; + window.addEventListener('resize', () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(applyView, 150); + }); +} + +// ── Problem summary ───────────────────────────────────────────────────── +function renderSummary() { + const box = document.getElementById('tp-summary'); + const problems = problemsOf(state.data); + const devices = state.data.nodes.filter(n => !['stub', 'internet'].includes(n.kind)).length; + const cables = state.data.links.length; + if (!problems.length) { + box.className = 'tp-summary tp-summary-ok'; + box.innerHTML = `✓ All clear${devices} devices and ${cables} cables, nothing down or mislabelled`; + return; + } + box.className = `tp-summary ${problems.some(p => p.severity === 'bad') ? 'tp-summary-bad' : 'tp-summary-warn'}`; + box.innerHTML = `⚠ ${problems.length} to look at + ${problems.map(p => ``).join('')}`; +} + +// Centre the view on a device or cable and show what is wrong with it +function focusOn(kind, id) { + const lay = state.layout; + const canvas = document.getElementById('tp-canvas'); + let x, y; + if (kind === 'node') { + const p = lay.pos[id]; + if (!p) return; + x = p.x + p.w / 2; + y = p.y + lay.drawings[id].h / 2; + } else { + const r = lay.routes[id]; + if (!r) return; + x = r.mid.x; + y = r.mid.y; + } + const k = Math.max(state.view.k, 0.55); + state.view = { k, tx: canvas.clientWidth / 2 - x * k, ty: canvas.clientHeight / 2 - y * k }; + applyView(); + if (kind === 'link') { + const link = state.data.links.find(l => l.id === id); + if (link) showPanel(link); + } else { + hidePanel(); + highlight(id); + } + canvas.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); +} + +// ── Data ──────────────────────────────────────────────────────────────── +async function refresh() { + try { + const data = await lt.api.get('/api/topology'); + state.data = data; + // The layout follows from the devices and cables alone, so a redraw with the same ones keeps + // everything where it was; the view (pan and zoom) is kept either way + render(); + renderSummary(); + const warn = document.getElementById('tp-warnings'); + warn.hidden = !(data.warnings && data.warnings.length); + warn.innerHTML = (data.warnings || []).map(w => `
${esc(w)}
`).join(''); + document.getElementById('tp-updated').textContent = data.updated ? `Port data from ${data.updated}` : ''; + } catch (e) { + document.getElementById('tp-loading').textContent = `Topology unavailable: ${e.message || e}`; + document.getElementById('tp-loading').hidden = false; + } +} + +document.addEventListener('DOMContentLoaded', () => { + initPanZoom(); + initEvents(); + applyFilterClasses(); + refresh(); + setInterval(() => { if (!document.hidden) refresh(); }, REFRESH_MS); +}); diff --git a/templates/base.html b/templates/base.html index ac8a75f..a4617d2 100644 --- a/templates/base.html +++ b/templates/base.html @@ -46,6 +46,9 @@ Rack + Topology {% if user.groups and 'admin' in user.groups %} Rack + + Topology + {% if user.groups and 'admin' in user.groups %} {% endif %} - -
-
-

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..04eef79 --- /dev/null +++ b/templates/topology.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} +{% block title %}Topology{% endblock %} + +{% block content %} +
+
+

Topology

+

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.

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