Files
jaredandClaude Opus 5.5 e32499b7f6
Lint / Python (flake8) (pull_request) Successful in 2m6s
Lint / JS (eslint) (pull_request) Successful in 18s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 2m11s
Test / Python Tests (pytest) (pull_request) Successful in 2m20s
Lint / JS (eslint) (push) Successful in 10s
Lint / Python (flake8) (push) Successful in 1m58s
Lint / Notify on failure (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m58s
Lint / Deploy (push) Successful in 4s
Test / Python Tests (pytest) (push) Successful in 58s
Add AT&T modem monitoring and device pages; replace Link Debug
Modem and WAN:
- Poll the BGW320 status pages read-only for light levels, PON and WAN
  state, with a WAN / Fiber panel, 7-day Rx trend, and alerts on WAN down,
  optical limits, and Rx drift
- Read WAN outages and Smart Power restarts from the UniFi system log,
  since outages can be over between 5 minute polls

Device pages:
- Add device search to the dashboard and a page per host, switch, router,
  AP, PDU, or modem
- Move all Link Debug data into expandable rows there; /links redirects
- Collect UDM ports and UniFi device details (firmware, load, WAN, outlets)
- Reorder the dashboard

Monitoring fixes:
- Alert when a configured host stops reporting: its series disappear
  rather than reporting down, so a dead host never raised an alert
- Alert on Prometheus outages instead of blanking the dashboard
- Treat a failed Pulse ping as unknown rather than host unreachable
- Stop new switch ports reporting lifetime bytes as one interval's rate
- Run database sessions in UTC: other server time zones shifted displayed
  ages and timed suppression expiries

Also log every avatar 404 reason, add a favicon, and correct the WAN link
labels (1G on Port 9, not 10G SFP+).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:40:17 -05:00

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