2026-07-17 04:00:38 +02:00
(() => {
const $ = ( sel ) => document . querySelector ( sel );
const canvas = $ ( "#topo" );
const ctx = canvas . getContext ( "2d" );
const state = {
data : null ,
viewMode : "status" ,
layoutMode : "orbit" ,
clusterHubs : [],
laneGuides : [],
subnet : "all" ,
model : null ,
groupHint : null ,
kpiFocus : null ,
selectedId : null ,
inventoryCache : {},
inventoryLoadingId : null ,
notifiedEventKeys : new Set (),
lastPulse : 0 ,
filters : {
connected : false ,
powered : false ,
servers : true ,
idrac : false ,
hasPower : false ,
ghostLinks : false ,
pulseLinks : true ,
2026-07-18 21:56:30 +02:00
pulseSpeed : 0.45 ,
pulseLineColor : "#3dffe0" ,
pulseGlowColor : "#00a8e8" ,
pulsePacketColor : "#ffffff" ,
2026-07-17 04:00:38 +02:00
minWatts : 0 ,
search : "" ,
},
cam : { x : 0 , y : 0 , scale : 1 },
nodes : [],
hub : null ,
dragging : false ,
last : null ,
hoverId : null ,
connectNode : null ,
pulseT : 0 ,
modelColors : new Map (),
subnetColors : new Map (),
};
const PALETTE = [
"#00a8e8" , "#3dffe0" , "#ffb020" , "#7ec8ff" , "#3dffa0" ,
"#ff7a59" , "#c4a7ff" , "#f0e68c" , "#5eead4" , "#f472b6" ,
];
function colorFor ( map , key ) {
if ( ! map . has ( key )) map . set ( key , PALETTE [ map . size % PALETTE . length ]);
return map . get ( key );
}
function statusColor ( status ) {
const s = String ( status || "" );
if ( s === "1000" || s === "0" ) return "#3dffa0" ;
if ( s === "2000" || s === "2" ) return "#ffb020" ;
if ( s === "3000" || s === "3" || s === "4000" ) return "#ff5c5c" ;
return "#7a93a8" ;
}
function powerColor ( watts ) {
if ( watts == null ) return "#3a4a5a" ;
if ( watts < 200 ) return "#3dffa0" ;
if ( watts < 450 ) return "#00a8e8" ;
if ( watts < 650 ) return "#ffb020" ;
return "#ff5c5c" ;
}
function nodeColor ( n ) {
switch ( state . viewMode ) {
case "power" :
return powerColor ( n . watts );
case "connection" :
return n . connected ? "#3dffe0" : "#ff5c5c" ;
case "model" :
return colorFor ( state . modelColors , n . model || "?" );
case "subnet" :
return colorFor ( state . subnetColors , n . subnet || "?" );
default :
if ( ! n . connected ) return "#5a6a7a" ;
return statusColor ( n . status );
}
}
function visibleDevices () {
const d = state . data ;
if ( ! d ) return [];
const f = state . filters ;
const q = ( f . search || "" ). trim (). toLowerCase ();
// Type filters are INCLUDE toggles:
// - Servers on => include servers
// - iDRACs on => include iDRACs
// - both off => show nothing (do not silently show all)
const includeServers = !! f . servers ;
const includeIdrac = !! f . idrac ;
if ( ! includeServers && ! includeIdrac ) return [];
return ( d . devices || []). filter (( n ) => {
2026-07-18 21:56:30 +02:00
if ( state . subnet !== "all" ) {
const cidrs = n . map_cidrs && n . map_cidrs . length ? n . map_cidrs : [ n . subnet ];
if ( ! cidrs . includes ( state . subnet )) return false ;
}
2026-07-17 04:00:38 +02:00
if ( state . model && n . model !== state . model ) return false ;
const isServer = !! n . is_server ;
const isIdrac = !! n . is_idrac ;
const typeOk =
( includeServers && isServer ) ||
( includeIdrac && isIdrac );
if ( ! typeOk ) return false ;
if ( f . connected && ! n . connected ) return false ;
if ( f . powered && ! n . powered_on ) return false ;
if ( f . hasPower && n . watts == null ) return false ;
if ( f . minWatts > 0 && ( n . watts == null || n . watts < f . minWatts )) return false ;
if ( state . kpiFocus === "offline" && ( n . connected || ! n . is_server )) return false ;
if ( state . kpiFocus === "connected" && ! n . connected ) return false ;
if ( state . kpiFocus === "powered" && ! n . powered_on ) return false ;
if ( state . kpiFocus === "idracs" && ! n . is_idrac ) return false ;
if ( state . kpiFocus === "power" && n . watts == null ) return false ;
if ( q ) {
const hay = [ n . name , n . model , n . service_tag , n . ip , n . subnet ]
. filter ( Boolean )
. join ( " " )
. toLowerCase ();
if ( ! hay . includes ( q )) return false ;
}
return true ;
});
}
function groupBySubnet ( devices ) {
const bySubnet = new Map ();
for ( const n of devices ) {
const k = n . subnet || "unknown" ;
if ( ! bySubnet . has ( k )) bySubnet . set ( k , []);
bySubnet . get ( k ). push ( n );
}
return [... bySubnet . keys ()]. sort (). map (( k ) => ({ cidr : k , members : bySubnet . get ( k ) }));
}
function nodeRadius ( n ) {
return n . connected ? 8 : 5.5 ;
}
function setTargets ( nodes ) {
// preserve current x/y for lerp; first time seed from target
const prev = new Map ( state . nodes . map (( n ) => [ n . id , n ]));
for ( const n of nodes ) {
const p = prev . get ( n . id );
if ( p && Number . isFinite ( p . x )) {
n . x = p . x ;
n . y = p . y ;
} else {
n . x = n . tx ;
n . y = n . ty ;
}
}
state . nodes = nodes ;
}
function layoutOrbit ( devices , cx , cy , w , h ) {
const groups = groupBySubnet ( devices );
const ringBase = Math . min ( w , h ) * 0.18 ;
const nodes = [];
groups . forEach (( g , si ) => {
const ring = ringBase + si * Math . min ( 70 , Math . max ( 42 , 360 / Math . max ( groups . length , 1 )));
g . members . forEach (( n , i ) => {
const a = ( i / Math . max ( g . members . length , 1 )) * Math . PI * 2 - Math . PI / 2 + si * 0.15 ;
const jitter = ( n . id % 7 ) * 2.2 ;
nodes . push ({
... n ,
tx : cx + Math . cos ( a ) * ( ring + jitter ),
ty : cy + Math . sin ( a ) * ( ring + jitter ),
r : nodeRadius ( n ),
depth : 1 ,
});
});
});
state . clusterHubs = [];
state . laneGuides = [];
setTargets ( nodes );
}
function layoutGalaxy ( devices , cx , cy , w , h ) {
const groups = groupBySubnet ( devices );
const nodes = [];
const arms = Math . max ( groups . length , 1 );
const maxR = Math . min ( w , h ) * 0.42 ;
groups . forEach (( g , si ) => {
const armAngle = ( si / arms ) * Math . PI * 2 ;
g . members . forEach (( n , i ) => {
const t = ( i + 1 ) / ( g . members . length + 1 );
const r = 70 + t * maxR ;
const twist = t * 3.2 + armAngle ;
const wobble = Math . sin ( i * 1.7 + si ) * 12 ;
nodes . push ({
... n ,
tx : cx + Math . cos ( twist ) * r + Math . cos ( twist + Math . PI / 2 ) * wobble * 0.35 ,
ty : cy + Math . sin ( twist ) * r + Math . sin ( twist + Math . PI / 2 ) * wobble * 0.35 ,
r : nodeRadius ( n ),
depth : 0.6 + t * 0.8 ,
arm : si ,
});
});
});
state . clusterHubs = [];
state . laneGuides = [];
setTargets ( nodes );
}
function layoutClusters ( devices , cx , cy , w , h ) {
const groups = groupBySubnet ( devices );
const nodes = [];
const hubs = [];
const R = Math . min ( w , h ) * 0.32 ;
2026-07-18 21:57:19 +02:00
const subnetMeta = new Map (( state . data ? . subnets || []). map (( s ) => [ s . cidr , s ]));
2026-07-17 04:00:38 +02:00
groups . forEach (( g , si ) => {
const a = ( si / Math . max ( groups . length , 1 )) * Math . PI * 2 - Math . PI / 2 ;
const hx = cx + Math . cos ( a ) * R ;
const hy = cy + Math . sin ( a ) * R ;
const localR = 28 + Math . min ( 90 , g . members . length * 4.5 );
2026-07-18 21:57:19 +02:00
const meta = subnetMeta . get ( g . cidr ) || {};
const vlanBit = meta . vlan_id != null ? `VLAN ${ meta . vlan_id } ` : null ;
const label = vlanBit ? ` ${ vlanBit } · ${ g . cidr } ` : g . cidr ;
hubs . push ({
x : hx ,
y : hy ,
label ,
cidr : g . cidr ,
color : meta . vlan_color || colorFor ( state . subnetColors , g . cidr ),
localR ,
angle : a ,
memberCount : g . members . length ,
});
2026-07-17 04:00:38 +02:00
g . members . forEach (( n , i ) => {
const la = ( i / Math . max ( g . members . length , 1 )) * Math . PI * 2 ;
nodes . push ({
... n ,
tx : hx + Math . cos ( la ) * localR ,
ty : hy + Math . sin ( la ) * localR ,
r : nodeRadius ( n ),
depth : 1 ,
cluster : si ,
});
});
});
state . clusterHubs = hubs ;
state . laneGuides = [];
setTargets ( nodes );
}
function layoutLanes ( devices , cx , cy , w , h ) {
const groups = groupBySubnet ( devices );
const nodes = [];
const guides = [];
const top = 70 ;
const bottom = h - 50 ;
const span = Math . max ( bottom - top , 120 );
groups . forEach (( g , si ) => {
const y = top + ( groups . length <= 1 ? span / 2 : ( si / ( groups . length - 1 || 1 )) * span );
guides . push ({ y , label : g . cidr , color : colorFor ( state . subnetColors , g . cidr ) });
g . members . forEach (( n , i ) => {
const t = g . members . length <= 1 ? 0.5 : i / ( g . members . length - 1 );
const x = 90 + t * ( w - 160 );
const bob = Math . sin ( i * 0.9 + si ) * 10 ;
nodes . push ({
... n ,
tx : x ,
ty : y + bob ,
r : nodeRadius ( n ),
depth : 1 ,
lane : si ,
});
});
});
state . clusterHubs = [];
state . laneGuides = guides ;
// hub left side
state . hub = { x : 48 , y : cy , r : 26 };
setTargets ( nodes );
}
function layoutHelix ( devices , cx , cy , w , h ) {
const list = [... devices ]. sort (( a , b ) => Number ( b . connected ) - Number ( a . connected ) || ( b . watts || 0 ) - ( a . watts || 0 ));
const nodes = [];
const turns = 2.4 ;
list . forEach (( n , i ) => {
const t = list . length <= 1 ? 0.5 : i / ( list . length - 1 );
const angle = t * Math . PI * 2 * turns ;
const y = 60 + t * ( h - 120 );
const amp = Math . min ( w , h ) * 0.28 ;
// perspective: scale by "depth"
const depth = 0.45 + 0.55 * ( 0.5 + 0.5 * Math . sin ( angle ));
const x = cx + Math . cos ( angle ) * amp * depth ;
nodes . push ({
... n ,
tx : x ,
ty : y + Math . sin ( angle * 2 ) * 8 ,
r : ( n . connected ? 7 : 4.5 ) * ( 0.75 + depth * 0.55 ),
depth ,
helixT : t ,
});
});
state . clusterHubs = [];
state . laneGuides = [];
setTargets ( nodes );
}
function layout () {
const devices = visibleDevices ();
const w = canvas . clientWidth ;
const h = canvas . clientHeight ;
const cx = w / 2 ;
const cy = h / 2 ;
if ( state . layoutMode !== "lanes" ) {
state . hub = { x : cx , y : cy , r : 28 };
}
switch ( state . layoutMode ) {
case "galaxy" :
layoutGalaxy ( devices , cx , cy , w , h );
break ;
case "clusters" :
layoutClusters ( devices , cx , cy , w , h );
break ;
case "lanes" :
layoutLanes ( devices , cx , cy , w , h );
break ;
case "helix" :
layoutHelix ( devices , cx , cy , w , h );
break ;
default :
layoutOrbit ( devices , cx , cy , w , h );
}
}
function resize () {
const dpr = Math . min ( window . devicePixelRatio || 1 , 2 );
const w = canvas . clientWidth ;
const h = canvas . clientHeight ;
canvas . width = Math . floor ( w * dpr );
canvas . height = Math . floor ( h * dpr );
ctx . setTransform ( dpr , 0 , 0 , dpr , 0 , 0 );
layout ();
}
function drawHub ( hub , t ) {
const hubGlow = 16 + 6 * Math . sin ( t * 0.05 );
const g = ctx . createRadialGradient ( hub . x , hub . y , 4 , hub . x , hub . y , hubGlow + 20 );
g . addColorStop ( 0 , "rgba(0,118,206,0.55)" );
g . addColorStop ( 1 , "rgba(0,118,206,0)" );
ctx . fillStyle = g ;
ctx . beginPath ();
ctx . arc ( hub . x , hub . y , hubGlow + 20 , 0 , Math . PI * 2 );
ctx . fill ();
ctx . beginPath ();
ctx . arc ( hub . x , hub . y , hub . r , 0 , Math . PI * 2 );
ctx . fillStyle = "#0076ce" ;
ctx . fill ();
2026-07-18 21:56:30 +02:00
const th = canvasTheme ();
ctx . strokeStyle = th . hubStroke ;
2026-07-17 04:00:38 +02:00
ctx . lineWidth = 2 ;
ctx . stroke ();
2026-07-18 21:56:30 +02:00
ctx . fillStyle = th . hubText ;
2026-07-17 04:00:38 +02:00
ctx . font = "700 11px IBM Plex Sans, sans-serif" ;
ctx . textAlign = "center" ;
ctx . textBaseline = "middle" ;
ctx . fillText ( "OME" , hub . x , hub . y );
}
2026-07-18 21:56:30 +02:00
function isLightTheme () {
return document . documentElement . getAttribute ( "data-theme" ) === "light" ;
}
function canvasTheme () {
if ( isLightTheme ()) {
return {
label : "rgba(14, 28, 44, 0.92)" ,
labelSoft : "rgba(30, 50, 72, 0.78)" ,
empty : "rgba(14, 28, 44, 0.88)" ,
emptyHint : "rgba(60, 85, 110, 0.95)" ,
hubStroke : "#0076ce" ,
hubText : "#ffffff" ,
selectRing : "#0076ce" ,
decor : ( i ) => `rgba(0, 100, 180, ${ 0.08 + i * 0.025 } )` ,
spiral : "rgba(0, 100, 180, 0.16)" ,
helix : ( i ) => `rgba(0, 100, 180, ${ 0.1 + i * 0.04 } )` ,
ghostLink : ( a ) => `rgba(70, 90, 110, ${ 0.22 * a } )` ,
clusterLabel : "rgba(14, 28, 44, 0.85)" ,
laneLabel : "rgba(14, 28, 44, 0.8)" ,
};
}
return {
label : "rgba(232,244,255,0.85)" ,
labelSoft : "rgba(232,244,255,0.7)" ,
empty : "rgba(232,244,255,0.82)" ,
emptyHint : "rgba(122,147,168,0.95)" ,
hubStroke : "#3dffe0" ,
hubText : "#fff" ,
selectRing : "#fff" ,
decor : ( i ) => `rgba(0,168,232, ${ 0.035 + i * 0.012 } )` ,
spiral : "rgba(0,168,232,0.08)" ,
helix : ( i ) => `rgba(0,168,232, ${ 0.06 + i * 0.03 } )` ,
ghostLink : ( a ) => `rgba(90,106,122, ${ 0.14 * a } )` ,
clusterLabel : "rgba(232,244,255,0.7)" ,
laneLabel : "rgba(232,244,255,0.55)" ,
};
}
function hexToRgb ( hex ) {
const h = String ( hex || "#3dffe0" ). replace ( "#" , "" );
const full = h . length === 3 ? h . split ( "" ). map (( c ) => c + c ). join ( "" ) : h . padEnd ( 6 , "0" );
const n = parseInt ( full . slice ( 0 , 6 ), 16 );
if ( ! Number . isFinite ( n )) return { r : 61 , g : 255 , b : 224 };
return { r : ( n >> 16 ) & 255 , g : ( n >> 8 ) & 255 , b : n & 255 };
}
function rgba ( hex , a ) {
const { r , g , b } = hexToRgb ( hex );
return `rgba( ${ r } , ${ g } , ${ b } , ${ a } )` ;
}
2026-07-17 04:00:38 +02:00
function drawPulseLink ( x0 , y0 , x1 , y1 , connected , t , id , alphaScale ) {
if ( ! connected && ! state . filters . ghostLinks ) return ;
const animate = state . filters . pulseLinks !== false ;
2026-07-18 21:56:30 +02:00
const spd = Math . max ( 0.1 , Number ( state . filters . pulseSpeed ) || 0.45 );
let line = state . filters . pulseLineColor || "#3dffe0" ;
let glow = state . filters . pulseGlowColor || "#00a8e8" ;
let packet = state . filters . pulsePacketColor || "#ffffff" ;
if ( isLightTheme ()) {
// Stronger, readable pulse on light stage when using default cyan palette
if ( ! state . filters . pulseLineColor || line === "#3dffe0" ) line = "#0076ce" ;
if ( ! state . filters . pulseGlowColor || glow === "#00a8e8" ) glow = "#00a4e4" ;
if ( ! state . filters . pulsePacketColor || packet === "#ffffff" ) packet = "#0b2a44" ;
}
2026-07-17 04:00:38 +02:00
const alpha = connected
2026-07-18 21:56:30 +02:00
? ( animate ? ( 0.55 + 0.35 * Math . sin ( t * 0.05 * spd + id )) : 0.72 ) * alphaScale
2026-07-17 04:00:38 +02:00
: 0.06 * alphaScale ;
// glow underlay for live OME links
if ( connected ) {
ctx . beginPath ();
ctx . moveTo ( x0 , y0 );
ctx . lineTo ( x1 , y1 );
2026-07-18 21:56:30 +02:00
ctx . strokeStyle = rgba ( glow , ( animate ? 0.28 : 0.16 ) * alphaScale );
2026-07-17 04:00:38 +02:00
ctx . lineWidth = animate ? 4.5 : 3.2 ;
ctx . stroke ();
}
ctx . beginPath ();
ctx . moveTo ( x0 , y0 );
ctx . lineTo ( x1 , y1 );
2026-07-18 21:56:30 +02:00
ctx . strokeStyle = connected ? rgba ( line , alpha ) : canvasTheme (). ghostLink ( alphaScale );
2026-07-17 04:00:38 +02:00
ctx . lineWidth = connected ? 2.4 : 0.8 ;
ctx . stroke ();
if ( connected && animate ) {
// dual packets along the link
for ( const off of [ 0 , 0.5 ]) {
2026-07-18 21:56:30 +02:00
const u = (( t * 0.018 * spd + ( id % 50 ) * 0.02 + off ) % 1 );
2026-07-17 04:00:38 +02:00
const px = x0 + ( x1 - x0 ) * u ;
const py = y0 + ( y1 - y0 ) * u ;
ctx . beginPath ();
ctx . arc ( px , py , 3.2 , 0 , Math . PI * 2 );
2026-07-18 21:56:30 +02:00
ctx . fillStyle = packet ;
2026-07-17 04:00:38 +02:00
ctx . fill ();
ctx . beginPath ();
ctx . arc ( px , py , 5.5 , 0 , Math . PI * 2 );
2026-07-18 21:56:30 +02:00
ctx . strokeStyle = rgba ( line , 0.6 );
2026-07-17 04:00:38 +02:00
ctx . lineWidth = 1.5 ;
ctx . stroke ();
}
}
}
function drawDecor ( w , h , hub , t ) {
const mode = state . layoutMode ;
if ( mode === "orbit" || mode === "galaxy" ) {
for ( let i = 1 ; i <= 5 ; i ++ ) {
ctx . beginPath ();
ctx . arc ( hub . x , hub . y , 70 * i * 0.5 , 0 , Math . PI * 2 );
2026-07-18 21:56:30 +02:00
ctx . strokeStyle = canvasTheme (). decor ( i );
2026-07-17 04:00:38 +02:00
ctx . lineWidth = 1 ;
ctx . stroke ();
}
if ( mode === "galaxy" ) {
// faint spiral guide
ctx . beginPath ();
for ( let i = 0 ; i <= 120 ; i ++ ) {
const u = i / 120 ;
const ang = u * Math . PI * 4 ;
const r = 40 + u * Math . min ( w , h ) * 0.4 ;
const x = hub . x + Math . cos ( ang ) * r ;
const y = hub . y + Math . sin ( ang ) * r ;
if ( i === 0 ) ctx . moveTo ( x , y );
else ctx . lineTo ( x , y );
}
2026-07-18 21:56:30 +02:00
ctx . strokeStyle = canvasTheme (). spiral ;
2026-07-17 04:00:38 +02:00
ctx . lineWidth = 1.2 ;
ctx . stroke ();
}
} else if ( mode === "clusters" ) {
for ( const ch of state . clusterHubs || []) {
2026-07-18 21:57:19 +02:00
const ring = ( ch . localR || 52 ) + 10 ;
2026-07-17 04:00:38 +02:00
ctx . beginPath ();
2026-07-18 21:57:19 +02:00
ctx . arc ( ch . x , ch . y , ring , 0 , Math . PI * 2 );
2026-07-17 04:00:38 +02:00
ctx . strokeStyle = ( ch . color || "#00a8e8" ) + "33" ;
ctx . lineWidth = 1.2 ;
ctx . stroke ();
ctx . beginPath ();
ctx . arc ( ch . x , ch . y , 10 , 0 , Math . PI * 2 );
ctx . fillStyle = ch . color || "#00a8e8" ;
ctx . globalAlpha = 0.55 ;
ctx . fill ();
ctx . globalAlpha = 1 ;
drawPulseLink ( hub . x , hub . y , ch . x , ch . y , true , t , ( ch . label || "" ). length * 17 , 0.55 );
}
} else if ( mode === "lanes" ) {
for ( const g of state . laneGuides || []) {
ctx . beginPath ();
ctx . moveTo ( 70 , g . y );
ctx . lineTo ( w - 30 , g . y );
ctx . strokeStyle = ( g . color || "#00a8e8" ) + "44" ;
ctx . lineWidth = 2 ;
ctx . stroke ();
// moving dashes
const dashX = (( t * 1.8 ) % ( w - 100 )) + 70 ;
ctx . beginPath ();
ctx . arc ( dashX , g . y , 2.5 , 0 , Math . PI * 2 );
ctx . fillStyle = g . color || "#3dffe0" ;
ctx . fill ();
2026-07-18 21:56:30 +02:00
ctx . fillStyle = canvasTheme (). laneLabel ;
ctx . font = "600 9px IBM Plex Mono, monospace" ;
2026-07-17 04:00:38 +02:00
ctx . textAlign = "left" ;
ctx . fillText (( g . label || "" ). slice ( 0 , 16 ), 74 , g . y - 8 );
}
} else if ( mode === "helix" ) {
// depth rails
for ( let i = 0 ; i < 3 ; i ++ ) {
ctx . beginPath ();
for ( let s = 0 ; s <= 80 ; s ++ ) {
const u = s / 80 ;
const ang = u * Math . PI * 2 * 2.4 + i * 0.9 ;
const y = 60 + u * ( h - 120 );
const amp = Math . min ( w , h ) * 0.28 ;
const depth = 0.45 + 0.55 * ( 0.5 + 0.5 * Math . sin ( ang ));
const x = hub . x + Math . cos ( ang ) * amp * depth ;
if ( s === 0 ) ctx . moveTo ( x , y );
else ctx . lineTo ( x , y );
}
2026-07-18 21:56:30 +02:00
ctx . strokeStyle = canvasTheme (). helix ( i );
2026-07-17 04:00:38 +02:00
ctx . lineWidth = 1 ;
ctx . stroke ();
}
}
}
2026-07-18 21:57:19 +02:00
function drawClusterLabelPipes ( omeHub ) {
const hubs = state . clusterHubs || [];
if ( ! hubs . length || ! omeHub ) return ;
const th = canvasTheme ();
const light = isLightTheme ();
for ( const ch of hubs ) {
const label = String ( ch . label || ch . cidr || "" ). trim ();
if ( ! label ) continue ;
// Radial outward from OME → cluster, then past the spoke ring ("long pipe")
let dx = ch . x - omeHub . x ;
let dy = ch . y - omeHub . y ;
let len = Math . hypot ( dx , dy );
if ( len < 1 ) {
dx = 0 ;
dy = - 1 ;
len = 1 ;
}
const ux = dx / len ;
const uy = dy / len ;
const ring = ch . localR || 52 ;
const pipeStart = 12 ;
const pipeEnd = ring + 36 ;
const sx = ch . x + ux * pipeStart ;
const sy = ch . y + uy * pipeStart ;
const ex = ch . x + ux * pipeEnd ;
const ey = ch . y + uy * pipeEnd ;
// Label sits a bit further out past the pipe tip
const lx = ch . x + ux * ( pipeEnd + 14 );
const ly = ch . y + uy * ( pipeEnd + 14 );
const color = ch . color || "#00a8e8" ;
const rgb = hexToRgb ( color );
// Long pipe / leader
ctx . save ();
ctx . strokeStyle = `rgba( ${ rgb . r } , ${ rgb . g } , ${ rgb . b } ,0.55)` ;
ctx . lineWidth = 1.6 ;
ctx . setLineDash ([]);
ctx . beginPath ();
ctx . moveTo ( sx , sy );
ctx . lineTo ( ex , ey );
ctx . stroke ();
// tip tick
ctx . beginPath ();
ctx . arc ( ex , ey , 2.4 , 0 , Math . PI * 2 );
ctx . fillStyle = `rgba( ${ rgb . r } , ${ rgb . g } , ${ rgb . b } ,0.9)` ;
ctx . fill ();
ctx . restore ();
// Pill label (drawn last so it sits above spokes)
ctx . font = "600 10px IBM Plex Mono, monospace" ;
const tw = ctx . measureText ( label ). width ;
const padX = 8 ;
const padY = 5 ;
const bw = tw + padX * 2 ;
const bh = 18 ;
const bx = lx - bw / 2 ;
const by = ly - bh / 2 ;
ctx . beginPath ();
const r = 6 ;
ctx . moveTo ( bx + r , by );
ctx . arcTo ( bx + bw , by , bx + bw , by + bh , r );
ctx . arcTo ( bx + bw , by + bh , bx , by + bh , r );
ctx . arcTo ( bx , by + bh , bx , by , r );
ctx . arcTo ( bx , by , bx + bw , by , r );
ctx . closePath ();
ctx . fillStyle = light ? "rgba(255,255,255,0.92)" : "rgba(6,14,24,0.88)" ;
ctx . fill ();
ctx . strokeStyle = `rgba( ${ rgb . r } , ${ rgb . g } , ${ rgb . b } ,0.75)` ;
ctx . lineWidth = 1.2 ;
ctx . stroke ();
ctx . fillStyle = th . clusterLabel ;
ctx . textAlign = "center" ;
ctx . textBaseline = "middle" ;
ctx . fillText ( label , lx , ly );
}
}
2026-07-17 04:00:38 +02:00
function drawLinks ( hub , t ) {
const mode = state . layoutMode ;
if ( mode === "clusters" ) {
// node to nearest cluster hub
for ( const n of state . nodes ) {
let best = null ;
let bestD = Infinity ;
for ( const ch of state . clusterHubs || []) {
const dx = n . x - ch . x ;
const dy = n . y - ch . y ;
const d = dx * dx + dy * dy ;
if ( d < bestD ) {
bestD = d ;
best = ch ;
}
}
if ( best ) drawPulseLink ( best . x , best . y , n . x , n . y , n . connected , t , n . id , 0.85 );
}
return ;
}
if ( mode === "lanes" ) {
for ( const n of state . nodes ) {
drawPulseLink ( hub . x , hub . y , n . x , n . y , n . connected , t , n . id , 0.55 );
}
return ;
}
if ( mode === "helix" ) {
// chain neighbors + faint hub links for connected
const sorted = [... state . nodes ]. sort (( a , b ) => ( a . helixT || 0 ) - ( b . helixT || 0 ));
for ( let i = 0 ; i < sorted . length - 1 ; i ++ ) {
const a = sorted [ i ];
const b = sorted [ i + 1 ];
drawPulseLink ( a . x , a . y , b . x , b . y , a . connected || b . connected , t , a . id , 0.7 );
}
for ( const n of state . nodes ) {
if ( n . connected ) drawPulseLink ( hub . x , hub . y , n . x , n . y , true , t , n . id , 0.25 );
}
return ;
}
// orbit / galaxy
for ( const n of state . nodes ) {
drawPulseLink ( hub . x , hub . y , n . x , n . y , n . connected , t , n . id , 1 );
}
}
function drawNodes ( t ) {
// draw far (small depth) first for helix
const list = [... state . nodes ]. sort (( a , b ) => ( a . depth || 1 ) - ( b . depth || 1 ));
for ( const n of list ) {
// lerp toward target
if ( Number . isFinite ( n . tx )) {
n . x += ( n . tx - n . x ) * 0.14 ;
n . y += ( n . ty - n . y ) * 0.14 ;
}
const col = nodeColor ( n );
const selected = n . id === state . selectedId ;
const depth = n . depth || 1 ;
if ( selected ) {
ctx . beginPath ();
ctx . arc ( n . x , n . y , n . r + 6 , 0 , Math . PI * 2 );
2026-07-18 21:56:30 +02:00
ctx . strokeStyle = canvasTheme (). selectRing ;
2026-07-17 04:00:38 +02:00
ctx . lineWidth = 2 ;
ctx . stroke ();
}
if ( n . watts != null && state . viewMode === "power" ) {
ctx . beginPath ();
ctx . arc ( n . x , n . y , n . r + 3 + Math . min ( 10 , n . watts / 80 ), 0 , Math . PI * 2 );
ctx . strokeStyle = col + "55" ;
ctx . lineWidth = 2 ;
ctx . stroke ();
}
ctx . beginPath ();
ctx . arc ( n . x , n . y , n . r , 0 , Math . PI * 2 );
ctx . fillStyle = col ;
ctx . globalAlpha = 0.55 + 0.45 * Math . min ( depth , 1 );
ctx . fill ();
ctx . globalAlpha = 1 ;
if ( state . cam . scale > 0.85 ) {
2026-07-18 21:56:30 +02:00
const th = canvasTheme ();
// Halo behind text for contrast on busy light canvas
2026-07-17 04:00:38 +02:00
const label =
state . viewMode === "power" && n . watts != null
? ` ${ Math . round ( n . watts ) } W`
: ( n . name || "" ). slice ( 0 , 18 );
2026-07-18 21:56:30 +02:00
ctx . font = "600 9px IBM Plex Mono, monospace" ;
ctx . textAlign = "center" ;
ctx . textBaseline = "top" ;
if ( isLightTheme ()) {
ctx . lineWidth = 3 ;
ctx . strokeStyle = "rgba(236, 242, 248, 0.92)" ;
ctx . strokeText ( label , n . x , n . y + n . r + 3 );
}
ctx . fillStyle = th . label ;
2026-07-17 04:00:38 +02:00
ctx . fillText ( label , n . x , n . y + n . r + 3 );
}
}
}
function draw () {
const w = canvas . clientWidth ;
const h = canvas . clientHeight ;
// hard clear in device pixels (avoids trail artifacts with DPR transform)
ctx . save ();
ctx . setTransform ( 1 , 0 , 0 , 1 , 0 , 0 );
ctx . clearRect ( 0 , 0 , canvas . width , canvas . height );
ctx . restore ();
ctx . save ();
ctx . translate ( state . cam . x , state . cam . y );
ctx . scale ( state . cam . scale , state . cam . scale );
const hub = state . hub ;
if ( ! hub ) {
ctx . restore ();
requestAnimationFrame ( draw );
return ;
}
const t = state . pulseT ;
drawDecor ( w , h , hub , t );
drawLinks ( hub , t );
drawHub ( hub , t );
drawNodes ( t );
2026-07-18 21:57:19 +02:00
if ( state . layoutMode === "clusters" ) drawClusterLabelPipes ( hub );
2026-07-17 04:00:38 +02:00
ctx . restore ();
if ( ! state . nodes . length ) {
2026-07-18 21:56:30 +02:00
const th = canvasTheme ();
ctx . fillStyle = th . empty ;
2026-07-17 04:00:38 +02:00
ctx . font = "600 15px IBM Plex Sans, sans-serif" ;
ctx . textAlign = "center" ;
ctx . textBaseline = "middle" ;
const f = state . filters ;
const tip = ( ! f . servers && ! f . idrac )
? "No types selected — enable Servers and/or iDRACs in Filters"
: "No devices match the current filters" ;
ctx . fillText ( tip , w / 2 , h / 2 );
ctx . font = "500 12px IBM Plex Mono, monospace" ;
2026-07-18 21:56:30 +02:00
ctx . fillStyle = th . emptyHint ;
2026-07-17 04:00:38 +02:00
ctx . fillText ( "Adjust Filters or click Reset filters" , w / 2 , h / 2 + 28 );
}
2026-07-18 21:56:30 +02:00
if ( state . filters . pulseLinks !== false ) {
state . pulseT += Math . max ( 0.1 , Number ( state . filters . pulseSpeed ) || 0.45 );
}
2026-07-17 04:00:38 +02:00
requestAnimationFrame ( draw );
}
function screenToWorld ( sx , sy ) {
return {
x : ( sx - state . cam . x ) / state . cam . scale ,
y : ( sy - state . cam . y ) / state . cam . scale ,
};
}
function hitTest ( sx , sy ) {
const p = screenToWorld ( sx , sy );
if ( state . hub ) {
const dx = p . x - state . hub . x ;
const dy = p . y - state . hub . y ;
if ( dx * dx + dy * dy <= ( state . hub . r + 6 ) ** 2 ) return { type : "hub" };
}
let best = null ;
let bestD = Infinity ;
for ( const n of state . nodes ) {
const dx = p . x - n . x ;
const dy = p . y - n . y ;
const d = dx * dx + dy * dy ;
if ( d <= ( n . r + 8 ) ** 2 && d < bestD ) {
bestD = d ;
best = n ;
}
}
return best ? { type : "node" , node : best } : null ;
}
const KPI_META = {
total : { title : "All nodes" , blurb : "Full OME inventory currently in cockpit." },
servers : { title : "Servers" , blurb : "Server-class devices (OME type 1000)." },
idracs : { title : "iDRACs" , blurb : "iDRAC / BMC endpoints discovered in OME." },
connected : { title : "Connected" , blurb : "Devices with live OME connection state." },
offline : { title : "Offline" , blurb : "Devices currently not connected to OME." },
powered : { title : "Powered on" , blurb : "Devices reporting powered-on state." },
power : { title : "Live power" , blurb : "Nodes with a live watt reading — hottest first." },
avgw : { title : "Average watts / node" , blurb : "Power samples contributing to fleet average." },
samples : { title : "Power samples" , blurb : "Devices included in the live power sample set." },
};
let kpiPopup = { key : null , selectedId : null , q : "" , sort : "name" };
function devicesForKpi ( key ) {
const all = state . data ? . devices || [];
switch ( key ) {
case "servers" :
return all . filter (( d ) => d . is_server );
case "idracs" :
return all . filter (( d ) => d . is_idrac );
case "connected" :
return all . filter (( d ) => d . connected );
case "offline" :
return all . filter (( d ) => ! d . connected );
case "powered" :
return all . filter (( d ) => d . powered_on );
case "power" :
case "avgw" :
case "samples" :
return all . filter (( d ) => d . watts != null );
case "total" :
default :
return all . slice ();
}
}
function sortKpiDevices ( list ) {
const sort = kpiPopup . sort || "name" ;
const arr = list . slice ();
if ( sort === "watts" ) arr . sort (( a , b ) => ( b . watts || 0 ) - ( a . watts || 0 ));
else if ( sort === "status" ) arr . sort (( a , b ) => String ( a . status || "" ). localeCompare ( String ( b . status || "" )));
else if ( sort === "subnet" ) arr . sort (( a , b ) => String ( a . subnet || "" ). localeCompare ( String ( b . subnet || "" )));
else arr . sort (( a , b ) => String ( a . name || "" ). localeCompare ( String ( b . name || "" )));
return arr ;
}
function filterKpiDevices ( list ) {
const q = ( kpiPopup . q || "" ). trim (). toLowerCase ();
if ( ! q ) return list ;
return list . filter (( d ) =>
[ d . name , d . ip , d . model , d . service_tag , d . subnet , d . status ]
. filter ( Boolean )
. join ( " " )
. toLowerCase ()
. includes ( q )
);
}
function kpiSummaryHtml ( key , list ) {
const connected = list . filter (( d ) => d . connected ). length ;
const offline = list . length - connected ;
const powered = list . filter (( d ) => d . powered_on ). length ;
const withW = list . filter (( d ) => d . watts != null );
const watts = withW . reduce (( a , d ) => a + ( d . watts || 0 ), 0 );
const subnets = new Set ( list . map (( d ) => d . subnet ). filter ( Boolean )). size ;
const pills = [
` ${ list . length } in view` ,
` ${ connected } connected` ,
` ${ offline } offline` ,
` ${ powered } powered` ,
withW . length ? ` ${ Math . round ( watts ) } W sampled` : "no power samples" ,
` ${ subnets } subnets` ,
];
if ( key === "power" || key === "avgw" || key === "samples" ) {
const top = sortKpiDevices ( withW ). slice ( 0 , 1 )[ 0 ];
if ( top ) pills . push ( `hottest ${ top . name ? . slice ( 0 , 22 ) } ${ Math . round ( top . watts ) } W` );
}
return pills . map (( p ) => `<span class="pill"> ${ escapeHtml ( p ) } </span>` ). join ( "" );
}
function renderKpiDetail ( node ) {
const el = $ ( "#kpi-detail" );
if ( ! el ) return ;
if ( ! node ) {
el . innerHTML = `<p class="hint">Select a system on the left for live context and actions.</p>` ;
return ;
}
const alerts = ( state . data ? . alerts || []). filter (( a ) => a . device_id === node . id ). slice ( 0 , 5 );
el . innerHTML = `
<h3> ${ escapeHtml ( node . name || "device" ) } </h3>
<p class="meta"> ${ escapeHtml ( node . model || "—" ) } · ${ escapeHtml ( node . service_tag || "no tag" ) } · id ${ node . id } </p>
<div class="badge-row">
<span class="badge ${ node . connected ? "on" : "off" } "> ${ node . connected ? "CONNECTED" : "OFFLINE" } </span>
<span class="badge ${ node . powered_on ? "on" : "off" } "> ${ node . powered_on ? "POWERED ON" : "POWER N/A" } </span>
${ node . watts != null ? `<span class="badge power"> ${ Math . round ( node . watts ) } W</span>` : "" }
${ node . is_idrac ? `<span class="badge">iDRAC</span>` : "" }
${ node . is_server ? `<span class="badge">SERVER</span>` : "" }
</div>
<div class="kpi-kv">
2026-07-17 15:15:57 +02:00
<div class="row"><span class="k">Service Tag</span><span class="v"><code> ${ escapeHtml ( node . service_tag || "—" ) } </code></span></div>
2026-07-17 04:00:38 +02:00
<div class="row"><span class="k">IP</span><span class="v"> ${ escapeHtml ( node . ip || "—" ) } </span></div>
<div class="row"><span class="k">Subnet</span><span class="v"> ${ escapeHtml ( node . subnet || "—" ) } </span></div>
<div class="row"><span class="k">Status</span><span class="v"> ${ escapeHtml ( node . status || "—" ) } </span></div>
<div class="row"><span class="k">Avg / peak</span><span class="v"> ${ node . avg_watts != null ? Math . round ( node . avg_watts ) + " W" : "—" } / ${ node . peak_watts != null ? Math . round ( node . peak_watts ) + " W" : "—" } </span></div>
<div class="row"><span class="k">Last status</span><span class="v"> ${ escapeHtml ( node . last_status_time || "—" ) } </span></div>
</div>
<div class="kpi-actions">
<button type="button" class="btn primary" data-kpi-act="focus">Focus on map</button>
<button type="button" class="btn conn-link" data-kpi-act="ssh" ${ node . ip ? "" : "disabled" } >SSH terminal</button>
2026-07-18 01:18:14 +02:00
<button type="button" class="btn conn-link" data-kpi-act="rdp" ${ node . ip ? "" : "disabled" } >RDP popout</button>
2026-07-17 04:00:38 +02:00
<button type="button" class="btn" data-kpi-act="connect">Quick Connect</button>
<button type="button" class="btn" data-kpi-act="inventory">Inspector + inventory</button>
<button type="button" class="btn" data-kpi-act="chat">Ask Cockpit chat</button>
${ node . idrac_url ? `<a class="btn conn-link" href=" ${ escapeAttr ( node . idrac_url ) } " target="_blank" rel="noopener">iDRAC Web</a>` : "" }
</div>
<div class="inv-section">
<h3>Related alerts</h3>
${
alerts . length
? alerts
. map (
( a ) =>
`<div class="inv-line"> ${ escapeHtml ( a . severity ) } · ${ escapeHtml (( a . message || "" ). slice ( 0 , 120 )) } </div>`
)
. join ( "" )
: `<p class="hint">No alerts for this node in the current feed.</p>`
}
</div>` ;
}
function renderKpiPopup () {
const key = kpiPopup . key ;
if ( ! key ) return ;
const meta = KPI_META [ key ] || { title : key , blurb : "" };
const raw = devicesForKpi ( key );
const list = sortKpiDevices ( filterKpiDevices ( raw ));
$ ( "#kpi-title" ). textContent = meta . title ;
$ ( "#kpi-sub" ). textContent = ` ${ meta . blurb } · ${ list . length } shown of ${ raw . length } ` ;
$ ( "#kpi-summary" ). innerHTML = kpiSummaryHtml ( key , raw );
const listEl = $ ( "#kpi-list" );
if ( ! list . length ) {
listEl . innerHTML = `<p class="hint">No systems match this KPI / search.</p>` ;
renderKpiDetail ( null );
return ;
}
if ( kpiPopup . selectedId == null || ! list . some (( d ) => d . id === kpiPopup . selectedId )) {
kpiPopup . selectedId = list [ 0 ]. id ;
}
listEl . innerHTML = list
. slice ( 0 , 200 )
. map (( d ) => {
const bits = [
d . ip || "no IP" ,
d . connected ? "up" : "down" ,
d . watts != null ? Math . round ( d . watts ) + "W" : null ,
d . subnet ,
]
. filter ( Boolean )
. join ( " · " );
return `<button type="button" class="kpi-item ${ d . id === kpiPopup . selectedId ? " active" : "" } " data-kpi-device=" ${ d . id } ">
<span class="t"> ${ escapeHtml ( d . name || "device" ) } </span>
<span class="s"> ${ escapeHtml ( bits ) } </span>
</button>` ;
})
. join ( "" );
const selected = list . find (( d ) => d . id === kpiPopup . selectedId ) || null ;
renderKpiDetail ( selected );
}
function openKpiPopup ( key ) {
kpiPopup . key = key ;
kpiPopup . selectedId = null ;
kpiPopup . q = "" ;
const search = $ ( "#kpi-search" );
if ( search ) search . value = "" ;
const modal = $ ( "#kpi-modal" );
const scrim = $ ( "#scrim" );
modal ? . classList . remove ( "hidden" );
modal ? . setAttribute ( "aria-hidden" , "false" );
scrim ? . classList . add ( "open" );
if ( scrim ) scrim . dataset . mode = "kpi" ;
renderKpiPopup ();
}
function closeKpiPopup () {
const modal = $ ( "#kpi-modal" );
modal ? . classList . add ( "hidden" );
modal ? . setAttribute ( "aria-hidden" , "true" );
const scrim = $ ( "#scrim" );
if ( scrim ? . dataset . mode === "kpi" ) {
scrim . classList . remove ( "open" );
delete scrim . dataset . mode ;
}
}
function applyKpiAsFilter ( key ) {
state . kpiFocus = key ;
// Align type filters so the map can show the KPI set
if ( key === "idracs" ) {
state . filters . idrac = true ;
state . filters . servers = false ;
} else if ( key === "servers" || key === "offline" || key === "powered" || key === "connected" || key === "total" ) {
state . filters . servers = true ;
// keep idrac optional for total/connected/offline breadth
if ( key === "total" || key === "connected" || key === "offline" ) {
state . filters . idrac = true ;
}
}
if ( key === "connected" ) state . filters . connected = true ;
if ( key === "powered" ) state . filters . powered = true ;
if ( key === "power" || key === "avgw" || key === "samples" ) {
state . filters . hasPower = true ;
state . viewMode = "power" ;
$ ( "#view-modes" ) ? . querySelectorAll ( ".chip" ). forEach (( c ) =>
c . classList . toggle ( "active" , c . dataset . mode === "power" )
);
}
syncFilterInputs ();
refreshLists ();
layout ();
snapNodes ();
fitCameraToNodes ();
updateFocusContext ();
showToast ( `Map filter: ${ KPI_META [ key ] ? . title || key } ` );
}
function renderKpis () {
const s = state . data ? . summary || {};
const items = [
{ key : "total" , label : "Nodes" , v : s . total ?? "—" , cls : "" },
{ key : "servers" , label : "Servers" , v : s . servers ?? "—" , cls : "" },
{ key : "idracs" , label : "iDRACs" , v : s . idracs ?? "—" , cls : "" },
{ key : "connected" , label : "Connected" , v : s . connected ?? "—" , cls : "" },
{ key : "offline" , label : "Offline" , v : s . offline ?? "—" , cls : "warn" },
{ key : "powered" , label : "Powered on" , v : s . powered_on ?? "—" , cls : "" },
{
key : "power" ,
label : "Live power" ,
v : s . total_watts != null ? ` ${ Math . round ( s . total_watts ) } W` : "—" ,
cls : "power" ,
},
{
key : "avgw" ,
label : "Avg / node" ,
v : s . avg_node_watts != null ? ` ${ Math . round ( s . avg_node_watts ) } W` : "—" ,
cls : "power" ,
},
{
key : "samples" ,
label : "Power samples" ,
v : s . power_samples ?? "—" ,
cls : "" ,
},
];
$ ( "#kpi-strip" ). innerHTML = items
. map (
( it ) => `
<button type="button" class="kpi ${ it . cls }${ state . kpiFocus === it . key ? " active" : "" } " data-kpi=" ${ it . key } ">
<span class="v"> ${ it . v } </span>
<span class="l"> ${ it . label } </span>
</button>`
)
. join ( "" );
}
function renderSubnets () {
const subs = state . data ? . subnets || [];
const html = [
`<button type="button" class="card ${ state . subnet === "all" ? " active" : "" } " data-subnet="all">
<span class="t">All networks</span>
<span class="s"> ${ state . data ? . summary ? . total ?? 0 } nodes · ${ state . data ? . summary ? . total_watts != null ? Math . round ( state . data . summary . total_watts ) + " W" : "—" } </span>
</button>` ,
2026-07-18 21:56:30 +02:00
... subs . map (( s ) => {
const vlanLabel =
s . vlan_id != null
? `VLAN ${ s . vlan_id }${ s . vlan_name ? " · " + s . vlan_name : "" } `
: s . cidr ;
const accent = s . vlan_color ? ` style="--vlan: ${ escapeAttr ( s . vlan_color ) } "` : "" ;
return `<button type="button" class="card ${ state . subnet === s . cidr ? " active" : "" }${ s . vlan_id != null ? " has-vlan" : "" } " data-subnet=" ${ escapeAttr ( s . cidr ) } " ${ accent } >
<span class="t"> ${ escapeHtml ( vlanLabel ) } </span>
<span class="s"> ${ escapeHtml ( s . cidr ) } · ${ s . connected } / ${ s . count } connected ${ s . watts != null ? ` · ${ Math . round ( s . watts ) } W` : "" } </span>
</button>` ;
}),
2026-07-17 04:00:38 +02:00
];
$ ( "#subnet-list" ). innerHTML = html . join ( "" );
}
function renderModels () {
const models = state . data ? . models || [];
$ ( "#model-list" ). innerHTML = [
`<button type="button" class="card ${ state . model == null ? " active" : "" } " data-model="">
<span class="t">All models</span>
<span class="s"> ${ models . reduce (( a , m ) => a + m . count , 0 ) } servers</span>
</button>` ,
... models . map (
( m ) => `<button type="button" class="card ${ state . model === m . name ? " active" : "" } " data-model=" ${ escapeAttr ( m . name ) } ">
<span class="t"> ${ escapeHtml ( m . name ) } </span>
<span class="s"> ${ m . count } nodes</span>
</button>`
),
]. join ( "" );
}
function renderGroups () {
const groups = state . data ? . groups || [];
$ ( "#group-list" ). innerHTML = groups . length
? groups
. map (
( g ) => `<button type="button" class="card" data-group=" ${ escapeAttr ( g . name ) } ">
<span class="t"> ${ escapeHtml ( g . name ) } </span>
<span class="s">OME group · id ${ g . id } </span>
</button>`
)
. join ( "" )
: `<p class="hint">No groups loaded</p>` ;
}
function renderLegend () {
const el = $ ( "#legend" );
if ( state . viewMode === "power" ) {
el . innerHTML = `
<span style="--c:#3dffa0"> <200W</span>
<span style="--c:#00a8e8">200– 450W</span>
<span style="--c:#ffb020">450– 650W</span>
<span style="--c:#ff5c5c">>650W</span>
<span style="--c:#3a4a5a">geen reading</span>` ;
} else if ( state . viewMode === "connection" ) {
el . innerHTML = `<span style="--c:#3dffe0">connected</span><span style="--c:#ff5c5c">offline</span>` ;
} else if ( state . viewMode === "model" ) {
el . innerHTML = [... state . modelColors . entries ()]
. slice ( 0 , 6 )
. map (([ k , c ]) => `<span style="--c: ${ c } "> ${ escapeHtml ( k . slice ( 0 , 22 )) } </span>` )
. join ( "" );
} else if ( state . viewMode === "subnet" ) {
el . innerHTML = [... state . subnetColors . entries ()]
. slice ( 0 , 6 )
. map (([ k , c ]) => `<span style="--c: ${ c } "> ${ escapeHtml ( k ) } </span>` )
. join ( "" );
} else {
el . innerHTML = `
<span style="--c:#3dffa0">healthy</span>
<span style="--c:#ffb020">warning</span>
<span style="--c:#ff5c5c">critical</span>
<span style="--c:#5a6a7a">offline</span>` ;
}
}
function renderTicker () {
const d = state . data ;
if ( ! d ) return ;
const ome = d . ome || {};
const s = d . summary || {};
const ctx = d . context || {};
const sev = ctx . alert_severity || {};
$ ( "#ticker" ). innerHTML = `
<button type="button" data-tick="ome"> ${ escapeHtml ( ome . name || "OME" ) } ${ escapeHtml ( ome . version || "" ) } </button>
<span class="sep">·</span>
<button type="button" data-tick="pulse">pulse # ${ d . pulse ?? 0 } </button>
<span class="sep">·</span>
<button type="button" data-tick="power"> ${ s . total_watts != null ? Math . round ( s . total_watts ) + " W live" : "power pending" } </button>
<span class="sep">·</span>
<button type="button" data-tick="connected"> ${ s . connected ?? 0 } connected</button>
<span class="sep">·</span>
<button type="button" data-tick="alerts"> ${ sev . Critical ?? 0 } crit / ${ sev . Warning ?? 0 } warn</button>
<span class="sep">·</span>
<button type="button" data-tick="visible"> ${ state . nodes . length } visible</button>
<span class="sep">·</span>
<span>updated ${ d . updated_at ? new Date ( d . updated_at * 1000 ). toLocaleTimeString () : "—" } </span>
` ;
}
function escapeHtml ( s ) {
return String ( s ?? "" )
. replace ( /&/g , "&" )
. replace ( /</g , "<" )
. replace ( />/g , ">" )
. replace ( /"/g , """ );
}
function escapeAttr ( s ) {
return escapeHtml ( s ). replace ( /'/g , "'" );
}
function sevClass ( sev ) {
const s = String ( sev || "" ). toLowerCase ();
if ( s . includes ( "crit" )) return "critical" ;
if ( s . includes ( "warn" )) return "warning" ;
return "info" ;
}
function relTime ( ts ) {
if ( ! ts ) return "—" ;
let t ;
if ( typeof ts === "number" ) t = ts * ( ts > 1e12 ? 1 : 1000 );
else {
// OME: "2026-07-16 23:36:33.858"
const d = Date . parse ( String ( ts ). replace ( " " , "T" ) + "Z" );
t = Number . isFinite ( d ) ? d : Date . parse ( String ( ts ));
}
if ( ! Number . isFinite ( t )) return String ( ts ). slice ( 0 , 19 );
const sec = Math . max ( 0 , Math . round (( Date . now () - t ) / 1000 ));
if ( sec < 60 ) return sec + "s ago" ;
if ( sec < 3600 ) return Math . floor ( sec / 60 ) + "m ago" ;
if ( sec < 86400 ) return Math . floor ( sec / 3600 ) + "h ago" ;
return Math . floor ( sec / 86400 ) + "d ago" ;
}
function updateFocusContext () {
const el = $ ( "#ctx-focus" );
if ( ! el ) return ;
const bits = [];
bits . push ( "layout " + ( state . layoutMode || "orbit" ));
bits . push ( "color " + ( state . viewMode || "status" ));
bits . push ( state . subnet === "all" ? "all networks" : state . subnet );
if ( state . model ) bits . push ( state . model );
const types = [];
if ( state . filters . servers ) types . push ( "servers" );
if ( state . filters . idrac ) types . push ( "iDRACs" );
bits . push ( types . length ? types . join ( "+" ) : "no types" );
if ( state . filters . connected ) bits . push ( "connected" );
if ( state . filters . powered ) bits . push ( "powered" );
if ( state . filters . hasPower ) bits . push ( "has W" );
if ( state . filters . minWatts > 0 ) bits . push ( "≥" + state . filters . minWatts + "W" );
if ( state . filters . pulseLinks === false ) bits . push ( "pulse off" );
if ( state . filters . search ) bits . push ( "“" + state . filters . search . slice ( 0 , 18 ) + "”" );
if ( state . selectedId ) {
const n = ( state . data ? . devices || []). find (( d ) => d . id === state . selectedId );
if ( n ) bits . push ( "node " + ( n . name || "" ). slice ( 0 , 22 ));
}
bits . push ( state . nodes . length + " visible" );
el . textContent = "Focus: " + bits . join ( " · " );
}
function renderContext () {
const d = state . data ;
const ctx = d ? . context || {};
const s = d ? . summary || {};
const live = $ ( "#ctx-live-text" );
if ( live ) {
const age = d ? . updated_at ? relTime ( d . updated_at ) : "—" ;
live . textContent =
( ctx . focus_hint || "Realtime context" ) +
" · pulse #" +
( d ? . pulse ?? 0 ) +
" · " +
age ;
}
const stats = $ ( "#ctx-stats" );
if ( stats ) {
const sev = ctx . alert_severity || {};
stats . innerHTML = `
<button type="button" class="ctx-stat danger" data-ctx="critical">
<span class="v"> ${ sev . Critical ?? 0 } </span><span class="l">Critical</span>
</button>
<button type="button" class="ctx-stat warn" data-ctx="warning">
<span class="v"> ${ sev . Warning ?? 0 } </span><span class="l">Warning</span>
</button>
<button type="button" class="ctx-stat" data-ctx="events">
<span class="v"> ${ ( d ? . events || []). length } </span><span class="l">Fleet deltas</span>
</button>
<button type="button" class="ctx-stat" data-ctx="hottest">
<span class="v"> ${ s . total_watts != null ? Math . round ( s . total_watts ) + "W" : "—" } </span><span class="l">Live power</span>
</button>` ;
}
// alerts in left rail
const al = $ ( "#alert-list" );
if ( al ) {
const alerts = ( d ? . alerts || []). slice ( 0 , 12 );
if ( ! alerts . length ) {
al . innerHTML = `<p class="hint">No alerts in this snapshot</p>` ;
} else {
al . innerHTML = alerts
. map (
( a ) => `<button type="button" class="card alert-card" data-alert-device=" ${ a . device_id ?? "" } " data-alert-id=" ${ a . id ?? "" } ">
<span class="t"> ${ escapeHtml ( a . severity ) } · ${ escapeHtml (( a . device || "OME" ). slice ( 0 , 28 )) } </span>
<span class="s"> ${ escapeHtml (( a . message || "" ). slice ( 0 , 110 )) }${ a . time ? " · " + escapeHtml ( relTime ( a . time )) : "" } </span>
</button>`
)
. join ( "" );
}
}
// live feed overlay: merge events + alerts
const body = $ ( "#ctx-feed-body" );
const meta = $ ( "#ctx-feed-meta" );
if ( meta ) {
meta . textContent =
( ctx . alerts_total != null ? ctx . alerts_total + " alerts total" : "alerts…" ) +
( ctx . events_new ? ` · + ${ ctx . events_new } delta` : "" );
}
if ( body ) {
if ( $ ( "#ctx-feed" ) ? . classList . contains ( "collapsed" )) {
body . innerHTML = "" ;
updateFocusContext ();
return ;
}
const items = [];
for ( const e of d ? . events || []) {
items . push ({
cls : sevClass ( e . severity ),
title : e . title || e . kind ,
msg : e . text ,
sub : ( e . kind || "delta" ) + " · " + relTime ( e . ts ),
deviceId : e . device_id ,
sort : e . ts || 0 ,
});
}
for ( const a of ( d ? . alerts || []). slice ( 0 , 15 )) {
let sort = 0 ;
const parsed = Date . parse ( String ( a . time || "" ). replace ( " " , "T" ) + "Z" );
sort = Number . isFinite ( parsed ) ? parsed / 1000 : 0 ;
items . push ({
cls : sevClass ( a . severity ),
title : ( a . device || "OME" ) + " · " + ( a . severity || "" ),
msg : a . message || "" ,
sub : ( a . category || "alert" ) + " · " + relTime ( a . time ),
deviceId : a . device_id ,
sort ,
});
}
// hottest quick context
for ( const h of ctx . hottest || []) {
items . push ({
cls : "info" ,
title : "Hot · " + ( h . name || "" ). slice ( 0 , 24 ),
msg : Math . round ( h . watts ) + " W · " + ( h . subnet || "" ),
sub : "power ranking" ,
deviceId : h . id ,
sort : ( d ? . updated_at || 0 ) + ( h . watts || 0 ) / 1e6 ,
});
}
items . sort (( x , y ) => ( y . sort || 0 ) - ( x . sort || 0 ));
body . innerHTML = items
. slice ( 0 , 24 )
. map (
( it ) => `<button type="button" class="feed-item ${ it . cls } " data-feed-device=" ${ it . deviceId ?? "" } ">
<span class="ft"> ${ escapeHtml ( it . title ) } </span>
<span class="fm"> ${ escapeHtml (( it . msg || "" ). slice ( 0 , 140 )) } </span>
<span class="fs"> ${ escapeHtml ( it . sub ) } </span>
</button>`
)
. join ( "" );
}
updateFocusContext ();
}
function focusDeviceId ( id ) {
if ( id == null || id === "" ) return ;
const nid = Number ( id );
const n = ( state . data ? . devices || []). find (( d ) => d . id === nid || d . id === id );
if ( ! n ) return ;
state . selectedId = n . id ;
// ensure visible: clear blocking filters lightly
showInspector ( n );
// pan toward node if laid out
const laid = state . nodes . find (( x ) => x . id === n . id );
if ( laid ) {
const w = canvas . clientWidth ;
const h = canvas . clientHeight ;
state . cam . x = w / 2 - laid . x * state . cam . scale ;
state . cam . y = h / 2 - laid . y * state . cam . scale ;
}
updateFocusContext ();
}
function snapNodes () {
for ( const n of state . nodes ) {
if ( Number . isFinite ( n . tx )) {
n . x = n . tx ;
n . y = n . ty ;
}
}
}
function fitCameraToNodes ( opts = {}) {
const nodes = state . nodes ;
const w = canvas . clientWidth ;
const h = canvas . clientHeight ;
if ( ! w || ! h ) {
state . cam = { x : 0 , y : 0 , scale : 1 };
return ;
}
if ( ! nodes . length ) {
state . cam = { x : 0 , y : 0 , scale : 1 };
return ;
}
let minX = Infinity , minY = Infinity , maxX = - Infinity , maxY = - Infinity ;
const expand = ( x , y , r = 0 ) => {
minX = Math . min ( minX , x - r );
maxX = Math . max ( maxX , x + r );
minY = Math . min ( minY , y - r );
maxY = Math . max ( maxY , y + r );
};
for ( const n of nodes ) {
const x = Number . isFinite ( n . tx ) ? n . tx : n . x ;
const y = Number . isFinite ( n . ty ) ? n . ty : n . y ;
expand ( x , y , ( n . r || 6 ) + 14 );
}
if ( state . hub ) expand ( state . hub . x , state . hub . y , ( state . hub . r || 28 ) + 16 );
for ( const ch of state . clusterHubs || []) expand ( ch . x , ch . y , 56 );
const bw = Math . max ( 80 , maxX - minX );
const bh = Math . max ( 80 , maxY - minY );
// Leave room for ctx-bar (top) + legend/feed (bottom)
const padX = opts . padX ?? 70 ;
const padY = opts . padY ?? 110 ;
const availW = Math . max ( 120 , w - padX * 2 );
const availH = Math . max ( 120 , h - padY * 2 );
const minScale = opts . minScale ?? 0.28 ;
const maxScale = opts . maxScale ?? 1.35 ;
let scale = Math . min ( availW / bw , availH / bh );
scale = Math . min ( maxScale , Math . max ( minScale , scale ));
const cx = ( minX + maxX ) / 2 ;
const cy = ( minY + maxY ) / 2 ;
// Bias slightly downward so top ctx-bar does not cover the hub
const yBias = 18 ;
state . cam . scale = scale ;
state . cam . x = w / 2 - cx * scale ;
state . cam . y = h / 2 - cy * scale + yBias ;
}
function resetView () {
// Ensure canvas metrics are current, then rebuild layout in screen space
const dpr = Math . min ( window . devicePixelRatio || 1 , 2 );
const w = canvas . clientWidth ;
const h = canvas . clientHeight ;
if ( w && h ) {
canvas . width = Math . floor ( w * dpr );
canvas . height = Math . floor ( h * dpr );
ctx . setTransform ( dpr , 0 , 0 , dpr , 0 , 0 );
}
layout ();
snapNodes ();
// Designed layouts assume identity camera; then nudge to fit with padding
state . cam = { x : 0 , y : 0 , scale : 1 };
fitCameraToNodes ();
updateFocusContext ();
showToast ( "View reset · fit to fleet" );
}
function syncFilterInputs () {
const f = state . filters ;
const set = ( id , val ) => {
const el = document . getElementById ( id );
if ( ! el ) return ;
if ( el . type === "checkbox" ) el . checked = !! val ;
else el . value = val ;
};
set ( "f-connected" , f . connected );
set ( "f-powered" , f . powered );
set ( "f-servers" , f . servers );
set ( "f-idrac" , f . idrac );
set ( "f-has-power" , f . hasPower );
set ( "f-ghost-links" , f . ghostLinks );
set ( "f-pulse-links" , f . pulseLinks !== false );
2026-07-18 21:56:30 +02:00
set ( "f-pulse-speed" , f . pulseSpeed ?? 0.45 );
set ( "f-pulse-line-color" , f . pulseLineColor || "#3dffe0" );
set ( "f-pulse-glow-color" , f . pulseGlowColor || "#00a8e8" );
set ( "f-pulse-packet-color" , f . pulsePacketColor || "#ffffff" );
2026-07-17 04:00:38 +02:00
set ( "f-min-watts" , f . minWatts || 0 );
const lab = $ ( "#min-w-label" );
if ( lab ) lab . textContent = String ( f . minWatts || 0 );
2026-07-18 21:56:30 +02:00
const pLab = $ ( "#pulse-speed-label" );
if ( pLab ) pLab . textContent = ` ${ Number ( f . pulseSpeed ?? 0.45 ). toFixed ( 2 ) } × `;
2026-07-17 04:00:38 +02:00
const search = $ ( "#search" );
if ( search ) search . value = f . search || "" ;
}
2026-07-18 21:56:30 +02:00
function savePulsePrefs () {
try {
localStorage . setItem (
"cockpit_pulse_prefs" ,
JSON . stringify ({
pulseSpeed : state . filters . pulseSpeed ?? 0.45 ,
pulseLineColor : state . filters . pulseLineColor || "#3dffe0" ,
pulseGlowColor : state . filters . pulseGlowColor || "#00a8e8" ,
pulsePacketColor : state . filters . pulsePacketColor || "#ffffff" ,
})
);
localStorage . setItem ( "cockpit_pulse_speed" , String ( state . filters . pulseSpeed ?? 0.45 ));
} catch ( _ ) {}
}
2026-07-17 04:00:38 +02:00
function readFiltersFromDom () {
const on = ( id ) => !! document . getElementById ( id ) ? . checked ;
state . filters . connected = on ( "f-connected" );
state . filters . powered = on ( "f-powered" );
state . filters . servers = on ( "f-servers" );
state . filters . idrac = on ( "f-idrac" );
state . filters . hasPower = on ( "f-has-power" );
state . filters . ghostLinks = on ( "f-ghost-links" );
state . filters . pulseLinks = on ( "f-pulse-links" );
2026-07-18 21:56:30 +02:00
const ps = document . getElementById ( "f-pulse-speed" );
if ( ps ) state . filters . pulseSpeed = Math . max ( 0.1 , Math . min ( 2 , Number ( ps . value ) || 0.45 ));
const line = document . getElementById ( "f-pulse-line-color" );
const glow = document . getElementById ( "f-pulse-glow-color" );
const packet = document . getElementById ( "f-pulse-packet-color" );
if ( line ? . value ) state . filters . pulseLineColor = line . value ;
if ( glow ? . value ) state . filters . pulseGlowColor = glow . value ;
if ( packet ? . value ) state . filters . pulsePacketColor = packet . value ;
2026-07-17 04:00:38 +02:00
const mw = document . getElementById ( "f-min-watts" );
state . filters . minWatts = mw ? Number ( mw . value ) || 0 : 0 ;
state . filters . search = $ ( "#search" ) ? . value || "" ;
2026-07-18 21:56:30 +02:00
savePulsePrefs ();
2026-07-17 04:00:38 +02:00
}
function clearFilters () {
state . filters = {
connected : false ,
powered : false ,
servers : true ,
idrac : false ,
hasPower : false ,
ghostLinks : false ,
pulseLinks : true ,
2026-07-18 21:56:30 +02:00
pulseSpeed : 0.45 ,
pulseLineColor : "#3dffe0" ,
pulseGlowColor : "#00a8e8" ,
pulsePacketColor : "#ffffff" ,
2026-07-17 04:00:38 +02:00
minWatts : 0 ,
search : "" ,
};
state . subnet = "all" ;
state . model = null ;
state . kpiFocus = null ;
state . groupHint = null ;
syncFilterInputs ();
refreshLists ();
layout ();
snapNodes ();
state . cam = { x : 0 , y : 0 , scale : 1 };
fitCameraToNodes ();
updateFocusContext ();
showToast ( "Filters reset · Servers on" );
}
function showToast ( msg , opts = {}) {
let el = $ ( "#toast" );
if ( ! el ) {
el = document . createElement ( "div" );
el . id = "toast" ;
el . className = "toast" ;
document . body . appendChild ( el );
}
el . textContent = msg ;
el . classList . toggle ( "toast-alert" , !! opts . alert );
el . classList . add ( "show" );
clearTimeout ( showToast . _t );
const ms = opts . ms != null ? opts . ms : opts . alert ? 8000 : 2200 ;
showToast . _t = setTimeout (() => el . classList . remove ( "show" ), ms );
}
function pushNotifyCard ( ev ) {
const host = $ ( "#notify-stack" );
if ( ! host ) return ;
const card = document . createElement ( "button" );
card . type = "button" ;
card . className = `notify-card ${ ev . severity || "info" } ` ;
card . dataset . deviceId = ev . device_id ?? "" ;
card . innerHTML = `
<span class="nc-kicker"> ${ escapeHtml ( ev . kind === "device_removed" ? "Removed" : "New on network" ) } </span>
<strong class="nc-title"> ${ escapeHtml ( ev . title || "Fleet change" ) } </strong>
<span class="nc-text"> ${ escapeHtml ( ev . text || "" ) } </span>
<span class="nc-meta"> ${ escapeHtml ( relTime ( ev . ts )) } </span>` ;
card . addEventListener ( "click" , () => {
if ( ev . device_id != null ) focusDeviceId ( ev . device_id );
card . remove ();
});
host . prepend ( card );
while ( host . children . length > 6 ) host . lastElementChild . remove ();
setTimeout (() => card . classList . add ( "show" ), 20 );
setTimeout (() => {
card . classList . remove ( "show" );
setTimeout (() => card . remove (), 400 );
}, 14000 );
}
function handleFleetNotifications ( data ) {
const notes = data ? . context ? . notifications || [];
const events = data ? . events || [];
const candidates = [
... notes ,
... events . filter (( e ) => e . kind === "device_new" || e . kind === "device_removed" ),
];
for ( const ev of candidates ) {
const key = ` ${ ev . kind } : ${ ev . device_id } : ${ Math . floor ( ev . ts || 0 ) } ` ;
if ( state . notifiedEventKeys . has ( key )) continue ;
state . notifiedEventKeys . add ( key );
if ( state . notifiedEventKeys . size > 200 ) {
state . notifiedEventKeys = new Set ([... state . notifiedEventKeys ]. slice ( - 100 ));
}
const role = ev . role || ( String ( ev . title || "" ). toLowerCase (). includes ( "idrac" ) ? "iDRAC" : "server" );
const msg =
ev . kind === "device_removed"
? ` ${ role } removed: ${ ev . name || ev . title || "device" } `
: `New ${ role } on network: ${ ev . name || ev . title || "device" } · ${ ev . ip || "" } ` . trim ();
showToast ( msg , { alert : true , ms : 9000 });
pushNotifyCard ( ev );
}
}
function hideTip () {
state . hoverId = null ;
const tip = $ ( "#node-tip" );
if ( tip ) tip . classList . add ( "hidden" );
}
function showTip ( node , clientX , clientY ) {
const tip = $ ( "#node-tip" );
if ( ! tip || ! node ) return ;
state . hoverId = node . id ;
const watts = node . watts != null ? ` ${ Math . round ( node . watts ) } W` : "no power sample" ;
tip . innerHTML = `
<p class="nt-name"> ${ escapeHtml ( node . name || "node" ) } </p>
2026-07-17 15:15:57 +02:00
<p class="nt-meta"> ${ escapeHtml ( node . model || "—" ) } · ST ${ escapeHtml ( node . service_tag || "—" ) } <br/>
2026-07-17 04:00:38 +02:00
${ node . connected ? "connected" : "offline" } · ${ node . powered_on ? "powered on" : "power n/a" } · ${ escapeHtml ( watts ) } <br/>
${ escapeHtml ( node . ip || "no IP" ) } · ${ escapeHtml ( node . subnet || "" ) } </p>
<p class="nt-hint">Double-click · Quick Connect</p>` ;
tip . classList . remove ( "hidden" );
const pad = 14 ;
let x = clientX + pad ;
let y = clientY + pad ;
const tw = tip . offsetWidth || 220 ;
const th = tip . offsetHeight || 100 ;
if ( x + tw > window . innerWidth - 8 ) x = clientX - tw - pad ;
if ( y + th > window . innerHeight - 8 ) y = clientY - th - pad ;
tip . style . left = ` ${ Math . max ( 8 , x ) } px` ;
tip . style . top = ` ${ Math . max ( 8 , y ) } px` ;
}
function relatedAlerts ( node ) {
const id = node ? . id ;
return ( state . data ? . alerts || []). filter (( a ) => a . device_id === id ). slice ( 0 , 3 );
}
function openConnect ( node ) {
if ( ! node || node . id == null ) return ;
state . connectNode = node ;
const modal = $ ( "#connect-modal" );
const scrim = $ ( "#scrim" );
if ( ! modal ) return ;
$ ( "#connect-title" ). textContent = node . name || "Device" ;
$ ( "#connect-sub" ). textContent = ` ${ node . model || "—" } · ${ node . service_tag || "no tag" } · OME # ${ node . id } ` ;
$ ( "#connect-badges" ). innerHTML = `
<span class="badge ${ node . connected ? "on" : "off" } "> ${ node . connected ? "CONNECTED" : "OFFLINE" } </span>
<span class="badge ${ node . powered_on ? "on" : "off" } "> ${ node . powered_on ? "POWERED ON" : "POWER N/A" } </span>
${ node . watts != null ? `<span class="badge power"> ${ Math . round ( node . watts ) } W</span>` : "" }
${ node . is_idrac ? `<span class="badge">iDRAC</span>` : "" }
${ node . is_server ? `<span class="badge">SERVER</span>` : "" } ` ;
const alerts = relatedAlerts ( node );
$ ( "#connect-context" ). innerHTML = `
<div class="modal-ctx-item"><span class="k">IP</span><span class="v"> ${ escapeHtml ( node . ip || "—" ) } </span></div>
<div class="modal-ctx-item"><span class="k">Subnet</span><span class="v"> ${ escapeHtml ( node . subnet || "—" ) } </span></div>
<div class="modal-ctx-item"><span class="k">Last status</span><span class="v"> ${ escapeHtml ( node . last_status_time || "—" ) } </span></div>
<div class="modal-ctx-item"><span class="k">Alerts</span><span class="v"> ${ alerts . length ? alerts [ 0 ]. severity + " · " + escapeHtml (( alerts [ 0 ]. message || "" ). slice ( 0 , 42 )) : "none in live feed" } </span></div>` ;
const ip = node . ip ;
const idrac = node . idrac_url || ( ip ? `https:// ${ ip } ` : null );
$ ( "#connect-grid" ). innerHTML = `
${ idrac ? `<a class="connect-tile primary conn-link" href=" ${ escapeAttr ( idrac ) } " target="_blank" rel="noopener">
<span class="ct-ico">▣</span>
<span class="ct-t">iDRAC Web</span>
<span class="ct-s">BMC console · ${ escapeHtml ( ip ) } </span>
</a>` : `<div class="connect-tile" style="opacity:.45"><span class="ct-t">iDRAC Web</span><span class="ct-s">No management IP</span></div>` }
${ ip ? `<button type="button" class="connect-tile conn-link" id="btn-modal-ssh">
<span class="ct-ico">〉_</span>
<span class="ct-t">SSH terminal</span>
<span class="ct-s">Login with your username · ${ escapeHtml ( ip ) } </span>
</button>` : "" }
2026-07-18 01:18:14 +02:00
${ ip ? `<button type="button" class="connect-tile conn-link" id="btn-modal-rdp">
<span class="ct-ico">▭</span>
<span class="ct-t">RDP popout</span>
<span class="ct-s"> ${ node . rdp_host ? `OS ${ escapeHtml ( node . rdp_host ) } ` : node . os_hostname ? `Resolve ${ escapeHtml ( node . os_hostname ) } ` : `Edit host · ${ escapeHtml ( ip ) } ` }${ node . is_windows ? " · Windows" : "" } </span>
</button>` : "" }
2026-07-17 04:00:38 +02:00
<button type="button" class="connect-tile" id="btn-modal-ai">
2026-07-18 01:18:14 +02:00
2026-07-17 04:00:38 +02:00
<span class="ct-ico">✦</span>
<span class="ct-t">Ask AI</span>
<span class="ct-s">OpenManage AI about this node</span>
</button>
<button type="button" class="connect-tile" id="btn-modal-inventory">
<span class="ct-ico">☰</span>
<span class="ct-t">Inspector</span>
<span class="ct-s">Load full inventory</span>
</button>` ;
modal . classList . remove ( "hidden" );
modal . setAttribute ( "aria-hidden" , "false" );
scrim ? . classList . add ( "open" );
scrim . dataset . mode = "connect" ;
$ ( "#connect-grid" ). onclick = async ( e ) => {
const copyBtn = e . target . closest ( "[data-copy]" );
if ( copyBtn ) {
try {
await navigator . clipboard . writeText ( copyBtn . dataset . copy );
showToast ( "SSH command copied" );
} catch ( _ ) {
showToast ( "Copy failed" );
}
return ;
}
if ( e . target . closest ( "#btn-modal-ssh" )) {
closeConnect ();
window . cockpitSsh ? . open ( node );
return ;
}
2026-07-18 01:18:14 +02:00
if ( e . target . closest ( "#btn-modal-rdp" )) {
closeConnect ();
window . cockpitRdp ? . open ( node );
return ;
}
2026-07-17 04:00:38 +02:00
if ( e . target . closest ( "#btn-modal-ai" )) {
closeConnect ();
openAi ( node );
return ;
}
if ( e . target . closest ( "#btn-modal-inventory" )) {
closeConnect ();
showInspector ( node );
setTimeout (() => $ ( "#btn-detail" ) ? . click (), 50 );
}
};
}
function closeConnect () {
const modal = $ ( "#connect-modal" );
modal ? . classList . add ( "hidden" );
modal ? . setAttribute ( "aria-hidden" , "true" );
state . connectNode = null ;
const scrim = $ ( "#scrim" );
if ( scrim ? . dataset . mode === "connect" ) {
scrim . classList . remove ( "open" );
delete scrim . dataset . mode ;
}
}
function softRefreshInspector ( node ) {
if ( ! node ) return ;
const body = $ ( "#inspector-body" );
if ( ! body || body . classList . contains ( "hidden" )) {
showInspector ( node );
return ;
}
// Same node already open: update live fields only — keep inventory panel.
if ( state . selectedId !== node . id ) {
showInspector ( node );
return ;
}
const head = body . querySelector ( ".insp-head h2" );
if ( head && head . textContent !== ( node . name || "" )) {
showInspector ( node );
return ;
}
const badges = body . querySelector ( ".badge-row" );
if ( badges ) {
badges . innerHTML = `
<span class="badge ${ node . connected ? "on" : "off" } "> ${ node . connected ? "CONNECTED" : "OFFLINE" } </span>
<span class="badge ${ node . powered_on ? "on" : "off" } "> ${ node . powered_on ? "POWERED ON" : "POWER OFF / N/A" } </span>
${ node . watts != null ? `<span class="badge power"> ${ Math . round ( node . watts ) } W</span>` : "" }
${ node . is_idrac ? `<span class="badge">iDRAC</span>` : "" }
${ node . is_server ? `<span class="badge">SERVER</span>` : "" } ` ;
}
const setKv = ( label , value ) => {
for ( const row of body . querySelectorAll ( ".kv-row" )) {
const k = row . querySelector ( ".k" );
const v = row . querySelector ( ".v" );
if ( k && v && k . textContent === label ) {
if ( label === "Subnet" ) return ; // keep jump button
v . textContent = value ;
}
}
};
setKv ( "IP" , node . ip || "—" );
setKv ( "Status" , node . status || "—" );
setKv ( "Avg W" , node . avg_watts != null ? Math . round ( node . avg_watts ) + " W" : "—" );
setKv ( "Peak W" , node . peak_watts != null ? Math . round ( node . peak_watts ) + " W" : "—" );
setKv ( "Energy" , node . energy_kwh != null ? node . energy_kwh + " kWh" : "—" );
setKv ( "Last status" , node . last_status_time || "—" );
setKv ( "Inventory" , node . last_inventory_time || "—" );
updateFocusContext ();
}
2026-07-18 01:18:14 +02:00
async function loadExpansionPreview ( deviceId ) {
const mount = $ ( "#warranty-compliance-mount" );
if ( ! mount ) return ;
try {
const r = await fetch ( `/api/devices/ ${ deviceId } /expansion` );
if ( ! r . ok ) return ;
const data = await r . json ();
const x = data . expansion || {};
const mem = x . memory || {};
const spec = x . spec || {};
const findings = ( x . findings || []). filter (( f ) => f . severity === "warning" ). slice ( 0 , 2 );
const box = document . createElement ( "div" );
box . className = "inv-section" ;
box . innerHTML = `<h3>Memory / expansion</h3>
<div class="inv-line"> ${ mem . total_gb ?? "—" } GB in ${ mem . populated ?? 0 } DIMMs · ${ mem . empty ?? 0 } empty slots · Dell max ${ spec . max_memory_tb != null ? spec . max_memory_tb + " TB" : "—" } </div>
<div class="inv-line">Disks ${ x . disks ? . installed ?? "—" } / ${ spec . drive_bays_max ?? "—" } bays · PCIe max ${ spec . pcie_slots_max ?? "—" } </div>
${ findings . map (( f ) => `<div class="inv-line"> ${ escapeHtml ( f . text ) } </div>` ). join ( "" ) }
<div class="inv-line"><button type="button" class="btn compact" id="btn-open-hw">Open full DIMM map & Dell guidance</button></div>` ;
mount . appendChild ( box );
$ ( "#btn-open-hw" ) ? . addEventListener ( "click" , () => {
window . cockpitReports ? . open ? .();
// slight delay for drawer open
setTimeout (() => {
if ( window . cockpitReports ) {
window . cockpitReports . state = window . cockpitReports . state || {};
}
const st = document . querySelector ( '#reports-tabs [data-tab="hardware"]' );
st ? . click ();
// set device after tab loads
setTimeout (() => {
const btn = document . querySelector ( `[data-hw-id=" ${ deviceId } "]` );
btn ? . click ();
}, 400 );
}, 200 );
});
} catch ( _ ) {
/* ignore */
}
}
async function loadWarrantyCompliance ( deviceId ) {
2026-07-17 15:15:57 +02:00
const mount = $ ( "#warranty-compliance-mount" );
if ( ! mount ) return ;
try {
const [ wRes , cRes ] = await Promise . all ([
fetch ( `/api/devices/ ${ deviceId } /warranty` ),
fetch ( `/api/devices/ ${ deviceId } /compliance` ),
]);
const w = wRes . ok ? await wRes . json () : { items : [] };
const c = cRes . ok ? await cRes . json () : { components : [] };
const primary = w . primary ;
const comps = ( c . components || []). filter (
( x ) =>
String ( x . update_action || "" ). toUpperCase () === "UPGRADE" ||
String ( x . compliance_status || "" ). toUpperCase () === "CRITICAL"
);
let html = `<div class="inv-section"><h3>Warranty</h3>` ;
if ( primary ) {
html += `<div class="inv-line">ST ${ escapeHtml ( primary . service_tag || "—" ) } · ${ escapeHtml ( primary . service_level || "—" ) } </div>
<div class="inv-line">Ends ${ escapeHtml ( primary . end_date || "—" ) } · ${ primary . days_remaining ?? "—" } days left</div>` ;
} else if (( w . items || []). length ) {
html += ( w . items || [])
. slice ( 0 , 3 )
. map (
( x ) =>
`<div class="inv-line"> ${ escapeHtml ( x . service_level || "—" ) } · ends ${ escapeHtml ( x . end_date || "—" ) } · ${ x . days_remaining ?? "—" } d</div>`
)
. join ( "" );
} else {
html += `<div class="inv-line">No warranty records in OME</div>` ;
}
html += `</div><div class="inv-section"><h3>Dell catalog compliance</h3>` ;
if ( c . device ) {
html += `<div class="inv-line"> ${ escapeHtml ( c . device . compliance_status || c . device . firmware_status || "—" ) } · baseline ${ escapeHtml (( c . baseline || { } ).name || "—")}</div>` ;
}
if ( comps . length ) {
html += comps
. slice ( 0 , 8 )
. map (
( x ) =>
`<div class="inv-line"> ${ escapeHtml ( x . component || "component" ) } : ${ escapeHtml ( x . current_version || "?" ) } → ${ escapeHtml ( x . catalog_version || "?" ) } ${ x . dell_uri ? `<a href=" ${ escapeAttr ( x . dell_uri ) } " target="_blank" rel="noopener">dell.com</a>` : "" } </div>`
)
. join ( "" );
} else if ( c . device ) {
html += `<div class="inv-line">No upgrade components in baseline report</div>` ;
} else {
html += `<div class="inv-line">Not in active Dell baseline report</div>` ;
}
html += `</div>` ;
mount . innerHTML = html ;
} catch ( err ) {
mount . innerHTML = `<p class="hint">Warranty/compliance unavailable</p>` ;
}
}
2026-07-18 21:56:30 +02:00
function setInvOpen ( on ) {
document . body . classList . toggle ( "inv-open" , !! on );
}
function wrapInvHtml ( html ) {
return `<div class="inv-panel">
<div class="inv-panel-head">
<strong>Full inventory</strong>
<span>Theme toggle parked left while reading</span>
</div>
${ html }
</div>` ;
}
2026-07-17 15:15:57 +02:00
async function showInspector ( node ) {
2026-07-17 04:00:38 +02:00
state . selectedId = node ? . id ?? null ;
2026-07-18 21:56:30 +02:00
setInvOpen ( false );
2026-07-17 04:00:38 +02:00
const empty = $ ( "#inspector-empty" );
const body = $ ( "#inspector-body" );
if ( ! node ) {
empty . classList . remove ( "hidden" );
body . classList . add ( "hidden" );
return ;
}
empty . classList . add ( "hidden" );
body . classList . remove ( "hidden" );
body . innerHTML = `
<div class="insp-head">
<h2> ${ escapeHtml ( node . name ) } </h2>
2026-07-17 15:15:57 +02:00
<p class="meta"> ${ escapeHtml ( node . model || "—" ) } · id ${ node . id } </p>
2026-07-17 04:00:38 +02:00
</div>
<div class="badge-row">
<span class="badge ${ node . connected ? "on" : "off" } "> ${ node . connected ? "CONNECTED" : "OFFLINE" } </span>
<span class="badge ${ node . powered_on ? "on" : "off" } "> ${ node . powered_on ? "POWERED ON" : "POWER OFF / N/A" } </span>
${ node . watts != null ? `<span class="badge power"> ${ Math . round ( node . watts ) } W</span>` : "" }
${ node . is_idrac ? `<span class="badge">iDRAC</span>` : "" }
${ node . is_server ? `<span class="badge">SERVER</span>` : "" }
</div>
<div class="kv">
2026-07-17 15:15:57 +02:00
<div class="kv-row st-row"><span class="k">Service Tag</span><span class="v st-value"><code> ${ escapeHtml ( node . service_tag || "—" ) } </code>
${ node . service_tag ? `<button type="button" class="btn ghost compact" id="btn-copy-st" title="Copy Service Tag">Copy</button>` : "" }
</span></div>
2026-07-17 04:00:38 +02:00
<div class="kv-row"><span class="k">IP</span><span class="v"> ${ escapeHtml ( node . ip || "—" ) } </span></div>
<div class="kv-row"><span class="k">Subnet</span><span class="v"><button type="button" class="linkish" data-jump-subnet=" ${ escapeAttr ( node . subnet ) } "> ${ escapeHtml ( node . subnet ) } </button></span></div>
2026-07-17 15:15:57 +02:00
<div class="kv-row"><span class="k">Model</span><span class="v"> ${ escapeHtml ( node . model || "—" ) } </span></div>
2026-07-17 04:00:38 +02:00
<div class="kv-row"><span class="k">Status</span><span class="v"> ${ escapeHtml ( node . status ) } </span></div>
<div class="kv-row"><span class="k">Avg W</span><span class="v"> ${ node . avg_watts != null ? Math . round ( node . avg_watts ) + " W" : "—" } </span></div>
<div class="kv-row"><span class="k">Peak W</span><span class="v"> ${ node . peak_watts != null ? Math . round ( node . peak_watts ) + " W" : "—" } </span></div>
<div class="kv-row"><span class="k">Energy</span><span class="v"> ${ node . energy_kwh != null ? node . energy_kwh + " kWh" : "—" } </span></div>
<div class="kv-row"><span class="k">OME id</span><span class="v"> ${ node . id } </span></div>
<div class="kv-row"><span class="k">Last status</span><span class="v"> ${ escapeHtml ( node . last_status_time || "—" ) } </span></div>
<div class="kv-row"><span class="k">Inventory</span><span class="v"> ${ escapeHtml ( node . last_inventory_time || "—" ) } </span></div>
</div>
2026-07-17 15:15:57 +02:00
<div id="warranty-compliance-mount" class="wc-mount"><p class="hint">Loading warranty & Dell compliance…</p></div>
2026-07-17 04:00:38 +02:00
<div class="action-stack">
<button type="button" class="btn primary" id="btn-quick-connect">Quick Connect · iDRAC / SSH</button>
${ node . idrac_url ? `<a class="btn conn-link" href=" ${ escapeAttr ( node . idrac_url ) } " target="_blank" rel="noopener">iDRAC Web</a>` : "" }
${ node . ip ? `<button type="button" class="btn conn-link" id="btn-ssh-term">SSH terminal · ${ escapeHtml ( node . ip ) } </button>` : "" }
2026-07-18 01:18:14 +02:00
${ node . ip ? `<button type="button" class="btn conn-link" id="btn-rdp-term">RDP popout · ${ escapeHtml ( node . rdp_host || node . os_hostname || node . ip ) } </button>` : "" }
2026-07-17 04:00:38 +02:00
<button type="button" class="btn" id="btn-detail">Load full inventory + app landscape</button>
<button type="button" class="btn" id="btn-ask-ai">Ask OpenManage AI</button>
<button type="button" class="btn ghost" id="btn-focus-model">Filter this model</button>
</div>
<div id="inv-mount"></div>
` ;
body . querySelector ( "[data-jump-subnet]" ) ? . addEventListener ( "click" , ( e ) => {
state . subnet = e . currentTarget . dataset . jumpSubnet ;
refreshLists ();
layout ();
});
$ ( "#btn-focus-model" ) ? . addEventListener ( "click" , () => {
state . model = node . model ;
refreshLists ();
layout ();
});
2026-07-17 15:15:57 +02:00
$ ( "#btn-copy-st" ) ? . addEventListener ( "click" , async () => {
try {
await navigator . clipboard . writeText ( node . service_tag );
const btn = $ ( "#btn-copy-st" );
if ( btn ) {
btn . textContent = "Copied" ;
setTimeout (() => {
btn . textContent = "Copy" ;
}, 1200 );
}
} catch ( _ ) {
/* ignore */
}
});
2026-07-17 04:00:38 +02:00
updateFocusContext ();
$ ( "#btn-quick-connect" ) ? . addEventListener ( "click" , () => openConnect ( node ));
$ ( "#btn-ssh-term" ) ? . addEventListener ( "click" , () => window . cockpitSsh ? . open ( node ));
2026-07-18 01:18:14 +02:00
$ ( "#btn-rdp-term" ) ? . addEventListener ( "click" , () => window . cockpitRdp ? . open ( node ));
2026-07-17 04:00:38 +02:00
$ ( "#btn-ask-ai" ) ? . addEventListener ( "click" , () => openAi ( node ));
2026-07-17 15:15:57 +02:00
loadWarrantyCompliance ( node . id );
2026-07-18 01:18:14 +02:00
loadExpansionPreview ( node . id );
2026-07-17 04:00:38 +02:00
const cachedInv = state . inventoryCache [ node . id ];
if ( cachedInv ) {
const mount = $ ( "#inv-mount" );
2026-07-18 21:56:30 +02:00
if ( mount ) {
mount . innerHTML = cachedInv . includes ( "inv-panel" ) ? cachedInv : wrapInvHtml ( cachedInv );
setInvOpen ( true );
}
2026-07-17 04:00:38 +02:00
}
$ ( "#btn-detail" ) ? . addEventListener ( "click" , async () => {
const mount = $ ( "#inv-mount" );
const deviceId = node . id ;
state . inventoryLoadingId = deviceId ;
2026-07-18 21:56:30 +02:00
setInvOpen ( true );
mount . innerHTML = `<div class="inv-panel"><p class="hint">Loading full inventory + application landscape…</p></div>` ;
2026-07-17 04:00:38 +02:00
try {
const r = await fetch ( `/api/devices/ ${ deviceId } ` );
const detail = await r . json ();
const inv = detail . inventory || {};
const land = detail . landscape || {};
const p = detail . power || {};
const line = ( s ) => `<div class="inv-line"> ${ escapeHtml ( s ) } </div>` ;
const section = ( title , rows ) => {
if ( ! rows || ! rows . length ) return "" ;
return `<div class="inv-section"><h3> ${ escapeHtml ( title ) } ( ${ rows . length } )</h3> ${ rows . join ( "" ) } </div>` ;
};
let html = `<div class="inv-section"><h3>Power (live)</h3>
<div class="inv-line">Instant ${ p . watts ?? "—" } W · avg ${ p . avg_watts ?? "—" } · peak ${ p . peak_watts ?? "—" } </div>
<div class="inv-line">Energy ${ p . energy_kwh ?? "—" } kWh</div></div>` ;
// Application / firmware landscape first
const osRows = ( land . os || []). map (( x ) =>
line ( ` ${ x . OsName || x . OperatingSystemName || "OS" } · ${ x . OsVersion || "" } · host ${ x . Hostname || "—" } ` )
);
html += section ( "Operating system" , osRows );
const soft = ( items , title ) => {
const rows = ( items || []). slice ( 0 , 40 ). map (( x ) =>
line (
` ${ x . DeviceDescription || x . SoftwareType || x . Name || "component" } · v ${ x . Version || "?" } · ${ x . Status || "" } · ${ x . InstallationDate || "" } ` . trim ()
)
);
return section ( title , rows );
};
html += soft ( land . firmware , "Firmware landscape" );
html += soft ( land . drivers , "Drivers" );
html += soft ( land . applications , "Software / apps" );
if ( ! ( land . firmware || []). length && ( land . software || []). length ) {
html += soft ( land . software , "Software inventory" );
}
const mgmtRows = ( land . management || []). map (( x ) => {
const agents = ( x . EndPointAgents || [])
. map (( a ) => a . AgentName || a . ManagementProfile || a . AgentType || "" )
. filter ( Boolean )
. join ( ", " );
return line (
` ${ x . DnsName || x . InstrumentationName || "mgmt" } · ${ x . IpAddress || "" } · MAC ${ x . MacAddress || "—" } ` +
( agents ? ` · agents: ${ agents } ` : "" )
);
});
html += section ( "Management / agents" , mgmtRows );
const licRows = ( land . licenses || []). slice ( 0 , 20 ). map (( x ) =>
line ( ` ${ x . LicenseDescription || x . EntitlementId || x . LicenseType || "license" } · ${ x . LicenseStatus || x . Status || "" } ` )
);
html += section ( "Licenses" , licRows );
const sections = [
[ "serverProcessors" , "CPUs" , ( x ) => ` ${ x . ModelName || x . BrandName || x . Manufacturer || "CPU" } · ${ x . CurrentSpeed || "" } MHz · ${ x . NumberOfCores || "?" } cores · ${ x . Status || "" } ` ],
[ "serverMemoryDevices" , "Memory" , ( x ) => ` ${ x . Name || "DIMM" } · ${ x . Size || "?" } · ${ x . Speed || x . CurrentOperatingSpeed || "" } · ${ x . Manufacturer || "" } · ${ x . SerialNumber || "" } ` ],
[ "serverArrayDisks" , "Disks" , ( x ) => ` ${ x . ModelNumber || x . SerialNumber || "disk" } · ${ x . MediaType || "" } · ${ x . Size || x . Capacity || "?" } · ${ x . StatusString || x . Status || "" } ` ],
[ "serverRaidControllers" , "RAID" , ( x ) => ` ${ x . Name || "RAID" } · FW ${ x . FirmwareVersion || "?" } · cache ${ x . CacheSizeInMb || "?" } MB · ${ x . Status || "" } ` ],
[ "serverPowerSupplies" , "PSUs" , ( x ) => ` ${ x . Name || x . Model || "PSU" } · ${ x . OutputWatts || "?" } W · ${ x . FirmwareVersion || "" } · ${ x . Status || "" } ` ],
[ "serverNetworkInterfaces" , "NICs" , ( x ) => {
const ports = ( x . Ports || []). map (( p ) => p . ProductName || p . PermanentMACAddress || p . MacAddress || "" ). filter ( Boolean ). slice ( 0 , 4 ). join ( " | " );
return ` ${ x . ProductName || x . VendorName || "NIC" } · ${ ports || x . PermanentMACAddress || "" } ` ;
}],
[ "serverFcCards" , "FC HBAs" , ( x ) => ` ${ x . ProductName || x . VendorName || "FC" } · ${ x . WWN || x . PortName || "" } ` ],
[ "serverDellVideos" , "GPUs / video" , ( x ) => ` ${ x . ProductName || x . Description || "video" } · ${ x . Manufacturer || "" } ` ],
[ "serverDeviceCards" , "Device cards" , ( x ) => ` ${ x . ProductName || x . Description || x . SlotName || "card" } · ${ x . Manufacturer || "" } ` ],
[ "deviceBaseboards" , "Baseboard" , ( x ) => ` ${ x . Manufacturer || "" } · ${ x . ProductName || x . Model || "" } · SN ${ x . SerialNumber || "—" } ` ],
[ "deviceFru" , "FRU" , ( x ) => ` ${ x . Name || "FRU" } · ${ x . Manufacturer || "" } · PN ${ x . PartNumber || "" } · SN ${ x . SerialNumber || "" } ` ],
[ "deviceLocation" , "Location" , ( x ) => ` ${ x . Datacenter || "" } ${ x . Room || "" } ${ x . Aisle || "" } ${ x . Rack || "" } U ${ x . Rackslot || x . RackSlot || "" } ` . trim () || JSON . stringify ( x ). slice ( 0 , 80 )],
[ "subsystemRollupStatus" , "Health rollup" , ( x ) => ` ${ x . SubsystemName || x . Type || "sub" } · ${ x . Status || "" } ` ],
[ "serverStorageEnclosures" , "Storage enclosures" , ( x ) => ` ${ x . Name || x . ProductName || "enclosure" } · ${ x . Status || "" } ` ],
[ "serverVirtualFlashes" , "Virtual flash" , ( x ) => ` ${ x . Name || "vFlash" } · ${ x . Capacity || x . Size || "" } · ${ x . Status || "" } ` ],
[ "serverBiosSystemProfileSettings" , "BIOS profile" , ( x ) => Object . entries ( x ). slice ( 0 , 6 ). map (([ k , v ]) => ` ${ k } = ${ v } ` ). join ( " · " )],
[ "deviceCapabilities" , "Capabilities" , ( x ) => `CapabilityType ${ x . CapabilityType ?? x . Id ?? "?" } ` ],
[ "serverSupportedPowerStates" , "Power states" , ( x ) => `PowerState ${ x . PowerState ?? x . Id ?? "?" } ` ],
];
for ( const [ key , title , fmt ] of sections ) {
const rows = inv [ key ] || [];
if ( ! rows . length ) continue ;
html += section (
title ,
rows . slice ( 0 , 24 ). map (( row ) => line ( fmt ( row )))
);
}
// Any remaining inventory keys not already shown
const shown = new Set ( sections . map (( s ) => s [ 0 ]). concat ([
"deviceSoftware" , "serverOperatingSystems" , "deviceManagement" , "deviceLicense" ,
]));
for ( const [ key , rows ] of Object . entries ( inv )) {
if ( shown . has ( key ) || ! Array . isArray ( rows ) || ! rows . length ) continue ;
html += section (
key ,
rows . slice ( 0 , 12 ). map (( row ) =>
line (
typeof row === "object"
? Object . entries ( row ). slice ( 0 , 6 ). map (([ k , v ]) => ` ${ k } = ${ v } ` ). join ( " · " )
: String ( row )
)
)
);
}
2026-07-18 21:56:30 +02:00
html = wrapInvHtml ( html || `<p class="hint">No inventory available</p>` );
2026-07-17 04:00:38 +02:00
state . inventoryCache [ deviceId ] = html ;
if ( state . selectedId === deviceId ) {
const live = $ ( "#inv-mount" );
if ( live ) live . innerHTML = html ;
2026-07-18 21:56:30 +02:00
setInvOpen ( true );
2026-07-17 04:00:38 +02:00
}
} catch ( e ) {
2026-07-18 21:56:30 +02:00
const errHtml = wrapInvHtml ( `<p class="hint">Inventory failed: ${ escapeHtml ( e . message ) } </p>` );
2026-07-17 04:00:38 +02:00
if ( state . selectedId === deviceId ) {
const live = $ ( "#inv-mount" );
if ( live ) live . innerHTML = errHtml ;
2026-07-18 21:56:30 +02:00
setInvOpen ( true );
2026-07-17 04:00:38 +02:00
}
} finally {
if ( state . inventoryLoadingId === deviceId ) state . inventoryLoadingId = null ;
}
});
}
function showHubInspector () {
const ome = state . data ? . ome || {};
const s = state . data ? . summary || {};
state . selectedId = null ;
2026-07-18 21:56:30 +02:00
setInvOpen ( false );
2026-07-17 04:00:38 +02:00
$ ( "#inspector-empty" ). classList . add ( "hidden" );
const body = $ ( "#inspector-body" );
body . classList . remove ( "hidden" );
body . innerHTML = `
<div class="insp-head">
<h2> ${ escapeHtml ( ome . name || "OpenManage Enterprise" ) } </h2>
<p class="meta">v ${ escapeHtml ( ome . version || "?" ) } · build ${ escapeHtml ( String ( ome . build || "?" )) } </p>
</div>
<div class="badge-row">
<span class="badge on">HUB</span>
<span class="badge power"> ${ s . total_watts != null ? Math . round ( s . total_watts ) + " W fleet" : "power…" } </span>
</div>
<div class="kv">
<div class="kv-row"><span class="k">FQDN</span><span class="v"> ${ escapeHtml ( ome . fqdn || "—" ) } </span></div>
<div class="kv-row"><span class="k">API</span><span class="v"> ${ escapeHtml ( ome . url || "—" ) } </span></div>
<div class="kv-row"><span class="k">Servers</span><span class="v"> ${ s . servers ?? "—" } </span></div>
<div class="kv-row"><span class="k">Connected</span><span class="v"> ${ s . connected ?? "—" } </span></div>
<div class="kv-row"><span class="k">Power samples</span><span class="v"> ${ s . power_samples ?? "—" } </span></div>
</div>
<div class="action-stack">
<a class="btn primary" href=" ${ escapeAttr ( ome . console_url || ome . url || "#" ) } " target="_blank" rel="noopener">Open OME console</a>
<button type="button" class="btn" id="btn-ai-hub">Ask OpenManage AI about fleet</button>
</div>` ;
$ ( "#btn-ai-hub" ) ? . addEventListener ( "click" , () => openAi ( null ));
}
function openAi ( node ) {
const url = state . data ? . openwebui_url || "http://atc-portal01.dell-atc.lan:3080" ;
$ ( "#ai-frame" ). src = url ;
$ ( "#ai-drawer" ). classList . add ( "open" );
$ ( "#ai-drawer" ). setAttribute ( "aria-hidden" , "false" );
const scrim = $ ( "#scrim" );
scrim ? . classList . add ( "open" );
if ( scrim ) scrim . dataset . mode = "ai" ;
if ( node ) {
console . info ( "Ask AI about" , node . name , node . service_tag , node . watts );
}
}
function closeAi () {
$ ( "#ai-drawer" ). classList . remove ( "open" );
$ ( "#ai-drawer" ). setAttribute ( "aria-hidden" , "true" );
const scrim = $ ( "#scrim" );
if ( scrim ? . dataset . mode === "ai" || ! scrim ? . dataset . mode ) {
scrim ? . classList . remove ( "open" );
if ( scrim ) delete scrim . dataset . mode ;
}
}
function refreshLists () {
renderKpis ();
renderSubnets ();
renderModels ();
renderGroups ();
renderLegend ();
renderTicker ();
renderContext ();
const ome = state . data ? . ome ;
if ( ome ) {
$ ( "#ome-meta" ). textContent = `OM Enterprise ${ ome . version || "?" } · build ${ ome . build || "?" } · live` ;
}
}
function applySnapshot ( data ) {
state . data = data ;
handleFleetNotifications ( data );
try {
window . dispatchEvent ( new CustomEvent ( "cockpit-snapshot" , { detail : data }));
if ( data . gpu ) window . dispatchEvent ( new CustomEvent ( "cockpit-gpu" , { detail : data . gpu }));
} catch ( _ ) {}
// warm color maps
for ( const d of data . devices || []) {
colorFor ( state . modelColors , d . model || "?" );
colorFor ( state . subnetColors , d . subnet || "?" );
}
refreshLists ();
layout ();
if ( state . selectedId ) {
const n = ( data . devices || []). find (( x ) => x . id === state . selectedId );
if ( n ) softRefreshInspector ( n );
}
}
// events
$ ( "#layout-modes" ) ? . addEventListener ( "click" , ( e ) => {
const btn = e . target . closest ( "[data-layout]" );
if ( ! btn ) return ;
state . layoutMode = btn . dataset . layout ;
$ ( "#layout-modes" ). querySelectorAll ( ".viz-card" ). forEach (( c ) => c . classList . toggle ( "active" , c === btn ));
layout ();
snapNodes ();
state . cam = { x : 0 , y : 0 , scale : 1 };
fitCameraToNodes ();
updateFocusContext ();
});
$ ( "#view-modes" ). addEventListener ( "click" , ( e ) => {
const btn = e . target . closest ( "[data-mode]" );
if ( ! btn ) return ;
state . viewMode = btn . dataset . mode ;
$ ( "#view-modes" ). querySelectorAll ( ".chip" ). forEach (( c ) => c . classList . toggle ( "active" , c === btn ));
renderLegend ();
layout ();
});
$ ( "#subnet-list" ). addEventListener ( "click" , ( e ) => {
const btn = e . target . closest ( "[data-subnet]" );
if ( ! btn ) return ;
state . subnet = btn . dataset . subnet ;
state . kpiFocus = null ;
refreshLists ();
layout ();
});
$ ( "#model-list" ). addEventListener ( "click" , ( e ) => {
const btn = e . target . closest ( "[data-model]" );
if ( ! btn ) return ;
state . model = btn . dataset . model || null ;
refreshLists ();
layout ();
});
$ ( "#group-list" ). addEventListener ( "click" , ( e ) => {
const btn = e . target . closest ( "[data-group]" );
if ( ! btn ) return ;
state . filters . search = btn . dataset . group || "" ;
$ ( "#search" ). value = state . filters . search ;
state . groupHint = btn . dataset . group ;
showInspector ({
id : null ,
name : `Group: ${ btn . dataset . group } ` ,
model : "OME Group" ,
service_tag : "—" ,
connected : true ,
powered_on : false ,
watts : null ,
avg_watts : null ,
peak_watts : null ,
energy_kwh : null ,
ip : null ,
subnet : "—" ,
status : "group" ,
is_idrac : false ,
is_server : false ,
idrac_url : null ,
});
// note: OME groups aren't device-membership expanded here — search/filter by name hint
layout ();
});
$ ( "#kpi-strip" ). addEventListener ( "click" , ( e ) => {
const btn = e . target . closest ( "[data-kpi]" );
if ( ! btn ) return ;
const key = btn . dataset . kpi ;
openKpiPopup ( key );
// Keep strip highlight in sync with open context
state . kpiFocus = key ;
renderKpis ();
});
$ ( "#btn-kpi-close" ) ? . addEventListener ( "click" , closeKpiPopup );
$ ( "#kpi-search" ) ? . addEventListener ( "input" , ( e ) => {
kpiPopup . q = e . target . value || "" ;
renderKpiPopup ();
});
$ ( "#kpi-sort" ) ? . addEventListener ( "change" , ( e ) => {
kpiPopup . sort = e . target . value || "name" ;
renderKpiPopup ();
});
$ ( "#btn-kpi-apply" ) ? . addEventListener ( "click" , () => {
if ( kpiPopup . key ) applyKpiAsFilter ( kpiPopup . key );
});
$ ( "#btn-kpi-clear" ) ? . addEventListener ( "click" , () => {
state . kpiFocus = null ;
state . filters . connected = false ;
state . filters . powered = false ;
state . filters . hasPower = false ;
state . filters . servers = true ;
state . filters . idrac = false ;
syncFilterInputs ();
refreshLists ();
layout ();
snapNodes ();
fitCameraToNodes ();
updateFocusContext ();
renderKpis ();
showToast ( "Map KPI filter cleared" );
});
$ ( "#kpi-list" ) ? . addEventListener ( "click" , ( e ) => {
const btn = e . target . closest ( "[data-kpi-device]" );
if ( ! btn ) return ;
kpiPopup . selectedId = Number ( btn . dataset . kpiDevice );
renderKpiPopup ();
});
$ ( "#kpi-detail" ) ? . addEventListener ( "click" , ( e ) => {
const act = e . target . closest ( "[data-kpi-act]" );
if ( ! act ) return ;
const node = ( state . data ? . devices || []). find (( d ) => d . id === kpiPopup . selectedId );
if ( ! node ) return ;
const a = act . dataset . kpiAct ;
if ( a === "focus" ) {
closeKpiPopup ();
focusDeviceId ( node . id );
showToast ( "Focused " + ( node . name || "" ));
} else if ( a === "ssh" ) {
window . cockpitSsh ? . open ( node );
2026-07-18 01:18:14 +02:00
} else if ( a === "rdp" ) {
window . cockpitRdp ? . open ( node );
2026-07-17 04:00:38 +02:00
} else if ( a === "connect" ) {
closeKpiPopup ();
openConnect ( node );
} else if ( a === "inventory" ) {
closeKpiPopup ();
showInspector ( node );
setTimeout (() => $ ( "#btn-detail" ) ? . click (), 60 );
} else if ( a === "chat" ) {
closeKpiPopup ();
document . getElementById ( "btn-chat" ) ? . click ();
setTimeout (() => {
const input = document . getElementById ( "chat-input" );
if ( input ) {
input . value = `Give operational context for ${ node . name } ( ${ node . ip || "no IP" } ). Status ${ node . status } , connected= ${ node . connected } , watts= ${ node . watts ?? "n/a" } . Suggest next actions for ATC admins.` ;
input . focus ();
}
}, 150 );
}
});
$ ( "#filters" ). addEventListener ( "change" , ( e ) => {
const t = e . target ;
readFiltersFromDom ();
if ( t . id === "f-min-watts" ) {
$ ( "#min-w-label" ). textContent = String ( state . filters . minWatts );
}
2026-07-18 21:56:30 +02:00
if ( t . id === "f-pulse-speed" ) {
const pLab = $ ( "#pulse-speed-label" );
if ( pLab ) pLab . textContent = ` ${ Number ( state . filters . pulseSpeed ). toFixed ( 2 ) } × `;
showToast ( `Pulse speed ${ Number ( state . filters . pulseSpeed ). toFixed ( 2 ) } × `);
return ;
}
2026-07-17 04:00:38 +02:00
// Pulse-only toggle: no need to relayout
if ( t . id === "f-pulse-links" ) {
updateFocusContext ();
showToast ( state . filters . pulseLinks ? "Link pulse on" : "Link pulse off" );
return ;
}
layout ();
snapNodes ();
fitCameraToNodes ();
renderTicker ();
updateFocusContext ();
if ( ! state . filters . servers && ! state . filters . idrac ) {
showToast ( "Enable Servers and/or iDRACs to show nodes" );
}
});
2026-07-18 21:56:30 +02:00
$ ( "#filters" ). addEventListener ( "input" , ( e ) => {
if ( e . target ? . id !== "f-pulse-speed" ) return ;
readFiltersFromDom ();
const pLab = $ ( "#pulse-speed-label" );
if ( pLab ) pLab . textContent = ` ${ Number ( state . filters . pulseSpeed ). toFixed ( 2 ) } × `;
});
function togglePulsePop ( force ) {
const pop = $ ( "#pulse-pop" );
const btn = $ ( "#btn-pulse-settings" );
const block = document . querySelector ( '.rail-block[data-module="filters"]' );
if ( ! pop || ! btn ) return ;
const open = force != null ? !! force : pop . hidden ;
pop . hidden = ! open ;
btn . setAttribute ( "aria-expanded" , open ? "true" : "false" );
block ? . classList . toggle ( "pulse-open" , open );
if ( open ) {
const ps = $ ( "#f-pulse-speed" );
if ( ps ) ps . value = String ( state . filters . pulseSpeed ?? 0.45 );
const pLab = $ ( "#pulse-speed-label" );
if ( pLab ) pLab . textContent = ` ${ Number ( state . filters . pulseSpeed ?? 0.45 ). toFixed ( 2 ) } × `;
const line = $ ( "#f-pulse-line-color" );
const glow = $ ( "#f-pulse-glow-color" );
const packet = $ ( "#f-pulse-packet-color" );
if ( line ) line . value = state . filters . pulseLineColor || "#3dffe0" ;
if ( glow ) glow . value = state . filters . pulseGlowColor || "#00a8e8" ;
if ( packet ) packet . value = state . filters . pulsePacketColor || "#ffffff" ;
}
}
$ ( "#btn-pulse-settings" ) ? . addEventListener ( "click" , ( e ) => {
e . preventDefault ();
e . stopPropagation ();
togglePulsePop ();
});
const PULSE_THEMES = {
cyan : { line : "#3dffe0" , glow : "#00a8e8" , packet : "#ffffff" },
orange : { line : "#ff9a3c" , glow : "#ffb040" , packet : "#fff4e5" },
green : { line : "#3dffa0" , glow : "#1b7a4a" , packet : "#e8fff4" },
purple : { line : "#c4a7ff" , glow : "#7b6cf0" , packet : "#f4efff" },
pink : { line : "#f472b6" , glow : "#db2777" , packet : "#fff0f7" },
gold : { line : "#f0e68c" , glow : "#d4af37" , packet : "#fffceb" },
};
$ ( "#pulse-pop" ) ? . addEventListener ( "click" , ( e ) => {
const chip = e . target . closest ( "[data-pulse-speed]" );
if ( chip ) {
const v = Number ( chip . dataset . pulseSpeed );
state . filters . pulseSpeed = v ;
const ps = $ ( "#f-pulse-speed" );
if ( ps ) ps . value = String ( v );
const pLab = $ ( "#pulse-speed-label" );
if ( pLab ) pLab . textContent = ` ${ v . toFixed ( 2 ) } × `;
savePulsePrefs ();
showToast ( `Pulse speed ${ v . toFixed ( 2 ) } × `);
return ;
}
const sw = e . target . closest ( "[data-pulse-theme]" );
if ( sw ) {
const theme = PULSE_THEMES [ sw . dataset . pulseTheme ];
if ( ! theme ) return ;
state . filters . pulseLineColor = theme . line ;
state . filters . pulseGlowColor = theme . glow ;
state . filters . pulsePacketColor = theme . packet ;
const line = $ ( "#f-pulse-line-color" );
const glow = $ ( "#f-pulse-glow-color" );
const packet = $ ( "#f-pulse-packet-color" );
if ( line ) line . value = theme . line ;
if ( glow ) glow . value = theme . glow ;
if ( packet ) packet . value = theme . packet ;
savePulsePrefs ();
showToast ( `Pulse color: ${ sw . dataset . pulseTheme } ` );
}
});
$ ( "#pulse-pop" ) ? . addEventListener ( "input" , ( e ) => {
if ( ! e . target ? . matches ? .( 'input[type="color"]' )) return ;
readFiltersFromDom ();
});
document . addEventListener ( "click" , ( e ) => {
const pop = $ ( "#pulse-pop" );
if ( ! pop || pop . hidden ) return ;
if ( e . target . closest ( "#pulse-pop" ) || e . target . closest ( "#btn-pulse-settings" )) return ;
togglePulsePop ( false );
});
2026-07-17 04:00:38 +02:00
$ ( "#search" ). addEventListener ( "input" , ( e ) => {
state . filters . search = e . target . value ;
layout ();
renderTicker ();
});
$ ( "#ticker" ). addEventListener ( "click" , ( e ) => {
const btn = e . target . closest ( "[data-tick]" );
if ( ! btn ) return ;
const k = btn . dataset . tick ;
if ( k === "ome" ) showHubInspector ();
if ( k === "power" ) {
state . viewMode = "power" ;
state . kpiFocus = "power" ;
state . filters . hasPower = true ;
$ ( "#f-has-power" ). checked = true ;
refreshLists ();
layout ();
}
if ( k === "connected" ) {
state . kpiFocus = "connected" ;
state . filters . connected = true ;
$ ( "#f-connected" ). checked = true ;
refreshLists ();
layout ();
}
if ( k === "alerts" ) {
if ( window . cockpit ? . openTriage ) window . cockpit . openTriage ( "critical" );
else updateFocusContext ();
}
});
canvas . addEventListener ( "mousedown" , ( e ) => {
state . dragging = true ;
state . last = { x : e . clientX , y : e . clientY };
});
window . addEventListener ( "mouseup" , () => {
state . dragging = false ;
});
canvas . addEventListener ( "mousemove" , ( e ) => {
if ( state . dragging ) {
state . cam . x += e . clientX - state . last . x ;
state . cam . y += e . clientY - state . last . y ;
state . last = { x : e . clientX , y : e . clientY };
hideTip ();
return ;
}
const rect = canvas . getBoundingClientRect ();
const hit = hitTest ( e . clientX - rect . left , e . clientY - rect . top );
if ( hit ? . type === "node" ) showTip ( hit . node , e . clientX , e . clientY );
else hideTip ();
});
canvas . addEventListener ( "mouseleave" , hideTip );
canvas . addEventListener ( "wheel" , ( e ) => {
e . preventDefault ();
const rect = canvas . getBoundingClientRect ();
const mx = e . clientX - rect . left ;
const my = e . clientY - rect . top ;
const before = screenToWorld ( mx , my );
const factor = e . deltaY > 0 ? 0.9 : 1.1 ;
state . cam . scale = Math . min ( 3.5 , Math . max ( 0.35 , state . cam . scale * factor ));
const after = screenToWorld ( mx , my );
state . cam . x += ( after . x - before . x ) * state . cam . scale ;
state . cam . y += ( after . y - before . y ) * state . cam . scale ;
}, { passive : false });
canvas . addEventListener ( "click" , ( e ) => {
const rect = canvas . getBoundingClientRect ();
const hit = hitTest ( e . clientX - rect . left , e . clientY - rect . top );
if ( ! hit ) {
showInspector ( null );
hideTip ();
return ;
}
if ( hit . type === "hub" ) {
showHubInspector ();
return ;
}
showInspector ( hit . node );
});
canvas . addEventListener ( "dblclick" , ( e ) => {
e . preventDefault ();
const rect = canvas . getBoundingClientRect ();
const hit = hitTest ( e . clientX - rect . left , e . clientY - rect . top );
if ( hit ? . type === "node" ) {
showInspector ( hit . node );
openConnect ( hit . node );
} else if ( hit ? . type === "hub" ) {
showHubInspector ();
}
});
// Shared API for Ops desk / triage popup
window . cockpit = {
getState : () => state ,
focusDeviceId ,
openConnect ,
showInspector ,
openAi ,
relTime ,
escapeHtml ,
openTriage : null ,
openKpiPopup ,
closeKpiPopup ,
};
// Realtime context interactivity
$ ( "#ctx-stats" ) ? . addEventListener ( "click" , ( e ) => {
const btn = e . target . closest ( "[data-ctx]" );
if ( ! btn ) return ;
if ( window . cockpit . openTriage ) window . cockpit . openTriage ( btn . dataset . ctx );
});
$ ( "#alert-list" ) ? . addEventListener ( "click" , ( e ) => {
const btn = e . target . closest ( "[data-alert-device]" );
if ( ! btn ) return ;
if ( window . cockpit . openTriage ) {
window . cockpit . openTriage ( "all" , {
alertId : btn . dataset . alertId ,
deviceId : btn . dataset . alertDevice ,
});
} else {
focusDeviceId ( btn . dataset . alertDevice );
}
});
$ ( "#ctx-feed-body" ) ? . addEventListener ( "click" , ( e ) => {
const btn = e . target . closest ( "[data-feed-device]" );
if ( ! btn ) return ;
if ( window . cockpit . openTriage ) {
window . cockpit . openTriage ( "all" , { deviceId : btn . dataset . feedDevice });
} else {
focusDeviceId ( btn . dataset . feedDevice );
}
});
2026-07-18 01:18:14 +02:00
function applyTheme ( theme ) {
const t = theme === "dark" ? "dark" : "light" ;
document . documentElement . setAttribute ( "data-theme" , t );
try {
localStorage . setItem ( "cockpit_theme" , t );
} catch ( _ ) {}
const btn = $ ( "#btn-theme" );
if ( btn ) {
btn . dataset . theme = t ;
btn . title = t === "light" ? "Switch to dark theme" : "Switch to light theme" ;
btn . setAttribute ( "aria-label" , btn . title );
}
}
applyTheme ( localStorage . getItem ( "cockpit_theme" ) || "light" );
$ ( "#btn-theme" ) ? . addEventListener ( "click" , () => {
const cur = document . documentElement . getAttribute ( "data-theme" ) || "light" ;
applyTheme ( cur === "light" ? "dark" : "light" );
});
2026-07-17 04:00:38 +02:00
$ ( "#btn-reset-view" ). addEventListener ( "click" , ( e ) => {
e . preventDefault ();
e . stopPropagation ();
resetView ();
});
$ ( "#btn-clear-filters" ) ? . addEventListener ( "click" , ( e ) => {
e . preventDefault ();
e . stopPropagation ();
clearFilters ();
});
$ ( "#btn-ai" ) ? . addEventListener ( "click" , () => openAi ( null ));
$ ( "#btn-ai-close" ). addEventListener ( "click" , closeAi );
$ ( "#btn-connect-close" ) ? . addEventListener ( "click" , closeConnect );
$ ( "#scrim" ). addEventListener ( "click" , () => {
const mode = $ ( "#scrim" ) ? . dataset . mode ;
if ( mode === "triage" ) document . getElementById ( "btn-triage-close" ) ? . click ();
else if ( mode === "kpi" ) closeKpiPopup ();
else if ( mode === "connect" ) closeConnect ();
2026-07-18 21:56:30 +02:00
else if (
mode === "chat-drawer" ||
mode === "ops-drawer" ||
mode === "ai-drawer" ||
mode === "reports-drawer" ||
mode === "an-context" ||
mode === "network-drawer"
) {
/* reports.js / ops.js / network.js also listen */
2026-07-17 04:00:38 +02:00
} else closeAi ();
});
$ ( "#btn-ome-console" ). addEventListener ( "click" , () => {
const url = state . data ? . ome ? . console_url || state . data ? . ome ? . url ;
if ( url ) window . open ( url , "_blank" , "noopener" );
});
// collapsible left-rail modules
$ ( "#left-rail" ) ? . addEventListener ( "click" , ( e ) => {
const btn = e . target . closest ( ".rail-toggle" );
if ( ! btn ) return ;
const block = btn . closest ( ".rail-block" );
if ( ! block ) return ;
const open = block . classList . toggle ( "open" );
btn . setAttribute ( "aria-expanded" , open ? "true" : "false" );
});
window . addEventListener ( "keydown" , ( e ) => {
if ( e . key === "Escape" ) {
2026-07-18 21:56:30 +02:00
if ( ! $ ( "#an-context-modal" ) ? . classList . contains ( "hidden" )) {
window . cockpitReports ? . closeAnContext ? .();
return ;
}
2026-07-17 04:00:38 +02:00
closeConnect ();
closeAi ();
closeKpiPopup ();
hideTip ();
document . getElementById ( "btn-triage-close" ) ? . click ();
}
});
window . addEventListener ( "resize" , resize );
window . addEventListener ( "cockpit-feed-expand" , () => {
try { renderContext (); } catch ( _ ) {}
});
async function boot () {
try {
const r = await fetch ( "/api/fleet" );
applySnapshot ( await r . json ());
} catch ( e ) {
console . error ( e );
}
connectWs ();
}
function connectWs () {
const proto = location . protocol === "https:" ? "wss" : "ws" ;
const ws = new WebSocket ( ` ${ proto } :// ${ location . host } /ws/fleet` );
ws . onmessage = ( ev ) => {
try {
const msg = JSON . parse ( ev . data );
if ( msg . type === "snapshot" && msg . data ) applySnapshot ( msg . data );
if ( msg . type === "gpu" && msg . data ) {
if ( state . data ) state . data . gpu = msg . data ;
window . dispatchEvent ( new CustomEvent ( "cockpit-gpu" , { detail : msg . data }));
}
} catch ( _ ) {}
};
ws . onclose = () => setTimeout ( connectWs , 2500 );
}
// Keep JS filter state in sync with checkbox defaults in HTML
2026-07-18 21:56:30 +02:00
try {
const saved = JSON . parse ( localStorage . getItem ( "cockpit_pulse_prefs" ) || "null" );
if ( saved && typeof saved === "object" ) {
if ( Number . isFinite ( Number ( saved . pulseSpeed )) && saved . pulseSpeed > 0 ) {
state . filters . pulseSpeed = Number ( saved . pulseSpeed );
}
if ( saved . pulseLineColor ) state . filters . pulseLineColor = saved . pulseLineColor ;
if ( saved . pulseGlowColor ) state . filters . pulseGlowColor = saved . pulseGlowColor ;
if ( saved . pulsePacketColor ) state . filters . pulsePacketColor = saved . pulsePacketColor ;
} else {
const savedSpd = Number ( localStorage . getItem ( "cockpit_pulse_speed" ));
if ( Number . isFinite ( savedSpd ) && savedSpd > 0 ) state . filters . pulseSpeed = savedSpd ;
}
} catch ( _ ) {}
2026-07-17 04:00:38 +02:00
readFiltersFromDom ();
syncFilterInputs ();
resize ();
requestAnimationFrame ( draw );
boot ();
// style for inline link buttons in inspector
const style = document . createElement ( "style" );
style . textContent = `.linkish{appearance:none;border:none;background:none;color:var(--dell-bright);font:inherit;font-family:var(--mono);padding:0;cursor:pointer;text-align:left}.linkish:hover{text-decoration:underline;color:#fff}` ;
document . head . appendChild ( style );
})();