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 @@
+