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:
@@ -22,6 +22,10 @@
|
||||
class="nav-link {% if request.endpoint == 'links_page' %}active{% endif %}">
|
||||
Link Debug
|
||||
</a>
|
||||
<a href="{{ url_for('inspector') }}"
|
||||
class="nav-link {% if request.endpoint == 'inspector' %}active{% endif %}">
|
||||
Inspector
|
||||
</a>
|
||||
<a href="{{ url_for('suppressions_page') }}"
|
||||
class="nav-link {% if request.endpoint == 'suppressions_page' %}active{% endif %}">
|
||||
Suppressions
|
||||
|
||||
@@ -116,7 +116,9 @@
|
||||
|
||||
<div class="host-actions">
|
||||
<button class="btn-sm btn-suppress"
|
||||
onclick="openSuppressModal('host', '{{ name }}', '')"
|
||||
data-sup-type="host"
|
||||
data-sup-name="{{ name }}"
|
||||
data-sup-detail=""
|
||||
title="Suppress alerts for this host">
|
||||
🔕 Suppress
|
||||
</button>
|
||||
@@ -164,7 +166,9 @@
|
||||
<td>
|
||||
{% if not d.connected %}
|
||||
<button class="btn-sm btn-suppress"
|
||||
onclick="openSuppressModal('unifi_device', '{{ d.name }}', '')">
|
||||
data-sup-type="unifi_device"
|
||||
data-sup-name="{{ d.name }}"
|
||||
data-sup-detail="">
|
||||
🔕 Suppress
|
||||
</button>
|
||||
{% endif %}
|
||||
@@ -221,7 +225,9 @@
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn-sm btn-suppress"
|
||||
onclick="openSuppressModal('{{ 'unifi_device' if e.event_type == 'unifi_device_offline' else 'interface' if e.event_type == 'interface_down' else 'host' }}', '{{ e.target_name }}', '{{ e.target_detail or '' }}')"
|
||||
data-sup-type="{{ 'unifi_device' if e.event_type == 'unifi_device_offline' else 'interface' if e.event_type == 'interface_down' else 'host' }}"
|
||||
data-sup-name="{{ e.target_name }}"
|
||||
data-sup-detail="{{ e.target_detail or '' }}"
|
||||
title="Suppress">🔕</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -271,11 +277,11 @@
|
||||
<div class="form-group" style="margin-bottom:0">
|
||||
<label>Duration</label>
|
||||
<div class="duration-pills">
|
||||
<button type="button" class="pill" onclick="setDuration(30)">30 min</button>
|
||||
<button type="button" class="pill" onclick="setDuration(60)">1 hr</button>
|
||||
<button type="button" class="pill" onclick="setDuration(240)">4 hr</button>
|
||||
<button type="button" class="pill" onclick="setDuration(480)">8 hr</button>
|
||||
<button type="button" class="pill pill-manual active" onclick="setDuration(null)">Manual ∞</button>
|
||||
<button type="button" class="pill" onclick="setDuration(30, this)">30 min</button>
|
||||
<button type="button" class="pill" onclick="setDuration(60, this)">1 hr</button>
|
||||
<button type="button" class="pill" onclick="setDuration(240, this)">4 hr</button>
|
||||
<button type="button" class="pill" onclick="setDuration(480, this)">8 hr</button>
|
||||
<button type="button" class="pill pill-manual active" onclick="setDuration(null, this)">Manual ∞</button>
|
||||
</div>
|
||||
<input type="hidden" id="sup-expires" name="expires_minutes" value="">
|
||||
<div class="form-hint" id="duration-hint">Persists until manually removed.</div>
|
||||
|
||||
391
templates/inspector.html
Normal file
391
templates/inspector.html
Normal file
@@ -0,0 +1,391 @@
|
||||
{% 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
`;
|
||||
|
||||
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);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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'});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,11 +50,11 @@
|
||||
<div class="form-group">
|
||||
<label>Duration</label>
|
||||
<div class="duration-pills">
|
||||
<button type="button" class="pill" onclick="setDur(30)">30 min</button>
|
||||
<button type="button" class="pill" onclick="setDur(60)">1 hr</button>
|
||||
<button type="button" class="pill" onclick="setDur(240)">4 hr</button>
|
||||
<button type="button" class="pill" onclick="setDur(480)">8 hr</button>
|
||||
<button type="button" class="pill pill-manual active" onclick="setDur(null)">Manual ∞</button>
|
||||
<button type="button" class="pill" onclick="setDur(30, this)">30 min</button>
|
||||
<button type="button" class="pill" onclick="setDur(60, this)">1 hr</button>
|
||||
<button type="button" class="pill" onclick="setDur(240, this)">4 hr</button>
|
||||
<button type="button" class="pill" onclick="setDur(480, this)">8 hr</button>
|
||||
<button type="button" class="pill pill-manual active" onclick="setDur(null, this)">Manual ∞</button>
|
||||
</div>
|
||||
<input type="hidden" id="s-expires" name="expires_minutes" value="">
|
||||
<div class="form-hint" id="s-dur-hint">Persists until manually removed.</div>
|
||||
@@ -86,7 +86,7 @@
|
||||
{% for s in active %}
|
||||
<tr id="sup-row-{{ s.id }}">
|
||||
<td><span class="badge badge-info">{{ s.target_type }}</span></td>
|
||||
<td>{{ s.target_name or '<em>all</em>' | safe }}</td>
|
||||
<td>{{ s.target_name or 'all' }}</td>
|
||||
<td>{{ s.target_detail or '–' }}</td>
|
||||
<td>{{ s.reason }}</td>
|
||||
<td>{{ s.suppressed_by }}</td>
|
||||
|
||||
Reference in New Issue
Block a user