2026-03-03 15:39:48 -05:00
{% extends "base.html" %}
{% block title %}Inspector – GANDALF{% endblock %}
{% block content %}
2026-05-01 01:09:30 -04:00
< div class = "lt-page-header" >
< div >
< h1 class = "lt-page-title" > Network Inspector</ h1 >
2026-05-10 23:09:25 -04:00
< p class = "g-page-sub" >
2026-05-01 01:09:30 -04:00
Visual switch chassis diagrams. Click a port to see detailed stats and LLDP path debug.
2026-05-10 23:19:32 -04:00
< span id = "inspector-updated" class = "g-page-sub-aside" ></ span >
2026-05-01 01:09:30 -04:00
</ p >
</ div >
2026-03-03 15:39:48 -05:00
</ div >
< div class = "inspector-layout" >
2026-05-10 23:44:23 -04:00
< div class = "inspector-main" id = "inspector-main" role = "region" aria-label = "Switch chassis diagrams" >
2026-03-03 15:39:48 -05:00
< div class = "link-loading" > Loading inspector data</ div >
</ div >
2026-05-10 23:44:23 -04:00
< div class = "inspector-panel" id = "inspector-panel" role = "complementary" aria-label = "Port detail panel" >
2026-03-03 15:39:48 -05:00
< div class = "inspector-panel-inner" id = "inspector-panel-inner" ></ div >
</ div >
</ div >
{% endblock %}
{% block scripts %}
< script >
2026-04-18 23:59:19 -04:00
const escHtml = s => lt . escHtml ( s );
2026-03-03 15:39:48 -05:00
// ── Switch layout config ─────────────────────────────────────────────────
// keys match the model field returned by the UniFi API
// rows: array of rows, each row is an array of port_idx values
// sfp_ports: port_idx values that are SFP cages (in rows, rendered differently)
// sfp_section: port_idx values rendered as a separate SFP bank below the rows
const SWITCH_LAYOUTS = {
'USF5P' : { rows : [[ 1 , 2 , 3 , 4 ]], sfp_section : [ 5 ] },
'USL8A' : { rows : [[ 1 , 2 , 3 , 4 ],[ 5 , 6 , 7 , 8 ]], sfp_ports : [ 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 ] },
'US24PRO' : {
rows : [
[ 1 , 3 , 5 , 7 , 9 , 11 , 13 , 15 , 17 , 19 , 21 , 23 ],
[ 2 , 4 , 6 , 8 , 10 , 12 , 14 , 16 , 18 , 20 , 22 , 24 ],
],
sfp_section : [ 25 , 26 ],
},
'USPPDUP' : { rows : [[ 1 ]] },
'USMINI' : { rows : [[ 1 , 2 , 3 , 4 , 5 ]] },
};
// ── Formatting helpers ───────────────────────────────────────────────────
function fmtSpeed ( mbps ) {
if ( ! mbps ) return '– ' ;
if ( mbps >= 1000 ) return ( mbps / 1000 ). toFixed ( 0 ) + 'G' ;
return mbps + 'M' ;
}
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 fmtErrors ( rate ) {
if ( rate === null || rate === undefined ) return '– ' ;
if ( rate < 0.001 ) return '<span class="val-good">0 /s</span>' ;
return `<span class="val-crit"> ${ rate . toFixed ( 3 ) } /s</span>` ;
}
// ── Build port_idx → port data map ──────────────────────────────────────
function buildPortIdxMap ( ports ) {
const map = {};
for ( const [ pname , d ] of Object . entries ( ports )) {
if ( d . port_idx != null ) {
map [ d . port_idx ] = Object . assign ({ name : pname }, d );
}
}
return map ;
}
// ── Determine port block CSS state class ────────────────────────────────
function portBlockState ( d ) {
if ( ! d || ! d . up ) return 'down' ;
if ( d . is_uplink ) return 'uplink' ;
if ( d . poe_power != null && d . poe_power > 0 ) return 'poe-active' ;
return 'up' ;
}
2026-03-14 22:22:19 -04:00
// ── Speed label helper ───────────────────────────────────────────────────
function portSpeedLabel ( port ) {
if ( ! port || ! port . up ) return '' ;
const spd = port . speed ; // speed in Mbps from UniFi API
if ( ! spd ) return '' ;
if ( spd >= 10000 ) return '10G' ;
if ( spd >= 1000 ) return '1G' ;
if ( spd >= 100 ) return '100M' ;
return spd + 'M' ;
}
2026-03-03 15:39:48 -05:00
// ── Render a single port block element ──────────────────────────────────
function portBlockHtml ( idx , port , swName , sfpBlock ) {
2026-03-14 22:22:19 -04:00
const state = portBlockState ( port );
const numLabel = sfpBlock ? 'SFP' : idx ;
const title = port ? escHtml ( port . name ) : `Port ${ idx } ` ;
const sfpCls = sfpBlock ? ' sfp-block' : '' ;
const speedTxt = portSpeedLabel ( port );
// LLDP neighbor: first 6 chars of hostname
2026-05-11 09:31:25 -04:00
const lldpName = ( port && port . lldp && ( port . lldp . system_name || port . lldp . chassis_id ))
? escHtml (( port . lldp . system_name || port . lldp . chassis_id || '' ). slice ( 0 , 6 ))
2026-03-14 22:22:19 -04:00
: '' ;
const lldpHtml = lldpName ? `<span class="port-lldp"> ${ lldpName } </span>` : '' ;
const speedHtml = speedTxt ? `<span class="port-speed"> ${ speedTxt } </span>` : '' ;
2026-03-03 15:39:48 -05:00
return `<div class="switch-port-block ${ state }${ sfpCls } "
data-switch=" ${ escHtml ( swName ) } " data-port-idx=" ${ idx } "
2026-04-30 21:09:56 -04:00
title=" ${ title } " aria-label=" ${ title } "
2026-04-29 17:53:48 -04:00
data-action="select-port"><span class="port-num"> ${ numLabel } </span> ${ speedHtml }${ lldpHtml } </div>` ;
2026-03-14 22:22:19 -04:00
}
// ── Chassis legend HTML ──────────────────────────────────────────────────
function chassisLegendHtml () {
return `<div class="chassis-legend">
<div class="chassis-legend-item"><span class="chassis-legend-swatch cls-down"></span>down</div>
<div class="chassis-legend-item"><span class="chassis-legend-swatch cls-up"></span>up</div>
<div class="chassis-legend-item"><span class="chassis-legend-swatch cls-poe"></span>poe active</div>
<div class="chassis-legend-item"><span class="chassis-legend-swatch cls-uplink"></span>uplink</div>
</div>` ;
2026-03-03 15:39:48 -05:00
}
// ── Render one switch chassis ────────────────────────────────────────────
function renderChassis ( swName , sw ) {
const model = sw . model || '' ;
const layout = SWITCH_LAYOUTS [ model ] || null ;
const portMap = buildPortIdxMap ( sw . ports || {});
const upCount = Object . values ( sw . ports || {}). filter ( p => p . up ). length ;
const totCount = Object . keys ( sw . ports || {}). length ;
const downCount = totCount - upCount ;
const meta = [ model , ` ${ upCount } / ${ totCount } up` , downCount ? ` ${ downCount } down` : '' ]. filter ( Boolean ). join ( ' · ' );
2026-03-14 22:22:19 -04:00
// Is this a US24PRO? Used to add group-separator class
const isUs24Pro = ( model === 'US24PRO' );
2026-03-03 15:39:48 -05:00
let chassisHtml = '' ;
if ( layout ) {
2026-03-14 22:22:19 -04:00
const sfpPortSet = new Set ( layout . sfp_ports || []);
2026-03-03 15:39:48 -05:00
const sfpSectionSet = new Set ( layout . sfp_section || []);
// Main port rows
chassisHtml += '<div class="chassis-rows">' ;
for ( const row of layout . rows ) {
2026-03-14 22:22:19 -04:00
const rowCls = isUs24Pro ? ' us24pro-row' : '' ;
chassisHtml += `<div class="chassis-row ${ rowCls } ">` ;
2026-03-03 15:39:48 -05:00
for ( const idx of row ) {
const port = portMap [ idx ];
const isSfp = sfpPortSet . has ( idx );
const sfpCls = isSfp ? ' sfp-port' : '' ;
const state = portBlockState ( port );
const title = port ? escHtml ( port . name ) : `Port ${ idx } ` ;
2026-03-14 22:22:19 -04:00
const speedTxt = portSpeedLabel ( port );
2026-05-11 09:31:25 -04:00
const lldpName = ( port && port . lldp && ( port . lldp . system_name || port . lldp . chassis_id ))
? escHtml (( port . lldp . system_name || port . lldp . chassis_id || '' ). slice ( 0 , 6 ))
2026-03-14 22:22:19 -04:00
: '' ;
const speedHtml = speedTxt ? `<span class="port-speed"> ${ speedTxt } </span>` : '' ;
const lldpHtml = lldpName ? `<span class="port-lldp"> ${ lldpName } </span>` : '' ;
2026-03-03 15:39:48 -05:00
chassisHtml += `<div class="switch-port-block ${ state }${ sfpCls } "
data-switch=" ${ escHtml ( swName ) } " data-port-idx=" ${ idx } "
2026-04-29 17:53:48 -04:00
title=" ${ title } " aria-label=" ${ title } "
data-action="select-port"><span class="port-num"> ${ idx } </span> ${ speedHtml }${ lldpHtml } </div>` ;
2026-03-03 15:39:48 -05:00
}
chassisHtml += '</div>' ;
}
chassisHtml += '</div>' ;
// Separate SFP section (if present)
if ( sfpSectionSet . size ) {
chassisHtml += '<div class="chassis-sfp-section">' ;
for ( const idx of layout . sfp_section ) {
chassisHtml += portBlockHtml ( idx , portMap [ idx ], swName , true );
}
chassisHtml += '</div>' ;
}
} else {
// Fallback: render all ports sorted by idx
const allPorts = Object . entries ( sw . ports || {})
. sort (([, a ], [, b ]) => ( a . port_idx || 0 ) - ( b . port_idx || 0 ));
chassisHtml += '<div class="chassis-rows"><div class="chassis-row">' ;
for ( const [ pname , d ] of allPorts ) {
chassisHtml += portBlockHtml ( d . port_idx || 0 , Object . assign ({ name : pname }, d ), swName , false );
}
chassisHtml += '</div></div>' ;
}
return `
<div class="inspector-chassis" id="chassis- ${ escHtml ( swName ) } ">
<div class="chassis-header">
<span class="chassis-name"> ${ escHtml ( swName ) } </span>
${ sw . ip ? `<span class="chassis-ip"> ${ escHtml ( sw . ip ) } </span>` : '' }
<span class="chassis-meta"> ${ escHtml ( meta ) } </span>
</div>
2026-03-14 22:22:19 -04:00
<div class="chassis-body">
<div class="chassis-ear-l"></div>
<div class="chassis-ear-r"></div>
${ chassisHtml }
</div>
${ chassisLegendHtml () }
2026-03-03 15:39:48 -05:00
</div>` ;
}
// ── State ────────────────────────────────────────────────────────────────
let _selectedSwitch = null ;
let _selectedIdx = null ;
let _apiData = null ;
// ── Port selection ───────────────────────────────────────────────────────
function selectPort ( el ) {
const swName = el . dataset . switch ;
const idx = parseInt ( el . dataset . portIdx , 10 );
document . querySelectorAll ( '.switch-port-block.selected' )
. forEach ( e => e . classList . remove ( 'selected' ));
el . classList . add ( 'selected' );
_selectedSwitch = swName ;
_selectedIdx = idx ;
renderPanel ( swName , idx );
}
function closePanel () {
2026-05-11 08:45:28 -04:00
if ( _diagPollTimer ) { clearInterval ( _diagPollTimer ); _diagPollTimer = null ; }
2026-03-03 15:39:48 -05:00
document . getElementById ( 'inspector-panel' ). classList . remove ( 'open' );
document . querySelectorAll ( '.switch-port-block.selected' )
. forEach ( el => el . classList . remove ( 'selected' ));
_selectedSwitch = null ;
_selectedIdx = null ;
}
// ── Render detail panel ──────────────────────────────────────────────────
function renderPanel ( swName , idx ) {
if ( ! _apiData ) return ;
const sw = _apiData . unifi_switches && _apiData . unifi_switches [ swName ];
if ( ! sw ) return ;
const portMap = buildPortIdxMap ( sw . ports || {});
const d = portMap [ idx ];
if ( ! d ) return ;
const upStr = d . up ? '<span class="val-good">UP</span>' : '<span class="val-crit">DOWN</span>' ;
const speedStr = d . speed_mbps ? `<span class="val-cyan"> ${ fmtSpeed ( d . speed_mbps ) } bps</span>` : '– ' ;
const duplexCls = d . full_duplex ? 'val-good' : ( d . up ? 'val-warn' : 'val-neutral' );
const duplexStr = d . up ? `<span class=" ${ duplexCls } "> ${ d . full_duplex ? 'Full' : 'Half' } </span>` : '– ' ;
const autoneg = d . autoneg ? 'On' : 'Off' ;
const mediaStr = d . media || '– ' ;
const isUplinkBadge = d . is_uplink ? ' <span class="port-badge port-badge-uplink">UPLINK</span>' : '' ;
// PoE section
let poeHtml = '' ;
if ( d . poe_class != null ) {
const poeMaxStr = d . poe_max_power != null ? ` / max ${ d . poe_max_power . toFixed ( 1 ) } W` : '' ;
const poeCurStr = ( d . poe_power != null && d . poe_power > 0 ) ? ` / draw <span class="val-amber"> ${ d . poe_power . toFixed ( 1 ) } W</span>` : '' ;
poeHtml = `
2026-04-30 21:09:56 -04:00
<div class="lt-divider"><span class="lt-divider-label">PoE</span></div>
2026-03-03 15:39:48 -05:00
<div class="panel-row"><span class="panel-label">Class</span><span class="panel-val">class ${ d . poe_class }${ poeMaxStr } </span></div>
${ d . poe_power != null ? `<div class="panel-row"><span class="panel-label">Draw</span><span class="panel-val"> ${ d . poe_power > 0 ? `<span class="val-amber"> ${ d . poe_power . toFixed ( 1 ) } W</span>` : '0W' } </span></div>` : '' }
${ d . poe_mode ? `<div class="panel-row"><span class="panel-label">Mode</span><span class="panel-val"> ${ escHtml ( d . poe_mode ) } </span></div>` : '' } ` ;
}
// Traffic section
let trafficHtml = '' ;
if ( d . tx_bytes_rate != null || d . rx_bytes_rate != null ) {
trafficHtml = `
2026-04-30 21:09:56 -04:00
<div class="lt-divider"><span class="lt-divider-label">Traffic</span></div>
<div class="panel-row"><span class="panel-label" data-tooltip="Transmit — outgoing from this port">TX</span><span class="panel-val"> ${ fmtRate ( d . tx_bytes_rate ) } </span></div>
<div class="panel-row"><span class="panel-label" data-tooltip="Receive — incoming to this port">RX</span><span class="panel-val"> ${ fmtRate ( d . rx_bytes_rate ) } </span></div>` ;
2026-03-03 15:39:48 -05:00
}
// Errors / drops section
let errHtml = '' ;
if ( d . tx_errs_rate != null || d . rx_errs_rate != null ) {
errHtml = `
2026-04-30 21:09:56 -04:00
<div class="lt-divider"><span class="lt-divider-label">Errors / Drops</span></div>
2026-03-03 15:39:48 -05:00
<div class="panel-row"><span class="panel-label">TX Err</span><span class="panel-val"> ${ fmtErrors ( d . tx_errs_rate ) } </span></div>
<div class="panel-row"><span class="panel-label">RX Err</span><span class="panel-val"> ${ fmtErrors ( d . rx_errs_rate ) } </span></div>
<div class="panel-row"><span class="panel-label">TX Drop</span><span class="panel-val"> ${ fmtErrors ( d . tx_drops_rate ) } </span></div>
<div class="panel-row"><span class="panel-label">RX Drop</span><span class="panel-val"> ${ fmtErrors ( d . rx_drops_rate ) } </span></div>` ;
}
// LLDP + path debug
let lldpHtml = '' ;
let pathHtml = '' ;
if ( d . lldp && d . lldp . system_name ) {
const l = d . lldp ;
lldpHtml = `
2026-04-30 21:09:56 -04:00
<div class="lt-divider"><span class="lt-divider-label">LLDP Neighbor</span></div>
2026-03-03 15:39:48 -05:00
<div class="panel-row"><span class="panel-label">System</span><span class="panel-val val-cyan"> ${ escHtml ( l . system_name ) } </span></div>
${ l . port_id ? `<div class="panel-row"><span class="panel-label">Port</span><span class="panel-val"> ${ escHtml ( l . port_id ) } </span></div>` : '' }
${ l . port_desc ? `<div class="panel-row"><span class="panel-label">Port Desc</span><span class="panel-val"> ${ escHtml ( l . port_desc ) } </span></div>` : '' }
${ l . chassis_id ? `<div class="panel-row"><span class="panel-label">Chassis</span><span class="panel-val"> ${ escHtml ( l . chassis_id ) } </span></div>` : '' }
${ l . mgmt_ips && l . mgmt_ips . length ? `<div class="panel-row"><span class="panel-label">Mgmt IP</span><span class="panel-val"> ${ escHtml ( l . mgmt_ips . join ( ', ' )) } </span></div>` : '' } ` ;
// Path debug: look for matching server interface
const hosts = _apiData . hosts || {};
const serverIfaces = hosts [ l . system_name ];
if ( serverIfaces ) {
let matchedIface = l . port_id && serverIfaces [ l . port_id ] ? l . port_id : null ;
if ( ! matchedIface && l . port_id ) {
// fuzzy match
matchedIface = Object . keys ( serverIfaces ). find ( k => l . port_id . includes ( k ) || k . includes ( l . port_id )) || null ;
}
if ( matchedIface ) {
pathHtml = buildPathDebug ( swName , d , l . system_name , matchedIface , serverIfaces [ matchedIface ]);
}
}
}
2026-03-03 16:03:54 -05:00
// Diagnose button (only when LLDP has an identified neighbor we can map)
const hasDiagTarget = !! ( d . lldp && d . lldp . system_name &&
_apiData . hosts && _apiData . hosts [ d . lldp . system_name ]);
const diagHtml = hasDiagTarget ? `
<div class="diag-bar">
2026-05-10 23:21:27 -04:00
<button class="btn-diag lt-btn lt-btn-secondary lt-btn-sm" data-action="run-diagnostic"
data-sw=" ${ escHtml ( swName ) } " data-idx=" ${ idx } "
aria-label="Run link diagnostics for port ${ idx } on ${ escHtml ( swName ) } ">Run Diagnostics</button>
2026-03-03 16:03:54 -05:00
<span class="diag-status" id="diag-status"></span>
</div>
<div class="diag-results" id="diag-results"></div>` : '' ;
2026-03-03 15:39:48 -05:00
const inner = document . getElementById ( 'inspector-panel-inner' );
inner . innerHTML = `
<div class="panel-header">
<div>
<span class="panel-port-name"> ${ escHtml ( d . name ) } </span> ${ isUplinkBadge }
<div class="panel-meta"> ${ escHtml ( swName ) } · port # ${ idx } </div>
</div>
2026-04-29 17:53:48 -04:00
<button class="panel-close" data-action="close-panel" aria-label="Close panel">✕</button>
2026-03-03 15:39:48 -05:00
</div>
2026-04-30 21:09:56 -04:00
<div class="lt-divider"><span class="lt-divider-label">Link</span></div>
2026-03-03 15:39:48 -05:00
<div class="panel-row"><span class="panel-label">Status</span><span class="panel-val"> ${ upStr } </span></div>
<div class="panel-row"><span class="panel-label">Speed</span><span class="panel-val"> ${ speedStr } </span></div>
2026-04-30 21:09:56 -04:00
<div class="panel-row"><span class="panel-label" data-tooltip="Full = simultaneous send/receive. Half = one direction at a time.">Duplex</span><span class="panel-val"> ${ duplexStr } </span></div>
<div class="panel-row"><span class="panel-label" data-tooltip="Autonegotiation: NIC and switch automatically agree speed and duplex.">Auto-neg</span><span class="panel-val val-neutral"> ${ autoneg } </span></div>
2026-03-03 15:39:48 -05:00
<div class="panel-row"><span class="panel-label">Media</span><span class="panel-val"> ${ escHtml ( mediaStr ) } </span></div>
${ poeHtml }
${ trafficHtml }
${ errHtml }
${ lldpHtml }
${ pathHtml }
2026-03-03 16:03:54 -05:00
${ diagHtml }
2026-03-03 15:39:48 -05:00
` ;
document . getElementById ( 'inspector-panel' ). classList . add ( 'open' );
}
// ── Build path debug two-column section ─────────────────────────────────
function buildPathDebug ( swName , swPort , serverName , ifaceName , svrData ) {
const isFiber = ( swPort . media || '' ). toLowerCase (). includes ( 'sfp' ) ||
( svrData . port_type || '' ). toLowerCase (). includes ( 'fibre' ) ||
( svrData . port_type || '' ). toLowerCase (). includes ( 'fiber' ) ||
!! svrData . sfp ;
const connType = isFiber ? 'SFP / Fiber' : 'Copper' ;
let sfpDomHtml = '' ;
if ( svrData . sfp && Object . keys ( svrData . sfp ). length ) {
const sfp = svrData . sfp ;
sfpDomHtml = '<div class="path-dom">' ;
if ( sfp . vendor ) sfpDomHtml += `<div class="path-dom-row"><span>Vendor</span><span> ${ escHtml ( sfp . vendor ) }${ sfp . part_no ? ' / ' + escHtml ( sfp . part_no ) : '' } </span></div>` ;
if ( sfp . temp_c != null ) sfpDomHtml += `<div class="path-dom-row"><span>Temp</span><span> ${ sfp . temp_c . toFixed ( 1 ) } °C</span></div>` ;
if ( sfp . tx_power_dbm != null ) sfpDomHtml += `<div class="path-dom-row"><span>TX</span><span> ${ sfp . tx_power_dbm . toFixed ( 2 ) } dBm</span></div>` ;
if ( sfp . rx_power_dbm != null ) sfpDomHtml += `<div class="path-dom-row"><span>RX</span><span> ${ sfp . rx_power_dbm . toFixed ( 2 ) } dBm</span></div>` ;
sfpDomHtml += '</div>' ;
}
const svrErrTx = ( svrData . tx_errs_rate > 0.001 ) ? 'val-crit' : 'val-good' ;
const svrErrRx = ( svrData . rx_errs_rate > 0.001 ) ? 'val-crit' : 'val-good' ;
const swErrTx = ( swPort . tx_errs_rate > 0.001 ) ? 'val-crit' : 'val-good' ;
const swErrRx = ( swPort . rx_errs_rate > 0.001 ) ? 'val-crit' : 'val-good' ;
2026-03-14 21:46:11 -04:00
// Detect duplex mismatch (switch full_duplex vs server duplex string)
const swFull = swPort . full_duplex ;
const svrFull = ( svrData . duplex || '' ). toLowerCase (). includes ( 'full' );
const duplexMismatch = swPort . up && svrData . duplex &&
(( swFull && ! svrFull ) || ( ! swFull && svrFull ));
const duplexWarnHtml = duplexMismatch
? `<div class="path-mismatch-alert">⚠ DUPLEX MISMATCH — Switch: ${ swFull ? 'Full' : 'Half' } · Server: ${ escHtml ( svrData . duplex ) } </div>`
: '' ;
// Detect speed mismatch
const swSpd = swPort . speed_mbps , svrSpd = svrData . speed_mbps ;
const speedMismatch = swSpd && svrSpd && swSpd > 0 && svrSpd > 0 && swSpd !== svrSpd ;
const speedWarnHtml = speedMismatch
? `<div class="path-mismatch-alert">⚠ SPEED MISMATCH — Switch: ${ fmtSpeed ( swSpd ) } · Server: ${ fmtSpeed ( svrSpd ) } </div>`
: '' ;
2026-03-03 15:39:48 -05:00
return `
2026-04-30 21:09:56 -04:00
<div class="lt-divider"><span class="lt-divider-label">Path Debug · ${ escHtml ( connType ) } </span></div>
2026-03-14 21:46:11 -04:00
${ duplexWarnHtml }${ speedWarnHtml }
2026-03-03 15:39:48 -05:00
<div class="path-debug-cols">
<div class="path-col">
<div class="path-col-header">Switch</div>
<div class="path-row"><span>Port</span><span> ${ escHtml ( swPort . name ) } </span></div>
<div class="path-row"><span>Speed</span><span> ${ fmtSpeed ( swPort . speed_mbps ) } bps</span></div>
<div class="path-row"><span>TX</span><span> ${ fmtRate ( swPort . tx_bytes_rate ) } </span></div>
<div class="path-row"><span>RX</span><span> ${ fmtRate ( swPort . rx_bytes_rate ) } </span></div>
<div class="path-row"><span>TX Err</span><span class=" ${ swErrTx } "> ${ fmtErrors ( swPort . tx_errs_rate ) } </span></div>
<div class="path-row"><span>RX Err</span><span class=" ${ swErrRx } "> ${ fmtErrors ( swPort . rx_errs_rate ) } </span></div>
${ ( swPort . poe_power != null && swPort . poe_power > 0 ) ? `<div class="path-row"><span>PoE</span><span class="val-amber"> ${ swPort . poe_power . toFixed ( 1 ) } W</span></div>` : '' }
</div>
<div class="path-col">
<div class="path-col-header">Server: ${ escHtml ( serverName ) } </div>
<div class="path-row"><span>Iface</span><span> ${ escHtml ( ifaceName ) } </span></div>
<div class="path-row"><span>Speed</span><span> ${ svrData . speed_mbps ? fmtSpeed ( svrData . speed_mbps ) + 'bps' : '– ' } </span></div>
<div class="path-row"><span>TX</span><span> ${ fmtRate ( svrData . tx_bytes_rate ) } </span></div>
<div class="path-row"><span>RX</span><span> ${ fmtRate ( svrData . rx_bytes_rate ) } </span></div>
<div class="path-row"><span>TX Err</span><span class=" ${ svrErrTx } "> ${ fmtErrors ( svrData . tx_errs_rate ) } </span></div>
<div class="path-row"><span>RX Err</span><span class=" ${ svrErrRx } "> ${ fmtErrors ( svrData . rx_errs_rate ) } </span></div>
2026-03-14 21:46:11 -04:00
${ svrData . carrier_changes != null ? `<div class="path-row"><span>Carrier Chg</span><span class=" ${ ( svrData . carrier_changes || 0 ) > 10 ? 'val-crit' : ( svrData . carrier_changes || 0 ) > 2 ? 'val-warn' : 'val-good' } "> ${ svrData . carrier_changes } </span></div>` : '' }
2026-03-03 15:39:48 -05:00
${ sfpDomHtml }
</div>
</div>` ;
}
// ── Render all switches ──────────────────────────────────────────────────
function renderInspector ( data ) {
_apiData = data ;
const main = document . getElementById ( 'inspector-main' );
const switches = data . unifi_switches || {};
const updEl = document . getElementById ( 'inspector-updated' );
2026-05-01 17:15:48 -04:00
if ( updEl && data . updated ) {
updEl . textContent = 'Updated: ' + new Date ( data . updated + ( data . updated . includes ( 'Z' ) ? '' : 'Z' )). toLocaleTimeString ();
}
2026-03-03 15:39:48 -05:00
if ( ! Object . keys ( switches ). length ) {
2026-05-10 23:21:27 -04:00
main . innerHTML = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-icon">⬡</div><div class="lt-empty-state-title">No switch data available</div><div class="lt-empty-state-body">Monitor may still be initialising.</div></div>' ;
2026-03-03 15:39:48 -05:00
return ;
}
main . innerHTML = Object . entries ( switches )
. map (([ swName , sw ]) => renderChassis ( swName , sw ))
. join ( '' );
// Re-apply selection highlight after re-render (dataset compare — no CSS escaping)
if ( _selectedSwitch && _selectedIdx !== null ) {
const block = Array . from ( document . querySelectorAll ( '.switch-port-block' )). find (
el => el . dataset . switch === _selectedSwitch && parseInt ( el . dataset . portIdx , 10 ) === _selectedIdx
);
if ( block ) {
block . classList . add ( 'selected' );
renderPanel ( _selectedSwitch , _selectedIdx );
}
}
}
// ── Fetch and render ─────────────────────────────────────────────────────
async function loadInspector () {
try {
2026-04-18 23:59:19 -04:00
const data = await lt . api . get ( '/api/links' );
2026-03-03 15:39:48 -05:00
renderInspector ( data );
} catch ( e ) {
document . getElementById ( 'inspector-main' ). innerHTML =
2026-05-10 23:21:27 -04:00
'<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-icon">✕</div><div class="lt-empty-state-title">Failed to load inspector data</div></div>' ;
2026-04-18 23:59:19 -04:00
lt . toast . error ( 'Failed to load inspector data' );
2026-03-03 15:39:48 -05:00
}
}
loadInspector ();
2026-05-10 23:45:42 -04:00
const _inspInterval = ( window . gandalfSettings && window . gandalfSettings . refreshInterval ) || 60 ;
2026-05-01 17:15:48 -04:00
if ( _inspInterval > 0 ) lt . autoRefresh . start ( loadInspector , Math . max ( _inspInterval , 15 ) * 1000 );
window . onGandalfSettingsChanged = function ( s ) {
lt . autoRefresh . stop ();
if ( s . refreshInterval > 0 ) lt . autoRefresh . start ( loadInspector , Math . max ( s . refreshInterval , 15 ) * 1000 );
};
2026-04-19 23:35:02 -04:00
lt . keys . on ( 'Escape' , () => {
if ( document . getElementById ( 'inspector-panel' ). classList . contains ( 'open' )) closePanel ();
});
2026-03-03 16:03:54 -05:00
2026-04-29 17:53:48 -04:00
document . addEventListener ( 'click' , e => {
const portBlock = e . target . closest ( '[data-action="select-port"]' );
if ( portBlock ) { selectPort ( portBlock ); return ; }
const closeBtn = e . target . closest ( '[data-action="close-panel"]' );
if ( closeBtn ) { closePanel (); return ; }
const diagBtn = e . target . closest ( '[data-action="run-diagnostic"]' );
if ( diagBtn ) { runDiagnostic ( diagBtn . dataset . sw , parseInt ( diagBtn . dataset . idx , 10 )); return ; }
const toggleDiag = e . target . closest ( '[data-action="toggle-diag"]' );
if ( toggleDiag ) { toggleDiag . parentElement . classList . toggle ( 'diag-open' ); return ; }
});
2026-03-03 16:03:54 -05:00
// ── Link Diagnostics ─────────────────────────────────────────────────
let _diagPollTimer = null ;
function runDiagnostic ( swName , portIdx ) {
const statusEl = document . getElementById ( 'diag-status' );
const resultsEl = document . getElementById ( 'diag-results' );
if ( ! statusEl || ! resultsEl ) return ;
// Clear any previous poll
if ( _diagPollTimer ) { clearInterval ( _diagPollTimer ); _diagPollTimer = null ; }
statusEl . textContent = 'Submitting to Pulse...' ;
resultsEl . innerHTML = '' ;
2026-04-18 23:59:19 -04:00
lt . api . post ( '/api/diagnose' , { switch_name : swName , port_idx : portIdx })
. then ( resp => {
statusEl . textContent = 'Collecting diagnostics via Pulse...' ;
pollDiagnostic ( resp . job_id , statusEl , resultsEl );
})
. catch ( e => {
statusEl . textContent = 'Error: ' + ( e . message || 'Request failed' );
});
2026-03-03 16:03:54 -05:00
}
function pollDiagnostic ( jobId , statusEl , resultsEl ) {
let attempts = 0 ;
_diagPollTimer = setInterval (() => {
attempts ++ ;
if ( attempts > 120 ) { // 2min timeout
clearInterval ( _diagPollTimer );
statusEl . textContent = 'Timed out waiting for results.' ;
return ;
}
2026-04-18 23:59:19 -04:00
lt . api . get ( `/api/diagnose/ ${ jobId } ` )
2026-03-03 16:03:54 -05:00
. then ( resp => {
if ( resp . status === 'done' ) {
clearInterval ( _diagPollTimer );
_diagPollTimer = null ;
statusEl . textContent = '' ;
renderDiagnosticResults ( resp . result , resultsEl );
}
})
2026-03-14 14:31:57 -04:00
. catch (() => {
clearInterval ( _diagPollTimer );
_diagPollTimer = null ;
statusEl . textContent = 'Error: lost connection while collecting diagnostics.' ;
});
2026-03-03 16:03:54 -05:00
}, 2000 );
}
function renderDiagnosticResults ( d , container ) {
if ( ! d || d . status === 'error' ) {
container . innerHTML = `<div class="diag-error">Diagnostic error: ${ escHtml (( d && d . error ) || 'unknown' ) } </div>` ;
return ;
}
const health = d . health || {};
const issues = health . issues || [];
const warns = health . warnings || [];
const infoArr = health . info || [];
const secs = d . sections || {};
const eth = secs . ethtool || {};
const drv = secs . ethtool_driver || {};
const pause = secs . ethtool_pause || {};
const ring = secs . ethtool_ring || {};
const dom = secs . ethtool_dom || {};
const sysfs = secs . sysfs_stats || {};
const dmesg = secs . dmesg || [];
const lldpctl = secs . lldpctl || {};
const nicStats = secs . ethtool_stats || {};
const swPort = d . switch_port || {};
// ── Health banner ──
let bannerHtml = '' ;
if ( issues . length === 0 && warns . length === 0 ) {
bannerHtml = '<div class="diag-health-banner"><span class="diag-health-ok">ALL OK</span></div>' ;
} else {
const parts = [];
if ( issues . length ) parts . push ( `<span class="diag-health-critical"> ${ issues . length } CRITICAL</span>` );
if ( warns . length ) parts . push ( `<span class="diag-health-warning"> ${ warns . length } WARNING</span>` );
bannerHtml = `<div class="diag-health-banner"> ${ parts . join ( ' ' ) } </div>` ;
}
const issueRows = [... issues , ... warns , ... infoArr ]. map ( item => {
const cls = issues . includes ( item ) ? 'diag-val-bad' : warns . includes ( item ) ? 'diag-val-warn' : 'diag-val-good' ;
const label = issues . includes ( item ) ? 'CRIT' : warns . includes ( item ) ? 'WARN' : 'INFO' ;
return `<div class="diag-issue-row"><span class=" ${ cls } ">[ ${ label } ]</span> <span class="diag-code"> ${ escHtml ( item . code ) } </span> — ${ escHtml ( item . message ) } </div>` ;
}). join ( '' );
// ── Physical layer ──
const carrierVal = secs . carrier === '1' ? '<span class="diag-val-good">YES</span>' :
secs . carrier === '0' ? '<span class="diag-val-bad">NO</span>' : '– ' ;
const operstateVal = ( secs . operstate || '?' ). toUpperCase ();
const opstateCls = secs . operstate === 'up' ? 'diag-val-good' : secs . operstate === 'down' ? 'diag-val-bad' : 'diag-val-warn' ;
const speedVal = eth . speed_mbps ? `<span class="diag-val-good"> ${ fmtSpeed ( eth . speed_mbps ) } bps</span>` : '<span class="diag-val-warn">– </span>' ;
const duplexVal = eth . duplex === 'full' ? '<span class="diag-val-good">Full</span>' :
eth . duplex === 'half' ? '<span class="diag-val-bad">Half</span>' : '– ' ;
const linkDetVal = eth . link_detected === true ? '<span class="diag-val-good">Yes</span>' :
eth . link_detected === false ? '<span class="diag-val-bad">No</span>' : '– ' ;
const autonegVal = eth . auto_neg === true ? '<span class="diag-val-good">On</span>' :
eth . auto_neg === false ? '<span class="diag-val-warn">Off</span>' : '– ' ;
const physHtml = `
<div class="diag-section">
<div class="diag-section-header">Physical Layer</div>
<table class="diag-table">
<tr><td>Carrier</td><td> ${ carrierVal } </td></tr>
<tr><td>Oper State</td><td><span class=" ${ opstateCls } "> ${ escHtml ( operstateVal ) } </span></td></tr>
<tr><td>Speed</td><td> ${ speedVal } </td></tr>
<tr><td>Duplex</td><td> ${ duplexVal } </td></tr>
<tr><td>Link Detected</td><td> ${ linkDetVal } </td></tr>
<tr><td>Auto-neg</td><td> ${ autonegVal } </td></tr>
${ secs . carrier_changes != null ? `<tr><td>Carrier Changes</td><td><span class=" ${ secs . carrier_changes > 20 ? 'diag-val-warn' : 'diag-val-good' } "> ${ secs . carrier_changes } </span></td></tr>` : '' }
</table>
</div>` ;
// ── SFP / DOM ──
let domHtml = '' ;
if ( dom && Object . keys ( dom ). length > 0 ) {
const rxBar = dom . rx_power_dbm != null ? renderPowerBar ( dom . rx_power_dbm , - 18 , - 25 ) : '' ;
const txBar = dom . tx_power_dbm != null ? renderPowerBar ( dom . tx_power_dbm , - 10 , - 13 ) : '' ;
domHtml = `
<div class="diag-section">
<div class="diag-section-header">SFP / DOM</div>
<table class="diag-table">
${ dom . vendor ? `<tr><td>Vendor</td><td> ${ escHtml ( dom . vendor ) }${ dom . part_no ? ' / ' + escHtml ( dom . part_no ) : '' } </td></tr>` : '' }
${ dom . sfp_type ? `<tr><td>Type</td><td> ${ escHtml ( dom . sfp_type ) } </td></tr>` : '' }
${ dom . connector ? `<tr><td>Connector</td><td> ${ escHtml ( dom . connector ) } </td></tr>` : '' }
${ dom . wavelength_nm != null ? `<tr><td>Wavelength</td><td> ${ dom . wavelength_nm } nm</td></tr>` : '' }
${ dom . temp_c != null ? `<tr><td>Temperature</td><td> ${ dom . temp_c . toFixed ( 1 ) } °C</td></tr>` : '' }
${ dom . voltage_v != null ? `<tr><td>Voltage</td><td> ${ dom . voltage_v . toFixed ( 4 ) } V</td></tr>` : '' }
${ dom . bias_ma != null ? `<tr><td>Bias Current</td><td> ${ dom . bias_ma . toFixed ( 3 ) } mA</td></tr>` : '' }
${ dom . tx_power_dbm != null ? `<tr><td>TX Power</td><td> ${ dom . tx_power_dbm . toFixed ( 2 ) } dBm ${ txBar } </td></tr>` : '' }
${ dom . rx_power_dbm != null ? `<tr><td>RX Power</td><td> ${ dom . rx_power_dbm . toFixed ( 2 ) } dBm ${ rxBar } </td></tr>` : '' }
</table>
</div>` ;
}
// ── NIC Error Counters ──
const errCounters = [ 'rx_crc_errors' , 'rx_frame_errors' , 'collisions' , 'tx_carrier_errors' , 'rx_missed_errors' , 'rx_fifo_errors' ];
const nonZeroCounters = errCounters . filter ( k => sysfs [ k ] > 0 );
let errCounterHtml = '' ;
if ( nonZeroCounters . length > 0 || secs . carrier_changes > 0 ) {
const rows = nonZeroCounters . map ( k => {
const v = sysfs [ k ];
const cls = v > 100 ? 'diag-val-bad' : 'diag-val-warn' ;
return `<tr><td> ${ escHtml ( k ) } </td><td class=" ${ cls } "> ${ v . toLocaleString () } </td></tr>` ;
}). join ( '' );
errCounterHtml = `
<div class="diag-section">
<div class="diag-section-header">NIC Error Counters</div>
<table class="diag-table">
${ rows || '<tr><td colspan="2" class="diag-val-good">All zero</td></tr>' }
</table>
</div>` ;
}
// ── ethtool -S (collapsible) ──
let nicStatHtml = '' ;
if ( Object . keys ( nicStats ). length > 0 ) {
const _ERR_KEYS = /err|drop|miss|crc|frame|fifo|abort|carrier|collision|fault|discard|overflow|reset/i ;
const rows = Object . entries ( nicStats ). map (([ k , v ]) => {
const cls = _ERR_KEYS . test ( k ) && v > 0 ? ' class="diag-stat-nonzero-warn"' : '' ;
return `<tr ${ cls } ><td> ${ escHtml ( k ) } </td><td> ${ v . toLocaleString () } </td></tr>` ;
}). join ( '' );
nicStatHtml = `
<div class="diag-section diag-collapsible">
2026-04-29 17:53:48 -04:00
<div class="diag-section-header diag-toggle" data-action="toggle-diag">
2026-03-03 16:03:54 -05:00
ethtool -S (NIC stats) <span class="diag-toggle-hint">[expand]</span>
</div>
<div class="diag-section-body">
<table class="diag-stat-table"> ${ rows } </table>
</div>
</div>` ;
}
// ── Flow Control + Ring Buffers ──
let flowRingHtml = '' ;
const hasPause = Object . keys ( pause ). length > 0 ;
const hasRing = Object . keys ( ring ). length > 0 ;
if ( hasPause || hasRing ) {
flowRingHtml = `
<div class="diag-section">
<div class="diag-section-header">Flow Control & Ring Buffers</div>
<table class="diag-table">
${ hasPause ? `
<tr><td>RX Pause</td><td> ${ pause . rx_pause ? '<span class="diag-val-good">On</span>' : 'Off' } </td></tr>
<tr><td>TX Pause</td><td> ${ pause . tx_pause ? '<span class="diag-val-good">On</span>' : 'Off' } </td></tr>` : '' }
${ hasRing ? `
<tr><td>RX Ring</td><td> ${ ring . rx_current != null ? ring . rx_current : '– ' } / ${ ring . rx_max != null ? ring . rx_max : '– ' } max</td></tr>
<tr><td>TX Ring</td><td> ${ ring . tx_current != null ? ring . tx_current : '– ' } / ${ ring . tx_max != null ? ring . tx_max : '– ' } max</td></tr>` : '' }
</table>
</div>` ;
}
// ── Driver Info ──
let drvHtml = '' ;
if ( Object . keys ( drv ). length > 0 ) {
drvHtml = `
<div class="diag-section">
<div class="diag-section-header">Driver Info</div>
<table class="diag-table">
${ drv . driver ? `<tr><td>Driver</td><td> ${ escHtml ( drv . driver ) } </td></tr>` : '' }
${ drv . version ? `<tr><td>Version</td><td> ${ escHtml ( drv . version ) } </td></tr>` : '' }
${ drv . firmware_version ? `<tr><td>Firmware</td><td> ${ escHtml ( drv . firmware_version ) } </td></tr>` : '' }
${ drv . bus_info ? `<tr><td>Bus</td><td> ${ escHtml ( drv . bus_info ) } </td></tr>` : '' }
</table>
</div>` ;
}
// ── LLDP Validation ──
let lldpValHtml = '' ;
const swLldp = swPort . lldp || {};
lldpValHtml = `
<div class="diag-section">
<div class="diag-section-header">LLDP Validation</div>
<div class="path-debug-cols">
<div class="path-col">
<div class="path-col-header">Switch sees</div>
<div class="path-row"><span>System</span><span> ${ escHtml ( swLldp . system_name || '– ' ) } </span></div>
<div class="path-row"><span>Port</span><span> ${ escHtml ( swLldp . port_id || '– ' ) } </span></div>
<div class="path-row"><span>Chassis</span><span> ${ escHtml ( swLldp . chassis_id || '– ' ) } </span></div>
</div>
<div class="path-col">
<div class="path-col-header">Server lldpctl</div>
${ lldpctl . available
? `<div class="path-row"><span>Neighbor</span><span> ${ escHtml ( lldpctl . neighbor_system || '– ' ) } </span></div>
<div class="path-row"><span>Port</span><span> ${ escHtml ( lldpctl . neighbor_port || '– ' ) } </span></div>`
: '<div class="path-row"><span class="diag-val-warn">lldpd not running</span></div>' }
</div>
</div>
</div>` ;
// ── dmesg ──
let dmesgHtml = '' ;
if ( dmesg . length > 0 ) {
const dlines = dmesg . map ( e => {
const cls = e . severity === 'error' ? ' diag-dmesg-err' : e . severity === 'warn' ? ' diag-dmesg-warn' : '' ;
const ts = e . timestamp ? `[ ${ e . timestamp } ] ` : '' ;
return `<div class="diag-dmesg-line ${ cls } "> ${ escHtml ( ts + e . msg ) } </div>` ;
}). join ( '' );
dmesgHtml = `
<div class="diag-section diag-collapsible">
2026-04-29 17:53:48 -04:00
<div class="diag-section-header diag-toggle" data-action="toggle-diag">
2026-03-03 16:03:54 -05:00
Kernel Events (dmesg) <span class="diag-toggle-hint">[expand]</span>
</div>
<div class="diag-section-body">
<div class="diag-dmesg-wrap"> ${ dlines } </div>
</div>
</div>` ;
}
// ── Switch Port Summary ──
const swSummaryHtml = `
<div class="diag-section">
<div class="diag-section-header">Switch Port Summary</div>
<table class="diag-table">
<tr><td>Status</td><td> ${ swPort . up ? '<span class="diag-val-good">UP</span>' : '<span class="diag-val-bad">DOWN</span>' } </td></tr>
<tr><td>Speed</td><td> ${ swPort . speed_mbps ? fmtSpeed ( swPort . speed_mbps ) + 'bps' : '– ' } </td></tr>
<tr><td>Duplex</td><td> ${ swPort . full_duplex ? 'Full' : ( swPort . up ? '<span class="diag-val-bad">Half</span>' : '– ' ) } </td></tr>
<tr><td>TX Err</td><td> ${ fmtErrors ( swPort . tx_errs_rate ) } </td></tr>
<tr><td>RX Err</td><td> ${ fmtErrors ( swPort . rx_errs_rate ) } </td></tr>
${ swPort . poe_power != null ? `<tr><td>PoE</td><td><span class="val-amber"> ${ swPort . poe_power . toFixed ( 1 ) } W</span></td></tr>` : '' }
</table>
</div>` ;
// ── Pulse link ──
const pulseLink = d . pulse_url
? `<div class="diag-pulse-link"><a href=" ${ escHtml ( d . pulse_url ) } " target="_blank" rel="noopener">View raw output in Pulse ↗</a></div>`
: '' ;
container . innerHTML = `
<div class="diag-results-inner">
${ bannerHtml }
<div class="diag-issue-list"> ${ issueRows } </div>
${ physHtml }
${ domHtml }
${ errCounterHtml }
${ nicStatHtml }
${ flowRingHtml }
${ drvHtml }
${ lldpValHtml }
${ dmesgHtml }
${ swSummaryHtml }
${ pulseLink }
</div>` ;
}
// SFP power bar: range is 0 dBm (best) to -35 dBm (worst)
function renderPowerBar ( dbm , warnThreshold , critThreshold ) {
const minDbm = - 35 , maxDbm = 0 ;
const pct = Math . max ( 0 , Math . min ( 100 , (( dbm - minDbm ) / ( maxDbm - minDbm )) * 100 ));
const warnPct = (( warnThreshold - minDbm ) / ( maxDbm - minDbm )) * 100 ;
const critPct = (( critThreshold - minDbm ) / ( maxDbm - minDbm )) * 100 ;
const barCls = dbm < critThreshold ? 'diag-val-bad' : dbm < warnThreshold ? 'diag-val-warn' : 'diag-val-good' ;
return `<span class="diag-power-bar-wrap">
<span class="diag-power-bar ${ barCls } " style="width: ${ pct . toFixed ( 1 ) } %"></span>
<span class="diag-power-zone-warn" style="left: ${ warnPct . toFixed ( 1 ) } %"></span>
<span class="diag-power-zone-crit" style="left: ${ critPct . toFixed ( 1 ) } %"></span>
</span>` ;
}
2026-03-03 15:39:48 -05:00
</ script >
{% endblock %}