03375ef22f
Lint / Python (flake8) (push) Successful in 39s
Lint / JS (eslint) (push) Successful in 6s
Security / Python Security (bandit) (push) Successful in 50s
Test / Python Tests (pytest) (push) Successful in 51s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 2s
- inspector.html: onclick on port blocks, close button, run-diagnostic button, and diag-toggle sections all converted to data-action attributes; single delegated click listener handles all cases + Escape key closes panel - links.html: onclick on panel title headers, Collapse All, Expand All converted to data-action with delegated listener - suppressions.html: onsubmit/onchange wired via addEventListener at init - index.html: onsubmit/onchange on suppress modal form wired at init No behavioural changes — pure event-handling refactor for TDS compliance. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
524 lines
22 KiB
HTML
524 lines
22 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}Link Debug – GANDALF{% endblock %}
|
||
|
||
{% block content %}
|
||
|
||
<div class="g-page-header">
|
||
<h1 class="g-page-title">Link Debug</h1>
|
||
<p class="g-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>
|
||
const escHtml = s => lt.escHtml(s);
|
||
|
||
// ── 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="val-good">0 /s</span>';
|
||
return `<span class="val-crit">${rate.toFixed(3)} /s</span>`;
|
||
}
|
||
|
||
function fmtCarrier(n) {
|
||
if (n === null || n === undefined) return '–';
|
||
if (n === 0) return '<span class="counter-zero">0</span>';
|
||
return `<span class="counter-nonzero">${n}</span>`;
|
||
}
|
||
|
||
// ── SFP/DOM value classification ─────────────────────────────────
|
||
function rxPowerClass(dbm) {
|
||
if (dbm === null || dbm === undefined) return 'val-neutral';
|
||
if (dbm < -15) return 'val-crit';
|
||
if (dbm < -10) return 'val-warn';
|
||
return 'val-good';
|
||
}
|
||
function txPowerClass(dbm) {
|
||
if (dbm === null || dbm === undefined) return 'val-neutral';
|
||
if (dbm < -5) return 'val-crit';
|
||
return 'val-good';
|
||
}
|
||
function tempClass(c) {
|
||
if (c === null || c === undefined) return 'val-neutral';
|
||
if (c > 80) return 'val-crit';
|
||
if (c > 70) 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 errorBadges(d) {
|
||
const badges = [];
|
||
if ((d.tx_errs_rate || 0) > 0.001 || (d.rx_errs_rate || 0) > 0.001)
|
||
badges.push('<span class="link-alert-badge">ERR</span>');
|
||
if ((d.tx_drops_rate || 0) > 0.001 || (d.rx_drops_rate || 0) > 0.001)
|
||
badges.push('<span class="link-alert-badge link-alert-amber">DROP</span>');
|
||
if ((d.carrier_changes || 0) > 3)
|
||
badges.push('<span class="link-alert-badge link-alert-amber">FLAP</span>');
|
||
return badges.join('');
|
||
}
|
||
|
||
// ── Render a single server interface card ─────────────────────────
|
||
function renderIfaceCard(ifaceName, d) {
|
||
const isDown = d.link_detected === false;
|
||
const pt = (d.port_type || '').toUpperCase();
|
||
const mediaTag = pt === 'FIBRE' || pt === 'SFP' || pt.includes('FIBRE') ? 'type-fibre'
|
||
: pt === 'DA' ? 'type-da'
|
||
: 'type-copper';
|
||
const mediaLabel = d.port_type || '–';
|
||
const speedStr = d.speed_mbps ? fmtSpeed(d.speed_mbps) : '–';
|
||
const txPct = fmtRateBar(d.tx_bytes_rate, d.speed_mbps);
|
||
const rxPct = fmtRateBar(d.rx_bytes_rate, d.speed_mbps);
|
||
|
||
let sfpHtml = '';
|
||
if (d.sfp && Object.keys(d.sfp).length > 0) {
|
||
const s = d.sfp;
|
||
const rxClass = rxPowerClass(s.rx_power_dbm);
|
||
const txClass = txPowerClass(s.tx_power_dbm);
|
||
const tmpClass = tempClass(s.temp_c);
|
||
const vClass = voltageClass(s.voltage_v);
|
||
const rxPct2 = s.rx_power_dbm != null ? Math.min(100, Math.max(0, (s.rx_power_dbm + 20) / 15 * 100)) : 0;
|
||
const txPct2 = s.tx_power_dbm != null ? Math.min(100, Math.max(0, (s.tx_power_dbm + 10) / 8 * 100)) : 0;
|
||
|
||
sfpHtml = `
|
||
<div class="sfp-panel">
|
||
<div class="sfp-vendor-row">
|
||
${s.vendor ? `<span>${escHtml(s.vendor)}</span>` : ''}
|
||
${s.part_no ? ` / <span>${escHtml(s.part_no)}</span>` : ''}
|
||
</div>
|
||
<div class="sfp-grid">
|
||
<div class="sfp-stat">
|
||
<span class="sfp-stat-label">Temp</span>
|
||
<span class="sfp-stat-value ${tmpClass}">${fmtTemp(s.temp_c)}</span>
|
||
</div>
|
||
<div class="sfp-stat">
|
||
<span class="sfp-stat-label">Voltage</span>
|
||
<span class="sfp-stat-value ${vClass}">${fmtVoltage(s.voltage_v)}</span>
|
||
</div>
|
||
<div class="sfp-stat">
|
||
<span class="sfp-stat-label">Bias</span>
|
||
<span class="sfp-stat-value">${fmtBias(s.bias_ma)}</span>
|
||
</div>
|
||
<div class="sfp-stat">
|
||
<span class="sfp-stat-label">TX Power</span>
|
||
<span class="sfp-stat-value ${txClass}">${fmtPower(s.tx_power_dbm)}</span>
|
||
<div class="power-row">
|
||
<div class="power-track"><div class="power-fill ${txClass === 'val-good' ? 'power-ok' : txClass === 'val-warn' ? 'power-warn' : 'power-crit'}" style="width:${txPct2}%"></div></div>
|
||
</div>
|
||
</div>
|
||
<div class="sfp-stat">
|
||
<span class="sfp-stat-label">RX Power</span>
|
||
<span class="sfp-stat-value ${rxClass}">${fmtPower(s.rx_power_dbm)}</span>
|
||
<div class="power-row">
|
||
<div class="power-track"><div class="power-fill ${rxClass === 'val-good' ? 'power-ok' : rxClass === 'val-warn' ? 'power-warn' : 'power-crit'}" style="width:${rxPct2}%"></div></div>
|
||
</div>
|
||
</div>
|
||
${s.rx_power_dbm != null && s.tx_power_dbm != null ? `
|
||
<div class="sfp-stat">
|
||
<span class="sfp-stat-label">RX−TX Δ</span>
|
||
<span class="sfp-stat-value">${(s.rx_power_dbm - s.tx_power_dbm).toFixed(2)} dBm</span>
|
||
</div>` : ''}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
return `
|
||
<div class="link-iface-card ${isDown ? 'port-down' : ''}">
|
||
<div class="link-iface-header">
|
||
<span class="link-iface-name">${escHtml(ifaceName)}</span>
|
||
<span class="link-iface-speed">${speedStr}</span>
|
||
<span class="link-iface-type ${mediaTag}">${escHtml(mediaLabel)}</span>
|
||
${errorBadges(d)}
|
||
</div>
|
||
<div class="link-stats-grid">
|
||
<div class="link-stat">
|
||
<span class="link-stat-label">Link</span>
|
||
<span class="link-stat-value ${isDown ? 'val-crit' : 'val-good'}">${isDown ? 'DOWN' : 'UP'}</span>
|
||
</div>
|
||
<div class="link-stat">
|
||
<span class="link-stat-label">Duplex</span>
|
||
<span class="link-stat-value">${fmtDuplex(d.duplex)}</span>
|
||
</div>
|
||
<div class="link-stat">
|
||
<span class="link-stat-label">Auto-neg</span>
|
||
<span class="link-stat-value">${d.auto_neg == null ? '–' : d.auto_neg ? 'On' : 'Off'}</span>
|
||
</div>
|
||
<div class="link-stat">
|
||
<span class="link-stat-label">Carrier Δ</span>
|
||
<span class="link-stat-value">${fmtCarrier(d.carrier_changes)}</span>
|
||
</div>
|
||
<div class="link-stat">
|
||
<span class="link-stat-label">TX Err/s</span>
|
||
<span class="link-stat-value">${fmtErrors(d.tx_errs_rate)}</span>
|
||
</div>
|
||
<div class="link-stat">
|
||
<span class="link-stat-label">RX Err/s</span>
|
||
<span class="link-stat-value">${fmtErrors(d.rx_errs_rate)}</span>
|
||
</div>
|
||
<div class="link-stat">
|
||
<span class="link-stat-label">TX Drop/s</span>
|
||
<span class="link-stat-value">${fmtErrors(d.tx_drops_rate)}</span>
|
||
</div>
|
||
<div class="link-stat">
|
||
<span class="link-stat-label">RX Drop/s</span>
|
||
<span class="link-stat-value">${fmtErrors(d.rx_drops_rate)}</span>
|
||
</div>
|
||
</div>
|
||
<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">${fmtRate(d.tx_bytes_rate)}</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">${fmtRate(d.rx_bytes_rate)}</span>
|
||
</div>
|
||
</div>
|
||
${sfpHtml}
|
||
</div>`;
|
||
}
|
||
|
||
// ── Render a single UniFi switch port card ────────────────────────
|
||
function renderPortCard(portName, d) {
|
||
const isDown = !d.up;
|
||
const speedStr = d.speed_mbps ? fmtSpeed(d.speed_mbps) : '–';
|
||
const txPct = fmtRateBar(d.tx_bytes_rate, d.speed_mbps);
|
||
const rxPct = fmtRateBar(d.rx_bytes_rate, d.speed_mbps);
|
||
const numBadge = d.port_idx != null ? `<span class="port-badge port-badge-num">#${d.port_idx}</span>` : '';
|
||
const uplinkBadge = d.is_uplink ? `<span class="port-badge port-badge-uplink">UPLINK</span>` : '';
|
||
const poeBadge = d.poe_power ? `<span class="port-badge port-badge-poe">PoE ${d.poe_power.toFixed(1)}W</span>` : '';
|
||
const lldpLine = d.lldp ? `<div class="port-lldp">→ ${escHtml(d.lldp.system_name || '')} (${escHtml(d.lldp.port_id || '')})</div>` : '';
|
||
const poeLine = d.poe_class ? `<div class="port-poe-info">PoE ${escHtml(d.poe_class)} · max ${d.poe_max_power != null ? d.poe_max_power.toFixed(1)+'W' : '–'}</div>` : '';
|
||
|
||
return `
|
||
<div class="link-iface-card ${isDown ? 'port-down' : ''}">
|
||
<div class="link-iface-header">
|
||
<span class="link-iface-name">${numBadge} ${escHtml(portName)}</span>
|
||
<span class="link-iface-speed">${speedStr}</span>
|
||
${uplinkBadge}${poeBadge}
|
||
${errorBadges(d)}
|
||
</div>
|
||
${lldpLine}${poeLine}
|
||
<div class="link-stats-grid">
|
||
<div class="link-stat">
|
||
<span class="link-stat-label">Link</span>
|
||
<span class="link-stat-value ${isDown ? 'val-crit' : 'val-good'}">${isDown ? 'DOWN' : 'UP'}</span>
|
||
</div>
|
||
<div class="link-stat">
|
||
<span class="link-stat-label">Duplex</span>
|
||
<span class="link-stat-value">${d.full_duplex == null ? '–' : d.full_duplex ? 'Full' : 'Half'}</span>
|
||
</div>
|
||
<div class="link-stat">
|
||
<span class="link-stat-label">Auto-neg</span>
|
||
<span class="link-stat-value">${d.autoneg == null ? '–' : d.autoneg ? 'On' : 'Off'}</span>
|
||
</div>
|
||
<div class="link-stat">
|
||
<span class="link-stat-label">TX Err/s</span>
|
||
<span class="link-stat-value">${fmtErrors(d.tx_errs_rate)}</span>
|
||
</div>
|
||
<div class="link-stat">
|
||
<span class="link-stat-label">RX Err/s</span>
|
||
<span class="link-stat-value">${fmtErrors(d.rx_errs_rate)}</span>
|
||
</div>
|
||
<div class="link-stat">
|
||
<span class="link-stat-label">TX Drop/s</span>
|
||
<span class="link-stat-value">${fmtErrors(d.tx_drops_rate)}</span>
|
||
</div>
|
||
</div>
|
||
<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">${fmtRate(d.tx_bytes_rate)}</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">${fmtRate(d.rx_bytes_rate)}</span>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ── Render all UniFi switches ─────────────────────────────────────
|
||
function renderUnifiSwitches(unifiSwitches, dataUpdated) {
|
||
if (!unifiSwitches || !Object.keys(unifiSwitches).length) return '';
|
||
const updStr = dataUpdated
|
||
? new Date(dataUpdated.replace(' UTC', 'Z').replace(' ', 'T')).toLocaleTimeString()
|
||
: '';
|
||
const html = Object.entries(unifiSwitches).map(([swName, sw]) => {
|
||
const ports = sw.ports || {};
|
||
const portValues = Object.values(ports);
|
||
const portCards = Object.entries(ports)
|
||
.sort(([,a],[,b]) => (a.port_idx||0) - (b.port_idx||0))
|
||
.map(([pname, d]) => renderPortCard(pname, d)).join('');
|
||
const poe_total_w = portValues.reduce((s, p) => s + (p.poe_power || 0), 0);
|
||
const poe_max_w = portValues.reduce((s, p) => s + (p.poe_max_power || 0), 0);
|
||
const poeLoad = poe_total_w > 0 ? ` · PoE ${poe_total_w.toFixed(1)}W` : '';
|
||
|
||
// PoE utilisation bar
|
||
let poebar = '';
|
||
if (poe_total_w > 0 && poe_max_w > 0) {
|
||
const pct = Math.min(100, (poe_total_w / poe_max_w) * 100);
|
||
const cls = pct > 80 ? 'poe-bar-crit' : pct > 60 ? 'poe-bar-warn' : 'poe-bar-ok';
|
||
poebar = `<div class="poe-bar-track"><div class="poe-bar-fill ${cls}" style="width:${pct}%"></div></div>`;
|
||
}
|
||
|
||
return `
|
||
<div class="link-host-panel" id="panel-${CSS.escape(swName)}">
|
||
<div class="link-host-title" data-action="toggle-panel">
|
||
<span class="link-host-name">${escHtml(swName)}</span>
|
||
<span class="link-host-ip">${escHtml(sw.ip || '')}</span>
|
||
<span class="link-host-upd">${escHtml(sw.model || '')}${updStr ? ' · ' + updStr : ''}${poeLoad}</span>
|
||
${poebar}
|
||
<span class="panel-toggle">[–]</span>
|
||
</div>
|
||
<div class="link-ifaces-grid">${portCards}</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
return `<div class="unifi-section-header">UNIFI SWITCH PORTS</div>${html}`;
|
||
}
|
||
|
||
// ── Panel collapse / expand ───────────────────────────────────────
|
||
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 collapsed = JSON.parse(sessionStorage.getItem('linksCollapsed') || '{}');
|
||
collapsed[id] = panel.classList.contains('collapsed');
|
||
sessionStorage.setItem('linksCollapsed', JSON.stringify(collapsed));
|
||
}
|
||
}
|
||
|
||
function restoreCollapseState() {
|
||
const collapsed = JSON.parse(sessionStorage.getItem('linksCollapsed') || '{}');
|
||
for (const [id, isCollapsed] of Object.entries(collapsed)) {
|
||
const panel = document.getElementById(id);
|
||
if (!panel) continue;
|
||
if (isCollapsed) {
|
||
panel.classList.add('collapsed');
|
||
const btn = panel.querySelector('.panel-toggle');
|
||
if (btn) btn.textContent = '[+]';
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Build summary stats header ────────────────────────────────────
|
||
function buildLinkSummary(hosts, unifiSwitches) {
|
||
let totalIfaces = 0, downIfaces = 0, errIfaces = 0, totalPoe = 0;
|
||
for (const ifaces of Object.values(hosts || {})) {
|
||
for (const d of Object.values(ifaces)) {
|
||
totalIfaces++;
|
||
if (d.link_detected === false) downIfaces++;
|
||
if ((d.tx_errs_rate || 0) > 0 || (d.rx_errs_rate || 0) > 0) errIfaces++;
|
||
}
|
||
}
|
||
for (const sw of Object.values(unifiSwitches || {})) {
|
||
for (const p of Object.values(sw.ports || {})) {
|
||
totalPoe += p.poe_power || 0;
|
||
}
|
||
}
|
||
const hasAlerts = downIfaces > 0 || errIfaces > 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">Total Interfaces</span>
|
||
<span class="lss-value">${totalIfaces}</span>
|
||
</div>
|
||
<div class="link-summary-stat ${downIfaces ? 'lss-alert' : ''}">
|
||
<span class="lss-label">Interfaces Down</span>
|
||
<span class="lss-value ${downIfaces ? 'val-crit' : 'val-good'}">${downIfaces}</span>
|
||
</div>
|
||
<div class="link-summary-stat ${errIfaces ? 'lss-alert' : ''}">
|
||
<span class="lss-label">With Errors</span>
|
||
<span class="lss-value ${errIfaces ? 'val-warn' : 'val-good'}">${errIfaces}</span>
|
||
</div>
|
||
${totalPoe > 0 ? `
|
||
<div class="link-summary-stat">
|
||
<span class="lss-label">PoE Load</span>
|
||
<span class="lss-value">${totalPoe.toFixed(1)} <span class="lss-sub">W</span></span>
|
||
</div>` : ''}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ── Main render ───────────────────────────────────────────────────
|
||
function renderLinks(data) {
|
||
const hosts = data.hosts || {};
|
||
const unifiSwitches = data.unifi_switches || {};
|
||
const parts = [];
|
||
|
||
parts.push(buildLinkSummary(hosts, unifiSwitches));
|
||
parts.push(`<div class="link-collapse-bar">
|
||
<button class="lt-btn lt-btn-ghost lt-btn-sm" data-action="collapse-all">Collapse All</button>
|
||
<button class="lt-btn lt-btn-ghost lt-btn-sm" data-action="expand-all">Expand All</button>
|
||
</div>`);
|
||
parts.push('<div class="link-host-list">');
|
||
|
||
for (const [hostname, ifaces] of Object.entries(hosts)) {
|
||
const ifaceCards = Object.entries(ifaces)
|
||
.sort(([a],[b]) => a.localeCompare(b))
|
||
.map(([iname, d]) => renderIfaceCard(iname, d)).join('');
|
||
const sample = Object.values(ifaces)[0] || {};
|
||
const ip = sample.host_ip || '';
|
||
const updStr = sample.updated
|
||
? new Date(sample.updated + (sample.updated.includes('Z') ? '' : 'Z')).toLocaleTimeString()
|
||
: '';
|
||
|
||
parts.push(`
|
||
<div class="link-host-panel" id="panel-${CSS.escape(hostname)}">
|
||
<div class="link-host-title" data-action="toggle-panel">
|
||
<span class="link-host-name">${escHtml(hostname)}</span>
|
||
<span class="link-host-ip">${escHtml(ip)}</span>
|
||
<span class="link-host-upd">${updStr}</span>
|
||
<span class="panel-toggle">[–]</span>
|
||
</div>
|
||
<div class="link-ifaces-grid">${ifaceCards}</div>
|
||
</div>`);
|
||
}
|
||
|
||
parts.push(renderUnifiSwitches(unifiSwitches, data.updated));
|
||
parts.push('</div>');
|
||
document.getElementById('links-container').innerHTML = parts.join('');
|
||
restoreCollapseState();
|
||
}
|
||
|
||
function collapseAll() {
|
||
document.querySelectorAll('.link-host-panel').forEach(p => {
|
||
p.classList.add('collapsed');
|
||
const btn = p.querySelector('.panel-toggle');
|
||
if (btn) btn.textContent = '[+]';
|
||
});
|
||
sessionStorage.setItem('linksCollapsed', JSON.stringify(
|
||
Object.fromEntries([...document.querySelectorAll('.link-host-panel')].map(p => [p.id, true]))
|
||
));
|
||
}
|
||
|
||
function expandAll() {
|
||
document.querySelectorAll('.link-host-panel').forEach(p => {
|
||
p.classList.remove('collapsed');
|
||
const btn = p.querySelector('.panel-toggle');
|
||
if (btn) btn.textContent = '[–]';
|
||
});
|
||
sessionStorage.setItem('linksCollapsed', '{}');
|
||
}
|
||
|
||
// ── Stale data warning ────────────────────────────────────────────
|
||
function checkLinksStale(updatedStr) {
|
||
if (!updatedStr) return;
|
||
const age = (Date.now() - new Date(updatedStr + (updatedStr.includes('Z') ? '' : 'Z'))) / 1000;
|
||
let banner = document.getElementById('links-stale-banner');
|
||
if (age > 120) {
|
||
if (!banner) {
|
||
banner = document.createElement('div');
|
||
banner.id = 'links-stale-banner';
|
||
banner.className = 'stale-banner';
|
||
document.getElementById('links-container').prepend(banner);
|
||
}
|
||
banner.textContent = `⚠ Link data may be stale — last updated ${Math.floor(age/60)}m ago.`;
|
||
banner.style.display = '';
|
||
} else if (banner) {
|
||
banner.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// ── Fetch + render ────────────────────────────────────────────────
|
||
async function loadLinks() {
|
||
try {
|
||
const data = await lt.api.get('/api/links');
|
||
if (!data.hosts && !data.unifi_switches) {
|
||
document.getElementById('links-container').innerHTML =
|
||
'<div class="link-no-data">No link data yet — monitor has not completed a full cycle.</div>';
|
||
return;
|
||
}
|
||
const updEl = document.getElementById('links-updated');
|
||
if (updEl && data.updated) {
|
||
updEl.textContent = 'Updated: ' + new Date(data.updated + (data.updated.includes('Z') ? '' : 'Z')).toLocaleTimeString();
|
||
}
|
||
renderLinks(data);
|
||
checkLinksStale(data.updated);
|
||
} catch (e) {
|
||
document.getElementById('links-container').innerHTML =
|
||
'<div class="error-state">Network error loading link statistics.</div>';
|
||
lt.toast.error('Failed to load link statistics');
|
||
}
|
||
}
|
||
|
||
loadLinks();
|
||
lt.autoRefresh.start(loadLinks, 60000);
|
||
|
||
document.addEventListener('click', e => {
|
||
const toggleTitle = e.target.closest('[data-action="toggle-panel"]');
|
||
if (toggleTitle) { togglePanel(toggleTitle.closest('.link-host-panel')); return; }
|
||
|
||
if (e.target.closest('[data-action="collapse-all"]')) { collapseAll(); return; }
|
||
if (e.target.closest('[data-action="expand-all"]')) { expandAll(); return; }
|
||
});
|
||
</script>
|
||
{% endblock %}
|