Files
rollcall/cmd/rollcall/web/fronts.js
T
Ilker ManapandClaude Opus 5.5 c31a89482e
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 42s
3D rooms: server backs, switches and the cables between them (LLDP)
GET /api/v1/cabling gives each located machine's ports (from its
inventory: name, speed, link, the PCI card they sit on) and the switch
port each is plugged into, from the LLDP its commissioning saw (lldpcli's
neighbours, either JSON shape). Switches get a place: NetBox's (the
device's site, location, rack and position, read with the layout on
every reconcile) or one set by hand (POST /api/v1/switches, which NetBox
does not move); a switch with neither is drawn where most of its cables
are, at the highest free unit (on top of the rack when none is free),
and marked as estimated.

In 3D every server now has a back too: power supplies, the BMC port, USB
and VGA, and its network cards with their ports (SFP cages from 10G,
RJ45 below), per sled for a multi-node chassis. Switches stand at the
back with their ports in two rows. Cables run from each port out of the
back, down the rack's side, over the top to another rack's switch, into
the switch port; one colour per switch, grey without link, and up
through the ceiling to a switch in another room. Hovering a cable names
both ends and the VLAN; with a rack selected the other racks' cables
fade. "Cables" turns them off, "Turn around" looks from the other side.
The side panel lists the room's switches with their place (place… sets
it) and, for a selected rack, every port and the switch port it goes to,
in 2D as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:41:40 +01:00

206 lines
13 KiB
JavaScript

