Move the port inspector onto device pages
Lint / JS (eslint) (push) Successful in 1m14s
Lint / Python (flake8) (push) Successful in 2m12s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m3s
Test / Python Tests (pytest) (push) Successful in 1m11s
Lint / Python (flake8) (pull_request) Successful in 1m1s
Lint / JS (eslint) (pull_request) Successful in 10s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 56s
Test / Python Tests (pytest) (pull_request) Successful in 1m5s

Replace the all-switches Inspector page with a per-device front panel,
port table and detail panel. /inspector now redirects to the dashboard,
and /device/<name>#port-<n> links directly to a port.

Restore neighbor names after UniFi's LLDP change: read local_port_idx
and resolve neighbors by MAC using Prometheus NIC data, UniFi devices
and the wired client table. This also brings back Run Diagnostics on
server ports, which depends on a named neighbor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 10:11:11 -05:00
co-authored by Claude Opus 5.5
parent e32499b7f6
commit bdcfb76c25
13 changed files with 484 additions and 226 deletions
+32 -1
View File
@@ -58,7 +58,19 @@ class TestPagesAndNav:
html = client.get('/device/%3Cscript%3Ex', headers=AUTH).get_data(as_text=True)
assert '<script>x' not in html
def test_link_data_api_remains_for_inspector(self, client):
def test_old_inspector_url_redirects_home(self, client):
resp = client.get('/inspector', headers=AUTH)
assert resp.status_code == 302 and resp.headers['Location'].endswith('/')
def test_nav_has_no_inspector_link(self, client):
assert '>Inspector<' not in client.get('/', headers=AUTH).get_data(as_text=True)
assert 'Inspector' not in client.get('/device/large1', headers=AUTH).get_data(as_text=True).split('</nav>')[0]
def test_device_page_loads_the_port_inspector(self, client):
html = client.get('/device/large1', headers=AUTH).get_data(as_text=True)
assert 'port-inspector.js' in html and 'id="inspector-panel"' in html
def test_link_data_api_remains(self, client):
assert client.get('/api/links', headers=AUTH).status_code == 200
@@ -91,3 +103,22 @@ class TestDatabaseTimeZone:
with patch.object(real_db.pymysql, 'connect') as connect:
real_db._new_conn({'host': 'h', 'user': 'u', 'password': 'p', 'name': 'n'})
assert connect.call_args.kwargs['init_command'] == "SET time_zone='+00:00'"
class TestDiagnoseWithResolvedNeighbor:
LINKS = json.dumps({
'hosts': {'large1': {'enp35s0': {'host_ip': '10.0.0.2'}}},
'unifi_switches': {'sw': {'ports': {'Port 4': {
'port_idx': 4, 'up': True,
'lldp': {'system_name': 'large1', 'port_id': 'enp35s0', 'chassis_id': 'aa:bb:cc:dd:ee:ff'},
}}}},
})
def test_port_with_a_named_neighbor_starts_a_job(self, client):
STATE['link_stats'], saved = self.LINKS, STATE['link_stats']
try:
with patch.object(gandalf.threading, 'Thread'):
resp = client.post('/api/diagnose', json={'switch_name': 'sw', 'port_idx': 4}, headers=AUTH)
finally:
STATE['link_stats'] = saved
assert resp.status_code == 200 and resp.get_json()['job_id']
+81
View File
@@ -122,6 +122,87 @@ class TestDetail:
def test_switch_has_ports(self):
assert set(devices.device_detail('24RJ45-POE', SNAPSHOT, LINKS, MODEM)['ports']) == {'Port 3', 'Port 4'}
def test_switch_gets_only_its_lldp_neighbors_for_the_path_check(self):
links = dict(LINKS, hosts=dict(LINKS['hosts'], other={'eth9': {}}))
d = devices.device_detail('24RJ45-POE', SNAPSHOT, links, MODEM)
assert set(d['neighbor_hosts']) == {'large1'}
assert 'eth0' in d['neighbor_hosts']['large1']
def test_router_has_info_even_without_port_data(self):
d = devices.device_detail('Dream Machine Pro', SNAPSHOT, LINKS, MODEM)
assert d['info']['firmware'] == '6.0.17' and d['ports'] == {}
class TestLldpResolution:
HOSTS = {'9c:6b:00:8a:17:e6': ('micro1', 'enp1s0')}
DEVS = {'78:45:58:b1:bd:3a': '24RJ45-POE'}
IPS = {'10.0.0.9': 'storage-01'}
def resolve(self, **lldp):
sw = {'s': {'ports': {'p': {'lldp': lldp}}}}
devices.resolve_lldp_neighbors(sw, self.HOSTS, self.DEVS, self.IPS)
return sw['s']['ports']['p']['lldp']
def test_port_mac_gives_host_and_interface(self):
got = self.resolve(chassis_id='aa:aa:aa:aa:aa:aa', port_id='9C:6B:00:8A:17:E6')
assert (got['system_name'], got['port_id']) == ('micro1', 'enp1s0')
def test_chassis_mac_keeps_a_named_port(self):
got = self.resolve(chassis_id='9c:6b:00:8a:17:e6', port_id='enp9s0')
assert (got['system_name'], got['port_id']) == ('micro1', 'enp9s0')
def test_unifi_device_by_chassis(self):
got = self.resolve(chassis_id='78:45:58:b1:bd:3a', port_id='Port 26')
assert (got['system_name'], got['port_id']) == ('24RJ45-POE', 'Port 26')
def test_management_ip_fallback(self):
got = self.resolve(chassis_id='aa:aa:aa:aa:aa:aa', port_id='x', mgmt_ips=['10.0.0.9'])
assert got['system_name'] == 'storage-01'
def test_unknown_neighbor_stays_unnamed(self):
got = self.resolve(chassis_id='aa:aa:aa:aa:aa:aa', port_id='aa:aa:aa:aa:aa:ab')
assert got['system_name'] == ''
def test_a_reported_name_is_kept(self):
got = self.resolve(system_name='lldp-name', chassis_id='78:45:58:b1:bd:3a', port_id='x')
assert got['system_name'] == 'lldp-name'
def test_port_without_lldp_is_left_alone(self):
sw = {'s': {'ports': {'p': {'lldp': None}}}}
devices.resolve_lldp_neighbors(sw, self.HOSTS, self.DEVS, self.IPS)
assert sw['s']['ports']['p']['lldp'] is None
class TestClientLocations:
HOSTS = {'aa:aa:aa:aa:aa:01': ('large1', 'enp35s0')}
DEVS = {'bb:bb:bb:bb:bb:01': 'sw'}
def apply(self, port, sw_port=10):
switches = {'sw': {'ports': {'Port 10': dict({'port_idx': 10}, **port)}}}
client = {'mac': 'AA:AA:AA:AA:AA:01', 'sw_mac': 'bb:bb:bb:bb:bb:01', 'sw_port': sw_port}
devices.apply_client_locations(switches, [client], self.HOSTS, self.DEVS)
return switches['sw']['ports']['Port 10']['lldp']
def test_server_without_lldp_is_located(self):
got = self.apply({'lldp': None})
assert (got['system_name'], got['port_id'], got['source']) == ('large1', 'enp35s0', 'unifi-clients')
def test_uplink_port_is_skipped(self):
assert self.apply({'lldp': None, 'is_uplink': True}) is None
def test_named_lldp_neighbor_is_kept(self):
assert self.apply({'lldp': {'system_name': 'other'}})['system_name'] == 'other'
def test_unnamed_lldp_entry_is_replaced(self):
assert self.apply({'lldp': {'system_name': '', 'chassis_id': 'x'}})['system_name'] == 'large1'
def test_other_port_is_untouched(self):
assert self.apply({'lldp': None}, sw_port=11) is None
def test_non_host_client_is_ignored(self):
switches = {'sw': {'ports': {'Port 10': {'port_idx': 10, 'lldp': None}}}}
devices.apply_client_locations(
switches, [{'mac': 'cc:cc:cc:cc:cc:cc', 'sw_mac': 'bb:bb:bb:bb:bb:01', 'sw_port': 10}],
self.HOSTS, self.DEVS,
)
assert switches['sw']['ports']['Port 10']['lldp'] is None
+41
View File
@@ -373,3 +373,44 @@ class TestUnifiDeviceDetail:
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
class TestLldpCollection:
def _client_with(self, entry):
c = monitor.UnifiClient({'controller': 'https://u.test', 'api_key': 'k'})
resp = MagicMock()
resp.json.return_value = {'data': [{
'type': 'usw', 'name': 'sw', 'lldp_table': [entry],
'port_table': [{'port_idx': 3, 'name': 'Port 3'}],
}]}
c.session.get = MagicMock(return_value=resp)
return c
def test_current_unifi_field_name(self):
ports = self._client_with({'local_port_idx': 3, 'chassis_id': 'aa'}).get_switch_ports()['sw']['ports']
assert ports['Port 3']['lldp']['chassis_id'] == 'aa'
def test_older_unifi_field_name(self):
ports = self._client_with({'lldp_port_idx': 3, 'chassis_id': 'aa'}).get_switch_ports()['sw']['ports']
assert ports['Port 3']['lldp']['chassis_id'] == 'aa'
def test_bond_does_not_hide_its_member_nic(self):
collector = monitor.LinkStatsCollector.__new__(monitor.LinkStatsCollector)
collector.prom = MagicMock()
collector.unifi = MagicMock()
collector.unifi.get_wired_clients.return_value = []
mac = 'aa:bb:cc:dd:ee:ff'
collector.prom.query.return_value = [
{'metric': {'device': 'bond0', 'address': mac, 'instance': '10.0.0.1:9100'}},
{'metric': {'device': 'eth0', 'address': mac, 'instance': '10.0.0.1:9100'}},
{'metric': {'device': 'vmbr0', 'address': mac, 'instance': '10.0.0.1:9100'}},
]
switches = {'sw': {'ports': {'p': {'lldp': {'chassis_id': 'x', 'port_id': mac.upper()}}}}}
collector._resolve_neighbors(switches, {}, {'10.0.0.1:9100': 'alpha'})
lldp = switches['sw']['ports']['p']['lldp']
assert (lldp['system_name'], lldp['port_id']) == ('alpha', 'eth0')
def test_client_list_failure_is_empty(self):
c = monitor.UnifiClient({'controller': 'https://u.test', 'api_key': 'k'})
c.session.get = MagicMock(side_effect=OSError('boom'))
assert c.get_wired_clients() == []