2026-07-18 01:18:14 +02:00
(() => {
const $ = ( sel , root = document ) => root . querySelector ( sel );
const $$ = ( sel , root = document ) => [... root . querySelectorAll ( sel )];
const state = {
tab : "fabric" ,
site : "ATC1" ,
fabric : null ,
racks : null ,
vlans : null ,
fleet : null ,
portmap : null ,
selectedSwitchId : null ,
selectedPort : null ,
selectedRackId : null ,
selectedVlanId : null ,
placeDeviceId : null ,
wireDeviceId : null ,
anim : 0 ,
raf : 0 ,
2026-07-18 21:56:30 +02:00
peerOrder : {}, // switchId -> [port,...]
cableColors : {}, // `${switchId}:${port}` -> #hex
2026-07-18 01:18:14 +02:00
};
2026-07-18 21:56:30 +02:00
const CABLE_PALETTE = [ "#ff9a3c" , "#3dffe0" , "#7ec8ff" , "#ff5c5c" , "#c4a7ff" , "#3dffa0" , "#f0e68c" , "#f472b6" ];
function loadFabricPrefs () {
try {
const raw = JSON . parse ( localStorage . getItem ( "cockpit_fabric_prefs" ) || "{}" );
if ( raw . peerOrder ) state . peerOrder = raw . peerOrder ;
if ( raw . cableColors ) state . cableColors = raw . cableColors ;
} catch ( _ ) {}
}
function saveFabricPrefs () {
try {
localStorage . setItem (
"cockpit_fabric_prefs" ,
JSON . stringify ({ peerOrder : state . peerOrder , cableColors : state . cableColors })
);
} catch ( _ ) {}
}
loadFabricPrefs ();
function cableColorKey ( port ) {
return ` ${ state . selectedSwitchId || 0 } : ${ port } ` ;
}
function getCableColor ( port ) {
return state . cableColors [ cableColorKey ( port )] || CABLE_PALETTE [( Number ( port ) - 1 ) % CABLE_PALETTE . length ];
}
function setCableColor ( port , hex ) {
state . cableColors [ cableColorKey ( port )] = hex ;
saveFabricPrefs ();
}
function hexToRgba ( hex , a ) {
const h = String ( hex || "#ff9a3c" ). replace ( "#" , "" );
const full = h . length === 3 ? h . split ( "" ). map (( c ) => c + c ). join ( "" ) : h ;
const n = parseInt ( full , 16 );
if ( ! Number . isFinite ( n )) return `rgba(255,154,60, ${ a } )` ;
const r = ( n >> 16 ) & 255 ;
const g = ( n >> 8 ) & 255 ;
const b = n & 255 ;
return `rgba( ${ r } , ${ g } , ${ b } , ${ a } )` ;
}
function orderedWiredPorts ( ports ) {
const wired = ( ports || []). filter (( p ) => p . wired && p . link ? . device );
const order = state . peerOrder [ state . selectedSwitchId ] || [];
const rank = new Map ( order . map (( p , i ) => [ Number ( p ), i ]));
return wired . slice (). sort (( a , b ) => {
const ra = rank . has ( a . port ) ? rank . get ( a . port ) : 1000 + a . port ;
const rb = rank . has ( b . port ) ? rank . get ( b . port ) : 1000 + b . port ;
return ra - rb ;
});
}
2026-07-18 01:18:14 +02:00
async function api ( method , url , body ) {
const opts = { method , headers : {} };
if ( body !== undefined ) {
opts . headers [ "Content-Type" ] = "application/json" ;
opts . body = JSON . stringify ( body );
}
const r = await fetch ( url , opts );
if ( ! r . ok ) throw new Error ( await r . text ());
return r . json ();
}
function escape ( s ) {
return String ( s ?? "" )
. replace ( /&/g , "&" )
. replace ( /</g , "<" )
. replace ( />/g , ">" )
. replace ( /"/g , """ );
}
function openNetwork () {
const drawer = $ ( "#network-drawer" );
const scrim = $ ( "#scrim" );
if ( ! drawer ) return ;
2026-07-19 00:16:19 +02:00
[ "#chat-drawer" , "#ops-drawer" , "#ai-drawer" , "#reports-drawer" , "#console-drawer" ]. forEach (( id ) => {
2026-07-18 01:18:14 +02:00
const el = $ ( id );
if ( el ) {
el . classList . remove ( "open" );
el . setAttribute ( "aria-hidden" , "true" );
}
});
drawer . classList . add ( "open" );
drawer . setAttribute ( "aria-hidden" , "false" );
if ( scrim ) {
scrim . classList . add ( "open" );
scrim . dataset . mode = "network-drawer" ;
}
showTab ( state . tab );
refresh ();
}
function closeNetwork () {
closeStudio ();
const drawer = $ ( "#network-drawer" );
const scrim = $ ( "#scrim" );
drawer ? . classList . remove ( "open" );
drawer ? . setAttribute ( "aria-hidden" , "true" );
if ( scrim ? . dataset . mode === "network-drawer" ) {
scrim . classList . remove ( "open" );
delete scrim . dataset . mode ;
}
stopPulse ();
}
function showTab ( tab ) {
state . tab = tab ;
$$ ( "#network-tabs .chip" ). forEach (( c ) => c . classList . toggle ( "active" , c . dataset . ntab === tab ));
$ ( "#network-fabric" ) ? . classList . toggle ( "hidden" , tab !== "fabric" );
$ ( "#network-racks" ) ? . classList . toggle ( "hidden" , tab !== "racks" );
$ ( "#network-vlans" ) ? . classList . toggle ( "hidden" , tab !== "vlans" );
if ( tab === "fabric" ) startPulse ();
else stopPulse ();
}
function stopPulse () {
if ( state . raf ) cancelAnimationFrame ( state . raf );
state . raf = 0 ;
}
function startPulse () {
stopPulse ();
const tick = ( ts ) => {
state . anim = ts / 1000 ;
drawCables ();
state . raf = requestAnimationFrame ( tick );
};
state . raf = requestAnimationFrame ( tick );
}
async function refresh () {
const mountHint = $ ( "#network-status" );
if ( mountHint ) mountHint . textContent = "Loading…" ;
try {
const [ fleet , fabric , racks , vlans ] = await Promise . all ([
api ( "GET" , "/api/fleet" ),
api ( "GET" , "/api/network/fabric" ),
api ( "GET" , "/api/racks" ),
api ( "GET" , "/api/network/vlans" ),
]);
state . fleet = fleet ;
state . fabric = fabric ;
state . racks = racks ;
state . vlans = vlans ;
const switches = ( fabric . switches || []). length
? fabric . switches
: ( fleet . devices || []). filter (( d ) => d . is_switch );
if ( ! state . selectedSwitchId && switches [ 0 ]) {
state . selectedSwitchId = switches [ 0 ]. id ;
}
if ( state . selectedSwitchId ) {
state . portmap = await api ( "GET" , `/api/network/switches/ ${ state . selectedSwitchId } /ports` );
}
const wired = state . portmap ? . wired_count || 0 ;
const ports = state . portmap ? . switch ? . port_count || 0 ;
if ( mountHint ) {
mountHint . textContent = ` ${ switches . length } switch · ${ wired } / ${ ports } ports wired · manual port map` ;
}
renderFabric ();
renderRacks ();
renderVlans ();
if ( state . tab === "fabric" ) startPulse ();
} catch ( e ) {
if ( mountHint ) mountHint . textContent = "Error: " + e . message ;
}
}
function devices () {
return state . fleet ? . devices || [];
}
function switchList () {
const fromFabric = state . fabric ? . switches || [];
if ( fromFabric . length ) return fromFabric ;
return devices ()
. filter (( d ) => d . is_switch )
. map (( s ) => ({
id : s . id ,
name : s . name ,
service_tag : s . service_tag ,
ip : s . ip || s . idrac_ip ,
subnet : s . subnet ,
model : s . model ,
connected_device_ids : [],
}));
}
function renderFabric () {
const root = $ ( "#network-fabric" );
if ( ! root ) return ;
const switches = switchList ();
const pm = state . portmap ;
const sw = pm ? . switch ;
const ports = pm ? . ports || [];
const selected = state . selectedPort ;
const selPort = ports . find (( p ) => p . port === selected );
const servers = devices (). filter (
( d ) => d . is_server || d . is_storage || d . is_chassis || d . is_idrac
);
root . innerHTML = `
<div class="sw-fabric">
<aside class="sw-list-pane">
<h4>Switches</h4>
<div class="sw-cards">
${ switches
. map (( s ) => {
const active = s . id === state . selectedSwitchId ? " active" : "" ;
return `<button type="button" class="sw-card ${ active } " data-sw=" ${ s . id } ">
<div class="sw-card-ico" aria-hidden="true"></div>
<div class="sw-card-body">
<strong> ${ escape ( s . name ) } </strong>
<span> ${ escape ( s . model || "Switch" ) } </span>
<span class="mono">ST= ${ escape ( s . service_tag || "—" ) } · ${ escape ( s . ip || "—" ) } </span>
</div>
</button>` ;
} )
.join("") || '<p class="hint">No switches in OME.</p>'}
</div>
<p class="hint"> ${ escape ( pm ? . note || "Select a switch to map ports." ) } </p>
</aside>
<section class="sw-face-pane">
${
sw
? `<div class="sw-faceplate" id="sw-faceplate">
<div class="sw-face-head">
<div class="sw-brand-bar">
<span class="sw-dell">DELL</span>
<span class="sw-model"> ${ escape (( sw . model || "" ). replace ( "Dell EMC Networking " , "" ). replace ( " switch/router" , "" )) } </span>
<span class="sw-st mono"> ${ escape ( sw . service_tag || "" ) } </span>
</div>
<div class="sw-face-meta">
<span> ${ escape ( sw . name ) } </span>
<span class="mono"> ${ escape ( sw . ip || "—" ) } · ${ escape ( sw . subnet || "" ) } </span>
<span class="sw-wired-pill"> ${ pm . wired_count } / ${ sw . port_count } ports wired</span>
</div>
</div>
2026-07-18 21:56:30 +02:00
<div class="sw-cable-stage" id="sw-cable-stage">
<canvas id="sw-cable-canvas" aria-hidden="true"></canvas>
<div class="sw-port-grid" id="sw-port-grid">
2026-07-18 01:18:14 +02:00
${ ports
2026-07-18 21:56:30 +02:00
. map (( p ) => {
const cls = [
"sw-port" ,
p . wired ? "wired" : "empty" ,
selected === p . port ? "selected" : "" ,
p . link ? . device ? . connected ? "live" : "" ,
]
. filter ( Boolean )
. join ( " " );
const tip = p . wired
? ` ${ p . label } → ${ ( p . link . device && p . link . device . name ) || "?" } / ${ p . link . device_port || "?" } `
: ` ${ p . label } — click to wire` ;
return `<button type="button" class=" ${ cls } " data-port=" ${ p . port } " title=" ${ escape ( tip ) } ">
<span class="sw-port-num"> ${ p . port } </span>
${ p . wired ? '<span class="sw-port-dot"></span>' : "" }
</button>` ;
} )
.join("")}
</div>
<div class="sw-peer-rail" id="sw-peer-rail">
<p class="sw-peer-hint">Drag servers to rearrange · pick cable color per link</p>
${ orderedWiredPorts ( ports )
2026-07-18 01:18:14 +02:00
. map (( p ) => {
const d = p . link . device ;
2026-07-18 21:56:30 +02:00
const col = getCableColor ( p . port );
const nicLabel = p . link . device_port || "—" ;
return `<div class="sw-peer-chip ${ d . connected ? "up" : "down" } " draggable="true" data-peer-port=" ${ p . port } " style="--cable: ${ escape ( col ) } ">
<span class="sw-peer-grip" title="Drag to reorder" aria-hidden="true">⋮⋮</span>
2026-07-18 01:18:14 +02:00
<span class="sw-peer-port">P ${ p . port } </span>
2026-07-18 21:56:30 +02:00
<div class="sw-peer-body">
2026-07-18 01:18:14 +02:00
<strong> ${ escape ( d . name ) } </strong>
2026-07-18 21:56:30 +02:00
<span class="mono"> ${ escape ( nicLabel ) } · ST= ${ escape ( d . service_tag || "—" ) } </span>
<span class="sw-peer-status"> ${ d . connected ? "connected" : "offline" } </span>
2026-07-18 01:18:14 +02:00
</div>
2026-07-18 21:56:30 +02:00
<label class="sw-cable-color" title="Cable / pulse color">
<input type="color" value=" ${ escape ( col ) } " data-cable-color=" ${ p . port } " />
</label>
2026-07-18 01:18:14 +02:00
</div>` ;
} )
.join("") || '<p class="hint">No ports wired yet — click a port on the switch.</p>'}
</div>
</div>
</div>`
: `<p class="hint" style="padding:1rem">Select a switch.</p>`
}
</section>
<aside class="sw-wire-pane" id="sw-wire-pane">
${ renderWirePane ( selPort , servers ) }
</aside>
</div>
` ;
root . querySelectorAll ( "[data-sw]" ). forEach (( btn ) => {
btn . addEventListener ( "click" , async () => {
state . selectedSwitchId = Number ( btn . dataset . sw );
state . selectedPort = null ;
state . portmap = await api ( "GET" , `/api/network/switches/ ${ state . selectedSwitchId } /ports` );
renderFabric ();
startPulse ();
});
});
root . querySelectorAll ( "[data-port]" ). forEach (( btn ) => {
btn . addEventListener ( "click" , () => {
state . selectedPort = Number ( btn . dataset . port );
renderFabric ();
startPulse ();
});
});
$ ( "#sw-wire-save" ) ? . addEventListener ( "click" , saveWire );
$ ( "#sw-wire-clear" ) ? . addEventListener ( "click" , clearWire );
$ ( "#sw-wire-device" ) ? . addEventListener ( "change" , onWireDeviceChange );
2026-07-18 21:56:30 +02:00
if ( $ ( "#sw-wire-device" ) ? . value ) onWireDeviceChange ();
bindPeerRail ( $ ( "#sw-peer-rail" ));
requestAnimationFrame (() => {
requestAnimationFrame (() => drawCables ());
});
}
function bindPeerRail ( rail ) {
if ( ! rail ) return ;
let dragPort = null ;
rail . querySelectorAll ( "[data-cable-color]" ). forEach (( inp ) => {
inp . addEventListener ( "input" , ( e ) => {
e . stopPropagation ();
const port = Number ( inp . dataset . cableColor );
setCableColor ( port , inp . value );
const chip = inp . closest ( ".sw-peer-chip" );
if ( chip ) chip . style . setProperty ( "--cable" , inp . value );
drawCables ();
});
inp . addEventListener ( "click" , ( e ) => e . stopPropagation ());
inp . addEventListener ( "mousedown" , ( e ) => e . stopPropagation ());
});
rail . querySelectorAll ( ".sw-peer-chip[draggable]" ). forEach (( chip ) => {
chip . addEventListener ( "dragstart" , ( e ) => {
dragPort = Number ( chip . dataset . peerPort );
chip . classList . add ( "dragging" );
e . dataTransfer . effectAllowed = "move" ;
e . dataTransfer . setData ( "text/plain" , String ( dragPort ));
});
chip . addEventListener ( "dragend" , () => {
chip . classList . remove ( "dragging" );
rail . querySelectorAll ( ".sw-peer-chip" ). forEach (( n ) => n . classList . remove ( "drag-over" ));
dragPort = null ;
drawCables ();
});
chip . addEventListener ( "dragover" , ( e ) => {
e . preventDefault ();
e . dataTransfer . dropEffect = "move" ;
chip . classList . add ( "drag-over" );
});
chip . addEventListener ( "dragleave" , () => chip . classList . remove ( "drag-over" ));
chip . addEventListener ( "drop" , ( e ) => {
e . preventDefault ();
chip . classList . remove ( "drag-over" );
const from = Number ( e . dataTransfer . getData ( "text/plain" ) || dragPort );
const to = Number ( chip . dataset . peerPort );
if ( ! from || ! to || from === to ) return ;
const ports = orderedWiredPorts ( state . portmap ? . ports || []). map (( p ) => p . port );
const fi = ports . indexOf ( from );
const ti = ports . indexOf ( to );
if ( fi < 0 || ti < 0 ) return ;
ports . splice ( fi , 1 );
ports . splice ( ti , 0 , from );
state . peerOrder [ state . selectedSwitchId ] = ports ;
saveFabricPrefs ();
renderFabric ();
startPulse ();
});
});
2026-07-18 01:18:14 +02:00
}
function renderWirePane ( selPort , servers ) {
if ( ! selPort ) {
return `<h4>Port wiring</h4><p class="hint">Click a port on the switch faceplate to map it to a server NIC / port.</p>` ;
}
const link = selPort . link ;
const curDev = link ? . device_id || state . wireDeviceId || "" ;
const curPort = link ? . device_port || "" ;
const opts = servers
. slice ()
. sort (( a , b ) => ( a . name || "" ). localeCompare ( b . name || "" ))
. map (
( d ) =>
`<option value=" ${ d . id } " ${ String ( d . id ) === String ( curDev ) ? "selected" : "" } > ${ escape ( d . name ) } · ST= ${ escape ( d . service_tag || "—" ) } </option>`
)
. join ( "" );
return `
<h4>Wire ${ escape ( selPort . label ) } </h4>
<p class="hint">Switch port <strong> ${ selPort . port } </strong> → server + NIC/port</p>
<label class="sw-field">Server / endpoint
<select id="sw-wire-device">
<option value="">— select —</option>
${ opts }
</select>
</label>
<label class="sw-field">Server port / NIC
2026-07-18 21:56:30 +02:00
<select id="sw-wire-nic-pick">
<option value="">— load after selecting server —</option>
</select>
<input type="text" id="sw-wire-nic" list="sw-nic-list" placeholder="PortId / FQDD (e.g. NIC.Slot.1-1)" value=" ${ escape ( curPort ) } " />
2026-07-18 01:18:14 +02:00
<datalist id="sw-nic-list"></datalist>
</label>
<label class="sw-field">Note
<input type="text" id="sw-wire-note" placeholder="optional" value=" ${ escape ( link ? . note || "" ) } " />
</label>
<div class="sw-wire-actions">
<button type="button" class="btn primary" id="sw-wire-save">Save link</button>
${ selPort . wired ? `<button type="button" class="btn danger" id="sw-wire-clear">Unwire</button>` : "" }
</div>
${
link ? . device
? `<div class="sw-wire-current">
<strong>Current</strong>
<span> ${ escape ( link . device . name ) } </span>
<span class="mono"> ${ escape ( link . device_port || "—" ) } · ${ link . device . connected ? "connected" : "offline" } </span>
</div>`
: ""
}
` ;
}
async function onWireDeviceChange () {
const id = Number ( $ ( "#sw-wire-device" ) ? . value || 0 );
state . wireDeviceId = id || null ;
const list = $ ( "#sw-nic-list" );
2026-07-18 21:56:30 +02:00
const pick = $ ( "#sw-wire-nic-pick" );
const input = $ ( "#sw-wire-nic" );
if ( list ) list . innerHTML = "" ;
if ( pick ) {
pick . innerHTML = id
? `<option value="">Loading NIC ports from OME…</option>`
: `<option value="">— select a server first —</option>` ;
}
if ( ! id ) return ;
2026-07-18 01:18:14 +02:00
try {
const data = await api ( "GET" , `/api/devices/ ${ id } /nics` );
2026-07-18 21:56:30 +02:00
const rows = data . nics || [];
if ( pick ) {
pick . innerHTML =
`<option value="">— ${ rows . length ? "select NIC port" : "no NIC ports in OME" } —</option>` +
rows
. map (( n ) => {
const val = n . port_id || n . fqdd || n . name || "" ;
const label = n . label || [ n . port_id || n . fqdd , n . name , n . mac ]. filter ( Boolean ). join ( " · " );
return `<option value=" ${ escape ( val ) } " title=" ${ escape ( label ) } "> ${ escape ( label ) } </option>` ;
})
. join ( "" );
if ( input ? . value ) {
const match = [... pick . options ]. find (( o ) => o . value && o . value === input . value );
if ( match ) pick . value = match . value ;
}
pick . onchange = () => {
if ( pick . value && input ) input . value = pick . value ;
};
}
rows . forEach (( n ) => {
if ( ! list ) return ;
2026-07-18 01:18:14 +02:00
const opt = document . createElement ( "option" );
2026-07-18 21:56:30 +02:00
const val = n . port_id || n . fqdd || n . name || "" ;
opt . value = val ;
opt . label = n . label || val ;
2026-07-18 01:18:14 +02:00
list . appendChild ( opt );
});
2026-07-18 21:56:30 +02:00
if ( rows [ 0 ] && input && ! input . value ) {
const first = rows [ 0 ]. port_id || rows [ 0 ]. fqdd || rows [ 0 ]. name || "" ;
input . value = first ;
if ( pick ) pick . value = first ;
2026-07-18 01:18:14 +02:00
}
} catch ( _ ) {
2026-07-18 21:56:30 +02:00
if ( pick ) pick . innerHTML = `<option value="">NIC lookup failed — type PortId manually</option>` ;
2026-07-18 01:18:14 +02:00
}
}
async function saveWire () {
const port = state . selectedPort ;
const deviceId = Number ( $ ( "#sw-wire-device" ) ? . value || 0 );
const nic = ( $ ( "#sw-wire-nic" ) ? . value || "" ). trim ();
const note = ( $ ( "#sw-wire-note" ) ? . value || "" ). trim ();
if ( ! state . selectedSwitchId || ! port ) return ;
if ( ! deviceId ) {
alert ( "Select a server / endpoint" );
return ;
}
try {
state . portmap = await api ( "PUT" , `/api/network/switches/ ${ state . selectedSwitchId } /ports/ ${ port } ` , {
switch_port : port ,
device_id : deviceId ,
device_port : nic ,
note ,
});
renderFabric ();
startPulse ();
} catch ( e ) {
alert ( "Save failed: " + e . message );
}
}
async function clearWire () {
const port = state . selectedPort ;
if ( ! state . selectedSwitchId || ! port ) return ;
if ( ! confirm ( `Unwire switch port ${ port } ?` )) return ;
try {
state . portmap = await api ( "DELETE" , `/api/network/switches/ ${ state . selectedSwitchId } /ports/ ${ port } ` );
renderFabric ();
startPulse ();
} catch ( e ) {
alert ( e . message );
}
}
function drawCables () {
const canvas = $ ( "#sw-cable-canvas" );
const grid = $ ( "#sw-port-grid" );
const rail = $ ( "#sw-peer-rail" );
2026-07-18 21:56:30 +02:00
const stage = $ ( "#sw-cable-stage" ) || canvas ? . parentElement ;
2026-07-18 01:18:14 +02:00
if ( ! canvas || ! grid || ! stage || state . tab !== "fabric" ) return ;
const dpr = window . devicePixelRatio || 1 ;
const w = stage . clientWidth ;
const h = stage . clientHeight ;
if ( w < 10 || h < 10 ) return ;
canvas . width = Math . floor ( w * dpr );
canvas . height = Math . floor ( h * dpr );
canvas . style . width = w + "px" ;
canvas . style . height = h + "px" ;
const ctx = canvas . getContext ( "2d" );
ctx . setTransform ( dpr , 0 , 0 , dpr , 0 , 0 );
ctx . clearRect ( 0 , 0 , w , h );
const stageBox = stage . getBoundingClientRect ();
const t = state . anim ;
const ports = state . portmap ? . ports || [];
ports . forEach (( p ) => {
if ( ! p . wired ) return ;
const portBtn = grid . querySelector ( `[data-port=" ${ p . port } "]` );
const peer = rail ? . querySelector ( `[data-peer-port=" ${ p . port } "]` );
if ( ! portBtn || ! peer ) return ;
const a = portBtn . getBoundingClientRect ();
const b = peer . getBoundingClientRect ();
2026-07-18 21:56:30 +02:00
/* Attach to port bottom-center → peer left-center (all in stage coords) */
2026-07-18 01:18:14 +02:00
const x1 = a . left + a . width / 2 - stageBox . left ;
const y1 = a . bottom - stageBox . top ;
const x2 = b . left - stageBox . left ;
const y2 = b . top + b . height / 2 - stageBox . top ;
2026-07-18 21:56:30 +02:00
if ( ! Number . isFinite ( x1 ) || ! Number . isFinite ( y1 ) || ! Number . isFinite ( x2 ) || ! Number . isFinite ( y2 )) return ;
2026-07-18 01:18:14 +02:00
2026-07-18 21:56:30 +02:00
const live = !! p . link ? . device ? . connected ;
const col = getCableColor ( p . port );
const dy = Math . max ( 28 , y2 - y1 );
const c1x = x1 ;
const c1y = y1 + dy * 0.45 ;
const c2x = x2 - Math . min ( 80 , Math . max ( 36 , ( x2 - x1 ) * 0.35 ));
const c2y = y2 ;
/* Base dashed cable — user-chosen color */
2026-07-18 01:18:14 +02:00
ctx . beginPath ();
ctx . moveTo ( x1 , y1 );
2026-07-18 21:56:30 +02:00
ctx . bezierCurveTo ( c1x , c1y , c2x , c2y , x2 , y2 );
ctx . strokeStyle = hexToRgba ( col , live ? 0.72 : 0.4 );
ctx . lineWidth = live ? 2.6 : 2.2 ;
ctx . lineCap = "round" ;
ctx . setLineDash ([ 7 , 6 ]);
ctx . lineDashOffset = - t * ( live ? 50 : 28 );
2026-07-18 01:18:14 +02:00
ctx . stroke ();
2026-07-18 21:56:30 +02:00
/* Glow pulse */
const pulse = ( Math . sin ( t * 5 + p . port ) + 1 ) / 2 ;
ctx . beginPath ();
ctx . moveTo ( x1 , y1 );
ctx . bezierCurveTo ( c1x , c1y , c2x , c2y , x2 , y2 );
ctx . strokeStyle = hexToRgba ( col , ( live ? 0.22 : 0.1 ) + pulse * ( live ? 0.5 : 0.25 ));
ctx . lineWidth = live ? 3.4 : 2.8 ;
ctx . setLineDash ([]);
ctx . stroke ();
2026-07-18 01:18:14 +02:00
2026-07-18 21:56:30 +02:00
/* Moving packet */
2026-07-18 01:18:14 +02:00
const u = ( t * 0.35 + p . port * 0.07 ) % 1 ;
2026-07-18 21:56:30 +02:00
const pt = bezierPoint ( x1 , y1 , c1x , c1y , c2x , c2y , x2 , y2 , u );
ctx . beginPath ();
ctx . arc ( pt . x , pt . y , live ? 3.4 : 2.8 , 0 , Math . PI * 2 );
ctx . fillStyle = col ;
ctx . fill ();
/* Anchor dots */
2026-07-18 01:18:14 +02:00
ctx . beginPath ();
2026-07-18 21:56:30 +02:00
ctx . arc ( x1 , y1 , 2.5 , 0 , Math . PI * 2 );
ctx . fillStyle = col ;
ctx . fill ();
ctx . beginPath ();
ctx . arc ( x2 , y2 , 2.5 , 0 , Math . PI * 2 );
2026-07-18 01:18:14 +02:00
ctx . fill ();
});
}
function bezierPoint ( x0 , y0 , x1 , y1 , x2 , y2 , x3 , y3 , t ) {
const u = 1 - t ;
return {
x : u * u * u * x0 + 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t * x3 ,
y : u * u * u * y0 + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * y3 ,
};
}
/* ===== Dell Rack Design Studio ===== */
const U_OVERVIEW = 7 ;
const U_STUDIO = 56 ; // full-bleed faceplate height per U
function roleOf ( it ) {
return ( it . category || it . catalog ? . category || it . device ? . role || it . face || "custom" ). toLowerCase ();
}
function roleClass ( role ) {
const r = ( role || "" ). toLowerCase ();
if ( r . includes ( "switch" )) return "switch" ;
if ( r . includes ( "storage" )) return "storage" ;
if ( r . includes ( "chassis" )) return "chassis" ;
if ( r . includes ( "pdu" )) return "pdu" ;
if ( r . includes ( "blank" )) return "blank" ;
if ( r . includes ( "server" ) || r . includes ( "blade" ) || r . includes ( "vxrail" )) return "server" ;
return "custom" ;
}
/** u_start = bottom U; device fills u_start .. u_start+h-1 (higher U = toward top of rack). */
function uSpan ( uStart , h ) {
const lo = Math . max ( 1 , Number ( uStart ) || 1 );
const height = Math . max ( 1 , Number ( h ) || 1 );
const hi = lo + height - 1 ;
return { lo , hi , height , label : height === 1 ? `U ${ lo } ` : `U ${ lo } – U${ hi } ` };
}
function catalogHeight ( sku , fallback = 1 ) {
const cat = ( state . racks ? . catalog || []). find (( c ) => c . sku === sku );
const h = Number ( cat ? . u_height );
return h > 0 ? h : Math . max ( 1 , Number ( fallback ) || 1 );
}
function unitsFree ( rk , uStart , h , excludeItemId ) {
const side = state . rackSide || "front" ;
const units = rk ? . units || 42 ;
const span = uSpan ( uStart , h );
if ( span . hi > units ) return false ;
const items = ( rk ? . items || []). filter (( it ) => ( it . side || "front" ) === side );
for ( const it of items ) {
if ( excludeItemId != null && ( it . id || it . item_id ) === excludeItemId ) continue ;
const a = it . u_start ;
const b = it . u_start + Math . max ( 1 , it . u_height || 1 ) - 1 ;
if ( ! ( span . hi < a || span . lo > b )) return false ;
}
return true ;
}
function rackFillPct ( rk ) {
const items = ( rk . items || []). filter (( it ) => ( it . side || "front" ) === "front" );
const used = items . reduce (( a , p ) => a + Math . max ( 0 , p . u_height || 0 ), 0 );
const units = rk . units || 42 ;
return { used , free : Math . max ( 0 , units - used ), pct : Math . round (( used / units ) * 100 ), units };
}
function allRacksList () {
const sites = state . racks ? . sites || [];
const show = state . site === "Both" ? sites : sites . filter (( s ) => s . id === state . site );
return show . flatMap (( s ) => ( s . racks || []). map (( r ) => ({ ... r , siteName : s . name })));
}
function focusRack () {
const all = allRacksList ();
return all . find (( r ) => r . id === state . selectedRackId ) || all [ 0 ] || null ;
}
let _faceSeq = 0 ;
function faceSvg ( face , label , hU , opts = {}) {
const px = opts . px || U_STUDIO ;
const h = Math . max ( px , Math . max ( 1 , hU || 1 ) * px );
const w = opts . w || 420 ;
const compact = !! opts . compact ;
const title = escape (( label || "" ). slice ( 0 , 36 ));
const gid = "fg" + ( ++ _faceSeq );
const cat = ( face || "" ). toLowerCase ();
if ( cat . startsWith ( "switch" )) {
const n = compact ? 16 : 24 ;
let ports = "" ;
for ( let i = 0 ; i < n ; i ++ ) {
const x = 40 + i * (( w - 70 ) / n );
ports += `<rect x=" ${ x } " y=" ${ h * 0.22 } " width=" ${ ( w - 70 ) / n - 1.4 } " height=" ${ h * 0.56 } " rx="1" fill="#040c12" stroke="#6ecfff" stroke-width="0.7"/>` ;
}
return `<svg class="ru-face" viewBox="0 0 ${ w } ${ h } " preserveAspectRatio="none">
<defs><linearGradient id=" ${ gid } " x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#1e5570"/><stop offset="100%" stop-color="#0f3144"/></linearGradient></defs>
<rect width=" ${ w } " height=" ${ h } " rx="3" fill="url(# ${ gid } )"/>
<text x="8" y=" ${ h * 0.62 } " fill="#b6e7ff" font-size=" ${ compact ? 6 : 9 } " font-family="ui-monospace,monospace">DELL</text>
${ ports } <circle cx=" ${ w - 12 } " cy=" ${ h / 2 } " r=" ${ compact ? 2 : 3 } " fill="#3dffe0"/>
</svg>` ;
}
if ( cat . startsWith ( "storage" )) {
const cols = compact ? 10 : 12 ;
let bays = "" ;
for ( let i = 0 ; i < cols ; i ++ ) {
bays += `<rect x=" ${ 10 + i * (( w - 20 ) / cols ) } " y="3" width=" ${ ( w - 20 ) / cols - 1.5 } " height=" ${ h - 6 } " rx="1.5" fill="#1a1008" stroke="#e08a40" stroke-width="0.7"/>` ;
}
return `<svg class="ru-face" viewBox="0 0 ${ w } ${ h } " preserveAspectRatio="none"><rect width=" ${ w } " height=" ${ h } " rx="3" fill="#2a180c"/> ${ bays } </svg>` ;
}
if ( cat . includes ( "chassis" ) || cat === "chassis_mx" ) {
const n = compact ? 5 : 8 ;
return `<svg class="ru-face" viewBox="0 0 ${ w } ${ h } " preserveAspectRatio="none">
<rect width=" ${ w } " height=" ${ h } " rx="3" fill="#1a1430"/>
${ Array . from ({ length : n } , (_, i) => ` < rect x = "${8 + i * ((w - 16) / n)}" y = "4" width = "${(w - 16) / n - 3}" height = "${h - 8}" rx = "2" fill = "#2a2150" stroke = "#9b8cff" stroke - width = "0.7" /> `).join("")}
</svg>` ;
}
if ( cat . startsWith ( "pdu" )) {
return `<svg class="ru-face" viewBox="0 0 ${ w } ${ h } " preserveAspectRatio="none">
<rect width=" ${ w } " height=" ${ h } " rx="2" fill="#2c2c2c"/>
${ Array . from ({ length : compact ? 8 : 12 } , (_, i) => ` < circle cx = "${18 + i * ((w - 30) / (compact ? 8 : 12))}" cy = "${h / 2}" r = "${compact ? 2 : 3}" fill = "#111" stroke = "#bbb" /> `).join("")}
</svg>` ;
}
if ( cat . startsWith ( "blank" )) {
return `<svg class="ru-face" viewBox="0 0 ${ w } ${ h } " preserveAspectRatio="none"><rect width=" ${ w } " height=" ${ h } " rx="2" fill="#141a22" stroke="#2e3742"/></svg>` ;
}
const drives = cat . includes ( "4u" ) ? 24 : cat . includes ( "2u" ) ? 12 : 8 ;
const n = compact ? Math . min ( drives , 10 ) : drives ;
let disks = "" ;
for ( let i = 0 ; i < n ; i ++ ) {
disks += `<rect x=" ${ 36 + i * (( w - 56 ) / n ) } " y=" ${ h * 0.16 } " width=" ${ ( w - 56 ) / n - 1.5 } " height=" ${ h * 0.68 } " rx="1" fill="#071820" stroke="#3dffe0" stroke-width="0.55"/>` ;
}
return `<svg class="ru-face" viewBox="0 0 ${ w } ${ h } " preserveAspectRatio="none">
<defs><linearGradient id=" ${ gid } " x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#1c4450"/><stop offset="100%" stop-color="#0c2834"/></linearGradient></defs>
<rect width=" ${ w } " height=" ${ h } " rx="3" fill="url(# ${ gid } )"/>
<text x="6" y=" ${ compact ? h * 0.55 : 11 } " fill="#9fe" font-size=" ${ compact ? 6 : 8 } " font-family="ui-monospace,monospace">PowerEdge</text>
${ disks } <circle cx=" ${ w - 10 } " cy=" ${ h / 2 } " r=" ${ compact ? 2 : 3 } " fill="#3dffe0"/>
${ compact ? "" : `<text x="6" y=" ${ h - 3 } " fill="#cfe" font-size="7"> ${ title } </text>` }
</svg>` ;
}
function ensureStudioRoot () {
let el = $ ( "#rack-studio" );
if ( el ) return el ;
el = document . createElement ( "div" );
el . id = "rack-studio" ;
el . className = "rack-studio" ;
el . setAttribute ( "aria-hidden" , "true" );
document . body . appendChild ( el );
return el ;
}
function openStudio ( rackId ) {
if ( rackId != null ) state . selectedRackId = rackId ;
state . studioOpen = true ;
state . studioZoom = state . studioZoom || 1 ;
state . rackSide = state . rackSide || "front" ;
state . libTab = state . libTab || "catalog" ;
state . catalogFamily = state . catalogFamily || "PowerEdge" ;
renderRacks (); // keep overview in sync
renderStudio ();
}
function closeStudio () {
state . studioOpen = false ;
state . placeDeviceId = null ;
state . placeSku = null ;
const el = $ ( "#rack-studio" );
if ( el ) {
if ( el . _onKey ) document . removeEventListener ( "keydown" , el . _onKey );
el . _onKey = null ;
el . classList . remove ( "open" );
el . setAttribute ( "aria-hidden" , "true" );
el . innerHTML = "" ;
}
}
function productPhoto ( itOrFace , label , opts = {}) {
const img =
( typeof itOrFace === "object" && itOrFace
? itOrFace . image || itOrFace . catalog ? . image || null
: null ) ||
opts . image ||
null ;
const face =
typeof itOrFace === "string"
? itOrFace
: itOrFace ? . face || itOrFace ? . catalog ? . face || "server_1u" ;
const fallback = {
server_1u : "/assets/dell/face-server-1u.jpg?v=bezel2" ,
server_2u : "/assets/dell/face-server-2u.jpg?v=bezel2" ,
server_4u : "/assets/dell/face-server-4u.jpg?v=bezel2" ,
switch_1u : "/assets/dell/face-switch-1u.jpg?v=bezel2" ,
switch_2u : "/assets/dell/face-switch-1u.jpg?v=bezel2" ,
storage_2u : "/assets/dell/face-storage-2u.jpg?v=bezel2" ,
storage_3u : "/assets/dell/face-storage-2u.jpg?v=bezel2" ,
chassis_mx : "/assets/dell/face-chassis-mx.jpg?v=bezel2" ,
pdu_1u : "/assets/dell/face-pdu-1u.jpg?v=bezel2" ,
pdu_0u : "/assets/dell/face-pdu-1u.jpg?v=bezel2" ,
blank_1u : "/assets/dell/face-blank-1u.jpg?v=bezel2" ,
blank_2u : "/assets/dell/face-blank-1u.jpg?v=bezel2" ,
blade : "/assets/dell/face-chassis-mx.jpg?v=bezel2" ,
fabric : "/assets/dell/face-switch-1u.jpg?v=bezel2" ,
custom : "/assets/dell/face-server-1u.jpg?v=bezel2" ,
};
const src = img || fallback [ face ] || fallback . custom ;
const cls = opts . compact ? "ru-photo compact" : "ru-photo" ;
return `<img class=" ${ cls } " src=" ${ escape ( src ) } " alt=" ${ escape ( label || "" ) } " loading="lazy" draggable="false" />` ;
}
function shortModel ( name ) {
const s = String ( name || "" );
const m = s . match ( /\b(R\d{3,4}\w*|MX\d+\w*|S\d{4}\w*|N\d{4}\w*|Z\d{4}\w*|ME\d{4}|DD\d{4}|T\d{3}|XE\d+|C\d{4}\w*|XR\d+|PC-?\d+|PowerConnect\s*\d+|PowerSwitch\s*\S+|PowerVault\s*\S+|VxRail\s*\S+)\b/i );
if ( m ) return m [ 1 ]. replace ( /^Power(Edge|Switch|Vault|Connect)\s*/i , "" ). trim ();
return s . replace ( /^PowerEdge\s+/i , "" ). slice ( 0 , 14 ) || "Device" ;
}
function tintFor ( key ) {
let h = 0 ;
const s = String ( key || "" );
for ( let i = 0 ; i < s . length ; i ++ ) h = ( h * 31 + s . charCodeAt ( i )) >>> 0 ;
return h % 360 ;
}
function facePlate ( p ) {
const face = p . face || p . catalog ? . face || "server_1u" ;
const label = p . label || p . device ? . name || p . catalog ? . name || "Device" ;
const model = p . catalog ? . name || p . device ? . model || label ;
const code = shortModel ( model );
const hU = p . u_height || 1 ;
const rc = roleClass ( roleOf ( p ));
const sku = p . catalog_sku || p . catalog ? . sku || model ;
const hue = tintFor ( sku );
const meta = [
hU > 1 ? uSpan ( p . u_start , hU ). label : null ,
hU ? ` ${ hU } U` : null ,
p . device ? . service_tag ? `ST ${ p . device . service_tag } ` : null ,
p . catalog ? . category || p . category || rc ,
]
. filter ( Boolean )
. join ( " · " );
return `<div class="rs-u-face role- ${ rc } " style="--face-hue: ${ hue } ">
${ productPhoto ({ face , image : p . image || p . catalog ? . image } , label)}
<div class="rs-u-plate">
<span class="rs-u-code"> ${ escape ( code ) } </span>
<strong> ${ escape ( label ) } </strong>
<span> ${ escape ( meta ) } </span>
</div>
</div>` ;
}
function miniElevation ( rk ) {
const units = rk . units || 42 ;
const items = ( rk . items || []). filter (( it ) => ( it . side || "front" ) === "front" );
const occ = new Array ( units + 1 ). fill ( null );
items . forEach (( p ) => {
for ( let u = p . u_start ; u < p . u_start + p . u_height && u <= units ; u ++ ) occ [ u ] = p ;
});
const rows = [];
for ( let u = units ; u >= 1 ; u -- ) {
const p = occ [ u ];
const isStart = p && p . u_start === u ;
if ( p && ! isStart ) continue ;
if ( p && isStart ) {
rows . push ( `<div class="ov-slot filled role- ${ roleClass ( roleOf ( p )) } " style="height: ${ Math . max ( 3 , p . u_height * U_OVERVIEW ) } px" title=" ${ escape ( p . label || "" ) } · ${ uSpan ( p . u_start , p . u_height ). label } "></div>` );
} else {
rows . push ( `<div class="ov-slot empty" style="height: ${ U_OVERVIEW } px"></div>` );
}
}
const fill = rackFillPct ( rk );
return `<button type="button" class="ov-rack" data-open-studio=" ${ rk . id } ">
<div class="ov-rack-top"><strong> ${ escape ( rk . name ) } </strong><span> ${ fill . used } / ${ fill . units } U · ${ fill . pct } %</span></div>
<div class="ov-rack-body"> ${ rows . join ( "" ) } </div>
<div class="ov-rack-cta">Open Design Studio</div>
</button>` ;
}
function renderRacks () {
const root = $ ( "#network-racks" );
if ( ! root || ! state . racks ) return ;
const allRacks = allRacksList ();
if ( ! state . selectedRackId && allRacks [ 0 ]) state . selectedRackId = allRacks [ 0 ]. id ;
root . innerHTML = `
<div class="rd-bar">
<div class="rd-bar-left">
<div class="chip-grid">
${ [ "ATC1" , "ATC2" , "Both" ]. map (( s ) => `<button type="button" class="chip ${ state . site === s ? "active" : "" } " data-site=" ${ s } "> ${ s } </button>` ). join ( "" ) }
</div>
</div>
<div class="rd-bar-right">
<button type="button" class="btn primary" id="rd-open-studio" ${ allRacks . length ? "" : "disabled" } >Design Studio</button>
<button type="button" class="btn" id="net-refresh-racks">Refresh</button>
</div>
</div>
<div class="rd-overview">
<header class="rd-overview-head">
<div>
<h3>Rack floor · ${ escape ( state . site === "Both" ? "ATC1 + ATC2" : state . site ) } </h3>
2026-07-18 21:56:30 +02:00
<p>Click a rack to open Design Studio (zoom, all 42U, Dell catalog).</p>
2026-07-18 01:18:14 +02:00
</div>
<div class="rd-legend">
<span class="lg server">Server</span><span class="lg switch">Switch</span>
<span class="lg storage">Storage</span><span class="lg chassis">Chassis</span><span class="lg pdu">PDU</span>
</div>
</header>
<div class="rd-gallery">
${ allRacks . map (( rk ) => miniElevation ( rk )). join ( "" ) || '<p class="hint">Geen racks</p>' }
</div>
</div>
` ;
root . querySelectorAll ( "[data-site]" ). forEach (( btn ) =>
btn . addEventListener ( "click" , () => {
state . site = btn . dataset . site ;
state . selectedRackId = null ;
renderRacks ();
})
);
root . querySelectorAll ( "[data-open-studio]" ). forEach (( btn ) =>
btn . addEventListener ( "click" , () => openStudio ( Number ( btn . dataset . openStudio )))
);
$ ( "#rd-open-studio" ) ? . addEventListener ( "click" , () => openStudio ( state . selectedRackId ));
$ ( "#net-refresh-racks" ) ? . addEventListener ( "click" , () => refresh ());
if ( state . studioOpen ) renderStudio ();
}
function renderStudio () {
const el = ensureStudioRoot ();
const rk = focusRack ();
const catalog = state . racks ? . catalog || [];
const families = [... new Set ( catalog . map (( c ) => c . family ))];
const unplaced = state . racks ? . unplaced || [];
const allRacks = allRacksList ();
if ( ! rk ) {
el . innerHTML = `<div class="rs-backdrop"></div><div class="rs-panel"><p class="hint">Geen rack</p><button class="btn" id="rs-close">Close</button></div>` ;
el . classList . add ( "open" );
$ ( "#rs-close" ) ? . addEventListener ( "click" , closeStudio );
return ;
}
state . studioZoom = Math . min ( 1.8 , Math . max ( 0.7 , state . studioZoom || 1.15 ));
const q = ( state . libQuery || "" ). toLowerCase (). trim ();
const filteredCat = catalog
. filter (( c ) => c . family === state . catalogFamily )
. filter (( c ) => ! q || [ c . name , c . sku , c . category ]. join ( " " ). toLowerCase (). includes ( q ));
const filteredUn = unplaced . filter (
( d ) => ! q || [ d . name , d . model , d . service_tag , d . role , d . ip ]. join ( " " ). toLowerCase (). includes ( q )
);
const placing = !! ( state . placeDeviceId || state . placeSku );
const placeLabel = state . placeSku
? catalog . find (( c ) => c . sku === state . placeSku ) ? . name || state . placeSku
: unplaced . find (( d ) => d . id === state . placeDeviceId ) ? . name || "" ;
el . innerHTML = `
<div class="rs-backdrop" data-rs-close></div>
<div class="rs-shell" role="dialog" aria-modal="true" aria-label="Dell Rack Design Studio">
<header class="rs-head">
<div class="rs-brand">
<img src="/dell.png" alt="" height="20" />
<div>
<strong>Dell Rack Design Studio</strong>
<span> ${ escape ( rk . name ) } · ${ escape ( rk . siteName || "" ) } · Visio design · drag & drop</span>
</div>
</div>
<div class="rs-head-actions">
<div class="chip-grid">
${ allRacks . map (( r ) => `<button type="button" class="chip ${ r . id === rk . id ? "active" : "" } " data-rs-rack=" ${ r . id } "> ${ escape ( r . name ) } </button>` ). join ( "" ) }
</div>
<div class="chip-grid">
${ [ "front" , "rear" ]. map (( s ) => `<button type="button" class="chip ${ state . rackSide === s ? "active" : "" } " data-rs-side=" ${ s } "> ${ s } </button>` ). join ( "" ) }
</div>
<div class="rs-zoom">
<button type="button" class="btn" data-rs-zoom="-">− </button>
<span> ${ Math . round ( state . studioZoom * 100 ) } %</span>
<button type="button" class="btn" data-rs-zoom="+">+</button>
<button type="button" class="btn" data-rs-zoom="fit">Fit</button>
</div>
<button type="button" class="btn ghost" id="rs-close">Close</button>
</div>
</header>
<div class="rs-body">
<aside class="rs-lib">
<div class="rs-lib-tabs">
<button type="button" class=" ${ state . libTab === "catalog" ? "active" : "" } " data-libtab="catalog">Dell products</button>
<button type="button" class=" ${ state . libTab === "unplaced" ? "active" : "" } " data-libtab="unplaced">OME ( ${ unplaced . length } )</button>
</div>
<input type="search" id="rs-lib-q" placeholder="Search SKU / model…" value=" ${ escape ( state . libQuery || "" ) } " />
${
state . libTab === "catalog"
? `<div class="chip-grid rs-fam"> ${ families
. map (( f ) => `<button type="button" class="chip ${ state . catalogFamily === f ? "active" : "" } " data-fam=" ${ escape ( f ) } "> ${ escape ( f ) } </button>` )
. join ( "" ) } </div>
<div class="rs-lib-list"> ${ filteredCat
. map (
( c ) => `<div class="rs-stencil ${ state . placeSku === c . sku ? "active" : "" } " draggable="true"
data-sku=" ${ escape ( c . sku ) } " data-h=" ${ c . u_height > 0 ? c . u_height : 1 } " data-face=" ${ escape ( c . face || "" ) } " data-img=" ${ escape ( c . image || "" ) } " data-name=" ${ escape ( c . name ) } ">
<span class="rs-stencil-grip" aria-hidden="true"></span>
<span class="rs-prod-face"> ${ productPhoto ( c , c . name , { compact : true } )}</span>
<span class="rs-prod-meta"><strong> ${ escape ( shortModel ( c . name )) } </strong> ${ escape ( c . name ) } <em> ${ c . u_height || 0 } U · ${ escape (( c . face || "" ). replace ( /_/g , " " )) } · drag</em></span>
</div>`
)
. join ( "" ) || '<p class="hint">No products</p>' } </div>`
: `<div class="rs-lib-list"> ${ filteredUn
. map (
( d ) => `<div class="rs-stencil ${ state . placeDeviceId === d . id ? "active" : "" } " draggable="true"
data-place=" ${ d . id } " data-h=" ${ d . default_u_height || 1 } " data-face=" ${ escape ( d . face || "" ) } " data-img=" ${ escape ( d . image || "" ) } " data-name=" ${ escape ( d . name ) } " data-sku=" ${ escape ( d . catalog_sku || "" ) } ">
<span class="rs-stencil-grip" aria-hidden="true"></span>
<span class="rs-prod-face"> ${ productPhoto ({ face : d . face , image : d . image } , d.name, { compact: true })}</span>
<span class="rs-prod-meta"><strong> ${ escape ( d . name ) } </strong><em> ${ escape ( d . model || "" ) } · ${ d . default_u_height } U · drag</em></span>
</div>`
)
. join ( "" ) || '<p class="hint">All placed</p>' } </div>`
}
<p class="rs-lib-hint">Visio-stijl: sleep een product naar een vrije U in het rack</p>
</aside>
<main class="rs-canvas-wrap">
<div class="rs-statusbar">
2026-07-18 21:56:30 +02:00
<span id="rs-status-text">Drag stencils from the library onto a U</span>
2026-07-18 01:18:14 +02:00
<span class="rs-status-side"> ${ escape (( state . rackSide || "front" ). toUpperCase ()) } view</span>
</div>
<div class="rs-canvas visio" id="rs-canvas">
<div class="rs-zoom-layer" id="rs-zoom-layer" style="transform:scale( ${ state . studioZoom } ); transform-origin: top center;">
${ renderStudioRack ( rk ) }
</div>
</div>
<div id="rs-drag-ghost" class="rs-drag-ghost" hidden></div>
</main>
<aside class="rs-insp">
${ renderStudioInspector ( rk ) }
</aside>
</div>
</div>
` ;
el . classList . add ( "open" );
el . setAttribute ( "aria-hidden" , "false" );
bindStudio ( el , catalog , unplaced );
// scroll so top (U42) is visible; user can scroll full height
const canvas = $ ( "#rs-canvas" , el );
if ( canvas && state . studioScrollTop != null ) canvas . scrollTop = state . studioScrollTop ;
}
function bindStudio ( el , catalog , unplaced ) {
const status = () => $ ( "#rs-status-text" , el );
const setStatus = ( msg ) => {
if ( status ()) status (). textContent = msg ;
};
$ ( "#rs-close" , el ) ? . addEventListener ( "click" , closeStudio );
el . querySelector ( "[data-rs-close]" ) ? . addEventListener ( "click" , closeStudio );
el . querySelectorAll ( "[data-rs-rack]" ). forEach (( btn ) =>
btn . addEventListener ( "click" , () => {
state . selectedRackId = Number ( btn . dataset . rsRack );
renderStudio ();
})
);
el . querySelectorAll ( "[data-rs-side]" ). forEach (( btn ) =>
btn . addEventListener ( "click" , () => {
state . rackSide = btn . dataset . rsSide ;
renderStudio ();
})
);
el . querySelectorAll ( "[data-rs-zoom]" ). forEach (( btn ) =>
btn . addEventListener ( "click" , () => {
const z = btn . dataset . rsZoom ;
if ( z === "+" ) state . studioZoom = Math . min ( 1.8 , ( state . studioZoom || 1 ) + 0.1 );
else if ( z === "-" ) state . studioZoom = Math . max ( 0.7 , ( state . studioZoom || 1 ) - 0.1 );
else state . studioZoom = 1 ;
renderStudio ();
})
);
el . querySelectorAll ( "[data-libtab]" ). forEach (( btn ) =>
btn . addEventListener ( "click" , () => {
state . libTab = btn . dataset . libtab ;
renderStudio ();
})
);
el . querySelectorAll ( "[data-fam]" ). forEach (( btn ) =>
btn . addEventListener ( "click" , () => {
state . catalogFamily = btn . dataset . fam ;
renderStudio ();
})
);
const qEl = $ ( "#rs-lib-q" , el );
qEl ? . addEventListener ( "keydown" , ( e ) => {
if ( e . key === "Enter" ) {
state . libQuery = qEl . value ;
renderStudio ();
}
});
qEl ? . addEventListener ( "change" , () => {
state . libQuery = qEl . value ;
renderStudio ();
});
const canvas = $ ( "#rs-canvas" , el );
canvas ? . addEventListener ( "scroll" , () => {
state . studioScrollTop = canvas . scrollTop ;
});
el . querySelectorAll ( "[data-item-del]" ). forEach (( btn ) =>
btn . addEventListener ( "click" , async ( e ) => {
e . stopPropagation ();
e . preventDefault ();
if ( ! confirm ( "Remove from rack?" )) return ;
state . racks = await api ( "DELETE" , `/api/racks/items/ ${ Number ( btn . dataset . itemDel ) } ` );
renderRacks ();
renderStudio ();
})
);
el . querySelectorAll ( "[data-item-sel]" ). forEach (( btn ) =>
btn . addEventListener ( "click" , ( e ) => {
if ( e . target . closest ( "[data-item-del]" )) return ;
if ( state . _dragMoved ) return ;
state . selectedItemId = Number ( btn . dataset . itemSel );
renderStudio ();
})
);
/* ---- Visio-style drag & drop ---- */
const ghost = $ ( "#rs-drag-ghost" , el );
let dragPayload = null ;
function clearDropHighlight () {
el . querySelectorAll ( ".rs-u.drop-over, .rs-u.drop-span, .rs-u.drop-bad" ). forEach (( n ) => {
n . classList . remove ( "drop-over" , "drop-span" , "drop-bad" );
});
}
function highlightSpan ( uStart , h , ok ) {
clearDropHighlight ();
const span = uSpan ( uStart , h );
for ( let u = span . lo ; u <= span . hi ; u ++ ) {
const zone = el . querySelector ( `.rs-u.empty[data-u=" ${ u } "]` );
if ( ! zone ) continue ;
zone . classList . add ( ok ? "drop-span" : "drop-bad" );
if ( u === uStart ) zone . classList . add ( "drop-over" );
}
el . classList . add ( "rs-dragging" );
return span ;
}
function parseDrag ( raw ) {
try {
return JSON . parse ( raw );
} catch {
return null ;
}
}
function resolveHeight ( payload ) {
if ( ! payload ) return state . placeH || 1 ;
if ( payload . sku ) return catalogHeight ( payload . sku , payload . u_height || 1 );
return Math . max ( 1 , Number ( payload . u_height ) || state . placeH || 1 );
}
function stencilDragStart ( node , e ) {
const sku = node . dataset . sku || null ;
const deviceId = node . dataset . place ? Number ( node . dataset . place ) : null ;
const h = Number ( node . dataset . h || 1 );
const catHit = ( sku && catalog . find (( c ) => c . sku === sku )) || null ;
const unHit = deviceId ? unplaced . find (( d ) => d . id === deviceId ) : null ;
const name = node . dataset . name || catHit ? . name || unHit ? . name || "Device" ;
const face = node . dataset . face || catHit ? . face || unHit ? . face || "server_1u" ;
const image = node . dataset . img || catHit ? . image || unHit ? . image || "" ;
const uHeight = sku
? catalogHeight ( sku , h )
: h > 0
? h
: catHit ? . u_height > 0
? catHit . u_height
: 1 ;
dragPayload = {
kind : "stencil" ,
sku : sku || null ,
device_id : deviceId ,
u_height : uHeight ,
name ,
image ,
face ,
};
state . placeSku = sku || null ;
state . placeDeviceId = deviceId ;
state . placeH = dragPayload . u_height ;
e . dataTransfer . effectAllowed = "copy" ;
const raw = JSON . stringify ( dragPayload );
e . dataTransfer . setData ( "application/x-rack-item" , raw );
e . dataTransfer . setData ( "text/plain" , raw );
if ( ghost ) {
ghost . hidden = false ;
const code = shortModel ( name );
ghost . innerHTML = ` ${ productPhoto ({ face , image } , name, { compact: true })}
<span class="rs-ghost-meta">
<span class="rs-ghost-code"> ${ escape ( code ) } </span>
<strong> ${ escape ( name ) } </strong>
<span> ${ uHeight } U · ${ escape ( face . replace ( /_/g , " " )) } </span>
</span>` ;
try {
e . dataTransfer . setDragImage ( ghost , 48 , 24 );
} catch ( _ ) {}
}
el . classList . add ( "rs-dragging" );
2026-07-18 21:56:30 +02:00
setStatus ( `Dragging ${ name } ( ${ uHeight } U) — drop on the bottom U (fills upward)` );
2026-07-18 01:18:14 +02:00
node . classList . add ( "dragging" );
}
function itemDragStart ( node , e ) {
if ( e . target . closest ( "[data-item-del]" )) {
e . preventDefault ();
return ;
}
state . _dragMoved = false ;
const h = Math . max ( 1 , Number ( node . dataset . h || 1 ));
dragPayload = {
kind : "move" ,
item_id : Number ( node . dataset . itemId ),
u_height : h ,
name : node . querySelector ( ".rs-u-plate strong" ) ? . textContent || node . querySelector ( "strong" ) ? . textContent || "Device" ,
};
e . dataTransfer . effectAllowed = "move" ;
const raw = JSON . stringify ( dragPayload );
e . dataTransfer . setData ( "application/x-rack-item" , raw );
e . dataTransfer . setData ( "text/plain" , raw );
el . classList . add ( "rs-dragging" );
2026-07-18 21:56:30 +02:00
setStatus ( `Moving ( ${ h } U): drop on a new bottom U` );
2026-07-18 01:18:14 +02:00
node . classList . add ( "dragging" );
}
el . querySelectorAll ( ".rs-stencil" ). forEach (( node ) => {
node . addEventListener ( "dragstart" , ( e ) => stencilDragStart ( node , e ));
node . addEventListener ( "dragend" , () => {
node . classList . remove ( "dragging" );
clearDropHighlight ();
el . classList . remove ( "rs-dragging" );
if ( ghost ) ghost . hidden = true ;
dragPayload = null ;
2026-07-18 21:56:30 +02:00
setStatus ( "Drag stencils from the library onto a bottom U" );
2026-07-18 01:18:14 +02:00
});
// click still selects for keyboard users
node . addEventListener ( "click" , () => {
if ( node . dataset . sku ) {
state . placeSku = node . dataset . sku ;
state . placeDeviceId = null ;
state . placeH = Number ( node . dataset . h || 1 );
} else if ( node . dataset . place ) {
state . placeDeviceId = Number ( node . dataset . place );
state . placeSku = node . dataset . sku || null ;
state . placeH = Number ( node . dataset . h || 1 );
}
2026-07-18 21:56:30 +02:00
setStatus ( `Selected: ${ node . dataset . name } — drag or click a U` );
2026-07-18 01:18:14 +02:00
el . querySelectorAll ( ".rs-stencil" ). forEach (( n ) => n . classList . toggle ( "active" , n === node ));
});
});
el . querySelectorAll ( ".rs-u.filled" ). forEach (( node ) => {
node . addEventListener ( "dragstart" , ( e ) => itemDragStart ( node , e ));
node . addEventListener ( "dragend" , () => {
node . classList . remove ( "dragging" );
clearDropHighlight ();
el . classList . remove ( "rs-dragging" );
dragPayload = null ;
setTimeout (() => {
state . _dragMoved = false ;
}, 0 );
});
});
el . querySelectorAll ( "[data-drop-u]" ). forEach (( zone ) => {
zone . addEventListener ( "dragover" , ( e ) => {
e . preventDefault ();
const rk = focusRack ();
const u = Number ( zone . dataset . u );
const h = resolveHeight ( dragPayload );
const exclude = dragPayload ? . kind === "move" ? dragPayload . item_id : null ;
const ok = unitsFree ( rk , u , h , exclude );
e . dataTransfer . dropEffect = ok ? ( dragPayload ? . kind === "move" ? "move" : "copy" ) : "none" ;
const span = highlightSpan ( u , h , ok );
if ( ok ) {
setStatus ( `Drop → ${ span . label } ( ${ span . height } U)` );
} else {
2026-07-18 21:56:30 +02:00
setStatus ( `Does not fit on ${ span . label } — overlap or outside U1– U ${ rk ? . units || 42 } ` );
2026-07-18 01:18:14 +02:00
}
});
zone . addEventListener ( "dragleave" , ( e ) => {
if ( ! zone . contains ( e . relatedTarget )) {
zone . classList . remove ( "drop-over" , "drop-span" , "drop-bad" );
}
});
zone . addEventListener ( "drop" , async ( e ) => {
e . preventDefault ();
e . stopPropagation ();
const raw =
e . dataTransfer . getData ( "application/x-rack-item" ) ||
e . dataTransfer . getData ( "text/plain" );
const payload = parseDrag ( raw ) || dragPayload ;
clearDropHighlight ();
el . classList . remove ( "rs-dragging" );
if ( ! payload ) return ;
const u = Number ( zone . dataset . u );
const h = resolveHeight ( payload );
const rk = focusRack ();
const exclude = payload . kind === "move" ? payload . item_id : null ;
if ( ! unitsFree ( rk , u , h , exclude )) {
2026-07-18 21:56:30 +02:00
setStatus ( "Drop blocked — U range is not free" );
2026-07-18 01:18:14 +02:00
return ;
}
state . _dragMoved = true ;
try {
if ( payload . kind === "move" && payload . item_id ) {
state . racks = await api ( "PATCH" , `/api/racks/items/ ${ payload . item_id } ` , {
u_start : u ,
u_height : h ,
side : state . rackSide || "front" ,
});
} else {
state . placeSku = payload . sku || null ;
state . placeDeviceId = payload . device_id || null ;
state . placeH = h ;
state . placeU = u ;
state . racks = await api ( "POST" , "/api/racks/items" , {
rack_id : state . selectedRackId ,
u_start : u ,
u_height : h ,
side : state . rackSide || "front" ,
device_id : payload . device_id || null ,
catalog_sku : payload . sku || null ,
});
state . placeSku = null ;
state . placeDeviceId = null ;
}
renderRacks ();
renderStudio ();
2026-07-18 21:56:30 +02:00
setStatus ( `Placed on ${ uSpan ( u , h ). label } ` );
2026-07-18 01:18:14 +02:00
} catch ( err ) {
alert ( "Drop failed: " + err . message );
2026-07-18 21:56:30 +02:00
setStatus ( "Drop failed — try a free U" );
2026-07-18 01:18:14 +02:00
}
});
});
// click-to-place still works when stencil preselected
el . querySelectorAll ( ".rs-u.empty" ). forEach (( node ) => {
node . addEventListener ( "click" , () => {
if ( ! state . placeDeviceId && ! state . placeSku ) return ;
const u = Number ( node . dataset . u );
const h = state . placeSku ? catalogHeight ( state . placeSku , state . placeH || 1 ) : Math . max ( 1 , state . placeH || 1 );
const rk = focusRack ();
if ( ! unitsFree ( rk , u , h , null )) {
2026-07-18 21:56:30 +02:00
setStatus ( `Does not fit on ${ uSpan ( u , h ). label } ` );
2026-07-18 01:18:14 +02:00
return ;
}
state . placeU = u ;
state . placeH = h ;
placeCurrent ( true );
});
node . addEventListener ( "mouseenter" , () => {
if ( ! state . placeDeviceId && ! state . placeSku ) return ;
if ( dragPayload ) return ;
const u = Number ( node . dataset . u );
const h = state . placeSku ? catalogHeight ( state . placeSku , state . placeH || 1 ) : Math . max ( 1 , state . placeH || 1 );
const rk = focusRack ();
const ok = unitsFree ( rk , u , h , null );
highlightSpan ( u , h , ok );
2026-07-18 21:56:30 +02:00
setStatus ( ok ? `Click → ${ uSpan ( u , h ). label } ` : `Does not fit on ${ uSpan ( u , h ). label } ` );
2026-07-18 01:18:14 +02:00
});
node . addEventListener ( "mouseleave" , () => {
if ( ! dragPayload ) clearDropHighlight ();
});
});
if ( el . _onKey ) document . removeEventListener ( "keydown" , el . _onKey );
el . _onKey = ( ev ) => {
if ( ev . key === "Escape" ) closeStudio ();
};
document . addEventListener ( "keydown" , el . _onKey );
}
async function placeCurrent ( fromStudio ) {
if ( ! state . selectedRackId ) return ;
const uStart = Number ( state . placeU || 1 );
const height = state . placeSku
? catalogHeight ( state . placeSku , state . placeH || 1 )
: Math . max ( 1 , Number ( state . placeH || 1 ));
const side = state . rackSide || "front" ;
const rk = focusRack ();
if ( ! unitsFree ( rk , uStart , height , null )) {
2026-07-18 21:56:30 +02:00
alert ( `Does not fit on ${ uSpan ( uStart , height ). label } ` );
2026-07-18 01:18:14 +02:00
return ;
}
try {
state . racks = await api ( "POST" , "/api/racks/items" , {
rack_id : state . selectedRackId ,
u_start : uStart ,
u_height : height ,
side ,
device_id : state . placeDeviceId || null ,
catalog_sku : state . placeSku || null ,
});
state . placeDeviceId = null ;
state . placeSku = null ;
renderRacks ();
if ( state . studioOpen ) renderStudio ();
} catch ( err ) {
alert ( "Place failed: " + err . message );
}
}
function renderStudioRack ( rk ) {
const units = rk . units || 42 ;
const side = state . rackSide || "front" ;
const items = ( rk . items || []). filter (( it ) => ( it . side || "front" ) === side );
const sideItems = ( rk . items || []). filter (( it ) => it . side === "left" || it . side === "right" );
const occ = new Array ( units + 1 ). fill ( null );
items . forEach (( p ) => {
for ( let u = p . u_start ; u < p . u_start + p . u_height && u <= units ; u ++ ) occ [ u ] = p ;
});
const rows = [];
for ( let u = units ; u >= 1 ; u -- ) {
const p = occ [ u ];
const isStart = p && p . u_start === u ;
if ( p && ! isStart ) continue ;
if ( p && isStart ) {
const hU = Math . max ( 1 , Number ( p . u_height ) || 1 );
const h = hU * U_STUDIO ;
const rc = roleClass ( roleOf ( p ));
const selected = state . selectedItemId === ( p . id || p . item_id );
const uLo = p . u_start ;
const uHi = p . u_start + hU - 1 ;
/* Rail labels: high U at top (rack reads top→bottom) */
const uMarks =
hU === 1
? `<span class="rs-u-num">U ${ String ( uLo ). padStart ( 2 , "0" ) } </span>`
: `<span class="rs-u-nums" title="U ${ uLo } – U${ uHi } ( ${ hU } U)">
${ Array . from ({ length : hU } , (_, i) => {
const uu = uHi - i;
return ` < span class = "rs-u-num" > U$ { String ( uu ). padStart ( 2 , "0" )} < /span>`;
}). join ( "" )}
< /span>`;
rows . push ( `<div class="rs-u filled role- ${ rc } ${ selected ? "sel" : "" } " draggable="true"
data-u=" ${ u } " data-item-sel=" ${ p . id || p . item_id } " data-item-id=" ${ p . id || p . item_id } "
data-h=" ${ hU } " style="height: ${ h } px" title="U ${ uLo } – U${ uHi } · Drag to move · click to select">
${ uMarks }
${ facePlate ( p ) }
<button type="button" class="rs-u-x" data-item-del=" ${ p . id || p . item_id } " title="Remove">× </button>
</div>` );
} else {
rows . push ( `<div class="rs-u empty drop-zone" data-u=" ${ u } " data-drop-u="1" style="height: ${ U_STUDIO } px">
<span class="rs-u-num">U ${ String ( u ). padStart ( 2 , "0" ) } </span>
<span class="rs-u-ghost">drop</span>
</div>` );
}
}
const fill = rackFillPct ( rk );
return `<div class="rs-cabinet">
<div class="rs-cab-head">
<div>
<strong> ${ escape ( rk . name ) } </strong>
<span> ${ side } · ${ fill . used } U used · ${ fill . free } U free · scroll for all 42U</span>
</div>
<div class="rs-side-pdu"> ${
sideItems . map (( s ) => `<span> ${ escape ( s . side ) } : ${ escape ( s . label || s . catalog ? . name || "PDU" ) } </span>` ). join ( "" ) ||
"<span class='muted'>No side PDUs — place 0U PDU on left/right</span>"
} </div>
</div>
<div class="rs-frame">
<div class="rs-rail" aria-hidden="true"></div>
<div class="rs-stack"> ${ rows . join ( "" ) } </div>
<div class="rs-rail" aria-hidden="true"></div>
</div>
</div>` ;
}
function renderStudioInspector ( rk ) {
const side = state . rackSide || "front" ;
const items = [...( rk . items || [])]
. filter (( it ) => ( it . side || "front" ) === side )
. sort (( a , b ) => b . u_start - a . u_start );
const fill = rackFillPct ( rk );
const sel = items . find (( i ) => ( i . id || i . item_id ) === state . selectedItemId );
return `
<div class="rs-insp-head">
<strong> ${ escape ( rk . name ) } </strong>
<span> ${ items . length } items · ${ fill . pct } % full</span>
</div>
<div class="rd-fillbar"><i style="width: ${ fill . pct } %"></i></div>
${
sel
? `<div class="rd-insp-card">
<h4>Selected</h4>
<strong> ${ escape ( sel . label || "" ) } </strong>
<p> ${ escape ( sel . catalog ? . name || sel . device ? . model || "" ) } </p>
<p class="mono"> ${ uSpan ( sel . u_start , sel . u_height ). label } · ${ escape ( sel . side || "front" ) } </p>
${ sel . device ? . service_tag ? `<p class="mono">ST ${ escape ( sel . device . service_tag ) } </p>` : "" }
${ sel . device ? . ip ? `<p class="mono"> ${ escape ( sel . device . ip ) } </p>` : "" }
<button type="button" class="btn" data-item-del=" ${ sel . id || sel . item_id } " style="margin-top:0.4rem">Remove</button>
</div>`
: `<p class="hint">Select a device in the rack for details</p>`
}
<h4 class="rd-insp-list-h">Rack contents (U42 → U1)</h4>
<div class="rd-insp-list">
${
items
. map (
( it ) => `<button type="button" class="rd-insp-row ${ state . selectedItemId === ( it . id || it . item_id ) ? "active" : "" } " data-item-sel=" ${ it . id || it . item_id } ">
<span class="rd-lib-swatch role- ${ roleClass ( roleOf ( it )) } "></span>
<span><strong> ${ uSpan ( it . u_start , it . u_height ). label } </strong> ${ escape ( it . label || "" ) } </span>
</button>`
)
. join ( "" ) || '<p class="hint">Empty — pick from Dell products</p>'
}
</div>
` ;
}
/* ===== VLANs ===== */
2026-07-18 21:56:30 +02:00
function rackInfoForDevice ( deviceId ) {
if ( deviceId == null || ! state . racks ? . sites ) return null ;
for ( const site of state . racks . sites ) {
for ( const rk of site . racks || []) {
for ( const it of rk . items || []) {
if ( it . device_id === deviceId ) {
const span = uSpan ( it . u_start , it . u_height || 1 );
return {
site : site . name || site . id ,
rack : rk . name ,
uLabel : span . label ,
side : it . side || "front" ,
};
}
}
}
}
return null ;
}
function switchLinksForDevice ( deviceId ) {
const fromMember = [];
// Prefer API-enriched port_links (all switches), fall back to selected faceplate
const all = state . vlans ? . port_links || [];
all
. filter (( l ) => l . device_id === deviceId )
. forEach (( l ) => fromMember . push ({ port : l . switch_port , switch_id : l . switch_id }));
if ( fromMember . length ) return fromMember ;
const ports = state . portmap ? . ports || [];
return ports . filter (( p ) => p . wired && p . link ? . device_id === deviceId );
}
function formatSwitchPorts ( ports ) {
if ( ! ports || ! ports . length ) return "none mapped" ;
return ports
. map (( p ) => {
const name = p . switch_name || ( p . switch_id != null ? `sw ${ p . switch_id } ` : "switch" );
const port = p . switch_port != null ? `P ${ p . switch_port } ` : p . port != null ? `P ${ p . port } ` : "port ?" ;
const hint = p . inferred ? " (hint)" : "" ;
return ` ${ name } · ${ port }${ hint } ` ;
})
. join ( "; " );
}
2026-07-18 01:18:14 +02:00
function renderVlans () {
const root = $ ( "#network-vlans" );
if ( ! root || ! state . vlans ) return ;
const vlans = state . vlans . vlans || [];
if ( state . selectedVlanId == null && vlans [ 0 ]) state . selectedVlanId = vlans [ 0 ]. id ;
const sel = vlans . find (( v ) => v . id === state . selectedVlanId ) || vlans [ 0 ];
2026-07-18 21:56:30 +02:00
const members = sel ? . members || [];
const up = members . filter (( m ) => m . connected ). length ;
const roles = {};
members . forEach (( m ) => {
const r = ( m . role || "other" ). toLowerCase ();
roles [ r ] = ( roles [ r ] || 0 ) + 1 ;
});
const matrix = state . vlans . attachment_matrix || [];
const matrixFocus = matrix . filter (( row ) => {
if ( ! sel ) return true ;
return ( row . vlans || []). some (( v ) => v . vlan_id === sel . vlan_id || v . cidr === sel . cidr );
});
2026-07-18 01:18:14 +02:00
root . innerHTML = `
<div class="vlan-layout">
<aside class="vlan-list-pane">
<h4>VLAN / subnet catalog</h4>
<p class="hint"> ${ escape ( state . vlans . note || "" ) } </p>
<div class="vlan-cards">
${ vlans
. map (( v ) => {
const active = v . id === state . selectedVlanId ? " active" : "" ;
2026-07-18 21:56:30 +02:00
return `<button type="button" class="vlan-card ${ active } " data-vlan=" ${ v . id } " style="--vlan: ${ escape ( v . color || "#ff9a3c" ) } ">
2026-07-18 01:18:14 +02:00
<span class="vlan-id">VLAN ${ v . vlan_id ?? "—" } </span>
<strong> ${ escape ( v . name ) } </strong>
<span class="mono"> ${ escape ( v . cidr ) } </span>
2026-07-18 21:56:30 +02:00
<span class="vlan-card-meta"> ${ v . member_count } nodes · ${ v . connected_count } up · ${ escape ( v . site_id || "—" ) } </span>
2026-07-18 01:18:14 +02:00
</button>` ;
} )
.join("")}
</div>
${ ( state . vlans . discovered_subnets || []). length
? `<h4>Discovered (not in catalog)</h4>
<ul class="vlan-discovered">
${ ( state . vlans . discovered_subnets || [])
2026-07-18 21:56:30 +02:00
. map (( d ) => `<li><span class="mono"> ${ escape ( d . cidr ) } </span> · ${ d . device_ips } device IPs seen</li>` )
2026-07-18 01:18:14 +02:00
. join ( "" ) }
</ul>`
: "" }
</aside>
<section class="vlan-detail-pane">
${
sel
2026-07-18 21:56:30 +02:00
? `<header class="vlan-detail-head" style="border-color: ${ escape ( sel . color || "#ff9a3c" ) } ">
2026-07-18 01:18:14 +02:00
<div>
<h3>VLAN ${ sel . vlan_id ?? "—" } · ${ escape ( sel . name ) } </h3>
2026-07-18 21:56:30 +02:00
<p class="mono"> ${ escape ( sel . cidr ) } </p>
<p class="vlan-purpose"> ${ escape ( sel . purpose || "No purpose set" ) } · site ${ escape ( sel . site_id || "—" ) } </p>
</div>
<div class="vlan-stat-stack">
<span class="vlan-stat"> ${ members . length } nodes</span>
<span class="vlan-stat soft"> ${ up } connected</span>
2026-07-18 01:18:14 +02:00
</div>
</header>
2026-07-18 21:56:30 +02:00
<div class="vlan-context">
<div class="vlan-kpi"><strong> ${ members . length } </strong><span>Endpoints</span></div>
<div class="vlan-kpi"><strong> ${ up } </strong><span>Online</span></div>
<div class="vlan-kpi"><strong> ${ members . length - up } </strong><span>Offline</span></div>
<div class="vlan-kpi"><strong> ${ Object . keys ( roles ). length } </strong><span>Roles</span></div>
</div>
${
Object . keys ( roles ). length
? `<div class="vlan-role-pills"> ${ Object . entries ( roles )
. sort (( a , b ) => b [ 1 ] - a [ 1 ])
. map (([ r , n ]) => `<span class="vlan-role-pill"> ${ escape ( r ) } · ${ n } </span>` )
. join ( "" ) } </div>`
: ""
}
<h4 class="vlan-nodes-h">Server → switch / VLAN map</h4>
<div class="vlan-matrix-wrap">
<table class="vlan-matrix">
<thead>
<tr>
<th>Server</th>
<th>Model</th>
<th>VLANs</th>
<th>Switch / port</th>
<th>Note</th>
</tr>
</thead>
<tbody>
${
( matrixFocus . length ? matrixFocus : matrix )
. slice ( 0 , 120 )
. map (( row ) => {
const vlanBits = ( row . vlans || [])
. map (
( v ) =>
`<span class="vlan-chip" style="--vlan: ${ escape ( v . color || "#888" ) } ">V ${ v . vlan_id ?? "?" } ${ escape ( v . name || "" ) } </span>`
)
. join ( " " );
const note = row . user_note || row . endpoint_note || "" ;
return `<tr>
<td>
<strong> ${ escape ( row . name ) } </strong>
<div class="muted mono"> ${ escape (( row . ips || []). slice ( 0 , 3 ). join ( ", " )) }${ row . service_tag ? " · " + escape ( row . service_tag ) : "" } </div>
</td>
<td> ${ escape ( row . model || "—" ) } </td>
<td> ${ vlanBits || "—" } </td>
<td class="mono"> ${ escape ( formatSwitchPorts ( row . switch_ports )) } </td>
<td class="vlan-note-cell"> ${ escape ( note || "—" ) } </td>
</tr>` ;
} )
.join("") || ` < tr >< td colspan = "5" class = "hint" > No server attachments yet . < /td></tr>`
}
</tbody>
</table>
</div>
<h4 class="vlan-nodes-h">Add / note a host</h4>
<form class="vlan-add-host" id="vlan-add-host">
<input name="hostname" placeholder="hostname" required />
<input name="ip" class="mono" placeholder="10.0.x.x" required />
<input name="model" placeholder="model (optional)" />
<input name="user_note" placeholder="manual note" />
<button type="submit" class="btn">Add host</button>
</form>
<h4 class="vlan-nodes-h">Coupled nodes</h4>
2026-07-18 01:18:14 +02:00
<div class="vlan-members">
2026-07-18 21:56:30 +02:00
${
members
. map (( m ) => {
const rack = rackInfoForDevice ( m . id );
const links = m . switch_ports ? . length ? m . switch_ports : switchLinksForDevice ( m . id );
const src = m . source === "inventory" ? "inventory" : m . ome_name && m . ome_name !== m . name ? m . ome_name : "" ;
const eid = m . endpoint_id ;
return `<article class="vlan-member ${ m . connected ? "up" : "down" } " data-endpoint-id=" ${ eid || "" } ">
<div class="vlan-member-top">
<strong> ${ escape ( m . name ) } </strong>
<span class="vlan-badge ${ m . connected ? "on" : "off" } "> ${ m . connected ? "online" : "offline" } </span>
</div>
<div class="vlan-member-grid">
<span><em>Role</em> ${ escape ( m . role || "—" ) } </span>
<span><em>Model</em> ${ escape ( m . model || "—" ) } </span>
<span><em>Service Tag</em> <span class="mono"> ${ escape ( m . service_tag || "—" ) } </span></span>
<span><em>IPs in VLAN</em> <span class="mono"> ${ escape (( m . ips || []). join ( ", " ) || "—" ) } </span></span>
<span><em>Rack</em> ${ rack ? ` ${ escape ( rack . site ) } / ${ escape ( rack . rack ) } · ${ rack . uLabel } ` : "not placed" } </span>
<span><em>Switch ports</em> ${ escape ( formatSwitchPorts ( links )) } </span>
${ src ? `<span><em>OME</em> ${ escape ( src ) } </span>` : "" }
${ m . endpoint_note ? `<span><em>System note</em> ${ escape ( m . endpoint_note ) } </span>` : "" }
</div>
<div class="vlan-note-edit">
<label>Manual note</label>
<textarea data-note-for=" ${ eid || "" } " rows="2" placeholder=" ${ eid ? "Type a note and save…" : "Host not yet in inventory — use Add host above" } " ${ eid ? "" : " disabled" } > ${ escape ( m . user_note || "" ) } </textarea>
<button type="button" class="btn ghost" data-save-note=" ${ eid || "" } " ${ eid ? "" : "disabled" } >Save note</button>
<span class="vlan-note-status" data-note-status=" ${ eid || "" } "></span>
</div>
</article>` ;
} )
.join("") || '<p class="hint">No live members matched this CIDR yet.</p>'
}
2026-07-18 01:18:14 +02:00
</div>`
: '<p class="hint">No VLANs</p>'
}
< /section>
< /div>
`;
root.querySelectorAll("[data-vlan]").forEach((btn) => {
btn.addEventListener("click", () => {
state.selectedVlanId = Number(btn.dataset.vlan);
renderVlans();
});
});
2026-07-18 21:56:30 +02:00
root.querySelectorAll("[data-save-note]").forEach((btn) => {
btn.addEventListener("click", async () => {
const eid = Number(btn.dataset.saveNote);
if (!eid) return;
const ta = root.querySelector(` textarea [ data - note - for = "${eid}" ] `);
const status = root.querySelector(` [ data - note - status = "${eid}" ] `);
try {
if (status) status.textContent = "Saving…";
const res = await api("PATCH", ` / api / network / endpoints / $ { eid } `, { user_note: ta?.value || "" });
if (res.vlans) state.vlans = res.vlans;
if (status) status.textContent = "Saved";
renderVlans();
} catch (e) {
if (status) status.textContent = "Error: " + e.message;
}
});
});
const addForm = root.querySelector("#vlan-add-host");
addForm?.addEventListener("submit", async (e) => {
e.preventDefault();
const fd = new FormData(addForm);
const body = {
hostname: String(fd.get("hostname") || "").trim(),
ip: String(fd.get("ip") || "").trim(),
model: String(fd.get("model") || "").trim() || null,
user_note: String(fd.get("user_note") || "").trim() || null,
vlan_id: sel?.vlan_id ?? null,
role: "server",
kind: "host",
};
try {
const res = await api("POST", "/api/network/endpoints", body);
if (res.vlans) state.vlans = res.vlans;
renderVlans();
} catch (err) {
alert("Add host failed: " + err.message);
}
});
2026-07-18 01:18:14 +02:00
}
function bind() {
$("#btn-network")?.addEventListener("click", openNetwork);
$("#btn-network-close")?.addEventListener("click", closeNetwork);
$("#network-tabs")?.addEventListener("click", (e) => {
const chip = e.target.closest("[data-ntab]");
if (!chip) return;
showTab(chip.dataset.ntab);
});
$("#scrim")?.addEventListener("click", () => {
if ($("#scrim")?.dataset.mode === "network-drawer") closeNetwork();
});
window.addEventListener("resize", () => {
if (state.tab === "fabric") drawCables();
});
}
bind();
window.cockpitNetwork = { open: openNetwork, close: closeNetwork, refresh };
})();