Lint / JS (eslint) (push) Successful in 44s
Lint / Python (flake8) (push) Successful in 1m40s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m18s
Lint / JS (eslint) (pull_request) Successful in 10s
Security / Python Security (bandit) (pull_request) Successful in 1m13s
Test / Python Tests (pytest) (pull_request) Successful in 1m11s
Lint / Python (flake8) (pull_request) Successful in 50s
Test / Python Tests (pytest) (push) Successful in 1m1s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Ports that are never meant to be up (compute-storage-01 nic1-3, large1's spare NIC and USB adapter) kept their hosts "degraded" and were the source of tonight's false alerts. An admin can now mark an interface unused from the device page, with an optional reason. Unused interfaces raise no link-down alerts, don't count toward the cluster-wide alert or the host's status, and show greyed as "unused" on the dashboard, device page and rack. Marking one closes any open link-down alert on it without a recovery note. New table unused_interfaces, created with the cutsheet tables. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
421 lines
24 KiB
JavaScript
421 lines
24 KiB
JavaScript
'use strict';
|
|
/* exported RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg, shelfSvg, SHELF_GEAR, DESK_FACEPLATES */
|
|
// 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')}`;
|
|
},
|
|
},
|
|
|
|
// Sliger CX4712 with its coloured front fitted, as in the rack photo (wiki: rack-2026.jpg):
|
|
// a hex-perforated panel over most of the face, the drive cages faintly visible through it,
|
|
// black rack ears, and a power button over two USB ports at the top right.
|
|
'sliger-cx4712-red': sligerFront('#c8161d', '#e9e5e0'),
|
|
'sliger-cx4712-white': sligerFront('#eceae6', '#9aa0a6'),
|
|
'sliger-cx4712-blue': sligerFront('#1f5fae', '#e9e5e0'),
|
|
|
|
// Ubiquiti UACC-Rack-Panel-Brush-1U, from Ubiquiti's product images (store.ui.com, UACC-Rack-Panel
|
|
// variant Brush 1U): a light silver face with separate ear plates (two rounded slots each), a
|
|
// recessed chamfered frame around the opening (shadowed along the top, bright bevel along the
|
|
// bottom), and two rows of black bristles meeting at a seam along the middle.
|
|
'uacc-brush-1u': {
|
|
label: 'UniFi Rack Panel Brush 1U (UACC-Rack-Panel-Brush-1U)', units: 1, width: 1100, height: 105,
|
|
draw() {
|
|
// Bristles: each row is shaded root to tip (dark where it leaves the frame, lighter where the
|
|
// two rows meet), with a faint strand texture on top. Strong individual strands alias into hard
|
|
// stripes at rack size, so the texture stays low-contrast and the shading carries the look.
|
|
// The opening runs almost the full width between the ear plates, as in Ubiquiti's image
|
|
const strands = (y0, y1, seed) => Array.from({ length: 172 }, (_, i) => {
|
|
const n = (i * 7919 + seed * 104729) % 97;
|
|
return `<rect x="${60 + i * 5.7 + (n % 5) * 0.4}" y="${y0 + (n % 4)}" width="${1.4 + (n % 3) * 0.5}"
|
|
height="${y1 - y0 - (n % 4)}" class="ub-strand" style="opacity:${0.18 + (n % 5) * 0.05}"/>`;
|
|
}).join('');
|
|
const slot = x => `<rect class="ua-ear-slot" x="${x - 7}" y="22" width="14" height="10" rx="5"/>
|
|
<rect class="ua-ear-slot" x="${x - 7}" y="73" width="14" height="10" rx="5"/>`;
|
|
return `<defs><linearGradient id="fp-metal" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0" stop-color="#e4e4e6"/><stop offset=".08" stop-color="#d6d6d8"/>
|
|
<stop offset=".92" stop-color="#cfcfd2"/><stop offset="1" stop-color="#a9a9ad"/>
|
|
</linearGradient>
|
|
<linearGradient id="ub-row-top" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0" stop-color="#08090a"/><stop offset=".7" stop-color="#1f2125"/><stop offset="1" stop-color="#34373c"/>
|
|
</linearGradient>
|
|
<linearGradient id="ub-row-bottom" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0" stop-color="#34373c"/><stop offset=".3" stop-color="#1f2125"/><stop offset="1" stop-color="#08090a"/>
|
|
</linearGradient></defs>
|
|
<rect class="fp-body" width="1100" height="105" rx="3"/>
|
|
<rect class="ua-ear-plate" x="0" y="1" width="32" height="103" rx="3"/>
|
|
<rect class="ua-ear-plate" x="1068" y="1" width="32" height="103" rx="3"/>
|
|
<rect class="ua-ear-seam" x="31" y="1" width="1.5" height="103"/>
|
|
<rect class="ua-ear-seam" x="1067.5" y="1" width="1.5" height="103"/>
|
|
${slot(16)}${slot(1084)}
|
|
<rect class="ub-frame" x="44" y="20" width="1012" height="66" rx="7"/>
|
|
<rect class="ub-frame-shadow" x="48" y="24" width="1004" height="7" rx="3"/>
|
|
<rect class="ub-frame-bevel" x="48" y="77" width="1004" height="5" rx="2.5"/>
|
|
<rect class="ub-opening" x="56" y="30" width="988" height="47" rx="2"/>
|
|
<rect x="56" y="30" width="988" height="23.5" fill="url(#ub-row-top)"/>
|
|
<rect x="56" y="53.5" width="988" height="23.5" fill="url(#ub-row-bottom)"/>
|
|
${strands(31, 53, 1)}${strands(54, 76, 2)}
|
|
<rect class="ub-seam" x="56" y="53" width="988" height="1"/>`;
|
|
},
|
|
},
|
|
|
|
// Generic servers, for gear without a specific drawing
|
|
'server-1u': genericServer(1, 4),
|
|
'server-2u': genericServer(2, 12),
|
|
'server-4u': genericServer(4, 24),
|
|
};
|
|
|
|
// A rack handle on a Sliger ear: a vertical bar standing off the ear on two mounts
|
|
function sligerHandle(x) {
|
|
const top = 70, bottom = 350;
|
|
return `<rect class="rf-sl-mount" x="${x - 2}" y="${top - 14}" width="30" height="22" rx="4"/>
|
|
<rect class="rf-sl-mount" x="${x - 2}" y="${bottom - 8}" width="30" height="22" rx="4"/>
|
|
<rect class="rf-sl-handle" x="${x}" y="${top - 6}" width="26" height="${bottom - top + 12}" rx="13"/>
|
|
<rect class="rf-sl-handle-shine" x="${x + 5}" y="${top + 6}" width="5" height="${bottom - top - 12}" rx="2.5"/>`;
|
|
}
|
|
|
|
function sligerFront(colour, hole) {
|
|
const W = 1100, H = 420;
|
|
// Hex perforations: a pattern of hexagons cut out of the panel through a mask
|
|
const hex = (cx, cy, r) => Array.from({ length: 6 }, (_, i) => {
|
|
const a = Math.PI / 6 + i * Math.PI / 3;
|
|
return `${(cx + r * Math.cos(a)).toFixed(1)},${(cy + r * Math.sin(a)).toFixed(1)}`;
|
|
}).join(' ');
|
|
// Hole size and pitch from the rack photo: about 20 rows of holes over the panel's height
|
|
const r = 6.9, dx = 18, dy = 15.6;
|
|
const holes = [];
|
|
for (let row = 0, y = 46; y < H - 34; row++, y += dy) {
|
|
for (let x = 82 + (row % 2 ? dx / 2 : 0); x < 925; x += dx) holes.push(`<polygon points="${hex(x, y, r)}"/>`);
|
|
}
|
|
const bays = Array.from({ length: 10 }, (_, i) => `<rect class="rf-sl-bay" x="${140 + i * 74}" y="120" width="44" height="250" rx="3" style="fill:${hole}"/>`).join('');
|
|
// The cages show only faintly through the mesh
|
|
return {
|
|
label: 'Sliger CX4712 4U (10-bay)', units: 4, width: W, height: H,
|
|
draw({ status }) {
|
|
const id = `sl-${colour.slice(1)}`;
|
|
return `<defs><mask id="${id}"><rect width="${W}" height="${H}" fill="#fff"/><g fill="#000">${holes.join('')}</g></mask></defs>
|
|
<rect class="rf-sl-inside" x="70" y="30" width="870" height="${H - 60}"/>${bays}
|
|
<rect class="rf-sl-ear" x="0" y="0" width="70" height="${H}" rx="4"/>
|
|
<rect class="rf-sl-ear" x="${W - 70}" y="0" width="70" height="${H}" rx="4"/>
|
|
${sligerHandle(14)}${sligerHandle(W - 40)}
|
|
<rect x="52" y="10" width="${W - 104}" height="${H - 20}" rx="8" style="fill:${colour}" mask="url(#${id})"/>
|
|
<rect class="rf-sl-edge" x="52" y="10" width="${W - 104}" height="${H - 20}" rx="8"/>
|
|
<circle class="rf-sl-button" cx="988" cy="88" r="17"/>
|
|
<circle class="rf-power-ring" cx="988" cy="88" r="12" style="stroke:${frontLed(status)}"/>
|
|
<rect class="rf-sl-io" x="966" y="138" width="44" height="74" rx="4"/>
|
|
${usbA(977, 146)}${usbA(977, 168)}${usbC(980, 192)}`;
|
|
},
|
|
};
|
|
}
|
|
|
|
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',
|
|
// For a new switch before UniFi reports it (or under a model code not mapped yet)
|
|
'usw-pro-max-24-poe': 'USPM24P',
|
|
'usw-pro-max-48-poe': 'USPM48P',
|
|
};
|
|
|
|
// 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`,
|
|
},
|
|
};
|
|
|
|
// UniFi desk gear drawn from its live faceplate when it sits on a shelf: the real width of the
|
|
// face (Ubiquiti tech specs) and the span of that face in the faceplate drawing, to scale it.
|
|
const DESK_FACEPLATES = {
|
|
USMINI: { mm: 107, body: [2, 661], label: 'UniFi Switch Flex Mini' }, // 107 x 70 x 21 mm
|
|
USF5P: { mm: 122.5, body: [66, 488], label: 'UniFi Switch Flex' }, // 122.5 x 107.1 x 28 mm
|
|
};
|
|
|
|
// 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, or UniFi desk gear with a live faceplate, is drawn to its real
|
|
// width; anything else is a labelled box. A shelf with Front drawing "resting" has no tray: its gear
|
|
// lies directly on whatever is below it (a switch on top of the NVR).
|
|
function shelfSvg(item, esc, faceplate) {
|
|
const units = item.rack_height || 1;
|
|
const W = 1100, H = 105 * units, perMm = W / 482.6;
|
|
const resting = item.chassis === 'resting';
|
|
const floor = resting ? H - 3 : H - 20;
|
|
let x = 64;
|
|
const things = (item.contents || []).map(c => {
|
|
const gear = SHELF_GEAR[c.chassis];
|
|
const desk = !gear && faceplate && DESK_FACEPLATES[c.model];
|
|
let w, h, body;
|
|
if (gear) {
|
|
w = gear.mm * perMm;
|
|
const k = w / gear.width;
|
|
h = gear.height * k;
|
|
body = `<g transform="translate(${x} ${floor - h}) scale(${k})">${gear.draw(c)}</g>`;
|
|
} else if (desk) {
|
|
// Scale the faceplate so its body spans the switch's real width, then place the whole drawing
|
|
const spec = faceplate.spec(c.model);
|
|
const k = (desk.mm * perMm) / (desk.body[1] - desk.body[0]);
|
|
w = desk.mm * perMm;
|
|
h = spec.height * k;
|
|
body = faceplate.svg(c, `x="${x - desk.body[0] * k}" y="${floor - h}" width="${spec.width * k}" height="${h}"`);
|
|
} else {
|
|
w = 150;
|
|
h = 58;
|
|
body = `<rect class="sh-box" x="${x}" y="${floor - h}" width="${w}" height="${h}" rx="4"/>
|
|
<text class="sh-box-name" x="${x + w / 2}" y="${floor - h / 2 + 5}" text-anchor="middle">${esc(c.name)}</text>`;
|
|
}
|
|
const top = floor - h;
|
|
const nics = (c.interfaces || []).map(n => `${n.name} ${n.unused ? 'unused' : (n.up ? 'up' : 'down')}`).join(', ');
|
|
const label = gear ? gear.label : (desk ? desk.label : c.kind);
|
|
const tip = [c.name, label, c.status, nics, gear && gear.specs].filter(Boolean).join(' · ');
|
|
// A live faceplate's ports are links of their own, so it isn't wrapped in one
|
|
const open = desk ? '<g class="sh-thing">' : `<a class="sh-thing" href="${esc(c.url)}">`;
|
|
const tagY = resting ? top - 16 : floor + 5;
|
|
const out = `${open}<title>${esc(tip)}</title>
|
|
${body}
|
|
${desk ? '' : `<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="${tagY}" width="92" height="12" rx="2"/>
|
|
<text class="sh-tag-text" x="${x + w / 2}" y="${tagY + 9}" text-anchor="middle">${esc(c.name)}</text>
|
|
${desk ? '</g>' : '</a>'}`;
|
|
x += w + 26;
|
|
return out;
|
|
}).join('');
|
|
const tray = resting ? '' : `${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"/>`;
|
|
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>
|
|
${tray}
|
|
${things || `<text class="sh-empty" x="${W / 2}" y="${floor - 12}" text-anchor="middle">${resting ? 'nothing here' : 'empty shelf'}</text>`}
|
|
<text class="sh-name" x="46" y="18">${esc(item.name)}</text>
|
|
</svg>`;
|
|
}
|