Topology: a live page drawn from the real gear #39
@@ -19,11 +19,13 @@ A Proxmox/Ceph cluster lives or dies by its network, and network problems are sn
|
||||
|
||||
## Gallery
|
||||
|
||||
**Dashboard**: alert queue with linked tickets, a live topology, per-host interface status and the UniFi device inventory.
|
||||
**Dashboard**: alert queue with linked tickets, per-host interface status and the UniFi device inventory.
|
||||
|
||||

|
||||
|
||||

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

|
||||
|
||||

|
||||
|
||||
@@ -105,9 +107,8 @@ Ping-only (no node_exporter): **pbs** (10.10.10.3)
|
||||
|
||||
### Dashboard (`/`)
|
||||
- Device search (see Device pages below)
|
||||
- Active alerts first, then the WAN / Fiber panel, hosts, UniFi devices, topology, and recently resolved alerts
|
||||
- Active alerts first, then the WAN / Fiber panel, hosts, UniFi devices, and recently resolved alerts
|
||||
- Real-time host status grid with per-NIC link state (UP / DOWN / degraded)
|
||||
- Network topology diagram (Internet → Modem → Gateway → Switches → Hosts)
|
||||
- WAN / Fiber panel: WAN and PON state, Rx/Tx power, optics temperature, throughput, errors, gateway uptime and last restart, 7-day Rx trend, recent WAN outages
|
||||
- UniFi device table (switches, APs, gateway)
|
||||
- Active alerts table with severity, target, consecutive failures, ticket link
|
||||
@@ -180,6 +181,47 @@ The rack front, drawn from the rack units set on cutsheet items, top first.
|
||||
claim the same top unit, the one recorded first is drawn. Items without a rack unit are listed below.
|
||||
- The rack keeps its own dark "hardware" palette in both themes.
|
||||
|
||||
### Topology (`/topology`)
|
||||
The whole network as the switches see it now, top down: the internet, the modem, the gateway, then
|
||||
each switch by how many hops it is from the gateway, the gear and unknown devices below them, and the
|
||||
servers along the bottom. Power gear (PDU, UPS, power strip) sits in a rail on the right.
|
||||
- **What needs a look** is listed above the diagram: devices down, degraded or with active alerts,
|
||||
cables down or last seen elsewhere, ports named after the wrong device, and cables with errors.
|
||||
Click one to jump to it. With nothing to report it says all clear. Problem cables are drawn in full
|
||||
colour with a glow and always shown, whatever the filters; healthy cables are drawn quietly.
|
||||
- **A card per device**, all the same height so the whole network reads at a glance: name, state
|
||||
(coloured edge and dot), and a picture: UniFi gear shows its real faceplate (the same drawings as the
|
||||
port inspector and rack page), cropped to the ports; servers show the **Front drawing** of their
|
||||
cutsheet item and a socket per physical NIC, so every cable lands on the NIC it is plugged into
|
||||
(NICs marked unused are dimmed). The internet, modem and gateway sit side by side along the top.
|
||||
- **Cables run like cable management**: each leaves the card edge right below its port (a short tail
|
||||
shows the port), drops into a tray above the row it goes to, runs along its own lane (10G on the
|
||||
upper lanes), and drops straight into its socket, so cables never lie on top of each other and only cross at right angles. Devices are placed clear of
|
||||
the cables passing down through their row, and each sits under its fastest cable.
|
||||
- **One line per physical cable**, leaving the exact port: a switch-to-switch cable is reported by both
|
||||
switches and drawn once. A server with two links (10G and 1G) has two cables. Colour is the cable
|
||||
type (DAC, multimode fibre, the provider's fibre, an SFP+ module that doesn't say which, copper) and
|
||||
thickness is the speed. A cable that is down is dashed red.
|
||||
- **Last seen**: a device with no live cable is drawn on the port it was last seen on, dotted red.
|
||||
- **Unknown devices**: something plugged into a switch port that Gandalf can't name is a grey stub,
|
||||
labelled with the port name (or its address), linking to that port. By default each switch's unknown
|
||||
devices are one block with one bundled cable ("10 unknown devices"); click it to show them one by
|
||||
one, or pick "one by one" or "hidden" in the toolbar.
|
||||
- **Port names**: a ⚠ marks a port named after one device with a different one plugged in (a port
|
||||
called "Large1 Management" with the AP on it). Generic names ("Port 4", "SFP+ 2", "Uplink") and
|
||||
"Downlink to X" with X on the other end never flag.
|
||||
- **Alerts**: a device with active alerts (not suppressed) gets a ring and a count in its worst
|
||||
severity colour, and the NIC it is about is marked.
|
||||
- Click a device for its page, or a port on a faceplate for that port. Click a cable for its details:
|
||||
both ends, speed, cable type and module, traffic each way and how busy it is, errors and drops, and
|
||||
its cutsheet record. Hovering a device traces its path to the internet.
|
||||
- Drag to pan, scroll or pinch to zoom, **Fit** (or `0`) to see everything, `+` and `-` to zoom. Show
|
||||
unknown devices grouped, one by one or not at all. **Server 1G links** (off by default) adds each
|
||||
server's slower second cable, its management link; **Power gear** (off by default) adds the PDU, UPS
|
||||
and power strip. Traffic labels and dimming everything without an alert can be switched too. The
|
||||
filters are remembered per browser. Refreshes every 30 seconds without moving the view.
|
||||
- API: `GET /api/topology` returns `{nodes, links, warnings, updated}` (see `topology.py`).
|
||||
|
||||
### Health check (`/health`)
|
||||
No login needed. Returns 200 when the database answers and the monitor has completed a check recently,
|
||||
otherwise 503 with the failing check: `{"status": "degraded", "checks": {"db": "ok", "monitor": "stale",
|
||||
@@ -738,6 +780,7 @@ gandalf/
|
||||
├── monitor.py # Background daemon (Prometheus, UniFi, Pulse, alert logic)
|
||||
├── modem.py # Read-only AT&T gateway status page client and parser
|
||||
├── devices.py # Device inventory for search and device pages
|
||||
├── topology.py # The topology graph: devices, cables, depth, last-seen links
|
||||
├── cutsheet.py # Cutsheet validation, path tracing, plan-vs-actual checks, CSV
|
||||
├── ticket_text.py # Markdown for tickets and recovery notes
|
||||
├── db.py # Database operations (MariaDB via pymysql, thread-local conn reuse)
|
||||
@@ -751,6 +794,8 @@ gandalf/
|
||||
│ ├── cutsheet.js # Cutsheet page
|
||||
│ ├── events.js # Alert history page
|
||||
│ ├── rack.js # Rack drawing
|
||||
│ ├── topology.js # Topology page: layout, cables, pan and zoom
|
||||
│ ├── faceplates.js # UniFi faceplates drawn from each model's real layout
|
||||
│ ├── fronts.js # Drawn fronts for gear without a live faceplate (NVR, server cases)
|
||||
│ └── port-inspector.js # Switch front panel, port panel, path check, diagnostics
|
||||
└── templates/
|
||||
@@ -760,6 +805,7 @@ gandalf/
|
||||
├── cutsheet.html # Cutsheet page and its editors
|
||||
├── events.html # Alert history page
|
||||
├── rack.html # Rack drawing
|
||||
├── topology.html # Topology page
|
||||
├── _modem_panel.html # WAN / Fiber panel, shared by the dashboard and modem page
|
||||
└── suppressions.html # Suppression management page
|
||||
```
|
||||
|
||||
@@ -26,6 +26,7 @@ import cutsheet
|
||||
import db
|
||||
import devices
|
||||
import diagnose
|
||||
import topology
|
||||
from monitor import PulseClient, UnifiClient
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -1195,17 +1196,7 @@ def _racks(items: list) -> list:
|
||||
return racks
|
||||
|
||||
|
||||
_VIRTUAL_IFACE = ('lo', 'vmbr', 'bond', 'docker', 'veth', 'tap', 'fwbr', 'fwpr', 'fwln', 'br-', 'virbr', 'wg', 'tun')
|
||||
|
||||
|
||||
def _physical_interfaces(name: str, snapshot: dict) -> list:
|
||||
"""A host's physical NICs in name order, with their state: [{'name', 'up'}].
|
||||
|
||||
Drawn fronts light one RJ45 per NIC in this order (first NIC on the leftmost jack).
|
||||
"""
|
||||
ifaces = ((snapshot.get('hosts') or {}).get(name) or {}).get('interfaces') or {}
|
||||
return [{'name': n, 'up': str(state).lower() == 'up', 'unused': str(state).lower() == 'unused'}
|
||||
for n, state in sorted(ifaces.items()) if not n.startswith(_VIRTUAL_IFACE)]
|
||||
_physical_interfaces = devices.physical_interfaces
|
||||
|
||||
|
||||
def _device_status(name: str, snapshot: dict) -> str:
|
||||
@@ -1314,6 +1305,35 @@ def api_rack():
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Topology
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.route('/topology')
|
||||
@require_auth
|
||||
def topology_page():
|
||||
return render_template('topology.html', user=_get_user())
|
||||
|
||||
|
||||
@app.route('/api/topology')
|
||||
@require_auth
|
||||
def api_topology():
|
||||
"""Every device and physical cable, for the topology page (see topology.build_graph)."""
|
||||
events = db.get_active_events(limit=_PAGE_LIMIT)
|
||||
_annotate_suppressions(events, db.get_active_suppressions())
|
||||
try:
|
||||
_ensure_cutsheet_schema()
|
||||
sheet = db.cutsheet_load()
|
||||
except Exception as e:
|
||||
logger.error(f'Cutsheet unavailable for topology: {e}')
|
||||
sheet = {'items': [], 'cables': []}
|
||||
graph = topology.build_graph(
|
||||
_state_json('network_snapshot'), _state_json('link_stats'), _state_json('modem_stats'),
|
||||
events, sheet['items'], sheet['cables'], _state_json('known_uplinks'),
|
||||
)
|
||||
return jsonify(graph)
|
||||
|
||||
|
||||
@app.route('/api/cutsheet/racks', methods=['POST'])
|
||||
@require_auth
|
||||
@require_admin
|
||||
|
||||
+13
@@ -14,6 +14,19 @@ _KIND_BY_TYPE = {
|
||||
}
|
||||
|
||||
|
||||
_VIRTUAL_IFACE = ('lo', 'vmbr', 'bond', 'docker', 'veth', 'tap', 'fwbr', 'fwpr', 'fwln', 'br-', 'virbr', 'wg', 'tun')
|
||||
|
||||
|
||||
def physical_interfaces(name: str, snapshot: dict) -> List[dict]:
|
||||
"""A host's physical NICs in name order, with their state: [{'name', 'up', 'unused'}].
|
||||
|
||||
Drawn fronts light one RJ45 per NIC in this order (first NIC on the leftmost jack).
|
||||
"""
|
||||
ifaces = (((snapshot or {}).get('hosts') or {}).get(name) or {}).get('interfaces') or {}
|
||||
return [{'name': n, 'up': str(state).lower() == 'up', 'unused': str(state).lower() == 'unused'}
|
||||
for n, state in sorted(ifaces.items()) if not n.startswith(_VIRTUAL_IFACE)]
|
||||
|
||||
|
||||
def unifi_kind(unifi_type: str, model: str) -> str:
|
||||
if model in _PDU_MODELS:
|
||||
return 'pdu'
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 163 KiB |
+8
-3
@@ -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."""
|
||||
|
||||
@@ -48,7 +48,6 @@ async function refreshAll() {
|
||||
const net = netResult.value;
|
||||
updateHostGrid(net.hosts || {});
|
||||
updateUnifiTable(net.unifi || []);
|
||||
updateTopology(net.hosts || {});
|
||||
} else {
|
||||
showToast('Network data unavailable', 'warning');
|
||||
}
|
||||
@@ -167,26 +166,6 @@ function updateHostGrid(hosts) {
|
||||
}
|
||||
}
|
||||
|
||||
function updateTopology(hosts) {
|
||||
document.querySelectorAll('.topo-host').forEach(node => {
|
||||
const name = node.dataset.host;
|
||||
const host = hosts[name];
|
||||
if (!host) return;
|
||||
node.className = node.className.replace(/topo-v2-status-(up|down|degraded|unknown)/g, '');
|
||||
node.classList.add(`topo-v2-status-${host.status}`);
|
||||
const badge = node.querySelector('.topo-badge');
|
||||
if (badge) {
|
||||
badge.className = `topo-badge topo-badge-${host.status}`;
|
||||
badge.textContent = host.status;
|
||||
}
|
||||
|
||||
// Animate the 10G drop-wire red+dashed when host is down
|
||||
document.querySelectorAll(`.topo-v2-wire-10g[data-host="${CSS.escape(name)}"]`).forEach(wire => {
|
||||
wire.classList.toggle('wire-down', host.status === 'down');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Device search ─────────────────────────────────────────────────────
|
||||
function deviceUrl(name) {
|
||||
return `/device/${encodeURIComponent(name)}`;
|
||||
@@ -396,13 +375,9 @@ function updateModem(data) {
|
||||
detailsLink.hidden = false;
|
||||
}
|
||||
|
||||
const rxLabel = document.getElementById('topo-modem-rx');
|
||||
const topo = document.getElementById('topo-modem');
|
||||
const title = document.getElementById('modem-title');
|
||||
|
||||
if (!data.reachable) {
|
||||
if (rxLabel) rxLabel.textContent = 'unreachable';
|
||||
if (topo) topo.classList.add('topo-v2-status-down');
|
||||
body.innerHTML = `
|
||||
<div class="modem-error">Modem status unavailable: ${lt.escHtml(data.error || 'unknown error')}
|
||||
<span class="modem-stat-note">Last attempt ${lt.escHtml(data.updated || '-')}</span></div>
|
||||
@@ -415,10 +390,7 @@ function updateModem(data) {
|
||||
const status = data.optics_status || {};
|
||||
const counters = data.counters || {};
|
||||
const rates = data.rates || {};
|
||||
const rx = optics.rx_power ? optics.rx_power.value : null;
|
||||
|
||||
if (topo) topo.classList.toggle('topo-v2-status-down', problems.length > 0);
|
||||
if (rxLabel) rxLabel.textContent = (rx === null || rx === undefined) ? 'Rx -' : `Rx ${rx} dBm`;
|
||||
if (title) {
|
||||
title.textContent = [data.model || 'AT&T Gateway', data.firmware && `fw ${data.firmware}`]
|
||||
.filter(Boolean).join(' · ');
|
||||
|
||||
+12
-1
@@ -1,5 +1,5 @@
|
||||
'use strict';
|
||||
/* exported renderFaceplate, faceplateScrollLeft, FACEPLATES */
|
||||
/* exported renderFaceplate, faceplateScrollLeft, faceplatePortAnchor, FACEPLATES */
|
||||
|
||||
// Realistic front panels, drawn as SVG from each model's real geometry.
|
||||
// Coordinates are traced from reference images. Keys match UniFi's model code.
|
||||
@@ -639,6 +639,17 @@ function faceplateSvg(model, ctx, attrs = '') {
|
||||
return { svg, marks };
|
||||
}
|
||||
|
||||
// Where a port's cable plugs in, in the faceplate's viewBox units: { x, y, face }, face 'top' for a
|
||||
// port the right way up and 'bottom' for a flipped one (the lower row). null when there is no such
|
||||
// port. A model whose one port takes any index (the PDU's management port) answers for every index.
|
||||
function faceplatePortAnchor(spec, idx) {
|
||||
const p = spec.ports.find(q => q.idx === idx) || spec.ports.find(q => q.anyPort);
|
||||
if (!p) return null;
|
||||
const face = p.flip ? 'bottom' : 'top';
|
||||
if (p.type === 'sfp') return { x: p.x + p.w / 2, y: p.y + p.h / 2, face };
|
||||
return { x: p.x, y: p.y, face };
|
||||
}
|
||||
|
||||
// Where an fp-scroll panel should start: its port area
|
||||
function faceplateScrollLeft(model, frame) {
|
||||
const spec = FACEPLATES[model];
|
||||
|
||||
+227
-269
@@ -60,12 +60,6 @@
|
||||
--glow-cyan: none;
|
||||
--glow-xl: none;
|
||||
}
|
||||
[data-theme="light"] .topology {
|
||||
background-image: radial-gradient(circle, rgba(0,100,160,0.07) 1px, transparent 1px);
|
||||
}
|
||||
[data-theme="light"] .topo-vc-label {
|
||||
background: rgba(235,238,242,.88);
|
||||
}
|
||||
|
||||
/* ── Header overlap fix ───────────────────────────────────────────
|
||||
.lt-container's padding shorthand resets padding-top, defeating
|
||||
@@ -151,23 +145,6 @@
|
||||
/* ── Stats grid spacing variant ──────────────────────────────────── */
|
||||
.lt-stats-grid--mb { margin-bottom: 16px; }
|
||||
|
||||
/* ── Topology section collapse toggle ────────────────────────────── */
|
||||
.topo-collapse-btn {
|
||||
margin-left: auto;
|
||||
font-size: .7em;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font);
|
||||
letter-spacing: .04em;
|
||||
transition: border-color .15s, color .15s;
|
||||
}
|
||||
.topo-collapse-btn:hover { border-color: var(--amber); color: var(--amber); }
|
||||
.topo-collapsible { overflow: hidden; transition: max-height .25s ease; }
|
||||
.topo-collapsible.is-collapsed { display: none; }
|
||||
|
||||
/* ── Animations used by custom components ─────────────────────────── */
|
||||
@keyframes pulse-red {
|
||||
0%,100% { box-shadow: 0 0 0 0 rgba(255,45,85,.5); }
|
||||
@@ -276,7 +253,7 @@
|
||||
border: 1px solid;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.chip-critical { color: var(--red); border-color: var(--red); text-shadow: var(--glow-red); animation: topo-pulse-down 2s ease-in-out infinite; }
|
||||
.chip-critical { color: var(--red); border-color: var(--red); text-shadow: var(--glow-red); animation: pulse-down 2s ease-in-out infinite; }
|
||||
.chip-warning { color: var(--orange); border-color: var(--orange); }
|
||||
.chip-ok { color: var(--green); border-color: var(--green-muted); text-shadow: var(--glow); }
|
||||
.status-meta { display: flex; align-items: center; gap: 10px; white-space: nowrap; }
|
||||
@@ -418,252 +395,12 @@
|
||||
.target-ifaces{ display: flex; flex-wrap: wrap; gap: 3px; }
|
||||
.iface-chip { font-family: var(--font); font-size: .65em; background: var(--bg3); border: 1px solid var(--border-color); padding: 1px 5px; color: var(--text-dim); }
|
||||
|
||||
/* ── Topology diagram ─────────────────────────────────────────────── */
|
||||
.topology {
|
||||
background-color: var(--bg2);
|
||||
background-image: radial-gradient(circle, rgba(0,212,255,0.07) 1px, transparent 1px);
|
||||
background-size: 22px 22px;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 20px 16px 16px;
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* topo-v2 outer wrapper */
|
||||
.topo-v2 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
min-width: 860px;
|
||||
padding: 20px 24px 24px;
|
||||
}
|
||||
|
||||
.topo-tier {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.topo-vc {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
height: 40px;
|
||||
}
|
||||
.topo-vc-wire {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
transform: translateX(-50%);
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background: linear-gradient(to bottom, var(--cyan), var(--green));
|
||||
opacity: .7;
|
||||
}
|
||||
.topo-vc-wire--wan { background: linear-gradient(to bottom, var(--cyan), rgba(0,212,255,.3)); opacity: .7; }
|
||||
.topo-vc-wire--10g { background: var(--amber); opacity: .6; }
|
||||
.topo-vc-wire--mgmt { background: var(--border-color); opacity: .5; }
|
||||
/* Blurred copy of the wire for a soft glow halo */
|
||||
.topo-vc-wire::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: inherit;
|
||||
filter: blur(5px);
|
||||
opacity: .5;
|
||||
}
|
||||
.topo-vc-label {
|
||||
position: absolute;
|
||||
left: calc(50% + 7px);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: .58em;
|
||||
color: var(--amber);
|
||||
white-space: nowrap;
|
||||
letter-spacing: .06em;
|
||||
font-family: var(--font);
|
||||
background: rgba(3,5,8,.7);
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.topo-v2-node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg3);
|
||||
position: relative;
|
||||
font-size: .75em;
|
||||
font-family: var(--font);
|
||||
min-width: 110px;
|
||||
text-align: center;
|
||||
transition: border-color .2s, box-shadow .2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Top highlight strip: color matches node type / status */
|
||||
.topo-v2-node::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
opacity: .4;
|
||||
}
|
||||
.topo-v2-status-up.topo-v2-node::before { background: var(--green); opacity: .65; }
|
||||
.topo-v2-status-down.topo-v2-node::before { background: var(--red); opacity: .75; }
|
||||
.topo-v2-status-degraded.topo-v2-node::before { background: var(--orange); opacity: .65; }
|
||||
.topo-v2-status-unknown.topo-v2-node::before { opacity: .15; }
|
||||
.topo-v2-icon { font-size: 1.3em; line-height: 1; }
|
||||
.topo-v2-label { font-weight: bold; letter-spacing: .04em; }
|
||||
.topo-v2-sub { font-size: .58em; color: var(--text-muted); letter-spacing: .02em; }
|
||||
.topo-v2-vlan { font-size: .54em; color: var(--cyan); opacity: .75; }
|
||||
|
||||
.topo-v2-host--bus { min-width: 80px; max-width: 96px; }
|
||||
.topo-v2-internet { border-color: var(--cyan); color: var(--cyan); text-shadow: var(--glow-cyan); box-shadow: 0 0 12px rgba(0,212,255,.12); }
|
||||
.topo-v2-router { border-color: var(--cyan); color: var(--cyan); text-shadow: var(--glow-cyan); box-shadow: 0 0 12px rgba(0,212,255,.14); }
|
||||
.topo-v2-switch { border-color: var(--amber); color: var(--amber); text-shadow: var(--glow-amber); box-shadow: 0 0 12px rgba(255,179,0,.12); }
|
||||
.topo-v2-host { border-color: var(--border-color); color: var(--text); cursor: default; }
|
||||
|
||||
@keyframes topo-pulse-down {
|
||||
/* Pulsing red glow for critical chips and down states */
|
||||
@keyframes pulse-down {
|
||||
0%,100% { box-shadow: 0 0 6px rgba(255,45,85,.3); }
|
||||
50% { box-shadow: 0 0 18px rgba(255,45,85,.75), 0 0 30px rgba(255,45,85,.2); }
|
||||
}
|
||||
|
||||
.topo-v2-status-up { border-color: var(--green); box-shadow: 0 0 8px rgba(0,255,136,.2); }
|
||||
.topo-v2-status-down { border-color: var(--red); animation: topo-pulse-down 2s ease-in-out infinite; }
|
||||
.topo-v2-status-degraded { border-color: var(--orange); box-shadow: 0 0 8px rgba(255,107,0,.2); }
|
||||
.topo-v2-status-unknown { border-color: var(--border-color); }
|
||||
.topo-v2-offrack { border-style: dashed !important; }
|
||||
|
||||
.topo-badge { font-size: .68em; padding: 1px 5px; border: 1px solid; letter-spacing: .03em; }
|
||||
.topo-badge-up { color: var(--green); border-color: var(--green); text-shadow: var(--glow); }
|
||||
.topo-badge-down { color: var(--red); border-color: var(--red); animation: topo-pulse-down 1.5s ease-in-out infinite; }
|
||||
.topo-badge-degraded { color: var(--orange); border-color: var(--orange); }
|
||||
.topo-badge-unknown { color: var(--text-muted); border-color: var(--border-color); }
|
||||
|
||||
/* Host wrap + dual-homing wires */
|
||||
.topo-v2-host-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.topo-v2-host-wires {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.topo-v2-wire-10g { width: 2px; height: 100%; background: var(--green); opacity: .55; transition: background .3s, opacity .3s; }
|
||||
.topo-v2-wire-1g { width: 0; height: 100%; border-left: 2px dashed var(--amber); opacity: .45; }
|
||||
|
||||
@keyframes wire-dash-anim { to { background-position: 0 -20px; } }
|
||||
.topo-v2-wire-10g.wire-down {
|
||||
background: repeating-linear-gradient(to bottom, var(--red) 0 6px, transparent 6px 10px) !important;
|
||||
background-size: 2px 10px !important;
|
||||
opacity: .9 !important;
|
||||
transition: none !important;
|
||||
animation: wire-dash-anim .7s linear infinite;
|
||||
}
|
||||
|
||||
/* Bus rails */
|
||||
.topo-bus-section {
|
||||
width: 100%;
|
||||
max-width: 860px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
}
|
||||
.topo-bus-10g {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
height: 20px;
|
||||
}
|
||||
.topo-bus-10g-line {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
background: var(--green);
|
||||
opacity: .55;
|
||||
margin: 0 4px;
|
||||
box-shadow: 0 0 6px rgba(0,255,136,.4);
|
||||
}
|
||||
.topo-bus-10g-label {
|
||||
font-size: .56em;
|
||||
color: var(--green);
|
||||
white-space: nowrap;
|
||||
letter-spacing: .05em;
|
||||
font-family: var(--font);
|
||||
opacity: .85;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.topo-bus-1g {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
height: 18px;
|
||||
}
|
||||
.topo-bus-1g-line {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
border-top: 2px dashed var(--amber);
|
||||
opacity: .35;
|
||||
margin: 0 4px;
|
||||
}
|
||||
.topo-bus-1g-label {
|
||||
font-size: .56em;
|
||||
color: var(--amber);
|
||||
white-space: nowrap;
|
||||
letter-spacing: .05em;
|
||||
font-family: var(--font);
|
||||
opacity: .8;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
/* Hosts row */
|
||||
.topo-v2-hosts {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Topology legend */
|
||||
.topo-legend {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
margin-top: 14px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(255,107,0,.12);
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.topo-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: .58em;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font);
|
||||
}
|
||||
.topo-legend-item--offrack {
|
||||
border: 1px dashed var(--border-color);
|
||||
padding: 1px 5px;
|
||||
}
|
||||
.topo-legend-line-10g { width: 24px; height: 2px; background: var(--green); display: inline-block; box-shadow: 0 0 4px rgba(0,255,136,.5); }
|
||||
.topo-legend-line-1g { width: 24px; height: 0; border-top: 2px dashed var(--amber); display: inline-block; }
|
||||
.topo-legend-line-wan { width: 24px; height: 2px; background: linear-gradient(to right, var(--cyan), var(--green)); display: inline-block; }
|
||||
|
||||
/* ── Link debug page ──────────────────────────────────────────────── */
|
||||
.link-host-list { display: flex; flex-direction: column; gap: 18px; }
|
||||
.link-host-panel {
|
||||
@@ -1067,7 +804,7 @@
|
||||
.lt-stat-card--alert {
|
||||
border-color: var(--red) !important;
|
||||
box-shadow: 0 0 8px rgba(255,45,85,.25) !important;
|
||||
animation: topo-pulse-down 2s ease-in-out infinite;
|
||||
animation: pulse-down 2s ease-in-out infinite;
|
||||
}
|
||||
.lt-stat-card--alert::before { background: var(--red); box-shadow: var(--glow-red); }
|
||||
|
||||
@@ -1077,7 +814,6 @@
|
||||
/* ── Responsive ───────────────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.host-grid { grid-template-columns: 1fr; }
|
||||
.topology { display: none; }
|
||||
.form-row { flex-direction: column; }
|
||||
.status-bar { flex-direction: column; align-items: flex-start; }
|
||||
.link-ifaces-grid { grid-template-columns: 1fr; }
|
||||
@@ -1150,7 +886,6 @@ a.device-link:hover { color: var(--text-accent); border-bottom-color: current
|
||||
.device-row-sub { font-size: .72em; color: var(--text-muted); }
|
||||
.device-row-toggle { background: none; border: 0; color: inherit; cursor: pointer; font: inherit; padding: 2px 6px; }
|
||||
.device-row-detail > td { background: var(--bg-tertiary); padding: 10px 14px; }
|
||||
.topo-host[data-host] { cursor: pointer; }
|
||||
|
||||
/* WAN outage list in the modem panel */
|
||||
.modem-outages { padding: 0 12px 8px; }
|
||||
@@ -1678,3 +1413,226 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; }
|
||||
.device-row--unused td { opacity: .6; }
|
||||
.device-row--unused td:last-child { opacity: 1; }
|
||||
.lt-btn-xs { padding: 1px 6px; font-size: .68rem; margin-left: 6px; }
|
||||
|
||||
/* ── Topology page ─────────────────────────────────────────────────── */
|
||||
.tp-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; margin: 8px 0 10px; }
|
||||
.tp-filters { display: flex; flex-wrap: wrap; gap: 6px 14px; }
|
||||
.tp-filter { display: inline-flex; align-items: center; gap: 6px; font-size: .78em; color: var(--text-dim); cursor: pointer; white-space: nowrap; }
|
||||
.tp-zoom { display: inline-flex; gap: 4px; }
|
||||
.tp-updated { margin-left: auto; font-size: .72em; color: var(--text-muted); }
|
||||
.tp-warnings { border: 1px solid var(--amber); color: var(--amber); background: var(--amber-dim); padding: 8px 12px; margin-bottom: 10px; font-size: .8em; }
|
||||
.tp-canvas {
|
||||
position: relative; overflow: hidden; touch-action: none; cursor: grab;
|
||||
height: clamp(420px, calc(100vh - 300px), 1400px);
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--bg2);
|
||||
background-image: radial-gradient(circle, rgba(0,212,255,.07) 1px, transparent 1px);
|
||||
background-size: 22px 22px;
|
||||
}
|
||||
[data-theme="light"] .tp-canvas { background-image: radial-gradient(circle, rgba(0,100,160,.08) 1px, transparent 1px); }
|
||||
.tp-canvas:focus-visible { outline: 2px solid var(--amber); outline-offset: 2px; }
|
||||
.tp-canvas.tp-dragging { cursor: grabbing; }
|
||||
.tp-svg { display: block; width: 100%; height: 100%; user-select: none; }
|
||||
.tp-loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
|
||||
.tp-loading[hidden] { display: none; }
|
||||
|
||||
/* Devices */
|
||||
.tp-node-link { cursor: pointer; outline: none; }
|
||||
.tp-node-hit { fill: transparent; stroke: transparent; stroke-width: 4; }
|
||||
.tp-node-link:hover .tp-node-hit, .tp-node-link:focus-visible .tp-node-hit { stroke: var(--amber); fill: rgba(255,179,0,.04); }
|
||||
.tp-name { font-family: var(--font); font-size: 40px; font-weight: 700; fill: var(--text); letter-spacing: .02em;
|
||||
paint-order: stroke; stroke: var(--bg2); stroke-width: 10px; stroke-linejoin: round; }
|
||||
.tp-sub { font-family: var(--font); font-size: 28px; fill: var(--text-muted);
|
||||
paint-order: stroke; stroke: var(--bg2); stroke-width: 8px; stroke-linejoin: round; }
|
||||
.tp-dot { stroke: rgba(0,0,0,.35); stroke-width: 2; }
|
||||
.tp-dot-up { fill: #2fd36b; }
|
||||
.tp-dot-degraded { fill: #ffb300; }
|
||||
.tp-dot-down { fill: #e0312b; }
|
||||
.tp-dot-unknown { fill: #6b737d; }
|
||||
.tp-ring { fill: none; stroke-width: 7; }
|
||||
.tp-ring-critical { stroke: #e0312b; }
|
||||
.tp-ring-warning { stroke: #ffb300; }
|
||||
.tp-ring-info { stroke: #34a8ff; }
|
||||
.tp-badge circle { stroke: var(--bg2); stroke-width: 5; }
|
||||
.tp-badge text { font-family: var(--font); font-size: 34px; font-weight: 700; fill: #fff; }
|
||||
.tp-badge-critical circle { fill: #e0312b; }
|
||||
.tp-badge-warning circle { fill: #c98a00; }
|
||||
.tp-badge-info circle { fill: #1f7ad6; }
|
||||
.tp-kind-stub .tp-name { font-size: 32px; font-weight: 400; fill: var(--text-dim); }
|
||||
.tp-kind-stub .tp-sub { font-size: 24px; }
|
||||
|
||||
.tp-cloud { fill: #dfe7ef; stroke: #8fa3b5; stroke-width: 4; }
|
||||
.tp-cloud-text { font-family: var(--font); font-size: 44px; font-weight: 700; fill: #34495e; letter-spacing: .12em; }
|
||||
.tp-st-down .tp-cloud { fill: #f3d6d4; stroke: #e0312b; }
|
||||
.tp-modem-body { fill: #16181b; stroke: #050506; stroke-width: 3; }
|
||||
.tp-modem-edge { fill: none; stroke: #2c3035; stroke-width: 3; }
|
||||
.tp-modem-globe { fill: #1f6fd1; }
|
||||
.tp-modem-globe-line { fill: none; stroke: #9cc6ff; stroke-width: 3; }
|
||||
.tp-modem-model { font-family: var(--font); font-size: 22px; fill: #8b929b; }
|
||||
.tp-led { stroke: #000; stroke-width: 2; }
|
||||
.tp-led-on { fill: #2fd36b; }
|
||||
.tp-led-off { fill: #3a3f45; }
|
||||
.tp-led-bad { fill: #e0312b; }
|
||||
.tp-ap-body { fill: #f2f3f4; stroke: #c3c6ca; stroke-width: 4; }
|
||||
.tp-ap-ring { fill: none; stroke-width: 10; stroke: #6b737d; }
|
||||
.tp-ap-up { stroke: #2a8cff; }
|
||||
.tp-ap-down { stroke: #e0312b; }
|
||||
.tp-ap-dot { fill: #d7dadd; }
|
||||
.tp-strip-body { fill: #f0f1f2; stroke: #c3c6ca; stroke-width: 4; }
|
||||
.tp-strip-outlet rect:first-of-type { fill: #fafafa; stroke: #b9bcc0; stroke-width: 3; }
|
||||
.tp-strip-slot { fill: #2b2d31; }
|
||||
.tp-strip-outlet.tp-off rect:first-of-type { fill: #d9dadc; }
|
||||
.tp-box, .tp-host-box { fill: #22272e; stroke: #3a434e; stroke-width: 3; }
|
||||
.tp-box-text { font-family: var(--font); font-size: 34px; fill: #8b97a5; }
|
||||
.tp-host-led { stroke: #000; stroke-width: 2; }
|
||||
.tp-gen-body { fill: #2a2f36; stroke: #12151a; stroke-width: 3; }
|
||||
.tp-gen-model { font-family: var(--font); font-size: 30px; fill: #c9d1d9; }
|
||||
.tp-gen-port { fill: #111418; stroke: #4b5562; stroke-width: 2; }
|
||||
.tp-gen-up { stroke: #2fd36b; }
|
||||
.tp-gen-num { font-family: var(--font); font-size: 16px; fill: #8b97a5; }
|
||||
.tp-stub-body { fill: #3a3f46; stroke: #23272c; stroke-width: 3; }
|
||||
.tp-stub-jack { fill: #15181c; }
|
||||
.tp-stub-q { font-family: var(--font); font-size: 30px; font-weight: 700; fill: #9aa3ad; }
|
||||
.tp-st-down .tp-stub-body { stroke: #e0312b; }
|
||||
.tp-stub-jack.tp-off { opacity: .4; }
|
||||
.tp-group-body { stroke-dasharray: 10 8; stroke: #5d6670; }
|
||||
.tp-group-hint { font-family: var(--font); font-size: 20px; fill: #9aa3ad; }
|
||||
.tp-select { font-size: .95em; padding: 1px 4px; margin-left: 4px; }
|
||||
.tp-tag-bundle rect { stroke: #3b82f6; }
|
||||
|
||||
/* Host NIC sockets */
|
||||
.tp-strip { fill: #1b1f24; stroke: #2f363f; stroke-width: 3; }
|
||||
.tp-nic-rj45, .tp-nic-sfp { fill: #0b0d10; stroke: #59626e; stroke-width: 3; }
|
||||
.tp-nic-sfp-in { fill: #23272d; }
|
||||
.tp-nic-name { font-family: var(--font); font-size: 17px; fill: #aab4bf; }
|
||||
.tp-nic-led { fill: #3a3f45; }
|
||||
.tp-nic-up .tp-nic-led { fill: #2fd36b; }
|
||||
.tp-nic-up .tp-nic-rj45, .tp-nic-up .tp-nic-sfp { stroke: #8f9aa6; }
|
||||
.tp-nic-down .tp-nic-led { fill: #e0312b; }
|
||||
.tp-nic-down .tp-nic-rj45, .tp-nic-down .tp-nic-sfp { stroke: #e0312b; }
|
||||
.tp-nic-unused { opacity: .45; }
|
||||
.tp-nic-unused .tp-nic-rj45, .tp-nic-unused .tp-nic-sfp { stroke-dasharray: 6 5; }
|
||||
.tp-nic-alert-critical .tp-nic-name { fill: #ff6b5e; }
|
||||
.tp-nic-alert-warning .tp-nic-name { fill: #ffb300; }
|
||||
|
||||
/* Cables: colours are the real jackets, the same in both themes */
|
||||
.tp-link { cursor: pointer; outline: none; }
|
||||
.tp-link path { fill: none; stroke-linecap: round; }
|
||||
.tp-casing { stroke: #050607; }
|
||||
.tp-core { stroke: #2f343b; }
|
||||
.tp-hit { stroke: transparent; stroke-width: 40; }
|
||||
.tp-s-10g .tp-casing { stroke-width: 17; } .tp-s-10g .tp-core { stroke-width: 11; }
|
||||
.tp-s-1g .tp-casing { stroke-width: 12; } .tp-s-1g .tp-core { stroke-width: 7; }
|
||||
.tp-s-100m .tp-casing { stroke-width: 8; } .tp-s-100m .tp-core { stroke-width: 4.5; }
|
||||
.tp-m-dac .tp-casing { stroke: #8a929c; } .tp-m-dac .tp-core { stroke: #16181c; }
|
||||
.tp-m-fibre .tp-casing { stroke: #13757c; } .tp-m-fibre .tp-core { stroke: #38c9d3; }
|
||||
.tp-m-wanfibre .tp-casing { stroke: #8a6a00; } .tp-m-wanfibre .tp-core { stroke: #f2c230; }
|
||||
.tp-m-sfp .tp-casing { stroke: #23272c; } .tp-m-sfp .tp-core { stroke: #69717c; }
|
||||
.tp-m-copper .tp-casing { stroke: #173f80; } .tp-m-copper .tp-core { stroke: #3b82f6; }
|
||||
/* A group's cables, bundled: one thick run */
|
||||
.tp-link.tp-bundle .tp-casing { stroke-width: 28; stroke: #0f2b57; } .tp-link.tp-bundle .tp-core { stroke-width: 20; }
|
||||
.tp-down .tp-casing { stroke: transparent; }
|
||||
.tp-down .tp-core { stroke: #e0312b; stroke-dasharray: 28 18; }
|
||||
.tp-ghost { opacity: .8; }
|
||||
.tp-ghost .tp-casing { stroke: transparent; }
|
||||
.tp-ghost .tp-core { stroke: #e0312b; stroke-dasharray: 2 18; stroke-width: 10; }
|
||||
.tp-plug { fill: #c9ccd1; stroke: #5d636b; stroke-width: 2; }
|
||||
.tp-boot { fill: #1e5bbf; stroke: #0d2f66; stroke-width: 2; }
|
||||
.tp-links-over { pointer-events: none; }
|
||||
.tp-links-over .tp-warn { pointer-events: auto; }
|
||||
.tp-flow { stroke: rgba(255,255,255,.4); stroke-width: 3; stroke-dasharray: 3 60; animation: tp-flow linear infinite; pointer-events: none; }
|
||||
.tp-s-100m .tp-flow { stroke-width: 2; }
|
||||
@keyframes tp-flow { to { stroke-dashoffset: -63; } }
|
||||
@media (prefers-reduced-motion: reduce) { .tp-flow { animation: none; display: none; } }
|
||||
.tp-link:hover .tp-casing, .tp-link:focus-visible .tp-casing, .tp-selected .tp-casing { stroke: var(--amber); }
|
||||
.tp-tag rect { fill: var(--bg-primary); stroke: var(--border-color); stroke-width: 2; opacity: .92; }
|
||||
.tp-tag text { font-family: var(--font); font-size: 24px; fill: var(--text); }
|
||||
.tp-tag-ghost rect { stroke: #e0312b; }
|
||||
.tp-tag-ghost text { fill: #ff6b5e; }
|
||||
.tp-rate { display: none; pointer-events: none; }
|
||||
.tp-near .tp-rate, .tp-hot .tp-rate, .tp-selected .tp-rate { display: inline; }
|
||||
.tp-warn path { fill: #ffb300; stroke: #5c4100; stroke-width: 3; }
|
||||
.tp-warn text { font-family: var(--font); font-size: 30px; font-weight: 700; fill: #2b1f00; }
|
||||
|
||||
/* Filters and the traced path */
|
||||
.tp-no-traffic .tp-rate, .tp-no-traffic .tp-flow { display: none !important; }
|
||||
.tp-alerts-only .tp-node:not(.tp-has-alert) { opacity: .25; }
|
||||
.tp-alerts-only .tp-link:not(.tp-touches-alert) { opacity: .2; }
|
||||
.tp-tracing .tp-node:not(.tp-hot), .tp-tracing .tp-link:not(.tp-hot) { opacity: .18; transition: opacity .15s; }
|
||||
.tp-tracing .tp-link.tp-hot .tp-casing { stroke: var(--amber); }
|
||||
.tp-far .tp-sub, .tp-far .tp-nic-name { display: none; }
|
||||
|
||||
/* Cable panel: a side panel, a bottom sheet on phones */
|
||||
.tp-panel {
|
||||
position: absolute; top: 12px; right: 12px; width: min(360px, calc(100% - 24px));
|
||||
max-height: calc(100% - 24px); overflow: auto; z-index: 2;
|
||||
background: var(--bg-primary); border: 1px solid var(--border-color); box-shadow: 0 8px 24px rgba(0,0,0,.35);
|
||||
padding: 10px 14px 14px; cursor: auto; font-size: .8em;
|
||||
}
|
||||
.tp-panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
|
||||
.tp-panel-title { font-size: 1em; margin: 0; letter-spacing: .06em; text-transform: uppercase; color: var(--amber); }
|
||||
.tp-panel-table { width: 100%; border-collapse: collapse; }
|
||||
.tp-panel-table th { text-align: left; font-weight: 400; color: var(--text-muted); padding: 4px 10px 4px 0; vertical-align: top; width: 42%; }
|
||||
.tp-panel-table td { padding: 4px 0; overflow-wrap: anywhere; }
|
||||
.tp-panel-warn { border: 1px solid var(--amber); background: var(--amber-dim); color: var(--text); padding: 6px 8px; margin: 0 0 8px; }
|
||||
.tp-bad { color: var(--red); }
|
||||
.tp-warn-text { color: var(--amber); }
|
||||
.tp-dim-text { color: var(--text-muted); }
|
||||
@media (max-width: 640px) {
|
||||
.tp-panel { top: auto; left: 0; right: 0; bottom: 0; width: auto; max-height: 55%; border-width: 1px 0 0; }
|
||||
.tp-updated { margin-left: 0; width: 100%; }
|
||||
.tp-canvas { height: clamp(380px, calc(100vh - 260px), 900px); }
|
||||
}
|
||||
|
||||
.tp-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; margin: 10px 0 4px; font-size: .74em; color: var(--text-dim); }
|
||||
.tp-key { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.tp-key svg { width: 34px; height: 12px; }
|
||||
.tp-key-line { fill: none; stroke-width: 5; stroke-linecap: round; }
|
||||
.tp-key-line.tp-m-dac { stroke: #2b2f35; }
|
||||
.tp-key-line.tp-m-fibre { stroke: #38c9d3; }
|
||||
.tp-key-line.tp-m-wanfibre { stroke: #f2c230; }
|
||||
.tp-key-line.tp-m-sfp { stroke: #69717c; }
|
||||
.tp-key-line.tp-m-copper { stroke: #3b82f6; }
|
||||
.tp-key-down { stroke: #e0312b; stroke-dasharray: 6 4; }
|
||||
.tp-key-ghost { stroke: #e0312b; stroke-dasharray: 1 5; }
|
||||
.tp-key-warn { color: #ffb300; font-weight: 700; }
|
||||
|
||||
/* Topology cards */
|
||||
.tp-card { fill: #121821; stroke: #2a3440; stroke-width: 3; }
|
||||
[data-theme="light"] .tp-card { fill: #ffffff; stroke: #c9d1da; }
|
||||
.tp-band { fill: #0a0e13; stroke: #1c242e; stroke-width: 2; }
|
||||
[data-theme="light"] .tp-band { fill: #eef1f4; stroke: #dde3ea; }
|
||||
.tp-card-name { font-family: var(--font); font-size: 44px; font-weight: 700; fill: var(--text); letter-spacing: .01em; }
|
||||
.tp-card-sub { font-family: var(--font); font-size: 28px; fill: var(--text-muted); }
|
||||
.tp-card-stripe { fill: #6b737d; }
|
||||
.tp-stripe-up { fill: #2fd36b; }
|
||||
.tp-stripe-degraded { fill: #ffb300; }
|
||||
.tp-stripe-down { fill: #e0312b; }
|
||||
.tp-node-link:hover .tp-card, .tp-node-link:focus-visible .tp-card { stroke: var(--amber); }
|
||||
.tp-kind-stub .tp-card, .tp-kind-group .tp-card { stroke-dasharray: 14 10; }
|
||||
.tp-kind-group .tp-node-link { cursor: pointer; }
|
||||
.tp-pigtail { stroke-width: 5 !important; stroke-dasharray: none !important; }
|
||||
[data-theme="light"] .tp-nic-name { fill: #4b5562; }
|
||||
|
||||
/* Healthy cables are quiet; problems are loud */
|
||||
.tp-links .tp-link:not(.tp-problem):not(.tp-hot):not(.tp-selected) { opacity: .6; }
|
||||
.tp-halo { stroke: rgba(224,49,43,.28); stroke-width: 46; fill: none; stroke-linecap: round; }
|
||||
.tp-problem-mismatch .tp-halo, .tp-problem-errors .tp-halo { stroke: rgba(255,179,0,.28); }
|
||||
.tp-node-problem .tp-card { stroke-width: 5; }
|
||||
.tp-node-problem.tp-st-degraded .tp-card { stroke: #ffb300; }
|
||||
.tp-node-problem.tp-st-down .tp-card { stroke: #e0312b; }
|
||||
.tp-summary { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin: 0 0 10px; padding: 8px 12px;
|
||||
border: 1px solid var(--border-color); background: var(--bg2); font-size: .8em; }
|
||||
.tp-summary:empty { display: none; }
|
||||
.tp-summary-head { font-weight: 700; letter-spacing: .04em; }
|
||||
.tp-summary-ok { border-color: var(--green-muted); }
|
||||
.tp-summary-ok .tp-summary-head { color: var(--green); }
|
||||
.tp-summary-text { color: var(--text-dim); }
|
||||
.tp-summary-bad { border-color: var(--red); }
|
||||
.tp-summary-bad .tp-summary-head { color: var(--red); }
|
||||
.tp-summary-warn { border-color: var(--amber); }
|
||||
.tp-summary-warn .tp-summary-head { color: var(--amber); }
|
||||
.tp-problem-chip { font: inherit; font-size: .95em; padding: 3px 9px; border: 1px solid; background: transparent; cursor: pointer; text-align: left; }
|
||||
.tp-chip-bad { color: var(--red); border-color: var(--red); background: var(--red-dim); }
|
||||
.tp-chip-warn { color: var(--amber); border-color: var(--amber); background: var(--amber-dim); }
|
||||
.tp-problem-chip:hover, .tp-problem-chip:focus-visible { filter: brightness(1.25); outline: none; }
|
||||
|
||||
+1218
File diff suppressed because it is too large
Load Diff
@@ -46,6 +46,9 @@
|
||||
<a href="{{ url_for('rack_page') }}"
|
||||
class="lt-nav-drawer-link{% if request.endpoint == 'rack_page' %} active{% endif %}"
|
||||
{% if request.endpoint == 'rack_page' %}aria-current="page"{% endif %}>Rack</a>
|
||||
<a href="{{ url_for('topology_page') }}"
|
||||
class="lt-nav-drawer-link{% if request.endpoint == 'topology_page' %} active{% endif %}"
|
||||
{% if request.endpoint == 'topology_page' %}aria-current="page"{% endif %}>Topology</a>
|
||||
{% if user.groups and 'admin' in user.groups %}
|
||||
<a href="{{ url_for('suppressions_page') }}"
|
||||
class="lt-nav-drawer-link{% if request.endpoint == 'suppressions_page' %} active{% endif %}"
|
||||
@@ -103,6 +106,11 @@
|
||||
{% if request.endpoint == 'rack_page' %}aria-current="page"{% endif %}>
|
||||
Rack
|
||||
</a>
|
||||
<a href="{{ url_for('topology_page') }}"
|
||||
class="lt-nav-link{% if request.endpoint == 'topology_page' %} active{% endif %}"
|
||||
{% if request.endpoint == 'topology_page' %}aria-current="page"{% endif %}>
|
||||
Topology
|
||||
</a>
|
||||
{% if user.groups and 'admin' in user.groups %}
|
||||
<a href="{{ url_for('suppressions_page') }}"
|
||||
class="lt-nav-link{% if request.endpoint == 'suppressions_page' %} active{% endif %}"
|
||||
@@ -399,6 +407,7 @@
|
||||
{ id: 'nav-history', group: 'Navigate', icon: '⏱', label: 'Alert History', action: function() { window.location.href = '/events'; } },
|
||||
{ id: 'nav-cutsheet', group: 'Navigate', icon: '🔌', label: 'Cutsheet', action: function() { window.location.href = '/cutsheet'; } },
|
||||
{ id: 'nav-rack', group: 'Navigate', icon: '🗄', label: 'Rack', action: function() { window.location.href = '/rack'; } },
|
||||
{ id: 'nav-topology', group: 'Navigate', icon: '⛓', label: 'Topology', action: function() { window.location.href = '/topology'; } },
|
||||
{ id: 'nav-suppressions', group: 'Navigate', icon: '🔕', label: 'Suppressions', action: function() { window.location.href = '/suppressions'; } },
|
||||
{ id: 'action-refresh', group: 'Actions', icon: '↻', label: 'Refresh Data', kbd: 'R', action: function() { lt.autoRefresh.now(); } },
|
||||
{ id: 'action-suppress', group: 'Actions', icon: '🔕', label: 'New Suppression', kbd: 'S', action: function() { if (typeof openSuppressModal === 'function') openSuppressModal('host','',''); else window.location.href='/suppressions'; } },
|
||||
|
||||
@@ -335,176 +335,6 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- ── Network topology ───────────────────────────────── -->
|
||||
<section class="g-section">
|
||||
<div class="g-section-header">
|
||||
<h2 class="g-section-title">Network Topology</h2>
|
||||
<button type="button" class="topo-collapse-btn" id="topo-toggle-btn"
|
||||
aria-expanded="true" aria-controls="topo-collapsible-wrap">▴ Collapse</button>
|
||||
</div>
|
||||
<div class="topo-collapsible" id="topo-collapsible-wrap">
|
||||
|
||||
<div class="topology" id="topology-diagram">
|
||||
<div class="topo-v2">
|
||||
|
||||
{%- set topo_h = snapshot.hosts if snapshot.hosts else {} -%}
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
TIER 1: Internet (WAN edge)
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<div class="topo-tier">
|
||||
<div class="topo-v2-node topo-v2-internet">
|
||||
<span class="topo-v2-icon">◈</span>
|
||||
<span class="topo-v2-label">INTERNET</span>
|
||||
<span class="topo-v2-sub">WAN uplink</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fiber wire: Internet → AT&T gateway -->
|
||||
<div class="topo-vc">
|
||||
<div class="topo-vc-wire topo-vc-wire--wan"></div>
|
||||
<span class="topo-vc-label">AT&T Fiber</span>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
TIER 1b: AT&T gateway (Rx filled in by updateModem)
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<div class="topo-tier">
|
||||
<div class="topo-v2-node topo-v2-router" id="topo-modem">
|
||||
<span class="topo-v2-icon">◈</span>
|
||||
<span class="topo-v2-label">BGW320</span>
|
||||
<span class="topo-v2-sub">AT&T Gateway</span>
|
||||
<span class="topo-v2-sub" id="topo-modem-rx">Rx -</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WAN wire: gateway → UDM-Pro -->
|
||||
<div class="topo-vc">
|
||||
<div class="topo-vc-wire topo-vc-wire--wan"></div>
|
||||
<span class="topo-vc-label">WAN · Port 9 · 1G</span>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
TIER 2: Router, UDM-Pro
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<div class="topo-tier">
|
||||
<div class="topo-v2-node topo-v2-router">
|
||||
<span class="topo-v2-icon">⬡</span>
|
||||
<span class="topo-v2-label">UDM-Pro</span>
|
||||
<span class="topo-v2-sub">Dream Machine Pro</span>
|
||||
<span class="topo-v2-sub">RU24</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- UDM-Pro → USW-Agg (10G SFP+) -->
|
||||
<div class="topo-vc">
|
||||
<div class="topo-vc-wire topo-vc-wire--10g"></div>
|
||||
<span class="topo-vc-label">10G SFP+</span>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
TIER 3: USW-Aggregation
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<div class="topo-tier">
|
||||
<div class="topo-v2-node topo-v2-switch" id="topo-switch-agg">
|
||||
<span class="topo-v2-icon">⬡</span>
|
||||
<span class="topo-v2-label">USW-Agg</span>
|
||||
<span class="topo-v2-sub">Aggregation · RU22</span>
|
||||
<span class="topo-v2-sub">8 × 10G SFP+</span>
|
||||
<span class="topo-v2-vlan">VLAN90 · 10.10.90.x/24</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- USW-Agg → Pro 24 PoE (10G trunk) -->
|
||||
<div class="topo-vc">
|
||||
<div class="topo-vc-wire topo-vc-wire--10g"></div>
|
||||
<span class="topo-vc-label">10G trunk</span>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
TIER 4: Pro 24 PoE
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<div class="topo-tier">
|
||||
<div class="topo-v2-node topo-v2-switch" id="topo-switch-poe">
|
||||
<span class="topo-v2-icon">⬡</span>
|
||||
<span class="topo-v2-label">Pro 24 PoE</span>
|
||||
<span class="topo-v2-sub">24-Port · RU23</span>
|
||||
<span class="topo-v2-sub">24 × 1G PoE</span>
|
||||
<span class="topo-v2-vlan">DHCP · mgmt</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pro 24 PoE → host bus section -->
|
||||
<div class="topo-vc">
|
||||
<div class="topo-vc-wire topo-vc-wire--mgmt"></div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
TIER 4 connecting bus: two rails (10G green + 1G amber dashed)
|
||||
showing dual-homing for all 6 servers
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<div class="topo-bus-section">
|
||||
|
||||
<!-- 10G storage bus (Agg → VLAN90) -->
|
||||
<div class="topo-bus-10g">
|
||||
<span class="topo-bus-10g-label">← USW-Agg · 10G SFP+ · VLAN90 →</span>
|
||||
<div class="topo-bus-10g-line"></div>
|
||||
</div>
|
||||
|
||||
<!-- 1G management bus (PoE → DHCP) -->
|
||||
<div class="topo-bus-1g">
|
||||
<span class="topo-bus-1g-label">← Pro 24 PoE · 1G · DHCP mgmt →</span>
|
||||
<div class="topo-bus-1g-line"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Host nodes with drop wires ── -->
|
||||
<div class="topo-v2-hosts">
|
||||
{%- set all_defs = [
|
||||
('compute-storage-gpu-01', 'csg-01', 'RU4-12', 'Ceph · VLAN90', False),
|
||||
('compute-storage-01', 'cs-01', 'RU14-17', 'Ceph · VLAN90', False),
|
||||
('storage-01', 'sto-01', 'rack', 'Ceph · VLAN90', False),
|
||||
('monitor-01', 'mon-01', 'ZimaBoard', 'mgmt', False),
|
||||
('monitor-02', 'mon-02', 'ZimaBoard', 'mgmt', False),
|
||||
('large1', 'large1', 'off-rack', 'table', True),
|
||||
] -%}
|
||||
{%- for hname, hlabel, hsub, hvlan, off_rack in all_defs -%}
|
||||
{%- set st = topo_h[hname].status if hname in topo_h else 'unknown' -%}
|
||||
<div class="topo-v2-host-wrap">
|
||||
<!-- dual-homing wires: 10G solid green + 1G dashed amber -->
|
||||
<div class="topo-v2-host-wires">
|
||||
<div class="topo-v2-wire-10g" data-host="{{ hname }}" title="10G SFP+ → USW-Agg"></div>
|
||||
<div class="topo-v2-wire-1g" data-host="{{ hname }}" title="1G → Pro 24 PoE"></div>
|
||||
</div>
|
||||
<!-- host box -->
|
||||
<div class="topo-v2-node topo-v2-host topo-host topo-v2-status-{{ st }}{{ ' topo-v2-offrack' if off_rack else '' }} topo-v2-host--bus"
|
||||
data-host="{{ hname }}">
|
||||
<span class="topo-v2-icon">▣</span>
|
||||
<span class="topo-v2-label">{{ hlabel }}</span>
|
||||
<span class="topo-v2-sub">{{ hsub }}</span>
|
||||
<span class="topo-v2-vlan">{{ hvlan }}</span>
|
||||
<span class="topo-badge topo-badge-{{ st }}">{{ st if st != 'unknown' else '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
|
||||
</div><!-- /topo-bus-section -->
|
||||
|
||||
<!-- ── Legend ── -->
|
||||
<div class="topo-legend">
|
||||
<div class="topo-legend-item"><span class="topo-legend-line-wan"></span> WAN / uplink</div>
|
||||
<div class="topo-legend-item"><span class="topo-legend-line-10g"></span> 10G SFP+ (Ceph / VLAN90)</div>
|
||||
<div class="topo-legend-item"><span class="topo-legend-line-1g"></span> 1G DHCP (mgmt)</div>
|
||||
<div class="topo-legend-item topo-legend-item--offrack">dashed border = off-rack</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /topo-v2 -->
|
||||
</div>
|
||||
|
||||
</div><!-- /#topo-collapsible-wrap -->
|
||||
</section>
|
||||
|
||||
|
||||
<!-- ── Recently Resolved (last 24h) ───────────────────────────────── -->
|
||||
{% if recent_resolved %}
|
||||
<section class="g-section">
|
||||
@@ -551,30 +381,6 @@
|
||||
if (s.refreshInterval > 0) lt.autoRefresh.start(refreshAll, s.refreshInterval * 1000);
|
||||
};
|
||||
|
||||
// ── Topology collapse toggle ───────────────────────────────────
|
||||
(function() {
|
||||
const LS_KEY = 'gandalf_topo_collapsed';
|
||||
const btn = document.getElementById('topo-toggle-btn');
|
||||
const wrap = document.getElementById('topo-collapsible-wrap');
|
||||
if (!btn || !wrap) return;
|
||||
|
||||
function setCollapsed(v) {
|
||||
wrap.classList.toggle('is-collapsed', v);
|
||||
wrap.setAttribute('aria-hidden', v ? 'true' : 'false');
|
||||
btn.setAttribute('aria-expanded', v ? 'false' : 'true');
|
||||
btn.textContent = v ? '▾ Expand' : '▴ Collapse';
|
||||
try { localStorage.setItem(LS_KEY, v ? '1' : '0'); } catch(_) {}
|
||||
}
|
||||
|
||||
let saved = false;
|
||||
try { saved = localStorage.getItem(LS_KEY) === '1'; } catch(_) {}
|
||||
setCollapsed(saved);
|
||||
|
||||
btn.addEventListener('click', function() {
|
||||
setCollapsed(!wrap.classList.contains('is-collapsed'));
|
||||
});
|
||||
})();
|
||||
|
||||
function updateEventAges() {
|
||||
document.querySelectorAll('.event-age[data-ts]').forEach(el => {
|
||||
el.textContent = lt.time.ago(_toIso(el.dataset.ts));
|
||||
@@ -635,12 +441,6 @@
|
||||
|
||||
initDeviceSearch();
|
||||
|
||||
// Topology hosts open their device page
|
||||
document.getElementById('topology-diagram')?.addEventListener('click', e => {
|
||||
const node = e.target.closest('.topo-host[data-host]');
|
||||
if (node) window.location.href = deviceUrl(node.dataset.host);
|
||||
});
|
||||
|
||||
// Stat card clicks: filter events table by severity
|
||||
document.querySelectorAll('.lt-stat-card[data-stat-filter]').forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Topology{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="lt-page-header">
|
||||
<div>
|
||||
<h1 class="lt-page-title">Topology</h1>
|
||||
<p class="g-page-sub">Every device and cable as the switches see them right now. Drag to pan, scroll or pinch to zoom. Hover a device to trace its path to the internet; click a cable for its details, or a group of unknown devices to open it.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tp-toolbar no-print" role="toolbar" aria-label="Topology view">
|
||||
<div class="tp-filters" role="group" aria-label="Show">
|
||||
<label class="tp-filter">Unknown devices
|
||||
<select class="lt-select tp-select" data-filter="stubs" aria-label="Unknown devices">
|
||||
<option value="grouped">grouped per switch</option>
|
||||
<option value="all">one by one</option>
|
||||
<option value="none">hidden</option>
|
||||
</select></label>
|
||||
<label class="tp-filter"><input type="checkbox" data-filter="power"> Power gear</label>
|
||||
<label class="tp-filter"><input type="checkbox" data-filter="traffic"> Traffic</label>
|
||||
<label class="tp-filter" title="Each server's slower second cable. A problem cable is always shown."><input type="checkbox" data-filter="mgmt"> Server 1G links</label>
|
||||
<label class="tp-filter"><input type="checkbox" data-filter="alerts"> Alerts only</label>
|
||||
</div>
|
||||
<div class="tp-zoom" role="group" aria-label="Zoom">
|
||||
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-zoom="out" aria-label="Zoom out" title="Zoom out (-)">−</button>
|
||||
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-zoom="fit" title="Fit everything (0)">Fit</button>
|
||||
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-zoom="in" aria-label="Zoom in" title="Zoom in (+)">+</button>
|
||||
</div>
|
||||
<span class="tp-updated" id="tp-updated" aria-live="polite"></span>
|
||||
</div>
|
||||
|
||||
<div class="tp-warnings" id="tp-warnings" role="status" hidden></div>
|
||||
<div class="tp-summary" id="tp-summary" aria-live="polite"></div>
|
||||
|
||||
<div class="tp-canvas" id="tp-canvas" tabindex="0" aria-label="Network topology diagram. Use plus, minus and zero to zoom, and Tab to move between devices and cables.">
|
||||
<svg class="tp-svg" id="tp-svg" xmlns="http://www.w3.org/2000/svg" role="group" aria-label="Network topology">
|
||||
<g id="tp-world"></g>
|
||||
</svg>
|
||||
<div class="link-loading tp-loading" id="tp-loading">Loading topology</div>
|
||||
<aside class="tp-panel" id="tp-panel" hidden aria-live="polite" aria-label="Cable details"></aside>
|
||||
</div>
|
||||
|
||||
<div class="tp-legend" aria-label="Legend">
|
||||
<span class="tp-key"><svg viewBox="0 0 40 12" aria-hidden="true"><path class="tp-key-line tp-m-dac" d="M2 6 H38"/></svg>DAC</span>
|
||||
<span class="tp-key"><svg viewBox="0 0 40 12" aria-hidden="true"><path class="tp-key-line tp-m-fibre" d="M2 6 H38"/></svg>Fibre (multimode)</span>
|
||||
<span class="tp-key"><svg viewBox="0 0 40 12" aria-hidden="true"><path class="tp-key-line tp-m-wanfibre" d="M2 6 H38"/></svg>Fibre (provider)</span>
|
||||
<span class="tp-key"><svg viewBox="0 0 40 12" aria-hidden="true"><path class="tp-key-line tp-m-sfp" d="M2 6 H38"/></svg>SFP+ module</span>
|
||||
<span class="tp-key"><svg viewBox="0 0 40 12" aria-hidden="true"><path class="tp-key-line tp-m-copper" d="M2 6 H38"/></svg>Copper</span>
|
||||
<span class="tp-key">Thicker = faster (10G, 1G, 100M)</span>
|
||||
<span class="tp-key"><svg viewBox="0 0 40 12" aria-hidden="true"><path class="tp-key-line tp-key-down" d="M2 6 H38"/></svg>Down</span>
|
||||
<span class="tp-key"><svg viewBox="0 0 40 12" aria-hidden="true"><path class="tp-key-line tp-key-ghost" d="M2 6 H38"/></svg>Last seen</span>
|
||||
<span class="tp-key"><span class="tp-key-warn" aria-hidden="true">⚠</span>Port name points at another device</span>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='faceplates.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='fronts.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='topology.js') }}"></script>
|
||||
{% endblock %}
|
||||
+42
-2
@@ -96,10 +96,15 @@ class TestPagesAndNav:
|
||||
html = client.get('/', headers=AUTH).get_data(as_text=True)
|
||||
order = [html.index(m) for m in (
|
||||
'id="device-search"', 'Alert Queue', 'WAN / Fiber', 'g-section-title">Hosts<',
|
||||
'Device Inventory', 'Network Topology',
|
||||
'Device Inventory',
|
||||
)]
|
||||
assert order == sorted(order)
|
||||
|
||||
def test_dashboard_topology_moved_to_its_own_page(self, client):
|
||||
html = client.get('/', headers=AUTH).get_data(as_text=True)
|
||||
assert 'topology-diagram' not in html and 'Network Topology' not in html
|
||||
assert 'href="/topology"' in html
|
||||
|
||||
def test_old_links_url_redirects_home(self, client):
|
||||
resp = client.get('/links', headers=AUTH)
|
||||
assert resp.status_code == 302 and resp.headers['Location'].endswith('/')
|
||||
@@ -385,7 +390,8 @@ class TestHealth:
|
||||
|
||||
class TestPageTitles:
|
||||
@pytest.mark.parametrize('path,title', [('/', 'Dashboard | GANDALF'), ('/events', 'Alert History | GANDALF'),
|
||||
('/cutsheet', 'Cutsheet | GANDALF')])
|
||||
('/cutsheet', 'Cutsheet | GANDALF'),
|
||||
('/topology', 'Topology | GANDALF')])
|
||||
def test_name_appears_once(self, client, path, title):
|
||||
gandalf._cutsheet_schema_ready = True
|
||||
html = client.get(path, headers=AUTH).get_data(as_text=True)
|
||||
@@ -703,3 +709,37 @@ class TestFollowUp:
|
||||
html = client.get('/', headers=AUTH).get_data(as_text=True)
|
||||
assert '<section class="g-section" id="followup" hidden>' in html
|
||||
assert 'TO FOLLOW UP' not in html
|
||||
|
||||
|
||||
class TestTopology:
|
||||
def test_page_needs_auth(self, client):
|
||||
assert client.get('/topology').status_code == 401
|
||||
assert client.get('/api/topology').status_code == 401
|
||||
|
||||
def test_page_loads_the_drawings_first(self, client):
|
||||
html = client.get('/topology', headers=AUTH).get_data(as_text=True)
|
||||
assert 'id="tp-canvas"' in html
|
||||
assert html.index('faceplates.js') < html.index('fronts.js') < html.index('topology.js')
|
||||
|
||||
def test_api_returns_the_graph(self, client):
|
||||
gandalf._cutsheet_schema_ready = True
|
||||
with patch.object(gandalf.db, 'cutsheet_load', return_value={'items': [], 'cables': []}):
|
||||
data = client.get('/api/topology', headers=AUTH).get_json()
|
||||
assert {'nodes', 'links', 'warnings', 'updated'} <= set(data)
|
||||
names = {n['name'] for n in data['nodes']}
|
||||
assert {'large1', 'USW Flex', 'Internet'} <= names
|
||||
|
||||
def test_api_survives_a_cutsheet_failure(self, client):
|
||||
gandalf._cutsheet_schema_ready = True
|
||||
with patch.object(gandalf.db, 'cutsheet_load', side_effect=RuntimeError('db down')):
|
||||
assert client.get('/api/topology', headers=AUTH).status_code == 200
|
||||
|
||||
def test_suppressed_alerts_are_not_drawn(self, client):
|
||||
gandalf._cutsheet_schema_ready = True
|
||||
ev = {'id': 1, 'event_type': 'host_down', 'severity': 'critical', 'target_name': 'large1',
|
||||
'target_detail': '', 'description': ''}
|
||||
with patch.object(gandalf.db, 'cutsheet_load', return_value={'items': [], 'cables': []}), \
|
||||
patch.object(gandalf.db, 'get_active_events', return_value=[ev]), \
|
||||
patch.object(gandalf.db, 'check_suppressed', return_value=True):
|
||||
data = client.get('/api/topology', headers=AUTH).get_json()
|
||||
assert next(n for n in data['nodes'] if n['name'] == 'large1')['alert_count'] == 0
|
||||
|
||||
@@ -642,9 +642,11 @@ class TestLldpCollection:
|
||||
{'metric': {'device': 'vmbr0', 'address': mac, 'instance': '10.0.0.1:9100'}},
|
||||
]
|
||||
switches = {'sw': {'ports': {'p': {'lldp': {'chassis_id': 'x', 'port_id': mac.upper()}}}}}
|
||||
collector._resolve_neighbors(switches, {}, {'10.0.0.1:9100': 'alpha'})
|
||||
host_macs = collector._resolve_neighbors(switches, {}, {'10.0.0.1:9100': 'alpha'})
|
||||
lldp = switches['sw']['ports']['p']['lldp']
|
||||
assert (lldp['system_name'], lldp['port_id']) == ('alpha', 'eth0')
|
||||
# Kept for the topology page: the physical NIC, not the bond or bridge
|
||||
assert host_macs == {mac: ('alpha', 'eth0')}
|
||||
|
||||
def test_client_list_failure_is_empty(self):
|
||||
c = monitor.UnifiClient({'controller': 'https://u.test', 'api_key': 'k'})
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Tests for topology.build_graph, on a small network shaped like the real one."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
import topology # noqa: E402
|
||||
|
||||
|
||||
def port(idx, name=None, up=True, speed=1000, neighbour='', port_id='', chassis='', uplink=False, sfp=None,
|
||||
tx=0.0, rx=0.0, source=None):
|
||||
lldp = {}
|
||||
if neighbour or chassis:
|
||||
lldp = {'system_name': neighbour, 'port_id': port_id, 'chassis_id': chassis, 'port_desc': '', 'mgmt_ips': []}
|
||||
if source:
|
||||
lldp['source'] = source
|
||||
p = {'port_idx': idx, 'up': up, 'speed_mbps': speed if up else 0, 'is_uplink': uplink, 'media': 'GE',
|
||||
'lldp': lldp, 'tx_bytes_rate': tx, 'rx_bytes_rate': rx, 'rx_errs_rate': 0.0, 'tx_errs_rate': 0.0,
|
||||
'rx_drops_rate': 0.0, 'tx_drops_rate': 0.0}
|
||||
if sfp:
|
||||
p['sfp'] = dict({'found': True, 'vendor': 'OEM', 'serial': 'SN1'}, **sfp)
|
||||
p['media'] = 'SFP+'
|
||||
return (name or f'Port {idx}'), p
|
||||
|
||||
|
||||
def ports(*items):
|
||||
return dict(items)
|
||||
|
||||
|
||||
def dac(peer=None, peer_port=None):
|
||||
s = {'kind': 'dac', 'part': 'SFP-H10GB-CU1M'}
|
||||
if peer:
|
||||
s['cable_peer'] = {'switch': peer, 'port_idx': peer_port}
|
||||
return s
|
||||
|
||||
|
||||
def network(poe_port_11='Port 11', pbs_up=True):
|
||||
"""Gateway -> aggregation -> PoE switch, two dual-homed hosts, an AP, a PDU and unknown devices."""
|
||||
switches = {
|
||||
'Dream Machine Pro': {'model': 'UDMPRO', 'ports': ports(
|
||||
port(1, neighbour='host-a', port_id='eno2', source='unifi-clients'),
|
||||
port(8), # up, nothing reported: a cable to something Gandalf can't see
|
||||
port(9, uplink=True, chassis='02:00:5e:00:00:09', port_id='02:00:5e:00:00:0a'),
|
||||
port(11, 'Downlink to USW-Aggregation', speed=10000, neighbour='8SFP-AGG', port_id='local Port 7',
|
||||
sfp=dac('8SFP-AGG', 7), tx=2000.0, rx=1000.0),
|
||||
)},
|
||||
'8SFP-AGG': {'model': 'USL8A', 'ports': ports(
|
||||
port(1, 'SFP+ 1', speed=10000, neighbour='host-a', port_id='eno1', sfp={'kind': 'dac', 'part': 'X'}),
|
||||
port(2, 'SFP+ 2', speed=10000, neighbour='host-b', port_id='enp1s0', sfp={'kind': '', 'part': 'L56'}),
|
||||
port(5, 'Downlink to 24RJ45-POE', speed=10000, neighbour='24RJ45-POE', port_id='Port 26',
|
||||
sfp=dac('24RJ45-POE', 26)),
|
||||
port(7, 'SFP+ 7', speed=10000, neighbour='Dream Machine Pro', port_id='02:00:5e:00:00:11',
|
||||
uplink=True, sfp=dac('Dream Machine Pro', 11), tx=1000.0, rx=2000.0),
|
||||
port(8, 'SFP+ 8', speed=10000, neighbour='host-c', port_id='nic4', sfp={'kind': 'optic', 'part': 'SR'}),
|
||||
)},
|
||||
'24RJ45-POE': {'model': 'US24PRO', 'ports': ports(
|
||||
port(1, 'Micro 2 Eth2', chassis='02:00:5e:00:01:01', port_id='02:00:5E:00:01:01'),
|
||||
port(3, 'Living Room Downlink', neighbour='USW Flex', port_id='02:00:5E:00:02:01'),
|
||||
port(4, neighbour='host-b', port_id='enp2s0', source='unifi-clients'),
|
||||
port(8, neighbour='192.0.2.44', port_id='eth0', chassis='02:00:5e:00:01:08', source='unifi-clients'),
|
||||
port(11, poe_port_11, neighbour='AP', port_id='02:00:5E:00:03:01'),
|
||||
port(12, up=False),
|
||||
port(15, 'PBS', up=pbs_up, neighbour='pbs' if pbs_up else '', port_id='enp0s25' if pbs_up else ''),
|
||||
port(19, neighbour='RPDU', port_id='02:00:5E:00:04:01', speed=100),
|
||||
port(26, 'SFP+ 2', speed=10000, neighbour='8SFP-AGG', port_id='Port 5', uplink=True,
|
||||
sfp=dac('8SFP-AGG', 5)),
|
||||
)},
|
||||
'USW Flex': {'model': 'USF5P', 'ports': ports(
|
||||
port(1, uplink=True, neighbour='24RJ45-POE', port_id='Port 3'),
|
||||
port(5, neighbour='UPS 2U Pro', port_id='Port 1', speed=100),
|
||||
)},
|
||||
'RPDU': {'model': 'USPPDUP', 'ports': ports(port(1, uplink=True, speed=100))},
|
||||
}
|
||||
unifi = [
|
||||
{'name': 'Dream Machine Pro', 'type': 'udm', 'model': 'UDMPRO', 'connected': True, 'mac': '', 'ip': ''},
|
||||
{'name': '8SFP-AGG', 'type': 'usw', 'model': 'USL8A', 'connected': True, 'mac': '', 'ip': ''},
|
||||
{'name': '24RJ45-POE', 'type': 'usw', 'model': 'US24PRO', 'connected': True, 'mac': '', 'ip': ''},
|
||||
{'name': 'USW Flex', 'type': 'usw', 'model': 'USF5P', 'connected': True, 'mac': '', 'ip': ''},
|
||||
{'name': 'AP', 'type': 'uap', 'model': 'UAL6', 'connected': True, 'mac': '', 'ip': ''},
|
||||
{'name': 'RPDU', 'type': 'usw', 'model': 'USPPDUP', 'connected': True, 'mac': '', 'ip': ''},
|
||||
{'name': 'UPS 2U Pro', 'type': 'usp', 'model': 'USPDA2B', 'connected': True, 'mac': '', 'ip': ''},
|
||||
{'name': 'USP Strip', 'type': 'uap', 'model': 'UP6', 'connected': True, 'mac': '', 'ip': ''},
|
||||
]
|
||||
hosts = {
|
||||
'host-a': {'status': 'up', 'interfaces': {'eno1': 'up', 'eno2': 'up', 'eno3': 'unused', 'vmbr0': 'up'}},
|
||||
'host-b': {'status': 'degraded',
|
||||
'interfaces': {'bond0': 'up', 'enp1s0': 'up', 'enp2s0': 'up', 'enp3s0': 'down'}},
|
||||
'host-c': {'status': 'up', 'interfaces': {'nic4': 'up'}},
|
||||
'pbs': {'status': 'up' if pbs_up else 'down', 'interfaces': {'enp0s25': 'up' if pbs_up else 'down'}},
|
||||
}
|
||||
snapshot = {'hosts': hosts, 'unifi': unifi}
|
||||
link_stats = {
|
||||
'hosts': {'192.0.2.44': {'eth0': {}}}, # an IP-only Prometheus target
|
||||
'unifi_switches': switches,
|
||||
'unifi_devices': {'Dream Machine Pro': {'wan': {'wan1': {'up': True}}},
|
||||
'RPDU': {'power_w': 120.5, 'outlets': [{'index': 1, 'name': 'AT&T Modem', 'on': True}]}},
|
||||
'updated': '2026-11-14 12:00:00 UTC',
|
||||
}
|
||||
modem = {'name': 'att-modem', 'model': 'BGW320-505', 'reachable': True, 'broadband_state': 'Up',
|
||||
'source': 'FIBER', 'line_speed_mbps': 10000, 'rates': {'rx_bytes': 5000.0, 'tx_bytes': 700.0},
|
||||
'wan_problems': []}
|
||||
return snapshot, link_stats, modem
|
||||
|
||||
|
||||
def build(events=(), items=(), cables=(), known=None, **kw):
|
||||
snapshot, link_stats, modem = network(**kw)
|
||||
return topology.build_graph(snapshot, link_stats, modem, list(events), list(items), list(cables), known)
|
||||
|
||||
|
||||
def find(graph, a, b):
|
||||
"""The links between two nodes, either way round."""
|
||||
return [lk for lk in graph['links'] if {lk['a']['node'], lk['b']['node']} == {a, b}]
|
||||
|
||||
|
||||
def node(graph, name):
|
||||
return next(n for n in graph['nodes'] if n['id'] == name)
|
||||
|
||||
|
||||
class TestSwitchCables:
|
||||
def test_cable_seen_from_both_ends_is_drawn_once(self):
|
||||
g = build()
|
||||
assert len(find(g, 'Dream Machine Pro', '8SFP-AGG')) == 1
|
||||
assert len(find(g, '8SFP-AGG', '24RJ45-POE')) == 1
|
||||
assert len(find(g, '24RJ45-POE', 'USW Flex')) == 1
|
||||
|
||||
def test_both_ports_of_a_switch_cable_are_known(self):
|
||||
g = build()
|
||||
(link,) = find(g, 'Dream Machine Pro', '8SFP-AGG')
|
||||
ends = {link['a']['node']: link['a']['port_idx'], link['b']['node']: link['b']['port_idx']}
|
||||
assert ends == {'Dream Machine Pro': 11, '8SFP-AGG': 7}
|
||||
(flex,) = find(g, '24RJ45-POE', 'USW Flex')
|
||||
assert {flex['a']['port_idx'], flex['b']['port_idx']} == {3, 1}
|
||||
|
||||
def test_gear_without_lldp_lands_on_its_uplink(self):
|
||||
(pdu,) = find(build(), '24RJ45-POE', 'RPDU')
|
||||
assert pdu['b'] == {'node': 'RPDU', 'port_idx': 1, 'port_name': 'Port 1', 'iface': ''}
|
||||
|
||||
def test_cables_point_away_from_the_internet(self):
|
||||
g = build()
|
||||
(link,) = find(g, 'Dream Machine Pro', '8SFP-AGG')
|
||||
assert link['a']['node'] == 'Dream Machine Pro'
|
||||
# The gateway sends 2000 B/s down the cable and gets 1000 back, whichever end measured it
|
||||
assert (link['a_to_b_bps'], link['b_to_a_bps']) == (2000.0, 1000.0)
|
||||
(down,) = find(g, '8SFP-AGG', '24RJ45-POE')
|
||||
assert down['a']['node'] == '8SFP-AGG'
|
||||
|
||||
def test_medium_and_module(self):
|
||||
g = build()
|
||||
assert find(g, 'Dream Machine Pro', '8SFP-AGG')[0]['medium'] == 'dac'
|
||||
assert find(g, '8SFP-AGG', 'host-c')[0]['medium'] == 'fibre'
|
||||
assert find(g, '8SFP-AGG', 'host-b')[0]['medium'] == 'sfp'
|
||||
assert find(g, '24RJ45-POE', 'host-b')[0]['medium'] == 'copper'
|
||||
assert find(g, 'Dream Machine Pro', '8SFP-AGG')[0]['sfp_part'] == 'SFP-H10GB-CU1M'
|
||||
|
||||
|
||||
class TestWanEdge:
|
||||
def test_gateway_to_modem_to_internet(self):
|
||||
g = build()
|
||||
(wan,) = find(g, 'Dream Machine Pro', 'att-modem')
|
||||
assert wan['wan'] and wan['b']['node'] == 'Dream Machine Pro' and wan['a']['node'] == 'att-modem'
|
||||
assert wan['b']['port_idx'] == 9
|
||||
(fibre,) = find(g, 'att-modem', topology.INTERNET)
|
||||
assert fibre['medium'] == 'fibre' and fibre['speed_mbps'] == 10000 and fibre['up']
|
||||
# Down from the internet is what the modem received
|
||||
assert fibre['a_to_b_bps'] == 5000.0
|
||||
|
||||
def test_the_wan_port_is_not_a_stub(self):
|
||||
assert 'stub:Dream Machine Pro:9' not in {n['id'] for n in build()['nodes']}
|
||||
|
||||
def test_without_a_modem_the_gateway_goes_straight_out(self):
|
||||
snapshot, link_stats, _ = network()
|
||||
g = topology.build_graph(snapshot, link_stats, None, [], [], [], None)
|
||||
assert len(find(g, 'Dream Machine Pro', topology.INTERNET)) == 1
|
||||
|
||||
def test_internet_status_follows_the_gateway_wan(self):
|
||||
snapshot, link_stats, modem = network()
|
||||
link_stats['unifi_devices']['Dream Machine Pro']['wan']['wan1']['up'] = False
|
||||
g = topology.build_graph(snapshot, link_stats, modem, [], [], [], None)
|
||||
assert node(g, topology.INTERNET)['status'] == 'down'
|
||||
|
||||
|
||||
class TestHosts:
|
||||
def test_dual_homed_host_has_two_cables(self):
|
||||
g = build()
|
||||
a = [lk for lk in g['links'] if 'host-a' in (lk['a']['node'], lk['b']['node'])]
|
||||
assert sorted((lk['speed_mbps'], lk['b']['iface']) for lk in a) == [(1000, 'eno2'), (10000, 'eno1')]
|
||||
|
||||
def test_physical_nics_only_with_the_cables_on_them(self):
|
||||
nics = {n['name']: n for n in node(build(), 'host-a')['interfaces']}
|
||||
assert set(nics) == {'eno1', 'eno2', 'eno3'}
|
||||
assert nics['eno3']['unused'] and 'links' not in nics['eno3']
|
||||
assert len(nics['eno1']['links']) == 1
|
||||
|
||||
def test_ip_only_target_is_a_stub_not_a_host(self):
|
||||
g = build()
|
||||
assert '192.0.2.44' not in {n['id'] for n in g['nodes'] if n['kind'] != 'stub'}
|
||||
stub = node(g, 'stub:24RJ45-POE:8')
|
||||
assert stub['name'] == '192.0.2.44' and stub['ip'] == '192.0.2.44' and stub['iface'] == 'eth0'
|
||||
|
||||
def test_chassis_comes_from_the_cutsheet_item(self):
|
||||
items = [{'name': 'Server A', 'device': 'host-a', 'chassis': 'sliger-cx4712-red'},
|
||||
{'name': 'host-b', 'device': '', 'chassis': 'zimaboard-832-cx3'}]
|
||||
g = build(items=items)
|
||||
assert node(g, 'host-a')['chassis'] == 'sliger-cx4712-red'
|
||||
assert node(g, 'host-b')['chassis'] == 'zimaboard-832-cx3'
|
||||
assert node(g, 'host-c')['chassis'] == ''
|
||||
|
||||
|
||||
class TestStubs:
|
||||
def test_unnamed_neighbour_is_a_stub_named_by_its_port(self):
|
||||
stub = node(build(), 'stub:24RJ45-POE:1')
|
||||
assert stub['kind'] == 'stub' and stub['name'] == 'Micro 2 Eth2'
|
||||
assert stub['mac'] == '02:00:5e:00:01:01' and not stub['anonymous']
|
||||
|
||||
def test_link_with_no_neighbour_is_an_anonymous_stub(self):
|
||||
stub = node(build(), 'stub:Dream Machine Pro:8')
|
||||
assert stub['anonymous'] and stub['name'] == 'unknown device'
|
||||
|
||||
def test_ports_with_no_link_are_left_out(self):
|
||||
assert 'stub:24RJ45-POE:12' not in {n['id'] for n in build()['nodes']}
|
||||
|
||||
|
||||
class TestNameMismatch:
|
||||
def test_port_named_after_another_device(self):
|
||||
(link,) = find(build(poe_port_11='Large1 Management'), '24RJ45-POE', 'AP')
|
||||
assert link['name_mismatch'] is None # no device called large1 in this network
|
||||
|
||||
def test_flags_when_the_named_device_exists(self):
|
||||
(link,) = find(build(poe_port_11='host-b management'), '24RJ45-POE', 'AP')
|
||||
assert link['name_mismatch'] == {'named': 'host-b', 'actual': 'AP', 'end': 'a'}
|
||||
|
||||
def test_downlink_and_generic_names_are_fine(self):
|
||||
g = build()
|
||||
assert all(lk['name_mismatch'] is None for lk in g['links'])
|
||||
|
||||
@pytest.mark.parametrize('name', ['Port 4', 'SFP+ 2', 'Uplink', 'Downlink to 24RJ45-POE'])
|
||||
def test_names_that_never_flag(self, name):
|
||||
known = {topology._norm(n): n for n in ('24RJ45-POE', 'host-a')}
|
||||
assert topology.name_mismatch(name, '24RJ45-POE', 'x', known) is None
|
||||
|
||||
def test_matches_ignore_case_spacing_and_dashes(self):
|
||||
known = {topology._norm('compute-storage-01'): 'compute-storage-01'}
|
||||
assert topology.name_mismatch('Compute Storage 01 mgmt', 'AP', 'sw', known)['named'] == 'compute-storage-01'
|
||||
|
||||
def test_a_stub_is_checked_against_what_was_seen(self):
|
||||
snapshot, link_stats, modem = network()
|
||||
link_stats['unifi_switches']['24RJ45-POE']['ports']['host-c'] = port(
|
||||
20, 'host-c', chassis='02:00:5e:00:09:09')[1]
|
||||
g = topology.build_graph(snapshot, link_stats, modem, [], [], [], None)
|
||||
(link,) = find(g, '24RJ45-POE', 'stub:24RJ45-POE:20')
|
||||
assert link['name_mismatch']['named'] == 'host-c'
|
||||
|
||||
|
||||
class TestGhosts:
|
||||
KNOWN = {'pbs': {'seen': '2026-11-14 20:49:08 UTC', 'uplinks': [
|
||||
{'switch': '24RJ45-POE', 'port': 'PBS', 'port_idx': 15, 'host_interface': 'enp0s25'}]}}
|
||||
|
||||
def test_missing_host_is_drawn_where_last_seen(self):
|
||||
g = build(known=self.KNOWN, pbs_up=False)
|
||||
(ghost,) = find(g, '24RJ45-POE', 'pbs')
|
||||
assert ghost['ghost'] and not ghost['up'] and ghost['seen'] == '2026-11-14 20:49:08 UTC'
|
||||
assert ghost['b'] == {'node': 'pbs', 'port_idx': None, 'port_name': '', 'iface': 'enp0s25'}
|
||||
|
||||
def test_no_ghost_while_the_host_is_linked(self):
|
||||
g = build(known=self.KNOWN)
|
||||
assert [lk['ghost'] for lk in find(g, '24RJ45-POE', 'pbs')] == [False]
|
||||
|
||||
def test_no_ghost_onto_a_port_now_used_by_something_else(self):
|
||||
known = {'pbs': {'seen': 'x', 'uplinks': [{'switch': '24RJ45-POE', 'port_idx': 4, 'host_interface': 'e'}]}}
|
||||
assert find(build(known=known, pbs_up=False), '24RJ45-POE', 'pbs') == []
|
||||
|
||||
|
||||
class TestDepth:
|
||||
def test_hops_from_the_internet(self):
|
||||
g = build()
|
||||
depths = {n['id']: n['depth'] for n in g['nodes']}
|
||||
assert depths[topology.INTERNET] == 0
|
||||
assert depths['att-modem'] == 1
|
||||
assert depths['Dream Machine Pro'] == 2
|
||||
assert depths['8SFP-AGG'] == 3
|
||||
assert depths['24RJ45-POE'] == 4
|
||||
assert depths['USW Flex'] == 5 and depths['AP'] == 5
|
||||
assert depths['UPS 2U Pro'] == 6
|
||||
# A host's depth is its nearest switch's plus one; hosts never pass the path on
|
||||
assert depths['host-a'] == 3
|
||||
|
||||
def test_unlinked_gear_has_no_depth(self):
|
||||
assert node(build(), 'USP Strip')['depth'] is None
|
||||
|
||||
def test_power_gear_is_marked(self):
|
||||
g = build()
|
||||
assert {n['id']: n['kind'] for n in g['nodes'] if n['power']} == {
|
||||
'RPDU': 'pdu', 'UPS 2U Pro': 'ups', 'USP Strip': 'strip'}
|
||||
assert node(g, 'RPDU')['power_w'] == 120.5
|
||||
|
||||
|
||||
class TestAlerts:
|
||||
def test_active_alerts_count_on_their_device(self):
|
||||
events = [
|
||||
{'id': 1, 'event_type': 'interface_down', 'severity': 'critical', 'target_name': 'host-b',
|
||||
'target_detail': 'enp3s0', 'description': ''},
|
||||
{'id': 2, 'event_type': 'unifi_port_errors', 'severity': 'warning', 'target_name': '24RJ45-POE',
|
||||
'target_detail': 'Port 9', 'description': ''},
|
||||
{'id': 3, 'event_type': 'host_down', 'severity': 'critical', 'target_name': 'host-a',
|
||||
'target_detail': '', 'description': '', 'is_suppressed': True},
|
||||
]
|
||||
g = build(events=events)
|
||||
b = node(g, 'host-b')
|
||||
assert (b['alert_count'], b['severity']) == (1, 'critical')
|
||||
assert next(n for n in b['interfaces'] if n['name'] == 'enp3s0')['alert'] == 'critical'
|
||||
assert node(g, '24RJ45-POE')['severity'] == 'warning'
|
||||
assert node(g, 'host-a')['alert_count'] == 0
|
||||
|
||||
def test_status_comes_from_the_snapshot(self):
|
||||
assert node(build(), 'host-b')['status'] == 'degraded'
|
||||
|
||||
|
||||
class TestCutsheetLabels:
|
||||
def test_cable_label_on_either_end(self):
|
||||
items = [{'name': 'Patch', 'device': '', 'chassis': ''}, {'name': 'UDM-Pro', 'device': 'Dream Machine Pro'}]
|
||||
cables = [{'id': 7, 'label': 'C07', 'a_device': 'UDM-Pro', 'a_port': '8', 'b_device': 'Patch',
|
||||
'b_port': '1', 'status': 'installed', 'cable_type': 'Cat6', 'color': 'Black'},
|
||||
{'id': 8, 'label': 'C08', 'a_device': 'host-a', 'a_port': 'eno2', 'b_device': '',
|
||||
'b_port': '', 'status': 'retired'}]
|
||||
g = build(items=items, cables=cables)
|
||||
(stub,) = find(g, 'Dream Machine Pro', 'stub:Dream Machine Pro:8')
|
||||
assert stub['cable'] == {'id': 7, 'label': 'C07', 'type': 'Cat6', 'color': 'Black'}
|
||||
# Retired cables are not on the network any more
|
||||
assert find(g, 'Dream Machine Pro', 'host-a')[0]['cable'] is None
|
||||
|
||||
|
||||
class TestEmpty:
|
||||
def test_no_data_yet(self):
|
||||
g = topology.build_graph({}, {}, None, [], [], [], None)
|
||||
assert [n['id'] for n in g['nodes']] == [topology.INTERNET]
|
||||
assert g['links'] == [] and g['warnings']
|
||||
+411
@@ -0,0 +1,411 @@
|
||||
"""The live network topology: every device and every physical cable between them.
|
||||
|
||||
Pure functions with no I/O. app.py passes in the monitor's cached state and the
|
||||
cutsheet, and the /topology page draws what build_graph returns.
|
||||
|
||||
The graph comes from the switches' point of view: each UniFi port with a neighbour
|
||||
is one end of a cable. The same switch-to-switch cable is seen from both ends, so
|
||||
those are merged into one link.
|
||||
"""
|
||||
import re
|
||||
from collections import deque
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import devices
|
||||
|
||||
INTERNET = 'internet'
|
||||
|
||||
# How UniFi devices are drawn and grouped. Power gear goes in its own rail.
|
||||
_POWER_MODELS = {'USPPDUP': 'pdu', 'USPDA2B': 'ups', 'UP6': 'strip'}
|
||||
POWER_KINDS = ('pdu', 'ups', 'strip')
|
||||
|
||||
_SEVERITY_RANK = {'critical': 3, 'warning': 2, 'info': 1}
|
||||
_PORT_REF_RE = re.compile(r'(?:^|\s)port\s*(\d+)$', re.IGNORECASE)
|
||||
_TOKEN_RE = re.compile(r'[a-z0-9]+')
|
||||
|
||||
|
||||
def _norm(name: str) -> str:
|
||||
"""A name with case, spaces, dashes and underscores ignored: "Large1" == "large-1"."""
|
||||
return ''.join(_TOKEN_RE.findall((name or '').lower()))
|
||||
|
||||
|
||||
def _node_kind(entry: dict) -> str:
|
||||
if entry['kind'] == 'pdu':
|
||||
return _POWER_MODELS.get(entry.get('model', ''), 'pdu')
|
||||
return entry['kind']
|
||||
|
||||
|
||||
def _medium(port: dict) -> str:
|
||||
"""dac, fibre, sfp (a module that doesn't say which) or copper."""
|
||||
sfp = port.get('sfp') or {}
|
||||
if sfp.get('found') or sfp.get('part'):
|
||||
kind = sfp.get('kind') or ''
|
||||
if kind == 'dac':
|
||||
return 'dac'
|
||||
if kind == 'optic':
|
||||
return 'fibre'
|
||||
return 'sfp'
|
||||
if str(port.get('media') or '').upper().startswith('SFP'):
|
||||
return 'sfp' if port.get('up') else 'copper'
|
||||
return 'copper'
|
||||
|
||||
|
||||
def _rate(value) -> float:
|
||||
try:
|
||||
return max(float(value or 0), 0.0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _utilisation(a_to_b: float, b_to_a: float, speed_mbps) -> Optional[float]:
|
||||
try:
|
||||
speed = float(speed_mbps or 0)
|
||||
except (TypeError, ValueError):
|
||||
speed = 0.0
|
||||
if speed <= 0:
|
||||
return None
|
||||
return round(min(max(a_to_b, b_to_a) * 8 / (speed * 1e6), 1.0), 4)
|
||||
|
||||
|
||||
def _port_stats(port: dict) -> dict:
|
||||
sfp = port.get('sfp') or {}
|
||||
return {
|
||||
'speed_mbps': port.get('speed_mbps') or 0,
|
||||
'up': bool(port.get('up')),
|
||||
'medium': _medium(port),
|
||||
'sfp_part': sfp.get('part') or '',
|
||||
'sfp_vendor': sfp.get('vendor') or '',
|
||||
'sfp_serial': sfp.get('serial') or '',
|
||||
'errors': round(_rate(port.get('rx_errs_rate')) + _rate(port.get('tx_errs_rate')), 3),
|
||||
'drops': round(_rate(port.get('rx_drops_rate')) + _rate(port.get('tx_drops_rate')), 3),
|
||||
}
|
||||
|
||||
|
||||
def _end(node: str, port_name: str = '', port_idx=None, iface: str = '') -> dict:
|
||||
return {'node': node, 'port_idx': port_idx, 'port_name': port_name, 'iface': iface}
|
||||
|
||||
|
||||
def _far_port(switches: dict, here: str, port: dict, neighbour: str) -> Optional[int]:
|
||||
"""Which port on a neighbouring switch the cable from `port` lands on, if it can be told."""
|
||||
peer = (port.get('sfp') or {}).get('cable_peer') or {}
|
||||
if peer.get('switch') == neighbour and peer.get('port_idx') is not None:
|
||||
return peer['port_idx']
|
||||
far = switches.get(neighbour)
|
||||
if not far:
|
||||
return None
|
||||
ports = [p for p in (far.get('ports') or {}).values() if p.get('port_idx') is not None]
|
||||
# LLDP's port ID names the neighbour's own port ("Port 3", "local Port 7")
|
||||
m = _PORT_REF_RE.search(str((port.get('lldp') or {}).get('port_id') or '').strip())
|
||||
if m and any(p['port_idx'] == int(m.group(1)) for p in ports):
|
||||
return int(m.group(1))
|
||||
back = [p['port_idx'] for p in ports if (p.get('lldp') or {}).get('system_name') == here]
|
||||
if len(back) == 1:
|
||||
return back[0]
|
||||
# Gear that sends no LLDP of its own (a PDU): its single uplink
|
||||
uplinks = [p['port_idx'] for p in ports if p.get('is_uplink')]
|
||||
return uplinks[0] if len(uplinks) == 1 else None
|
||||
|
||||
|
||||
def _is_generic_port_name(name: str) -> bool:
|
||||
return bool(re.fullmatch(r'(port|sfp\+?|uplink|downlink)\s*\d*', (name or '').strip(), re.IGNORECASE))
|
||||
|
||||
|
||||
def name_mismatch(port_name: str, neighbour: str, own: str, known: Dict[str, str]) -> Optional[dict]:
|
||||
"""A port named after a different device than the one plugged into it.
|
||||
|
||||
known: {normalised name: display name} of every device. Runs of up to four words of
|
||||
the port name are compared with them, so "Large1 Management" names large1 and
|
||||
"Downlink to 24RJ45-POE" names 24RJ45-POE. Ports named only "Port 4" never match.
|
||||
"""
|
||||
if not port_name or _is_generic_port_name(port_name):
|
||||
return None
|
||||
words = _TOKEN_RE.findall(port_name.lower())
|
||||
skip = {_norm(neighbour), _norm(own)}
|
||||
for size in range(min(4, len(words)), 0, -1):
|
||||
for i in range(len(words) - size + 1):
|
||||
run = ''.join(words[i:i + size])
|
||||
if run in known and run not in skip:
|
||||
return {'named': known[run], 'actual': neighbour}
|
||||
return None
|
||||
|
||||
|
||||
def _cable_labels(items: List[dict], cables: List[dict]) -> Dict[Tuple[str, str], dict]:
|
||||
"""{(device, port): cable} for cutsheet cables still in place. A cable can name the
|
||||
device itself or a cutsheet item linked to it."""
|
||||
device_of = {i['name']: (i.get('device') or i['name']) for i in items or []}
|
||||
out: Dict[Tuple[str, str], dict] = {}
|
||||
for c in cables or []:
|
||||
if c.get('status') == 'retired':
|
||||
continue
|
||||
for end in ('a', 'b'):
|
||||
dev = c.get(f'{end}_device') or ''
|
||||
if not dev:
|
||||
continue
|
||||
ref = {'id': c.get('id'), 'label': c.get('label') or '', 'type': c.get('cable_type') or '',
|
||||
'color': c.get('color') or ''}
|
||||
for name in {dev, device_of.get(dev, dev)}:
|
||||
out[(name, str(c.get(f'{end}_port') or ''))] = ref
|
||||
return out
|
||||
|
||||
|
||||
def _alerts_by_target(events: List[dict]) -> Dict[str, List[dict]]:
|
||||
out: Dict[str, List[dict]] = {}
|
||||
for ev in events or []:
|
||||
if ev.get('is_suppressed'):
|
||||
continue
|
||||
out.setdefault(ev.get('target_name') or '', []).append({
|
||||
'id': ev.get('id'), 'severity': ev.get('severity') or 'info',
|
||||
'type': ev.get('event_type') or '', 'detail': ev.get('target_detail') or '',
|
||||
'description': ev.get('description') or '',
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _worst(alerts: List[dict]) -> str:
|
||||
return max((a['severity'] for a in alerts), key=lambda s: _SEVERITY_RANK.get(s, 0), default='')
|
||||
|
||||
|
||||
def build_graph(snapshot: dict, link_stats: dict, modem: Optional[dict], events: List[dict],
|
||||
cutsheet_items: List[dict], cutsheet_cables: List[dict],
|
||||
known_uplinks: Optional[dict] = None) -> dict:
|
||||
"""Every device and cable, as {nodes, links, warnings, updated}.
|
||||
|
||||
snapshot, link_stats, modem: the monitor's saved state.
|
||||
events: active events, with is_suppressed set (suppressed ones are left out).
|
||||
known_uplinks: the monitor's record of where each device was last plugged in,
|
||||
drawn as "last seen" links for a device with no live link.
|
||||
"""
|
||||
snapshot = snapshot or {}
|
||||
link_stats = link_stats or {}
|
||||
switches = link_stats.get('unifi_switches') or {}
|
||||
unifi_info = link_stats.get('unifi_devices') or {}
|
||||
alerts = _alerts_by_target(events)
|
||||
labels = _cable_labels(cutsheet_items, cutsheet_cables)
|
||||
chassis = {}
|
||||
for item in cutsheet_items or []:
|
||||
chassis.setdefault(item.get('device') or item['name'], item.get('chassis') or '')
|
||||
warnings: List[str] = []
|
||||
|
||||
# ── Nodes ────────────────────────────────────────────────────────────
|
||||
nodes: Dict[str, dict] = {}
|
||||
configured = set(snapshot.get('hosts') or {})
|
||||
for entry in devices.build_index(snapshot, link_stats, modem):
|
||||
name = entry['name']
|
||||
if entry['kind'] == 'host' and name not in configured:
|
||||
continue # IP-only Prometheus targets: shown only as a stub where a port sees them
|
||||
kind = _node_kind(entry)
|
||||
node = {
|
||||
'id': name, 'name': name, 'kind': kind, 'status': entry['status'], 'model': entry.get('model', ''),
|
||||
'ip': entry.get('ip', ''), 'url': f'/device/{name}', 'power': kind in POWER_KINDS,
|
||||
}
|
||||
if kind == 'host':
|
||||
node['chassis'] = chassis.get(name, '')
|
||||
node['interfaces'] = devices.physical_interfaces(name, snapshot)
|
||||
for nic in node['interfaces']:
|
||||
nic_alerts = [a for a in alerts.get(name, []) if a['detail'] == nic['name']]
|
||||
nic['alert'] = _worst(nic_alerts)
|
||||
elif name in switches or kind in ('router', 'switch'):
|
||||
node['ports'] = {
|
||||
str(p['port_idx']): {'name': pname, 'up': bool(p.get('up')), 'speed_mbps': p.get('speed_mbps') or 0,
|
||||
'is_uplink': bool(p.get('is_uplink')), 'media': p.get('media') or '',
|
||||
'poe_power': p.get('poe_power'), 'sfp': p.get('sfp') or None,
|
||||
'tx_bytes_rate': _rate(p.get('tx_bytes_rate')),
|
||||
'rx_bytes_rate': _rate(p.get('rx_bytes_rate')),
|
||||
'neighbour': (p.get('lldp') or {}).get('system_name') or ''}
|
||||
for pname, p in ((switches.get(name) or {}).get('ports') or {}).items()
|
||||
if p.get('port_idx') is not None
|
||||
}
|
||||
if kind in POWER_KINDS:
|
||||
info = unifi_info.get(name) or {}
|
||||
node['power_w'] = info.get('power_w')
|
||||
node['outlets'] = info.get('outlets') or []
|
||||
node_alerts = alerts.get(name, [])
|
||||
node['alerts'] = node_alerts[:10]
|
||||
node['alert_count'] = len(node_alerts)
|
||||
node['severity'] = _worst(node_alerts)
|
||||
nodes[name] = node
|
||||
|
||||
router = next((n for n in nodes.values() if n['kind'] == 'router'), None)
|
||||
modem_name = (modem or {}).get('name') if (modem or {}).get('name') in nodes else None
|
||||
wan = ((unifi_info.get(router['name']) or {}).get('wan') or {}) if router else {}
|
||||
wan_up = any(w.get('up') for w in wan.values()) if wan else None
|
||||
if modem_name:
|
||||
wan_up = (modem or {}).get('broadband_state', '').lower() == 'up' if wan_up is None else wan_up
|
||||
nodes[INTERNET] = {
|
||||
'id': INTERNET, 'name': 'Internet', 'kind': INTERNET, 'model': '', 'ip': '', 'url': '',
|
||||
'status': 'unknown' if wan_up is None else ('up' if wan_up else 'down'),
|
||||
'alerts': alerts.get('WAN', [])[:10], 'alert_count': len(alerts.get('WAN', [])),
|
||||
'severity': _worst(alerts.get('WAN', [])), 'power': False,
|
||||
}
|
||||
known_names = {_norm(n['name']): n['name'] for n in nodes.values() if n['id'] != INTERNET}
|
||||
|
||||
# ── Links ────────────────────────────────────────────────────────────
|
||||
links: List[dict] = []
|
||||
claimed: Dict[Tuple[str, int], dict] = {}
|
||||
|
||||
def add_link(a: dict, b: dict, port: dict, **extra) -> dict:
|
||||
link = {'id': '', 'a': a, 'b': b, 'ghost': False, 'name_mismatch': None,
|
||||
'cable': labels.get((a['node'], str(a['port_idx'] if a['port_idx'] is not None else a['iface'])))
|
||||
or labels.get((b['node'], str(b['port_idx'] if b['port_idx'] is not None else b['iface']))),
|
||||
**_port_stats(port),
|
||||
'_tx': _rate(port.get('tx_bytes_rate')), '_rx': _rate(port.get('rx_bytes_rate'))}
|
||||
link.update(extra)
|
||||
links.append(link)
|
||||
for end in (a, b):
|
||||
if end['port_idx'] is not None and end['node'] in switches:
|
||||
claimed[(end['node'], end['port_idx'])] = link
|
||||
return link
|
||||
|
||||
def ordered_ports(sw: dict):
|
||||
return sorted(((pn, p) for pn, p in (sw.get('ports') or {}).items() if p.get('port_idx') is not None),
|
||||
key=lambda kv: kv[1]['port_idx'])
|
||||
|
||||
# The router's internet port goes to the modem, then the modem to the internet
|
||||
wan_port = None
|
||||
if router and router['name'] in switches:
|
||||
wan_port = next(((pn, p) for pn, p in ordered_ports(switches[router['name']]) if p.get('is_uplink')), None)
|
||||
upstream = modem_name or INTERNET
|
||||
if wan_port:
|
||||
pn, p = wan_port
|
||||
add_link(_end(router['name'], pn, p['port_idx']), _end(upstream, 'WAN' if modem_name else ''), p, wan=True)
|
||||
elif router:
|
||||
warnings.append(f'No internet port found on {router["name"]}')
|
||||
if modem_name:
|
||||
m = modem or {}
|
||||
rates = m.get('rates') or {}
|
||||
up = str(m.get('broadband_state') or '').lower() == 'up'
|
||||
links.append({
|
||||
'id': '', 'a': _end(INTERNET), 'b': _end(modem_name, str(m.get('source') or 'WAN').title()),
|
||||
'ghost': False, 'name_mismatch': None, 'cable': None, 'wan': True,
|
||||
'speed_mbps': m.get('line_speed_mbps') or 0, 'up': up,
|
||||
'medium': 'fibre' if str(m.get('source') or '').upper() == 'FIBER' else 'copper',
|
||||
'sfp_part': m.get('optics_part_no') or '', 'sfp_vendor': '', 'sfp_serial': '',
|
||||
'errors': round(_rate(rates.get('rx_errors')) + _rate(rates.get('tx_errors')), 3),
|
||||
'drops': round(_rate(rates.get('rx_drops')) + _rate(rates.get('tx_drops')), 3),
|
||||
# Seen from the modem's broadband side: rx comes down from the internet
|
||||
'_tx': _rate(rates.get('rx_bytes')), '_rx': _rate(rates.get('tx_bytes')),
|
||||
})
|
||||
elif not router:
|
||||
warnings.append('No UniFi gateway reported yet, so the internet edge is not drawn')
|
||||
|
||||
# Device-to-device and device-to-host cables, from every switch port with a neighbour
|
||||
for sw_name in sorted(switches):
|
||||
for pname, port in ordered_ports(switches[sw_name]):
|
||||
idx = port['port_idx']
|
||||
if (sw_name, idx) in claimed:
|
||||
continue
|
||||
lldp = port.get('lldp') or {}
|
||||
neighbour = lldp.get('system_name') or ''
|
||||
here = _end(sw_name, pname, idx)
|
||||
if neighbour in nodes and neighbour != sw_name:
|
||||
nb = nodes[neighbour]
|
||||
if nb['kind'] == 'host':
|
||||
far = _end(neighbour, iface=lldp.get('port_id') or '')
|
||||
else:
|
||||
far_idx = _far_port(switches, sw_name, port, neighbour)
|
||||
if far_idx is not None and (neighbour, far_idx) in claimed:
|
||||
continue # the other end already drew this cable
|
||||
far_port = next((pn for pn, p in ((switches.get(neighbour) or {}).get('ports') or {}).items()
|
||||
if p.get('port_idx') == far_idx), '') if far_idx is not None else ''
|
||||
far = _end(neighbour, far_port, far_idx)
|
||||
link = add_link(here, far, port)
|
||||
link['name_mismatch'] = name_mismatch(pname, neighbour, sw_name, known_names)
|
||||
if link['name_mismatch'] is None and far['port_name']:
|
||||
link['name_mismatch'] = name_mismatch(far['port_name'], sw_name, neighbour, known_names)
|
||||
if link['name_mismatch']:
|
||||
link['name_mismatch']['end'] = 'b'
|
||||
elif link['name_mismatch']:
|
||||
link['name_mismatch']['end'] = 'a'
|
||||
continue
|
||||
if port.get('is_uplink') and not neighbour:
|
||||
continue # leads upstream to gear already drawn (a PDU's management port)
|
||||
if not port.get('up') and not lldp:
|
||||
continue
|
||||
# Something is plugged in that Gandalf can't name: a grey stub
|
||||
stub_id = f'stub:{sw_name}:{idx}'
|
||||
mac = lldp.get('chassis_id') or ''
|
||||
label = neighbour or ('' if _is_generic_port_name(pname) else pname) or mac or 'unknown device'
|
||||
nodes[stub_id] = {
|
||||
'id': stub_id, 'name': label, 'kind': 'stub', 'status': 'up' if port.get('up') else 'down',
|
||||
'model': '', 'ip': neighbour if re.fullmatch(r'[\d.]+', neighbour) else '', 'mac': mac,
|
||||
'url': f'/device/{sw_name}#port-{idx}', 'switch': sw_name, 'port_idx': idx, 'port_name': pname,
|
||||
'iface': (lldp.get('port_id') or '') if neighbour else '', 'anonymous': not lldp,
|
||||
'alerts': [], 'alert_count': 0, 'severity': '', 'power': False,
|
||||
}
|
||||
link = add_link(here, _end(stub_id, iface=nodes[stub_id]['iface']), port)
|
||||
# A stub labelled by its port name would always "match" it, so compare with what was seen
|
||||
link['name_mismatch'] = name_mismatch(pname, neighbour or mac or 'an unnamed device', sw_name, known_names)
|
||||
if link['name_mismatch']:
|
||||
link['name_mismatch']['end'] = 'a'
|
||||
|
||||
# Gear with no live cable: draw where it was last plugged in
|
||||
linked = {e['node'] for link in links for e in (link['a'], link['b'])}
|
||||
for name, entry in sorted((known_uplinks or {}).items()):
|
||||
node = nodes.get(name)
|
||||
if not node or name in linked or node['kind'] == 'stub':
|
||||
continue
|
||||
for up in entry.get('uplinks') or []:
|
||||
sw, idx = up.get('switch'), up.get('port_idx')
|
||||
if sw not in nodes or idx is None or (sw, idx) in claimed:
|
||||
continue
|
||||
port = next((p for p in ((switches.get(sw) or {}).get('ports') or {}).values()
|
||||
if p.get('port_idx') == idx), {})
|
||||
far = (_end(name, iface=up.get('host_interface') or '') if node['kind'] == 'host'
|
||||
else _end(name, '', None))
|
||||
link = add_link(_end(sw, up.get('port') or '', idx), far, port,
|
||||
ghost=True, seen=entry.get('seen') or '')
|
||||
link['up'] = False
|
||||
|
||||
# ── Depth: hops from the internet over device-to-device cables ───────
|
||||
adjacency: Dict[str, set] = {}
|
||||
for link in links:
|
||||
a, b = link['a']['node'], link['b']['node']
|
||||
adjacency.setdefault(a, set()).add(b)
|
||||
adjacency.setdefault(b, set()).add(a)
|
||||
depth: Dict[str, int] = {INTERNET: 0}
|
||||
queue = deque([INTERNET])
|
||||
while queue:
|
||||
cur = queue.popleft()
|
||||
for nxt in sorted(adjacency.get(cur, ())):
|
||||
if nxt in depth:
|
||||
continue
|
||||
depth[nxt] = depth[cur] + 1
|
||||
# Hosts and stubs are leaves: they never carry the path on to another device
|
||||
if nodes[nxt]['kind'] not in ('host', 'stub'):
|
||||
queue.append(nxt)
|
||||
for node in nodes.values():
|
||||
node['depth'] = depth.get(node['id'])
|
||||
|
||||
# Orient each cable from the internet side (a) to the far side (b), and count traffic that way
|
||||
for i, link in enumerate(links):
|
||||
da = depth.get(link['a']['node'], 1 << 20)
|
||||
db_ = depth.get(link['b']['node'], 1 << 20)
|
||||
# Rates were measured at end a as added: the reporting switch port, or the internet side
|
||||
tx, rx = link.pop('_tx'), link.pop('_rx')
|
||||
if db_ < da:
|
||||
link['a'], link['b'] = link['b'], link['a']
|
||||
if link.get('name_mismatch') and link['name_mismatch'].get('end'):
|
||||
link['name_mismatch']['end'] = 'a' if link['name_mismatch']['end'] == 'b' else 'b'
|
||||
tx, rx = rx, tx
|
||||
link['a_to_b_bps'] = round(tx, 1)
|
||||
link['b_to_a_bps'] = round(rx, 1)
|
||||
link['utilisation'] = _utilisation(tx, rx, link['speed_mbps'])
|
||||
link['id'] = f'l{i}'
|
||||
|
||||
# Which host NIC each cable lands on, so the drawing can light the right socket
|
||||
for link in links:
|
||||
for end in (link['a'], link['b']):
|
||||
node = nodes[end['node']]
|
||||
if node['kind'] == 'host' and end['iface']:
|
||||
for nic in node.get('interfaces', []):
|
||||
if nic['name'] == end['iface']:
|
||||
nic.setdefault('links', []).append(link['id'])
|
||||
|
||||
if not switches:
|
||||
warnings.append('No UniFi switch data yet: the monitor has not reported any ports')
|
||||
return {
|
||||
'nodes': sorted(nodes.values(), key=lambda n: (99 if n['depth'] is None else n['depth'], n['name'].lower())),
|
||||
'links': links,
|
||||
'warnings': warnings,
|
||||
'updated': link_stats.get('updated'),
|
||||
}
|
||||
Reference in New Issue
Block a user