154 lines
6.6 KiB
Python
154 lines
6.6 KiB
Python
"""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())
|
||
|
|
|
||
|
|
|
||
|
|
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 {}
|
||
|
|
detail['info'] = (link_stats.get('unifi_devices') or {}).get(name) or {}
|
||
|
|
detail['updated'] = link_stats.get('updated')
|
||
|
|
return detail
|