2026-11-04 23:40:17 -05:00
|
|
|
"""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',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-11-04 23:40:17 -05:00
|
|
|
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}
|
2026-11-04 23:40:17 -05:00
|
|
|
detail['info'] = (link_stats.get('unifi_devices') or {}).get(name) or {}
|
|
|
|
|
detail['updated'] = link_stats.get('updated')
|
|
|
|
|
return detail
|