diff --git a/README.md b/README.md index 437fd7a..1e23e1f 100644 --- a/README.md +++ b/README.md @@ -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/#port-`. +**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 | |---|---| diff --git a/monitor.py b/monitor.py index 88ea2a8..1f7527b 100644 --- a/monitor.py +++ b/monitor.py @@ -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')) diff --git a/static/faceplates.js b/static/faceplates.js index 52d06b8..8ea5ae4 100644 --- a/static/faceplates.js +++ b/static/faceplates.js @@ -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 - ? ` + const kind = sfpModuleKind(port); + const sfp = port && port.sfp; + const fault = sfp && (sfp.rx_fault || sfp.tx_fault) ? ' fp-module-fault' : ''; + const body = ``; + let inner; + if (kind === 'optic') { + inner = `${body} - ` - : ` + `; + } else if (kind === 'dac') { + // A DAC's cable leaves through a boot in the middle of the module + inner = `${body} + + `; + } else if (kind === 'module') { + // Present but unidentified: the module body with its pull tab, no connector detail + inner = `${body} + `; + } else { + inner = ` `; + } return `${inner}`; } diff --git a/static/port-inspector.js b/static/port-inspector.js index 359f362..46e7a58 100644 --- a/static/port-inspector.js +++ b/static/port-inspector.js @@ -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) => + `
${label}${val}
`; + const head = '
SFP Module
'; + if (!sfp.found) return head + row('Module', 'Empty cage'); + + 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', `${which} fault`, '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', 'None: copper DACs have no optics')); + } + const peer = sfp.cable_peer; + if (peer) { + const href = `${deviceUrl(peer.switch)}#port-${encodeURIComponent(peer.port_idx)}`; + rows.push(row('Other end', `${escHtml(peer.switch)} port ${escHtml(String(peer.port_idx))}`, + '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) {
Auto-neg${autoneg}
Media${escHtml(mediaStr)}
+ ${sfpPanelHtml(d.sfp)} ${poeHtml} ${trafficHtml} ${errHtml} diff --git a/static/style.css b/static/style.css index e0895fc..67b89d3 100644 --- a/static/style.css +++ b/static/style.css @@ -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; } diff --git a/tests/test_monitor.py b/tests/test_monitor.py index 44e8cb3..e65d816 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -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'})