Move the port inspector onto device pages
Lint / JS (eslint) (push) Successful in 1m14s
Lint / Python (flake8) (push) Successful in 2m12s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m3s
Test / Python Tests (pytest) (push) Successful in 1m11s
Lint / Python (flake8) (pull_request) Successful in 1m1s
Lint / JS (eslint) (pull_request) Successful in 10s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 56s
Test / Python Tests (pytest) (pull_request) Successful in 1m5s
Lint / JS (eslint) (push) Successful in 1m14s
Lint / Python (flake8) (push) Successful in 2m12s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m3s
Test / Python Tests (pytest) (push) Successful in 1m11s
Lint / Python (flake8) (pull_request) Successful in 1m1s
Lint / JS (eslint) (pull_request) Successful in 10s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 56s
Test / Python Tests (pytest) (pull_request) Successful in 1m5s
Replace the all-switches Inspector page with a per-device front panel, port table and detail panel. /inspector now redirects to the dashboard, and /device/<name>#port-<n> links directly to a port. Restore neighbor names after UniFi's LLDP change: read local_port_idx and resolve neighbors by MAC using Prometheus NIC data, UniFi devices and the wired client table. This also brings back Run Diagnostics on server ports, which depends on a named neighbor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,7 +29,7 @@ A Proxmox/Ceph cluster lives or dies by its network, and network problems are sn
|
||||
|
||||
**Device pages**: search by hostname, IP, MAC, model or interface from the dashboard, then open one page per host, switch, router, PDU or modem. Each interface or port is one row with its state, speed, traffic and neighbour. Expanding a row shows duplex, carrier flaps, error and drop rates, PoE, LLDP, and **SFP optical readings** (temperature, TX/RX power) with colour-coded thresholds.
|
||||
|
||||
**Network inspector**: chassis diagrams for each UniFi switch. Click a port for its stats, LLDP neighbour and a side-by-side switch/server path check.
|
||||
**Port inspector**: each switch and router page has a front-panel diagram. Click a port for its stats, LLDP neighbour and a side-by-side switch/server path check.
|
||||
|
||||

