<?php
// 3D hangar of the nodes that posted to the YABS board.
// One canvas, no dependency: the page ships its own isometric projection.
// Reads the board's SQLite database read-only and never writes to it.
// Deployed next to index.php in ~/www/public/yabs/.
declare(strict_types=1);
error_reporting(E_ALL & ~E_DEPRECATED);
ini_set('display_errors', '0');
date_default_timezone_set('Europe/Helsinki');
// Same database as index.php. Duplicated on purpose: the board is one file
// that must keep working if this page is deleted.
const DB_PATH = '/home/vasilves/yabs-data/yabs.sqlite';
const STEP = 1.7; // floor spacing between racks, in scene units
const COLS = 4; // racks per row inside a hall
const HALF_W = 0.55; // half footprint of a rack, must match HALF in the script
const PAD = 0.55; // free floor on every side of the racks in a hall
function h(?string $s): string
{
return htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8');
}
function n($v, int $d = 2): string
{
// Dot for decimals, space for thousands, same notation as index.php.
return $v === null ? 'n/a' : number_format((float) $v, $d, '.', ' ');
}
$error = null;
$nodes = [];
try {
$db = new PDO('sqlite:' . DB_PATH, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
// Latest run per node, plus how many runs that node ever posted.
$nodes = $db->query(
'SELECT r.*, (SELECT COUNT(*) FROM runs x WHERE x.node = r.node) AS n
FROM runs r
JOIN (SELECT node, MAX(tested_at) AS m FROM runs GROUP BY node) t
ON t.node = r.node AND t.m = r.tested_at
ORDER BY r.node'
)->fetchAll();
} catch (Throwable $e) {
$error = $e->getMessage();
}
// PMSS puts the datacenter row in the first two letters of a hostname. Two
// rows share one datacenter, and nodes older than the scheme match no row.
const ROW_DC = ['le' => 'Lauttasaari', 'lt' => 'Lauttasaari',
'ke' => 'Kerava', 'kt' => 'Kerava', 'k2' => 'Kerava 2'];
// The hostname family ("le4", "le6", "lt5") is the hall. It comes from PMSS's
// naming, not from the database: no column here knows a datacenter.
$halls = [];
foreach ($nodes as $row) {
$family = explode('-', (string) $row['node'])[0] ?: '?';
$halls[$family][] = $row;
}
ksort($halls);
$peak = 1.0;
foreach ($nodes as $row) {
$peak = max($peak, (float) ($row['d1m'] ?? 0));
}
$racks = [];
$plates = [];
$offset = 0.0;
foreach ($halls as $family => $list) {
// "3-229mighty" makes explode() return an int key, so cast before substr().
$dc = ROW_DC[substr((string) $family, 0, 2)] ?? null; // datacenter row, if the name follows the scheme
$cols = max(1, min(COLS, count($list)));
$rows = (int) ceil(count($list) / $cols);
foreach ($list as $i => $row) {
$perf = $row['d1m'] === null ? null : (float) $row['d1m'];
$ratio = $perf === null ? 0.0 : min(1.0, $perf / $peak);
$parts = explode('-', (string) $row['node']);
$racks[] = [
'x' => round($offset + ($i % $cols) * STEP, 3),
'z' => round(intdiv($i, $cols) * STEP, 3),
'hh' => round(0.5 + 1.7 * $ratio, 3),
't' => round($ratio, 3),
'null' => $perf === null,
'label' => end($parts),
'node' => $row['node'],
'tip' => sprintf(
"%s\n4k %s MB/s · %s IOPS\n64k %s · 512k %s · 1m %s MB/s\nsend %s · recv %s Mbit/s\n%s · %s cores · %s GiB RAM · %s GiB swap\n%s · %s · %s · GPU %s\n%d run(s), last %s",
$row['node'],
n($row['d4k']), n($row['d4k_iops'], 0),
n($row['d64k']), n($row['d512k']), n($row['d1m']),
n($row['send_mbit'], 0), n($row['recv_mbit'], 0),
$row['cpu'] ?? 'cpu?', $row['cores'] ?? '?', n($row['ram_gib'], 1), n($row['swap_gib'], 1),
$row['disk_gb'] === null ? 'disk?' : n((float) $row['disk_gb'] / 1000, 1) . ' TB',
$row['os'] ?? '?', $row['vm'] ?? '?', $row['gpu'] ?? 'none',
(int) $row['n'],
$row['tested_at'] === null ? '?' : date('Y-m-d H:i T', (int) $row['tested_at'])
),
];
}
// Symmetric margin, so every rack sits inside its slab. Offsetting from the
// corner left the near rack 0.15 unit outside and 0.7 free on the far side.
$w = ($cols - 1) * STEP + 2 * (HALF_W + PAD);
$d = ($rows - 1) * STEP + 2 * (HALF_W + PAD);
$plates[] = [
'name' => $dc === null ? (string) $family : $family . ' · ' . $dc,
'x' => round($offset - HALF_W - PAD, 3),
'z' => round(-HALF_W - PAD, 3),
'w' => round($w, 3),
'd' => round($d, 3),
];
$offset += $w + 1.5;
}
$scene = ['racks' => $racks, 'plates' => $plates, 'width' => round(max($offset, 4), 3), 'peak' => $peak, 'step' => STEP];
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>YABS hangar</title>
<link rel="icon" href="data:,">
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; background: #0d1117; color: #c9d1d9;
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
header { padding: 14px 18px 10px; border-bottom: 1px solid #21262d; }
h1 { margin: 0 0 4px; font-size: 15px; font-weight: 600; letter-spacing: .04em; }
.mut { color: #8b949e; }
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
#stage { position: relative; height: calc(100vh - 132px); min-height: 320px; cursor: grab; }
#stage.drag { cursor: grabbing; }
canvas { display: block; width: 100%; height: 100%; }
#tip { position: absolute; left: 14px; top: 14px; max-width: 340px; padding: 10px 12px;
background: #161b22ee; border: 1px solid #30363d; border-radius: 6px;
white-space: pre-wrap; font-size: 12px; pointer-events: none; }
#tip.empty { display: none; }
footer { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; padding: 8px 18px;
border-top: 1px solid #21262d; color: #8b949e; font-size: 12px; }
button { font: inherit; color: #c9d1d9; background: #21262d; border: 1px solid #30363d;
border-radius: 5px; padding: 3px 10px; cursor: pointer; }
button:hover { background: #30363d; }
.key { display: inline-flex; align-items: center; gap: 6px; }
.sw { width: 34px; height: 8px; border-radius: 2px;
background: linear-gradient(90deg, hsl(200 70% 55%), hsl(110 70% 50%), hsl(20 80% 55%)); }
</style>
</head>
<body>
<header>
<h1>YABS hangar</h1>
<div class="mut">
One hall per hostname family, one rack per node, height and colour from the
latest 1M fio throughput. Hover a rack for its numbers.
<?php if ($racks): ?>
<?= count($racks) ?> node(s), peak <?= n($peak) ?> MB/s.
<?php endif; ?>
<br>
The first two letters of a PMSS hostname name a datacenter row:
<code>le</code> and <code>lt</code> are Lauttasaari, <code>ke</code> and
<code>kt</code> are Kerava, and a second Kerava DC will be <code>k2</code>.
Older nodes predate the scheme and show their family alone.
</div>
</header>
<div id="stage">
<canvas id="hangar"></canvas>
<div id="tip" class="empty"></div>
</div>
<footer>
<button id="reset" type="button">reset view</button>
<span class="key"><span class="sw"></span> 1M MB/s, slow to fast</span>
<span class="mut">drag to orbit, wheel to zoom</span>
<a href="./">board</a>
<a href="README.md">README</a>
</footer>
<?php if ($error !== null): ?>
<script>document.getElementById('tip').className = '';
document.getElementById('tip').textContent = <?= json_encode('Database error: ' . $error, JSON_INVALID_UTF8_SUBSTITUTE) ?>;</script>
<?php elseif (!$racks): ?>
<script>document.getElementById('tip').className = '';
document.getElementById('tip').textContent = 'No runs yet. Install the probe, then reload.';</script>
<?php endif; ?>
<script>
// JSON_INVALID_UTF8_SUBSTITUTE: the text fields come from uploaded JSON, cut
// with substr(), so a split multibyte character would otherwise make
// json_encode return false and break this script for every visitor.
const scene = <?= json_encode($scene, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_INVALID_UTF8_SUBSTITUTE) ?>;
const canvas = document.getElementById('hangar');
const ctx = canvas.getContext('2d');
const tip = document.getElementById('tip');
const stage = document.getElementById('stage');
const HALF = 0.55; // half width of a rack footprint
let yaw = -0.65, pitch = 0.55, zoom = 1, drag = null;
let boxes = []; // screen rect per rack, for hover
function resize() {
const r = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(r.width * dpr);
canvas.height = Math.round(r.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
draw();
}
// Axonometric camera. y is up, x/z are the floor.
function projector(scale, ox, oy) {
const cy = Math.cos(yaw), sy = Math.sin(yaw), sp = Math.sin(pitch), cp = Math.cos(pitch);
const mid = scene.width / 2 - HALF;
return (x, y, z) => {
const X = (x - mid) * cy - z * sy;
const Z = (x - mid) * sy + z * cy;
return { x: ox + X * scale, y: oy + Z * scale * sp - y * scale * cp, d: Z };
};
}
// Every corner the drawing can touch, so the fit does not depend on hand-tuned
// constants when halls or racks are added.
function extents(P) {
const pts = [];
for (const p of scene.plates) {
for (const [x, z] of [[p.x, p.z], [p.x + p.w, p.z], [p.x + p.w, p.z + p.d], [p.x, p.z + p.d]]) {
pts.push(P(x, 0, z));
}
pts.push(P(p.x, 0, p.z + p.d + 0.75));
}
for (const r of scene.racks) {
pts.push(P(r.x, r.hh + 0.4, r.z)); // label above the rack
pts.push(P(r.x, 0, r.z));
}
return {
x0: Math.min(...pts.map(p => p.x)), x1: Math.max(...pts.map(p => p.x)),
y0: Math.min(...pts.map(p => p.y)), y1: Math.max(...pts.map(p => p.y)),
};
}
function quad(P, corners) {
ctx.beginPath();
corners.forEach((c, i) => {
const p = P(c[0], c[1], c[2]);
i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y);
});
ctx.closePath();
}
function draw() {
const w = canvas.clientWidth, h = canvas.clientHeight;
ctx.clearRect(0, 0, w, h);
boxes = [];
if (!scene.racks.length) return;
const probe = extents(projector(1, 0, 0));
const scale = Math.min((w - 80) / Math.max(1, probe.x1 - probe.x0),
(h - 90) / Math.max(1, probe.y1 - probe.y0)) * zoom;
const P = projector(
scale,
w / 2 - (probe.x0 + probe.x1) / 2 * scale,
h / 2 - (probe.y0 + probe.y1) / 2 * scale
);
// Halls: a floor slab, a light grid, and the family name on the near edge.
for (const p of scene.plates) {
quad(P, [[p.x, 0, p.z], [p.x + p.w, 0, p.z], [p.x + p.w, 0, p.z + p.d], [p.x, 0, p.z + p.d]]);
ctx.fillStyle = '#11161e';
ctx.fill();
ctx.strokeStyle = '#1d242e';
ctx.lineWidth = 1;
ctx.stroke();
ctx.strokeStyle = '#161d27';
const seg = (ax, az, bx, bz) => {
const a = P(ax, 0, az), b = P(bx, 0, bz);
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
};
for (let gx = p.x + scene.step; gx < p.x + p.w; gx += scene.step) seg(gx, p.z, gx, p.z + p.d);
for (let gz = p.z + scene.step; gz < p.z + p.d; gz += scene.step) seg(p.x, gz, p.x + p.w, gz);
const lb = P(p.x + 0.15, 0, p.z + p.d + 0.55);
ctx.fillStyle = '#7d8590';
ctx.font = '600 13px ui-monospace, monospace';
ctx.textAlign = 'left';
ctx.fillText(p.name, lb.x, lb.y);
}
// Painter's algorithm over whole boxes: farthest rack drawn first.
const order = scene.racks
.map(r => ({ r, d: P(r.x, 0, r.z).d }))
.sort((a, b) => a.d - b.d);
for (const { r } of order) {
const x = r.x, z = r.z, hgt = r.hh;
const base = r.null ? 'hsl(210 8% 55%)' : `hsl(${Math.round(200 - 180 * r.t)} 70% 55%)`;
// Contact shadow keeps the box sitting on the slab instead of floating.
quad(P, [[x - HALF - .12, 0.001, z - HALF - .12], [x + HALF + .12, 0.001, z - HALF - .12],
[x + HALF + .12, 0.001, z + HALF + .12], [x - HALF - .12, 0.001, z + HALF + .12]]);
ctx.fillStyle = 'rgba(0,0,0,.45)';
ctx.fill();
const wall = (x1, z1, x2, z2, shade, front) => {
const pts = [P(x1, 0, z1), P(x2, 0, z2), P(x2, hgt, z2), P(x1, hgt, z1)];
return { pts, depth: pts.reduce((s, p) => s + p.d, 0) / 4, shade,
a: [x1, z1], b: [x2, z2], front: !!front };
};
const walls = [
wall(x - HALF, z - HALF, x + HALF, z - HALF, .55), // north
wall(x + HALF, z - HALF, x + HALF, z + HALF, .74), // east
wall(x + HALF, z + HALF, x - HALF, z + HALF, .92, true), // south, the door
wall(x - HALF, z + HALF, x - HALF, z - HALF, .66), // west
].sort((a, b) => a.depth - b.depth);
ctx.fillStyle = base;
for (const f of walls) {
ctx.beginPath();
f.pts.forEach((pt, i) => i ? ctx.lineTo(pt.x, pt.y) : ctx.moveTo(pt.x, pt.y));
ctx.closePath();
ctx.fill();
ctx.globalAlpha = 1 - f.shade;
ctx.fillStyle = '#000';
ctx.fill();
ctx.globalAlpha = 1;
ctx.fillStyle = base;
// Vent slots and two status LEDs, so the box reads as a server and not a
// bare cube. Drawn per face, in wall order, so nearer walls cover them.
ctx.strokeStyle = 'rgba(0,0,0,.4)';
ctx.lineWidth = 1;
const along = (t, yv) => P(f.a[0] + (f.b[0] - f.a[0]) * t, yv,
f.a[1] + (f.b[1] - f.a[1]) * t);
for (let yv = 0.16; yv < hgt - 0.08; yv += 0.16) {
const p1 = along(0.07, yv), p2 = along(0.93, yv);
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
}
if (f.front) {
for (const [t, col] of [[0.15, '#7ee787'], [0.22, '#58a6ff']]) {
const led = along(t, hgt - 0.13);
ctx.fillStyle = col;
ctx.beginPath();
ctx.arc(led.x, led.y, Math.max(1.6, scale * 0.03), 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = base;
}
}
quad(P, [[x - HALF, hgt, z - HALF], [x + HALF, hgt, z - HALF],
[x + HALF, hgt, z + HALF], [x - HALF, hgt, z + HALF]]);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,.07)';
ctx.fill();
ctx.strokeStyle = '#0d1117';
ctx.lineWidth = 1;
ctx.stroke();
// Halo first: the label can land on a bright top face of the rack behind.
const lbl = P(x, hgt + 0.22, z);
ctx.font = '600 13px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.lineJoin = 'round';
if (scale > 26) {
ctx.strokeStyle = '#0d1117';
ctx.lineWidth = 3.5;
ctx.strokeText(r.label, lbl.x, lbl.y);
ctx.fillStyle = '#c9d1d9';
ctx.fillText(r.label, lbl.x, lbl.y);
}
const all = walls.flatMap(f => f.pts).concat([lbl, P(x, 0, z)]);
boxes.push({
x0: Math.min(...all.map(p => p.x)), x1: Math.max(...all.map(p => p.x)),
y0: Math.min(...all.map(p => p.y)), y1: Math.max(...all.map(p => p.y)),
tip: r.tip,
});
}
}
stage.addEventListener('pointerdown', e => {
drag = { x: e.clientX, y: e.clientY };
stage.classList.add('drag');
stage.setPointerCapture(e.pointerId);
});
stage.addEventListener('pointermove', e => {
if (drag) {
yaw += (e.clientX - drag.x) * 0.008;
pitch = Math.max(0.15, Math.min(1.2, pitch + (e.clientY - drag.y) * 0.005));
drag = { x: e.clientX, y: e.clientY };
draw();
return;
}
const r = canvas.getBoundingClientRect();
const px = e.clientX - r.left, py = e.clientY - r.top;
const hit = boxes.find(b => px >= b.x0 && px <= b.x1 && py >= b.y0 && py <= b.y1);
if (hit) {
tip.textContent = hit.tip;
tip.className = '';
} else {
tip.className = 'empty';
}
});
stage.addEventListener('pointerup', e => {
drag = null;
stage.classList.remove('drag');
stage.releasePointerCapture(e.pointerId);
});
stage.addEventListener('pointercancel', () => { drag = null; stage.classList.remove('drag'); });
stage.addEventListener('wheel', e => {
e.preventDefault();
zoom = Math.max(0.35, Math.min(4, zoom * Math.exp(-e.deltaY * 0.0012)));
draw();
}, { passive: false });
document.getElementById('reset').addEventListener('click', () => {
yaw = -0.65; pitch = 0.55; zoom = 1;
draw();
});
window.addEventListener('resize', resize);
resize();
</script>
</body>
</html>