Files
gandalf/static/rack.js
T
jaredandClaude Opus 5.5 0f4e129c20
Lint / JS (eslint) (push) Successful in 15s
Security / Python Security (bandit) (push) Successful in 1m5s
Lint / Python (flake8) (push) Successful in 49s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Lint / Python (flake8) (pull_request) Successful in 53s
Test / Python Tests (pytest) (push) Successful in 1m0s
Lint / JS (eslint) (pull_request) Successful in 7s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 53s
Test / Python Tests (pytest) (pull_request) Successful in 57s
Rack: drawn fronts for the NVR and server cases
Items Gandalf has no live faceplate for can pick a Front drawing: the
UniFi NVR Pro (drawn from the CC0 NetBox devicetype-library image), the
Sliger CX4712 4U and Rosewill RSV-L4500U 4U cases (from driveAtlas's
layouts), and generic 1U/2U/4U servers. The power light shows the linked
device's live status. They export with the rest of the rack.

New cutsheet_items column chassis, added on first use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 23:25:02 -05:00

535 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* global FACEPLATES, renderFaceplate, RACK_CAN_EDIT, RACK_FRONTS, rackFrontSvg */
// 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) {
const sw = item.switch;
return renderFaceplate(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>` : '';
}
function shelfHtml(item) {
const contents = item.contents || [];
const things = contents.map(c => `
<a class="rack-shelf-thing" href="${esc(c.url)}" title="${esc([c.name, c.model, c.status].filter(Boolean).join(' · '))}">
${statusDot(c.status)}<span class="rack-shelf-name">${esc(c.name)}</span>
</a>`).join('');
return `<div class="rack-plate rack-shelf">
<span class="rack-shelf-label">${esc(item.name)}</span>
<div class="rack-shelf-things">${things || '<span class="rack-plate-sub">empty shelf</span>'}</div>
</div>`;
}
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 (item.chassis && RACK_FRONTS[item.chassis]) body = rackFrontSvg(item.chassis, item);
else if (item.kind === 'patch_panel') body = panelSvg(item);
else if (item.kind === 'shelf') body = shelfHtml(item);
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('');
document.getElementById('rack-title').textContent = `${data.rack} · ${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 rear = (data.racks.find(r => r.name === data.rack) || {}).rear || 0;
document.querySelector('.rack-face-btn[data-face="rear"]').textContent = rear ? `Rear (${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')) {
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() {
return new URLSearchParams(location.search).get('face') === 'rear' ? 'rear' : '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 === 'rear' ? 'rear' : '' });
});
async function load() {
try {
const q = new URLSearchParams();
if (currentRack()) q.set('rack', currentRack());
if (currentFace() === 'rear') q.set('face', 'rear');
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);
}