2026-11-10 20:59:30 -05:00
'use strict' ;
2026-11-11 00:12:07 -05:00
/* global FACEPLATES, renderFaceplate, RACK_CAN_EDIT, RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg, shelfSvg */
2026-11-10 20:59:30 -05:00
// Rack page: draws the rack front from /api/rack. Switches use their real faceplates,
// patch panels a generic panel; hovering a port lights up every port on the same cable run.
const esc = s => lt . escHtml ( s == null ? '' : String ( s ));
const RATIO = 10.5 ; // a 19" rack's width to one unit's height
const KIND_TEXT = { patch_panel : 'Patch panel' , wall_jack : 'Wall jack' , device : 'Device' , shelf : 'Shelf' , other : 'Other' };
let _rack = null ;
function speedText ( mbps ) {
if ( ! mbps ) return '' ;
return mbps >= 1000 ? ` ${ mbps / 1000 } G` : ` ${ mbps } M` ;
}
function portMapOf ( ports ) {
const map = {};
Object . entries ( ports || {}). forEach (([ name , p ]) => {
if ( p . port_idx != null ) map [ p . port_idx ] = Object . assign ({ name }, p );
});
return map ;
}
function portTitle ( port , idx ) {
const parts = [ port && port . name && port . name !== `Port ${ idx } ` ? `Port ${ idx } " ${ port . name } "` : `Port ${ idx } ` ];
if ( port ) {
parts . push ( port . up ? ( speedText ( port . speed_mbps ) || 'up' ) : 'down' );
const nb = port . lldp && ( port . lldp . system_name || port . lldp . chassis_id );
if ( nb ) parts . push ( `→ ${ nb } ` );
}
return esc ( parts . join ( ' · ' ));
}
function isRackFaceplate ( model ) {
const spec = FACEPLATES [ model ];
return !! spec && ! /fp-desk/ . test ( spec . frameClass || '' );
}
// ── Item drawings ───────────────────────────────────────────────────────
2026-11-10 23:31:19 -05:00
function switchHtml ( item , model ) {
// A live switch, or a faceplate picked by Front drawing (no port data: drawn unlinked)
const sw = item . switch || { model , ports : {}, outlets : [], online : item . status === 'up' };
return renderFaceplate ( model || sw . model , {
2026-11-10 21:44:59 -05:00
swName : item . device , portMap : portMapOf ( sw . ports ), outlets : sw . outlets , online : sw . online , rack : true ,
2026-11-10 20:59:30 -05:00
titleFor : ( port , idx ) => portTitle ( port , idx ),
cutsheetFor : idx => {
const m = item . ports [ String ( idx )];
return m ? { mark : m . mark , text : m . text } : null ;
},
});
}
function panelSvg ( item ) {
const count = Math . max ( 1 , item . port_count || 24 );
const units = item . rack_height || 1 ;
const rows = Math . max ( 1 , Math . min ( units * 2 , Math . ceil ( count / 24 )));
const perRow = Math . ceil ( count / rows );
const W = 1100 , H = 105 * units , ear = 70 ;
const pitch = Math . min ( 38 , ( W - ear * 2 - 40 ) / perRow );
const left = ( W - pitch * perRow ) / 2 ;
const rowH = ( H - 20 ) / rows ;
// Label characters that fit under one port at the label's font size (9px, ~5.6px a character)
const fit = Math . max ( 3 , Math . floor (( pitch - 2 ) / 5.6 ));
2026-11-10 23:02:18 -05:00
const short = ( t , max = fit ) => ( t . length > max ? ` ${ t . slice ( 0 , max - 1 ) } …` : t );
2026-11-10 20:59:30 -05:00
const ports = [];
for ( let n = 1 ; n <= count ; n ++ ) {
const r = Math . floor (( n - 1 ) / perRow ), c = ( n - 1 ) % perRow ;
const x = left + c * pitch + pitch / 2 , y = 10 + r * rowH + rowH / 2 ;
const m = item . ports [ String ( n )];
const label = item . labels [ String ( n )] || '' ;
const title = [ `Port ${ n } ` , label && `" ${ label } "` , m ? m . text : 'not on the cutsheet' ]. filter ( Boolean ). join ( ' · ' );
const attrs = m ? ` data-run=" ${ m . run . join ( ' ' ) } " data-cable=" ${ m . cable_id } " tabindex="0" role="link"` : '' ;
ports . push ( `<g class="rack-pp-port ${ m ? ` rack-pp- ${ m . mark } ` : '' } " ${ attrs } transform="translate( ${ x } ${ y } )">
<title> ${ esc ( title ) } </title>
<text class="rack-pp-num" y="-17" text-anchor="middle"> ${ n } </text>
<rect class="rack-pp-jack" x="-12" y="-10" width="24" height="20" rx="2"/>
<rect class="rack-pp-hit" x=" ${ - pitch / 2 } " y="-28" width=" ${ pitch } " height="56"/>
${ label ? `<text class="rack-pp-label" y="24" text-anchor="middle"> ${ esc ( short ( label )) } </text>` : '' }
</g>` );
}
return `<svg class="rack-svg" viewBox="0 0 ${ W } ${ H } " preserveAspectRatio="xMidYMid meet" role="group"
aria-label=" ${ esc ( item . name ) } patch panel">
<rect class="rack-pp-body" width=" ${ W } " height=" ${ H } " rx="3"/>
2026-11-10 23:02:18 -05:00
<text class="rack-pp-name" x="14" y="17"><title> ${ esc ( item . name ) } </title> ${ esc ( short ( item . name , 48 )) } </text>
2026-11-10 20:59:30 -05:00
${ ports . join ( '' ) }
</svg>` ;
}
2026-11-10 21:44:59 -05:00
const STATUS_CLASS = { up : 'dot-up' , down : 'dot-down' , degraded : 'dot-degraded' };
function statusDot ( status ) {
return status ? `<span class=" ${ STATUS_CLASS [ status ] || 'dot-unknown' } " title=" ${ esc ( status ) } "></span>` : '' ;
}
2026-11-10 23:39:27 -05:00
// Ubiquiti UACC-Rack-Panel-Patch-Blank-24, laid out from the CC0 NetBox devicetype-library image
// (elevation-images/Ubiquiti/ubiquiti-uacc-rack-panel-patch-blank-24.front.png): a grey steel face,
// darker ears with oval holes, 24 square keystone openings in one row with the cable-support bar
// behind them, and a narrow cable slot at each end. A port with a documented cable gets a keystone jack.
function uaccPanelSvg ( item ) {
const W = 1178 , H = 108 , x0 = 107 , pitch = 40.5 , ow = 34 , oy = 36 , oh = 40 ;
const count = Math . min ( item . port_count || 24 , 24 );
const ports = [];
for ( let n = 1 ; n <= count ; n ++ ) {
const x = x0 + ( n - 1 ) * pitch ;
const m = item . ports [ String ( n )];
const label = item . labels [ String ( n )] || '' ;
const title = [ `Port ${ n } ` , label && `" ${ label } "` , m ? m . text : 'not on the cutsheet' ]. filter ( Boolean ). join ( ' · ' );
const attrs = m ? ` data-run=" ${ m . run . join ( ' ' ) } " data-cable=" ${ m . cable_id } " tabindex="0" role="link"` : '' ;
const jack = m ? `<rect class="ua-keystone" x=" ${ x + 3 } " y=" ${ oy + 3 } " width=" ${ ow - 6 } " height=" ${ oh - 6 } " rx="2"/>
<path class="ua-keystone-hole" d="M ${ x + 7 } ${ oy + 13 } h20 v14 h-4 v4 h-12 v-4 h-4 z"/>` : '' ;
ports . push ( `<g class="rack-pp-port ua-port ${ m ? ` rack-pp- ${ m . mark } ` : '' } " ${ attrs } >
<title> ${ esc ( title ) } </title>
<rect class="ua-open" x=" ${ x } " y=" ${ oy } " width=" ${ ow } " height=" ${ oh } "/>
<rect class="ua-bar" x=" ${ x } " y=" ${ oy + 19 } " width=" ${ ow } " height="5"/>
${ jack }
<rect class="rack-pp-jack ua-mark" x=" ${ x - 1 } " y=" ${ oy - 1 } " width=" ${ ow + 2 } " height=" ${ oh + 2 } " rx="1"/>
<text class="ua-num" x=" ${ x + ow / 2 } " y=" ${ oy - 6 } " text-anchor="middle"> ${ n } </text>
${ label ? `<text class="ua-label" x=" ${ x + ow / 2 } " y=" ${ oy + oh + 12 } " text-anchor="middle"> ${ esc ( label . length > 7 ? ` ${ label . slice ( 0 , 6 ) } …` : label ) } </text>` : '' }
<rect class="rack-pp-hit" x=" ${ x - 3 } " y=" ${ oy - 16 } " width=" ${ pitch } " height=" ${ oh + 32 } "/>
</g>` );
}
const hole = ( x , y ) => `<rect class="ua-hole" x=" ${ x } " y=" ${ y } " width="22" height="13" rx="6.5"/>` ;
return `<svg class="rack-svg" viewBox="0 0 ${ W } ${ H } " preserveAspectRatio="none" role="group"
aria-label=" ${ esc ( item . name ) } patch panel">
<title> ${ esc ( item . name ) } </title>
<rect class="ua-ear" x="0" y="2" width="44" height=" ${ H - 4 } " rx="3"/>
<rect class="ua-ear" x=" ${ W - 44 } " y="2" width="44" height=" ${ H - 4 } " rx="3"/>
${ hole ( 8 , 12 ) }${ hole ( 8 , 83 ) }${ hole ( W - 30 , 12 ) }${ hole ( W - 30 , 83 ) }
<rect class="ua-face" x="36" y="0" width=" ${ W - 72 } " height=" ${ H } " rx="2"/>
<rect class="ua-lip" x="36" y="0" width=" ${ W - 72 } " height="4"/>
<rect class="ua-open" x="62" y=" ${ oy } " width="28" height=" ${ oh } "/><rect class="ua-bar" x="62" y=" ${ oy + 19 } " width="28" height="5"/>
<rect class="ua-open" x=" ${ W - 95 } " y=" ${ oy } " width="28" height=" ${ oh } "/><rect class="ua-bar" x=" ${ W - 95 } " y=" ${ oy + 19 } " width="28" height="5"/>
${ ports . join ( '' ) }
</svg>` ;
}
2026-11-10 20:59:30 -05:00
function plateHtml ( item ) {
2026-11-10 21:44:59 -05:00
const unlinked = item . kind === 'device' && ! item . linked ;
const sub = [ item . device !== item . name ? item . device : '' , KIND_TEXT [ item . kind ] || item . kind , item . location ,
unlinked ? 'not linked to a Gandalf device' : '' ]. filter ( Boolean ). join ( ' · ' );
2026-11-10 20:59:30 -05:00
return `<div class="rack-plate">
2026-11-10 21:44:59 -05:00
${ statusDot ( item . status ) } <a class="device-link rack-plate-name" href=" ${ esc ( item . url ) } "> ${ esc ( item . name ) } </a>
2026-11-10 20:59:30 -05:00
<span class="rack-plate-sub"> ${ esc ( sub ) } </span>
</div>` ;
}
2026-11-10 22:35:37 -05:00
function backHtml ( item ) {
// A full-depth item seen from the rear: its back, not its faceplate
return `<div class="rack-plate rack-back">
${ statusDot ( item . status ) } <a class="device-link rack-plate-name" href=" ${ esc ( item . url ) } "> ${ esc ( item . name ) } </a>
<span class="rack-plate-sub">back of a full-depth ${ esc ( KIND_TEXT [ item . kind ] || item . kind ). toLowerCase () } </span>
</div>` ;
}
2026-11-10 20:59:30 -05:00
function itemHtml ( item ) {
let body ;
2026-11-10 22:35:37 -05:00
if ( item . through ) body = backHtml ( item );
else if ( item . switch && isRackFaceplate ( item . switch . model )) body = switchHtml ( item );
2026-11-10 23:31:19 -05:00
else if ( CHASSIS_FACEPLATES [ item . chassis ]) body = switchHtml ( item , CHASSIS_FACEPLATES [ item . chassis ]);
2026-11-10 23:25:02 -05:00
else if ( item . chassis && RACK_FRONTS [ item . chassis ]) body = rackFrontSvg ( item . chassis , item );
2026-11-10 23:39:27 -05:00
else if ( item . kind === 'patch_panel' && item . chassis === 'uacc-patch-24' ) body = uaccPanelSvg ( item );
2026-11-10 20:59:30 -05:00
else if ( item . kind === 'patch_panel' ) body = panelSvg ( item );
2026-11-11 00:12:07 -05:00
else if ( item . kind === 'shelf' ) body = shelfSvg ( item , esc );
2026-11-10 20:59:30 -05:00
else body = plateHtml ( item );
return `<div class="rack-item rack-kind- ${ esc ( item . kind ) } " data-item=" ${ esc ( item . id ) } "> ${ body } </div>` ;
}
function uLabels ( top , bottom ) {
const spans = [];
for ( let u = top ; u >= bottom ; u -- ) spans . push ( `<span> ${ u } </span>` );
return `<div class="rack-u" aria-hidden="true"> ${ spans . join ( '' ) } </div>` ;
}
2026-11-10 21:44:59 -05:00
// Long runs of empty units are folded into one thin band unless the viewer asks for them
const GAP_MIN = 3 ;
function showEmpty () {
try { return localStorage . getItem ( 'gandalf.rackShowEmpty' ) === '1' ; } catch ( _ ) { return false ; }
}
function gapRow ( top , bottom ) {
const n = top - bottom + 1 ;
return `<div class="rack-row rack-row-gap" data-gap=" ${ n } " style="--n: ${ n } ">
<div class="rack-u" aria-hidden="true"></div>
2026-11-10 23:37:56 -05:00
<div class="rack-gap">U ${ bottom } - ${ top } · ${ n } U free</div>
2026-11-10 21:44:59 -05:00
</div>` ;
}
2026-11-10 20:59:30 -05:00
function row ( top , bottom , inner , cls ) {
const n = top - bottom + 1 ;
return `<div class="rack-row ${ cls } " style="--n: ${ n } ">
${ uLabels ( top , bottom ) }
<div class="rack-slot" style="aspect-ratio: ${ RATIO } / ${ n } "> ${ inner } </div>
</div>` ;
}
// ── Page ────────────────────────────────────────────────────────────────
2026-11-10 21:44:59 -05:00
function renderTabs ( data ) {
const tabs = document . getElementById ( 'rack-tabs' );
tabs . hidden = data . racks . length < 2 ;
tabs . innerHTML = data . racks . map ( r => {
const on = r . name === data . rack ;
return `<button type="button" class="rack-tab ${ on ? ' active' : '' } " role="tab" aria-selected=" ${ on } "
data-rack=" ${ esc ( r . name ) } "> ${ esc ( r . name ) } <span class="rack-tab-count"> ${ r . count } </span></button>` ;
}). join ( '' );
2026-11-11 00:12:07 -05:00
const VIEW_TITLE = { front : 'front' , behind : 'rear posts, from the front' , rear : 'rear' };
document . getElementById ( 'rack-title' ). textContent = ` ${ data . rack } · ${ VIEW_TITLE [ data . face ] || data . face } ` ;
2026-11-10 22:35:37 -05:00
document . querySelectorAll ( '.rack-face-btn' ). forEach ( b => {
const on = b . dataset . face === data . face ;
b . classList . toggle ( 'active' , on );
b . setAttribute ( 'aria-pressed' , on );
});
2026-11-11 00:12:07 -05:00
const r = data . racks . find ( x => x . name === data . rack ) || {};
document . querySelector ( '.rack-face-btn[data-face="behind"]' ). textContent = r . behind ? `Rear posts ( ${ r . behind } )` : 'Rear posts' ;
document . querySelector ( '.rack-face-btn[data-face="rear"]' ). textContent = r . rear ? `Rear ( ${ r . rear } )` : 'Rear' ;
2026-11-10 21:44:59 -05:00
}
2026-11-10 20:59:30 -05:00
function render ( data ) {
_rack = data ;
2026-11-10 21:44:59 -05:00
renderTabs ( data );
2026-11-10 20:59:30 -05:00
const byTop = Object . fromEntries ( data . items . map ( i => [ i . rack_top , i ]));
const rows = [];
let u = data . units ;
while ( u >= 1 ) {
const item = byTop [ u ];
if ( item ) {
rows . push ( row ( u , item . rack_u , itemHtml ( item ), 'rack-row-item' ));
u = item . rack_u - 1 ;
continue ;
}
let low = u ;
while ( low - 1 >= 1 && ! byTop [ low - 1 ]) low -- ;
2026-11-10 21:44:59 -05:00
rows . push ( ! showEmpty () && u - low + 1 >= GAP_MIN ? gapRow ( u , low ) : row ( u , low , '' , 'rack-row-empty' ));
2026-11-10 20:59:30 -05:00
u = low - 1 ;
}
document . getElementById ( 'rack' ). innerHTML = rows . join ( '' );
2026-11-10 22:35:37 -05:00
// Every rack device is 19" wide: stretch a faceplate to fill its slot when its drawing is close to
// the slot's proportions. A drawing far from them (the PDU's is twice as tall as a real 1U) keeps its
// shape rather than being squashed.
document . querySelectorAll ( '#rack .rack-slot' ). forEach ( slot => {
const svg = slot . querySelector ( '.fp-svg' );
if ( ! svg ) return ;
const [, , w , h ] = svg . getAttribute ( 'viewBox' ). split ( ' ' ). map ( Number );
const n = parseInt ( slot . closest ( '.rack-row' ). style . getPropertyValue ( '--n' ), 10 );
const off = ( w / h ) / ( RATIO / n );
svg . setAttribute ( 'preserveAspectRatio' , off > 0.85 && off < 1.15 ? 'none' : 'xMidYMid meet' );
});
2026-11-10 21:44:59 -05:00
document . getElementById ( 'rack-show-empty' ). checked = showEmpty ();
2026-11-10 20:59:30 -05:00
document . getElementById ( 'rack-count' ). textContent = data . items . length ;
// Faceplate ports carry their cable run too, for hover and click
document . querySelectorAll ( '.rack-item .fp-port' ). forEach ( g => {
2026-11-10 21:44:59 -05:00
const item = data . items . find ( i => i . device === g . dataset . switch );
2026-11-10 20:59:30 -05:00
const m = item && item . ports [ g . dataset . portIdx ];
if ( m ) {
g . dataset . run = m . run . join ( ' ' );
g . dataset . cable = m . cable_id ;
}
});
const notes = [];
data . overlaps . forEach ( o => notes . push ( `<strong> ${ esc ( o . name ) } </strong> ( ${ esc ( o . units ) } ) overlaps ${ esc ( o . with . join ( ', ' )) } and is not drawn.` ));
data . outside . forEach ( o => notes . push ( `<strong> ${ esc ( o . name ) } </strong> ( ${ esc ( o . units ) } ) is above the top of a ${ data . units } U rack and is not drawn.` ));
const warn = document . getElementById ( 'rack-warnings' );
warn . hidden = ! notes . length ;
warn . innerHTML = notes . length ? `<ul> ${ notes . map ( n => `<li> ${ n } </li>` ). join ( '' ) } </ul>
<p>Fix the rack unit on the <a href="/cutsheet#cs-items-section">cutsheet</a>.</p>` : '' ;
const unplaced = document . getElementById ( 'rack-unplaced' );
unplaced . innerHTML = data . unplaced . length
? `Not in the drawing (no rack unit set): ${ data . unplaced . map ( n => `<span class="cs-chip"> ${ esc ( n ) } </span>` ). join ( ' ' ) } `
: '' ;
if ( ! data . items . length ) {
document . getElementById ( 'rack' ). insertAdjacentHTML ( 'afterbegin' ,
`<div class="lt-empty-state lt-empty-state--sm rack-empty"><div class="lt-empty-state-title">Nothing placed yet</div>
<div class="lt-empty-state-text">Give panels, shelves and devices a rack unit on the <a href="/cutsheet">cutsheet</a> and they appear here.</div></div>` );
}
}
function readout ( el ) {
const out = document . getElementById ( 'rack-readout' );
if ( ! el ) {
out . textContent = 'Hover a port to follow its cable.' ;
return ;
}
const title = el . querySelector ( 'title' );
out . textContent = title ? title . textContent : '' ;
}
function highlight ( el ) {
const ids = el && el . dataset . run ? el . dataset . run . split ( ' ' ) : [];
document . querySelectorAll ( '#rack [data-run]' ). forEach ( x => {
x . classList . toggle ( 'rack-hl' , ids . length > 0 && x . dataset . run . split ( ' ' ). some ( i => ids . includes ( i )));
});
}
function portAt ( target ) {
return target . closest && target . closest ( '#rack .fp-port, #rack .rack-pp-port' );
}
function onEnter ( e ) {
const el = portAt ( e . target );
if ( ! el ) return ;
highlight ( el );
readout ( el );
}
function onLeave ( e ) {
const el = portAt ( e . target );
if ( el && ! el . contains ( e . relatedTarget )) {
highlight ( null );
readout ( null );
}
}
function openPort ( el ) {
if ( el . classList . contains ( 'fp-port' )) {
2026-11-10 23:53:18 -05:00
const item = _rack && _rack . items . find ( i => i . device === el . dataset . switch );
if ( item && ! item . linked ) {
// A faceplate drawn by Front drawing for gear Gandalf can't see (the NVR) has no device page:
// open the port's cable if one is documented, otherwise the item on the cutsheet
location . href = el . dataset . cable ? `/cutsheet#cable- ${ encodeURIComponent ( el . dataset . cable ) } ` : item . url ;
return ;
}
2026-11-10 20:59:30 -05:00
location . href = `/device/ ${ encodeURIComponent ( el . dataset . switch ) } #port- ${ encodeURIComponent ( el . dataset . portIdx ) } ` ;
} else if ( el . dataset . cable ) {
location . href = `/cutsheet#cable- ${ encodeURIComponent ( el . dataset . cable ) } ` ;
}
}
const rackEl = document . getElementById ( 'rack' );
rackEl . addEventListener ( 'mouseover' , onEnter );
rackEl . addEventListener ( 'mouseout' , onLeave );
rackEl . addEventListener ( 'focusin' , onEnter );
rackEl . addEventListener ( 'focusout' , onLeave );
rackEl . addEventListener ( 'click' , e => {
const el = portAt ( e . target );
if ( el ) openPort ( el );
});
rackEl . addEventListener ( 'keydown' , e => {
const el = portAt ( e . target );
if ( el && ( e . key === 'Enter' || e . key === ' ' )) {
e . preventDefault ();
openPort ( el );
}
});
2026-11-10 21:44:59 -05:00
function currentRack () {
return new URLSearchParams ( location . search ). get ( 'rack' ) || '' ;
}
2026-11-10 22:35:37 -05:00
function currentFace () {
2026-11-11 00:12:07 -05:00
const face = new URLSearchParams ( location . search ). get ( 'face' );
return [ 'behind' , 'rear' ]. includes ( face ) ? face : 'front' ;
2026-11-10 22:35:37 -05:00
}
function setView ( changes ) {
const q = new URLSearchParams ( location . search );
Object . entries ( changes ). forEach (([ k , v ]) => ( v ? q . set ( k , v ) : q . delete ( k )));
const qs = q . toString ();
history . replaceState ( null , '' , qs ? `? ${ qs } ` : location . pathname );
_rack = null ;
load ();
}
2026-11-10 21:44:59 -05:00
document . getElementById ( 'rack-tabs' ). addEventListener ( 'click' , e => {
const tab = e . target . closest ( '[data-rack]' );
if ( ! tab ) return ;
const first = _rack && _rack . racks [ 0 ] && _rack . racks [ 0 ]. name ;
2026-11-10 22:35:37 -05:00
setView ({ rack : tab . dataset . rack === first ? '' : tab . dataset . rack });
});
document . querySelector ( '.rack-face' ). addEventListener ( 'click' , e => {
const btn = e . target . closest ( '[data-face]' );
2026-11-11 00:12:07 -05:00
if ( btn ) setView ({ face : btn . dataset . face === 'front' ? '' : btn . dataset . face });
2026-11-10 21:44:59 -05:00
});
2026-11-10 20:59:30 -05:00
async function load () {
try {
2026-11-10 22:35:37 -05:00
const q = new URLSearchParams ();
if ( currentRack ()) q . set ( 'rack' , currentRack ());
2026-11-11 00:12:07 -05:00
if ( currentFace () !== 'front' ) q . set ( 'face' , currentFace ());
2026-11-10 22:35:37 -05:00
render ( await lt . api . get ( `/api/rack ${ q . toString () ? `? ${ q } ` : '' } ` ));
2026-11-10 20:59:30 -05:00
} catch ( err ) {
if ( _rack ) return ; // keep the last drawing on a failed refresh
document . getElementById ( 'rack' ). innerHTML = `<div class="lt-empty-state lt-empty-state--sm">
<div class="lt-empty-state-title">Could not load the rack</div>
<div class="lt-empty-state-text"> ${ esc ( err . message || err ) } </div></div>` ;
}
}
readout ( null );
load ();
setInterval (() => { if ( ! document . hidden && ! document . querySelector ( '#rack .rack-hl' )) load (); }, 60000 );
2026-11-10 21:44:59 -05:00
// ── Export (SVG / PNG) ──────────────────────────────────────────────────
// Rebuilds the drawing as one standalone SVG on a white page, for printing or planning.
// Faceplates and panels are copied from the page with their computed styles inlined,
// since the stylesheet doesn't travel with the file.
const SVG_NS = 'http://www.w3.org/2000/svg' ;
const STYLE_PROPS = [ 'fill' , 'fill-opacity' , 'stroke' , 'stroke-width' , 'stroke-opacity' , 'stroke-dasharray' ,
'stroke-linecap' , 'stroke-linejoin' , 'opacity' , 'font-size' , 'font-family' , 'font-weight' , 'letter-spacing' ,
'text-anchor' , 'dominant-baseline' , 'display' , 'visibility' ];
function inlineStyles ( src , dst ) {
const cs = getComputedStyle ( src );
dst . setAttribute ( 'style' , STYLE_PROPS . map ( p => ` ${ p } : ${ cs . getPropertyValue ( p ) } ` ). join ( ';' ));
dst . removeAttribute ( 'class' );
for ( let i = 0 ; i < src . children . length ; i ++ ) inlineStyles ( src . children [ i ], dst . children [ i ]);
}
function svgEl ( tag , attrs , text ) {
const el = document . createElementNS ( SVG_NS , tag );
Object . entries ( attrs ). forEach (([ k , v ]) => el . setAttribute ( k , v ));
if ( text != null ) el . textContent = text ;
return el ;
}
function buildExport () {
document . querySelectorAll ( '#rack .rack-hl' ). forEach ( el => el . classList . remove ( 'rack-hl' ));
const W = 1100 , gutter = 46 , pad = 20 , head = 44 , unit = W / RATIO ;
const rows = [... document . querySelectorAll ( '#rack .rack-row' )];
const height = rows . reduce (( h , r ) => h + ( r . dataset . gap ? 26 : parseInt ( r . style . getPropertyValue ( '--n' ), 10 ) * unit ), 0 );
const H = head + height + pad * 2 ;
const root = svgEl ( 'svg' , { xmlns : SVG_NS , width : W + gutter + pad * 2 , height : H ,
viewBox : `0 0 ${ W + gutter + pad * 2 } ${ H } ` , 'font-family' : 'Helvetica, Arial, sans-serif' });
root . appendChild ( svgEl ( 'rect' , { width : '100%' , height : '100%' , fill : '#ffffff' }));
const stamp = new Date (). toLocaleString ();
root . appendChild ( svgEl ( 'text' , { x : pad , y : pad + 14 , 'font-size' : 18 , 'font-weight' : 700 , fill : '#111' },
2026-11-10 22:35:37 -05:00
` ${ _rack . rack } · ${ _rack . face } ` ));
2026-11-10 21:44:59 -05:00
root . appendChild ( svgEl ( 'text' , { x : W + gutter + pad , y : pad + 14 , 'font-size' : 11 , fill : '#666' , 'text-anchor' : 'end' },
`Gandalf · ${ stamp } ` ));
let y = head + pad ;
const x = pad + gutter ;
rows . forEach ( row => {
const n = parseInt ( row . style . getPropertyValue ( '--n' ), 10 );
if ( row . dataset . gap ) {
const band = 26 ;
root . appendChild ( svgEl ( 'rect' , { x , y : y + 2 , width : W , height : band - 4 , fill : '#f4f5f7' , stroke : '#d5d9de' ,
'stroke-dasharray' : '4 4' }));
root . appendChild ( svgEl ( 'text' , { x : x + W / 2 , y : y + band / 2 + 4 , 'font-size' : 11 , fill : '#777' ,
'text-anchor' : 'middle' }, row . textContent . trim ()));
y += band ;
return ;
}
const h = n * unit ;
[... row . querySelectorAll ( '.rack-u span' )]. forEach (( s , i ) => {
root . appendChild ( svgEl ( 'text' , { x : x - 8 , y : y + unit * i + unit / 2 + 4 , 'font-size' : 11 , fill : '#777' ,
'text-anchor' : 'end' }, s . textContent ));
});
const art = row . querySelector ( 'svg.fp-svg, svg.rack-svg' );
const plate = row . querySelector ( '.rack-plate' );
if ( art ) {
const clone = art . cloneNode ( true );
inlineStyles ( art , clone );
[ 'x' , 'y' , 'width' , 'height' ]. forEach (( k , i ) => clone . setAttribute ( k , [ x , y , W , h ][ i ]));
2026-11-10 22:35:37 -05:00
clone . setAttribute ( 'preserveAspectRatio' , art . getAttribute ( 'preserveAspectRatio' ) || 'xMidYMid meet' );
2026-11-10 21:44:59 -05:00
clone . removeAttribute ( 'style' );
root . appendChild ( clone );
} else if ( plate ) {
root . appendChild ( svgEl ( 'rect' , { x , y : y + 1 , width : W , height : h - 2 , rx : 3 , fill : '#e9ecef' , stroke : '#9aa3ad' }));
const name = plate . querySelector ( '.rack-plate-name, .rack-shelf-label' );
const things = [... plate . querySelectorAll ( '.rack-shelf-name' )]. map ( t => t . textContent );
const sub = things . length ? things . join ( ' · ' ) : ( plate . querySelector ( '.rack-plate-sub' ) || {}). textContent || '' ;
root . appendChild ( svgEl ( 'text' , { x : x + 16 , y : y + h / 2 + ( sub ? - 2 : 5 ), 'font-size' : 15 , 'font-weight' : 700 ,
fill : '#111' }, name ? name . textContent : '' ));
if ( sub ) root . appendChild ( svgEl ( 'text' , { x : x + 16 , y : y + h / 2 + 15 , 'font-size' : 11 , fill : '#444' }, sub ));
} else {
root . appendChild ( svgEl ( 'rect' , { x , y : y + 1 , width : W , height : h - 2 , fill : 'none' , stroke : '#d5d9de' ,
'stroke-dasharray' : '4 4' }));
}
y += h ;
});
return new XMLSerializer (). serializeToString ( root );
}
function download ( blob , name ) {
const a = document . createElement ( 'a' );
a . href = URL . createObjectURL ( blob );
a . download = name ;
document . body . appendChild ( a );
a . click ();
a . remove ();
setTimeout (() => URL . revokeObjectURL ( a . href ), 10000 );
}
async function exportRack ( kind ) {
if ( ! _rack || ! _rack . items . length ) {
lt . toast . error ( 'Nothing in this rack to export yet' );
return ;
}
const svg = buildExport ();
2026-11-10 22:35:37 -05:00
const base = ` ${ _rack . rack . replace ( /[^\w.-]+/g , '-' ) } - ${ _rack . face } ` ;
2026-11-10 21:44:59 -05:00
if ( kind === 'svg' ) {
download ( new Blob ([ svg ], { type : 'image/svg+xml' }), ` ${ base } .svg` );
return ;
}
const img = new Image ();
const url = URL . createObjectURL ( new Blob ([ svg ], { type : 'image/svg+xml' }));
try {
await new Promise (( resolve , reject ) => { img . onload = resolve ; img . onerror = reject ; img . src = url ; });
const scale = 2 ;
const canvas = document . createElement ( 'canvas' );
canvas . width = img . width * scale ;
canvas . height = img . height * scale ;
const ctx = canvas . getContext ( '2d' );
ctx . scale ( scale , scale );
ctx . drawImage ( img , 0 , 0 );
canvas . toBlob ( blob => download ( blob , ` ${ base } .png` ), 'image/png' );
} catch ( err ) {
lt . toast . error ( 'Could not render the PNG; try the SVG' );
} finally {
URL . revokeObjectURL ( url );
}
}
document . getElementById ( 'rack-show-empty' ). addEventListener ( 'change' , e => {
try { localStorage . setItem ( 'gandalf.rackShowEmpty' , e . target . checked ? '1' : '0' ); } catch ( _ ) { /* private mode */ }
if ( _rack ) render ( _rack );
});
document . querySelectorAll ( '[data-export]' ). forEach ( btn => btn . addEventListener ( 'click' , () => exportRack ( btn . dataset . export )));
2026-11-10 22:35:37 -05:00
// ── Rack settings (admins) ──────────────────────────────────────────────
function openRackForm ( adding ) {
const form = document . getElementById ( 'rack-form' );
form . reset ();
const r = adding ? null : ( _rack . racks . find ( x => x . name === _rack . rack ) || {});
form . id . value = r && r . id ? r . id : '' ;
// The default rack exists only in config until saved; its items follow the new name
form . previous . value = r && ! r . id ? r . name : '' ;
form . name . value = r ? r . name : '' ;
form . units . value = r ? r . units : 42 ;
form . notes . value = r ? ( r . notes || '' ) : '' ;
form . querySelector ( '[data-action="delete-rack"]' ). hidden = ! ( r && r . id );
document . getElementById ( 'rack-modal-title' ). textContent = adding ? 'Add a rack' : 'Rack settings' ;
lt . modal . open ( 'rack-modal' );
form . name . focus ();
}
async function saveRack ( e ) {
e . preventDefault ();
const form = e . target ;
const body = { name : form . name . value . trim (), units : form . units . value , notes : form . notes . value , previous : form . previous . value };
try {
if ( form . id . value ) await lt . api . put ( `/api/cutsheet/racks/ ${ encodeURIComponent ( form . id . value ) } ` , body );
else await lt . api . post ( '/api/cutsheet/racks' , body );
lt . modal . close ( 'rack-modal' );
lt . toast . success ( 'Rack saved' );
setView ({ rack : body . name });
} catch ( err ) {
lt . toast . error ( err . message || 'Save failed' );
}
}
async function deleteRack () {
const form = document . getElementById ( 'rack-form' );
if ( ! form . id . value || ! window . confirm ( `Delete the rack ${ form . name . value } ?` )) return ;
try {
await lt . api . delete ( `/api/cutsheet/racks/ ${ encodeURIComponent ( form . id . value ) } ` );
lt . modal . close ( 'rack-modal' );
lt . toast . success ( 'Rack deleted' );
setView ({ rack : '' });
} catch ( err ) {
lt . toast . error ( err . message || 'Delete failed' );
}
}
if ( RACK_CAN_EDIT ) {
document . querySelector ( '[data-action="rack-settings"]' ). addEventListener ( 'click' , () => _rack && openRackForm ( false ));
document . querySelector ( '[data-action="add-rack"]' ). addEventListener ( 'click' , () => _rack && openRackForm ( true ));
document . getElementById ( 'rack-form' ). addEventListener ( 'submit' , saveRack );
document . querySelector ( '[data-action="delete-rack"]' ). addEventListener ( 'click' , deleteRack );
}