2026-03-02 12:43:11 -05:00
|
|
|
|
{% extends "base.html" %}
|
|
|
|
|
|
{% block title %}Link Debug – GANDALF{% endblock %}
|
|
|
|
|
|
|
|
|
|
|
|
{% block content %}
|
|
|
|
|
|
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<div class="g-page-header">
|
|
|
|
|
|
<h1 class="g-page-title">Link Debug</h1>
|
|
|
|
|
|
<p class="g-page-sub">
|
2026-03-02 12:43:11 -05:00
|
|
|
|
Per-interface stats: speed, duplex, SFP optical levels, TX/RX rates, errors, and carrier changes.
|
2026-03-03 15:39:48 -05:00
|
|
|
|
Data collected via Prometheus node_exporter + SSH ethtool (servers) and UniFi API (switches) every poll cycle.
|
2026-03-02 12:43:11 -05:00
|
|
|
|
<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>
|
2026-04-18 23:59:19 -04:00
|
|
|
|
const escHtml = s => lt.escHtml(s);
|
|
|
|
|
|
|
2026-03-02 12:43:11 -05:00
|
|
|
|
// ── 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 '–';
|
2026-04-18 21:01:20 -04:00
|
|
|
|
return c.toFixed(1) + ' °C';
|
2026-03-02 12:43:11 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function fmtVoltage(v) {
|
|
|
|
|
|
if (v === null || v === undefined) return '–';
|
2026-04-18 21:01:20 -04:00
|
|
|
|
return v.toFixed(2) + ' V';
|
2026-03-02 12:43:11 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 '–';
|
2026-04-18 21:01:20 -04:00
|
|
|
|
if (rate < 0.001) return '<span class="val-good">0 /s</span>';
|
|
|
|
|
|
return `<span class="val-crit">${rate.toFixed(3)} /s</span>`;
|
2026-03-02 12:43:11 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function fmtCarrier(n) {
|
|
|
|
|
|
if (n === null || n === undefined) return '–';
|
2026-04-18 21:01:20 -04:00
|
|
|
|
if (n === 0) return '<span class="counter-zero">0</span>';
|
|
|
|
|
|
return `<span class="counter-nonzero">${n}</span>`;
|
2026-03-02 12:43:11 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-18 21:01:20 -04:00
|
|
|
|
// ── SFP/DOM value classification ─────────────────────────────────
|
2026-03-02 12:43:11 -05:00
|
|
|
|
function rxPowerClass(dbm) {
|
2026-04-18 21:01:20 -04:00
|
|
|
|
if (dbm === null || dbm === undefined) return 'val-neutral';
|
|
|
|
|
|
if (dbm < -15) return 'val-crit';
|
|
|
|
|
|
if (dbm < -10) return 'val-warn';
|
|
|
|
|
|
return 'val-good';
|
2026-03-02 12:43:11 -05:00
|
|
|
|
}
|
|
|
|
|
|
function txPowerClass(dbm) {
|
2026-04-18 21:01:20 -04:00
|
|
|
|
if (dbm === null || dbm === undefined) return 'val-neutral';
|
|
|
|
|
|
if (dbm < -5) return 'val-crit';
|
|
|
|
|
|
return 'val-good';
|
2026-03-02 12:43:11 -05:00
|
|
|
|
}
|
|
|
|
|
|
function tempClass(c) {
|
|
|
|
|
|
if (c === null || c === undefined) return 'val-neutral';
|
|
|
|
|
|
if (c > 80) return 'val-crit';
|
2026-04-18 21:01:20 -04:00
|
|
|
|
if (c > 70) return 'val-warn';
|
2026-03-02 12:43:11 -05:00
|
|
|
|
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';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 21:46:11 -04:00
|
|
|
|
function errorBadges(d) {
|
|
|
|
|
|
const badges = [];
|
2026-04-19 23:35:02 -04:00
|
|
|
|
if ((d.tx_errs_rate || 0) > 0.001 || (d.rx_errs_rate || 0) > 0.001)
|
2026-03-14 21:46:11 -04:00
|
|
|
|
badges.push('<span class="link-alert-badge">ERR</span>');
|
2026-04-19 23:35:02 -04:00
|
|
|
|
if ((d.tx_drops_rate || 0) > 0.001 || (d.rx_drops_rate || 0) > 0.001)
|
2026-03-14 21:46:11 -04:00
|
|
|
|
badges.push('<span class="link-alert-badge link-alert-amber">DROP</span>');
|
2026-04-18 21:01:20 -04:00
|
|
|
|
if ((d.carrier_changes || 0) > 3)
|
2026-03-14 21:46:11 -04:00
|
|
|
|
badges.push('<span class="link-alert-badge link-alert-amber">FLAP</span>');
|
|
|
|
|
|
return badges.join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-18 21:01:20 -04:00
|
|
|
|
// ── Render a single server interface card ─────────────────────────
|
2026-03-02 12:43:11 -05:00
|
|
|
|
function renderIfaceCard(ifaceName, d) {
|
2026-04-19 23:35:02 -04:00
|
|
|
|
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'
|
2026-04-18 21:01:20 -04:00
|
|
|
|
: 'type-copper';
|
2026-04-19 23:35:02 -04:00
|
|
|
|
const mediaLabel = d.port_type || '–';
|
2026-04-18 21:01:20 -04:00
|
|
|
|
const speedStr = d.speed_mbps ? fmtSpeed(d.speed_mbps) : '–';
|
2026-04-19 23:35:02 -04:00
|
|
|
|
const txPct = fmtRateBar(d.tx_bytes_rate, d.speed_mbps);
|
|
|
|
|
|
const rxPct = fmtRateBar(d.rx_bytes_rate, d.speed_mbps);
|
2026-03-02 12:43:11 -05:00
|
|
|
|
|
2026-04-18 21:01:20 -04:00
|
|
|
|
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;
|
2026-03-02 12:43:11 -05:00
|
|
|
|
|
|
|
|
|
|
sfpHtml = `
|
|
|
|
|
|
<div class="sfp-panel">
|
|
|
|
|
|
<div class="sfp-vendor-row">
|
2026-04-18 21:01:20 -04:00
|
|
|
|
${s.vendor ? `<span>${escHtml(s.vendor)}</span>` : ''}
|
2026-04-19 23:35:02 -04:00
|
|
|
|
${s.part_no ? ` / <span>${escHtml(s.part_no)}</span>` : ''}
|
2026-03-02 12:43:11 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="sfp-grid">
|
|
|
|
|
|
<div class="sfp-stat">
|
|
|
|
|
|
<span class="sfp-stat-label">Temp</span>
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<span class="sfp-stat-value ${tmpClass}">${fmtTemp(s.temp_c)}</span>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="sfp-stat">
|
|
|
|
|
|
<span class="sfp-stat-label">Voltage</span>
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<span class="sfp-stat-value ${vClass}">${fmtVoltage(s.voltage_v)}</span>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="sfp-stat">
|
|
|
|
|
|
<span class="sfp-stat-label">Bias</span>
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<span class="sfp-stat-value">${fmtBias(s.bias_ma)}</span>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="sfp-stat">
|
|
|
|
|
|
<span class="sfp-stat-label">TX Power</span>
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<span class="sfp-stat-value ${txClass}">${fmtPower(s.tx_power_dbm)}</span>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
<div class="power-row">
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<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>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="sfp-stat">
|
|
|
|
|
|
<span class="sfp-stat-label">RX Power</span>
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<span class="sfp-stat-value ${rxClass}">${fmtPower(s.rx_power_dbm)}</span>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
<div class="power-row">
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<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>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-18 21:01:20 -04:00
|
|
|
|
${s.rx_power_dbm != null && s.tx_power_dbm != null ? `
|
2026-03-02 12:43:11 -05:00
|
|
|
|
<div class="sfp-stat">
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<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>` : ''}
|
2026-03-02 12:43:11 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return `
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<div class="link-iface-card ${isDown ? 'port-down' : ''}">
|
2026-03-02 12:43:11 -05:00
|
|
|
|
<div class="link-iface-header">
|
|
|
|
|
|
<span class="link-iface-name">${escHtml(ifaceName)}</span>
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<span class="link-iface-speed">${speedStr}</span>
|
|
|
|
|
|
<span class="link-iface-type ${mediaTag}">${escHtml(mediaLabel)}</span>
|
2026-03-14 21:46:11 -04:00
|
|
|
|
${errorBadges(d)}
|
2026-03-02 12:43:11 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="link-stats-grid">
|
|
|
|
|
|
<div class="link-stat">
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<span class="link-stat-label">Link</span>
|
|
|
|
|
|
<span class="link-stat-value ${isDown ? 'val-crit' : 'val-good'}">${isDown ? 'DOWN' : 'UP'}</span>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="link-stat">
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<span class="link-stat-label">Duplex</span>
|
|
|
|
|
|
<span class="link-stat-value">${fmtDuplex(d.duplex)}</span>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="link-stat">
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<span class="link-stat-label">Auto-neg</span>
|
2026-04-19 23:35:02 -04:00
|
|
|
|
<span class="link-stat-value">${d.auto_neg == null ? '–' : d.auto_neg ? 'On' : 'Off'}</span>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="link-stat">
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<span class="link-stat-label">Carrier Δ</span>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
<span class="link-stat-value">${fmtCarrier(d.carrier_changes)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="link-stat">
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<span class="link-stat-label">TX Err/s</span>
|
2026-04-19 23:35:02 -04:00
|
|
|
|
<span class="link-stat-value">${fmtErrors(d.tx_errs_rate)}</span>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="link-stat">
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<span class="link-stat-label">RX Err/s</span>
|
2026-04-19 23:35:02 -04:00
|
|
|
|
<span class="link-stat-value">${fmtErrors(d.rx_errs_rate)}</span>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="link-stat">
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<span class="link-stat-label">TX Drop/s</span>
|
2026-04-19 23:35:02 -04:00
|
|
|
|
<span class="link-stat-value">${fmtErrors(d.tx_drops_rate)}</span>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="link-stat">
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<span class="link-stat-label">RX Drop/s</span>
|
2026-04-19 23:35:02 -04:00
|
|
|
|
<span class="link-stat-value">${fmtErrors(d.rx_drops_rate)}</span>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="traffic-section">
|
|
|
|
|
|
<div class="traffic-row">
|
|
|
|
|
|
<span class="traffic-label">TX</span>
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<div class="traffic-bar-track"><div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div></div>
|
2026-04-19 23:35:02 -04:00
|
|
|
|
<span class="traffic-value">${fmtRate(d.tx_bytes_rate)}</span>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="traffic-row">
|
|
|
|
|
|
<span class="traffic-label">RX</span>
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<div class="traffic-bar-track"><div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div></div>
|
2026-04-19 23:35:02 -04:00
|
|
|
|
<span class="traffic-value">${fmtRate(d.rx_bytes_rate)}</span>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
</div>
|
2026-04-18 21:01:20 -04:00
|
|
|
|
</div>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
${sfpHtml}
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 15:39:48 -05:00
|
|
|
|
// ── Render a single UniFi switch port card ────────────────────────
|
|
|
|
|
|
function renderPortCard(portName, d) {
|
2026-04-18 21:01:20 -04:00
|
|
|
|
const isDown = !d.up;
|
|
|
|
|
|
const speedStr = d.speed_mbps ? fmtSpeed(d.speed_mbps) : '–';
|
2026-04-19 23:35:02 -04:00
|
|
|
|
const txPct = fmtRateBar(d.tx_bytes_rate, d.speed_mbps);
|
|
|
|
|
|
const rxPct = fmtRateBar(d.rx_bytes_rate, d.speed_mbps);
|
2026-04-18 21:01:20 -04:00
|
|
|
|
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>` : '';
|
2026-04-19 23:35:02 -04:00
|
|
|
|
const poeBadge = d.poe_power ? `<span class="port-badge port-badge-poe">PoE ${d.poe_power.toFixed(1)}W</span>` : '';
|
2026-04-18 21:01:20 -04:00
|
|
|
|
const lldpLine = d.lldp ? `<div class="port-lldp">→ ${escHtml(d.lldp.system_name || '')} (${escHtml(d.lldp.port_id || '')})</div>` : '';
|
2026-04-19 23:35:02 -04:00
|
|
|
|
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>` : '';
|
2026-03-03 15:39:48 -05:00
|
|
|
|
|
|
|
|
|
|
return `
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<div class="link-iface-card ${isDown ? 'port-down' : ''}">
|
2026-03-03 15:39:48 -05:00
|
|
|
|
<div class="link-iface-header">
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<span class="link-iface-name">${numBadge} ${escHtml(portName)}</span>
|
|
|
|
|
|
<span class="link-iface-speed">${speedStr}</span>
|
|
|
|
|
|
${uplinkBadge}${poeBadge}
|
2026-03-14 21:46:11 -04:00
|
|
|
|
${errorBadges(d)}
|
2026-03-03 15:39:48 -05:00
|
|
|
|
</div>
|
2026-04-18 21:01:20 -04:00
|
|
|
|
${lldpLine}${poeLine}
|
2026-03-03 15:39:48 -05:00
|
|
|
|
<div class="link-stats-grid">
|
|
|
|
|
|
<div class="link-stat">
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<span class="link-stat-label">Link</span>
|
|
|
|
|
|
<span class="link-stat-value ${isDown ? 'val-crit' : 'val-good'}">${isDown ? 'DOWN' : 'UP'}</span>
|
2026-03-03 15:39:48 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="link-stat">
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<span class="link-stat-label">Duplex</span>
|
2026-04-19 23:35:02 -04:00
|
|
|
|
<span class="link-stat-value">${d.full_duplex == null ? '–' : d.full_duplex ? 'Full' : 'Half'}</span>
|
2026-03-03 15:39:48 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="link-stat">
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<span class="link-stat-label">Auto-neg</span>
|
2026-04-19 23:35:02 -04:00
|
|
|
|
<span class="link-stat-value">${d.autoneg == null ? '–' : d.autoneg ? 'On' : 'Off'}</span>
|
2026-03-03 15:39:48 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="link-stat">
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<span class="link-stat-label">TX Err/s</span>
|
2026-04-19 23:35:02 -04:00
|
|
|
|
<span class="link-stat-value">${fmtErrors(d.tx_errs_rate)}</span>
|
2026-03-03 15:39:48 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="link-stat">
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<span class="link-stat-label">RX Err/s</span>
|
2026-04-19 23:35:02 -04:00
|
|
|
|
<span class="link-stat-value">${fmtErrors(d.rx_errs_rate)}</span>
|
2026-03-03 15:39:48 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="link-stat">
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<span class="link-stat-label">TX Drop/s</span>
|
2026-04-19 23:35:02 -04:00
|
|
|
|
<span class="link-stat-value">${fmtErrors(d.tx_drops_rate)}</span>
|
2026-03-03 15:39:48 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="traffic-section">
|
|
|
|
|
|
<div class="traffic-row">
|
|
|
|
|
|
<span class="traffic-label">TX</span>
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<div class="traffic-bar-track"><div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div></div>
|
2026-04-19 23:35:02 -04:00
|
|
|
|
<span class="traffic-value">${fmtRate(d.tx_bytes_rate)}</span>
|
2026-03-03 15:39:48 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="traffic-row">
|
|
|
|
|
|
<span class="traffic-label">RX</span>
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<div class="traffic-bar-track"><div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div></div>
|
2026-04-19 23:35:02 -04:00
|
|
|
|
<span class="traffic-value">${fmtRate(d.rx_bytes_rate)}</span>
|
2026-03-03 15:39:48 -05:00
|
|
|
|
</div>
|
2026-04-18 21:01:20 -04:00
|
|
|
|
</div>
|
2026-03-03 15:39:48 -05:00
|
|
|
|
</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-18 21:01:20 -04:00
|
|
|
|
// ── Render all UniFi switches ─────────────────────────────────────
|
2026-04-19 23:35:02 -04:00
|
|
|
|
function renderUnifiSwitches(unifiSwitches, dataUpdated) {
|
2026-03-03 15:39:48 -05:00
|
|
|
|
if (!unifiSwitches || !Object.keys(unifiSwitches).length) return '';
|
2026-04-19 23:35:02 -04:00
|
|
|
|
const updStr = dataUpdated
|
|
|
|
|
|
? new Date(dataUpdated.replace(' UTC', 'Z').replace(' ', 'T')).toLocaleTimeString()
|
|
|
|
|
|
: '';
|
2026-04-18 21:01:20 -04:00
|
|
|
|
const html = Object.entries(unifiSwitches).map(([swName, sw]) => {
|
|
|
|
|
|
const ports = sw.ports || {};
|
2026-04-19 23:35:02 -04:00
|
|
|
|
const portValues = Object.values(ports);
|
2026-04-18 21:01:20 -04:00
|
|
|
|
const portCards = Object.entries(ports)
|
|
|
|
|
|
.sort(([,a],[,b]) => (a.port_idx||0) - (b.port_idx||0))
|
|
|
|
|
|
.map(([pname, d]) => renderPortCard(pname, d)).join('');
|
2026-04-19 23:35:02 -04:00
|
|
|
|
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` : '';
|
2026-04-18 21:01:20 -04:00
|
|
|
|
|
|
|
|
|
|
// PoE utilisation bar
|
|
|
|
|
|
let poebar = '';
|
2026-04-19 23:35:02 -04:00
|
|
|
|
if (poe_total_w > 0 && poe_max_w > 0) {
|
|
|
|
|
|
const pct = Math.min(100, (poe_total_w / poe_max_w) * 100);
|
2026-04-18 21:01:20 -04:00
|
|
|
|
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>`;
|
|
|
|
|
|
}
|
2026-03-03 15:39:48 -05:00
|
|
|
|
|
|
|
|
|
|
return `
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<div class="link-host-panel" id="panel-${CSS.escape(swName)}">
|
2026-04-29 17:53:48 -04:00
|
|
|
|
<div class="link-host-title" data-action="toggle-panel">
|
2026-03-03 15:39:48 -05:00
|
|
|
|
<span class="link-host-name">${escHtml(swName)}</span>
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<span class="link-host-ip">${escHtml(sw.ip || '')}</span>
|
2026-04-19 23:35:02 -04:00
|
|
|
|
<span class="link-host-upd">${escHtml(sw.model || '')}${updStr ? ' · ' + updStr : ''}${poeLoad}</span>
|
2026-04-18 21:01:20 -04:00
|
|
|
|
${poebar}
|
|
|
|
|
|
<span class="panel-toggle">[–]</span>
|
2026-03-03 15:39:48 -05:00
|
|
|
|
</div>
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<div class="link-ifaces-grid">${portCards}</div>
|
2026-03-03 15:39:48 -05:00
|
|
|
|
</div>`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
|
2026-04-18 21:01:20 -04:00
|
|
|
|
return `<div class="unifi-section-header">UNIFI SWITCH PORTS</div>${html}`;
|
2026-03-03 15:39:48 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-18 21:01:20 -04:00
|
|
|
|
// ── Panel collapse / expand ───────────────────────────────────────
|
2026-03-03 15:39:48 -05:00
|
|
|
|
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) {
|
2026-04-18 21:01:20 -04:00
|
|
|
|
const collapsed = JSON.parse(sessionStorage.getItem('linksCollapsed') || '{}');
|
|
|
|
|
|
collapsed[id] = panel.classList.contains('collapsed');
|
|
|
|
|
|
sessionStorage.setItem('linksCollapsed', JSON.stringify(collapsed));
|
2026-03-03 15:39:48 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function restoreCollapseState() {
|
2026-04-18 21:01:20 -04:00
|
|
|
|
const collapsed = JSON.parse(sessionStorage.getItem('linksCollapsed') || '{}');
|
|
|
|
|
|
for (const [id, isCollapsed] of Object.entries(collapsed)) {
|
2026-03-03 15:39:48 -05:00
|
|
|
|
const panel = document.getElementById(id);
|
2026-04-18 21:01:20 -04:00
|
|
|
|
if (!panel) continue;
|
|
|
|
|
|
if (isCollapsed) {
|
2026-03-03 15:39:48 -05:00
|
|
|
|
panel.classList.add('collapsed');
|
|
|
|
|
|
const btn = panel.querySelector('.panel-toggle');
|
|
|
|
|
|
if (btn) btn.textContent = '[+]';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-18 21:01:20 -04:00
|
|
|
|
// ── Build summary stats header ────────────────────────────────────
|
2026-03-14 21:48:40 -04:00
|
|
|
|
function buildLinkSummary(hosts, unifiSwitches) {
|
2026-04-18 21:01:20 -04:00
|
|
|
|
let totalIfaces = 0, downIfaces = 0, errIfaces = 0, totalPoe = 0;
|
|
|
|
|
|
for (const ifaces of Object.values(hosts || {})) {
|
2026-03-14 21:48:40 -04:00
|
|
|
|
for (const d of Object.values(ifaces)) {
|
2026-04-18 21:01:20 -04:00
|
|
|
|
totalIfaces++;
|
|
|
|
|
|
if (d.link_detected === false) downIfaces++;
|
2026-04-19 23:35:02 -04:00
|
|
|
|
if ((d.tx_errs_rate || 0) > 0 || (d.rx_errs_rate || 0) > 0) errIfaces++;
|
2026-03-14 21:48:40 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const sw of Object.values(unifiSwitches || {})) {
|
2026-04-19 23:35:02 -04:00
|
|
|
|
for (const p of Object.values(sw.ports || {})) {
|
|
|
|
|
|
totalPoe += p.poe_power || 0;
|
|
|
|
|
|
}
|
2026-03-14 21:48:40 -04:00
|
|
|
|
}
|
2026-04-18 21:01:20 -04:00
|
|
|
|
const hasAlerts = downIfaces > 0 || errIfaces > 0;
|
2026-03-14 21:48:40 -04:00
|
|
|
|
return `
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<div class="link-summary-panel ${hasAlerts ? 'link-summary-has-alerts' : ''}">
|
2026-03-14 21:48:40 -04:00
|
|
|
|
<div class="link-summary-grid">
|
|
|
|
|
|
<div class="link-summary-stat">
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<span class="lss-label">Total Interfaces</span>
|
|
|
|
|
|
<span class="lss-value">${totalIfaces}</span>
|
2026-03-14 21:48:40 -04:00
|
|
|
|
</div>
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<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">
|
2026-03-14 21:48:40 -04:00
|
|
|
|
<span class="lss-label">PoE Load</span>
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<span class="lss-value">${totalPoe.toFixed(1)} <span class="lss-sub">W</span></span>
|
2026-03-14 21:48:40 -04:00
|
|
|
|
</div>` : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-18 21:01:20 -04:00
|
|
|
|
// ── Main render ───────────────────────────────────────────────────
|
2026-03-02 12:43:11 -05:00
|
|
|
|
function renderLinks(data) {
|
2026-04-18 21:01:20 -04:00
|
|
|
|
const hosts = data.hosts || {};
|
|
|
|
|
|
const unifiSwitches = data.unifi_switches || {};
|
|
|
|
|
|
const parts = [];
|
|
|
|
|
|
|
|
|
|
|
|
parts.push(buildLinkSummary(hosts, unifiSwitches));
|
|
|
|
|
|
parts.push(`<div class="link-collapse-bar">
|
2026-04-29 17:53:48 -04:00
|
|
|
|
<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>
|
2026-04-18 21:01:20 -04:00
|
|
|
|
</div>`);
|
|
|
|
|
|
parts.push('<div class="link-host-list">');
|
|
|
|
|
|
|
|
|
|
|
|
for (const [hostname, ifaces] of Object.entries(hosts)) {
|
2026-03-02 12:43:11 -05:00
|
|
|
|
const ifaceCards = Object.entries(ifaces)
|
|
|
|
|
|
.sort(([a],[b]) => a.localeCompare(b))
|
2026-04-18 21:01:20 -04:00
|
|
|
|
.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)}">
|
2026-04-29 17:53:48 -04:00
|
|
|
|
<div class="link-host-title" data-action="toggle-panel">
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<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>
|
2026-03-02 12:43:11 -05:00
|
|
|
|
</div>
|
2026-04-18 21:01:20 -04:00
|
|
|
|
<div class="link-ifaces-grid">${ifaceCards}</div>
|
|
|
|
|
|
</div>`);
|
|
|
|
|
|
}
|
2026-03-03 15:39:48 -05:00
|
|
|
|
|
2026-04-19 23:35:02 -04:00
|
|
|
|
parts.push(renderUnifiSwitches(unifiSwitches, data.updated));
|
2026-04-18 21:01:20 -04:00
|
|
|
|
parts.push('</div>');
|
|
|
|
|
|
document.getElementById('links-container').innerHTML = parts.join('');
|
2026-03-03 15:39:48 -05:00
|
|
|
|
restoreCollapseState();
|
2026-04-18 21:01:20 -04:00
|
|
|
|
}
|
2026-03-02 12:43:11 -05:00
|
|
|
|
|
2026-04-18 21:01:20 -04:00
|
|
|
|
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', '{}');
|
2026-03-02 12:43:11 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-18 21:01:20 -04:00
|
|
|
|
// ── Stale data warning ────────────────────────────────────────────
|
2026-03-14 21:46:11 -04:00
|
|
|
|
function checkLinksStale(updatedStr) {
|
|
|
|
|
|
if (!updatedStr) return;
|
2026-04-18 21:01:20 -04:00
|
|
|
|
const age = (Date.now() - new Date(updatedStr + (updatedStr.includes('Z') ? '' : 'Z'))) / 1000;
|
|
|
|
|
|
let banner = document.getElementById('links-stale-banner');
|
|
|
|
|
|
if (age > 120) {
|
2026-03-14 21:46:11 -04:00
|
|
|
|
if (!banner) {
|
|
|
|
|
|
banner = document.createElement('div');
|
|
|
|
|
|
banner.id = 'links-stale-banner';
|
|
|
|
|
|
banner.className = 'stale-banner';
|
2026-04-18 21:01:20 -04:00
|
|
|
|
document.getElementById('links-container').prepend(banner);
|
2026-03-14 21:46:11 -04:00
|
|
|
|
}
|
2026-04-18 21:01:20 -04:00
|
|
|
|
banner.textContent = `⚠ Link data may be stale — last updated ${Math.floor(age/60)}m ago.`;
|
2026-03-14 21:46:11 -04:00
|
|
|
|
banner.style.display = '';
|
|
|
|
|
|
} else if (banner) {
|
|
|
|
|
|
banner.style.display = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-18 21:01:20 -04:00
|
|
|
|
// ── Fetch + render ────────────────────────────────────────────────
|
2026-03-02 12:43:11 -05:00
|
|
|
|
async function loadLinks() {
|
|
|
|
|
|
try {
|
2026-04-18 23:59:19 -04:00
|
|
|
|
const data = await lt.api.get('/api/links');
|
2026-04-18 21:01:20 -04:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
2026-03-02 12:43:11 -05:00
|
|
|
|
renderLinks(data);
|
2026-03-14 21:46:11 -04:00
|
|
|
|
checkLinksStale(data.updated);
|
2026-04-18 21:01:20 -04:00
|
|
|
|
} catch (e) {
|
2026-03-02 12:43:11 -05:00
|
|
|
|
document.getElementById('links-container').innerHTML =
|
2026-04-18 21:01:20 -04:00
|
|
|
|
'<div class="error-state">Network error loading link statistics.</div>';
|
2026-04-18 23:59:19 -04:00
|
|
|
|
lt.toast.error('Failed to load link statistics');
|
2026-03-02 12:43:11 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
loadLinks();
|
2026-04-18 23:59:19 -04:00
|
|
|
|
lt.autoRefresh.start(loadLinks, 60000);
|
2026-04-29 17:53:48 -04:00
|
|
|
|
|
|
|
|
|
|
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; }
|
|
|
|
|
|
});
|
2026-03-02 12:43:11 -05:00
|
|
|
|
</script>
|
|
|
|
|
|
{% endblock %}
|