Files
gandalf/static/port-inspector.js
jaredandClaude Opus 5.5 619a2c913d
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (flake8) (push) Successful in 57s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 57s
Lint / JS (eslint) (pull_request) Successful in 10s
Lint / Python (flake8) (pull_request) Successful in 51s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 1m0s
Test / Python Tests (pytest) (push) Successful in 1m3s
Test / Python Tests (pytest) (pull_request) Successful in 52s
Remove em and en dashes from the UI, code and docs
Page titles read "Rack | GANDALF", ranges use a hyphen (U26-42,
1-100 of 300), empty cells show "-", and dash-separated phrases use a
colon, comma or full stop. In the shared base.js only the on-screen strings
changed (boot line, placeholders, offline banner and toast).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 23:37:56 -05:00

884 lines
44 KiB
JavaScript

'use strict';
/* global deviceUrl, FACEPLATES, renderFaceplate, faceplateScrollLeft */
/* exported renderPortInspector, fmtRate, fmtErrors */
// Port inspector for a switch or router's device page: front-panel diagram,
// port detail panel, switch/server path check, and link diagnostics.
const escHtml = s => lt.escHtml(s);
// ── Formatting helpers ───────────────────────────────────────────────────
function fmtShortSpeed(mbps) {
if (!mbps) return '-';
if (mbps >= 1000) return (mbps / 1000).toFixed(0) + 'G';
return mbps + 'M';
}
function fmtRate(bytesPerSec) {
if (bytesPerSec === null || bytesPerSec === undefined) return '-';
const bps = bytesPerSec * 8;
if (bps < 1e3) return bps.toFixed(0) + ' bps';
if (bps < 1e6) return (bps / 1e3).toFixed(1) + ' Kbps';
if (bps < 1e9) return (bps / 1e6).toFixed(2) + ' Mbps';
return (bps / 1e9).toFixed(3) + ' Gbps';
}
function fmtErrors(rate) {
if (rate === null || rate === undefined) return '-';
if (rate < 0.001) return '<span class="val-good">0 /s</span>';
return `<span class="val-crit">${rate.toFixed(3)} /s</span>`;
}
// ── Build port_idx → port data map ──────────────────────────────────────
function buildPortIdxMap(ports) {
const map = {};
for (const [pname, d] of Object.entries(ports)) {
if (d.port_idx != null) {
map[d.port_idx] = Object.assign({ name: pname }, d);
}
}
return map;
}
// ── Determine port block CSS state class ────────────────────────────────
function portBlockState(d) {
if (!d || !d.up) return 'down';
if (d.is_uplink) return 'uplink';
if (d.poe_power != null && d.poe_power > 0) return 'poe-active';
return 'up';
}
// ── Speed label helper ───────────────────────────────────────────────────
function portSpeedLabel(port) {
if (!port || !port.up) return '';
const spd = port.speed_mbps;
if (!spd) return '';
if (spd >= 10000) return '10G';
if (spd >= 1000) return '1G';
if (spd >= 100) return '100M';
return spd + 'M';
}
// Blocks only fit a few characters of the neighbor, so hover text carries it in full
function portTitle(port, idx) {
if (!port) return `Port ${idx}`;
const neighbor = port.lldp && (port.lldp.system_name || port.lldp.chassis_id);
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(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(' · '));
}
// ── Cutsheet section of the port panel ──────────────────────────────────
const CUTSHEET_PROBLEM = new Set(['wrong_neighbor', 'no_link', 'missing_port', 'conflict']);
function cutsheetPanelHtml(swName, idx) {
const entries = ((_apiData && _apiData.cutsheet) || []).filter(e => e.port === String(idx));
const head = '<div class="lt-divider"><span class="lt-divider-label">Cutsheet</span></div>';
if (!entries.length) {
const add = `/cutsheet?a_device=${encodeURIComponent(swName)}&a_port=${encodeURIComponent(idx)}`;
return `${head}<div class="panel-row"><span class="panel-label">Planned</span><span class="panel-val">
<span class="val-neutral">Not documented</span> · <a class="device-link" href="${escHtml(add)}">Add cable</a></span></div>`;
}
return head + entries.map(e => {
const cls = CUTSHEET_PROBLEM.has(e.verdict) ? 'val-crit' : (e.verdict === 'match' ? 'val-good' : 'val-neutral');
const path = (e.path || []).filter(h => !('cable_id' in h)).map(h => escHtml(h.text)).join(' → ');
const name = e.label ? escHtml(e.label) : 'unlabeled';
return `
<div class="panel-row"><span class="panel-label">Cable</span><span class="panel-val">
<a class="device-link" href="/cutsheet#cable-${encodeURIComponent(e.id)}">${name}</a> · ${escHtml(e.status)}</span></div>
<div class="cutsheet-check ${cls}">${escHtml(e.detail)}</div>
${path ? `<div class="cutsheet-path">${path}</div>` : ''}`;
}).join('');
}
// The faceplate dot for one port: the worst state of the cables documented on it
const MARK_RANK = { bad: 3, doc: 2, ok: 1 };
function cutsheetMarkFor(idx) {
const entries = ((_apiData && _apiData.cutsheet) || []).filter(e => e.port === String(idx) && e.status !== 'retired');
if (!entries.length) return null;
let best = null;
entries.forEach(e => {
const mark = CUTSHEET_PROBLEM.has(e.verdict) ? 'bad' : (e.verdict === 'match' ? 'ok' : 'doc');
if (!best || MARK_RANK[mark] > MARK_RANK[best.mark]) {
best = { mark, text: `cable ${e.label || 'unlabeled'}: ${e.detail}` };
}
});
return best;
}
document.addEventListener('change', e => {
if (!e.target.matches('[data-action="toggle-cutsheet-marks"]')) return;
const off = !e.target.checked;
document.body.classList.toggle('fp-marks-off', off);
try { localStorage.setItem('gandalf.cutsheetMarks', off ? 'off' : 'on'); } catch (_) { /* private mode */ }
});
try {
if (localStorage.getItem('gandalf.cutsheetMarks') === 'off') document.body.classList.add('fp-marks-off');
} catch (_) { /* private mode */ }
// ── 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) {
const readout = document.getElementById('fp-readout');
if (!readout) return;
const target = el || document.querySelector('.fp-port.selected');
const title = target && target.getAttribute('aria-label');
readout.textContent = title || (document.querySelector('.fp-outlet') ? 'Hover or select an outlet or port' : 'Hover or select a port');
}
// ── Render a single port block element ──────────────────────────────────
function portBlockHtml(idx, port, swName, sfpBlock) {
const state = portBlockState(port);
const numLabel = sfpBlock ? 'SFP' : idx;
const title = portTitle(port, idx);
const sfpCls = sfpBlock ? ' sfp-block' : '';
const speedTxt = portSpeedLabel(port);
// LLDP neighbor: first 6 chars of hostname
const lldpName = (port && port.lldp && (port.lldp.system_name || port.lldp.chassis_id))
? escHtml((port.lldp.system_name || port.lldp.chassis_id || '').slice(0, 6))
: '';
const lldpHtml = lldpName ? `<span class="port-lldp">${lldpName}</span>` : '';
const speedHtml = speedTxt ? `<span class="port-speed">${speedTxt}</span>` : '';
return `<div class="switch-port-block ${state}${sfpCls}"
data-switch="${escHtml(swName)}" data-port-idx="${idx}"
title="${title}" aria-label="${title}"
data-action="select-port" role="button" tabindex="0"><span class="port-num">${numLabel}</span>${speedHtml}${lldpHtml}</div>`;
}
// ── Chassis legend HTML ──────────────────────────────────────────────────
function chassisLegendHtml() {
return `<div class="chassis-legend">
<div class="chassis-legend-item"><span class="chassis-legend-swatch cls-down"></span>down</div>
<div class="chassis-legend-item"><span class="chassis-legend-swatch cls-up"></span>up</div>
<div class="chassis-legend-item"><span class="chassis-legend-swatch cls-poe"></span>poe active</div>
<div class="chassis-legend-item"><span class="chassis-legend-swatch cls-uplink"></span>uplink</div>
</div>`;
}
// ── Render one switch chassis ────────────────────────────────────────────
function renderChassis(swName, sw) {
const model = sw.model || '';
const portMap = buildPortIdxMap(sw.ports || {});
const ports = Object.entries(sw.ports || {});
const upCount = ports.filter(([, p]) => p.up).length;
const downCount = ports.length - upCount;
const counts = ports.length ? [`${upCount}/${ports.length} up`, downCount ? `${downCount} down` : ''] : [];
const meta = [model, ...counts].filter(Boolean).join(' · ');
const header = `<div class="chassis-header">
<span class="chassis-name">Front panel</span>
<span class="chassis-meta">${escHtml(meta)}</span>
</div>`;
if (FACEPLATES[model]) {
return `<div class="inspector-chassis">${header}${renderFaceplate(model, {
swName, portMap, outlets: sw.outlets, online: sw.online, titleFor: faceplatePortTitle,
cutsheetFor: cutsheetMarkFor,
})}</div>`;
}
// No faceplate for this model yet: plain blocks in port order
const blocks = ports
.sort(([, a], [, b]) => (a.port_idx || 0) - (b.port_idx || 0))
.map(([pname, d]) => portBlockHtml(d.port_idx || 0, Object.assign({ name: pname }, d), swName, false))
.join('');
return `
<div class="inspector-chassis">
${header}
<div class="chassis-body">
<div class="chassis-ear-l"></div>
<div class="chassis-ear-r"></div>
<div class="chassis-rows"><div class="chassis-row">${blocks}</div></div>
</div>
${chassisLegendHtml()}
</div>`;
}
// ── State ────────────────────────────────────────────────────────────────
let _selectedSwitch = null;
let _selectedIdx = null;
let _apiData = null;
let _diagPollTimer = null;
// ── Port selection ───────────────────────────────────────────────────────
function selectPort(el) {
selectPortByIdx(el.dataset.switch, parseInt(el.dataset.portIdx, 10));
}
function selectPortByIdx(swName, idx) {
const sw = _apiData && _apiData.unifi_switches[swName];
if (!sw || !buildPortIdxMap(sw.ports || {})[idx]) return;
if (_diagPollTimer) { clearInterval(_diagPollTimer); _diagPollTimer = null; }
_selectedSwitch = swName;
_selectedIdx = idx;
renderPanel(swName, idx);
highlightSelectedPort();
history.replaceState(null, '', `#port-${idx}`);
// On narrow screens the panel sits below the front panel, possibly off screen
if (window.matchMedia('(max-width: 900px)').matches) {
document.getElementById('inspector-panel').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
function closePanel() {
if (_diagPollTimer) { clearInterval(_diagPollTimer); _diagPollTimer = null; }
document.getElementById('inspector-panel').classList.remove('open');
_selectedSwitch = null;
_selectedIdx = null;
highlightSelectedPort();
history.replaceState(null, '', window.location.pathname + window.location.search);
}
// ── Render detail panel ──────────────────────────────────────────────────
function renderPanel(swName, idx) {
if (!_apiData) return;
const sw = _apiData.unifi_switches && _apiData.unifi_switches[swName];
if (!sw) return;
const portMap = buildPortIdxMap(sw.ports || {});
const d = portMap[idx];
if (!d) return;
const upStr = d.up ? '<span class="val-good">UP</span>' : '<span class="val-crit">DOWN</span>';
const speedStr = d.speed_mbps ? `<span class="val-cyan">${fmtShortSpeed(d.speed_mbps)}bps</span>` : '-';
const duplexCls = d.full_duplex ? 'val-good' : (d.up ? 'val-warn' : 'val-neutral');
const duplexStr = d.up ? `<span class="${duplexCls}">${d.full_duplex ? 'Full' : 'Half'}</span>` : '-';
const autoneg = d.autoneg ? 'On' : 'Off';
const mediaStr = d.media || '-';
const isUplinkBadge = d.is_uplink ? ' <span class="port-badge port-badge-uplink">UPLINK</span>' : '';
// PoE section
let poeHtml = '';
if (d.poe_power || d.poe_max_power) {
const poeMaxStr = d.poe_max_power != null ? ` / max ${d.poe_max_power.toFixed(1)}W` : '';
poeHtml = `
<div class="lt-divider"><span class="lt-divider-label">PoE</span></div>
<div class="panel-row"><span class="panel-label">Class</span><span class="panel-val">class ${escHtml(String(d.poe_class))}${poeMaxStr}</span></div>
${d.poe_power != null ? `<div class="panel-row"><span class="panel-label">Draw</span><span class="panel-val">${d.poe_power > 0 ? `<span class="val-amber">${d.poe_power.toFixed(1)}W</span>` : '0W'}</span></div>` : ''}
${d.poe_mode ? `<div class="panel-row"><span class="panel-label">Mode</span><span class="panel-val">${escHtml(d.poe_mode)}</span></div>` : ''}`;
}
// Traffic section
let trafficHtml = '';
if (d.tx_bytes_rate != null || d.rx_bytes_rate != null) {
trafficHtml = `
<div class="lt-divider"><span class="lt-divider-label">Traffic</span></div>
<div class="panel-row"><span class="panel-label" data-tooltip="Transmit: outgoing from this port">TX</span><span class="panel-val">${fmtRate(d.tx_bytes_rate)}</span></div>
<div class="panel-row"><span class="panel-label" data-tooltip="Receive: incoming to this port">RX</span><span class="panel-val">${fmtRate(d.rx_bytes_rate)}</span></div>`;
}
// Errors / drops section
let errHtml = '';
if (d.tx_errs_rate != null || d.rx_errs_rate != null) {
errHtml = `
<div class="lt-divider"><span class="lt-divider-label">Errors / Drops</span></div>
<div class="panel-row"><span class="panel-label">TX Err</span><span class="panel-val">${fmtErrors(d.tx_errs_rate)}</span></div>
<div class="panel-row"><span class="panel-label">RX Err</span><span class="panel-val">${fmtErrors(d.rx_errs_rate)}</span></div>
<div class="panel-row"><span class="panel-label">TX Drop</span><span class="panel-val">${fmtErrors(d.tx_drops_rate)}</span></div>
<div class="panel-row"><span class="panel-label">RX Drop</span><span class="panel-val">${fmtErrors(d.rx_drops_rate)}</span></div>`;
}
// LLDP + path debug
let lldpHtml = '';
let pathHtml = '';
if (d.lldp) {
const l = d.lldp;
lldpHtml = `
<div class="lt-divider"><span class="lt-divider-label">${l.source === 'unifi-clients' ? 'Neighbor (UniFi client table)' : 'LLDP Neighbor'}</span></div>
${l.system_name ? `<div class="panel-row"><span class="panel-label">System</span><span class="panel-val val-cyan"><a class="device-link" href="${escHtml(deviceUrl(l.system_name))}">${escHtml(l.system_name)}</a></span></div>` : ''}
${l.port_id ? `<div class="panel-row"><span class="panel-label">Port</span><span class="panel-val">${escHtml(l.port_id)}</span></div>` : ''}
${l.port_desc ? `<div class="panel-row"><span class="panel-label">Port Desc</span><span class="panel-val">${escHtml(l.port_desc)}</span></div>` : ''}
${l.chassis_id ? `<div class="panel-row"><span class="panel-label">Chassis</span><span class="panel-val">${escHtml(l.chassis_id)}</span></div>` : ''}
${l.mgmt_ips && l.mgmt_ips.length ? `<div class="panel-row"><span class="panel-label">Mgmt IP</span><span class="panel-val">${escHtml(l.mgmt_ips.join(', '))}</span></div>` : ''}`;
// Path debug: look for matching server interface
const hosts = _apiData.hosts || {};
const serverIfaces = hosts[l.system_name];
if (serverIfaces) {
let matchedIface = l.port_id && serverIfaces[l.port_id] ? l.port_id : null;
if (!matchedIface && l.port_id) {
// fuzzy match
matchedIface = Object.keys(serverIfaces).find(k => l.port_id.includes(k) || k.includes(l.port_id)) || null;
}
if (matchedIface) {
pathHtml = buildPathDebug(swName, d, l.system_name, matchedIface, serverIfaces[matchedIface]);
}
}
}
// Diagnose button (only when LLDP has an identified neighbor we can map)
const hasDiagTarget = !!(d.lldp && d.lldp.system_name &&
_apiData.hosts && _apiData.hosts[d.lldp.system_name]);
const diagHtml = hasDiagTarget ? `
<div class="diag-bar">
<button class="btn-diag lt-btn lt-btn-secondary lt-btn-sm" data-action="run-diagnostic"
data-sw="${escHtml(swName)}" data-idx="${idx}"
aria-label="Run link diagnostics for port ${idx} on ${escHtml(swName)}">Run Diagnostics</button>
<span class="diag-status" id="diag-status"></span>
</div>
<div class="diag-results" id="diag-results"></div>` : '';
// Auto-refresh re-renders this panel. A running poll holds these elements,
// and results may be on screen, so carry them over for the same port.
const inner = document.getElementById('inspector-panel-inner');
const panelKey = `${swName}#${idx}`;
const samePort = inner.dataset.port === panelKey;
const keptStatus = samePort ? document.getElementById('diag-status') : null;
const keptResults = samePort ? document.getElementById('diag-results') : null;
const scrollTop = samePort ? inner.scrollTop : 0;
inner.dataset.port = panelKey;
inner.innerHTML = `
<div class="panel-header">
<div>
<span class="panel-port-name">${escHtml(d.name)}</span>${isUplinkBadge}
<div class="panel-meta">${escHtml(swName)} · port #${idx}</div>
</div>
<button class="panel-close" data-action="close-panel" aria-label="Close panel">✕</button>
</div>
<div class="lt-divider"><span class="lt-divider-label">Link</span></div>
<div class="panel-row"><span class="panel-label">Status</span><span class="panel-val">${upStr}</span></div>
<div class="panel-row"><span class="panel-label">Speed</span><span class="panel-val">${speedStr}</span></div>
<div class="panel-row"><span class="panel-label" data-tooltip="Full = simultaneous send/receive. Half = one direction at a time.">Duplex</span><span class="panel-val">${duplexStr}</span></div>
<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>
${cutsheetPanelHtml(swName, idx)}
${sfpPanelHtml(d.sfp)}
${poeHtml}
${trafficHtml}
${errHtml}
${lldpHtml}
${pathHtml}
${diagHtml}
`;
if (keptStatus) document.getElementById('diag-status')?.replaceWith(keptStatus);
if (keptResults) document.getElementById('diag-results')?.replaceWith(keptResults);
inner.scrollTop = scrollTop;
document.getElementById('inspector-panel').classList.add('open');
}
// ── Build path debug two-column section ─────────────────────────────────
function buildPathDebug(swName, swPort, serverName, ifaceName, svrData) {
const isFiber = (swPort.media || '').toLowerCase().includes('sfp') ||
(svrData.port_type || '').toLowerCase().includes('fibre') ||
(svrData.port_type || '').toLowerCase().includes('fiber') ||
!!svrData.sfp;
const connType = isFiber ? 'SFP / Fiber' : 'Copper';
let sfpDomHtml = '';
if (svrData.sfp && Object.keys(svrData.sfp).length) {
const sfp = svrData.sfp;
sfpDomHtml = '<div class="path-dom">';
if (sfp.vendor) sfpDomHtml += `<div class="path-dom-row"><span>Vendor</span><span>${escHtml(sfp.vendor)}${sfp.part_no ? ' / ' + escHtml(sfp.part_no) : ''}</span></div>`;
if (sfp.temp_c != null) sfpDomHtml += `<div class="path-dom-row"><span>Temp</span><span>${sfp.temp_c.toFixed(1)}°C</span></div>`;
if (sfp.tx_power_dbm != null) sfpDomHtml += `<div class="path-dom-row"><span>TX</span><span>${sfp.tx_power_dbm.toFixed(2)} dBm</span></div>`;
if (sfp.rx_power_dbm != null) sfpDomHtml += `<div class="path-dom-row"><span>RX</span><span>${sfp.rx_power_dbm.toFixed(2)} dBm</span></div>`;
sfpDomHtml += '</div>';
}
const svrErrTx = (svrData.tx_errs_rate > 0.001) ? 'val-crit' : 'val-good';
const svrErrRx = (svrData.rx_errs_rate > 0.001) ? 'val-crit' : 'val-good';
const swErrTx = (swPort.tx_errs_rate > 0.001) ? 'val-crit' : 'val-good';
const swErrRx = (swPort.rx_errs_rate > 0.001) ? 'val-crit' : 'val-good';
// Detect duplex mismatch (switch full_duplex vs server duplex string)
const swFull = swPort.full_duplex;
const svrFull = (svrData.duplex || '').toLowerCase().includes('full');
const duplexMismatch = swPort.up && svrData.duplex &&
((swFull && !svrFull) || (!swFull && svrFull));
const duplexWarnHtml = duplexMismatch
? `<div class="path-mismatch-alert">⚠ DUPLEX MISMATCH. Switch: ${swFull ? 'Full' : 'Half'} · Server: ${escHtml(svrData.duplex)}</div>`
: '';
// Detect speed mismatch
const swSpd = swPort.speed_mbps, svrSpd = svrData.speed_mbps;
const speedMismatch = swSpd && svrSpd && swSpd > 0 && svrSpd > 0 && swSpd !== svrSpd;
const speedWarnHtml = speedMismatch
? `<div class="path-mismatch-alert">⚠ SPEED MISMATCH. Switch: ${fmtShortSpeed(swSpd)} · Server: ${fmtShortSpeed(svrSpd)}</div>`
: '';
return `
<div class="lt-divider"><span class="lt-divider-label">Path Debug · ${escHtml(connType)}</span></div>
${duplexWarnHtml}${speedWarnHtml}
<div class="path-debug-cols">
<div class="path-col">
<div class="path-col-header">Switch</div>
<div class="path-row"><span>Port</span><span>${escHtml(swPort.name)}</span></div>
<div class="path-row"><span>Speed</span><span>${fmtShortSpeed(swPort.speed_mbps)}bps</span></div>
<div class="path-row"><span>TX</span><span>${fmtRate(swPort.tx_bytes_rate)}</span></div>
<div class="path-row"><span>RX</span><span>${fmtRate(swPort.rx_bytes_rate)}</span></div>
<div class="path-row"><span>TX Err</span><span class="${swErrTx}">${fmtErrors(swPort.tx_errs_rate)}</span></div>
<div class="path-row"><span>RX Err</span><span class="${swErrRx}">${fmtErrors(swPort.rx_errs_rate)}</span></div>
${(swPort.poe_power != null && swPort.poe_power > 0) ? `<div class="path-row"><span>PoE</span><span class="val-amber">${swPort.poe_power.toFixed(1)}W</span></div>` : ''}
</div>
<div class="path-col">
<div class="path-col-header">Server: ${escHtml(serverName)}</div>
<div class="path-row"><span>Iface</span><span>${escHtml(ifaceName)}</span></div>
<div class="path-row"><span>Speed</span><span>${svrData.speed_mbps ? fmtShortSpeed(svrData.speed_mbps) + 'bps' : '-'}</span></div>
<div class="path-row"><span>TX</span><span>${fmtRate(svrData.tx_bytes_rate)}</span></div>
<div class="path-row"><span>RX</span><span>${fmtRate(svrData.rx_bytes_rate)}</span></div>
<div class="path-row"><span>TX Err</span><span class="${svrErrTx}">${fmtErrors(svrData.tx_errs_rate)}</span></div>
<div class="path-row"><span>RX Err</span><span class="${svrErrRx}">${fmtErrors(svrData.rx_errs_rate)}</span></div>
${svrData.carrier_changes != null ? `<div class="path-row"><span>Carrier Chg</span><span class="${(svrData.carrier_changes||0)>10?'val-crit':(svrData.carrier_changes||0)>2?'val-warn':'val-good'}">${svrData.carrier_changes}</span></div>` : ''}
${sfpDomHtml}
</div>
</div>`;
}
// ── Page integration: device.js supplies this one device's data ──────────
function renderPortInspector(detail) {
_apiData = {
unifi_switches: { [detail.name]: {
ip: detail.ip, model: detail.model, ports: detail.ports || {},
outlets: detail.info && detail.info.outlets, online: detail.status === 'up',
} },
hosts: detail.neighbor_hosts || {},
cutsheet: detail.cutsheet || [],
};
const chassis = document.getElementById('device-chassis');
// Auto-refresh redraws the panel, so keep a scrolled panel where the user left it
const oldFrame = chassis.querySelector('.fp-scroll');
const scrollLeft = oldFrame ? oldFrame.scrollLeft : null;
chassis.innerHTML = renderChassis(detail.name, _apiData.unifi_switches[detail.name]);
const frame = chassis.querySelector('.fp-scroll');
if (frame) frame.scrollLeft = scrollLeft !== null ? scrollLeft : faceplateScrollLeft(detail.model, frame);
if (_selectedSwitch === detail.name && _selectedIdx !== null) {
renderPanel(_selectedSwitch, _selectedIdx);
} else if (_selectedSwitch === null) {
openPortFromHash(detail.name);
}
highlightSelectedPort();
}
function openPortFromHash(swName) {
const m = /^#port-(\d+)$/.exec(window.location.hash);
if (m) selectPortByIdx(swName, parseInt(m[1], 10));
}
function highlightSelectedPort() {
document.querySelectorAll('[data-action="select-port"]').forEach(el => {
el.classList.toggle('selected', parseInt(el.dataset.portIdx, 10) === _selectedIdx);
});
updateFaceplateReadout();
document.querySelectorAll('tr.device-row[data-port-idx]').forEach(row => {
row.classList.toggle('is-selected', parseInt(row.dataset.portIdx, 10) === _selectedIdx);
});
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('inspector-panel').classList.contains('open')) {
closePanel();
return;
}
if (e.key !== 'Enter' && e.key !== ' ') return;
const block = e.target.closest('[data-action="select-port"]');
if (block) { e.preventDefault(); selectPort(block); }
});
document.addEventListener('mouseover', e => {
const port = e.target.closest && e.target.closest('.fp-port, .fp-outlet');
if (port) updateFaceplateReadout(port);
});
document.addEventListener('mouseout', e => {
if (e.target.closest && e.target.closest('.fp-port, .fp-outlet')) updateFaceplateReadout();
});
document.addEventListener('focusin', e => {
const port = e.target.closest && e.target.closest('.fp-port, .fp-outlet');
if (port) updateFaceplateReadout(port);
});
document.addEventListener('click', e => {
const portBlock = e.target.closest('[data-action="select-port"]');
if (portBlock) { selectPort(portBlock); return; }
const closeBtn = e.target.closest('[data-action="close-panel"]');
if (closeBtn) { closePanel(); return; }
const diagBtn = e.target.closest('[data-action="run-diagnostic"]');
if (diagBtn) { runDiagnostic(diagBtn.dataset.sw, parseInt(diagBtn.dataset.idx, 10)); return; }
const toggleDiag = e.target.closest('[data-action="toggle-diag"]');
if (toggleDiag) {
const section = toggleDiag.parentElement;
const nowOpen = section.classList.toggle('diag-open');
const hint = toggleDiag.querySelector('.diag-toggle-hint');
if (hint) hint.textContent = nowOpen ? '[collapse]' : '[expand]';
return;
}
});
// ── Link Diagnostics ─────────────────────────────────────────────────
function runDiagnostic(swName, portIdx) {
const statusEl = document.getElementById('diag-status');
const resultsEl = document.getElementById('diag-results');
if (!statusEl || !resultsEl) return;
// Clear any previous poll
if (_diagPollTimer) { clearInterval(_diagPollTimer); _diagPollTimer = null; }
statusEl.textContent = 'Submitting to Pulse...';
resultsEl.innerHTML = '';
lt.api.post('/api/diagnose', {switch_name: swName, port_idx: portIdx})
.then(resp => {
statusEl.textContent = 'Collecting diagnostics via Pulse...';
pollDiagnostic(resp.job_id, statusEl, resultsEl);
})
.catch(e => {
const msg = (e && e.status === 429)
? 'Rate limit reached: max 5 diagnostics per minute. Please wait.'
: 'Error: ' + (e && e.message || 'Request failed');
statusEl.textContent = msg;
});
}
function pollDiagnostic(jobId, statusEl, resultsEl) {
let attempts = 0;
_diagPollTimer = setInterval(() => {
attempts++;
if (attempts > 120) { // 2min timeout
clearInterval(_diagPollTimer);
_diagPollTimer = null;
statusEl.innerHTML = 'Timed out waiting for results. '
+ '<button class="lt-btn lt-btn-ghost lt-btn-sm" id="diag-retry-btn">Retry</button>';
document.getElementById('diag-retry-btn')?.addEventListener('click', () => {
const sel = document.querySelector('[data-action="select-port"].selected');
if (sel) runDiagnostic(sel.dataset.switch, parseInt(sel.dataset.portIdx));
});
return;
}
lt.api.get(`/api/diagnose/${jobId}`)
.then(resp => {
if (resp.status === 'done') {
clearInterval(_diagPollTimer);
_diagPollTimer = null;
statusEl.textContent = '';
renderDiagnosticResults(resp.result, resultsEl);
}
})
.catch(() => {
clearInterval(_diagPollTimer);
_diagPollTimer = null;
statusEl.innerHTML = 'Error: lost connection while collecting diagnostics. '
+ '<button class="lt-btn lt-btn-ghost lt-btn-sm" id="diag-retry-btn">Retry</button>';
document.getElementById('diag-retry-btn')?.addEventListener('click', () => {
const sel = document.querySelector('[data-action="select-port"].selected');
if (sel) runDiagnostic(sel.dataset.switch, parseInt(sel.dataset.portIdx));
});
});
}, 2000);
}
function renderDiagnosticResults(d, container) {
if (!d || d.status === 'error') {
container.innerHTML = `<div class="diag-error">Diagnostic error: ${escHtml((d && d.error) || 'unknown')}</div>`;
return;
}
const health = d.health || {};
const issues = health.issues || [];
const warns = health.warnings || [];
const infoArr = health.info || [];
const secs = d.sections || {};
const eth = secs.ethtool || {};
const drv = secs.ethtool_driver || {};
const pause = secs.ethtool_pause || {};
const ring = secs.ethtool_ring || {};
const dom = secs.ethtool_dom || {};
const sysfs = secs.sysfs_stats || {};
const dmesg = secs.dmesg || [];
const lldpctl = secs.lldpctl || {};
const nicStats = secs.ethtool_stats || {};
const swPort = d.switch_port || {};
// ── Health banner ──
let bannerHtml = '';
if (issues.length === 0 && warns.length === 0) {
bannerHtml = '<div class="diag-health-banner"><span class="diag-health-ok">ALL OK</span></div>';
} else {
const parts = [];
if (issues.length) parts.push(`<span class="diag-health-critical">${issues.length} CRITICAL</span>`);
if (warns.length) parts.push(`<span class="diag-health-warning">${warns.length} WARNING</span>`);
bannerHtml = `<div class="diag-health-banner">${parts.join(' ')}</div>`;
}
const issueRows = [...issues, ...warns, ...infoArr].map(item => {
const cls = issues.includes(item) ? 'diag-val-bad' : warns.includes(item) ? 'diag-val-warn' : 'diag-val-good';
const label = issues.includes(item) ? 'CRIT' : warns.includes(item) ? 'WARN' : 'INFO';
return `<div class="diag-issue-row"><span class="${cls}">[${label}]</span> <span class="diag-code">${escHtml(item.code)}</span>: ${escHtml(item.message)}</div>`;
}).join('');
// ── Physical layer ──
const carrierVal = secs.carrier === '1' ? '<span class="diag-val-good">YES</span>' :
secs.carrier === '0' ? '<span class="diag-val-bad">NO</span>' : '-';
const operstateVal = (secs.operstate || '?').toUpperCase();
const opstateCls = secs.operstate === 'up' ? 'diag-val-good' : secs.operstate === 'down' ? 'diag-val-bad' : 'diag-val-warn';
const speedVal = eth.speed_mbps ? `<span class="diag-val-good">${fmtShortSpeed(eth.speed_mbps)}bps</span>` : '<span class="diag-val-warn">-</span>';
const duplexVal = eth.duplex === 'full' ? '<span class="diag-val-good">Full</span>' :
eth.duplex === 'half' ? '<span class="diag-val-bad">Half</span>' : '-';
const linkDetVal = eth.link_detected === true ? '<span class="diag-val-good">Yes</span>' :
eth.link_detected === false ? '<span class="diag-val-bad">No</span>' : '-';
const autonegVal = eth.auto_neg === true ? '<span class="diag-val-good">On</span>' :
eth.auto_neg === false ? '<span class="diag-val-warn">Off</span>' : '-';
const physHtml = `
<div class="diag-section">
<div class="diag-section-header">Physical Layer</div>
<table class="diag-table">
<tr><td>Carrier</td><td>${carrierVal}</td></tr>
<tr><td>Oper State</td><td><span class="${opstateCls}">${escHtml(operstateVal)}</span></td></tr>
<tr><td>Speed</td><td>${speedVal}</td></tr>
<tr><td>Duplex</td><td>${duplexVal}</td></tr>
<tr><td>Link Detected</td><td>${linkDetVal}</td></tr>
<tr><td>Auto-neg</td><td>${autonegVal}</td></tr>
${secs.carrier_changes != null ? `<tr><td>Carrier Changes</td><td><span class="${secs.carrier_changes > 20 ? 'diag-val-warn' : 'diag-val-good'}">${secs.carrier_changes}</span></td></tr>` : ''}
</table>
</div>`;
// ── SFP / DOM ──
let domHtml = '';
if (dom && Object.keys(dom).length > 0) {
const rxBar = dom.rx_power_dbm != null ? renderPowerBar(dom.rx_power_dbm, -18, -25) : '';
const txBar = dom.tx_power_dbm != null ? renderPowerBar(dom.tx_power_dbm, -10, -13) : '';
domHtml = `
<div class="diag-section">
<div class="diag-section-header">SFP / DOM</div>
<table class="diag-table">
${dom.vendor ? `<tr><td>Vendor</td><td>${escHtml(dom.vendor)}${dom.part_no ? ' / ' + escHtml(dom.part_no) : ''}</td></tr>` : ''}
${dom.sfp_type ? `<tr><td>Type</td><td>${escHtml(dom.sfp_type)}</td></tr>` : ''}
${dom.connector ? `<tr><td>Connector</td><td>${escHtml(dom.connector)}</td></tr>` : ''}
${dom.wavelength_nm != null ? `<tr><td>Wavelength</td><td>${dom.wavelength_nm} nm</td></tr>` : ''}
${dom.temp_c != null ? `<tr><td>Temperature</td><td>${dom.temp_c.toFixed(1)} °C</td></tr>` : ''}
${dom.voltage_v != null ? `<tr><td>Voltage</td><td>${dom.voltage_v.toFixed(4)} V</td></tr>` : ''}
${dom.bias_ma != null ? `<tr><td>Bias Current</td><td>${dom.bias_ma.toFixed(3)} mA</td></tr>` : ''}
${dom.tx_power_dbm != null ? `<tr><td>TX Power</td><td>${dom.tx_power_dbm.toFixed(2)} dBm ${txBar}</td></tr>` : ''}
${dom.rx_power_dbm != null ? `<tr><td>RX Power</td><td>${dom.rx_power_dbm.toFixed(2)} dBm ${rxBar}</td></tr>` : ''}
</table>
</div>`;
}
// ── NIC Error Counters ──
const errCounters = ['rx_crc_errors','rx_frame_errors','collisions','tx_carrier_errors','rx_missed_errors','rx_fifo_errors'];
const nonZeroCounters = errCounters.filter(k => sysfs[k] > 0);
let errCounterHtml = '';
if (nonZeroCounters.length > 0 || secs.carrier_changes > 0) {
const rows = nonZeroCounters.map(k => {
const v = sysfs[k];
const cls = v > 100 ? 'diag-val-bad' : 'diag-val-warn';
return `<tr><td>${escHtml(k)}</td><td class="${cls}">${v.toLocaleString()}</td></tr>`;
}).join('');
errCounterHtml = `
<div class="diag-section">
<div class="diag-section-header">NIC Error Counters</div>
<table class="diag-table">
${rows || '<tr><td colspan="2" class="diag-val-good">All zero</td></tr>'}
</table>
</div>`;
}
// ── ethtool -S (collapsible) ──
let nicStatHtml = '';
if (Object.keys(nicStats).length > 0) {
const _ERR_KEYS = /err|drop|miss|crc|frame|fifo|abort|carrier|collision|fault|discard|overflow|reset/i;
const rows = Object.entries(nicStats).map(([k, v]) => {
const cls = _ERR_KEYS.test(k) && v > 0 ? ' class="diag-stat-nonzero-warn"' : '';
return `<tr${cls}><td>${escHtml(k)}</td><td>${v.toLocaleString()}</td></tr>`;
}).join('');
nicStatHtml = `
<div class="diag-section diag-collapsible">
<div class="diag-section-header diag-toggle" data-action="toggle-diag">
ethtool -S (NIC stats) <span class="diag-toggle-hint">[expand]</span>
</div>
<div class="diag-section-body">
<table class="diag-stat-table">${rows}</table>
</div>
</div>`;
}
// ── Flow Control + Ring Buffers ──
let flowRingHtml = '';
const hasPause = Object.keys(pause).length > 0;
const hasRing = Object.keys(ring).length > 0;
if (hasPause || hasRing) {
flowRingHtml = `
<div class="diag-section">
<div class="diag-section-header">Flow Control &amp; Ring Buffers</div>
<table class="diag-table">
${hasPause ? `
<tr><td>RX Pause</td><td>${pause.rx_pause ? '<span class="diag-val-good">On</span>' : 'Off'}</td></tr>
<tr><td>TX Pause</td><td>${pause.tx_pause ? '<span class="diag-val-good">On</span>' : 'Off'}</td></tr>` : ''}
${hasRing ? `
<tr><td>RX Ring</td><td>${ring.rx_current != null ? ring.rx_current : '-'} / ${ring.rx_max != null ? ring.rx_max : '-'} max</td></tr>
<tr><td>TX Ring</td><td>${ring.tx_current != null ? ring.tx_current : '-'} / ${ring.tx_max != null ? ring.tx_max : '-'} max</td></tr>` : ''}
</table>
</div>`;
}
// ── Driver Info ──
let drvHtml = '';
if (Object.keys(drv).length > 0) {
drvHtml = `
<div class="diag-section">
<div class="diag-section-header">Driver Info</div>
<table class="diag-table">
${drv.driver ? `<tr><td>Driver</td><td>${escHtml(drv.driver)}</td></tr>` : ''}
${drv.version ? `<tr><td>Version</td><td>${escHtml(drv.version)}</td></tr>` : ''}
${drv.firmware_version ? `<tr><td>Firmware</td><td>${escHtml(drv.firmware_version)}</td></tr>` : ''}
${drv.bus_info ? `<tr><td>Bus</td><td>${escHtml(drv.bus_info)}</td></tr>` : ''}
</table>
</div>`;
}
// ── LLDP Validation ──
let lldpValHtml = '';
const swLldp = swPort.lldp || {};
lldpValHtml = `
<div class="diag-section">
<div class="diag-section-header">LLDP Validation</div>
<div class="path-debug-cols">
<div class="path-col">
<div class="path-col-header">Switch sees</div>
<div class="path-row"><span>System</span><span>${escHtml(swLldp.system_name || '-')}</span></div>
<div class="path-row"><span>Port</span><span>${escHtml(swLldp.port_id || '-')}</span></div>
<div class="path-row"><span>Chassis</span><span>${escHtml(swLldp.chassis_id || '-')}</span></div>
</div>
<div class="path-col">
<div class="path-col-header">Server lldpctl</div>
${lldpctl.available
? `<div class="path-row"><span>Neighbor</span><span>${escHtml(lldpctl.neighbor_system || '-')}</span></div>
<div class="path-row"><span>Port</span><span>${escHtml(lldpctl.neighbor_port || '-')}</span></div>`
: '<div class="path-row"><span class="diag-val-warn">lldpd not running</span></div>'}
</div>
</div>
</div>`;
// ── dmesg ──
let dmesgHtml = '';
if (dmesg.length > 0) {
const dlines = dmesg.map(e => {
const cls = e.severity === 'error' ? ' diag-dmesg-err' : e.severity === 'warn' ? ' diag-dmesg-warn' : '';
const ts = e.timestamp ? `[${e.timestamp}] ` : '';
return `<div class="diag-dmesg-line${cls}">${escHtml(ts + e.msg)}</div>`;
}).join('');
dmesgHtml = `
<div class="diag-section diag-collapsible">
<div class="diag-section-header diag-toggle" data-action="toggle-diag">
Kernel Events (dmesg) <span class="diag-toggle-hint">[expand]</span>
</div>
<div class="diag-section-body">
<div class="diag-dmesg-wrap">${dlines}</div>
</div>
</div>`;
}
// ── Switch Port Summary ──
const swSummaryHtml = `
<div class="diag-section">
<div class="diag-section-header">Switch Port Summary</div>
<table class="diag-table">
<tr><td>Status</td><td>${swPort.up ? '<span class="diag-val-good">UP</span>' : '<span class="diag-val-bad">DOWN</span>'}</td></tr>
<tr><td>Speed</td><td>${swPort.speed_mbps ? fmtShortSpeed(swPort.speed_mbps) + 'bps' : '-'}</td></tr>
<tr><td>Duplex</td><td>${swPort.full_duplex ? 'Full' : (swPort.up ? '<span class="diag-val-bad">Half</span>' : '-')}</td></tr>
<tr><td>TX Err</td><td>${fmtErrors(swPort.tx_errs_rate)}</td></tr>
<tr><td>RX Err</td><td>${fmtErrors(swPort.rx_errs_rate)}</td></tr>
${swPort.poe_power != null ? `<tr><td>PoE</td><td><span class="val-amber">${swPort.poe_power.toFixed(1)}W</span></td></tr>` : ''}
</table>
</div>`;
// ── Pulse link ──
const pulseLink = d.pulse_url
? `<div class="diag-pulse-link"><a href="${escHtml(d.pulse_url)}" target="_blank" rel="noopener">View raw output in Pulse ↗</a></div>`
: '';
container.innerHTML = `
<div class="diag-results-inner">
${bannerHtml}
<div class="diag-issue-list">${issueRows}</div>
${physHtml}
${domHtml}
${errCounterHtml}
${nicStatHtml}
${flowRingHtml}
${drvHtml}
${lldpValHtml}
${dmesgHtml}
${swSummaryHtml}
${pulseLink}
</div>`;
}
// SFP power bar: range is 0 dBm (best) to -35 dBm (worst)
function renderPowerBar(dbm, warnThreshold, critThreshold) {
const minDbm = -35, maxDbm = 0;
const pct = Math.max(0, Math.min(100, ((dbm - minDbm) / (maxDbm - minDbm)) * 100));
const warnPct = ((warnThreshold - minDbm) / (maxDbm - minDbm)) * 100;
const critPct = ((critThreshold - minDbm) / (maxDbm - minDbm)) * 100;
const barCls = dbm < critThreshold ? 'diag-val-bad' : dbm < warnThreshold ? 'diag-val-warn' : 'diag-val-good';
return `<span class="diag-power-bar-wrap">
<span class="diag-power-bar ${barCls}" style="width:${pct.toFixed(1)}%"></span>
<span class="diag-power-zone-warn" style="left:${warnPct.toFixed(1)}%"></span>
<span class="diag-power-zone-crit" style="left:${critPct.toFixed(1)}%"></span>
</span>`;
}