feat: inspector page, link debug enhancements, security hardening
- Add /inspector page: visual model-accurate switch chassis diagrams
(USF5P, USL8A, US24PRO, USPPDUP, USMINI), clickable port blocks
with color coding (green=up, amber=PoE, cyan=uplink, grey=down),
detail panel with stats/PoE/LLDP, LLDP-based path debug side-by-side
- Link Debug: port number badges (#N), LLDP neighbor line, PoE class/max,
collapsible host/switch panels with sessionStorage persistence
- monitor.py: collect LLDP neighbor map + PoE class/max/mode per switch
port; PulseClient uses requests.Session() for HTTP keep-alive; add
shlex.quote() around interface names (defense-in-depth)
- Security: suppress buttons use data-* attrs + delegated click handler
instead of inline onclick with Jinja2 variable interpolation; remove
| safe filter from user-controlled fields in suppressions.html;
setDuration() takes explicit el param instead of implicit event global
- db.py: thread-local connection reuse with ping(reconnect=True) to
avoid a new TCP handshake per query
- .gitignore: add config.json (contains credentials), __pycache__
- README: full rewrite covering architecture, all 4 pages, alert logic,
config reference, deployment, troubleshooting, security notes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 15:39:48 -05:00
|
|
|
|
{% extends "base.html" %}
|
|
|
|
|
|
{% block title %}Inspector – GANDALF{% endblock %}
|
|
|
|
|
|
|
|
|
|
|
|
{% block content %}
|
|
|
|
|
|
|
|
|
|
|
|
<div class="page-header">
|
|
|
|
|
|
<h1 class="page-title">Network Inspector</h1>
|
|
|
|
|
|
<p class="page-sub">
|
|
|
|
|
|
Visual switch chassis diagrams. Click a port to see detailed stats and LLDP path debug.
|
|
|
|
|
|
<span id="inspector-updated" style="margin-left:10px; color:var(--text-muted); font-size:.85em;"></span>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="inspector-layout">
|
|
|
|
|
|
<div class="inspector-main" id="inspector-main">
|
|
|
|
|
|
<div class="link-loading">Loading inspector data</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="inspector-panel" id="inspector-panel">
|
|
|
|
|
|
<div class="inspector-panel-inner" id="inspector-panel-inner"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{% endblock %}
|
|
|
|
|
|
|
|
|
|
|
|
{% block scripts %}
|
|
|
|
|
|
<script>
|
|
|
|
|
|
// ── Switch layout config ─────────────────────────────────────────────────
|
|
|
|
|
|
// keys match the model field returned by the UniFi API
|
|
|
|
|
|
// rows: array of rows, each row is an array of port_idx values
|
|
|
|
|
|
// sfp_ports: port_idx values that are SFP cages (in rows, rendered differently)
|
|
|
|
|
|
// sfp_section: port_idx values rendered as a separate SFP bank below the rows
|
|
|
|
|
|
const SWITCH_LAYOUTS = {
|
|
|
|
|
|
'USF5P': { rows: [[1,2,3,4]], sfp_section: [5] },
|
|
|
|
|
|
'USL8A': { rows: [[1,2,3,4],[5,6,7,8]], sfp_ports: [1,2,3,4,5,6,7,8] },
|
|
|
|
|
|
'US24PRO': {
|
|
|
|
|
|
rows: [
|
|
|
|
|
|
[1,3,5,7,9,11,13,15,17,19,21,23],
|
|
|
|
|
|
[2,4,6,8,10,12,14,16,18,20,22,24],
|
|
|
|
|
|
],
|
|
|
|
|
|
sfp_section: [25,26],
|
|
|
|
|
|
},
|
|
|
|
|
|
'USPPDUP': { rows: [[1]] },
|
|
|
|
|
|
'USMINI': { rows: [[1,2,3,4,5]] },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ── Formatting helpers ───────────────────────────────────────────────────
|
|
|
|
|
|
function fmtSpeed(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';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Render a single port block element ──────────────────────────────────
|
|
|
|
|
|
function portBlockHtml(idx, port, swName, sfpBlock) {
|
|
|
|
|
|
const state = portBlockState(port);
|
|
|
|
|
|
const label = sfpBlock ? 'SFP' : idx;
|
|
|
|
|
|
const title = port ? escHtml(port.name) : `Port ${idx}`;
|
|
|
|
|
|
const sfpCls = sfpBlock ? ' sfp-block' : '';
|
|
|
|
|
|
return `<div class="switch-port-block ${state}${sfpCls}"
|
|
|
|
|
|
data-switch="${escHtml(swName)}" data-port-idx="${idx}"
|
|
|
|
|
|
title="${title}"
|
|
|
|
|
|
onclick="selectPort(this)">${label}</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Render one switch chassis ────────────────────────────────────────────
|
|
|
|
|
|
function renderChassis(swName, sw) {
|
|
|
|
|
|
const model = sw.model || '';
|
|
|
|
|
|
const layout = SWITCH_LAYOUTS[model] || null;
|
|
|
|
|
|
const portMap = buildPortIdxMap(sw.ports || {});
|
|
|
|
|
|
|
|
|
|
|
|
const upCount = Object.values(sw.ports || {}).filter(p => p.up).length;
|
|
|
|
|
|
const totCount = Object.keys(sw.ports || {}).length;
|
|
|
|
|
|
const downCount = totCount - upCount;
|
|
|
|
|
|
const meta = [model, `${upCount}/${totCount} up`, downCount ? `${downCount} down` : ''].filter(Boolean).join(' · ');
|
|
|
|
|
|
|
|
|
|
|
|
let chassisHtml = '';
|
|
|
|
|
|
|
|
|
|
|
|
if (layout) {
|
|
|
|
|
|
const sfpPortSet = new Set(layout.sfp_ports || []);
|
|
|
|
|
|
const sfpSectionSet = new Set(layout.sfp_section || []);
|
|
|
|
|
|
|
|
|
|
|
|
// Main port rows
|
|
|
|
|
|
chassisHtml += '<div class="chassis-rows">';
|
|
|
|
|
|
for (const row of layout.rows) {
|
|
|
|
|
|
chassisHtml += '<div class="chassis-row">';
|
|
|
|
|
|
for (const idx of row) {
|
|
|
|
|
|
const port = portMap[idx];
|
|
|
|
|
|
const isSfp = sfpPortSet.has(idx);
|
|
|
|
|
|
const sfpCls = isSfp ? ' sfp-port' : '';
|
|
|
|
|
|
const state = portBlockState(port);
|
|
|
|
|
|
const title = port ? escHtml(port.name) : `Port ${idx}`;
|
|
|
|
|
|
chassisHtml += `<div class="switch-port-block ${state}${sfpCls}"
|
|
|
|
|
|
data-switch="${escHtml(swName)}" data-port-idx="${idx}"
|
|
|
|
|
|
title="${title}"
|
|
|
|
|
|
onclick="selectPort(this)">${idx}</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
chassisHtml += '</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
chassisHtml += '</div>';
|
|
|
|
|
|
|
|
|
|
|
|
// Separate SFP section (if present)
|
|
|
|
|
|
if (sfpSectionSet.size) {
|
|
|
|
|
|
chassisHtml += '<div class="chassis-sfp-section">';
|
|
|
|
|
|
for (const idx of layout.sfp_section) {
|
|
|
|
|
|
chassisHtml += portBlockHtml(idx, portMap[idx], swName, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
chassisHtml += '</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Fallback: render all ports sorted by idx
|
|
|
|
|
|
const allPorts = Object.entries(sw.ports || {})
|
|
|
|
|
|
.sort(([, a], [, b]) => (a.port_idx || 0) - (b.port_idx || 0));
|
|
|
|
|
|
chassisHtml += '<div class="chassis-rows"><div class="chassis-row">';
|
|
|
|
|
|
for (const [pname, d] of allPorts) {
|
|
|
|
|
|
chassisHtml += portBlockHtml(d.port_idx || 0, Object.assign({ name: pname }, d), swName, false);
|
|
|
|
|
|
}
|
|
|
|
|
|
chassisHtml += '</div></div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="inspector-chassis" id="chassis-${escHtml(swName)}">
|
|
|
|
|
|
<div class="chassis-header">
|
|
|
|
|
|
<span class="chassis-name">${escHtml(swName)}</span>
|
|
|
|
|
|
${sw.ip ? `<span class="chassis-ip">${escHtml(sw.ip)}</span>` : ''}
|
|
|
|
|
|
<span class="chassis-meta">${escHtml(meta)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="chassis-body">${chassisHtml}</div>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── State ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
let _selectedSwitch = null;
|
|
|
|
|
|
let _selectedIdx = null;
|
|
|
|
|
|
let _apiData = null;
|
|
|
|
|
|
|
|
|
|
|
|
// ── Port selection ───────────────────────────────────────────────────────
|
|
|
|
|
|
function selectPort(el) {
|
|
|
|
|
|
const swName = el.dataset.switch;
|
|
|
|
|
|
const idx = parseInt(el.dataset.portIdx, 10);
|
|
|
|
|
|
document.querySelectorAll('.switch-port-block.selected')
|
|
|
|
|
|
.forEach(e => e.classList.remove('selected'));
|
|
|
|
|
|
el.classList.add('selected');
|
|
|
|
|
|
_selectedSwitch = swName;
|
|
|
|
|
|
_selectedIdx = idx;
|
|
|
|
|
|
renderPanel(swName, idx);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closePanel() {
|
|
|
|
|
|
document.getElementById('inspector-panel').classList.remove('open');
|
|
|
|
|
|
document.querySelectorAll('.switch-port-block.selected')
|
|
|
|
|
|
.forEach(el => el.classList.remove('selected'));
|
|
|
|
|
|
_selectedSwitch = null;
|
|
|
|
|
|
_selectedIdx = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 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">${fmtSpeed(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_class != null) {
|
|
|
|
|
|
const poeMaxStr = d.poe_max_power != null ? ` / max ${d.poe_max_power.toFixed(1)}W` : '';
|
|
|
|
|
|
const poeCurStr = (d.poe_power != null && d.poe_power > 0) ? ` / draw <span class="val-amber">${d.poe_power.toFixed(1)}W</span>` : '';
|
|
|
|
|
|
poeHtml = `
|
|
|
|
|
|
<div class="panel-section-title">PoE</div>
|
|
|
|
|
|
<div class="panel-row"><span class="panel-label">Class</span><span class="panel-val">class ${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="panel-section-title">Traffic</div>
|
|
|
|
|
|
<div class="panel-row"><span class="panel-label">TX</span><span class="panel-val">${fmtRate(d.tx_bytes_rate)}</span></div>
|
|
|
|
|
|
<div class="panel-row"><span class="panel-label">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="panel-section-title">Errors / Drops</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 && d.lldp.system_name) {
|
|
|
|
|
|
const l = d.lldp;
|
|
|
|
|
|
lldpHtml = `
|
|
|
|
|
|
<div class="panel-section-title">LLDP Neighbor</div>
|
|
|
|
|
|
<div class="panel-row"><span class="panel-label">System</span><span class="panel-val val-cyan">${escHtml(l.system_name)}</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]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: deep link diagnostics via Pulse SSH
Adds comprehensive per-port link troubleshooting triggered from the
Inspector panel when a port has an LLDP-identified server counterpart.
- diagnose.py: DiagnosticsRunner with 15-section SSH command (carrier,
operstate, sysfs counters, ethtool, ethtool -i/-a/-g/-S/-m, ip link,
ip addr, ip route, dmesg, lldpctl); parsers for all sections; health
analyzer with 14 check codes (NO_CARRIER, HALF_DUPLEX, SPEED_MISMATCH,
SFP_RX_CRITICAL, CARRIER_FLAPPING, CRC_ERRORS_HIGH, LLDP_MISMATCH, etc.)
- monitor.py: PulseClient now tracks last_execution_id so callers can
link back to the raw Pulse execution URL
- app.py: POST /api/diagnose + GET /api/diagnose/<job_id> with daemon
thread background execution and 10-minute in-memory job store
- inspector.html: "Run Link Diagnostics" button (shown only when LLDP
host is resolvable); full results panel: health banner, physical layer,
SFP/DOM with power bars, NIC error counters, collapsible ethtool -S,
flow control/ring buffers, driver info, LLDP 2-col validation,
collapsible dmesg, switch port summary, "View in Pulse" link
- style.css: all .diag-* CSS classes with terminal aesthetic
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 16:03:54 -05:00
|
|
|
|
// 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" onclick="runDiagnostic('${escHtml(swName)}', ${idx})">Run Link Diagnostics</button>
|
|
|
|
|
|
<span class="diag-status" id="diag-status"></span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="diag-results" id="diag-results"></div>` : '';
|
|
|
|
|
|
|
feat: inspector page, link debug enhancements, security hardening
- Add /inspector page: visual model-accurate switch chassis diagrams
(USF5P, USL8A, US24PRO, USPPDUP, USMINI), clickable port blocks
with color coding (green=up, amber=PoE, cyan=uplink, grey=down),
detail panel with stats/PoE/LLDP, LLDP-based path debug side-by-side
- Link Debug: port number badges (#N), LLDP neighbor line, PoE class/max,
collapsible host/switch panels with sessionStorage persistence
- monitor.py: collect LLDP neighbor map + PoE class/max/mode per switch
port; PulseClient uses requests.Session() for HTTP keep-alive; add
shlex.quote() around interface names (defense-in-depth)
- Security: suppress buttons use data-* attrs + delegated click handler
instead of inline onclick with Jinja2 variable interpolation; remove
| safe filter from user-controlled fields in suppressions.html;
setDuration() takes explicit el param instead of implicit event global
- db.py: thread-local connection reuse with ping(reconnect=True) to
avoid a new TCP handshake per query
- .gitignore: add config.json (contains credentials), __pycache__
- README: full rewrite covering architecture, all 4 pages, alert logic,
config reference, deployment, troubleshooting, security notes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 15:39:48 -05:00
|
|
|
|
const inner = document.getElementById('inspector-panel-inner');
|
|
|
|
|
|
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" onclick="closePanel()">✕</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="panel-section-title">Link</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">Duplex</span><span class="panel-val">${duplexStr}</span></div>
|
|
|
|
|
|
<div class="panel-row"><span class="panel-label">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>
|
|
|
|
|
|
|
|
|
|
|
|
${poeHtml}
|
|
|
|
|
|
${trafficHtml}
|
|
|
|
|
|
${errHtml}
|
|
|
|
|
|
${lldpHtml}
|
|
|
|
|
|
${pathHtml}
|
feat: deep link diagnostics via Pulse SSH
Adds comprehensive per-port link troubleshooting triggered from the
Inspector panel when a port has an LLDP-identified server counterpart.
- diagnose.py: DiagnosticsRunner with 15-section SSH command (carrier,
operstate, sysfs counters, ethtool, ethtool -i/-a/-g/-S/-m, ip link,
ip addr, ip route, dmesg, lldpctl); parsers for all sections; health
analyzer with 14 check codes (NO_CARRIER, HALF_DUPLEX, SPEED_MISMATCH,
SFP_RX_CRITICAL, CARRIER_FLAPPING, CRC_ERRORS_HIGH, LLDP_MISMATCH, etc.)
- monitor.py: PulseClient now tracks last_execution_id so callers can
link back to the raw Pulse execution URL
- app.py: POST /api/diagnose + GET /api/diagnose/<job_id> with daemon
thread background execution and 10-minute in-memory job store
- inspector.html: "Run Link Diagnostics" button (shown only when LLDP
host is resolvable); full results panel: health banner, physical layer,
SFP/DOM with power bars, NIC error counters, collapsible ethtool -S,
flow control/ring buffers, driver info, LLDP 2-col validation,
collapsible dmesg, switch port summary, "View in Pulse" link
- style.css: all .diag-* CSS classes with terminal aesthetic
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 16:03:54 -05:00
|
|
|
|
${diagHtml}
|
feat: inspector page, link debug enhancements, security hardening
- Add /inspector page: visual model-accurate switch chassis diagrams
(USF5P, USL8A, US24PRO, USPPDUP, USMINI), clickable port blocks
with color coding (green=up, amber=PoE, cyan=uplink, grey=down),
detail panel with stats/PoE/LLDP, LLDP-based path debug side-by-side
- Link Debug: port number badges (#N), LLDP neighbor line, PoE class/max,
collapsible host/switch panels with sessionStorage persistence
- monitor.py: collect LLDP neighbor map + PoE class/max/mode per switch
port; PulseClient uses requests.Session() for HTTP keep-alive; add
shlex.quote() around interface names (defense-in-depth)
- Security: suppress buttons use data-* attrs + delegated click handler
instead of inline onclick with Jinja2 variable interpolation; remove
| safe filter from user-controlled fields in suppressions.html;
setDuration() takes explicit el param instead of implicit event global
- db.py: thread-local connection reuse with ping(reconnect=True) to
avoid a new TCP handshake per query
- .gitignore: add config.json (contains credentials), __pycache__
- README: full rewrite covering architecture, all 4 pages, alert logic,
config reference, deployment, troubleshooting, security notes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 15:39:48 -05:00
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
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';
|
|
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="panel-section-title">Path Debug <span class="path-conn-type">${escHtml(connType)}</span></div>
|
|
|
|
|
|
<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>${fmtSpeed(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 ? fmtSpeed(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>
|
|
|
|
|
|
${sfpDomHtml}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Render all switches ──────────────────────────────────────────────────
|
|
|
|
|
|
function renderInspector(data) {
|
|
|
|
|
|
_apiData = data;
|
|
|
|
|
|
const main = document.getElementById('inspector-main');
|
|
|
|
|
|
const switches = data.unifi_switches || {};
|
|
|
|
|
|
|
|
|
|
|
|
const upd = data.updated ? `Updated: ${data.updated}` : '';
|
|
|
|
|
|
const updEl = document.getElementById('inspector-updated');
|
|
|
|
|
|
if (updEl) updEl.textContent = upd;
|
|
|
|
|
|
|
|
|
|
|
|
if (!Object.keys(switches).length) {
|
|
|
|
|
|
main.innerHTML = '<p class="empty-state">No switch data available. Monitor may still be initialising.</p>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
main.innerHTML = Object.entries(switches)
|
|
|
|
|
|
.map(([swName, sw]) => renderChassis(swName, sw))
|
|
|
|
|
|
.join('');
|
|
|
|
|
|
|
|
|
|
|
|
// Re-apply selection highlight after re-render (dataset compare — no CSS escaping)
|
|
|
|
|
|
if (_selectedSwitch && _selectedIdx !== null) {
|
|
|
|
|
|
const block = Array.from(document.querySelectorAll('.switch-port-block')).find(
|
|
|
|
|
|
el => el.dataset.switch === _selectedSwitch && parseInt(el.dataset.portIdx, 10) === _selectedIdx
|
|
|
|
|
|
);
|
|
|
|
|
|
if (block) {
|
|
|
|
|
|
block.classList.add('selected');
|
|
|
|
|
|
renderPanel(_selectedSwitch, _selectedIdx);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Fetch and render ─────────────────────────────────────────────────────
|
|
|
|
|
|
async function loadInspector() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const resp = await fetch('/api/links');
|
|
|
|
|
|
if (!resp.ok) throw new Error('API error');
|
|
|
|
|
|
const data = await resp.json();
|
|
|
|
|
|
renderInspector(data);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
document.getElementById('inspector-main').innerHTML =
|
|
|
|
|
|
'<p class="empty-state">Failed to load inspector data.</p>';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
loadInspector();
|
|
|
|
|
|
setInterval(loadInspector, 60000);
|
feat: deep link diagnostics via Pulse SSH
Adds comprehensive per-port link troubleshooting triggered from the
Inspector panel when a port has an LLDP-identified server counterpart.
- diagnose.py: DiagnosticsRunner with 15-section SSH command (carrier,
operstate, sysfs counters, ethtool, ethtool -i/-a/-g/-S/-m, ip link,
ip addr, ip route, dmesg, lldpctl); parsers for all sections; health
analyzer with 14 check codes (NO_CARRIER, HALF_DUPLEX, SPEED_MISMATCH,
SFP_RX_CRITICAL, CARRIER_FLAPPING, CRC_ERRORS_HIGH, LLDP_MISMATCH, etc.)
- monitor.py: PulseClient now tracks last_execution_id so callers can
link back to the raw Pulse execution URL
- app.py: POST /api/diagnose + GET /api/diagnose/<job_id> with daemon
thread background execution and 10-minute in-memory job store
- inspector.html: "Run Link Diagnostics" button (shown only when LLDP
host is resolvable); full results panel: health banner, physical layer,
SFP/DOM with power bars, NIC error counters, collapsible ethtool -S,
flow control/ring buffers, driver info, LLDP 2-col validation,
collapsible dmesg, switch port summary, "View in Pulse" link
- style.css: all .diag-* CSS classes with terminal aesthetic
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 16:03:54 -05:00
|
|
|
|
|
|
|
|
|
|
// ── Link Diagnostics ─────────────────────────────────────────────────
|
|
|
|
|
|
let _diagPollTimer = null;
|
|
|
|
|
|
|
|
|
|
|
|
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 = '';
|
|
|
|
|
|
|
|
|
|
|
|
fetch('/api/diagnose', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {'Content-Type': 'application/json'},
|
|
|
|
|
|
body: JSON.stringify({switch_name: swName, port_idx: portIdx}),
|
|
|
|
|
|
})
|
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
|
.then(resp => {
|
|
|
|
|
|
if (resp.error) {
|
|
|
|
|
|
statusEl.textContent = 'Error: ' + resp.error;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
statusEl.textContent = 'Collecting diagnostics via Pulse...';
|
|
|
|
|
|
pollDiagnostic(resp.job_id, statusEl, resultsEl);
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(e => {
|
|
|
|
|
|
statusEl.textContent = 'Request failed: ' + e;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function pollDiagnostic(jobId, statusEl, resultsEl) {
|
|
|
|
|
|
let attempts = 0;
|
|
|
|
|
|
_diagPollTimer = setInterval(() => {
|
|
|
|
|
|
attempts++;
|
|
|
|
|
|
if (attempts > 120) { // 2min timeout
|
|
|
|
|
|
clearInterval(_diagPollTimer);
|
|
|
|
|
|
statusEl.textContent = 'Timed out waiting for results.';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
fetch(`/api/diagnose/${jobId}`)
|
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
|
.then(resp => {
|
|
|
|
|
|
if (resp.status === 'done') {
|
|
|
|
|
|
clearInterval(_diagPollTimer);
|
|
|
|
|
|
_diagPollTimer = null;
|
|
|
|
|
|
statusEl.textContent = '';
|
|
|
|
|
|
renderDiagnosticResults(resp.result, resultsEl);
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-03-14 14:31:57 -04:00
|
|
|
|
.catch(() => {
|
|
|
|
|
|
clearInterval(_diagPollTimer);
|
|
|
|
|
|
_diagPollTimer = null;
|
|
|
|
|
|
statusEl.textContent = 'Error: lost connection while collecting diagnostics.';
|
|
|
|
|
|
});
|
feat: deep link diagnostics via Pulse SSH
Adds comprehensive per-port link troubleshooting triggered from the
Inspector panel when a port has an LLDP-identified server counterpart.
- diagnose.py: DiagnosticsRunner with 15-section SSH command (carrier,
operstate, sysfs counters, ethtool, ethtool -i/-a/-g/-S/-m, ip link,
ip addr, ip route, dmesg, lldpctl); parsers for all sections; health
analyzer with 14 check codes (NO_CARRIER, HALF_DUPLEX, SPEED_MISMATCH,
SFP_RX_CRITICAL, CARRIER_FLAPPING, CRC_ERRORS_HIGH, LLDP_MISMATCH, etc.)
- monitor.py: PulseClient now tracks last_execution_id so callers can
link back to the raw Pulse execution URL
- app.py: POST /api/diagnose + GET /api/diagnose/<job_id> with daemon
thread background execution and 10-minute in-memory job store
- inspector.html: "Run Link Diagnostics" button (shown only when LLDP
host is resolvable); full results panel: health banner, physical layer,
SFP/DOM with power bars, NIC error counters, collapsible ethtool -S,
flow control/ring buffers, driver info, LLDP 2-col validation,
collapsible dmesg, switch port summary, "View in Pulse" link
- style.css: all .diag-* CSS classes with terminal aesthetic
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 16:03:54 -05:00
|
|
|
|
}, 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">${fmtSpeed(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" onclick="this.parentElement.classList.toggle('diag-open')">
|
|
|
|
|
|
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 & 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" onclick="this.parentElement.classList.toggle('diag-open')">
|
|
|
|
|
|
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 ? fmtSpeed(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>`;
|
|
|
|
|
|
}
|
feat: inspector page, link debug enhancements, security hardening
- Add /inspector page: visual model-accurate switch chassis diagrams
(USF5P, USL8A, US24PRO, USPPDUP, USMINI), clickable port blocks
with color coding (green=up, amber=PoE, cyan=uplink, grey=down),
detail panel with stats/PoE/LLDP, LLDP-based path debug side-by-side
- Link Debug: port number badges (#N), LLDP neighbor line, PoE class/max,
collapsible host/switch panels with sessionStorage persistence
- monitor.py: collect LLDP neighbor map + PoE class/max/mode per switch
port; PulseClient uses requests.Session() for HTTP keep-alive; add
shlex.quote() around interface names (defense-in-depth)
- Security: suppress buttons use data-* attrs + delegated click handler
instead of inline onclick with Jinja2 variable interpolation; remove
| safe filter from user-controlled fields in suppressions.html;
setDuration() takes explicit el param instead of implicit event global
- db.py: thread-local connection reuse with ping(reconnect=True) to
avoid a new TCP handshake per query
- .gitignore: add config.json (contains credentials), __pycache__
- README: full rewrite covering architecture, all 4 pages, alert logic,
config reference, deployment, troubleshooting, security notes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 15:39:48 -05:00
|
|
|
|
</script>
|
|
|
|
|
|
{% endblock %}
|