Files
gandalf/static/rack.js
jaredandClaude Opus 5.5 a9de175d90
Lint / JS (eslint) (push) Successful in 8s
Lint / Python (flake8) (push) Successful in 1m0s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m9s
Lint / Python (flake8) (pull_request) Successful in 57s
Test / Python Tests (pytest) (push) Successful in 1m14s
Lint / JS (eslint) (pull_request) Successful in 8s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 56s
Test / Python Tests (pytest) (pull_request) Successful in 1m5s
Rack: ports-facing separate from posts, three views, ZimaBoard 832 shelves to scale
Gear on the rear posts usually still faces the front (the StarTech's RPDU
and ZimaBoard shelf), so "which posts" and "which way the ports face" are
now separate fields (mount, facing). The page has three views: Front (front
posts), Rear posts (rear-post gear seen from the front, i.e. what is behind
the front gear) and Rear (from behind). An item seen from the side it
doesn't face shows its back. Facing defaults to front, so existing items
keep their meaning.

Shelves are drawn at true scale with their contents ordered by a new
"Position on shelf". The IceWhale ZimaBoard 832 is traced from IceWhale's
labelled photo of its port end (ZimaDocs): Mini-DP, two RJ45-over-USB 3.0
stacks with link/activity LEDs, DC jack, gunmetal case, gold PCB edge and
black base. Its RJ45 link lights follow the host's physical NICs and the
status glow follows the host.

New cutsheet_items columns facing and slot, added on first use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 00:12:07 -05:00

578 lines
26 KiB
JavaScript

'use strict';
/* global FACEPLATES, renderFaceplate, RACK_CAN_EDIT, RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg, shelfSvg */
// 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 ───────────────────────────────────────────────────────
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, {
swName: item.device, portMap: portMapOf(sw.ports), outlets: sw.outlets, online: sw.online, rack: true,
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));
const short = (t, max = fit) => (t.length > max ? `${t.slice(0, max - 1)}…` : t);
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"/>
<text class="rack-pp-name" x="14" y="17"><title>${esc(item.name)}</title>${esc(short(item.name, 48))}</text>
${ports.join('')}
</svg>`;
}
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>` : '';
}
// 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>`;
}
function plateHtml(item) {
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(' · ');
return `<div class="rack-plate">
${statusDot(item.status)}<a class="device-link rack-plate-name" href="${esc(item.url)}">${esc(item.name)}</a>
<span class="rack-plate-sub">${esc(sub)}</span>
</div>`;
}
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>`;
}
function itemHtml(item) {
let body;
if (item.through) body = backHtml(item);
else if (item.switch && isRackFaceplate(item.switch.model)) body = switchHtml(item);
else if (CHASSIS_FACEPLATES[item.chassis]) body = switchHtml(item, CHASSIS_FACEPLATES[item.chassis]);
else if (item.chassis && RACK_FRONTS[item.chassis]) body = rackFrontSvg(item.chassis, item);
else if (item.kind === 'patch_panel' && item.chassis === 'uacc-patch-24') body = uaccPanelSvg(item);
else if (item.kind === 'patch_panel') body = panelSvg(item);
else if (item.kind === 'shelf') body = shelfSvg(item, esc);
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>`;
}
// 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>
<div class="rack-gap">U${bottom}-${top} · ${n}U free</div>
</div>`;
}
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 ────────────────────────────────────────────────────────────────
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('');
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}`;
document.querySelectorAll('.rack-face-btn').forEach(b => {
const on = b.dataset.face === data.face;
b.classList.toggle('active', on);
b.setAttribute('aria-pressed', on);
});
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';
}
function render(data) {
_rack = data;
renderTabs(data);
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--;
rows.push(!showEmpty() && u - low + 1 >= GAP_MIN ? gapRow(u, low) : row(u, low, '', 'rack-row-empty'));
u = low - 1;
}
document.getElementById('rack').innerHTML = rows.join('');
// 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');
});
document.getElementById('rack-show-empty').checked = showEmpty();
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 => {
const item = data.items.find(i => i.device === g.dataset.switch);
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')) {
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;
}
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);
}
});
function currentRack() {
return new URLSearchParams(location.search).get('rack') || '';
}
function currentFace() {
const face = new URLSearchParams(location.search).get('face');
return ['behind', 'rear'].includes(face) ? face : 'front';
}
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();
}
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;
setView({ rack: tab.dataset.rack === first ? '' : tab.dataset.rack });
});
document.querySelector('.rack-face').addEventListener('click', e => {
const btn = e.target.closest('[data-face]');
if (btn) setView({ face: btn.dataset.face === 'front' ? '' : btn.dataset.face });
});
async function load() {
try {
const q = new URLSearchParams();
if (currentRack()) q.set('rack', currentRack());
if (currentFace() !== 'front') q.set('face', currentFace());
render(await lt.api.get(`/api/rack${q.toString() ? `?${q}` : ''}`));
} 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);
// ── 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' },
`${_rack.rack} · ${_rack.face}`));
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]));
clone.setAttribute('preserveAspectRatio', art.getAttribute('preserveAspectRatio') || 'xMidYMid meet');
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();
const base = `${_rack.rack.replace(/[^\w.-]+/g, '-')}-${_rack.face}`;
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)));
// ── 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);
}