Files
gandalf/templates/links.html
Jared Vititoe 0ca6b1f744 feat: link health summary, recently resolved panel, event duration
- dashboard: pass recent_resolved (last 24h, limit 10) to index template;
  render "Recently Resolved" section showing type, target, resolved time,
  and calculated duration (first_seen → resolved_at)
- dashboard: event-age spans now also update via setInterval; duration
  shown for resolved events (e.g. "2h 15m")
- links page: link health summary panel shows server iface count,
  error/flap counts, switch port up/down, PoE total draw/capacity bar;
  only shows problematic stats if non-zero; shows "All OK ✔" when clean
- style.css: new classes for summary panel, resolved row/badge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 21:48:40 -04:00

627 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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'};
}
// ── Error alert badge ─────────────────────────────────────────────
function errorBadges(d) {
const badges = [];
if ((d.tx_errs_rate || 0) > 0.01 || (d.rx_errs_rate || 0) > 0.01)
badges.push('<span class="link-alert-badge">ERR</span>');
if ((d.tx_drops_rate || 0) > 0.1 || (d.rx_drops_rate || 0) > 0.1)
badges.push('<span class="link-alert-badge link-alert-amber">DROP</span>');
if ((d.carrier_changes || 0) > 10)
badges.push('<span class="link-alert-badge link-alert-amber">FLAP</span>');
return badges.join('');
}
// ── 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>` : ''}
${errorBadges(d)}
</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>` : '';
let poeMaxHtml = '';
if (d.poe_class != null) {
const poeDraw = d.poe_power || 0;
const poeMax = d.poe_max_power || 0;
const poePct = poeMax > 0 ? Math.min(100, (poeDraw / poeMax) * 100) : 0;
const poeBarCls = poePct >= 80 ? 'poe-bar-crit' : poePct >= 60 ? 'poe-bar-warn' : 'poe-bar-ok';
const poeMode = d.poe_mode ? ` · ${escHtml(d.poe_mode)}` : '';
poeMaxHtml = `<div class="port-poe-info">
PoE class ${d.poe_class}${poeMax > 0 ? ` · ${poeDraw.toFixed(1)}W / ${poeMax.toFixed(1)}W max${poeMode}` : poeMode}
${poeMax > 0 ? `<div class="poe-bar-track"><div class="poe-bar-fill ${poeBarCls}" style="width:${poePct.toFixed(1)}%"></div></div>` : ''}
</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>` : ''}
${errorBadges(d)}
</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(localStorage.getItem('gandalfCollapsed') || '{}');
saved[id] = panel.classList.contains('collapsed');
localStorage.setItem('gandalfCollapsed', JSON.stringify(saved));
}
}
function restoreCollapseState() {
const saved = JSON.parse(localStorage.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 = '[+]';
const id = panel.id;
if (id) {
const saved = JSON.parse(localStorage.getItem('gandalfCollapsed') || '{}');
saved[id] = true;
localStorage.setItem('gandalfCollapsed', JSON.stringify(saved));
}
});
}
function expandAll() {
document.querySelectorAll('.link-host-panel').forEach(panel => {
panel.classList.remove('collapsed');
const btn = panel.querySelector('.panel-toggle');
if (btn) btn.textContent = '[]';
const id = panel.id;
if (id) {
const saved = JSON.parse(localStorage.getItem('gandalfCollapsed') || '{}');
saved[id] = false;
localStorage.setItem('gandalfCollapsed', JSON.stringify(saved));
}
});
}
// ── Link health summary ───────────────────────────────────────────
function buildLinkSummary(hosts, unifiSwitches) {
let svrTotal = 0, svrErrors = 0, svrFlap = 0;
let swTotal = 0, swUp = 0, swDown = 0, swErrors = 0;
let poeDrawW = 0, poeMaxW = 0;
for (const ifaces of Object.values(hosts)) {
for (const d of Object.values(ifaces)) {
svrTotal++;
if ((d.tx_errs_rate || 0) > 0.01 || (d.rx_errs_rate || 0) > 0.01) svrErrors++;
if ((d.carrier_changes || 0) > 10) svrFlap++;
}
}
for (const sw of Object.values(unifiSwitches || {})) {
for (const d of Object.values(sw.ports || {})) {
swTotal++;
if (d.up) swUp++; else swDown++;
if ((d.tx_errs_rate || 0) > 0.01 || (d.rx_errs_rate || 0) > 0.01) swErrors++;
if (d.poe_power != null) poeDrawW += d.poe_power;
if (d.poe_max_power != null) poeMaxW += d.poe_max_power;
}
}
const poePct = poeMaxW > 0 ? (poeDrawW / poeMaxW * 100) : null;
const poeBarCls = poePct >= 80 ? 'poe-bar-crit' : poePct >= 60 ? 'poe-bar-warn' : 'poe-bar-ok';
const totalErrors = svrErrors + swErrors;
const hasAlerts = totalErrors > 0 || svrFlap > 0 || swDown > 0;
return `
<div class="link-summary-panel${hasAlerts ? ' link-summary-has-alerts' : ''}">
<div class="link-summary-grid">
<div class="link-summary-stat">
<span class="lss-label">Server Ifaces</span>
<span class="lss-value">${svrTotal}</span>
</div>
${svrErrors > 0 ? `<div class="link-summary-stat lss-alert">
<span class="lss-label">Iface Errors</span>
<span class="lss-value val-crit">${svrErrors}</span>
</div>` : ''}
${svrFlap > 0 ? `<div class="link-summary-stat lss-alert">
<span class="lss-label">Flapping</span>
<span class="lss-value val-warn">${svrFlap}</span>
</div>` : ''}
${swTotal > 0 ? `<div class="link-summary-stat">
<span class="lss-label">Switch Ports</span>
<span class="lss-value">${swUp}<span class="lss-sub">/${swTotal}</span></span>
</div>` : ''}
${swDown > 0 ? `<div class="link-summary-stat lss-alert">
<span class="lss-label">Ports Down</span>
<span class="lss-value val-crit">${swDown}</span>
</div>` : ''}
${swErrors > 0 ? `<div class="link-summary-stat lss-alert">
<span class="lss-label">Port Errors</span>
<span class="lss-value val-crit">${swErrors}</span>
</div>` : ''}
${poePct !== null ? `<div class="link-summary-stat">
<span class="lss-label">PoE Load</span>
<span class="lss-value ${poeBarCls === 'poe-bar-crit' ? 'val-crit' : poeBarCls === 'poe-bar-warn' ? 'val-warn' : 'val-good'}">${poeDrawW.toFixed(0)}W<span class="lss-sub">/${poeMaxW.toFixed(0)}W</span></span>
<div class="poe-bar-track" style="margin-top:3px"><div class="poe-bar-fill ${poeBarCls}" style="width:${poePct.toFixed(1)}%"></div></div>
</div>` : ''}
${totalErrors === 0 && svrFlap === 0 && swDown === 0 ? `<div class="link-summary-stat">
<span class="lss-label">Status</span>
<span class="lss-value val-good">All OK ✔</span>
</div>` : ''}
</div>
</div>`;
}
// ── 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 =
buildLinkSummary(hosts, unifi) +
`<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'});
}
}
}
// ── Stale data check ─────────────────────────────────────────────
function checkLinksStale(updatedStr) {
let banner = document.getElementById('links-stale-banner');
if (!updatedStr) return;
const ageMs = Date.now() - new Date(updatedStr.replace(' UTC', 'Z').replace(' ', 'T'));
if (ageMs > 120000) { // >2 minutes
if (!banner) {
banner = document.createElement('div');
banner.id = 'links-stale-banner';
banner.className = 'stale-banner';
document.getElementById('links-container').insertAdjacentElement('beforebegin', banner);
}
const mins = Math.floor(ageMs / 60000);
banner.textContent = `⚠ Link data is stale — last update was ${mins} minute${mins !== 1 ? 's' : ''} ago.`;
banner.style.display = '';
} else if (banner) {
banner.style.display = 'none';
}
}
// ── Fetch and render ──────────────────────────────────────────────
async function loadLinks() {
try {
const resp = await fetch('/api/links');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
renderLinks(data);
checkLinksStale(data.updated);
} catch(e) {
document.getElementById('links-container').innerHTML =
`<div class="error-state"><p>Failed to load link data: ${escHtml(e.message)}</p></div>`;
}
}
loadLinks();
setInterval(loadLinks, 60000);
</script>
{% endblock %}