3D rooms: server fronts drawn from the model
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:
1 parent
d9546fe264
commit
0d6f227819
4 files changed
+162
-6
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user