Keep every switch port and show SFP modules
Security / Python Security (bandit) (push) Successful in 1m50s
Test / Python Tests (pytest) (push) Successful in 1m52s
Lint / Python (flake8) (pull_request) Successful in 1m20s
Lint / Python (flake8) (push) Successful in 1m16s
Lint / JS (eslint) (pull_request) Successful in 10s
Lint / JS (eslint) (push) Successful in 15s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 1m5s
Test / Python Tests (pytest) (pull_request) Successful in 1m7s

Ports were keyed by their UniFi name, so when two ports shared a name the
later one replaced the earlier. The 24-port switch lost port 13 this way
(ports 13 and 14 are both "Office TV"). A repeated name is now kept as
"<name> (Port N)".

Traffic rates are now matched to the previous poll by port number rather
than name. Names are re-read from UniFi every poll, so a port renamed
there keeps its rates instead of losing them for a cycle.

Collect what UniFi reports about each SFP cage: whether a module is
present, its type (DAC or optic), part, serial, fault flags, and DOM
readings when the module has them. The faceplate draws empty cages,
optics, DACs and unidentified modules differently and outlines a faulted
module in red. The port panel gets an SFP Module section. Both ends of a
DAC report the same cable serial, so each end links to the other.

Closes #7
Closes #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-10 14:37:08 -05:00
co-authored by Claude Opus 5.5
parent 085c488f7b
commit 7e588e5b02
6 changed files with 241 additions and 10 deletions
+6
View File
@@ -139,6 +139,12 @@ they aren't alerted on.
A realistic front panel, drawn as SVG from the model's real geometry in `static/faceplates.js`
(`FACEPLATES`). `/inspector` now redirects to the dashboard. Link to a port with `/device/<name>#port-<n>`.
**SFP cages** show what UniFi reports is in them: empty, a fibre optic (two LC sockets), a DAC (cable
boot), or an unidentified module. A module raising a receive or transmit fault is outlined in red.
The port panel's **SFP Module** section lists type, part, serial and faults, plus the module's
optical readings (TX/RX power, temperature, voltage, bias) when it reports them. Copper DACs don't.
Both ends of a DAC report the same cable serial, so a DAC seen on two ports links to its other end.
**Port block colours:**
| Colour | State |
|---|---|
+77 -3
View File
@@ -245,6 +245,68 @@ class UnifiClient:
logger.error(f'UniFi API error: {e}')
return None
@staticmethod
def _sfp_detail(port: dict) -> Optional[dict]:
"""The module in an SFP cage, or None for a port that has no cage.
Every cage reports whether a module is present and what it is. Optical
readings (DOM) only come from modules that report them, such as fibre
optics; copper DACs have none. Switches and the UDM spell the fault flags
differently.
"""
if 'sfp_found' not in port:
return None
if not port.get('sfp_found'):
return {'found': False}
def number(key: str) -> Optional[float]:
try:
return float(port[key])
except (KeyError, TypeError, ValueError):
return None
part = str(port.get('sfp_part') or '').strip()
compliance = str(port.get('sfp_compliance') or '').strip()
if compliance.lower() == 'unknown':
compliance = ''
rx_power = number('sfp_rxpower')
if 'DAC' in compliance.upper() or re.search(r'(^|[-_])CU\d', part, re.IGNORECASE):
kind = 'dac'
elif rx_power is not None or compliance:
kind = 'optic'
else:
kind = ''
return {
'found': True,
'kind': kind,
'vendor': str(port.get('sfp_vendor') or '').strip(),
'part': part,
'serial': str(port.get('sfp_serial') or '').strip(),
'compliance': compliance,
'rx_power_dbm': rx_power,
'tx_power_dbm': number('sfp_txpower'),
'temperature_c': number('sfp_temperature'),
'voltage_v': number('sfp_voltage'),
'current_ma': number('sfp_current'),
'rx_fault': bool(port.get('sfp_rxfault', port.get('sfp_rx_los', False))),
'tx_fault': bool(port.get('sfp_txfault', port.get('sfp_tx_fault', False))),
}
@staticmethod
def _pair_dac_ends(switches: Dict[str, dict]) -> None:
"""Link the two ends of each DAC cable: both report the cable's serial."""
ends: Dict[str, List[tuple]] = {}
for sw_name, sw in switches.items():
for port in sw['ports'].values():
sfp = port.get('sfp') or {}
if sfp.get('kind') == 'dac' and sfp.get('serial'):
ends.setdefault(sfp['serial'], []).append((sw_name, port))
for pair in ends.values():
if len(pair) != 2:
continue # one end not seen, or a serial that isn't unique
for (_, port), (peer_name, peer) in (pair, pair[::-1]):
port['sfp']['cable_peer'] = {'switch': peer_name, 'port_idx': peer['port_idx']}
def get_switch_ports(self) -> Optional[Dict[str, dict]]:
"""Return per-port stats for UniFi switches and routers, keyed by device name.
@@ -280,6 +342,11 @@ class UnifiClient:
for port in dev.get('port_table', []):
idx = port.get('port_idx', 0)
pname = port.get('name') or f'Port {idx}'
# Ports are keyed by name, and UniFi lets two ports share one
# (e.g. a stale label copied to the next port). Without this the
# later port silently replaced the earlier one.
if pname in ports:
pname = f'{pname} (Port {idx})'
raw_poe = port.get('poe_power')
raw_poe_max = port.get('poe_max_power')
ports[pname] = {
@@ -302,9 +369,11 @@ class UnifiClient:
'rx_errors': port.get('rx_errors', 0),
'tx_dropped': port.get('tx_dropped', 0),
'rx_dropped': port.get('rx_dropped', 0),
'sfp': self._sfp_detail(port),
}
if ports:
result[sw_name] = {'ip': sw_ip, 'model': sw_model, 'ports': ports}
self._pair_dac_ends(result)
return result
except Exception as e:
logger.error(f'UniFi switch port stats error: {e}')
@@ -786,7 +855,7 @@ class LinkStatsCollector:
dt = now - self._prev_unifi_time if self._prev_unifi_time > 0 else 0
def rate(new_val: int, old_val: Optional[int]) -> Optional[float]:
# No earlier reading (first poll, or a new/renamed port) means no rate,
# No earlier reading (first poll, or a newly reported port) means no rate,
# not "all bytes since boot arrived in this one interval".
if dt <= 0 or old_val is None:
return None
@@ -794,11 +863,16 @@ class LinkStatsCollector:
result: Dict[str, dict] = {}
for sw_name, sw_data in raw.items():
prev_ports = self._prev_unifi.get(sw_name, {}).get('ports', {})
# Match by port number: names are re-read from UniFi every poll, so a
# port renamed there keeps its rates instead of losing them for a cycle.
prev_ports = {
p.get('port_idx'): p
for p in self._prev_unifi.get(sw_name, {}).get('ports', {}).values()
}
merged_ports: Dict[str, dict] = {}
for pname, d in sw_data['ports'].items():
entry = dict(d)
prev = prev_ports.get(pname, {})
prev = prev_ports.get(d.get('port_idx'), {})
entry['tx_bytes_rate'] = rate(d['tx_bytes'], prev.get('tx_bytes'))
entry['rx_bytes_rate'] = rate(d['rx_bytes'], prev.get('rx_bytes'))
entry['tx_errs_rate'] = rate(d['tx_errors'], prev.get('tx_errors'))
+29 -4
View File
@@ -54,13 +54,38 @@ function rj45Svg(port, p) {
}
// An SFP+ cage seen head-on. Fills with a module when the port has link.
// What sits in a cage: 'empty', 'optic', 'dac', or 'module' when the module
// doesn't say what it is. Without module data (older snapshots, or a gateway
// that doesn't report it) a linked port is shown as an optic, as before.
function sfpModuleKind(port) {
const sfp = port && port.sfp;
if (sfp) return !sfp.found ? 'empty' : (sfp.kind || 'module');
return port && port.up ? 'optic' : 'empty';
}
function sfpSvg(port, w, h) {
const inner = port && port.up
? `<rect class="fp-module" x="2.5" y="2.5" width="${w - 5}" height="${h - 5}" rx="1"/>
const kind = sfpModuleKind(port);
const sfp = port && port.sfp;
const fault = sfp && (sfp.rx_fault || sfp.tx_fault) ? ' fp-module-fault' : '';
const body = `<rect class="fp-module${fault}" x="2.5" y="2.5" width="${w - 5}" height="${h - 5}" rx="1"/>`;
let inner;
if (kind === 'optic') {
inner = `${body}
<rect class="fp-module-lc" x="6.5" y="6" width="9" height="${h - 12}" rx="1.2"/>
<rect class="fp-module-lc" x="${w - 15.5}" y="6" width="9" height="${h - 12}" rx="1.2"/>`
: `<path class="fp-cage-spring" d="M4 5 H${w - 4} M4 ${h - 5} H${w - 4}"/>
<rect class="fp-module-lc" x="${w - 15.5}" y="6" width="9" height="${h - 12}" rx="1.2"/>`;
} else if (kind === 'dac') {
// A DAC's cable leaves through a boot in the middle of the module
inner = `${body}
<rect class="fp-dac-boot" x="${w / 2 - 7}" y="4.5" width="14" height="${h - 9}" rx="2.5"/>
<circle class="fp-dac-cable" cx="${w / 2}" cy="${h / 2}" r="3.2"/>`;
} else if (kind === 'module') {
// Present but unidentified: the module body with its pull tab, no connector detail
inner = `${body}
<rect class="fp-cage-rail" x="${w / 2 - 6}" y="${h / 2 - 1.5}" width="12" height="3" rx="1"/>`;
} else {
inner = `<path class="fp-cage-spring" d="M4 5 H${w - 4} M4 ${h - 5} H${w - 4}"/>
<rect class="fp-cage-rail" x="${w / 2 - 8}" y="${h / 2 - 2}" width="16" height="4" rx="1"/>`;
}
return `<rect class="fp-cage" width="${w}" height="${h}" rx="1"/>${inner}`;
}
+48 -1
View File
@@ -66,20 +66,66 @@ function portTitle(port, idx) {
return escHtml(neighbor ? `${port.name} → ${neighbor}` : port.name);
}
function sfpKindLabel(sfp) {
if (sfp.kind === 'dac') return 'DAC';
if (sfp.kind === 'optic') return sfp.compliance || 'Optic';
return 'Module';
}
function faceplatePortTitle(port, idx, role) {
const parts = [role ? `Port ${idx} (${role})` : `Port ${idx}`];
const sfp = port && port.sfp;
if (!port || !port.up) {
parts.push('no link');
parts.push(sfp && !sfp.found ? 'empty cage' : 'no link');
} else {
const speed = portSpeedLabel(port);
if (speed) parts.push(speed);
if (port.poe_power > 0) parts.push(`${port.poe_power.toFixed(1)} W PoE`);
}
if (sfp && sfp.found) parts.push(sfp.part ? `${sfpKindLabel(sfp)} ${sfp.part}` : sfpKindLabel(sfp));
if (sfp && (sfp.rx_fault || sfp.tx_fault)) parts.push('module fault');
const neighbor = port && port.lldp && (port.lldp.system_name || port.lldp.chassis_id);
if (neighbor) parts.push(`→ ${neighbor}`);
return escHtml(parts.join(' · '));
}
// ── SFP module section of the port panel ────────────────────────────────
function sfpPanelHtml(sfp) {
if (!sfp) return '';
const row = (label, val, tip) =>
`<div class="panel-row"><span class="panel-label"${tip ? ` data-tooltip="${escHtml(tip)}"` : ''}>${label}</span><span class="panel-val">${val}</span></div>`;
const head = '<div class="lt-divider"><span class="lt-divider-label">SFP Module</span></div>';
if (!sfp.found) return head + row('Module', '<span class="val-neutral">Empty cage</span>');
const rows = [row('Type', escHtml(sfpKindLabel(sfp)))];
const model = [sfp.vendor, sfp.part].filter(Boolean).join(' ');
if (model) rows.push(row('Part', escHtml(model)));
if (sfp.serial) rows.push(row('Serial', escHtml(sfp.serial)));
if (sfp.rx_fault || sfp.tx_fault) {
const which = [sfp.rx_fault && 'receive', sfp.tx_fault && 'transmit'].filter(Boolean).join(' and ');
rows.push(row('Fault', `<span class="val-crit">${which} fault</span>`, 'Flags the module itself raises: receive = loss of signal, transmit = laser fault.'));
}
if (sfp.rx_power_dbm != null) {
rows.push(row('RX power', `${sfp.rx_power_dbm.toFixed(2)} dBm ${renderPowerBar(sfp.rx_power_dbm, -18, -25)}`, 'Light received from the far end'));
}
if (sfp.tx_power_dbm != null) {
rows.push(row('TX power', `${sfp.tx_power_dbm.toFixed(2)} dBm ${renderPowerBar(sfp.tx_power_dbm, -10, -13)}`, 'Light this module sends'));
}
if (sfp.temperature_c != null) rows.push(row('Temp', `${sfp.temperature_c.toFixed(1)} °C`));
if (sfp.voltage_v != null) rows.push(row('Voltage', `${sfp.voltage_v.toFixed(2)} V`));
if (sfp.current_ma != null) rows.push(row('Bias', `${sfp.current_ma.toFixed(1)} mA`, 'Laser bias current'));
if (sfp.kind === 'dac' && sfp.rx_power_dbm == null) {
rows.push(row('Readings', '<span class="val-neutral">None: copper DACs have no optics</span>'));
}
const peer = sfp.cable_peer;
if (peer) {
const href = `${deviceUrl(peer.switch)}#port-${encodeURIComponent(peer.port_idx)}`;
rows.push(row('Other end', `<a class="device-link" href="${escHtml(href)}">${escHtml(peer.switch)} port ${escHtml(String(peer.port_idx))}</a>`,
'Both ends of this DAC report the same cable serial'));
}
return head + rows.join('');
}
// Faceplate jacks are too small for text, so one line under the panel names
// the hovered or selected port.
function updateFaceplateReadout(el) {
@@ -305,6 +351,7 @@ function renderPanel(swName, idx) {
<div class="panel-row"><span class="panel-label" data-tooltip="Autonegotiation: NIC and switch automatically agree speed and duplex.">Auto-neg</span><span class="panel-val val-neutral">${autoneg}</span></div>
<div class="panel-row"><span class="panel-label">Media</span><span class="panel-val">${escHtml(mediaStr)}</span></div>
${sfpPanelHtml(d.sfp)}
${poeHtml}
${trafficHtml}
${errHtml}
+3
View File
@@ -1210,6 +1210,9 @@ tr.device-row.is-selected { background: var(--bg-tertiary); box-shadow: inset 3p
.fp-cage-rail { fill: #3b3c41; }
.fp-module { fill: #a5a7ad; stroke: #6a6b70; stroke-width: .6; }
.fp-module-lc { fill: #2a4a7f; stroke: #1a2a44; stroke-width: .5; }
.fp-module-fault { stroke: #e0312b; stroke-width: 1.4; }
.fp-dac-boot { fill: #2b2c30; stroke: #17171a; stroke-width: .5; }
.fp-dac-cable { fill: #111113; }
.fp-led-idle { fill: #6a6b70; }
.fp-led-off { fill: #4b4c50; }
+78 -2
View File
@@ -179,18 +179,27 @@ class TestUnifiRates:
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,
'port_idx': 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}}}
raw = {'sw': {'ip': '', 'model': '', 'ports': {
'Port 1': dict(port, port_idx=1, tx_bytes=300), 'Port 2': dict(port, port_idx=2)}}}
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
def test_port_renamed_in_unifi_keeps_its_rates(self):
port = {'port_idx': 1, 'tx_bytes': 300, 'rx_bytes': 100, 'tx_errors': 0, 'rx_errors': 0,
'tx_dropped': 0, 'rx_dropped': 0}
raw = {'sw': {'ip': '', 'model': '', 'ports': {'Office TV': port}}}
result = self._collector()._compute_unifi_rates(raw, 200.0)['sw']['ports']
assert result['Office TV']['tx_bytes_rate'] == 2.0
class TestModemAlerts:
def test_healthy_link_raises_nothing(self, mon, db):
@@ -398,6 +407,73 @@ class TestUnifiDeviceDetail:
assert c.get_system_events(0, modem_api.WAN_LOG_KEYS) is None
class TestSwitchPortCollection:
def test_ports_sharing_a_name_are_both_kept(self):
c = monitor.UnifiClient({'controller': 'https://u.test', 'api_key': 'k'})
resp = MagicMock()
resp.json.return_value = {'data': [{'type': 'usw', 'name': 'sw', 'port_table': [
{'port_idx': 13, 'name': 'Office TV', 'up': False},
{'port_idx': 14, 'name': 'Office TV', 'up': True},
{'port_idx': 15},
]}]}
c.session.get = MagicMock(return_value=resp)
ports = c.get_switch_ports()['sw']['ports']
assert {n: p['port_idx'] for n, p in ports.items()} == {
'Office TV': 13, 'Office TV (Port 14)': 14, 'Port 15': 15,
}
class TestSfpModules:
OPTIC = {'sfp_found': True, 'sfp_compliance': '10G BASE-SR', 'sfp_part': 'SFP-10G-SR', 'sfp_vendor': 'OEM',
'sfp_serial': 'YC1', 'sfp_rxpower': '-2.45', 'sfp_txpower': '-2.56', 'sfp_temperature': '40.6',
'sfp_voltage': '3.318', 'sfp_current': '7.564', 'sfp_rxfault': False, 'sfp_txfault': False}
def test_port_without_a_cage_has_no_module_info(self):
assert monitor.UnifiClient._sfp_detail({'port_idx': 1}) is None
def test_empty_cage(self):
assert monitor.UnifiClient._sfp_detail({'sfp_found': False}) == {'found': False}
def test_optic_with_readings(self):
d = monitor.UnifiClient._sfp_detail(self.OPTIC)
assert d['kind'] == 'optic' and d['rx_power_dbm'] == -2.45 and d['current_ma'] == 7.564
assert d['rx_fault'] is False
def test_dac_by_compliance_or_part_number(self):
assert monitor.UnifiClient._sfp_detail({'sfp_found': True, 'sfp_compliance': 'DAC'})['kind'] == 'dac'
d = monitor.UnifiClient._sfp_detail({'sfp_found': True, 'sfp_compliance': 'Unknown',
'sfp_part': 'SFP-H10GB-CU3M'})
assert d['kind'] == 'dac' and d['compliance'] == '' and d['rx_power_dbm'] is None
def test_unidentified_module_and_bad_readings(self):
d = monitor.UnifiClient._sfp_detail({'sfp_found': True, 'sfp_part': 'L56SF017-SD-R',
'sfp_rxpower': 'n/a'})
assert d['kind'] == '' and d['rx_power_dbm'] is None
def test_udm_fault_flag_spelling(self):
d = monitor.UnifiClient._sfp_detail({'sfp_found': True, 'sfp_rx_los': True, 'sfp_tx_fault': True})
assert d['rx_fault'] is True and d['tx_fault'] is True
def test_dac_ends_are_paired_by_serial(self):
c = monitor.UnifiClient({'controller': 'https://u.test', 'api_key': 'k'})
dac = {'sfp_found': True, 'sfp_part': 'SFP-H10GB-CU1M', 'sfp_serial': 'CSC1'}
resp = MagicMock()
resp.json.return_value = {'data': [
{'type': 'udm', 'name': 'gw', 'port_table': [dict(dac, port_idx=11)]},
{'type': 'usw', 'name': 'agg', 'port_table': [
dict(dac, port_idx=7),
dict(dac, port_idx=1, sfp_serial='LONE'),
dict(self.OPTIC, port_idx=8),
]},
]}
c.session.get = MagicMock(return_value=resp)
sw = c.get_switch_ports()
assert sw['gw']['ports']['Port 11']['sfp']['cable_peer'] == {'switch': 'agg', 'port_idx': 7}
assert sw['agg']['ports']['Port 7']['sfp']['cable_peer'] == {'switch': 'gw', 'port_idx': 11}
assert 'cable_peer' not in sw['agg']['ports']['Port 1']['sfp']
assert 'cable_peer' not in sw['agg']['ports']['Port 8']['sfp']
class TestLldpCollection:
def _client_with(self, entry):
c = monitor.UnifiClient({'controller': 'https://u.test', 'api_key': 'k'})