Merge pull request 'Keep every switch port and show SFP modules' (#17) from switch-ports-and-sfp-modules into main
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (flake8) (push) Successful in 2m2s
Security / Python Security (bandit) (push) Successful in 1m57s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Successful in 9s
Test / Python Tests (pytest) (push) Successful in 52s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (flake8) (push) Successful in 2m2s
Security / Python Security (bandit) (push) Successful in 1m57s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Successful in 9s
Test / Python Tests (pytest) (push) Successful in 52s
This commit was merged in pull request #17.
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