3D rooms: server fronts drawn from the model
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 42s

Servers in the 3D room now show the front their model family has: 2.5"
bays standing or 3.5" bays lying in their groups, rack ears with the
power button, vents where there are no bays, in the vendor's colours
(fronts.js; drawn, no photos or logos). A family's bay options are tried
in order and the first with room for the machine's disks is drawn, empty
bays as blanks. A multi-node chassis shows one bay group per sled (six
each on Dell C-series), each sled clickable and lit in its own state.
The state shows as a strip and LED on the left ear and, when busy or
failed, a frame around the front. "Fronts / State" switches back to the
coloured blocks.

Seen from the aisle a rack's local +x is on the left: sleds were laid
out mirrored; fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-11-01 10:16:26 +01:00
1 parent d9546fe264
commit 0d6f227819
4 files changed
+162 -6

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
'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 };
}
+1
View File
@@ -52,6 +52,7 @@
<script src="topo.js"></script>
<script src="netview.js"></script>
<script src="sites.js"></script>
<script src="fronts.js"></script>
<script src="room3d.js"></script>
<script src="app.js"></script>
</body>
+33 -4
View File
@@ -14,6 +14,12 @@ const roomView = {
set(v) { try { localStorage.setItem('rollcall.roomview', v); } catch (e) { /* the choice is not kept */ } },
};
// frontView: servers as their drawn fronts (fronts.js) or as blocks in their state's colour
const frontView = {
get() { try { return localStorage.getItem('rollcall.fronts') === 'state' ? 'state' : 'real'; } catch (e) { return 'real'; } },
set(v) { try { localStorage.setItem('rollcall.fronts', v); } catch (e) { /* the choice is not kept */ } },
};
function loadThree() {
if (!loadThree.p) loadThree.p = import('./vendor/three/three.module.min.js').catch(e => { loadThree.p = null; throw e; });
return loadThree.p;
@@ -49,7 +55,8 @@ async function room3d(site, roomArg, racks) {
const blocks = racks.map(r => rackBlocks(r));
const css = getComputedStyle(document.body);
const sig = JSON.stringify([racks.map((r, i) => [r.name, r.x, r.y, r.facing, r.height, r.auto, Object.values(blocks[i].top).map(b => [b.unit, b.h, b.known, b.ms.map(m => m.id + m.state)])]),
SITE.sel, css.getPropertyValue('--bg'), css.getPropertyValue('--accent')]);
racks.map((r, i) => Object.values(blocks[i].top).map(b => b.ms.map(m => [m.manufacturer, m.product, m.disks]))),
SITE.sel, frontView.get(), css.getPropertyValue('--bg'), css.getPropertyValue('--accent')]);
if (key !== R3.key) { R3.key = key; R3.orb = null; }
if (sig !== R3.sig) { build(site, room, racks, blocks); R3.sig = sig; }
if (!R3.orb) fit();
@@ -76,6 +83,8 @@ function build(site, room, racks, blocks) {
});
const lineMat = name => mat('l' + name, () => new T.LineBasicMaterial({ color: cv(name) }));
const box = (w, h, d) => keep(new T.BoxGeometry(w, h, d));
const real = frontView.get() === 'real', fronts = {}; // one texture per look of front
const colorOf = Object.fromEntries(['ok', 'warn', 'bad', 'idle'].map(k => [k, css.getPropertyValue({ ok: '--ok', warn: '--warn', bad: '--danger', idle: '--muted' }[k]).trim()]));
sc.add(new T.HemisphereLight(0xffffff, 0x50555c, 1.7));
const sun = new T.DirectionalLight(0xffffff, 1.3); sun.position.set(-6, 12, 8); sc.add(sun);
@@ -110,10 +119,30 @@ function build(site, room, racks, blocks) {
g.add(body, edges, face); R3.pick.push(body);
for (const b of Object.values(blocks[i].top)) {
const span = Math.min(H, b.unit + b.h - 1) - b.unit + 1, hh = span * U_M - 0.004, y = PLINTH + (b.unit - 1) * U_M + 0.002 + hh / 2;
const n = b.node ? b.ms.length : 1, sw = RAIL / n;
const n = b.node ? b.ms.length : 1;
// each server (or sled) gets the part of the front between cuts[k] and cuts[k+1], in metres
let cuts = [...Array(n + 1).keys()].map(k => -RAIL / 2 + RAIL * k / n), front = null;
if (real) {
const p = frontProfile(b, span), cls = b.ms.map(m => stateClass(m.state)), key = p.key + '|' + p.disks + '|' + cls.join(',');
front = fronts[key] || (fronts[key] = (() => {
const f = drawFront(p, span, cls, colorOf), tex = keep(new T.CanvasTexture(f.canvas));
tex.colorSpace = T.SRGBColorSpace; tex.anisotropy = R3.gl.capabilities.getMaxAnisotropy();
return { tex, ends: f.ends };
})());
if (n > 1 && front.ends.length === n - 1) cuts = [0, ...front.ends, MM.W].map(mm => -RAIL / 2 + RAIL * mm / MM.W);
}
b.ms.slice(0, n).forEach((m, k) => {
const s = new T.Mesh(box(sw - 0.006, hh, 0.03), stateMat(stateClass(m.state), b.known));
s.position.set(-RAIL / 2 + sw * (k + 0.5), y, -TILE - 0.006);
const sw = cuts[k + 1] - cuts[k];
let look = stateMat(stateClass(m.state), b.known);
if (front) { // the drawn front on the face towards the aisle (-z), the cabinet colour elsewhere
let tex = front.tex;
if (n > 1) { tex = keep(front.tex.clone()); tex.repeat.set(sw / RAIL, 1); tex.offset.set((cuts[k] + RAIL / 2) / RAIL, 0); tex.needsUpdate = true; }
const face = keep(new T.MeshStandardMaterial({ map: tex, emissiveMap: tex, emissive: 0xffffff, emissiveIntensity: 0.3, roughness: 0.7, transparent: !b.known, opacity: b.known ? 1 : 0.55 }));
const side = mat('side' + b.known, () => new T.MeshStandardMaterial({ color: 0x1c1e22, roughness: 0.8, transparent: !b.known, opacity: b.known ? 1 : 0.55 }));
look = [side, side, side, side, side, face];
}
const s = new T.Mesh(box(sw - (front ? 0.001 : 0.006), hh, 0.03), look);
s.position.set(-(cuts[k] + cuts[k + 1]) / 2, y, -TILE - 0.006); // seen from the aisle, local +x is on the left
s.userData = { rack: r.name, id: m.id, tip: `${nameOf(m)} · ${m.state} · U${b.unit}${span > 1 ? '–' + (b.unit + span - 1) : ''}${b.known ? '' : ' (height unknown)'} · ${[m.manufacturer, m.product].filter(Boolean).join(' ')}` };
g.add(s); R3.pick.push(s);
});
+3 -2
View File
@@ -217,12 +217,12 @@ function pageRoom(siteName, roomArg) {
${SITE.edit ? `<div class="actions" style="margin-bottom:8px"><button class="btn sm" id="rk-rotate">Rotate</button></div>` : ''}
${rackElev(selRack)}</div>`
: `<div class="card floor-side muted">Click a rack for its front.${autoN ? `<br><br>${autoN} rack(s) have no place yet and are laid out automatically (${v3 ? 'see-through' : 'dashed'}): Edit layout to place them.` : ''}</div>`;
const legend = v3 ? '<span>drag: turn · right or shift drag: move · wheel: zoom · double click: go to a rack · a click on a selected rack\'s server opens it · see-through: height unknown</span>'
const legend = v3 ? `<span>drag: turn · right or shift drag: move · wheel: zoom · double click: go to a rack · a click on a selected rack\'s server opens it · see-through: height unknown${frontView.get() === 'real' ? ' · fronts: the drive bays of the model, the strip on the left ear is the state' : ''}</span>`
: `<span><b class="lg vacant"></b>empty</span><span>thick edge: the front · bar: units used${SITE.edit ? ' · drag to move, select and Rotate' : ''}</span>`;
return `<div class="page-head"><h1><a href="#/sites" class="muted" style="font-weight:400">Sites</a> / <a href="#/sites/${encodeURIComponent(site.name)}" class="muted" style="font-weight:400">${esc(site.name)}</a> / ${esc(room || '(no room)')}</h1>
<span class="sub">${racks.length} racks · floor tiles of 600 mm</span>
<div class="right">${SITE.edit ? '<button class="btn" id="fl-cancel">Cancel</button><button class="btn" id="fl-auto">Automatic again</button><button class="btn primary" id="fl-save">Save layout</button>'
: `<span class="seg-btns"><button class="btn${v3 ? '' : ' on'}" data-roomview="2d">2D</button><button class="btn${v3 ? ' on' : ''}" data-roomview="3d">3D</button></span>${v3 ? '<button class="btn" id="r3-reset">Reset view</button>' : ''}<button class="btn" id="fl-edit">Edit layout</button>`}</div></div>
: `<span class="seg-btns"><button class="btn${v3 ? '' : ' on'}" data-roomview="2d">2D</button><button class="btn${v3 ? ' on' : ''}" data-roomview="3d">3D</button></span>${v3 ? `<span class="seg-btns" title="servers: their fronts as the model has them, or blocks in their state's colour"><button class="btn${frontView.get() === 'real' ? ' on' : ''}" data-fronts="real">Fronts</button><button class="btn${frontView.get() === 'state' ? ' on' : ''}" data-fronts="state">State</button></span><button class="btn" id="r3-reset">Reset view</button>` : ''}<button class="btn" id="fl-edit">Edit layout</button>`}</div></div>
<div class="floor-wrap"><div class="card floor-card">${v3 ? '<div id="room3d" class="room3d"></div>' : floorSVG(site, room)}
<div class="legend"><span><b class="lg ok"></b>deployed / ready</span><span><b class="lg warn"></b>busy / new</span><span><b class="lg bad"></b>failed</span>${legend}</div></div>${panel}</div>`;
}
@@ -231,6 +231,7 @@ function wireRoom(siteName, roomArg) {
const site = siteList().find(s => s.name === siteName); if (!site) return;
const room = roomArg === '-' ? '' : roomArg;
$$('[data-roomview]').forEach(b => b.onclick = () => { roomView.set(b.dataset.roomview); render(); });
$$('[data-fronts]').forEach(b => b.onclick = () => { frontView.set(b.dataset.fronts); render(); });
$$('.floor-side .elev .slot.m').forEach(s => s.onclick = () => { location.hash = '#/machine/' + s.dataset.id; });
const e = $('#fl-edit'); if (e) e.onclick = () => { SITE.edit = { racks: racksOnFloor(site, room).map(r => ({ ...r })) }; render(); };
const rs = $('#r3-reset'); if (rs) rs.onclick = () => room3dReset();