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

169 lines
8.8 KiB
JavaScript

'use strict';
/* exported RACK_FRONTS, rackFrontSvg */
// Drawn fronts for rack gear Gandalf can't read a faceplate from: an NVR UniFi doesn't
// report, and 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 = {
// Ubiquiti UNVR Pro: drawn from the CC0 NetBox devicetype-library front image.
// 2U; touchscreen top left, bays 1-3 on top and 4-7 below, RJ45 + SFP+ top right.
'unvr-pro': {
label: 'UniFi NVR Pro (UNVR Pro)', units: 2, width: 1167, height: 230,
draw({ status }) {
const bay = (x, y, w, n) => `<rect class="rf-unvr-bay" x="${x}" y="${y}" width="${w}" height="76" rx="2"/>
<rect class="rf-unvr-rail" x="${x}" y="${y}" width="5" height="76"/>
<circle class="rf-unvr-dot" cx="${x + w - 30}" cy="${y + 38}" r="2.6"/>
<text class="rf-small" x="${x + w / 2}" y="${y + 90}" text-anchor="middle">${n}</text>`;
const top = [97, 384, 669].map((x, i) => bay(x, 27, 283, i + 1)).join('');
const bottom = [12, 299, 585, 871].map((x, i) => bay(x, 125, 283, i + 4)).join('');
const strip = (y) => [97, 302, 507, 712, 916].map(x => `<rect class="rf-unvr-strip" x="${x}" y="${y}" width="196" height="4" rx="2"/>`).join('');
return `<rect class="rf-unvr-body" width="1167" height="230" rx="3"/>
${strip(9)}${strip(216)}
<rect class="rf-screen" x="14" y="35" width="61" height="60" rx="4"/>
<circle class="rf-screen-ring" cx="44" cy="65" r="13" style="stroke:${frontLed(status)}"/>
<text class="rf-small rf-dark" x="44" y="113" text-anchor="middle">NVR Pro</text>
${top}${bottom}
<rect class="rf-port" x="1018" y="52" width="44" height="38" rx="2"/>
<rect class="rf-port-led" x="1050" y="55" width="9" height="5" style="fill:${frontLed(status)}"/>
<rect class="rf-port" x="1070" y="65" width="40" height="27" rx="2"/>
<text class="rf-small" x="1040" y="46" text-anchor="middle">1</text>
<text class="rf-small" x="1090" y="46" text-anchor="middle">2</text>
<circle class="rf-reset" cx="1128" cy="219" r="3"/><text class="rf-small" x="1134" y="222">RESET</text>`;
},
},
// 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 }) {
const bays = Array.from({ length: 10 }, (_, i) => driveBay(78 + i * 86, 150, 78, 240, true)).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)}
<rect class="rf-door" x="64" y="16" width="880" height="388" rx="6"/>
<rect class="rf-mesh" x="80" y="32" width="848" height="356" rx="4"/>
${fan(225)}${fan(504)}${fan(783)}
<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)}`;
},
};
}
// 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>`;
}