diff --git a/cmd/rollcall/web/fronts.js b/cmd/rollcall/web/fronts.js new file mode 100644 index 0000000..ddf9619 --- /dev/null +++ b/cmd/rollcall/web/fronts.js @@ -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 }; +} diff --git a/cmd/rollcall/web/index.html b/cmd/rollcall/web/index.html index 1416d43..7999d5a 100644 --- a/cmd/rollcall/web/index.html +++ b/cmd/rollcall/web/index.html @@ -52,6 +52,7 @@ + diff --git a/cmd/rollcall/web/room3d.js b/cmd/rollcall/web/room3d.js index 2fddbcc..d07133c 100644 --- a/cmd/rollcall/web/room3d.js +++ b/cmd/rollcall/web/room3d.js @@ -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); }); diff --git a/cmd/rollcall/web/sites.js b/cmd/rollcall/web/sites.js index 428cad5..576ab65 100644 --- a/cmd/rollcall/web/sites.js +++ b/cmd/rollcall/web/sites.js @@ -217,12 +217,12 @@ function pageRoom(siteName, roomArg) { ${SITE.edit ? `
` : ''} ${rackElev(selRack)}` : `
Click a rack for its front.${autoN ? `

${autoN} rack(s) have no place yet and are laid out automatically (${v3 ? 'see-through' : 'dashed'}): Edit layout to place them.` : ''}
`; - const legend = v3 ? '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' + const legend = v3 ? `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' : ''}` : `emptythick edge: the front · bar: units used${SITE.edit ? ' · drag to move, select and Rotate' : ''}`; return `

Sites / ${esc(site.name)} / ${esc(room || '(no room)')}

${racks.length} racks · floor tiles of 600 mm
${SITE.edit ? '' - : `${v3 ? '' : ''}`}
+ : `${v3 ? `` : ''}`}
${v3 ? '
' : floorSVG(site, room)}
deployed / readybusy / newfailed${legend}
${panel}
`; } @@ -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();