2026-03-02 12:43:11 -05:00
{% extends "base.html" %}
{% block title %}Link Debug – GANDALF{% endblock %}
{% block content %}
2026-05-01 01:09:30 -04:00
< div class = "lt-page-header" >
< div >
< h1 class = "lt-page-title" > Link Debug< / h1 >
< p class = "g-page-sub" style = "margin-top:4px" >
Per-interface stats: speed, duplex, SFP optical levels, TX/RX rates, errors, and carrier changes.
< span id = "links-updated" style = "margin-left:8px" > < / span >
< / p >
< / div >
2026-03-02 12:43:11 -05:00
< / 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 ) ;
}
2026-04-29 23:37:47 -04:00
function trafficBarClass ( pct , isTx ) {
if ( pct > 85 ) return 'lt-progress--red' ;
2026-04-30 21:09:56 -04:00
if ( pct > 65 ) return 'lt-progress--amber' ;
2026-04-29 23:37:47 -04:00
return isTx ? '' : 'lt-progress--cyan' ;
}
2026-03-02 12:43:11 -05:00
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">
2026-04-30 21:09:56 -04:00
<span class="sfp-stat-label" data-tooltip="SFP module temperature. Normal: below 70°C. Warn: 70– 85°C. Critical: above 85°C.">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">
2026-04-30 21:09:56 -04:00
<span class="sfp-stat-label" data-tooltip="SFP supply voltage. Normal: 3.1– 3.5V.">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">
2026-04-30 21:09:56 -04:00
<span class="sfp-stat-label" data-tooltip="Laser bias current in mA. High values may indicate end-of-life laser diode.">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">
2026-04-30 21:09:56 -04:00
<span class="sfp-stat-label" data-tooltip="Optical transmit power in dBm. Typical good range: -3 to -9 dBm.">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">
2026-04-30 21:09:56 -04:00
<span class="sfp-stat-label" data-tooltip="Optical receive power in dBm. Typical good range: -3 to -18 dBm. Below -20 dBm may indicate dirty/damaged fiber.">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-30 21:09:56 -04:00
<span class="sfp-stat-label" data-tooltip="Insertion loss: difference between transmit and receive power. Large negative values indicate fiber loss or connector issues.">RX− TX Δ</span>
2026-04-18 21:01:20 -04:00
<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-30 21:09:56 -04:00
<span class="link-stat-label" data-tooltip="Full = simultaneous send/receive at full speed. Half = one direction at a time, can cause collisions.">Duplex</span>
2026-04-18 21:01:20 -04:00
<span class="link-stat-value"> ${ fmtDuplex ( d . duplex ) } </span>
2026-03-02 12:43:11 -05:00
</div>
<div class="link-stat">
2026-04-30 21:09:56 -04:00
<span class="link-stat-label" data-tooltip="Autonegotiation: NIC and switch automatically agree on link speed and duplex mode.">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-30 21:09:56 -04:00
<span class="link-stat-label" data-tooltip="Carrier changes: number of times the link went up or down. High values indicate a flapping or unstable cable/SFP.">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-30 21:09:56 -04:00
<span class="link-stat-label" data-tooltip="Transmit errors per second reported by the kernel network driver.">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-30 21:09:56 -04:00
<span class="link-stat-label" data-tooltip="Receive errors per second reported by the kernel network driver.">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-30 21:09:56 -04:00
<span class="link-stat-label" data-tooltip="Transmit packets dropped per second (ring buffer full or driver overrun).">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-30 21:09:56 -04:00
<span class="link-stat-label" data-tooltip="Receive packets dropped per second (ring buffer full or driver overrun).">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">
2026-04-30 21:09:56 -04:00
<span class="traffic-label" data-tooltip="Transmit — outgoing traffic from this server">TX</span>
2026-04-29 23:37:47 -04:00
<div class="lt-progress ${ trafficBarClass ( txPct , true ) } "><div class="lt-progress-bar" 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">
2026-04-30 21:09:56 -04:00
<span class="traffic-label" data-tooltip="Receive — incoming traffic to this server">RX</span>
2026-04-29 23:37:47 -04:00
<div class="lt-progress ${ trafficBarClass ( rxPct , false ) } "><div class="lt-progress-bar" 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-29 23:37:47 -04:00
<div class="lt-progress ${ trafficBarClass ( txPct , true ) } "><div class="lt-progress-bar" 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-29 23:37:47 -04:00
<div class="lt-progress ${ trafficBarClass ( rxPct , false ) } "><div class="lt-progress-bar" 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
}
}
2026-05-01 17:15:48 -04:00
let swTotal = 0 , swDown = 0 ;
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-05-01 17:15:48 -04:00
swTotal ++ ;
if ( ! p . up ) swDown ++ ;
2026-04-19 23:35:02 -04:00
}
2026-03-14 21:48:40 -04:00
}
2026-05-01 17:15:48 -04:00
const allTotal = totalIfaces + swTotal ;
const allDown = downIfaces + swDown ;
const downColor = allDown > 0 ? 'var(--red)' : 'var(--green)' ;
const errColor = errIfaces > 0 ? 'var(--amber)' : 'var(--green)' ;
const downCardCls = allDown > 0 ? ' lt-stat-card--alert' : '' ;
const poeCard = totalPoe > 0 ? `
<div class="lt-stat-card">
<span class="lt-stat-icon" aria-hidden="true" style="color:var(--amber)">⚡</span>
<div class="lt-stat-info">
<span class="lt-stat-value" style="color:var(--amber)"> ${ totalPoe . toFixed ( 1 ) } </span>
<span class="lt-stat-label">PoE Load (W)</span>
</div>
</div> ` : '' ;
2026-03-14 21:48:40 -04:00
return `
2026-05-01 17:15:48 -04:00
<div class="lt-stats-grid" style="margin-bottom:16px">
<div class="lt-stat-card">
<span class="lt-stat-icon" aria-hidden="true" style="color:var(--cyan)">⬡</span>
<div class="lt-stat-info">
<span class="lt-stat-value" style="color:var(--cyan)"> ${ allTotal } </span>
<span class="lt-stat-label">Interfaces</span>
2026-03-14 21:48:40 -04:00
</div>
2026-05-01 17:15:48 -04:00
</div>
<div class="lt-stat-card ${ downCardCls } ">
<span class="lt-stat-icon" aria-hidden="true" style="color: ${ downColor } ">●</span>
<div class="lt-stat-info">
<span class="lt-stat-value" style="color: ${ downColor } "> ${ allDown } </span>
<span class="lt-stat-label">Ports Down</span>
2026-04-18 21:01:20 -04:00
</div>
2026-05-01 17:15:48 -04:00
</div>
<div class="lt-stat-card">
<span class="lt-stat-icon" aria-hidden="true" style="color: ${ errColor } ">▲</span>
<div class="lt-stat-info">
<span class="lt-stat-value" style="color: ${ errColor } "> ${ errIfaces } </span>
<span class="lt-stat-label">With Errors</span>
2026-04-18 21:01:20 -04:00
</div>
2026-03-14 21:48:40 -04:00
</div>
2026-05-01 17:15:48 -04:00
${ poeCard }
2026-03-14 21:48:40 -04:00
</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 || '' ;
2026-04-30 21:09:56 -04:00
const updStr = data . updated
? new Date ( data . updated . replace ( ' UTC' , 'Z' ) . replace ( ' ' , 'T' ) ) . toLocaleTimeString ( )
2026-04-18 21:01:20 -04:00
: '' ;
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' ;
2026-04-29 23:37:47 -04:00
banner . className = 'lt-alert lt-alert--warning' ;
banner . innerHTML = '<span class="lt-alert-icon">⚠</span><div class="lt-alert-body"><div class="lt-alert-msg"></div></div>' ;
2026-04-18 21:01:20 -04:00
document . getElementById ( 'links-container' ) . prepend ( banner ) ;
2026-03-14 21:46:11 -04:00
}
2026-04-29 23:37:47 -04:00
banner . querySelector ( '.lt-alert-msg' ) . 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-30 21:33:02 -04:00
var _linksInterval = ( window . gandalfSettings && window . gandalfSettings . refreshInterval ) || 60 ;
if ( _linksInterval > 0 ) lt . autoRefresh . start ( loadLinks , Math . max ( _linksInterval , 15 ) * 1000 ) ;
window . onGandalfSettingsChanged = function ( s ) {
lt . autoRefresh . stop ( ) ;
if ( s . refreshInterval > 0 ) lt . autoRefresh . start ( loadLinks , Math . max ( s . refreshInterval , 15 ) * 1000 ) ;
} ;
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 %}