Files
gandalf/devices.py
T

229 lines
9.6 KiB
Python
Raw Normal View History

"""Device inventory built from the monitor's cached state.
Pure functions with no I/O: app.py passes in the JSON blobs the monitor stored,
so search and the per-device pages always agree about what a device is.
"""
import re
from typing import Dict, List, Optional
# UniFi reports PDUs as switches or APs, so model codes take priority over type.
_PDU_MODELS = {'USPPDUP', 'UP6', 'USPDA2B'}
_KIND_BY_TYPE = {
'usw': 'switch', 'udm': 'router', 'ugw': 'router', 'uxg': 'router',
'uap': 'ap', 'usp': 'pdu',
}
def unifi_kind(unifi_type: str, model: str) -> str:
if model in _PDU_MODELS:
return 'pdu'
return _KIND_BY_TYPE.get((unifi_type or '').lower(), 'device')
def _mac_forms(mac: str) -> List[str]:
"""Return a MAC with and without separators, so either spelling is searchable."""
return [mac, re.sub(r'[^0-9a-fA-F]', '', mac)] if mac else []
def _search_text(*parts) -> str:
flat = []
for p in parts:
if isinstance(p, (list, tuple, set)):
flat.extend(str(x) for x in p if x)
elif p:
flat.append(str(p))
return ' '.join(flat).lower()
def _port_neighbors(ports: dict) -> List[str]:
return [p['lldp']['system_name'] for p in ports.values()
if (p.get('lldp') or {}).get('system_name')]
def build_index(snapshot: dict, link_stats: dict, modem: Optional[dict]) -> List[dict]:
"""Return every known device as {name, kind, status, ip, model, ..., search}."""
snapshot = snapshot or {}
link_stats = link_stats or {}
devices: Dict[str, dict] = {}
for name, host in (snapshot.get('hosts') or {}).items():
link_ifaces = (link_stats.get('hosts') or {}).get(name) or {}
ifaces = sorted(set(host.get('interfaces') or {}) | set(link_ifaces))
devices[name] = {
'name': name, 'kind': 'host', 'status': host.get('status', 'unknown'),
'ip': host.get('ip', ''), 'mac': '', 'model': '', 'source': host.get('source', ''),
'interface_count': len(ifaces),
'search': _search_text(name, host.get('ip'), 'host server', ifaces),
}
# Prometheus also sees hosts that aren't in config.json, e.g. LXC app servers.
# They have link stats but no alerting, so their state is reported as unknown.
for name, ifaces in (link_stats.get('hosts') or {}).items():
if name not in devices:
ip = next((i.get('host_ip') for i in ifaces.values() if i.get('host_ip')), '')
devices[name] = {
'name': name, 'kind': 'host', 'status': 'unknown', 'ip': ip, 'mac': '', 'model': '',
'source': 'prometheus', 'interface_count': len(ifaces),
'search': _search_text(name, ip, 'host server', list(ifaces)),
}
switches = link_stats.get('unifi_switches') or {}
for d in snapshot.get('unifi') or []:
name = d.get('name', '')
ports = (switches.get(name) or {}).get('ports') or {}
kind = unifi_kind(d.get('type', ''), d.get('model', ''))
devices[name] = {
'name': name, 'kind': kind, 'status': 'up' if d.get('connected') else 'down',
'ip': d.get('ip', ''), 'mac': d.get('mac', ''), 'model': d.get('model', ''),
'source': 'unifi', 'interface_count': len(ports),
'search': _search_text(
name, d.get('ip'), _mac_forms(d.get('mac', '')), d.get('model'),
d.get('type'), kind, list(ports), _port_neighbors(ports),
),
}
# A switch can have link stats while the device list is unavailable this cycle.
for name, sw in switches.items():
if name not in devices:
ports = sw.get('ports') or {}
devices[name] = {
'name': name, 'kind': unifi_kind('usw', sw.get('model', '')), 'status': 'unknown',
'ip': sw.get('ip', ''), 'mac': '', 'model': sw.get('model', ''),
'source': 'unifi', 'interface_count': len(ports),
'search': _search_text(name, sw.get('ip'), sw.get('model'), list(ports), _port_neighbors(ports)),
}
if modem and modem.get('name'):
name = modem['name']
if not modem.get('reachable'):
status = 'unknown'
else:
status = 'down' if modem.get('wan_problems') else 'up'
devices[name] = {
'name': name, 'kind': 'modem', 'status': status,
'ip': modem.get('lan_ip', ''), 'mac': '', 'model': modem.get('model', ''),
'source': 'modem', 'interface_count': 0,
'search': _search_text(
name, modem.get('lan_ip'), modem.get('wan_ip'), modem.get('model'),
'modem gateway fiber wan at&t att',
),
}
return sorted(devices.values(), key=lambda d: d['name'].lower())
2026-11-05 10:11:11 -05:00
_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 = []
for sw_name, sw in (switches or {}).items():
for port_name, port in (sw.get('ports') or {}).items():
lldp = port.get('lldp') or {}
if lldp.get('system_name') == name:
found.append({
'switch': sw_name, 'port': port_name, 'port_idx': port.get('port_idx'),
'host_interface': lldp.get('port_id', ''), 'up': port.get('up', False),
'speed_mbps': port.get('speed_mbps', 0),
})
return found
def device_detail(name: str, snapshot: dict, link_stats: dict, modem: Optional[dict]) -> Optional[dict]:
"""Return everything known about one device, or None if it doesn't exist."""
match = next((d for d in build_index(snapshot, link_stats, modem) if d['name'] == name), None)
if match is None:
return None
link_stats = link_stats or {}
detail = {k: v for k, v in match.items() if k != 'search'}
switches = link_stats.get('unifi_switches') or {}
if match['kind'] == 'host':
states = ((snapshot or {}).get('hosts') or {}).get(name, {}).get('interfaces') or {}
link_ifaces = (link_stats.get('hosts') or {}).get(name) or {}
interfaces = {i: {'state': s} for i, s in states.items()}
for iface, stats in link_ifaces.items():
interfaces.setdefault(iface, {}).update(stats)
detail['interfaces'] = interfaces
detail['uplinks'] = uplinks_for_host(name, switches)
elif match['kind'] == 'modem':
pass # the page renders the shared WAN / Fiber panel from /api/modem
else:
detail['ports'] = (switches.get(name) or {}).get('ports') or {}
2026-11-05 10:11:11 -05:00
# 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