- 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>
502 lines
20 KiB
HTML
502 lines
20 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}Link Debug – GANDALF{% endblock %}
|
||
|
||
{% block content %}
|
||
|
||
<div class="page-header">
|
||
<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 (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>
|
||
|
||
<div id="links-container">
|
||
<div class="link-loading">Loading link statistics</div>
|
||
</div>
|
||
|
||
{% endblock %}
|
||
|
||
{% block scripts %}
|
||
<script>
|
||
// ── Formatting helpers ────────────────────────────────────────────
|
||
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 fmtRateBar(bytesPerSec, linkSpeedMbps) {
|
||
if (!linkSpeedMbps || linkSpeedMbps <= 0) return 0;
|
||
const mbps = (bytesPerSec * 8) / 1e6;
|
||
return Math.min(100, (mbps / linkSpeedMbps) * 100);
|
||
}
|
||
|
||
function fmtSpeed(mbps) {
|
||
if (mbps === null || mbps === undefined) return '–';
|
||
if (mbps >= 1000) return (mbps/1000).toFixed(0) + ' Gbps';
|
||
return mbps + ' Mbps';
|
||
}
|
||
|
||
function fmtDuplex(d) {
|
||
if (!d) return '–';
|
||
return d.charAt(0).toUpperCase() + d.slice(1);
|
||
}
|
||
|
||
function fmtTemp(c) {
|
||
if (c === null || c === undefined) return '–';
|
||
return c.toFixed(1) + '°C';
|
||
}
|
||
|
||
function fmtVoltage(v) {
|
||
if (v === null || v === undefined) return '–';
|
||
return v.toFixed(2) + 'V';
|
||
}
|
||
|
||
function fmtPower(dbm) {
|
||
if (dbm === null || dbm === undefined) return '–';
|
||
return dbm.toFixed(2) + ' dBm';
|
||
}
|
||
|
||
function fmtBias(ma) {
|
||
if (ma === null || ma === undefined) return '–';
|
||
return ma.toFixed(2) + ' mA';
|
||
}
|
||
|
||
function fmtErrors(rate) {
|
||
if (rate === null || rate === undefined) return '–';
|
||
if (rate < 0.001) return '<span class="counter-zero">0 /s</span>';
|
||
return `<span class="counter-nonzero">${rate.toFixed(3)} /s</span>`;
|
||
}
|
||
|
||
function fmtCarrier(n) {
|
||
if (n === null || n === undefined) return '–';
|
||
const v = parseInt(n);
|
||
if (v <= 2) return `<span class="val-good">${v}</span>`;
|
||
if (v <= 10) return `<span class="val-warn">${v}</span>`;
|
||
return `<span class="val-crit">${v}</span>`;
|
||
}
|
||
|
||
// Power level: returns {cls, pct} for -30..0 dBm scale
|
||
function rxPowerClass(dbm) {
|
||
if (dbm === null || dbm === undefined) return {cls:'power-ok', pct:0};
|
||
const pct = Math.max(0, Math.min(100, (dbm + 30) / 30 * 100));
|
||
let cls = 'power-ok';
|
||
if (dbm < -25 || dbm > 0) cls = 'power-crit';
|
||
else if (dbm < -20) cls = 'power-warn';
|
||
return {cls, pct};
|
||
}
|
||
|
||
function txPowerClass(dbm) {
|
||
if (dbm === null || dbm === undefined) return {cls:'power-ok', pct:0};
|
||
const pct = Math.max(0, Math.min(100, (dbm + 20) / 20 * 100));
|
||
let cls = 'power-ok';
|
||
if (dbm < -15 || dbm > 2) cls = 'power-crit';
|
||
else if (dbm < -10) cls = 'power-warn';
|
||
return {cls, pct};
|
||
}
|
||
|
||
function tempClass(c) {
|
||
if (c === null || c === undefined) return 'val-neutral';
|
||
if (c > 80) return 'val-crit';
|
||
if (c > 60) return 'val-warn';
|
||
return 'val-good';
|
||
}
|
||
|
||
function voltageClass(v) {
|
||
if (v === null || v === undefined) return 'val-neutral';
|
||
if (v < 3.0 || v > 3.6) return 'val-crit';
|
||
if (v < 3.1 || v > 3.5) return 'val-warn';
|
||
return 'val-good';
|
||
}
|
||
|
||
function portTypeLabel(pt) {
|
||
if (!pt) return {label:'–', cls:''};
|
||
const u = pt.toUpperCase();
|
||
if (u.includes('FIBRE') || u.includes('FIBER') || u.includes('SFP'))
|
||
return {label: pt, cls: 'type-fibre'};
|
||
if (u.includes('DA') || u.includes('DIRECT'))
|
||
return {label: pt, cls: 'type-da'};
|
||
return {label: pt, cls: 'type-copper'};
|
||
}
|
||
|
||
// ── Render a single interface card ────────────────────────────────
|
||
function renderIfaceCard(ifaceName, d) {
|
||
const speed = fmtSpeed(d.speed_mbps);
|
||
const duplex = fmtDuplex(d.duplex);
|
||
const ptype = portTypeLabel(d.port_type);
|
||
const autoneg= d.auto_neg !== undefined ? (d.auto_neg ? 'On' : 'Off') : '–';
|
||
const linkDet= d.link_detected !== undefined ? (d.link_detected ? '<span class="val-good">Yes</span>' : '<span class="val-crit">No</span>') : '–';
|
||
|
||
// Traffic bars
|
||
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);
|
||
|
||
// SFP / optical section
|
||
let sfpHtml = '';
|
||
const sfp = d.sfp;
|
||
if (sfp && Object.keys(sfp).length > 0) {
|
||
const tx = txPowerClass(sfp.tx_power_dbm);
|
||
const rx = rxPowerClass(sfp.rx_power_dbm);
|
||
const tcls = tempClass(sfp.temp_c);
|
||
const vcls = voltageClass(sfp.voltage_v);
|
||
|
||
const vendorStr = [sfp.vendor, sfp.part_no].filter(Boolean).join(' / ') || '–';
|
||
const sfpTypeStr= [sfp.sfp_type, sfp.connector, sfp.wavelength_nm ? sfp.wavelength_nm + 'nm' : ''].filter(Boolean).join(' · ') || '';
|
||
|
||
sfpHtml = `
|
||
<div class="sfp-panel">
|
||
<div class="sfp-vendor-row">
|
||
<span>${escHtml(vendorStr)}</span>
|
||
${sfpTypeStr ? `<span style="margin-left:8px;color:var(--text-muted)">${escHtml(sfpTypeStr)}</span>` : ''}
|
||
</div>
|
||
<div class="sfp-grid">
|
||
<div class="sfp-stat">
|
||
<span class="sfp-stat-label">Temp</span>
|
||
<span class="sfp-stat-value ${tcls}">${fmtTemp(sfp.temp_c)}</span>
|
||
</div>
|
||
<div class="sfp-stat">
|
||
<span class="sfp-stat-label">Voltage</span>
|
||
<span class="sfp-stat-value ${vcls}">${fmtVoltage(sfp.voltage_v)}</span>
|
||
</div>
|
||
<div class="sfp-stat">
|
||
<span class="sfp-stat-label">Bias</span>
|
||
<span class="sfp-stat-value">${fmtBias(sfp.bias_ma)}</span>
|
||
</div>
|
||
<div class="sfp-stat">
|
||
<span class="sfp-stat-label">TX Power</span>
|
||
<span class="sfp-stat-value ${tx.cls === 'power-ok' ? 'val-good' : tx.cls === 'power-warn' ? 'val-warn' : 'val-crit'}">${fmtPower(sfp.tx_power_dbm)}</span>
|
||
<div class="power-row">
|
||
<div class="power-track"><div class="power-fill ${tx.cls}" style="width:${tx.pct}%"></div></div>
|
||
</div>
|
||
</div>
|
||
<div class="sfp-stat">
|
||
<span class="sfp-stat-label">RX Power</span>
|
||
<span class="sfp-stat-value ${rx.cls === 'power-ok' ? 'val-good' : rx.cls === 'power-warn' ? 'val-warn' : 'val-crit'}">${fmtPower(sfp.rx_power_dbm)}</span>
|
||
<div class="power-row">
|
||
<div class="power-track"><div class="power-fill ${rx.cls}" style="width:${rx.pct}%"></div></div>
|
||
</div>
|
||
</div>
|
||
<div class="sfp-stat">
|
||
<span class="sfp-stat-label">RX – TX</span>
|
||
<span class="sfp-stat-value ${sfp.rx_power_dbm !== undefined && sfp.tx_power_dbm !== undefined ? (Math.abs(sfp.rx_power_dbm - sfp.tx_power_dbm) > 8 ? 'val-warn' : 'val-neutral') : 'val-neutral'}">
|
||
${sfp.rx_power_dbm !== undefined && sfp.tx_power_dbm !== undefined
|
||
? (sfp.rx_power_dbm - sfp.tx_power_dbm).toFixed(2) + ' dBm'
|
||
: '–'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
return `
|
||
<div class="link-iface-card">
|
||
<div class="link-iface-header">
|
||
<span class="link-iface-name">${escHtml(ifaceName)}</span>
|
||
${speed !== '–' ? `<span class="link-iface-speed">${speed}</span>` : ''}
|
||
${ptype.label !== '–' ? `<span class="link-iface-type ${ptype.cls}">${escHtml(ptype.label)}</span>` : ''}
|
||
</div>
|
||
<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">${autoneg}</span>
|
||
</div>
|
||
<div class="link-stat">
|
||
<span class="link-stat-label">Link Det.</span>
|
||
<span class="link-stat-value">${linkDet}</span>
|
||
</div>
|
||
<div class="link-stat">
|
||
<span class="link-stat-label">Carrier Chg</span>
|
||
<span class="link-stat-value">${fmtCarrier(d.carrier_changes)}</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>
|
||
|
||
${(txRate !== undefined || rxRate !== undefined) ? `
|
||
<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>` : ''}
|
||
|
||
${sfpHtml}
|
||
</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 || {};
|
||
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;
|
||
}
|
||
|
||
const upd = data.updated ? `Updated: ${data.updated}` : '';
|
||
const updEl = document.getElementById('links-updated');
|
||
if (updEl) updEl.textContent = upd;
|
||
|
||
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))
|
||
.join('');
|
||
|
||
const hostIp = ifaces[Object.keys(ifaces)[0]]?.host_ip || '';
|
||
return `
|
||
<div class="link-host-panel" id="${escHtml(hostName)}">
|
||
<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>'}
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
document.getElementById('links-container').innerHTML =
|
||
`<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) {
|
||
if (el.classList.contains('collapsed')) togglePanel(el);
|
||
el.scrollIntoView({behavior:'smooth', block:'start'});
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Fetch and render ──────────────────────────────────────────────
|
||
async function loadLinks() {
|
||
try {
|
||
const resp = await fetch('/api/links');
|
||
if (!resp.ok) throw new Error('API error');
|
||
const data = await resp.json();
|
||
renderLinks(data);
|
||
} catch(e) {
|
||
document.getElementById('links-container').innerHTML =
|
||
'<p class="empty-state">Failed to load link data.</p>';
|
||
}
|
||
}
|
||
|
||
loadLinks();
|
||
setInterval(loadLinks, 60000);
|
||
</script>
|
||
{% endblock %}
|