Files
gandalf/static/rack.js
T
jaredandClaude Opus 5.5 bdd68a397d
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (flake8) (push) Successful in 48s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 49s
Lint / Python (flake8) (pull_request) Successful in 51s
Test / Python Tests (pytest) (push) Successful in 1m2s
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 50s
Test / Python Tests (pytest) (pull_request) Successful in 53s
Rack: link items to devices, shelves, several racks, export; fix layout and light mode
Cutsheet items gain a "Gandalf device" link, so an item named after the
product (UDM-Pro) shows the live device's faceplate, ports and status.
Cable checks, suggestions, tickets and the device page treat an item and
its linked device as one. Without this, product-named items drew as bare
name plates.

Items can sit on a shelf instead of a rack unit; the shelf draws what is
on it with live status. Items have a rack, the page shows a tab per rack
(config "racks"), and a rack can be downloaded as SVG or PNG.

The rack is centred, long runs of empty units fold into one band (with a
toggle), every device fills the 19" width, the UPS shows its front only,
and the rack keeps its own palette so it reads in light mode.

New cutsheet_items columns rack, shelf and device are added on first use.
Deleting a shelf, or making it something else, is refused while it holds
items; renaming it carries over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 21:44:59 -05:00

439 lines
18 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 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 => (t.length > fit ? `${t.slice(0, fit - 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="${ear - 50}" y="${H / 2 + 5}">${esc(item.name)}</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 itemHtml(item) {
let body;
if (item.switch && isRackFaceplate(item.switch.model)) body = switchHtml(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.racks.length > 1 ? `${data.rack} · front` : 'Front';
}
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: fill the slot rather than keep each drawing's own proportions
document.querySelectorAll('#rack .fp-svg').forEach(s => s.setAttribute('preserveAspectRatio', 'none'));
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') || '';
}
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;
history.replaceState(null, '', tab.dataset.rack === first ? location.pathname : `?rack=${encodeURIComponent(tab.dataset.rack)}`);
_rack = null;
load();
});
async function load() {
try {
const rack = currentRack();
render(await lt.api.get(`/api/rack${rack ? `?rack=${encodeURIComponent(rack)}` : ''}`));
} 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} · front`));
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.classList.contains('fp-svg') ? 'none' : '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`;
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)));