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:
@@ -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'});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user