Files
gandalf/static/port-inspector.js
T
jaredandClaude Opus 5.5 c262e5ecc8
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (flake8) (push) Successful in 54s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m1s
Lint / JS (eslint) (pull_request) Successful in 19s
Test / Python Tests (pytest) (push) Successful in 2m6s
Security / Python Security (bandit) (pull_request) Successful in 1m18s
Lint / Python (flake8) (pull_request) Successful in 53s
Test / Python Tests (pytest) (pull_request) Successful in 1m1s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Inventory links, cutsheet marks on faceplates, and single page titles
Device pages get an Inventory button: it opens the device's HomeBox record
once an admin pastes its URL (stored in the new device_links table; only
URLs on inventory.base_url are accepted), and searches the inventory for
the model until then. Tickets link the record too.

Faceplate ports with a documented cable get a corner dot: green matches,
red mismatch, grey documented but unconfirmed. The port's tooltip names
the cable, and a legend toggle (remembered per browser) hides them.

Page titles no longer repeat GANDALF ("Alert History - GANDALF - GANDALF").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:47:14 -05:00

884 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>`;
}