Lint / JS (eslint) (push) Successful in 10s
Lint / Python (flake8) (push) Successful in 51s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Lint / JS (eslint) (pull_request) Successful in 8s
Lint / Python (flake8) (pull_request) Successful in 50s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 1m7s
Security / Python Security (bandit) (push) Successful in 1m8s
Test / Python Tests (pytest) (push) Successful in 1m38s
Test / Python Tests (pytest) (pull_request) Successful in 1m38s
From the rack photo, each ZimaBoard has a single-port ConnectX-3 lying flat in its side PCIe slot with a DAC attached. Front drawing "ZimaBoard 832 + ConnectX-3" draws the card beside the board at scale: PCB at board height, heatsink, SFP+ cage with the DAC and pull tab, and a Gandalf link mark for the card's interface. The jacks were lit in NIC name order, which put the ConnectX-3 (enp1s0, PCI bus 1) on the left RJ45. The onboard ports are enp2s0 (left) and enp3s0 (right); the PCIe card is enp1s0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
293 lines
16 KiB
JavaScript
293 lines
16 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('')}`;
|
|
}
|
|
|
|
// Which NIC is which on a ZimaBoard: the onboard Realtek GbE ports enumerate on PCI buses 2 and 3
|
|
// (enp2s0 left jack, enp3s0 right), and a card in the PCIe slot on bus 1 (enp1s0, enp1s0d1).
|
|
const ZIMA_NICS = { rj45: ['enp2s0', 'enp3s0'], slot: /^enp1s0/ };
|
|
|
|
function zimaNic(ctx, name) {
|
|
const n = (ctx.interfaces || []).find(i => i.name === name);
|
|
return n ? n.up : ctx.status === 'up';
|
|
}
|
|
|
|
function zimaFront(ctx) {
|
|
// ctx: { status, interfaces: [{name, up}] }
|
|
const link = i => zimaNic(ctx, ZIMA_NICS.rj45[i]);
|
|
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"/>`;
|
|
}
|
|
|
|
// A Mellanox ConnectX-3 single-port SFP+ card (MCX311A) in the ZimaBoard's side PCIe slot, lying flat
|
|
// beside the board as in the rack photo (wiki.lotusguild.org/zimaboards.jpg): its front edge is about
|
|
// 54 mm (572 px at the ZimaBoard photo's 10.59 px/mm), PCB at the board's PCB height, heatsink on the
|
|
// chip, and the SFP+ cage toward the board with a DAC plugged in and its pull tab hanging down.
|
|
// The round dot on the cage is Gandalf's link mark (like the cutsheet marks), not an LED on the card.
|
|
const CX3_PX = 572;
|
|
|
|
function cx3Card(up) {
|
|
return `
|
|
<rect class="cx-part" x="40" y="314" width="70" height="22" rx="2"/>
|
|
<rect class="cx-part" x="150" y="314" width="40" height="16" rx="2"/>
|
|
<rect class="cx-part" x="250" y="314" width="90" height="26" rx="2"/>
|
|
<rect class="cx-pcb" x="0" y="296" width="${CX3_PX}" height="18" rx="2"/>
|
|
<rect class="cx-pcb-edge" x="0" y="296" width="${CX3_PX}" height="4"/>
|
|
<rect class="cx-heatsink" x="110" y="232" width="210" height="64" rx="3"/>
|
|
${Array.from({ length: 9 }, (_, i) => `<rect class="cx-fin" x="${120 + i * 22}" y="232" width="9" height="64"/>`).join('')}
|
|
<rect class="cx-cage" x="380" y="190" width="150" height="106" rx="4"/>
|
|
<rect class="cx-cage-lip" x="380" y="190" width="150" height="10" rx="3"/>
|
|
<rect class="cx-plug" x="392" y="206" width="126" height="80" rx="5"/>
|
|
<rect class="cx-boot" x="410" y="220" width="90" height="52" rx="10"/>
|
|
<circle class="cx-cable" cx="455" cy="246" r="20"/>
|
|
<path class="cx-tab" d="M441 286 V392 Q441 404 455 404 Q469 404 469 392 V286"/>
|
|
<circle class="cx-link" cx="520" cy="200" r="13" style="fill:${up ? '#3ad16b' : '#e0312b'}"><title>Gandalf: SFP+ link ${up ? 'up' : 'down'}</title></circle>`;
|
|
}
|
|
|
|
function zimaCx3Front(ctx) {
|
|
return `${cx3Card(zimaNic(ctx, (ctx.interfaces || []).map(i => i.name).find(n => ZIMA_NICS.slot.test(n)) || 'enp1s0'))}
|
|
<g transform="translate(${CX3_PX} 0)">${zimaFront(ctx)}</g>`;
|
|
}
|
|
|
|
const ZIMA_SPECS = 'Intel Celeron N3450, 8GB LPDDR4, 32GB eMMC, 2x GbE, 2x USB 3.0, 2x SATA, PCIe 2.0 x4, 6W';
|
|
|
|
const SHELF_GEAR = {
|
|
'zimaboard-832': {
|
|
label: 'IceWhale ZimaBoard 832', mm: ZIMA.mm, width: ZIMA.width, height: ZIMA.height, draw: zimaFront,
|
|
specs: ZIMA_SPECS,
|
|
},
|
|
'zimaboard-832-cx3': {
|
|
label: 'IceWhale ZimaBoard 832 + ConnectX-3 SFP+', mm: ZIMA.mm * (ZIMA.width + CX3_PX) / ZIMA.width,
|
|
width: ZIMA.width + CX3_PX, height: ZIMA.height, draw: zimaCx3Front,
|
|
specs: `${ZIMA_SPECS}; Mellanox ConnectX-3 10G SFP+ (enp1s0) in the PCIe slot, DAC attached`,
|
|
},
|
|
};
|
|
|
|
// 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>`;
|
|
}
|