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

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:
2026-11-05 10:11:11 -05:00
co-authored by Claude Opus 5.5
parent e32499b7f6
commit bdcfb76c25
13 changed files with 484 additions and 226 deletions
+75
View File
@@ -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