'use strict';
// Server fronts drawn from the model: the drive bays the model family comes
// with (2.5" bays standing, 3.5" bays lying, in their groups), the rack ears
// with the control panel, vents where there are no bays, in the vendor's
// colours. No photos and no logos: the layout is what tells servers apart.
// A family comes in several bay options (an R750 has 8 or 16 or 24 2.5" bays
// or 12 3.5" ones); the first option with room for the machine's disks is
// drawn. A multi-node chassis shows one bay group per sled (Dell C-series:
// six bays each), lit in that sled's state.
const MM = { W: 482.6, EAR: 22, U: 44.45 };
const FRONT_STYLE = { // body, ears, drive carriers, latches
dell: ['#1d1f22', '#8f959b', '#3a3e44', '#9aa2aa'],
hpe: ['#34383d', '#26292d', '#555b62', '#b0b6bc'],
lenovo: ['#141517', '#1f2023', '#34373b', '#3d7be0'],
smc: ['#18191b', '#a8acb1', '#383b40', '#a0a6ac'],
xfusion: ['#26282c', '#c9ccd0', '#42464b', '#a0a6ac'],
fujitsu: ['#222427', '#c3c6ca', '#3e4145', '#a0a6ac'],
inspur: ['#1c1e22', '#2a2c30', '#3a3e44', '#3aa0d8'],
gigabyte: ['#1b1c20', '#34363a', '#393c41', '#e0a030'],
generic: ['#212428', '#3a3e43', '#3c4046', '#9aa0a6'],
};
const sff = (n, group) => ({ n, ff: 's', group }), lff = n => ({ n, ff: 'l' });
const FRONT_RULES = [ // vendor, model, style, bay options
[/dell/i, /\bC6[456]\d{2}\b/i, 'dell', [sff(24, 6)]],
[/dell/i, /\bR6\d{2,3}\b/i, 'dell', [sff(8), sff(10)]],
[/dell/i, /\bR7\d{2,3}\b/i, 'dell', [sff(8), lff(12), sff(16, 8), sff(24, 8)]],
[/dell/i, /\bR(860|940|960)\b/i, 'dell', [sff(8), sff(16, 8), sff(24, 8)]],
[/hpe?\b|hewlett/i, /\bDL3(25|60)\b/i, 'hpe', [sff(8), sff(10)]],
[/hpe?\b|hewlett/i, /\bDL(345|380|560)\b/i, 'hpe', [sff(8, 8), lff(12), sff(24, 8)]],
[/lenovo/i, /\bSR63\d\b/i, 'lenovo', [sff(8), sff(10)]],
[/lenovo/i, /\bSR6[45]\d\b/i, 'lenovo', [sff(8, 8), lff(12), sff(24, 8)]],
[/lenovo/i, /\bSR(850|860|950)\b/i, 'lenovo', [sff(8, 8), sff(24, 8)]],
[/xfusion|huawei/i, /\b1288H\b/i, 'xfusion', [sff(8), sff(10)]],
[/xfusion|huawei/i, /\b2[24]88H\b/i, 'xfusion', [lff(12), sff(24, 8)]],
[/fujitsu/i, /\bRX2530\b/i, 'fujitsu', [lff(4), sff(8), sff(10)]],
[/fujitsu/i, /\bRX2540\b/i, 'fujitsu', [lff(12), sff(24, 8)]],
[/fujitsu/i, /\bRX4770\b/i, 'fujitsu', [sff(8), sff(16, 8)]],
[/inspur|kaytus/i, /\bNF5180/i, 'inspur', [lff(4), sff(10)]],
[/inspur|kaytus/i, /\bNF5280/i, 'inspur', [lff(12), sff(24, 8)]],
[/inspur|kaytus/i, /\bNF5468/i, 'inspur', [sff(8)]],
[/giga-?byte/i, /^R1/i, 'gigabyte', [lff(4), sff(10)]],
[/giga-?byte/i, /^R2/i, 'gigabyte', [lff(12), sff(24, 8)]],
[/giga-?byte/i, /^G/i, 'gigabyte', [sff(8)]],
];
const FRONT_BY_U = { 1: [lff(4), sff(10)], 2: [lff(12), sff(24, 8)], 3: [lff(16)], 4: [lff(24)] };
// frontProfile: style and bays for a block of rackBlocks (one server, or the sleds of a chassis)
function frontProfile(b, span) {
const m = b.ms[0], mfr = m.manufacturer || '', model = m.product || '';
const rule = FRONT_RULES.find(r => r[0].test(mfr) && r[1].test(model));
const style = rule ? rule[2] : /supermicro/i.test(mfr) ? 'smc' : 'generic';
let opts = rule ? rule[3] : FRONT_BY_U[Math.min(4, span)];
if (b.node && !rule) opts = [sff(24, Math.max(1, Math.floor(24 / b.ms.length)))];
const disks = b.node ? Math.max(...b.ms.map(x => x.disks || 0)) * b.ms.length : (m.disks || 0);
let bays = opts.find(o => o.n >= disks) || opts[opts.length - 1];
if (bays.ff === 'l' && Math.ceil(bays.n / 4) * 26.1 > span * MM.U - 6) bays = sff(Math.min(24, bays.n * 2), 8); // no room for that many 3.5" rows
return { style, bays, disks: b.node ? 0 : disks, key: `${style}|${bays.ff}${bays.n}/${bays.group || 0}|${span}` };
}
// drawFront paints the front on a canvas, px wide; cls: state class of each
// sled (one for a server). Returns the canvas and, for a chassis, the x (mm)
// where each sled's part of the front ends.
function drawFront(p, span, cls, colors, px = 768) {
const H = span * MM.U - 1, k = px / MM.W, c = document.createElement('canvas');
c.width = px; c.height = Math.round(H * k);
const x = c.getContext('2d'); x.scale(k, k);
const [body, ear, carrier, latch] = FRONT_STYLE[p.style];
const rect = (fill, a, b, w, h) => { x.fillStyle = fill; x.fillRect(a, b, w, h); };
const dots = (a, b, w, h, step) => { // perforation
x.fillStyle = 'rgba(0,0,0,.55)';
for (let yy = b + step / 2, row = 0; yy < b + h; yy += step * 0.87, row++) for (let xx = a + step / 2 + (row % 2) * step / 2; xx < a + w; xx += step) { x.beginPath(); x.arc(xx, yy, step * 0.3, 0, 7); x.fill(); }
};
const bevel = (a, b, w, h) => { x.lineWidth = 0.5; x.strokeStyle = 'rgba(255,255,255,.18)'; x.beginPath(); x.moveTo(a, b + h); x.lineTo(a, b); x.lineTo(a + w, b); x.stroke(); x.strokeStyle = 'rgba(0,0,0,.7)'; x.beginPath(); x.moveTo(a + w, b); x.lineTo(a + w, b + h); x.lineTo(a, b + h); x.stroke(); };
const led = (a, b, r, col) => { x.fillStyle = col; x.shadowColor = col; x.shadowBlur = r * 3; x.beginPath(); x.arc(a, b, r, 0, 7); x.fill(); x.shadowBlur = 0; };
rect(body, 0, 0, MM.W, H);
// ears: a state strip and LED on the left, the power button on the right
rect(ear, 0, 0, MM.EAR, H); rect(ear, MM.W - MM.EAR, 0, MM.EAR, H);
rect(colors[cls[0]], 1, 1, 5.5, H - 2);
if (cls.some(c => c === 'bad' || c === 'warn')) { // something to see from across the room
const worst = colors[cls.includes('bad') ? 'bad' : 'warn'];
x.strokeStyle = worst; x.lineWidth = 1.6; x.strokeRect(0.8, 0.8, MM.W - 1.6, H - 1.6);
}
led(13, Math.min(9, H / 2 - 6), 1.8, colors[cls[0]]);
x.strokeStyle = 'rgba(0,0,0,.6)'; x.lineWidth = 0.8; x.beginPath(); x.arc(MM.W - 11, Math.min(10, H / 2), 3.2, 0, 7); x.stroke();
led(MM.W - 11, Math.min(10, H / 2), 1, '#7fb6ff');
rect('rgba(0,0,0,.5)', MM.W - 15, H - 9, 8, 4.5); // USB
// bays
const b = p.bays, inner = [MM.EAR + 2, MM.W - MM.EAR - 2], ends = [];
const filled = i => !p.disks || i < p.disks;
const sled = i => b.group && cls.length > 1 ? Math.min(cls.length - 1, Math.floor(i / b.group)) : 0;
let right = inner[0];
if (b.ff === 's') {
const bw = 17.6, bh = Math.min(H - 5, 70), y0 = (H - bh) / 2, gap = 4;
let xx = inner[0];
for (let i = 0; i < b.n; i++) {
if (i && b.group && i % b.group === 0) { ends.push(xx + gap / 2); xx += gap; }
if (filled(i)) {
rect(carrier, xx + 0.4, y0, bw - 0.8, bh);
bevel(xx + 0.4, y0, bw - 0.8, bh);
rect(latch, xx + 2, y0 + bh * 0.62, bw - 4, Math.min(3, bh * 0.08)); // the release latch
x.fillStyle = 'rgba(0,0,0,.45)'; for (let v = y0 + 4; v < y0 + bh * 0.55; v += 2.2) x.fillRect(xx + 3, v, bw - 6, 0.8); // vents
led(xx + 4, y0 + bh - 3, 0.9, colors[cls[sled(i)]]); led(xx + bw - 4, y0 + bh - 3, 0.9, '#2a2d31');
} else { rect('rgba(0,0,0,.35)', xx + 0.4, y0, bw - 0.8, bh); dots(xx + 1, y0 + 1, bw - 2, bh - 2, 2.4); }
xx += bw;
}
right = xx;
} else {
const bw = 103, bh = 26.1, cols = Math.min(4, b.n), rows = Math.ceil(b.n / 4);
const x0 = inner[0] + (inner[1] - inner[0] - cols * bw) / 2, y0 = (H - rows * bh) / 2;
for (let i = 0; i < b.n; i++) {
const xx = x0 + (i % 4) * bw, yy = y0 + Math.floor(i / 4) * bh;
if (filled(i)) {
rect(carrier, xx + 0.5, yy + 0.5, bw - 1, bh - 1);
bevel(xx + 0.5, yy + 0.5, bw - 1, bh - 1);
rect(latch, xx + 3, yy + 3, 4, bh - 6);
x.fillStyle = 'rgba(0,0,0,.45)'; for (let v = xx + 12; v < xx + bw - 14; v += 2.4) x.fillRect(v, yy + 5, 0.9, bh - 10);
led(xx + bw - 6, yy + 6, 1, colors[cls[0]]); led(xx + bw - 6, yy + bh - 6, 1, '#2a2d31');
} else { rect('rgba(0,0,0,.35)', xx + 0.5, yy + 0.5, bw - 1, bh - 1); dots(xx + 2, yy + 2, bw - 4, bh - 4, 2.6); }
}
right = b.n >= 4 ? inner[1] : x0 + cols * bw;
}
if (inner[1] - right > 12) dots(right + 3, 3, inner[1] - right - 3, H - 6, 3.2); // vents where there are no bays
return { canvas: c, ends };
}
// drawRear paints a server's back: PSUs at the right end, then per server
// (or per sled of a chassis) the BMC port, USB and VGA and its network
// cards with their ports (SFP cages from 10G up, RJ45 below), cover plates
// where there is no card. sleds: [{ports}] from /api/v1/cabling. Returns the
// canvas and each port's centre in mm from the top left: {sled, name, x, y}.
function drawRear(p, span, sleds, colors, px = 768) {
const H = span * MM.U - 1, k = px / MM.W, c = document.createElement('canvas');
c.width = px; c.height = Math.round(H * k);
const x = c.getContext('2d'); x.scale(k, k);
const [body, ear] = FRONT_STYLE[p.style];
const rect = (fill, a, b, w, h) => { x.fillStyle = fill; x.fillRect(a, b, w, h); };
const frame = (a, b, w, h, col = 'rgba(255,255,255,.16)') => { x.lineWidth = 0.5; x.strokeStyle = col; x.strokeRect(a, b, w, h); };
const grille = (a, b, w, h, step = 2.6) => { x.fillStyle = 'rgba(0,0,0,.6)'; for (let yy = b + 1; yy < b + h - 1; yy += step) x.fillRect(a + 1, yy, w - 2, step * 0.45); };
const out = [];
rect(body, 0, 0, MM.W, H);
rect(ear, 0, 0, 6, H); rect(ear, MM.W - 6, 0, 6, H); // the rails' flanges
// power supplies at the right end: side by side in 1U, stacked above
const psuW = span === 1 ? 72 : 86, psuH = span === 1 ? H - 4 : Math.min(40, H / 2 - 3);
const psus = span === 1 ? [[MM.W - 8 - 2 * psuW - 2, 2], [MM.W - 8 - psuW, 2]] : [[MM.W - 8 - psuW, H / 2 - psuH - 1], [MM.W - 8 - psuW, H / 2 + 1]];
for (const [a, b] of psus) {
rect('#2b2e33', a, b, psuW, psuH); frame(a, b, psuW, psuH);
const r = Math.min(psuH, 34) / 2 - 2; x.fillStyle = 'rgba(0,0,0,.65)'; x.beginPath(); x.arc(a + psuW * 0.62, b + psuH / 2, r, 0, 7); x.fill();
x.strokeStyle = 'rgba(255,255,255,.12)'; x.lineWidth = 0.6; for (let q = 0; q < 4; q++) { x.beginPath(); x.arc(a + psuW * 0.62, b + psuH / 2, r * (0.3 + q * 0.22), 0, 7); x.stroke(); }
rect('#1a1c1f', a + 4, b + psuH / 2 - 4, 12, 8); // the inlet
x.fillStyle = colors.ok; x.beginPath(); x.arc(a + psuW - 6, b + 5, 1, 0, 7); x.fill();
}
const left = 8, right = psus[0][0] - 3;
const n = Math.max(1, sleds.length), sw = (right - left) / n;
sleds.forEach((s, si) => {
const x0 = left + si * sw;
if (n > 1) frame(x0 + 0.5, 1, sw - 1, H - 2, 'rgba(0,0,0,.8)');
let xx = x0 + 3;
const yMid = Math.min(H / 2, 20);
// BMC port, two USB, VGA
rect('#0d0e10', xx, yMid - 5, 12, 10); frame(xx, yMid - 5, 12, 10); out.push({ sled: si, name: 'bmc', x: xx + 6, y: yMid }); xx += 15;
rect('#0d0e10', xx, yMid - 7, 6, 6); rect('#0d0e10', xx, yMid + 1, 6, 6); xx += 9;
if (n === 1) { rect('#13243f', xx, yMid - 4, 16, 8); xx += 20; }
// network cards, one plate each, ports along it
const cards = new Map();
for (const pt of s.ports || []) { const kk = pt.card || pt.name; if (!cards.has(kk)) cards.set(kk, []); cards.get(kk).push(pt); }
for (const ports of cards.values()) {
const fast = ports.some(pt => parseInt(pt.speed) >= 10000), pw = fast ? 14 : 12.5, ph = fast ? 9 : 10.5;
const perRow = Math.max(1, Math.floor((x0 + sw - 4 - xx - 6) / (pw + 2)));
const rows = Math.ceil(ports.length / perRow), cols = Math.min(ports.length, perRow);
const cw = cols * (pw + 2) + 6, ch = Math.min(H - 4, rows * (ph + 3) + 6), cy = Math.max(2, yMid - ch / 2);
if (xx + cw > x0 + sw - 2) break; // no room left on this sled
rect('#2f3338', xx, cy, cw, ch); frame(xx, cy, cw, ch);
ports.forEach((pt, i) => {
const a = xx + 4 + (i % perRow) * (pw + 2), b = cy + 3 + Math.floor(i / perRow) * (ph + 3);
rect('#08090a', a, b, pw, ph); frame(a, b, pw, ph, 'rgba(255,255,255,.25)');
x.fillStyle = pt.link ? colors.ok : '#3a3d42'; x.fillRect(a + 1, b - 1.6, 2.4, 1.2);
out.push({ sled: si, name: pt.name, x: a + pw / 2, y: b + ph / 2 });
});
xx += cw + 3;
}
// cover plates for the empty slots
for (; n === 1 && xx + 18 < right; xx += 20) { rect('#2a2d31', xx, 3, 18, H - 6); grille(xx, 5, 18, H - 10); }
});
return { canvas: c, ports: out };
}
// drawSwitch paints a switch's port side: two rows of ports (odd above, even
// below), uplinks to the right. Returns where port number i is (mm).
function drawSwitch(count, colors, px = 768) {
const H = MM.U - 1, k = px / MM.W, c = document.createElement('canvas');
c.width = px; c.height = Math.round(H * k);
const x = c.getContext('2d'); x.scale(k, k);
x.fillStyle = '#1f2226'; x.fillRect(0, 0, MM.W, H);
x.fillStyle = '#3a3e43'; x.fillRect(0, 0, 18, H); x.fillRect(MM.W - 18, 0, 18, H);
const cols = Math.ceil(count / 2), pw = Math.min(12, (MM.W - 140) / cols - 1.2), at = {};
for (let i = 1; i <= count; i++) {
const col = Math.floor((i - 1) / 2), a = 24 + col * (pw + 1.2) + Math.floor(col / 6) * 2, b = (i % 2 ? 9 : 24);
x.fillStyle = '#07080a'; x.fillRect(a, b, pw, 10); x.strokeStyle = 'rgba(255,255,255,.2)'; x.lineWidth = 0.4; x.strokeRect(a, b, pw, 10);
at[i] = { x: a + pw / 2, y: b + 5 };
}
for (let u = 0; u < 6; u++) { const a = MM.W - 110 + u * 15; x.fillStyle = '#07080a'; x.fillRect(a, 14, 13, 13); x.strokeStyle = 'rgba(255,255,255,.25)'; x.strokeRect(a, 14, 13, 13); at['u' + u] = { x: a + 6.5, y: 20.5 }; }
x.fillStyle = colors.ok; x.beginPath(); x.arc(MM.W - 10, 8, 1.2, 0, 7); x.fill();
return { canvas: c, at };
}