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
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:
+32
-1
@@ -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']
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() == []
|
||||
|
||||
Reference in New Issue
Block a user