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>
This commit is contained in:
2026-03-03 15:39:48 -05:00
parent fa7512a2c2
commit 0278dad502
12 changed files with 1548 additions and 176 deletions

View File

@@ -7,7 +7,7 @@
<h1 class="page-title">Link Debug</h1>
<p class="page-sub">
Per-interface stats: speed, duplex, SFP optical levels, TX/RX rates, errors, and carrier changes.
Data collected via Prometheus node_exporter + SSH ethtool every poll cycle.
Data collected via Prometheus node_exporter + SSH ethtool (servers) and UniFi API (switches) every poll cycle.
<span id="links-updated" style="margin-left:10px; color:var(--text-muted); font-size:.85em;"></span>
</p>
</div>
@@ -262,10 +262,177 @@ function renderIfaceCard(ifaceName, d) {
</div>`;
}
// ── Render a single UniFi switch port card ────────────────────────
function renderPortCard(portName, d) {
const up = d.up;
const speed = up ? fmtSpeed(d.speed_mbps) : 'DOWN';
const duplex = d.full_duplex ? 'Full' : (up ? 'Half' : '');
const media = d.media || '';
const uplinkBadge = d.is_uplink
? '<span class="port-badge port-badge-uplink">UPLINK</span>' : '';
const poeBadge = (d.poe_power != null && d.poe_power > 0)
? `<span class="port-badge port-badge-poe">PoE ${d.poe_power.toFixed(1)}W</span>` : '';
const numBadge = d.port_idx
? `<span class="port-badge port-badge-num">#${d.port_idx}</span>` : '';
const lldpHtml = (d.lldp && d.lldp.system_name)
? `<div class="port-lldp">→ ${escHtml(d.lldp.system_name)}${d.lldp.port_id ? ' (' + escHtml(d.lldp.port_id) + ')' : ''}</div>` : '';
const poeMaxHtml = (d.poe_class != null)
? `<div class="port-poe-info">PoE class ${d.poe_class}${d.poe_max_power ? ' / max ' + d.poe_max_power.toFixed(1) + 'W' : ''}</div>` : '';
const txRate = d.tx_bytes_rate;
const rxRate = d.rx_bytes_rate;
const txPct = fmtRateBar(txRate, d.speed_mbps);
const rxPct = fmtRateBar(rxRate, d.speed_mbps);
const txStr = fmtRate(txRate);
const rxStr = fmtRate(rxRate);
return `
<div class="link-iface-card${up ? '' : ' port-down'}">
<div class="link-iface-header">
<span class="link-iface-name">${escHtml(portName)}</span>
<span class="link-iface-speed ${up ? '' : 'val-crit'}">${speed}</span>
${numBadge}${uplinkBadge}${poeBadge}
${media ? `<span class="link-iface-type type-${media.toLowerCase().includes('sfp') ? 'fibre' : 'copper'}">${escHtml(media)}</span>` : ''}
</div>
${lldpHtml}${poeMaxHtml}
<div class="link-stats-grid">
<div class="link-stat">
<span class="link-stat-label">Duplex</span>
<span class="link-stat-value ${duplex==='Full'?'val-good':duplex==='Half'?'val-crit':'val-neutral'}">${duplex}</span>
</div>
<div class="link-stat">
<span class="link-stat-label">Auto-neg</span>
<span class="link-stat-value val-neutral">${d.autoneg ? 'On' : 'Off'}</span>
</div>
<div class="link-stat">
<span class="link-stat-label">TX Errors</span>
<span class="link-stat-value">${fmtErrors(d.tx_errs_rate)}</span>
</div>
<div class="link-stat">
<span class="link-stat-label">RX Errors</span>
<span class="link-stat-value">${fmtErrors(d.rx_errs_rate)}</span>
</div>
<div class="link-stat">
<span class="link-stat-label">TX Drops</span>
<span class="link-stat-value">${fmtErrors(d.tx_drops_rate)}</span>
</div>
<div class="link-stat">
<span class="link-stat-label">RX Drops</span>
<span class="link-stat-value">${fmtErrors(d.rx_drops_rate)}</span>
</div>
</div>
${(up && (txRate != null || rxRate != null)) ? `
<div class="traffic-section">
<div class="traffic-row">
<span class="traffic-label">TX</span>
<div class="traffic-bar-track">
<div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div>
</div>
<span class="traffic-value">${txStr}</span>
</div>
<div class="traffic-row">
<span class="traffic-label">RX</span>
<div class="traffic-bar-track">
<div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div>
</div>
<span class="traffic-value">${rxStr}</span>
</div>
</div>` : ''}
</div>`;
}
// ── Render UniFi switches section ─────────────────────────────────
function renderUnifiSwitches(unifiSwitches) {
if (!unifiSwitches || !Object.keys(unifiSwitches).length) return '';
const panels = Object.entries(unifiSwitches).map(([swName, sw]) => {
const ports = sw.ports || {};
const allPorts= Object.entries(ports)
.sort(([,a],[,b]) => (a.port_idx||0) - (b.port_idx||0));
const upCount = allPorts.filter(([,d]) => d.up).length;
const downCount = allPorts.length - upCount;
const portCards = allPorts
.map(([pname, d]) => renderPortCard(pname, d))
.join('');
const meta = [
sw.model,
`${upCount} up`,
downCount ? `${downCount} down` : '',
].filter(Boolean).join(' · ');
return `
<div class="link-host-panel" id="unifi-${escHtml(swName)}">
<div class="link-host-title" onclick="togglePanel(this.closest('.link-host-panel'))">
<span class="link-host-name">${escHtml(swName)}</span>
${sw.ip ? `<span class="link-host-ip">${escHtml(sw.ip)}</span>` : ''}
<span class="link-host-upd">${escHtml(meta)}</span>
<span class="panel-toggle" title="Collapse / expand">[]</span>
</div>
<div class="link-ifaces-grid">
${portCards || '<div class="link-no-data">No port data available.</div>'}
</div>
</div>`;
}).join('');
return `
<div class="unifi-section-header">UniFi Switches</div>
<div class="link-host-list">${panels}</div>`;
}
// ── Collapse / expand panels ───────────────────────────────────────
function togglePanel(panel) {
panel.classList.toggle('collapsed');
const btn = panel.querySelector('.panel-toggle');
if (btn) btn.textContent = panel.classList.contains('collapsed') ? '[+]' : '[]';
const id = panel.id;
if (id) {
const saved = JSON.parse(sessionStorage.getItem('gandalfCollapsed') || '{}');
saved[id] = panel.classList.contains('collapsed');
sessionStorage.setItem('gandalfCollapsed', JSON.stringify(saved));
}
}
function restoreCollapseState() {
const saved = JSON.parse(sessionStorage.getItem('gandalfCollapsed') || '{}');
for (const [id, collapsed] of Object.entries(saved)) {
if (!collapsed) continue;
const panel = document.getElementById(id);
if (panel) {
panel.classList.add('collapsed');
const btn = panel.querySelector('.panel-toggle');
if (btn) btn.textContent = '[+]';
}
}
}
function collapseAll() {
document.querySelectorAll('.link-host-panel').forEach(panel => {
panel.classList.add('collapsed');
const btn = panel.querySelector('.panel-toggle');
if (btn) btn.textContent = '[+]';
});
sessionStorage.setItem('gandalfCollapsed', '{}'); // let restore pick it up next time
}
function expandAll() {
document.querySelectorAll('.link-host-panel').forEach(panel => {
panel.classList.remove('collapsed');
const btn = panel.querySelector('.panel-toggle');
if (btn) btn.textContent = '[]';
});
sessionStorage.setItem('gandalfCollapsed', '{}');
}
// ── Render all hosts ──────────────────────────────────────────────
function renderLinks(data) {
const hosts = data.hosts || {};
if (!Object.keys(hosts).length) {
const hosts = data.hosts || {};
const unifi = data.unifi_switches || {};
if (!Object.keys(hosts).length && !Object.keys(unifi).length) {
document.getElementById('links-container').innerHTML =
'<p class="empty-state">No link data collected yet. Monitor may still be initialising.</p>';
return;
@@ -275,7 +442,7 @@ function renderLinks(data) {
const updEl = document.getElementById('links-updated');
if (updEl) updEl.textContent = upd;
const html = Object.entries(hosts).map(([hostName, ifaces]) => {
const serverHtml = Object.entries(hosts).map(([hostName, ifaces]) => {
const ifaceCards = Object.entries(ifaces)
.sort(([a],[b]) => a.localeCompare(b))
.map(([ifaceName, d]) => renderIfaceCard(ifaceName, d))
@@ -284,9 +451,10 @@ function renderLinks(data) {
const hostIp = ifaces[Object.keys(ifaces)[0]]?.host_ip || '';
return `
<div class="link-host-panel" id="${escHtml(hostName)}">
<div class="link-host-title">
<div class="link-host-title" onclick="togglePanel(this.closest('.link-host-panel'))">
<span class="link-host-name">${escHtml(hostName)}</span>
${hostIp ? `<span class="link-host-ip">${escHtml(hostIp)}</span>` : ''}
<span class="panel-toggle" title="Collapse / expand">[]</span>
</div>
<div class="link-ifaces-grid">
${ifaceCards || '<div class="link-no-data">No interface data available.</div>'}
@@ -295,12 +463,22 @@ function renderLinks(data) {
}).join('');
document.getElementById('links-container').innerHTML =
`<div class="link-host-list">${html}</div>`;
`<div class="link-collapse-bar">
<button class="btn btn-secondary btn-sm" onclick="collapseAll()">Collapse all</button>
<button class="btn btn-secondary btn-sm" onclick="expandAll()">Expand all</button>
</div>` +
`<div class="link-host-list">${serverHtml}</div>` +
renderUnifiSwitches(unifi);
restoreCollapseState();
// Jump to anchor if URL has #hostname
if (location.hash) {
const el = document.querySelector(location.hash);
if (el) el.scrollIntoView({behavior:'smooth', block:'start'});
if (el) {
if (el.classList.contains('collapsed')) togglePanel(el);
el.scrollIntoView({behavior:'smooth', block:'start'});
}
}
}