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
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:
@@ -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
@@ -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
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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'})
|
||||
|
||||
Reference in New Issue
Block a user