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 >
2026-05-10 23:09:25 -04:00
< p class = "g-page-sub" >
2026-05-01 01:09:30 -04:00
Per-interface stats: speed, duplex, SFP optical levels, TX/RX rates, errors, and carrier changes.
2026-05-10 23:19:32 -04:00
< span id = "links-updated" class = "g-page-sub-aside" ></ span >
2026-05-01 01:09:30 -04:00
</ p >
</ div >
2026-03-02 12:43:11 -05:00
</ div >
2026-05-01 17:39:11 -04:00
< div class = "lt-toolbar" id = "links-toolbar" style = "display:none" >
< div class = "lt-toolbar-left" >
< div class = "lt-search" >
< input type = "search" class = "lt-input lt-search-input" id = "links-search"
2026-05-10 23:34:16 -04:00
placeholder = "Filter by host or switch name…" autocomplete = "off"
aria-label = "Filter by host or switch name" >
2026-05-01 17:39:11 -04:00
</ div >
</ div >
< div class = "lt-toolbar-right" >
< button class = "lt-btn lt-btn-ghost lt-btn-sm" data-action = "collapse-all" > Collapse All</ button >
< button class = "lt-btn lt-btn-ghost lt-btn-sm" data-action = "expand-all" > Expand All</ button >
</ div >
</ div >
2026-03-02 12:43:11 -05:00
< 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-05-10 23:37:32 -04:00
const _toIso = s => s ? s . replace ( ' UTC' , 'Z' ). replace ( ' ' , 'T' ) : s ;
2026-04-18 23:59:19 -04:00
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
2026-05-10 23:37:32 -04:00
? new Date ( _toIso ( dataUpdated )). toLocaleTimeString ()
2026-04-19 23:35:02 -04:00
: '' ;
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-05-10 23:44:23 -04:00
<div class="link-host-title" data-action="toggle-panel" role="button" tabindex="0" aria-expanded="true">
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-05-10 23:19:32 -04:00
return `<div class="lt-divider lt-divider--unifi"><span class="lt-divider-label lt-divider-label--unifi">UNIFI SWITCH PORTS</span></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' );
2026-05-10 23:44:23 -04:00
const isCollapsed = panel . classList . contains ( 'collapsed' );
const btn = panel . querySelector ( '.panel-toggle' );
const title = panel . querySelector ( '.link-host-title' );
if ( btn ) btn . textContent = isCollapsed ? '[+]' : '[– ]' ;
if ( title ) title . setAttribute ( 'aria-expanded' , isCollapsed ? 'false' : 'true' );
2026-03-03 15:39:48 -05:00
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' );
2026-05-10 23:44:23 -04:00
const btn = panel . querySelector ( '.panel-toggle' );
const title = panel . querySelector ( '.link-host-title' );
if ( btn ) btn . textContent = '[+]' ;
if ( title ) title . setAttribute ( 'aria-expanded' , 'false' );
2026-03-03 15:39:48 -05:00
}
}
}
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 ;
2026-05-10 23:34:16 -04:00
const downCls = allDown > 0 ? 'lt-text-red' : 'lt-text-green' ;
const errCls = errIfaces > 0 ? 'lt-text-amber' : 'lt-text-green' ;
2026-05-01 17:15:48 -04:00
const downCardCls = allDown > 0 ? ' lt-stat-card--alert' : '' ;
const poeCard = totalPoe > 0 ? `
<div class="lt-stat-card">
2026-05-10 23:19:32 -04:00
<span class="lt-stat-icon lt-text-amber" aria-hidden="true">⚡</span>
2026-05-01 17:15:48 -04:00
<div class="lt-stat-info">
2026-05-10 23:19:32 -04:00
<span class="lt-stat-value lt-text-amber"> ${ totalPoe . toFixed ( 1 ) } </span>
2026-05-01 17:15:48 -04:00
<span class="lt-stat-label">PoE Load (W)</span>
</div>
</div>` : '' ;
2026-03-14 21:48:40 -04:00
return `
2026-05-10 23:19:32 -04:00
<div class="lt-stats-grid lt-stats-grid--mb">
2026-05-01 17:15:48 -04:00
<div class="lt-stat-card">
2026-05-10 23:19:32 -04:00
<span class="lt-stat-icon lt-text-cyan" aria-hidden="true">⬡</span>
2026-05-01 17:15:48 -04:00
<div class="lt-stat-info">
2026-05-10 23:19:32 -04:00
<span class="lt-stat-value lt-text-cyan"> ${ allTotal } </span>
2026-05-01 17:15:48 -04:00
<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 } ">
2026-05-10 23:34:16 -04:00
<span class="lt-stat-icon ${ downCls } " aria-hidden="true">●</span>
2026-05-01 17:15:48 -04:00
<div class="lt-stat-info">
2026-05-10 23:34:16 -04:00
<span class="lt-stat-value ${ downCls } "> ${ allDown } </span>
2026-05-01 17:15:48 -04:00
<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">
2026-05-10 23:34:16 -04:00
<span class="lt-stat-icon ${ errCls } " aria-hidden="true">▲</span>
2026-05-01 17:15:48 -04:00
<div class="lt-stat-info">
2026-05-10 23:34:16 -04:00
<span class="lt-stat-value ${ errCls } "> ${ errIfaces } </span>
2026-05-01 17:15:48 -04:00
<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-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
2026-05-10 23:44:23 -04:00
? new Date ( _toIso ( data . updated )). toLocaleTimeString ()
2026-04-18 21:01:20 -04:00
: '' ;
parts . push ( `
<div class="link-host-panel" id="panel- ${ CSS . escape ( hostname ) } ">
2026-05-10 23:44:23 -04:00
<div class="link-host-title" data-action="toggle-panel" role="button" tabindex="0" aria-expanded="true">
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-05-01 17:39:11 -04:00
document . getElementById ( 'links-toolbar' ). style . display = '' ;
applyLinksSearch ();
}
// ── Host/switch search filter ─────────────────────────────────────
function applyLinksSearch () {
const q = ( document . getElementById ( 'links-search' ) ? . value || '' ). trim (). toLowerCase ();
document . querySelectorAll ( '.link-host-panel' ). forEach ( panel => {
const text = ( panel . querySelector ( '.link-host-name' ) ? . textContent || '' ). toLowerCase ();
panel . style . display = ( ! q || text . includes ( q )) ? '' : 'none' ;
});
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' );
2026-05-10 23:44:23 -04:00
const btn = p . querySelector ( '.panel-toggle' );
const title = p . querySelector ( '.link-host-title' );
if ( btn ) btn . textContent = '[+]' ;
if ( title ) title . setAttribute ( 'aria-expanded' , 'false' );
2026-04-18 21:01:20 -04:00
});
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' );
2026-05-10 23:44:23 -04:00
const btn = p . querySelector ( '.panel-toggle' );
const title = p . querySelector ( '.link-host-title' );
if ( btn ) btn . textContent = '[– ]' ;
if ( title ) title . setAttribute ( 'aria-expanded' , 'true' );
2026-04-18 21:01:20 -04:00
});
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-05-10 23:37:32 -04:00
const _linksInterval = ( window . gandalfSettings && window . gandalfSettings . refreshInterval ) || 60 ;
2026-04-30 21:33:02 -04:00
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-05-01 17:39:11 -04:00
2026-05-10 23:44:23 -04:00
document . addEventListener ( 'keydown' , e => {
if ( e . key !== 'Enter' && e . key !== ' ' ) return ;
const toggleTitle = e . target . closest ( '[data-action="toggle-panel"]' );
if ( toggleTitle ) { e . preventDefault (); togglePanel ( toggleTitle . closest ( '.link-host-panel' )); }
});
2026-05-01 17:39:11 -04:00
document . getElementById ( 'links-search' ) ? . addEventListener ( 'input' , applyLinksSearch );
2026-03-02 12:43:11 -05:00
</ script >
{% endblock %}