Files
gandalf/static/fronts.js
T
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

248 lines
13 KiB
JavaScript

'use strict';
/* exported RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg, shelfSvg, SHELF_GEAR */
// Drawn fronts for rack gear Gandalf can't read a faceplate from: custom-built servers. Chosen per cutsheet item ("Front drawing"). Each one
// shows the device's live status on its power light when the item is linked to a device.
//
// Spec: { label, units, width, height, draw(ctx) -> SVG markup }, ctx: { status, name }.
// Width:height should be about 10.5:1 per rack unit so the drawing fills its slot.
const FRONT_STATUS = { up: '#2fd36b', degraded: '#ffb300', down: '#e0312b' };
function frontLed(status) {
return FRONT_STATUS[status] || '#6b737d';
}
function ears(w, h, cls = 'rf-ear') {
// Rack ears with their mounting slots, both sides
const slots = Array.from({ length: Math.round(h / 105) }, (_, i) => 105 * i + 52);
const holes = x => slots.map(y => `<rect class="rf-slot" x="${x}" y="${y - 9}" width="9" height="18" rx="4"/>`).join('');
return `<rect class="${cls}" x="0" y="0" width="34" height="${h}" rx="3"/>${holes(12)}
<rect class="${cls}" x="${w - 34}" y="0" width="34" height="${h}" rx="3"/>${holes(w - 21)}`;
}
function vents(x0, x1, y, h = 8, gap = 6, seg = 120) {
const out = [];
for (let x = x0; x + seg <= x1; x += seg + gap) out.push(`<rect class="rf-vent" x="${x}" y="${y}" width="${seg}" height="${h}" rx="${h / 2}"/>`);
return out.join('');
}
function powerButton(cx, cy, r, status) {
return `<circle class="rf-power-ring" cx="${cx}" cy="${cy}" r="${r}" style="stroke:${frontLed(status)}"/>
<path class="rf-power-icon" d="M${cx - r * 0.35} ${cy - r * 0.3} a${r * 0.48} ${r * 0.48} 0 1 0 ${r * 0.7} 0 M${cx} ${cy - r * 0.55} v${r * 0.5}"/>`;
}
function usbA(x, y) {
return `<rect class="rf-port" x="${x}" y="${y}" width="22" height="9" rx="1"/><rect class="rf-port-tongue" x="${x + 3}" y="${y + 2}" width="16" height="3"/>`;
}
function usbC(x, y) {
return `<rect class="rf-port" x="${x}" y="${y}" width="16" height="6" rx="3"/>`;
}
function keyLock(cx, cy) {
return `<circle class="rf-lock" cx="${cx}" cy="${cy}" r="9"/><rect class="rf-lock-slot" x="${cx - 1.5}" y="${cy - 6}" width="3" height="12" rx="1"/>`;
}
function brand(x, y, text, anchor = 'start') {
return `<text class="rf-brand" x="${x}" y="${y}" text-anchor="${anchor}">${text}</text>`;
}
function driveBay(x, y, w, h, vertical) {
// A hot-swap bay: tray face, latch and activity light
const latch = vertical
? `<rect class="rf-latch" x="${x + w / 2 - 6}" y="${y + h - 26}" width="12" height="18" rx="2"/>`
: `<rect class="rf-latch" x="${x + w - 26}" y="${y + h / 2 - 6}" width="18" height="12" rx="2"/>`;
const led = vertical ? `<circle class="rf-bay-led" cx="${x + w / 2}" cy="${y + 10}" r="2.5"/>`
: `<circle class="rf-bay-led" cx="${x + 10}" cy="${y + h / 2}" r="2.5"/>`;
return `<rect class="rf-bay" x="${x}" y="${y}" width="${w}" height="${h}" rx="3"/>${latch}${led}`;
}
const RACK_FRONTS = {
// Sliger CX4712 4U with its faceplate off (as driveAtlas draws it): two 5.25" blanks side by
// side, ten 3.5" hot-swap bays standing on edge in one row, a narrow I/O strip on the right.
'sliger-cx4712': {
label: 'Sliger CX4712 4U (10-bay)', units: 4, width: 1100, height: 420,
draw({ status }) {
// Bays numbered left to right, the local convention driveAtlas uses for this case
const bays = Array.from({ length: 10 }, (_, i) => `${driveBay(78 + i * 86, 150, 78, 228, true)}
<text class="rf-bay-num" x="${117 + i * 86}" y="396" text-anchor="middle">${i + 1}</text>`).join('');
return `<rect class="rf-body-dark" width="1100" height="420" rx="3"/>${ears(1100, 420, 'rf-ear-dark')}
<rect class="rf-blank" x="78" y="30" width="418" height="98" rx="3"/>
<rect class="rf-blank" x="510" y="30" width="418" height="98" rx="3"/>
${vents(98, 480, 74, 7, 8, 40)}${vents(530, 912, 74, 7, 8, 40)}
${bays}
<rect class="rf-io" x="950" y="30" width="104" height="360" rx="4"/>
${powerButton(1002, 78, 20, status)}
${usbA(991, 130)}${usbA(991, 150)}${usbC(995, 176)}
${keyLock(1002, 220)}
${brand(1002, 372, 'SLIGER', 'middle')}`;
},
},
// Rosewill RSV-L4500U 4U: the perforated, lockable drop-down door over the fan wall
// (15 bays sit behind it), D-handles on the ears, I/O strip with power and USB on the right.
'rosewill-rsv-l4500u': {
label: 'Rosewill RSV-L4500U 4U (15-bay)', units: 4, width: 1100, height: 420,
draw({ status }) {
const fan = cx => `<circle class="rf-fan" cx="${cx}" cy="210" r="112"/>
<circle class="rf-fan-hub" cx="${cx}" cy="210" r="30"/>
${[0, 72, 144, 216, 288].map(a => `<path class="rf-fan-blade" transform="rotate(${a} ${cx} 210)" d="M${cx} 180 q40 -40 70 -10 q-30 10 -70 40 z"/>`).join('')}`;
const handle = x => `<rect class="rf-handle" x="${x}" y="60" width="16" height="300" rx="8"/>`;
return `<rect class="rf-body-dark" width="1100" height="420" rx="3"/>${ears(1100, 420, 'rf-ear-dark')}
${handle(36)}${handle(1048)}
<defs><pattern id="rf-perf" width="9" height="9" patternUnits="userSpaceOnUse">
<circle class="rf-perf-hole" cx="4.5" cy="4.5" r="2.4"/></pattern></defs>
<rect class="rf-door" x="64" y="16" width="880" height="388" rx="6"/>
${fan(225)}${fan(504)}${fan(783)}
<rect class="rf-mesh" x="80" y="32" width="848" height="356" rx="4" fill="url(#rf-perf)"/>
<rect class="rf-door-latch" x="900" y="180" width="26" height="60" rx="5"/>
<rect class="rf-io" x="954" y="16" width="86" height="388" rx="4"/>
${powerButton(997, 62, 18, status)}
<circle class="rf-reset" cx="997" cy="104" r="5"/>
<circle class="rf-led" cx="985" cy="130" r="3.5" style="fill:${frontLed(status)}"/><circle class="rf-led" cx="1009" cy="130" r="3.5"/>
${usbA(986, 160)}${usbA(986, 180)}
${keyLock(997, 232)}
${brand(997, 388, 'ROSEWILL', 'middle')}`;
},
},
// Generic servers, for gear without a specific drawing
'server-1u': genericServer(1, 4),
'server-2u': genericServer(2, 12),
'server-4u': genericServer(4, 24),
};
function genericServer(units, bays) {
const H = 105 * units;
return {
label: `Generic ${units}U server`, units, width: 1100, height: H,
draw({ status }) {
const rows = Math.max(1, Math.round(units * 0.75));
const cols = Math.ceil(bays / rows);
const bw = Math.min(118, 720 / cols - 8);
const bh = (H - 30 - (rows - 1) * 8) / rows;
const out = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols && r * cols + c < bays; c++) out.push(driveBay(60 + c * (bw + 8), 15 + r * (bh + 8), bw, bh, false));
}
return `<rect class="rf-body-dark" width="1100" height="${H}" rx="3"/>${ears(1100, H, 'rf-ear-dark')}
${out.join('')}
${vents(810, 1040, 18, 7, 6, 36)}${units > 1 ? vents(810, 1040, H - 26, 7, 6, 36) : ''}
${powerButton(1020, H / 2, Math.min(18, H / 5), status)}`;
},
};
}
// Front drawings that are full faceplates in faceplates.js (same parts as the UniFi gear)
const CHASSIS_FACEPLATES = { 'unvr-pro': 'UNVRPRO' };
// The markup for one item's drawn front, or '' when the key is unknown
function rackFrontSvg(key, ctx) {
const spec = RACK_FRONTS[key];
if (!spec) return '';
return `<svg class="rack-svg rack-front" viewBox="0 0 ${spec.width} ${spec.height}" preserveAspectRatio="none"
role="img" aria-label="${lt.escHtml(`${ctx.name}: ${spec.label}${ctx.status ? `, ${ctx.status}` : ''}`)}">
<title>${lt.escHtml(`${ctx.name} · ${spec.label}${ctx.status ? ` · ${ctx.status}` : ''}`)}</title>
${spec.draw(ctx)}
</svg>`;
}
// ── IceWhale ZimaBoard 832 (shelf gear) ─────────────────────────────────
// The port end, traced from IceWhale's labelled photo in ZimaDocs
// (source/images/Hardware-Interface-Introduction-/hardware-interface-introduction.webp),
// in that photo's pixel coordinates (the port end spans 862 x 415 px; 81.4 mm wide).
// Left to right: Mini-DisplayPort 1.2, two stacks of GbE RJ45 over USB 3.0, DC 12V 5.5x2.5mm,
// on a gunmetal case over a thin gold PCB edge and the black polycarbonate base.
const ZIMA = { width: 862, height: 415, mm: 81.4 };
function zimaRj45(x, y, link) {
const contacts = Array.from({ length: 8 }, (_, i) => `<rect class="zb-contact" x="${x + 52 + i * 14}" y="${y + 34}" width="5" height="22"/>`).join('');
return `<rect class="zb-shield" x="${x}" y="${y}" width="207" height="143" rx="5"/>
<rect class="zb-hole" x="${x + 22}" y="${y + 30}" width="163" height="102" rx="2"/>
<rect class="zb-hole-key" x="${x + 70}" y="${y + 120}" width="67" height="16" rx="1"/>
${contacts}
<rect class="zb-led" x="${x + 16}" y="${y + 9}" width="30" height="15" rx="2" style="fill:${link ? '#3ad16b' : '#8f8f8a'}"/>
<rect class="zb-led" x="${x + 161}" y="${y + 9}" width="30" height="15" rx="2" style="fill:${link ? '#f2d13a' : '#77714a'}"/>`;
}
function zimaUsb(x, y) {
return `<rect class="zb-shield" x="${x}" y="${y}" width="204" height="100" rx="5"/>
<rect class="zb-hole" x="${x + 26}" y="${y + 20}" width="152" height="62" rx="2"/>
<rect class="zb-usb-tongue" x="${x + 38}" y="${y + 28}" width="128" height="22" rx="2"/>
${Array.from({ length: 5 }, (_, i) => `<rect class="zb-usb-pin" x="${x + 50 + i * 24}" y="${y + 46}" width="10" height="4"/>`).join('')}`;
}
function zimaFront(ctx) {
// ctx: { status, interfaces: [{name, up}] } - one RJ45 per NIC, left to right
const nics = ctx.interfaces || [];
const link = i => (nics[i] ? nics[i].up : ctx.status === 'up');
const on = ctx.status === 'up' || ctx.status === 'degraded';
const pads = [60, 105, 420, 470, 640, 700, 790, 835]
.map(x => `<rect class="zb-pad" x="${x}" y="309" width="12" height="7"/>`).join('');
return `
<rect class="zb-base" x="0" y="300" width="862" height="115" rx="10"/>
<rect class="zb-base-sheen" x="6" y="330" width="850" height="6" rx="3"/>
${on ? '<ellipse class="zb-glow" cx="34" cy="404" rx="34" ry="9"/>' : ''}
<rect class="zb-pcb" x="2" y="306" width="858" height="12"/>${pads}
<path class="zb-case" d="M0 14 Q0 0 14 0 H848 Q862 0 862 14 V308 H0 Z"/>
<rect class="zb-case-edge" x="0" y="6" width="18" height="300"/>
<rect class="zb-case-top" x="14" y="0" width="834" height="5"/>
<rect class="zb-shield" x="63" y="225" width="127" height="80" rx="6"/>
<path class="zb-hole" d="M75 243 H178 V280 L163 293 H90 L75 280 Z"/>
<rect class="zb-dp-pins" x="92" y="262" width="69" height="9" rx="1"/>
${zimaRj45(213, 47, link(0))}${zimaRj45(443, 47, link(1))}
${zimaUsb(216, 205)}${zimaUsb(443, 205)}
<rect class="zb-dc" x="678" y="177" width="132" height="130" rx="8"/>
<rect class="zb-dc-face" x="690" y="189" width="108" height="106" rx="6"/>
<circle class="zb-dc-ring" cx="744" cy="242" r="36"/>
<circle class="zb-hole" cx="744" cy="242" r="27"/>
<circle class="zb-dc-pin" cx="744" cy="242" r="8"/>`;
}
const SHELF_GEAR = {
'zimaboard-832': {
label: 'IceWhale ZimaBoard 832', mm: ZIMA.mm, width: ZIMA.width, height: ZIMA.height, draw: zimaFront,
specs: 'Intel Celeron N3450, 8GB LPDDR4, 32GB eMMC, 2x GbE, 2x USB 3.0, 2x SATA, PCIe 2.0 x4, 6W',
},
};
// A shelf at true scale (1100 units = 482.6 mm, a 19" panel) with what sits on it, left to right.
// Gear with a SHELF_GEAR drawing is drawn to its real width; anything else is a labelled box.
function shelfSvg(item, esc) {
const units = item.rack_height || 1;
const W = 1100, H = 105 * units, perMm = W / 482.6;
const floor = H - 20;
let x = 64;
const things = (item.contents || []).map(c => {
const gear = SHELF_GEAR[c.chassis];
const w = gear ? gear.mm * perMm : 150;
const k = gear ? w / gear.width : 1;
const h = gear ? gear.height * k : 58;
const top = floor - h;
const nics = (c.interfaces || []).map(n => `${n.name} ${n.up ? 'up' : 'down'}`).join(', ');
const tip = [c.name, gear ? gear.label : c.kind, c.status, nics, gear && gear.specs].filter(Boolean).join(' · ');
const body = gear
? `<g transform="translate(${x} ${top}) scale(${k})">${gear.draw(c)}</g>`
: `<rect class="sh-box" x="${x}" y="${top}" width="${w}" height="${h}" rx="4"/>
<text class="sh-box-name" x="${x + w / 2}" y="${top + h / 2 + 5}" text-anchor="middle">${esc(c.name)}</text>`;
const out = `<a class="sh-thing" href="${esc(c.url)}"><title>${esc(tip)}</title>
${body}
<rect class="sh-hit" x="${x - 4}" y="${top - 4}" width="${w + 8}" height="${h + 8}" rx="4"/>
<rect class="sh-tag" x="${x + w / 2 - 46}" y="${floor + 5}" width="92" height="12" rx="2"/>
<text class="sh-tag-text" x="${x + w / 2}" y="${floor + 14}" text-anchor="middle">${esc(c.name)}</text>
</a>`;
x += w + 26;
return out;
}).join('');
return `<svg class="rack-svg rack-shelf-svg" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="group"
aria-label="${esc(item.name)}">
<title>${esc(item.name)}</title>
${ears(W, H, 'rf-ear-dark')}
<rect class="sh-back" x="36" y="6" width="${W - 72}" height="${floor - 6}"/>
<rect class="sh-floor" x="30" y="${floor}" width="${W - 60}" height="${H - floor - 2}" rx="2"/>
<rect class="sh-lip" x="30" y="${floor}" width="${W - 60}" height="3"/>
${things || `<text class="sh-empty" x="${W / 2}" y="${floor - 12}" text-anchor="middle">empty shelf</text>`}
<text class="sh-name" x="46" y="18">${esc(item.name)}</text>
</svg>`;
}