Restructure app to use LotusGuild Terminal Design System v1.2
Lint / Python (flake8) (push) Failing after 45s
Lint / JS (eslint) (push) Successful in 7s
Security / Python Security (bandit) (push) Failing after 1m22s
Test / Python Tests (pytest) (push) Successful in 51s
Lint / Notify on failure (push) Successful in 2s
Lint / Deploy (push) Has been skipped
Lint / Python (flake8) (push) Failing after 45s
Lint / JS (eslint) (push) Successful in 7s
Security / Python Security (bandit) (push) Failing after 1m22s
Test / Python Tests (pytest) (push) Successful in 51s
Lint / Notify on failure (push) Successful in 2s
Lint / Deploy (push) Has been skipped
Replace custom phosphor-green terminal aesthetic with the lt-* component system from base.css/base.js. All templates now inherit the LotusGuild multi-accent Anduril palette via variable aliases in style.css, and use lt-header, lt-nav, lt-card, lt-table, lt-btn, lt-modal, lt-badge etc. Custom components (topology, inspector chassis, link debug, SFP panels) are preserved with color values updated to base.css palette variables. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+230
-346
@@ -3,9 +3,9 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Link Debug</h1>
|
||||
<p class="page-sub">
|
||||
<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>
|
||||
@@ -49,12 +49,12 @@ function fmtDuplex(d) {
|
||||
|
||||
function fmtTemp(c) {
|
||||
if (c === null || c === undefined) return '–';
|
||||
return c.toFixed(1) + '°C';
|
||||
return c.toFixed(1) + ' °C';
|
||||
}
|
||||
|
||||
function fmtVoltage(v) {
|
||||
if (v === null || v === undefined) return '–';
|
||||
return v.toFixed(2) + 'V';
|
||||
return v.toFixed(2) + ' V';
|
||||
}
|
||||
|
||||
function fmtPower(dbm) {
|
||||
@@ -69,44 +69,34 @@ function fmtBias(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>`;
|
||||
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 '–';
|
||||
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>`;
|
||||
if (n === 0) return '<span class="counter-zero">0</span>';
|
||||
return `<span class="counter-nonzero">${n}</span>`;
|
||||
}
|
||||
|
||||
// Power level: returns {cls, pct} for -30..0 dBm scale
|
||||
// ── SFP/DOM value classification ─────────────────────────────────
|
||||
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};
|
||||
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 {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};
|
||||
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 > 60) return 'val-warn';
|
||||
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';
|
||||
@@ -114,319 +104,254 @@ function voltageClass(v) {
|
||||
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)
|
||||
if ((d.tx_errors_per_sec || 0) > 0 || (d.rx_errors_per_sec || 0) > 0)
|
||||
badges.push('<span class="link-alert-badge">ERR</span>');
|
||||
if ((d.tx_drops_rate || 0) > 0.1 || (d.rx_drops_rate || 0) > 0.1)
|
||||
if ((d.tx_drops_per_sec || 0) > 0 || (d.rx_drops_per_sec || 0) > 0)
|
||||
badges.push('<span class="link-alert-badge link-alert-amber">DROP</span>');
|
||||
if ((d.carrier_changes || 0) > 10)
|
||||
if ((d.carrier_changes || 0) > 3)
|
||||
badges.push('<span class="link-alert-badge link-alert-amber">FLAP</span>');
|
||||
return badges.join('');
|
||||
}
|
||||
|
||||
// ── Render a single interface card ────────────────────────────────
|
||||
// ── Render a single server 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>') : '–';
|
||||
const isDown = d.link_detected === false || d.admin_status === 'down';
|
||||
const mediaTag = d.media_type === 'fibre' ? 'type-fibre'
|
||||
: d.media_type === 'da' ? 'type-da'
|
||||
: 'type-copper';
|
||||
const mediaLabel = d.media_type || '–';
|
||||
const speedStr = d.speed_mbps ? fmtSpeed(d.speed_mbps) : '–';
|
||||
const txPct = fmtRateBar(d.tx_bytes_per_sec, d.speed_mbps);
|
||||
const rxPct = fmtRateBar(d.rx_bytes_per_sec, d.speed_mbps);
|
||||
|
||||
// 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(' · ') || '';
|
||||
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">
|
||||
<span>${escHtml(vendorStr)}</span>
|
||||
${sfpTypeStr ? `<span style="margin-left:8px;color:var(--text-muted)">${escHtml(sfpTypeStr)}</span>` : ''}
|
||||
${s.vendor ? `<span>${escHtml(s.vendor)}</span>` : ''}
|
||||
${s.part_number ? ` / <span>${escHtml(s.part_number)}</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>
|
||||
<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 ${vcls}">${fmtVoltage(sfp.voltage_v)}</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(sfp.bias_ma)}</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 ${tx.cls === 'power-ok' ? 'val-good' : tx.cls === 'power-warn' ? 'val-warn' : 'val-crit'}">${fmtPower(sfp.tx_power_dbm)}</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 ${tx.cls}" style="width:${tx.pct}%"></div></div>
|
||||
<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 ${rx.cls === 'power-ok' ? 'val-good' : rx.cls === 'power-warn' ? 'val-warn' : 'val-crit'}">${fmtPower(sfp.rx_power_dbm)}</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 ${rx.cls}" style="width:${rx.pct}%"></div></div>
|
||||
<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 ${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>
|
||||
<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">
|
||||
<div class="link-iface-card ${isDown ? 'port-down' : ''}">
|
||||
<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>` : ''}
|
||||
<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 ${duplex==='Full'?'val-good':duplex==='Half'?'val-crit':'val-neutral'}">${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 val-neutral">${autoneg}</span>
|
||||
<span class="link-stat-value">${d.auto_negotiation == null ? '–' : d.auto_negotiation ? 'On' : 'Off'}</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-label">Carrier Δ</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>
|
||||
<span class="link-stat-label">TX Err/s</span>
|
||||
<span class="link-stat-value">${fmtErrors(d.tx_errors_per_sec)}</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>
|
||||
<span class="link-stat-label">RX Err/s</span>
|
||||
<span class="link-stat-value">${fmtErrors(d.rx_errors_per_sec)}</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>
|
||||
<span class="link-stat-label">TX Drop/s</span>
|
||||
<span class="link-stat-value">${fmtErrors(d.tx_drops_per_sec)}</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>
|
||||
<span class="link-stat-label">RX Drop/s</span>
|
||||
<span class="link-stat-value">${fmtErrors(d.rx_drops_per_sec)}</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 class="traffic-bar-track"><div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div></div>
|
||||
<span class="traffic-value">${fmtRate(d.tx_bytes_per_sec)}</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 class="traffic-bar-track"><div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div></div>
|
||||
<span class="traffic-value">${fmtRate(d.rx_bytes_per_sec)}</span>
|
||||
</div>
|
||||
</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);
|
||||
const isDown = !d.up;
|
||||
const speedStr = d.speed_mbps ? fmtSpeed(d.speed_mbps) : '–';
|
||||
const txPct = fmtRateBar(d.tx_bytes_per_sec, d.speed_mbps);
|
||||
const rxPct = fmtRateBar(d.rx_bytes_per_sec, 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_w ? `<span class="port-badge port-badge-poe">PoE ${d.poe_power_w.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_power_w_max != null ? d.poe_power_w_max.toFixed(1)+'W' : '–'}</div>` : '';
|
||||
|
||||
return `
|
||||
<div class="link-iface-card${up ? '' : ' port-down'}">
|
||||
<div class="link-iface-card ${isDown ? '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>` : ''}
|
||||
<span class="link-iface-name">${numBadge} ${escHtml(portName)}</span>
|
||||
<span class="link-iface-speed">${speedStr}</span>
|
||||
${uplinkBadge}${poeBadge}
|
||||
${errorBadges(d)}
|
||||
</div>
|
||||
${lldpHtml}${poeMaxHtml}
|
||||
${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 ${duplex==='Full'?'val-good':duplex==='Half'?'val-crit':'val-neutral'}">${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 val-neutral">${d.autoneg ? 'On' : 'Off'}</span>
|
||||
<span class="link-stat-value">${d.auto_negotiation == null ? '–' : d.auto_negotiation ? '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>
|
||||
<span class="link-stat-label">TX Err/s</span>
|
||||
<span class="link-stat-value">${fmtErrors(d.tx_errors_per_sec)}</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>
|
||||
<span class="link-stat-label">RX Err/s</span>
|
||||
<span class="link-stat-value">${fmtErrors(d.rx_errors_per_sec)}</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>
|
||||
<span class="link-stat-label">TX Drop/s</span>
|
||||
<span class="link-stat-value">${fmtErrors(d.tx_drops_per_sec)}</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 class="traffic-bar-track"><div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div></div>
|
||||
<span class="traffic-value">${fmtRate(d.tx_bytes_per_sec)}</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 class="traffic-bar-track"><div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div></div>
|
||||
<span class="traffic-value">${fmtRate(d.rx_bytes_per_sec)}</span>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Render UniFi switches section ─────────────────────────────────
|
||||
// ── Render all UniFi switches ─────────────────────────────────────
|
||||
function renderUnifiSwitches(unifiSwitches) {
|
||||
if (!unifiSwitches || !Object.keys(unifiSwitches).length) return '';
|
||||
const html = Object.entries(unifiSwitches).map(([swName, sw]) => {
|
||||
const ports = sw.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 updStr = sw.updated ? new Date(sw.updated + (sw.updated.includes('Z') ? '' : 'Z')).toLocaleTimeString() : '';
|
||||
const poeLoad = sw.poe_total_w != null ? ` · PoE ${sw.poe_total_w.toFixed(1)}W` : '';
|
||||
|
||||
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(' · ');
|
||||
// PoE utilisation bar
|
||||
let poebar = '';
|
||||
if (sw.poe_total_w != null && sw.poe_max_w) {
|
||||
const pct = Math.min(100, (sw.poe_total_w / sw.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="unifi-${escHtml(swName)}">
|
||||
<div class="link-host-panel" id="panel-${CSS.escape(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>'}
|
||||
<span class="link-host-ip">${escHtml(sw.ip || '')}</span>
|
||||
<span class="link-host-upd">${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 Switches</div>
|
||||
<div class="link-host-list">${panels}</div>`;
|
||||
return `<div class="unifi-section-header">UNIFI SWITCH PORTS</div>${html}`;
|
||||
}
|
||||
|
||||
// ── Collapse / expand panels ───────────────────────────────────────
|
||||
// ── 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 saved = JSON.parse(localStorage.getItem('gandalfCollapsed') || '{}');
|
||||
saved[id] = panel.classList.contains('collapsed');
|
||||
localStorage.setItem('gandalfCollapsed', JSON.stringify(saved));
|
||||
const collapsed = JSON.parse(sessionStorage.getItem('linksCollapsed') || '{}');
|
||||
collapsed[id] = panel.classList.contains('collapsed');
|
||||
sessionStorage.setItem('linksCollapsed', JSON.stringify(collapsed));
|
||||
}
|
||||
}
|
||||
|
||||
function restoreCollapseState() {
|
||||
const saved = JSON.parse(localStorage.getItem('gandalfCollapsed') || '{}');
|
||||
for (const [id, collapsed] of Object.entries(saved)) {
|
||||
if (!collapsed) continue;
|
||||
const collapsed = JSON.parse(sessionStorage.getItem('linksCollapsed') || '{}');
|
||||
for (const [id, isCollapsed] of Object.entries(collapsed)) {
|
||||
const panel = document.getElementById(id);
|
||||
if (panel) {
|
||||
if (!panel) continue;
|
||||
if (isCollapsed) {
|
||||
panel.classList.add('collapsed');
|
||||
const btn = panel.querySelector('.panel-toggle');
|
||||
if (btn) btn.textContent = '[+]';
|
||||
@@ -434,189 +359,148 @@ function restoreCollapseState() {
|
||||
}
|
||||
}
|
||||
|
||||
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 ───────────────────────────────────────────
|
||||
// ── Build summary stats header ────────────────────────────────────
|
||||
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)) {
|
||||
let totalIfaces = 0, downIfaces = 0, errIfaces = 0, totalPoe = 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++;
|
||||
totalIfaces++;
|
||||
if (d.link_detected === false) downIfaces++;
|
||||
if ((d.tx_errors_per_sec || 0) > 0 || (d.rx_errors_per_sec || 0) > 0) errIfaces++;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
totalPoe += sw.poe_total_w || 0;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const hasAlerts = downIfaces > 0 || errIfaces > 0;
|
||||
return `
|
||||
<div class="link-summary-panel${hasAlerts ? ' link-summary-has-alerts' : ''}">
|
||||
<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>
|
||||
<span class="lss-label">Total Interfaces</span>
|
||||
<span class="lss-value">${totalIfaces}</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">
|
||||
<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 ${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>
|
||||
<span class="lss-value">${totalPoe.toFixed(1)} <span class="lss-sub">W</span></span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Render all hosts ──────────────────────────────────────────────
|
||||
// ── Main render ───────────────────────────────────────────────────
|
||||
function renderLinks(data) {
|
||||
const hosts = data.hosts || {};
|
||||
const unifi = data.unifi_switches || {};
|
||||
const hosts = data.hosts || {};
|
||||
const unifiSwitches = data.unifi_switches || {};
|
||||
const parts = [];
|
||||
|
||||
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;
|
||||
}
|
||||
parts.push(buildLinkSummary(hosts, unifiSwitches));
|
||||
parts.push(`<div class="link-collapse-bar">
|
||||
<button class="lt-btn lt-btn-ghost lt-btn-sm" onclick="collapseAll()">Collapse All</button>
|
||||
<button class="lt-btn lt-btn-ghost lt-btn-sm" onclick="expandAll()">Expand All</button>
|
||||
</div>`);
|
||||
parts.push('<div class="link-host-list">');
|
||||
|
||||
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]) => {
|
||||
for (const [hostname, ifaces] of Object.entries(hosts)) {
|
||||
const ifaceCards = Object.entries(ifaces)
|
||||
.sort(([a],[b]) => a.localeCompare(b))
|
||||
.map(([ifaceName, d]) => renderIfaceCard(ifaceName, d))
|
||||
.join('');
|
||||
.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()
|
||||
: '';
|
||||
|
||||
const hostIp = ifaces[Object.keys(ifaces)[0]]?.host_ip || '';
|
||||
return `
|
||||
<div class="link-host-panel" id="${escHtml(hostName)}">
|
||||
parts.push(`
|
||||
<div class="link-host-panel" id="panel-${CSS.escape(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>
|
||||
<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 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'});
|
||||
}
|
||||
<div class="link-ifaces-grid">${ifaceCards}</div>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
parts.push(renderUnifiSwitches(unifiSwitches));
|
||||
parts.push('</div>');
|
||||
document.getElementById('links-container').innerHTML = parts.join('');
|
||||
restoreCollapseState();
|
||||
}
|
||||
|
||||
// ── Stale data check ─────────────────────────────────────────────
|
||||
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) {
|
||||
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
|
||||
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').insertAdjacentElement('beforebegin', banner);
|
||||
document.getElementById('links-container').prepend(banner);
|
||||
}
|
||||
const mins = Math.floor(ageMs / 60000);
|
||||
banner.textContent = `⚠ Link data is stale — last update was ${mins} minute${mins !== 1 ? 's' : ''} ago.`;
|
||||
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 and render ──────────────────────────────────────────────
|
||||
// ── Fetch + render ────────────────────────────────────────────────
|
||||
async function loadLinks() {
|
||||
try {
|
||||
const resp = await fetch('/api/links');
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
if (!resp.ok) {
|
||||
document.getElementById('links-container').innerHTML =
|
||||
'<div class="error-state">Failed to load link statistics.</div>';
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
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) {
|
||||
} catch (e) {
|
||||
document.getElementById('links-container').innerHTML =
|
||||
`<div class="error-state"><p>Failed to load link data: ${escHtml(e.message)}</p></div>`;
|
||||
'<div class="error-state">Network error loading link statistics.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user