|
||||
|
||||
@@ -124,8 +124,9 @@ interface and port names, and LLDP neighbors.
|
||||
- **WAN** (routers): connection state, speed, latency, availability
|
||||
- **Connected to** (hosts): switch ports that see this host over LLDP
|
||||
- **Outlets** (PDUs): per-outlet state, power, and auto power-cycle setting
|
||||
- **Port inspector** (switches, routers): see below
|
||||
- **Interfaces / Ports**: one row each with state, speed, RX/TX rate, and neighbor, filterable.
|
||||
Expanding a row shows duplex, auto-negotiation, carrier changes, error and drop rates,
|
||||
Expanding a host's row shows duplex, auto-negotiation, carrier changes, error and drop rates,
|
||||
traffic bars, PoE, LLDP detail, and the **SFP / optical panel** (vendor/PN, temperature, voltage,
|
||||
bias, TX/RX power, RX−TX delta)
|
||||
- **WAN / Fiber** (modem): the same panel as the dashboard
|
||||
@@ -134,9 +135,9 @@ These replaced the Link Debug page; `/links` now redirects to the dashboard. Hos
|
||||
sees but `config.json` doesn't list still get a page, with their state shown as unknown because
|
||||
they aren't alerted on.
|
||||
|
||||
### Inspector (`/inspector`)
|
||||
Visual switch chassis diagrams. Each switch is rendered model-accurately using
|
||||
layout config in the template (`SWITCH_LAYOUTS`).
|
||||
### Port inspector (on switch and router device pages)
|
||||
A model-accurate front panel, drawn from the layout config in `static/port-inspector.js`
|
||||
(`SWITCH_LAYOUTS`). `/inspector` now redirects to the dashboard. Link to a port with `/device/<name>#port-<n>`.
|
||||
|
||||
**Port block colours:**
|
||||
| Colour | State |
|
||||
@@ -147,7 +148,7 @@ layout config in the template (`SWITCH_LAYOUTS`).
|
||||
| Grey | Down |
|
||||
| White outline | Currently selected |
|
||||
|
||||
**Clicking a port** opens the right-side detail panel showing:
|
||||
**Clicking a port**, on the panel or in the port table, opens the detail panel showing:
|
||||
- Link stats (status, speed, duplex, auto-neg, media type)
|
||||
- PoE (class, max wattage, current draw, mode)
|
||||
- Traffic (TX/RX rates)
|
||||
@@ -157,10 +158,15 @@ layout config in the template (`SWITCH_LAYOUTS`).
|
||||
two-column comparison of the switch port stats vs. the server NIC stats,
|
||||
including SFP DOM data if the server side has an SFP module
|
||||
|
||||
**LLDP path debug requirements:**
|
||||
1. Server must run `lldpd`: `apt install lldpd && systemctl enable --now lldpd`
|
||||
2. `lldpd` hostname must match the key in `data.hosts` (set via `config.json → hosts`)
|
||||
3. Switch has LLDP enabled (UniFi default: on)
|
||||
**Port neighbors**: UniFi's LLDP data identifies a neighbor only by MAC, so Gandalf names it by matching that MAC
|
||||
against host NICs (`node_network_info` from node_exporter) and UniFi devices, then falling back to management IPs.
|
||||
Servers that don't send LLDP are found through UniFi's wired client table. Uplink ports are skipped there, and
|
||||
neighbors that can't be matched show their MAC. Bonded NICs share a MAC, so only one member's port may be identified.
|
||||
|
||||
**Path check and diagnostics requirements:**
|
||||
1. The server's node_exporter is scraped, so its NIC MACs are in Prometheus
|
||||
2. The Pulse worker can SSH to the server
|
||||
3. Optional: `lldpd` on the server lets diagnostics confirm the cabling from the server's side
|
||||
|
||||
**Supported switch models** (set `SWITCH_LAYOUTS` keys to your UniFi model codes):
|
||||
|
||||
@@ -468,16 +474,15 @@ after `ldap.cache_ttl` seconds or can be removed from `ldap.cache_dir`.
|
||||
- Confirm `sshpass` + SSH access from the Pulse worker to each Proxmox host
|
||||
- Only interfaces with physical SFP modules return DOM data (`ethtool -m`)
|
||||
|
||||
### Inspector: path debug section not appearing
|
||||
- Requires LLDP: run `apt install lldpd && systemctl enable --now lldpd` on each server
|
||||
- The LLDP `system_name` broadcast by `lldpd` must match the hostname in `config.json → hosts[].name`
|
||||
- Override: `echo 'configure system hostname large1' > /etc/lldpd.d/hostname.conf && systemctl restart lldpd`
|
||||
- Allow up to 2 poll cycles (240s) after installing lldpd for LLDP table to populate
|
||||
### Port inspector: path check or Run Diagnostics not appearing
|
||||
- These need the port's neighbor to be a host with link stats. A port showing only a MAC couldn't be matched
|
||||
- Check that the server's node_exporter is scraped and reports `node_network_info` for that NIC
|
||||
- Hosts missing from `config.json` are named by IP, so their ports may not match what you expect
|
||||
|
||||
### Inspector: switch chassis shows as flat list (no layout)
|
||||
- The switch's `model` field from UniFi doesn't match any key in `SWITCH_LAYOUTS` in `inspector.html`
|
||||
- Check the UniFi API: the model appears in the `link_stats` API response under `unifi_switches.<name>.model`
|
||||
- Add the model key to `SWITCH_LAYOUTS` in `inspector.html` with the correct row/SFP layout
|
||||
### Port inspector: front panel shows as a flat list (no layout)
|
||||
- The switch's `model` field from UniFi doesn't match any key in `SWITCH_LAYOUTS` in `port-inspector.js`
|
||||
- The model is shown on the device page
|
||||
- Add the model key to `SWITCH_LAYOUTS` in `port-inspector.js` with the correct row/SFP layout
|
||||
|
||||
### Baseline re-initializing on every restart
|
||||
- `interface_baseline` is stored in the `monitor_state` DB table; survives restarts
|
||||
@@ -519,13 +524,13 @@ gandalf/
|
||||
├── static/
|
||||
│ ├── style.css # Terminal aesthetic CSS (CRT scanlines, green-on-black)
|
||||
│ ├── app.js # Dashboard JS (auto-refresh, events, suppress modal, search, modem panel)
|
||||
│ └── device.js # Device page rendering
|
||||
│ ├── device.js # Device page rendering
|
||||
│ └── port-inspector.js # Switch front panel, port panel, path check, diagnostics
|
||||
└── templates/
|
||||
├── base.html # Shared layout (header, nav, footer)
|
||||
├── index.html # Dashboard page
|
||||
├── device.html # Per-device page
|
||||
├── _modem_panel.html # WAN / Fiber panel, shared by the dashboard and modem page
|
||||
├── inspector.html # Visual switch inspector + LLDP path debug
|
||||
└── suppressions.html # Suppression management page
|
||||
```
|
||||
|
||||
@@ -540,11 +545,10 @@ gandalf/
|
||||
4. Restart monitor: `systemctl restart gandalf-monitor`
|
||||
5. For SFP DOM / ethtool: ensure the host is SSH-accessible from the Pulse worker
|
||||
|
||||
### Adding a New Switch Layout (Inspector)
|
||||
### Adding a New Switch Layout
|
||||
|
||||
Find the UniFi model code for the switch (it appears in the `/api/links` JSON response
|
||||
under `unifi_switches.<switch_name>.model`), then add to `SWITCH_LAYOUTS` in
|
||||
`templates/inspector.html`:
|
||||
Find the UniFi model code for the switch (shown on its device page), then add to `SWITCH_LAYOUTS` in
|
||||
`static/port-inspector.js`:
|
||||
|
||||
```javascript
|
||||
'MYNEWMODEL': {
|
||||
|
||||
@@ -261,8 +261,8 @@ def device_page(name: str):
|
||||
@app.route('/inspector')
|
||||
@require_auth
|
||||
def inspector():
|
||||
user = _get_user()
|
||||
return render_template('inspector.html', user=user)
|
||||
"""The port inspector moved to each switch's device page; keep old bookmarks working."""
|
||||
return redirect(url_for('index'))
|
||||
|
||||
|
||||
@app.route('/suppressions')
|
||||
|
||||
+75
@@ -112,6 +112,77 @@ def build_index(snapshot: dict, link_stats: dict, modem: Optional[dict]) -> List
|
||||
return sorted(devices.values(), key=lambda d: d['name'].lower())
|
||||
|
||||
|
||||
_MAC_RE = re.compile(r'^[0-9a-f]{2}(:[0-9a-f]{2}){5}$')
|
||||
|
||||
|
||||
def _norm_mac(value: Optional[str]) -> str:
|
||||
mac = (value or '').strip().lower().replace('-', ':')
|
||||
return mac if _MAC_RE.match(mac) else ''
|
||||
|
||||
|
||||
def resolve_lldp_neighbors(
|
||||
switches: dict,
|
||||
host_macs: Dict[str, tuple],
|
||||
device_macs: Dict[str, str],
|
||||
host_ips: Dict[str, str],
|
||||
) -> None:
|
||||
"""Fill in LLDP neighbor names in place.
|
||||
|
||||
UniFi's API reports a neighbor's MAC, port ID, and sometimes management IPs, but no
|
||||
system name. Names come from what Gandalf already knows:
|
||||
|
||||
host_macs: {mac: (host, interface)} from node_network_info
|
||||
device_macs: {mac: device name} from UniFi
|
||||
host_ips: {ip: host}
|
||||
"""
|
||||
for sw in (switches or {}).values():
|
||||
for port in (sw.get('ports') or {}).values():
|
||||
lldp = port.get('lldp')
|
||||
if not lldp or lldp.get('system_name'):
|
||||
continue
|
||||
port_mac = _norm_mac(lldp.get('port_id'))
|
||||
chassis_mac = _norm_mac(lldp.get('chassis_id'))
|
||||
if port_mac in host_macs:
|
||||
# The port ID names the exact NIC, so it also gives the interface.
|
||||
lldp['system_name'], lldp['port_id'] = host_macs[port_mac]
|
||||
elif chassis_mac in host_macs:
|
||||
lldp['system_name'] = host_macs[chassis_mac][0]
|
||||
elif chassis_mac in device_macs or port_mac in device_macs:
|
||||
lldp['system_name'] = device_macs.get(chassis_mac) or device_macs[port_mac]
|
||||
else:
|
||||
lldp['system_name'] = next(
|
||||
(host_ips[ip] for ip in lldp.get('mgmt_ips') or [] if ip in host_ips), '',
|
||||
)
|
||||
|
||||
|
||||
def apply_client_locations(
|
||||
switches: dict,
|
||||
clients: List[dict],
|
||||
host_macs: Dict[str, tuple],
|
||||
device_macs: Dict[str, str],
|
||||
) -> None:
|
||||
"""Name server neighbors from UniFi's wired client table, where LLDP gave no name.
|
||||
|
||||
This finds servers that don't run lldpd. Uplink ports are skipped because they
|
||||
carry traffic from devices further downstream.
|
||||
"""
|
||||
for client in clients or []:
|
||||
mac = _norm_mac(client.get('mac'))
|
||||
switch = device_macs.get(_norm_mac(client.get('sw_mac')))
|
||||
if mac not in host_macs or switch not in (switches or {}):
|
||||
continue
|
||||
for port in (switches[switch].get('ports') or {}).values():
|
||||
if port.get('port_idx') != client.get('sw_port') or port.get('is_uplink'):
|
||||
continue
|
||||
if (port.get('lldp') or {}).get('system_name'):
|
||||
continue
|
||||
host, iface = host_macs[mac]
|
||||
port['lldp'] = {
|
||||
'system_name': host, 'port_id': iface, 'chassis_id': mac,
|
||||
'port_desc': '', 'mgmt_ips': [], 'source': 'unifi-clients',
|
||||
}
|
||||
|
||||
|
||||
def uplinks_for_host(name: str, switches: dict) -> List[dict]:
|
||||
"""Find the switch ports whose LLDP neighbor is this host."""
|
||||
found = []
|
||||
@@ -148,6 +219,10 @@ def device_detail(name: str, snapshot: dict, link_stats: dict, modem: Optional[d
|
||||
pass # the page renders the shared WAN / Fiber panel from /api/modem
|
||||
else:
|
||||
detail['ports'] = (switches.get(name) or {}).get('ports') or {}
|
||||
# The port panel compares each port with the server on the other end
|
||||
hosts = link_stats.get('hosts') or {}
|
||||
neighbors = set(_port_neighbors(detail['ports']))
|
||||
detail['neighbor_hosts'] = {h: ifaces for h, ifaces in hosts.items() if h in neighbors}
|
||||
detail['info'] = (link_stats.get('unifi_devices') or {}).get(name) or {}
|
||||
detail['updated'] = link_stats.get('updated')
|
||||
return detail
|
||||
|
||||
+44
-1
@@ -21,6 +21,7 @@ import requests
|
||||
from urllib3.exceptions import InsecureRequestWarning
|
||||
|
||||
import db
|
||||
import devices
|
||||
import modem as modem_api
|
||||
|
||||
|
||||
@@ -150,6 +151,7 @@ class UnifiClient:
|
||||
'latency_ms': w.get('latency'), 'availability': w.get('availability'),
|
||||
}
|
||||
return {
|
||||
'mac': d.get('mac', ''),
|
||||
'firmware': d.get('version', ''),
|
||||
'uptime_s': d.get('uptime'),
|
||||
'cpu_pct': number(stats.get('cpu')),
|
||||
@@ -174,6 +176,20 @@ class UnifiClient:
|
||||
],
|
||||
}
|
||||
|
||||
def get_wired_clients(self) -> List[dict]:
|
||||
"""Return wired clients as {mac, sw_mac, sw_port}; empty if unavailable."""
|
||||
try:
|
||||
url = f'{self.base_url}/proxy/network/api/s/{self.site_id}/stat/sta'
|
||||
resp = self.session.get(url, headers=self.headers, timeout=15)
|
||||
resp.raise_for_status()
|
||||
return [
|
||||
{'mac': c.get('mac', ''), 'sw_mac': c.get('sw_mac', ''), 'sw_port': c.get('sw_port')}
|
||||
for c in resp.json().get('data', []) if c.get('is_wired')
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f'UniFi client list error: {e}')
|
||||
return []
|
||||
|
||||
def get_system_events(self, since_ms: int, keys: tuple) -> Optional[List[dict]]:
|
||||
"""Return system log entries with the given keys, or None if the log is unavailable."""
|
||||
url = f'{self.base_url}/proxy/network/v2/api/site/{self.site_id}/system-log/all'
|
||||
@@ -251,7 +267,8 @@ class UnifiClient:
|
||||
# Build LLDP neighbor map (keyed by port_idx)
|
||||
lldp_map: Dict[int, dict] = {}
|
||||
for entry in dev.get('lldp_table', []):
|
||||
pidx = entry.get('lldp_port_idx')
|
||||
# Current UniFi Network versions use local_port_idx
|
||||
pidx = entry.get('local_port_idx', entry.get('lldp_port_idx'))
|
||||
if pidx is not None:
|
||||
lldp_map[int(pidx)] = {
|
||||
'chassis_id': entry.get('chassis_id', ''),
|
||||
@@ -730,6 +747,7 @@ 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)
|
||||
|
||||
return {
|
||||
'hosts': result_hosts,
|
||||
@@ -738,6 +756,31 @@ class LinkStatsCollector:
|
||||
'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."""
|
||||
host_macs: Dict[str, tuple] = {}
|
||||
host_ips: Dict[str, str] = {}
|
||||
# Bonds share their MAC with their member NICs, and LLDP runs on the NIC.
|
||||
results = sorted(
|
||||
self.prom.query('node_network_info'),
|
||||
key=lambda r: r['metric'].get('device', '').startswith('bond'),
|
||||
)
|
||||
for r in results:
|
||||
metric = r['metric']
|
||||
device, mac = metric.get('device', ''), metric.get('address', '').lower()
|
||||
instance = metric.get('instance', '')
|
||||
if not is_physical_interface(device) or not mac or mac == '00:00:00:00:00:00':
|
||||
continue
|
||||
ip = instance.split(':')[0]
|
||||
host = instance_map.get(instance, ip)
|
||||
host_macs.setdefault(mac, (host, device))
|
||||
host_ips[ip] = host
|
||||
device_macs = {
|
||||
info['mac'].lower(): name for name, info in unifi_devices.items() if info.get('mac')
|
||||
}
|
||||
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)
|
||||
|
||||
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."""
|
||||
dt = now - self._prev_unifi_time if self._prev_unifi_time > 0 else 0
|
||||
|
||||
@@ -88,7 +88,7 @@ async def shots():
|
||||
await pg.screenshot(path=f"{OUT}/device-host.png", full_page=True)
|
||||
await go(pg, "/device/" + quote("Pro 24 PoE"))
|
||||
await pg.screenshot(path=f"{OUT}/device-switch.png", full_page=True)
|
||||
await go(pg, "/inspector")
|
||||
await go(pg, "/device/USW-Aggregation")
|
||||
await pg.screenshot(path=f"{OUT}/inspector.png")
|
||||
await select_port(pg, "USW-Aggregation", 4)
|
||||
await pg.screenshot(path=f"{OUT}/inspector-port.png", full_page=False)
|
||||
@@ -132,7 +132,7 @@ async def gif():
|
||||
await snap(3)
|
||||
await pg.mouse.wheel(0, 500)
|
||||
await snap(2, 500)
|
||||
await go(pg, "/inspector", 4000)
|
||||
await go(pg, "/device/USW-Aggregation", 4000)
|
||||
await snap(2)
|
||||
await select_port(pg, "USW-Aggregation", 4)
|
||||
await snap(3)
|
||||
|
||||
+33
-71
@@ -1,8 +1,8 @@
|
||||
'use strict';
|
||||
/* global DEVICE_NAME, deviceUrl, refreshModem, _toIso */
|
||||
/* global DEVICE_NAME, deviceUrl, refreshModem, _toIso, fmtRate, fmtErrors, renderPortInspector, selectPortByIdx, highlightSelectedPort */
|
||||
|
||||
// Per-device page: overview, alerts, and a compact interface/port table whose
|
||||
// rows expand to show everything the old Link Debug page showed.
|
||||
// Per-device page: overview, alerts, and a compact interface/port table. Host
|
||||
// rows expand in place; switch and router rows open the port inspector panel.
|
||||
|
||||
const esc = s => lt.escHtml(String(s === null || s === undefined ? '' : s));
|
||||
|
||||
@@ -12,26 +12,11 @@ let _query = '';
|
||||
let _device = null;
|
||||
|
||||
// ── Formatting ───────────────────────────────────────────────────────
|
||||
function fmtRate(bytesPerSec) {
|
||||
if (bytesPerSec === null || bytesPerSec === undefined) return '–';
|
||||
const bps = bytesPerSec * 8;
|
||||
if (bps < 1e3) return bps.toFixed(0) + ' bps';
|
||||
if (bps < 1e6) return (bps / 1e3).toFixed(1) + ' Kbps';
|
||||
if (bps < 1e9) return (bps / 1e6).toFixed(2) + ' Mbps';
|
||||
return (bps / 1e9).toFixed(3) + ' Gbps';
|
||||
}
|
||||
|
||||
function fmtSpeed(mbps) {
|
||||
if (!mbps) return '–';
|
||||
return mbps >= 1000 ? (mbps / 1000).toFixed(0) + ' Gbps' : mbps + ' Mbps';
|
||||
}
|
||||
|
||||
function fmtErrors(rate) {
|
||||
if (rate === null || rate === undefined) return '–';
|
||||
if (rate < 0.001) return '<span class="val-good">0 /s</span>';
|
||||
return `<span class="val-crit">${rate.toFixed(3)} /s</span>`;
|
||||
}
|
||||
|
||||
function fmtUnit(value, unit, digits) {
|
||||
return value === null || value === undefined ? '–' : `${value.toFixed(digits)} ${unit}`;
|
||||
}
|
||||
@@ -160,28 +145,14 @@ function portRows(ports) {
|
||||
return Object.entries(ports)
|
||||
.sort(([, a], [, b]) => (a.port_idx || 0) - (b.port_idx || 0))
|
||||
.map(([name, d]) => {
|
||||
const neighbor = d.lldp && d.lldp.system_name;
|
||||
return {
|
||||
id: name, label: name, num: d.port_idx, up: !!d.up, data: d,
|
||||
neighbor: d.lldp ? { name: neighbor, port: d.lldp.port_id } : null,
|
||||
neighbor: d.lldp ? { name: d.lldp.system_name, port: d.lldp.port_id, chassis: d.lldp.chassis_id } : null,
|
||||
sub: [d.is_uplink ? 'uplink' : '', d.media || '', d.poe_power ? `PoE ${d.poe_power.toFixed(1)} W` : ''].filter(Boolean).join(' · '),
|
||||
detail: `
|
||||
<div class="link-stats-grid">
|
||||
${stat('Duplex', d.full_duplex === null || d.full_duplex === undefined ? '–' : d.full_duplex ? 'Full' : 'Half')}
|
||||
${stat('Auto-neg', d.autoneg === null || d.autoneg === undefined ? '–' : d.autoneg ? 'On' : 'Off')}
|
||||
${stat('TX Err/s', fmtErrors(d.tx_errs_rate))}
|
||||
${stat('RX Err/s', fmtErrors(d.rx_errs_rate))}
|
||||
${stat('TX Drop/s', fmtErrors(d.tx_drops_rate))}
|
||||
${stat('RX Drop/s', fmtErrors(d.rx_drops_rate))}
|
||||
${d.poe_power || d.poe_max_power ? stat('PoE', `${esc(d.poe_class || '–')} · max ${fmtUnit(d.poe_max_power, 'W', 1)}`) : ''}
|
||||
</div>
|
||||
${d.lldp ? `<div class="port-lldp">LLDP: ${esc(d.lldp.system_name || '')} (${esc(d.lldp.port_id || '')})${d.lldp.chassis_id ? ` · ${esc(d.lldp.chassis_id)}` : ''}${(d.lldp.mgmt_ips || []).length ? ` · ${esc(d.lldp.mgmt_ips.join(', '))}` : ''}</div>` : ''}
|
||||
${trafficBars(d)}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Down alone isn't a problem: most down ports are simply unused.
|
||||
function rowHasProblem(r) {
|
||||
return !!badges(r.data);
|
||||
}
|
||||
@@ -196,14 +167,14 @@ function renderTable(rows) {
|
||||
if (!shown.length) return '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Nothing matches this filter</div></div>';
|
||||
const isPorts = rows.some(r => r.num !== undefined);
|
||||
const body = shown.map(r => {
|
||||
const open = _openRows.has(r.id);
|
||||
const open = !isPorts && _openRows.has(r.id);
|
||||
const d = r.data;
|
||||
const neighbor = r.neighbor
|
||||
? (r.neighbor.name ? `<a class="device-link" href="${esc(deviceUrl(r.neighbor.name))}">${esc(r.neighbor.name)}</a>` : esc(r.neighbor.port || ''))
|
||||
? (r.neighbor.name ? `<a class="device-link" href="${esc(deviceUrl(r.neighbor.name))}">${esc(r.neighbor.name)}</a>` : esc(r.neighbor.chassis || r.neighbor.port || ''))
|
||||
: '–';
|
||||
return `
|
||||
<tr class="device-row${r.up ? '' : ' device-row--down'}">
|
||||
<td><button type="button" class="device-row-toggle" data-row="${esc(r.id)}" aria-expanded="${open}" aria-label="Details for ${esc(r.label)}">${open ? '▾' : '▸'}</button></td>
|
||||
<tr class="device-row${r.up ? '' : ' device-row--down'}"${isPorts ? ` data-port-idx="${esc(r.num)}"` : ''}>
|
||||
<td><button type="button" class="device-row-toggle" data-row="${esc(r.id)}"${isPorts ? '' : ` aria-expanded="${open}"`} aria-label="Details for ${esc(r.label)}">${open ? '▾' : '▸'}</button></td>
|
||||
<td><span class="dot-${r.up ? 'up' : 'down'}"></span></td>
|
||||
<td class="device-row-name">${r.num !== undefined ? `<span class="port-badge port-badge-num">#${esc(r.num)}</span> ` : ''}${esc(r.label)}<div class="device-row-sub">${esc(r.sub)}</div></td>
|
||||
<td class="device-num">${r.up ? fmtSpeed(d.speed_mbps) : '–'}</td>
|
||||
@@ -212,7 +183,7 @@ function renderTable(rows) {
|
||||
${isPorts ? `<td class="device-col-wide">${neighbor}</td>` : ''}
|
||||
<td>${badges(d) || (r.up ? '<span class="val-good">OK</span>' : '<span class="device-row-sub">down</span>')}</td>
|
||||
</tr>
|
||||
${open ? `<tr class="device-row-detail"><td colspan="${isPorts ? 8 : 7}">${r.detail}</td></tr>` : ''}`;
|
||||
${open ? `<tr class="device-row-detail"><td colspan="7">${r.detail}</td></tr>` : ''}`;
|
||||
}).join('');
|
||||
return `
|
||||
<div class="lt-table-wrap">
|
||||
@@ -272,7 +243,7 @@ function renderUplinks(d) {
|
||||
<td><span class="dot-${u.up ? 'up' : 'down'}"></span></td>
|
||||
<td>${esc(u.host_interface || '–')}</td>
|
||||
<td><a class="device-link" href="${esc(deviceUrl(u.switch))}">${esc(u.switch)}</a></td>
|
||||
<td>#${esc(u.port_idx)} ${esc(u.port)}</td>
|
||||
<td><a class="device-link" href="${esc(deviceUrl(u.switch))}#port-${esc(u.port_idx)}">#${esc(u.port_idx)} ${esc(u.port)}</a></td>
|
||||
<td>${fmtSpeed(u.speed_mbps)}</td>
|
||||
</tr>`).join('');
|
||||
return section('Connected to', `<div class="lt-table-wrap"><table class="lt-table">
|
||||
@@ -341,27 +312,19 @@ function currentRows() {
|
||||
|
||||
function renderInterfaces() {
|
||||
const wrap = document.getElementById('device-table-wrap');
|
||||
if (wrap) wrap.innerHTML = renderTable(currentRows());
|
||||
if (!wrap) return;
|
||||
wrap.innerHTML = renderTable(currentRows());
|
||||
highlightSelectedPort();
|
||||
}
|
||||
|
||||
function renderInterfaceSection(rows, title) {
|
||||
if (!rows.length) return '';
|
||||
const up = rows.filter(r => r.up).length;
|
||||
const problems = rows.filter(rowHasProblem).length;
|
||||
const pill = (value, label) => `<button type="button" class="pill${_filter === value ? ' active' : ''}" data-device-filter="${value}" aria-pressed="${_filter === value}">${label}</button>`;
|
||||
return section(`${title} · ${up} of ${rows.length} up`, `
|
||||
<div class="lt-toolbar device-toolbar">
|
||||
<div class="lt-toolbar-left">
|
||||
<div class="lt-search">
|
||||
<input type="search" class="lt-input lt-search-input lt-search-input--sm" id="device-table-search"
|
||||
placeholder="Filter…" autocomplete="off" aria-label="Filter ${esc(title.toLowerCase())}" value="${esc(_query)}">
|
||||
</div>
|
||||
<div class="sev-pills" role="group" aria-label="Filter by state">
|
||||
${pill('all', 'All')}${pill('up', 'Up')}${pill('problems', `Errors (${problems})`)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="device-table-wrap"></div>`);
|
||||
function updateInterfaceSection(rows, title) {
|
||||
const section = document.getElementById('device-ports');
|
||||
section.hidden = !rows.length;
|
||||
if (!rows.length) return;
|
||||
document.getElementById('device-ports-title').textContent =
|
||||
`${title} · ${rows.filter(r => r.up).length} of ${rows.length} up`;
|
||||
document.getElementById('device-table-search').setAttribute('aria-label', `Filter ${title.toLowerCase()}`);
|
||||
document.getElementById('device-error-count').textContent = rows.filter(rowHasProblem).length;
|
||||
}
|
||||
|
||||
function render(d) {
|
||||
@@ -373,29 +336,22 @@ function render(d) {
|
||||
|
||||
const unifi = d.source === 'unifi';
|
||||
document.getElementById('device-actions').innerHTML = `
|
||||
${d.ports && Object.keys(d.ports).length ? `<a class="lt-btn lt-btn-secondary lt-btn-sm" href="/inspector">⬡ Inspector</a>` : ''}
|
||||
<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress" data-sup-type="${unifi ? 'unifi_device' : 'host'}"
|
||||
data-sup-name="${esc(d.name)}" data-sup-detail="" aria-label="Suppress alerts for ${esc(d.name)}">🔕 Suppress</button>`;
|
||||
|
||||
// Keep focus and caret in the filter box across auto-refresh
|
||||
const active = document.activeElement && document.activeElement.id === 'device-table-search';
|
||||
const caret = active ? document.activeElement.selectionStart : 0;
|
||||
|
||||
const rows = currentRows();
|
||||
document.getElementById('device-root').innerHTML =
|
||||
renderOverview(d) +
|
||||
renderEvents(d.events) +
|
||||
renderWan(d) +
|
||||
renderUplinks(d) +
|
||||
renderOutlets(d) +
|
||||
renderInterfaceSection(rows, d.ports ? 'Ports' : 'Interfaces');
|
||||
renderOutlets(d);
|
||||
|
||||
const hasPorts = !!(d.ports && Object.keys(d.ports).length);
|
||||
document.getElementById('device-inspector').classList.toggle('has-chassis', hasPorts);
|
||||
updateInterfaceSection(currentRows(), d.ports ? 'Ports' : 'Interfaces');
|
||||
if (hasPorts) renderPortInspector(d);
|
||||
renderInterfaces();
|
||||
|
||||
if (active) {
|
||||
const box = document.getElementById('device-table-search');
|
||||
box.focus();
|
||||
box.setSelectionRange(caret, caret);
|
||||
}
|
||||
if (d.kind === 'modem') refreshModem();
|
||||
}
|
||||
|
||||
@@ -418,6 +374,12 @@ async function loadDevice() {
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
// Switch and router rows open the shared port panel, except when following a link
|
||||
const portRow = e.target.closest('tr.device-row[data-port-idx]');
|
||||
if (portRow && !e.target.closest('a')) {
|
||||
selectPortByIdx(_device.name, parseInt(portRow.dataset.portIdx, 10));
|
||||
return;
|
||||
}
|
||||
const toggle = e.target.closest('.device-row-toggle');
|
||||
if (toggle) {
|
||||
const id = toggle.dataset.row;
|
||||
|
||||
@@ -1,31 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Inspector – GANDALF{% endblock %}
|
||||
'use strict';
|
||||
/* global deviceUrl */
|
||||
/* exported renderPortInspector, fmtRate, fmtErrors */
|
||||
|
||||
{% block content %}
|
||||
// Port inspector for a switch or router's device page: front-panel diagram,
|
||||
// port detail panel, switch/server path check, and link diagnostics.
|
||||
|
||||
<div class="lt-page-header">
|
||||
<div>
|
||||
<h1 class="lt-page-title">Network Inspector</h1>
|
||||
<p class="g-page-sub">
|
||||
Visual switch chassis diagrams. Click a port to see detailed stats and LLDP path debug.
|
||||
<span id="inspector-updated" class="g-page-sub-aside"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inspector-layout">
|
||||
<div class="inspector-main" id="inspector-main" role="region" aria-label="Switch chassis diagrams">
|
||||
<div class="link-loading">Loading inspector data</div>
|
||||
</div>
|
||||
<div class="inspector-panel" id="inspector-panel" role="complementary" aria-label="Port detail panel">
|
||||
<div class="inspector-panel-inner" id="inspector-panel-inner"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const escHtml = s => lt.escHtml(s);
|
||||
|
||||
// ── Switch layout config ─────────────────────────────────────────────────
|
||||
@@ -49,7 +28,7 @@ const SWITCH_LAYOUTS = {
|
||||
};
|
||||
|
||||
// ── Formatting helpers ───────────────────────────────────────────────────
|
||||
function fmtSpeed(mbps) {
|
||||
function fmtShortSpeed(mbps) {
|
||||
if (!mbps) return '–';
|
||||
if (mbps >= 1000) return (mbps / 1000).toFixed(0) + 'G';
|
||||
return mbps + 'M';
|
||||
@@ -92,7 +71,7 @@ function portBlockState(d) {
|
||||
// ── Speed label helper ───────────────────────────────────────────────────
|
||||
function portSpeedLabel(port) {
|
||||
if (!port || !port.up) return '';
|
||||
const spd = port.speed; // speed in Mbps from UniFi API
|
||||
const spd = port.speed_mbps;
|
||||
if (!spd) return '';
|
||||
if (spd >= 10000) return '10G';
|
||||
if (spd >= 1000) return '1G';
|
||||
@@ -100,11 +79,18 @@ function portSpeedLabel(port) {
|
||||
return spd + 'M';
|
||||
}
|
||||
|
||||
// Blocks only fit a few characters of the neighbor, so hover text carries it in full
|
||||
function portTitle(port, idx) {
|
||||
if (!port) return `Port ${idx}`;
|
||||
const neighbor = port.lldp && (port.lldp.system_name || port.lldp.chassis_id);
|
||||
return escHtml(neighbor ? `${port.name} → ${neighbor}` : port.name);
|
||||
}
|
||||
|
||||
// ── Render a single port block element ──────────────────────────────────
|
||||
function portBlockHtml(idx, port, swName, sfpBlock) {
|
||||
const state = portBlockState(port);
|
||||
const numLabel = sfpBlock ? 'SFP' : idx;
|
||||
const title = port ? escHtml(port.name) : `Port ${idx}`;
|
||||
const title = portTitle(port, idx);
|
||||
const sfpCls = sfpBlock ? ' sfp-block' : '';
|
||||
const speedTxt = portSpeedLabel(port);
|
||||
// LLDP neighbor: first 6 chars of hostname
|
||||
@@ -116,7 +102,7 @@ function portBlockHtml(idx, port, swName, sfpBlock) {
|
||||
return `<div class="switch-port-block ${state}${sfpCls}"
|
||||
data-switch="${escHtml(swName)}" data-port-idx="${idx}"
|
||||
title="${title}" aria-label="${title}"
|
||||
data-action="select-port"><span class="port-num">${numLabel}</span>${speedHtml}${lldpHtml}</div>`;
|
||||
data-action="select-port" role="button" tabindex="0"><span class="port-num">${numLabel}</span>${speedHtml}${lldpHtml}</div>`;
|
||||
}
|
||||
|
||||
// ── Chassis legend HTML ──────────────────────────────────────────────────
|
||||
@@ -159,7 +145,7 @@ function renderChassis(swName, sw) {
|
||||
const isSfp = sfpPortSet.has(idx);
|
||||
const sfpCls = isSfp ? ' sfp-port' : '';
|
||||
const state = portBlockState(port);
|
||||
const title = port ? escHtml(port.name) : `Port ${idx}`;
|
||||
const title = portTitle(port, idx);
|
||||
const speedTxt = portSpeedLabel(port);
|
||||
const lldpName = (port && port.lldp && (port.lldp.system_name || port.lldp.chassis_id))
|
||||
? escHtml((port.lldp.system_name || port.lldp.chassis_id || '').slice(0, 6))
|
||||
@@ -169,7 +155,7 @@ function renderChassis(swName, sw) {
|
||||
chassisHtml += `<div class="switch-port-block ${state}${sfpCls}"
|
||||
data-switch="${escHtml(swName)}" data-port-idx="${idx}"
|
||||
title="${title}" aria-label="${title}"
|
||||
data-action="select-port"><span class="port-num">${idx}</span>${speedHtml}${lldpHtml}</div>`;
|
||||
data-action="select-port" role="button" tabindex="0"><span class="port-num">${idx}</span>${speedHtml}${lldpHtml}</div>`;
|
||||
}
|
||||
chassisHtml += '</div>';
|
||||
}
|
||||
@@ -195,10 +181,9 @@ function renderChassis(swName, sw) {
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="inspector-chassis" id="chassis-${escHtml(swName)}">
|
||||
<div class="inspector-chassis">
|
||||
<div class="chassis-header">
|
||||
<span class="chassis-name">${escHtml(swName)}</span>
|
||||
${sw.ip ? `<span class="chassis-ip">${escHtml(sw.ip)}</span>` : ''}
|
||||
<span class="chassis-name">Front panel</span>
|
||||
<span class="chassis-meta">${escHtml(meta)}</span>
|
||||
</div>
|
||||
<div class="chassis-body">
|
||||
@@ -214,27 +199,35 @@ function renderChassis(swName, sw) {
|
||||
let _selectedSwitch = null;
|
||||
let _selectedIdx = null;
|
||||
let _apiData = null;
|
||||
let _diagPollTimer = null;
|
||||
|
||||
// ── Port selection ───────────────────────────────────────────────────────
|
||||
function selectPort(el) {
|
||||
const swName = el.dataset.switch;
|
||||
const idx = parseInt(el.dataset.portIdx, 10);
|
||||
selectPortByIdx(el.dataset.switch, parseInt(el.dataset.portIdx, 10));
|
||||
}
|
||||
|
||||
function selectPortByIdx(swName, idx) {
|
||||
const sw = _apiData && _apiData.unifi_switches[swName];
|
||||
if (!sw || !buildPortIdxMap(sw.ports || {})[idx]) return;
|
||||
if (_diagPollTimer) { clearInterval(_diagPollTimer); _diagPollTimer = null; }
|
||||
document.querySelectorAll('.switch-port-block.selected')
|
||||
.forEach(e => e.classList.remove('selected'));
|
||||
el.classList.add('selected');
|
||||
_selectedSwitch = swName;
|
||||
_selectedIdx = idx;
|
||||
renderPanel(swName, idx);
|
||||
highlightSelectedPort();
|
||||
history.replaceState(null, '', `#port-${idx}`);
|
||||
// On narrow screens the panel sits below the front panel, possibly off screen
|
||||
if (window.matchMedia('(max-width: 900px)').matches) {
|
||||
document.getElementById('inspector-panel').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
if (_diagPollTimer) { clearInterval(_diagPollTimer); _diagPollTimer = null; }
|
||||
document.getElementById('inspector-panel').classList.remove('open');
|
||||
document.querySelectorAll('.switch-port-block.selected')
|
||||
.forEach(el => el.classList.remove('selected'));
|
||||
_selectedSwitch = null;
|
||||
_selectedIdx = null;
|
||||
highlightSelectedPort();
|
||||
history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||
}
|
||||
|
||||
// ── Render detail panel ──────────────────────────────────────────────────
|
||||
@@ -247,7 +240,7 @@ function renderPanel(swName, idx) {
|
||||
if (!d) return;
|
||||
|
||||
const upStr = d.up ? '<span class="val-good">UP</span>' : '<span class="val-crit">DOWN</span>';
|
||||
const speedStr = d.speed_mbps ? `<span class="val-cyan">${fmtSpeed(d.speed_mbps)}bps</span>` : '–';
|
||||
const speedStr = d.speed_mbps ? `<span class="val-cyan">${fmtShortSpeed(d.speed_mbps)}bps</span>` : '–';
|
||||
const duplexCls = d.full_duplex ? 'val-good' : (d.up ? 'val-warn' : 'val-neutral');
|
||||
const duplexStr = d.up ? `<span class="${duplexCls}">${d.full_duplex ? 'Full' : 'Half'}</span>` : '–';
|
||||
const autoneg = d.autoneg ? 'On' : 'Off';
|
||||
@@ -256,9 +249,8 @@ function renderPanel(swName, idx) {
|
||||
|
||||
// PoE section
|
||||
let poeHtml = '';
|
||||
if (d.poe_class != null) {
|
||||
if (d.poe_power || d.poe_max_power) {
|
||||
const poeMaxStr = d.poe_max_power != null ? ` / max ${d.poe_max_power.toFixed(1)}W` : '';
|
||||
const poeCurStr = (d.poe_power != null && d.poe_power > 0) ? ` / draw <span class="val-amber">${d.poe_power.toFixed(1)}W</span>` : '';
|
||||
poeHtml = `
|
||||
<div class="lt-divider"><span class="lt-divider-label">PoE</span></div>
|
||||
<div class="panel-row"><span class="panel-label">Class</span><span class="panel-val">class ${escHtml(String(d.poe_class))}${poeMaxStr}</span></div>
|
||||
@@ -289,11 +281,11 @@ function renderPanel(swName, idx) {
|
||||
// LLDP + path debug
|
||||
let lldpHtml = '';
|
||||
let pathHtml = '';
|
||||
if (d.lldp && d.lldp.system_name) {
|
||||
if (d.lldp) {
|
||||
const l = d.lldp;
|
||||
lldpHtml = `
|
||||
<div class="lt-divider"><span class="lt-divider-label">LLDP Neighbor</span></div>
|
||||
<div class="panel-row"><span class="panel-label">System</span><span class="panel-val val-cyan">${escHtml(l.system_name)}</span></div>
|
||||
<div class="lt-divider"><span class="lt-divider-label">${l.source === 'unifi-clients' ? 'Neighbor (UniFi client table)' : 'LLDP Neighbor'}</span></div>
|
||||
${l.system_name ? `<div class="panel-row"><span class="panel-label">System</span><span class="panel-val val-cyan"><a class="device-link" href="${escHtml(deviceUrl(l.system_name))}">${escHtml(l.system_name)}</a></span></div>` : ''}
|
||||
${l.port_id ? `<div class="panel-row"><span class="panel-label">Port</span><span class="panel-val">${escHtml(l.port_id)}</span></div>` : ''}
|
||||
${l.port_desc ? `<div class="panel-row"><span class="panel-label">Port Desc</span><span class="panel-val">${escHtml(l.port_desc)}</span></div>` : ''}
|
||||
${l.chassis_id ? `<div class="panel-row"><span class="panel-label">Chassis</span><span class="panel-val">${escHtml(l.chassis_id)}</span></div>` : ''}
|
||||
@@ -326,7 +318,15 @@ function renderPanel(swName, idx) {
|
||||
</div>
|
||||
<div class="diag-results" id="diag-results"></div>` : '';
|
||||
|
||||
// Auto-refresh re-renders this panel. A running poll holds these elements,
|
||||
// and results may be on screen, so carry them over for the same port.
|
||||
const inner = document.getElementById('inspector-panel-inner');
|
||||
const panelKey = `${swName}#${idx}`;
|
||||
const samePort = inner.dataset.port === panelKey;
|
||||
const keptStatus = samePort ? document.getElementById('diag-status') : null;
|
||||
const keptResults = samePort ? document.getElementById('diag-results') : null;
|
||||
const scrollTop = samePort ? inner.scrollTop : 0;
|
||||
inner.dataset.port = panelKey;
|
||||
inner.innerHTML = `
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
@@ -350,6 +350,9 @@ function renderPanel(swName, idx) {
|
||||
${pathHtml}
|
||||
${diagHtml}
|
||||
`;
|
||||
if (keptStatus) document.getElementById('diag-status')?.replaceWith(keptStatus);
|
||||
if (keptResults) document.getElementById('diag-results')?.replaceWith(keptResults);
|
||||
inner.scrollTop = scrollTop;
|
||||
|
||||
document.getElementById('inspector-panel').classList.add('open');
|
||||
}
|
||||
@@ -391,7 +394,7 @@ function buildPathDebug(swName, swPort, serverName, ifaceName, svrData) {
|
||||
const swSpd = swPort.speed_mbps, svrSpd = svrData.speed_mbps;
|
||||
const speedMismatch = swSpd && svrSpd && swSpd > 0 && svrSpd > 0 && swSpd !== svrSpd;
|
||||
const speedWarnHtml = speedMismatch
|
||||
? `<div class="path-mismatch-alert">⚠ SPEED MISMATCH — Switch: ${fmtSpeed(swSpd)} · Server: ${fmtSpeed(svrSpd)}</div>`
|
||||
? `<div class="path-mismatch-alert">⚠ SPEED MISMATCH — Switch: ${fmtShortSpeed(swSpd)} · Server: ${fmtShortSpeed(svrSpd)}</div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
@@ -401,7 +404,7 @@ function buildPathDebug(swName, swPort, serverName, ifaceName, svrData) {
|
||||
<div class="path-col">
|
||||
<div class="path-col-header">Switch</div>
|
||||
<div class="path-row"><span>Port</span><span>${escHtml(swPort.name)}</span></div>
|
||||
<div class="path-row"><span>Speed</span><span>${fmtSpeed(swPort.speed_mbps)}bps</span></div>
|
||||
<div class="path-row"><span>Speed</span><span>${fmtShortSpeed(swPort.speed_mbps)}bps</span></div>
|
||||
<div class="path-row"><span>TX</span><span>${fmtRate(swPort.tx_bytes_rate)}</span></div>
|
||||
<div class="path-row"><span>RX</span><span>${fmtRate(swPort.rx_bytes_rate)}</span></div>
|
||||
<div class="path-row"><span>TX Err</span><span class="${swErrTx}">${fmtErrors(swPort.tx_errs_rate)}</span></div>
|
||||
@@ -411,7 +414,7 @@ function buildPathDebug(swName, swPort, serverName, ifaceName, svrData) {
|
||||
<div class="path-col">
|
||||
<div class="path-col-header">Server: ${escHtml(serverName)}</div>
|
||||
<div class="path-row"><span>Iface</span><span>${escHtml(ifaceName)}</span></div>
|
||||
<div class="path-row"><span>Speed</span><span>${svrData.speed_mbps ? fmtSpeed(svrData.speed_mbps) + 'bps' : '–'}</span></div>
|
||||
<div class="path-row"><span>Speed</span><span>${svrData.speed_mbps ? fmtShortSpeed(svrData.speed_mbps) + 'bps' : '–'}</span></div>
|
||||
<div class="path-row"><span>TX</span><span>${fmtRate(svrData.tx_bytes_rate)}</span></div>
|
||||
<div class="path-row"><span>RX</span><span>${fmtRate(svrData.rx_bytes_rate)}</span></div>
|
||||
<div class="path-row"><span>TX Err</span><span class="${svrErrTx}">${fmtErrors(svrData.tx_errs_rate)}</span></div>
|
||||
@@ -422,67 +425,44 @@ function buildPathDebug(swName, swPort, serverName, ifaceName, svrData) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Render all switches ──────────────────────────────────────────────────
|
||||
function renderInspector(data) {
|
||||
_apiData = data;
|
||||
const main = document.getElementById('inspector-main');
|
||||
const switches = data.unifi_switches || {};
|
||||
|
||||
const updEl = document.getElementById('inspector-updated');
|
||||
if (updEl && data.updated) {
|
||||
const updMs = new Date(_toIso(data.updated));
|
||||
const ageMin = (Date.now() - updMs) / 60000;
|
||||
const timeStr = updMs.toLocaleTimeString();
|
||||
if (ageMin > 15) {
|
||||
updEl.innerHTML = `<span class="g-stale-warn" title="Data is ${Math.floor(ageMin)} minutes old — monitor may be down">⚠ Stale: ${timeStr}</span>`;
|
||||
} else {
|
||||
updEl.textContent = 'Updated: ' + timeStr;
|
||||
}
|
||||
// ── Page integration: device.js supplies this one device's data ──────────
|
||||
function renderPortInspector(detail) {
|
||||
_apiData = {
|
||||
unifi_switches: { [detail.name]: { ip: detail.ip, model: detail.model, ports: detail.ports || {} } },
|
||||
hosts: detail.neighbor_hosts || {},
|
||||
};
|
||||
const chassis = document.getElementById('device-chassis');
|
||||
chassis.innerHTML = renderChassis(detail.name, _apiData.unifi_switches[detail.name]);
|
||||
if (_selectedSwitch === detail.name && _selectedIdx !== null) {
|
||||
renderPanel(_selectedSwitch, _selectedIdx);
|
||||
} else if (_selectedSwitch === null) {
|
||||
openPortFromHash(detail.name);
|
||||
}
|
||||
highlightSelectedPort();
|
||||
}
|
||||
|
||||
if (!Object.keys(switches).length) {
|
||||
main.innerHTML = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-icon">⬡</div><div class="lt-empty-state-title">No switch data available</div><div class="lt-empty-state-body">Monitor may still be initialising.</div></div>';
|
||||
function openPortFromHash(swName) {
|
||||
const m = /^#port-(\d+)$/.exec(window.location.hash);
|
||||
if (m) selectPortByIdx(swName, parseInt(m[1], 10));
|
||||
}
|
||||
|
||||
function highlightSelectedPort() {
|
||||
document.querySelectorAll('.switch-port-block').forEach(el => {
|
||||
el.classList.toggle('selected', parseInt(el.dataset.portIdx, 10) === _selectedIdx);
|
||||
});
|
||||
document.querySelectorAll('tr.device-row[data-port-idx]').forEach(row => {
|
||||
row.classList.toggle('is-selected', parseInt(row.dataset.portIdx, 10) === _selectedIdx);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape' && document.getElementById('inspector-panel').classList.contains('open')) {
|
||||
closePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
main.innerHTML = Object.entries(switches)
|
||||
.map(([swName, sw]) => renderChassis(swName, sw))
|
||||
.join('');
|
||||
|
||||
// Re-apply selection highlight after re-render (dataset compare — no CSS escaping)
|
||||
if (_selectedSwitch && _selectedIdx !== null) {
|
||||
const block = Array.from(document.querySelectorAll('.switch-port-block')).find(
|
||||
el => el.dataset.switch === _selectedSwitch && parseInt(el.dataset.portIdx, 10) === _selectedIdx
|
||||
);
|
||||
if (block) {
|
||||
block.classList.add('selected');
|
||||
renderPanel(_selectedSwitch, _selectedIdx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fetch and render ─────────────────────────────────────────────────────
|
||||
async function loadInspector() {
|
||||
try {
|
||||
const data = await lt.api.get('/api/links');
|
||||
renderInspector(data);
|
||||
} catch (e) {
|
||||
document.getElementById('inspector-main').innerHTML =
|
||||
'<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-icon">✕</div><div class="lt-empty-state-title">Failed to load inspector data</div></div>';
|
||||
lt.toast.error('Failed to load inspector data');
|
||||
}
|
||||
}
|
||||
|
||||
loadInspector();
|
||||
const _inspInterval = window.gandalfSettings?.refreshInterval ?? 60;
|
||||
if (_inspInterval > 0) lt.autoRefresh.start(loadInspector, Math.max(_inspInterval, 15) * 1000);
|
||||
|
||||
window.onGandalfSettingsChanged = function(s) {
|
||||
lt.autoRefresh.stop();
|
||||
if (s.refreshInterval > 0) lt.autoRefresh.start(loadInspector, Math.max(s.refreshInterval, 15) * 1000);
|
||||
};
|
||||
lt.keys.on('Escape', () => {
|
||||
if (document.getElementById('inspector-panel').classList.contains('open')) closePanel();
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
const block = e.target.closest('[data-action="select-port"]');
|
||||
if (block) { e.preventDefault(); selectPort(block); }
|
||||
});
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
@@ -506,7 +486,6 @@ document.addEventListener('click', e => {
|
||||
});
|
||||
|
||||
// ── Link Diagnostics ─────────────────────────────────────────────────
|
||||
let _diagPollTimer = null;
|
||||
|
||||
function runDiagnostic(swName, portIdx) {
|
||||
const statusEl = document.getElementById('diag-status');
|
||||
@@ -613,7 +592,7 @@ function renderDiagnosticResults(d, container) {
|
||||
secs.carrier === '0' ? '<span class="diag-val-bad">NO</span>' : '–';
|
||||
const operstateVal = (secs.operstate || '?').toUpperCase();
|
||||
const opstateCls = secs.operstate === 'up' ? 'diag-val-good' : secs.operstate === 'down' ? 'diag-val-bad' : 'diag-val-warn';
|
||||
const speedVal = eth.speed_mbps ? `<span class="diag-val-good">${fmtSpeed(eth.speed_mbps)}bps</span>` : '<span class="diag-val-warn">–</span>';
|
||||
const speedVal = eth.speed_mbps ? `<span class="diag-val-good">${fmtShortSpeed(eth.speed_mbps)}bps</span>` : '<span class="diag-val-warn">–</span>';
|
||||
const duplexVal = eth.duplex === 'full' ? '<span class="diag-val-good">Full</span>' :
|
||||
eth.duplex === 'half' ? '<span class="diag-val-bad">Half</span>' : '–';
|
||||
const linkDetVal = eth.link_detected === true ? '<span class="diag-val-good">Yes</span>' :
|
||||
@@ -777,7 +756,7 @@ function renderDiagnosticResults(d, container) {
|
||||
<div class="diag-section-header">Switch Port Summary</div>
|
||||
<table class="diag-table">
|
||||
<tr><td>Status</td><td>${swPort.up ? '<span class="diag-val-good">UP</span>' : '<span class="diag-val-bad">DOWN</span>'}</td></tr>
|
||||
<tr><td>Speed</td><td>${swPort.speed_mbps ? fmtSpeed(swPort.speed_mbps) + 'bps' : '–'}</td></tr>
|
||||
<tr><td>Speed</td><td>${swPort.speed_mbps ? fmtShortSpeed(swPort.speed_mbps) + 'bps' : '–'}</td></tr>
|
||||
<tr><td>Duplex</td><td>${swPort.full_duplex ? 'Full' : (swPort.up ? '<span class="diag-val-bad">Half</span>' : '–')}</td></tr>
|
||||
<tr><td>TX Err</td><td>${fmtErrors(swPort.tx_errs_rate)}</td></tr>
|
||||
<tr><td>RX Err</td><td>${fmtErrors(swPort.rx_errs_rate)}</td></tr>
|
||||
@@ -820,5 +799,3 @@ function renderPowerBar(dbm, warnThreshold, critThreshold) {
|
||||
<span class="diag-power-zone-crit" style="left:${critPct.toFixed(1)}%"></span>
|
||||
</span>`;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1149,3 +1149,26 @@ a.device-link:hover { color: var(--text-accent); border-bottom-color: current
|
||||
/* Scroll inside the wrapper rather than squeezing every column to a few letters */
|
||||
#events-table { min-width: 760px; }
|
||||
}
|
||||
|
||||
/* ── Port inspector on the device page ────────────────────────────── */
|
||||
.device-inspector { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 14px 16px; align-items: start; }
|
||||
.device-chassis { grid-column: 1; min-width: 0; }
|
||||
.device-port-table { grid-column: 1; min-width: 0; }
|
||||
.device-inspector .inspector-panel { grid-column: 2; grid-row: 1 / span 2; position: sticky; top: 70px; }
|
||||
.device-inspector:not(.has-chassis) .inspector-panel,
|
||||
.device-inspector:not(.has-chassis) .device-chassis { display: none; }
|
||||
tr.device-row[data-port-idx] { cursor: pointer; }
|
||||
tr.device-row.is-selected { background: var(--bg-tertiary); box-shadow: inset 3px 0 0 var(--terminal-cyan, #0ff); }
|
||||
.device-chassis .switch-port-block,
|
||||
.device-chassis .switch-port-block.sfp-port,
|
||||
.device-chassis .switch-port-block.sfp-block { width: 50px; height: 50px; font-size: .68em; gap: 2px; }
|
||||
.device-chassis .port-num { font-size: 1.2em; }
|
||||
.device-chassis .port-speed { font-size: .85em; }
|
||||
.device-chassis .port-lldp { font-size: .8em; max-width: 46px; margin: 0; }
|
||||
@media (max-width: 900px) {
|
||||
.device-inspector { grid-template-columns: minmax(0, 1fr); }
|
||||
.device-inspector .inspector-panel { grid-column: 1; grid-row: auto; position: static; }
|
||||
.device-inspector .inspector-panel.open { width: 100%; }
|
||||
.device-inspector .inspector-panel-inner { width: 100%; max-height: none; }
|
||||
.device-chassis .chassis-ear-l, .device-chassis .chassis-ear-r { display: none; }
|
||||
}
|
||||
|
||||
+2
-11
@@ -37,9 +37,6 @@
|
||||
<a href="{{ url_for('index') }}"
|
||||
class="lt-nav-drawer-link{% if request.endpoint == 'index' %} active{% endif %}"
|
||||
{% if request.endpoint == 'index' %}aria-current="page"{% endif %}>Dashboard</a>
|
||||
<a href="{{ url_for('inspector') }}"
|
||||
class="lt-nav-drawer-link{% if request.endpoint == 'inspector' %} active{% endif %}"
|
||||
{% if request.endpoint == 'inspector' %}aria-current="page"{% endif %}>Inspector</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 %}"
|
||||
@@ -82,11 +79,6 @@
|
||||
{% if request.endpoint == 'index' %}aria-current="page"{% endif %}>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="{{ url_for('inspector') }}"
|
||||
class="lt-nav-link{% if request.endpoint == 'inspector' %} active{% endif %}"
|
||||
{% if request.endpoint == 'inspector' %}aria-current="page"{% endif %}>
|
||||
Inspector
|
||||
</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 %}"
|
||||
@@ -179,7 +171,7 @@
|
||||
<span class="lt-footer-sep">|</span>
|
||||
<span class="lt-footer-hint"><span class="lt-footer-key">[ S ]</span> SUPPRESS</span>
|
||||
<span class="lt-footer-sep">|</span>
|
||||
{% elif request.endpoint in ('device_page', 'inspector') %}
|
||||
{% elif request.endpoint == 'device_page' %}
|
||||
<span class="lt-footer-hint"><span class="lt-footer-key">[ R ]</span> REFRESH</span>
|
||||
<span class="lt-footer-sep">|</span>
|
||||
{% endif %}
|
||||
@@ -255,7 +247,7 @@
|
||||
<thead><tr><th>Shortcut</th><th>Action</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Ctrl / ⌘ + K</td><td>Command palette</td></tr>
|
||||
<tr><td>R</td><td>Refresh data (Dashboard / Device / Inspector)</td></tr>
|
||||
<tr><td>R</td><td>Refresh data (Dashboard / Device)</td></tr>
|
||||
<tr><td>/</td><td>Search devices (Dashboard)</td></tr>
|
||||
<tr><td>S</td><td>Quick-suppress alert (Dashboard)</td></tr>
|
||||
<tr><td>*</td><td>Open settings</td></tr>
|
||||
@@ -324,7 +316,6 @@
|
||||
// Command palette
|
||||
lt.cmdPalette.init([
|
||||
{ id: 'nav-dashboard', group: 'Navigate', icon: '~', label: 'Dashboard', action: function() { window.location.href = '/'; } },
|
||||
{ id: 'nav-inspector', group: 'Navigate', icon: '⬡', label: 'Inspector', action: function() { window.location.href = '/inspector'; } },
|
||||
{ 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'; } },
|
||||
|
||||
@@ -16,10 +16,40 @@
|
||||
|
||||
<div id="device-root"><div class="link-loading">Loading device</div></div>
|
||||
|
||||
<!-- Outside device-root: auto-refresh must not reset the filter or port panel -->
|
||||
<section class="g-section" id="device-ports" hidden>
|
||||
<div class="g-section-header"><h2 class="g-section-title" id="device-ports-title">Ports</h2></div>
|
||||
<div class="device-inspector" id="device-inspector">
|
||||
<div class="device-chassis" id="device-chassis" role="region" aria-label="Front panel"></div>
|
||||
<div class="inspector-panel" id="inspector-panel" role="complementary" aria-label="Port detail panel">
|
||||
<div class="inspector-panel-inner" id="inspector-panel-inner"></div>
|
||||
</div>
|
||||
<div class="lt-frame device-port-table">
|
||||
<span class="lt-frame-bl">╚</span>
|
||||
<span class="lt-frame-br">╝</span>
|
||||
<div class="lt-toolbar device-toolbar">
|
||||
<div class="lt-toolbar-left">
|
||||
<div class="lt-search">
|
||||
<input type="search" class="lt-input lt-search-input lt-search-input--sm" id="device-table-search"
|
||||
placeholder="Filter…" autocomplete="off" aria-label="Filter ports">
|
||||
</div>
|
||||
<div class="sev-pills" role="group" aria-label="Filter by state">
|
||||
<button type="button" class="pill active" data-device-filter="all" aria-pressed="true">All</button>
|
||||
<button type="button" class="pill" data-device-filter="up" aria-pressed="false">Up</button>
|
||||
<button type="button" class="pill" data-device-filter="problems" aria-pressed="false">Errors (<span id="device-error-count">0</span>)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="device-table-wrap"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% include "_modem_panel.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>const DEVICE_NAME = {{ device_name | tojson }};</script>
|
||||
<script src="{{ url_for('static', filename='port-inspector.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='device.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
+32
-1
@@ -58,7 +58,19 @@ class TestPagesAndNav:
|
||||
html = client.get('/device/%3Cscript%3Ex', headers=AUTH).get_data(as_text=True)
|
||||
assert '<script>x' not in html
|
||||
|
||||
def test_link_data_api_remains_for_inspector(self, client):
|
||||
def test_old_inspector_url_redirects_home(self, client):
|
||||
resp = client.get('/inspector', headers=AUTH)
|
||||
assert resp.status_code == 302 and resp.headers['Location'].endswith('/')
|
||||
|
||||
def test_nav_has_no_inspector_link(self, client):
|
||||
assert '>Inspector<' not in client.get('/', headers=AUTH).get_data(as_text=True)
|
||||
assert 'Inspector' not in client.get('/device/large1', headers=AUTH).get_data(as_text=True).split('</nav>')[0]
|
||||
|
||||
def test_device_page_loads_the_port_inspector(self, client):
|
||||
html = client.get('/device/large1', headers=AUTH).get_data(as_text=True)
|
||||
assert 'port-inspector.js' in html and 'id="inspector-panel"' in html
|
||||
|
||||
def test_link_data_api_remains(self, client):
|
||||
assert client.get('/api/links', headers=AUTH).status_code == 200
|
||||
|
||||
|
||||
@@ -91,3 +103,22 @@ class TestDatabaseTimeZone:
|
||||
with patch.object(real_db.pymysql, 'connect') as connect:
|
||||
real_db._new_conn({'host': 'h', 'user': 'u', 'password': 'p', 'name': 'n'})
|
||||
assert connect.call_args.kwargs['init_command'] == "SET time_zone='+00:00'"
|
||||
|
||||
|
||||
class TestDiagnoseWithResolvedNeighbor:
|
||||
LINKS = json.dumps({
|
||||
'hosts': {'large1': {'enp35s0': {'host_ip': '10.0.0.2'}}},
|
||||
'unifi_switches': {'sw': {'ports': {'Port 4': {
|
||||
'port_idx': 4, 'up': True,
|
||||
'lldp': {'system_name': 'large1', 'port_id': 'enp35s0', 'chassis_id': 'aa:bb:cc:dd:ee:ff'},
|
||||
}}}},
|
||||
})
|
||||
|
||||
def test_port_with_a_named_neighbor_starts_a_job(self, client):
|
||||
STATE['link_stats'], saved = self.LINKS, STATE['link_stats']
|
||||
try:
|
||||
with patch.object(gandalf.threading, 'Thread'):
|
||||
resp = client.post('/api/diagnose', json={'switch_name': 'sw', 'port_idx': 4}, headers=AUTH)
|
||||
finally:
|
||||
STATE['link_stats'] = saved
|
||||
assert resp.status_code == 200 and resp.get_json()['job_id']
|
||||
|
||||
@@ -122,6 +122,87 @@ class TestDetail:
|
||||
def test_switch_has_ports(self):
|
||||
assert set(devices.device_detail('24RJ45-POE', SNAPSHOT, LINKS, MODEM)['ports']) == {'Port 3', 'Port 4'}
|
||||
|
||||
def test_switch_gets_only_its_lldp_neighbors_for_the_path_check(self):
|
||||
links = dict(LINKS, hosts=dict(LINKS['hosts'], other={'eth9': {}}))
|
||||
d = devices.device_detail('24RJ45-POE', SNAPSHOT, links, MODEM)
|
||||
assert set(d['neighbor_hosts']) == {'large1'}
|
||||
assert 'eth0' in d['neighbor_hosts']['large1']
|
||||
|
||||
def test_router_has_info_even_without_port_data(self):
|
||||
d = devices.device_detail('Dream Machine Pro', SNAPSHOT, LINKS, MODEM)
|
||||
assert d['info']['firmware'] == '6.0.17' and d['ports'] == {}
|
||||
|
||||
|
||||
class TestLldpResolution:
|
||||
HOSTS = {'9c:6b:00:8a:17:e6': ('micro1', 'enp1s0')}
|
||||
DEVS = {'78:45:58:b1:bd:3a': '24RJ45-POE'}
|
||||
IPS = {'10.0.0.9': 'storage-01'}
|
||||
|
||||
def resolve(self, **lldp):
|
||||
sw = {'s': {'ports': {'p': {'lldp': lldp}}}}
|
||||
devices.resolve_lldp_neighbors(sw, self.HOSTS, self.DEVS, self.IPS)
|
||||
return sw['s']['ports']['p']['lldp']
|
||||
|
||||
def test_port_mac_gives_host_and_interface(self):
|
||||
got = self.resolve(chassis_id='aa:aa:aa:aa:aa:aa', port_id='9C:6B:00:8A:17:E6')
|
||||
assert (got['system_name'], got['port_id']) == ('micro1', 'enp1s0')
|
||||
|
||||
def test_chassis_mac_keeps_a_named_port(self):
|
||||
got = self.resolve(chassis_id='9c:6b:00:8a:17:e6', port_id='enp9s0')
|
||||
assert (got['system_name'], got['port_id']) == ('micro1', 'enp9s0')
|
||||
|
||||
def test_unifi_device_by_chassis(self):
|
||||
got = self.resolve(chassis_id='78:45:58:b1:bd:3a', port_id='Port 26')
|
||||
assert (got['system_name'], got['port_id']) == ('24RJ45-POE', 'Port 26')
|
||||
|
||||
def test_management_ip_fallback(self):
|
||||
got = self.resolve(chassis_id='aa:aa:aa:aa:aa:aa', port_id='x', mgmt_ips=['10.0.0.9'])
|
||||
assert got['system_name'] == 'storage-01'
|
||||
|
||||
def test_unknown_neighbor_stays_unnamed(self):
|
||||
got = self.resolve(chassis_id='aa:aa:aa:aa:aa:aa', port_id='aa:aa:aa:aa:aa:ab')
|
||||
assert got['system_name'] == ''
|
||||
|
||||
def test_a_reported_name_is_kept(self):
|
||||
got = self.resolve(system_name='lldp-name', chassis_id='78:45:58:b1:bd:3a', port_id='x')
|
||||
assert got['system_name'] == 'lldp-name'
|
||||
|
||||
def test_port_without_lldp_is_left_alone(self):
|
||||
sw = {'s': {'ports': {'p': {'lldp': None}}}}
|
||||
devices.resolve_lldp_neighbors(sw, self.HOSTS, self.DEVS, self.IPS)
|
||||
assert sw['s']['ports']['p']['lldp'] is None
|
||||
|
||||
|
||||
class TestClientLocations:
|
||||
HOSTS = {'aa:aa:aa:aa:aa:01': ('large1', 'enp35s0')}
|
||||
DEVS = {'bb:bb:bb:bb:bb:01': 'sw'}
|
||||
|
||||
def apply(self, port, sw_port=10):
|
||||
switches = {'sw': {'ports': {'Port 10': dict({'port_idx': 10}, **port)}}}
|
||||
client = {'mac': 'AA:AA:AA:AA:AA:01', 'sw_mac': 'bb:bb:bb:bb:bb:01', 'sw_port': sw_port}
|
||||
devices.apply_client_locations(switches, [client], self.HOSTS, self.DEVS)
|
||||
return switches['sw']['ports']['Port 10']['lldp']
|
||||
|
||||
def test_server_without_lldp_is_located(self):
|
||||
got = self.apply({'lldp': None})
|
||||
assert (got['system_name'], got['port_id'], got['source']) == ('large1', 'enp35s0', 'unifi-clients')
|
||||
|
||||
def test_uplink_port_is_skipped(self):
|
||||
assert self.apply({'lldp': None, 'is_uplink': True}) is None
|
||||
|
||||
def test_named_lldp_neighbor_is_kept(self):
|
||||
assert self.apply({'lldp': {'system_name': 'other'}})['system_name'] == 'other'
|
||||
|
||||
def test_unnamed_lldp_entry_is_replaced(self):
|
||||
assert self.apply({'lldp': {'system_name': '', 'chassis_id': 'x'}})['system_name'] == 'large1'
|
||||
|
||||
def test_other_port_is_untouched(self):
|
||||
assert self.apply({'lldp': None}, sw_port=11) is None
|
||||
|
||||
def test_non_host_client_is_ignored(self):
|
||||
switches = {'sw': {'ports': {'Port 10': {'port_idx': 10, 'lldp': None}}}}
|
||||
devices.apply_client_locations(
|
||||
switches, [{'mac': 'cc:cc:cc:cc:cc:cc', 'sw_mac': 'bb:bb:bb:bb:bb:01', 'sw_port': 10}],
|
||||
self.HOSTS, self.DEVS,
|
||||
)
|
||||
assert switches['sw']['ports']['Port 10']['lldp'] is None
|
||||
|
||||
@@ -373,3 +373,44 @@ class TestUnifiDeviceDetail:
|
||||
c = monitor.UnifiClient({'controller': 'https://u.test', 'api_key': 'k'})
|
||||
c.session.post = MagicMock(side_effect=OSError('boom'))
|
||||
assert c.get_system_events(0, modem_api.WAN_LOG_KEYS) is None
|
||||
|
||||
|
||||
class TestLldpCollection:
|
||||
def _client_with(self, entry):
|
||||
c = monitor.UnifiClient({'controller': 'https://u.test', 'api_key': 'k'})
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = {'data': [{
|
||||
'type': 'usw', 'name': 'sw', 'lldp_table': [entry],
|
||||
'port_table': [{'port_idx': 3, 'name': 'Port 3'}],
|
||||
}]}
|
||||
c.session.get = MagicMock(return_value=resp)
|
||||
return c
|
||||
|
||||
def test_current_unifi_field_name(self):
|
||||
ports = self._client_with({'local_port_idx': 3, 'chassis_id': 'aa'}).get_switch_ports()['sw']['ports']
|
||||
assert ports['Port 3']['lldp']['chassis_id'] == 'aa'
|
||||
|
||||
def test_older_unifi_field_name(self):
|
||||
ports = self._client_with({'lldp_port_idx': 3, 'chassis_id': 'aa'}).get_switch_ports()['sw']['ports']
|
||||
assert ports['Port 3']['lldp']['chassis_id'] == 'aa'
|
||||
|
||||
def test_bond_does_not_hide_its_member_nic(self):
|
||||
collector = monitor.LinkStatsCollector.__new__(monitor.LinkStatsCollector)
|
||||
collector.prom = MagicMock()
|
||||
collector.unifi = MagicMock()
|
||||
collector.unifi.get_wired_clients.return_value = []
|
||||
mac = 'aa:bb:cc:dd:ee:ff'
|
||||
collector.prom.query.return_value = [
|
||||
{'metric': {'device': 'bond0', 'address': mac, 'instance': '10.0.0.1:9100'}},
|
||||
{'metric': {'device': 'eth0', 'address': mac, 'instance': '10.0.0.1:9100'}},
|
||||
{'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'})
|
||||
lldp = switches['sw']['ports']['p']['lldp']
|
||||
assert (lldp['system_name'], lldp['port_id']) == ('alpha', 'eth0')
|
||||
|
||||
def test_client_list_failure_is_empty(self):
|
||||
c = monitor.UnifiClient({'controller': 'https://u.test', 'api_key': 'k'})
|
||||
c.session.get = MagicMock(side_effect=OSError('boom'))
|
||||
assert c.get_wired_clients() == []
|
||||
|
||||
Reference in New Issue
Block a user