Files
gandalf/tests/test_monitor.py
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

376 lines
16 KiB
Python

"""Tests for monitor.py alert logic: database and external services are mocked."""
import json
import os
import sys
import time
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
import modem as modem_api # noqa: E402
import monitor # noqa: E402
HOST_A, HOST_B = '10.0.0.1:9100', '10.0.0.2:9100'
@pytest.fixture
def db():
with patch.object(monitor, 'db') as mock_db:
mock_db.check_suppressed.return_value = False
mock_db.get_baseline.return_value = {}
# Second consecutive failure and no ticket yet, unless a test overrides it
mock_db.upsert_event.return_value = (1, False, 2, None)
mock_db.get_modem_rx_values.return_value = []
mock_db.get_state.return_value = None
yield mock_db
@pytest.fixture
def mon(db):
m = monitor.NetworkMonitor.__new__(monitor.NetworkMonitor)
m.cfg = {}
m.fail_thresh = 2
m.cluster_thresh = 2
m.cluster_name = 'test-cluster'
m.tickets = MagicMock()
m.tickets.create.return_value = '123'
m.prom = MagicMock(url='http://prom.test')
m._instance_map = {HOST_A: 'alpha', HOST_B: 'beta'}
m._last_iface_states = {}
m.modem = MagicMock(base_url='http://modem.test')
m.modem_name = 'att-modem'
m.modem_lan_ip = '192.168.1.254'
m.modem_fail_thresh = 2
m.rx_drift_db = 3.0
m.rx_drift_min_samples = 288
m._prev_modem_counters = None
m._prev_modem_time = 0.0
return m
def upserted(db):
return [(c.args[0], c.args[3], c.args[4]) for c in db.upsert_event.call_args_list]
def healthy_modem(rx=-16.9, rx_status=None):
def metric(value, alarm_low, alarm_high, warn_low, warn_high):
return {'value': value, 'alarm_low': alarm_low, 'alarm_high': alarm_high,
'warn_low': warn_low, 'warn_high': warn_high,
'alarm_flag': False, 'warn_flag': False}
return {
'broadband_state': 'Up', 'optical_state': 'Up', 'pon_state': 'OPERATION (O5)',
'counters': {'rx_bytes': 1},
'optics': {
'rx_power': metric(rx, -29.2, -9.0, -27.9, -10.0),
'tx_power': metric(3.3, -1.0, 5.9, 0.0, 4.9),
'temperature': metric(34.0, -50.0, 95.0, -45.0, 90.0),
},
}
class TestDeadHostDetection:
def test_host_missing_from_prometheus_alerts_and_tickets(self, mon, db):
mon._process_interfaces({HOST_A: {'eth0': True}}, [])
assert ('host_metrics_missing', 'beta', HOST_B) in upserted(db)
assert 'No metrics' in mon.tickets.create.call_args.args[0]
def test_host_reporting_resolves_the_event(self, mon, db):
mon._process_interfaces({HOST_A: {'eth0': True}, HOST_B: {'eth0': True}}, [])
db.resolve_event.assert_any_call('host_metrics_missing', 'beta', HOST_B)
assert not db.upsert_event.called
def test_no_ticket_before_threshold(self, mon, db):
db.upsert_event.return_value = (1, True, 1, None)
mon._process_interfaces({HOST_A: {'eth0': True}}, [])
assert not mon.tickets.create.called
def test_suppressed_host_gets_no_ticket(self, mon, db):
db.check_suppressed.return_value = True
mon._process_interfaces({HOST_A: {'eth0': True}}, [])
assert not mon.tickets.create.called
def test_missing_hosts_count_toward_cluster_alert(self, mon, db):
mon._instance_map['10.0.0.3:9100'] = 'gamma'
mon._process_interfaces({HOST_A: {'eth0': True}}, [])
assert ('cluster_network_issue', 'test-cluster', '') in upserted(db)
def test_dead_host_shows_as_down_on_dashboard(self, mon):
snap = mon._collect_snapshot({HOST_A: {'eth0': True}})
assert snap['hosts']['alpha']['status'] == 'up'
assert snap['hosts']['beta']['status'] == 'down'
class TestPrometheusOutage:
def test_empty_result_means_unavailable(self):
client = monitor.PrometheusClient('http://prom.test')
with patch.object(client, '_fetch', return_value=[]):
assert client.get_interface_states() is None
def test_failed_query_is_unavailable_not_empty(self):
client = monitor.PrometheusClient('http://prom.test')
with patch.object(monitor.requests, 'get', side_effect=OSError('boom')):
assert client.get_interface_states() is None
assert client.query('up') == []
def test_outage_alerts_about_prometheus_not_each_host(self, mon, db):
mon._process_prometheus_outage([])
assert upserted(db) == [('monitor_source_unavailable', 'prometheus', '')]
def test_dashboard_keeps_hosts_as_unknown(self, mon):
mon._last_iface_states = {HOST_A: {'eth0': True}}
snap = mon._collect_snapshot(None)
assert snap['hosts']['alpha']['status'] == 'unknown'
assert snap['hosts']['alpha']['interfaces'] == {'eth0': 'up'}
assert snap['hosts']['beta']['status'] == 'unknown'
class TestPingUnknown:
def _pulse(self, output):
pulse = monitor.PulseClient({})
pulse.run_command = MagicMock(return_value=output)
return pulse
def test_pulse_failure_is_unknown(self):
assert self._pulse(None).ping('10.0.0.1') is None
def test_reachability(self):
assert self._pulse('REACHABLE\n').ping('10.0.0.1') is True
assert self._pulse('UNREACHABLE\n').ping('10.0.0.1') is False
def test_unknown_neither_alerts_nor_resolves(self, mon, db):
mon.cfg = {'monitor': {'ping_hosts': [{'name': 'pbs', 'ip': '10.0.0.3'}]}}
mon._process_ping_hosts([], {'pbs': None})
assert not db.upsert_event.called
assert not db.resolve_event.called
def test_unreachable_still_alerts(self, mon, db):
mon.cfg = {'monitor': {'ping_hosts': [{'name': 'pbs', 'ip': '10.0.0.3'}]}}
mon._process_ping_hosts([], {'pbs': False})
assert ('host_unreachable', 'pbs', '10.0.0.3') in upserted(db)
class TestUnifiRates:
def _collector(self):
c = monitor.LinkStatsCollector.__new__(monitor.LinkStatsCollector)
c._prev_unifi_time = 100.0
c._prev_unifi = {'sw': {'ports': {'Port 1': {
'tx_bytes': 100, 'rx_bytes': 100, 'tx_errors': 0, 'rx_errors': 0, 'tx_dropped': 0, 'rx_dropped': 0,
}}}}
return c
def test_new_port_has_no_rate_spike(self):
port = {'tx_bytes': 10**12, 'rx_bytes': 10**12, 'tx_errors': 5, 'rx_errors': 5,
'tx_dropped': 5, 'rx_dropped': 5}
raw = {'sw': {'ip': '', 'model': '', 'ports': {'Port 1': dict(port, tx_bytes=300), 'Port 2': port}}}
result = self._collector()._compute_unifi_rates(raw, 200.0)['sw']['ports']
assert result['Port 2']['tx_bytes_rate'] is None
assert result['Port 1']['tx_bytes_rate'] == 2.0
class TestModemAlerts:
def test_healthy_link_raises_nothing(self, mon, db):
mon._process_modem(healthy_modem(), [])
assert not db.upsert_event.called
assert not mon.tickets.create.called
def test_wan_down_is_p1(self, mon, db):
data = healthy_modem()
data['broadband_state'] = 'Down'
mon._process_modem(data, [])
assert ('wan_down', 'att-modem', '') in upserted(db)
assert mon.tickets.create.call_args.kwargs['priority'] == '1'
def test_cut_fiber_opens_only_one_ticket(self, mon, db):
data = healthy_modem(rx=-40.0)
data['optical_state'] = 'Down'
mon._process_modem(data, [])
assert [e[0] for e in upserted(db)] == ['wan_down']
def test_low_rx_warning(self, mon, db):
mon._process_modem(healthy_modem(rx=-28.0), [])
assert ('fiber_signal_warning', 'att-modem', 'rx_power') in upserted(db)
assert mon.tickets.create.call_args.kwargs['priority'] == '3'
db.resolve_event.assert_any_call('fiber_signal_alarm', 'att-modem', 'rx_power')
def test_low_rx_alarm_with_link_up(self, mon, db):
mon._process_modem(healthy_modem(rx=-30.0), [])
assert ('fiber_signal_alarm', 'att-modem', 'rx_power') in upserted(db)
db.resolve_event.assert_any_call('fiber_signal_warning', 'att-modem', 'rx_power')
def test_recovery_resolves_signal_events(self, mon, db):
mon._process_modem(healthy_modem(), [])
db.resolve_event.assert_any_call('fiber_signal_alarm', 'att-modem', 'rx_power')
db.resolve_event.assert_any_call('fiber_signal_warning', 'att-modem', 'rx_power')
db.resolve_event.assert_any_call('wan_down', 'att-modem', '')
def test_suppression_blocks_ticket(self, mon, db):
db.check_suppressed.return_value = True
mon._process_modem(healthy_modem(rx=-30.0), [])
assert upserted(db)
assert not mon.tickets.create.called
def test_drift_needs_enough_history(self, mon, db):
db.get_modem_rx_values.return_value = [-17.0] * 10
mon._process_modem(healthy_modem(rx=-25.0), [])
assert not any(e[0] == 'fiber_rx_drift' for e in upserted(db))
def test_drift_alerts_inside_the_modem_limits(self, mon, db):
db.get_modem_rx_values.return_value = [-17.0] * 300
mon._process_modem(healthy_modem(rx=-21.0), [])
assert ('fiber_rx_drift', 'att-modem', 'rx_power') in upserted(db)
assert 'fiber_signal_warning' not in [e[0] for e in upserted(db)]
def test_small_change_is_not_drift(self, mon, db):
db.get_modem_rx_values.return_value = [-17.0] * 300
mon._process_modem(healthy_modem(rx=-18.0), [])
assert not db.upsert_event.called
db.resolve_event.assert_any_call('fiber_rx_drift', 'att-modem', 'rx_power')
def test_stronger_signal_is_not_drift(self, mon, db):
db.get_modem_rx_values.return_value = [-17.0] * 300
mon._process_modem(healthy_modem(rx=-12.0), [])
assert not db.upsert_event.called
class TestModemCollection:
def test_unreachable_keeps_link_events_open(self, mon, db):
mon.modem.poll.side_effect = modem_api.ModemUnreachable('timeout')
mon._collect_modem([])
assert upserted(db) == [('modem_unreachable', 'att-modem', '')]
assert db.set_state.call_args.args[1]['reachable'] is False
assert not db.record_modem_sample.called
resolved = [c.args[0] for c in db.resolve_event.call_args_list]
assert 'wan_down' not in resolved and 'fiber_signal_alarm' not in resolved
def test_unreadable_page_is_a_warning_not_an_outage(self, mon, db):
mon.modem.poll.side_effect = modem_api.ModemDataInvalid('login required')
mon._collect_modem([])
assert upserted(db) == [('modem_data_invalid', 'att-modem', '')]
assert mon.tickets.create.call_args.kwargs['priority'] == '3'
def test_success_stores_status_and_sample(self, mon, db):
mon.modem.poll.return_value = healthy_modem()
mon._collect_modem([])
assert db.set_state.call_args.args[0] == 'modem_stats'
assert db.set_state.call_args.args[1]['reachable'] is True
db.record_modem_sample.assert_called_once_with(-16.9, 3.3, 34.0)
def test_drift_baseline_excludes_the_current_reading(self, mon, db):
order = []
db.get_modem_rx_values.side_effect = lambda days: order.append('read') or []
db.record_modem_sample.side_effect = lambda *a: order.append('write')
mon.modem.poll.return_value = healthy_modem()
mon._collect_modem([])
assert order == ['read', 'write']
class TestWanLog:
def _mon(self, mon, events):
mon.unifi = MagicMock()
mon.unifi.get_system_events.return_value = events
return mon
def _state(self, db, **values):
db.get_state.side_effect = lambda key, default=None: values.get(key, default)
def test_first_run_starts_from_now_without_tickets(self, mon, db):
self._state(db)
self._mon(mon, [])._process_wan_log([])
assert db.set_state.call_args.args[0] == 'unifi_log_cursor'
assert not mon.unifi.get_system_events.called
def test_log_failure_keeps_the_cursor(self, mon, db):
self._state(db, unifi_log_cursor='1000')
self._mon(mon, None)._process_wan_log([])
assert not db.set_state.called
def test_rpdu_restart_opens_a_ticket_and_resolves(self, mon, db):
self._state(db, unifi_log_cursor=str(int(time.time() * 1000)))
db.upsert_event.return_value = (9, True, 1, None)
self._mon(mon, [{'key': modem_api.LOG_MODEM_RESTARTED, 'ts': time.time(), 'device': 'RPDU', 'wan': ''}])
mon._process_wan_log([])
assert 'power-cycled' in mon.tickets.create.call_args.args[0]
db.resolve_event.assert_called_with('modem_power_cycled', 'att-modem', '')
def test_short_outage_only_records_history(self, mon, db):
now = time.time()
self._state(db, unifi_log_cursor=str(int(now * 1000)))
self._mon(mon, [
{'key': modem_api.LOG_WAN_FAILED, 'ts': now, 'device': 'UDM', 'wan': 'WAN1'},
{'key': modem_api.LOG_WAN_RESTORED, 'ts': now + 90, 'device': 'UDM', 'wan': 'WAN1'},
])
mon._process_wan_log([])
assert not mon.tickets.create.called
saved = dict((c.args[0], c.args[1]) for c in db.set_state.call_args_list)
assert saved['wan_history']['outages'][0]['restored_at'] == now + 90
def test_unstable_wan_tickets_once_a_day(self, mon, db):
now = time.time()
history = json.dumps({'outages': [], 'restarts': [], 'unstable_ticketed_at': now - 3600})
self._state(db, unifi_log_cursor=str(int(now * 1000)), wan_history=history)
self._mon(mon, [{'key': modem_api.LOG_WAN_UNSTABLE, 'ts': now, 'device': 'UDM', 'wan': 'WAN1'}])
mon._process_wan_log([])
assert not mon.tickets.create.called
def test_unstable_wan_tickets_after_a_day(self, mon, db):
now = time.time()
self._state(db, unifi_log_cursor=str(int(now * 1000)))
self._mon(mon, [{'key': modem_api.LOG_WAN_UNSTABLE, 'ts': now, 'device': 'UDM', 'wan': 'WAN1'}])
mon._process_wan_log([])
assert 'unstable' in mon.tickets.create.call_args.args[0]
def test_suppression_blocks_one_shot_ticket(self, mon, db):
now = time.time()
db.check_suppressed.return_value = True
self._state(db, unifi_log_cursor=str(int(now * 1000)))
self._mon(mon, [{'key': modem_api.LOG_MODEM_RESTARTED, 'ts': now, 'device': 'RPDU', 'wan': ''}])
mon._process_wan_log([])
assert not mon.tickets.create.called
def test_uptime_going_backwards_records_a_restart(self, mon, db):
self._state(db, modem_stats=json.dumps({'uptime_s': 5000}))
mon._note_modem_restart({'uptime_s': 60})
saved = dict((c.args[0], c.args[1]) for c in db.set_state.call_args_list)
assert len(saved['wan_history']['restarts']) == 1
def test_uptime_going_forward_records_nothing(self, mon, db):
self._state(db, modem_stats=json.dumps({'uptime_s': 5000}))
mon._note_modem_restart({'uptime_s': 5300})
assert not db.set_state.called
class TestUnifiDeviceDetail:
def test_parses_string_stats_and_outlets(self):
d = monitor.UnifiClient._device_detail({
'version': '7.0', 'uptime': 10, 'system-stats': {'cpu': ' 2.4 ', 'mem': 'bad'},
'outlet_table': [{'index': 13, 'name': 'AT&T Modem', 'relay_state': True,
'cycle_enabled': True, 'outlet_power': '0.000'}],
'wan1': {'up': True, 'speed': 1000, 'latency': 15, 'availability': 100},
})
assert d['cpu_pct'] == 2.4 and d['mem_pct'] is None
assert d['outlets'][0]['auto_power_cycle'] is True and d['outlets'][0]['power_w'] == 0.0
assert d['wan']['wan1']['speed_mbps'] == 1000
assert 'wan2' not in d['wan']
def test_system_log_filters_and_paginates(self):
c = monitor.UnifiClient({'controller': 'https://u.test', 'api_key': 'k'})
page = MagicMock()
page.json.side_effect = [
{'data': [
{'key': modem_api.LOG_MODEM_RESTARTED, 'timestamp': 2000, 'parameters': {'DEVICE': {'name': 'RPDU'}}},
{'key': 'CLIENT_CONNECTED_WIRED_2', 'timestamp': 2000},
{'key': modem_api.LOG_WAN_FAILED, 'timestamp': 500},
]},
{'data': []},
]
c.session.post = MagicMock(return_value=page)
got = c.get_system_events(1000, modem_api.WAN_LOG_KEYS)
assert got == [{'key': modem_api.LOG_MODEM_RESTARTED, 'ts': 2.0, 'device': 'RPDU', 'wan': ''}]
def test_system_log_failure_is_none_not_empty(self):
c = monitor.UnifiClient({'controller': 'https://u.test', 'api_key': 'k'})
c.session.post = MagicMock(side_effect=OSError('boom'))
assert c.get_system_events(0, modem_api.WAN_LOG_KEYS) is None