<?php
// PM node YABS board — one page to publish and compare `yabs` runs per node.
// Single file PHP + SQLite. The DB lives outside the public folder, in
// /home/vasilves/yabs-data/, so the web server never serves it.
// Deployed to ~/www/public/yabs/ and mirrored from servers/caroline/www/yabs/.
declare(strict_types=1);
error_reporting(E_ALL & ~E_DEPRECATED);
ini_set('display_errors', '0');
date_default_timezone_set('UTC');
const DB_DIR = '/home/vasilves/yabs-data';
const DB_FILE = DB_DIR . '/yabs.sqlite';
const TOKEN_FILE = DB_DIR . '/upload-token.txt';
const CUTOFF_MONTHS = 6;
const MAX_BODY = 262144; // a full yabs JSON is ~10 KB; this only stops abuse
// Hardware the node reports. Kept per run so a changed CPU or disk shows up.
const HW_COLUMNS = [
'cpu' => 'TEXT', 'cores' => 'INTEGER', 'ram_gib' => 'REAL', 'swap_gib' => 'REAL',
'disk_gb' => 'REAL', 'os' => 'TEXT', 'vm' => 'TEXT', 'gpu' => 'TEXT',
];
// Measured once, during the run, by the probe: neither can be recomputed later,
// so a run from an older probe keeps NULL and the board falls back to no weight.
const RUN_EXTRAS = [
'psi_some' => 'REAL', 'psi_full' => 'REAL',
'disk_rota' => 'INTEGER', 'disk_tran' => 'TEXT', 'disk_virt' => 'INTEGER',
];
// PMSS forwards the panel path with this prefix stripped, so the page rebuilds
// it to print a URL that works from another node.
const PANEL_PREFIX = '/public-vasilves';
function db(): PDO {
if (!is_dir(DB_DIR)) { @mkdir(DB_DIR, 0770, true); }
$p = new PDO('sqlite:' . DB_FILE);
$p->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$p->exec('CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node TEXT NOT NULL,
uploaded_at INTEGER NOT NULL,
tested_at INTEGER,
yabs_version TEXT,
raw TEXT NOT NULL,
d4k REAL, d4k_iops REAL,
d64k REAL, d512k REAL, d1m REAL,
send_mbit REAL, recv_mbit REAL,
gb6_single REAL, gb6_multi REAL,
uploader TEXT
)');
$p->exec('CREATE INDEX IF NOT EXISTS idx_runs_node ON runs(node, tested_at)');
// One row per node and run start: re-posting the same JSON is a no-op.
try {
$p->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_runs_uniq ON runs(node, tested_at)');
} catch (PDOException $e) {
// Older duplicates survive; inserts still work, dedupe only applies to new rows.
}
// A node asks for its own token here instead of sharing the admin one.
$p->exec('CREATE TABLE IF NOT EXISTS nodes (
name TEXT PRIMARY KEY,
token TEXT NOT NULL,
gpu TEXT,
registered_at INTEGER NOT NULL,
last_seen INTEGER
)');
$have = array_column($p->query('PRAGMA table_info(runs)')->fetchAll(PDO::FETCH_ASSOC), 'name');
// Additive only: nodes own their rows. Never recreate, drop or rewrite the
// table, and never delete the database file: a measurement cannot be re-run
// on demand, so a lost row is lost data.
foreach (array_merge(HW_COLUMNS, RUN_EXTRAS) as $col => $type) {
if (!in_array($col, $have, true)) { $p->exec("ALTER TABLE runs ADD COLUMN $col $type"); }
}
return $p;
}
function upload_token(): string {
if (!file_exists(TOKEN_FILE)) {
$t = bin2hex(random_bytes(16));
file_put_contents(TOKEN_FILE, $t);
chmod(TOKEN_FILE, 0600);
}
return trim((string)file_get_contents(TOKEN_FILE));
}
// KBps or MBps string field from yabs fio output, normalised to MB/s.
function fio_mbps(array $r, string $key): ?float {
if (!isset($r[$key]) || !is_numeric($r[$key])) return null;
$unit = strtoupper((string)($r['speed_units'] ?? 'KBps'));
return str_contains($unit, 'MB') ? (float)$r[$key] : (float)$r[$key] / 1024;
}
// iperf3 result strings look like "5.20 Gbits/sec"; return Mbit/s.
function rate_mbit($v): ?float {
if (!is_string($v) || !preg_match('~([\d.]+)\s*([KMGT])?bits/sec~i', $v, $m)) return null;
$n = (float)$m[1];
switch (strtoupper($m[2] ?? 'M')) {
case 'G': return $n * 1000;
case 'T': return $n * 1000000;
case 'K': return $n / 1000;
default: return $n;
}
}
// Flatten the yabs JSON (v2026-10-07 layout) into the columns the board shows.
function extract_numbers(array $y): array {
$out = [
'd4k'=>null,'d4k_iops'=>null,'d64k'=>null,'d512k'=>null,'d1m'=>null,
'send_mbit'=>null,'recv_mbit'=>null,'gb6_single'=>null,'gb6_multi'=>null,
];
// v2026 puts fio results at the top level; keep the older disk.fio path too.
$fio = $y['fio'] ?? ($y['disk']['fio'] ?? []);
foreach ((array)$fio as $r) {
if (!is_array($r)) continue;
$bs = (string)($r['bs'] ?? '');
$mbps = fio_mbps($r, 'speed_rw');
if ($mbps === null) { // older layouts report read and write separately
$r2 = fio_mbps($r, 'speed_r');
$w2 = fio_mbps($r, 'speed_w');
if ($r2 !== null && $w2 !== null) $mbps = ($r2 + $w2) / 2;
}
if ($mbps === null) continue;
$iops = isset($r['iops_rw']) && is_numeric($r['iops_rw']) ? (float)$r['iops_rw'] : null;
if ($bs === '4k') { $out['d4k'] = $mbps; $out['d4k_iops'] = $iops; }
if ($bs === '64k') { $out['d64k'] = $mbps; }
if ($bs === '512k') { $out['d512k'] = $mbps; }
if ($bs === '1m') { $out['d1m'] = $mbps; }
}
// iperf3 runs against 7 locations and half of them time out; a dead
// location still reports a few Kbit/s. Keep the best one and drop those.
$sends = []; $recvs = [];
foreach ((array)($y['iperf'] ?? []) as $r) {
if (!is_array($r)) continue;
$s = rate_mbit($r['send'] ?? null);
$v = rate_mbit($r['recv'] ?? null);
if ($s !== null && $s >= 1) $sends[] = $s;
if ($v !== null && $v >= 1) $recvs[] = $v;
}
if ($sends) $out['send_mbit'] = max($sends);
if ($recvs) $out['recv_mbit'] = max($recvs);
// geekbench is a list of runs (4/5/6/7); keep the first one that scored.
foreach ((array)($y['geekbench'] ?? []) as $gb) {
if (is_array($gb) && (isset($gb['single']) || isset($gb['multi']))) {
$out['gb6_single'] = is_numeric($gb['single'] ?? null) ? (float)$gb['single'] : null;
$out['gb6_multi'] = is_numeric($gb['multi'] ?? null) ? (float)$gb['multi'] : null;
break;
}
}
return $out;
}
// cpu/mem/os blocks of the yabs JSON, normalised to the columns the board shows.
function extract_hardware(array $y): array {
$cpu = is_array($y['cpu'] ?? null) ? $y['cpu'] : [];
$mem = is_array($y['mem'] ?? null) ? $y['mem'] : [];
$os = is_array($y['os'] ?? null) ? $y['os'] : [];
return [
'cpu' => isset($cpu['model']) ? substr((string)$cpu['model'], 0, 120) : null,
'cores' => is_numeric($cpu['cores'] ?? null) ? (int)$cpu['cores'] : null,
// yabs reports KiB and KB, so GiB is /1048576 and GB is /1e6.
'ram_gib' => is_numeric($mem['ram'] ?? null) ? round($mem['ram'] / 1048576, 1) : null,
'swap_gib' => is_numeric($mem['swap'] ?? null) ? round($mem['swap'] / 1048576, 1) : null,
'disk_gb' => is_numeric($mem['disk'] ?? null) ? round($mem['disk'] / 1000000) : null,
'os' => isset($os['distro']) ? substr((string)$os['distro'], 0, 80) : null,
'vm' => isset($os['vm']) ? substr((string)$os['vm'], 0, 20) : null,
];
}
// What the node's kernel says about the disk behind its home directory. The
// probe reads /sys/block/<dev>/queue/rotational, `lsblk -dno TRAN` and, when the
// device exposes it, the ATA rotation rate. NVMe is flash by definition, so the
// transport alone settles it. A virtio disk declares nothing: QEMU leaves the
// rotational flag at 1 whatever the backing store is, so `?virt=1` wins and the
// board prints `virtio` rather than publishing that default as HDD. The board
// never guesses from the measured IOPS: a stalled disk measures low, not slow.
function disk_media($rota, $tran, $virt = null): string {
if ((int)$virt === 1) return 'virtio';
if (strtolower(trim((string)$tran)) === 'nvme') return 'NVMe';
if (!is_numeric($rota)) return '—';
return ((int)$rota) === 0 ? 'SSD' : 'HDD';
}
// A run measured while the disk was stalled understates the hardware, so it
// counts less in the average. The 0.25 floor keeps a permanently slow disk from
// weighting its own bad numbers away. NULL pressure (old run) counts as 1.
function wavg(string $col): string {
$w = 'CASE WHEN psi_full IS NULL THEN 1.0 ELSE MAX(0.25, 1.0 - psi_full / 100.0) END';
return "SUM($col * $w) / SUM($w)";
}
// yabs writes "time":"20261027-164500" (local time of the node, UTC on ours).
function parse_tested_at(array $y): ?int {
$raw = $y['time'] ?? null;
if (!is_string($raw) || $raw === '') return null;
$t = DateTimeImmutable::createFromFormat('Ymd-His', $raw, new DateTimeZone('UTC'));
if ($t === false) return null;
$ts = $t->getTimestamp();
return ($ts > 946684800 && $ts < time() + 86400) ? $ts : null;
}
// PMSS terminates TLS on its own proxy, so the visitor address arrives here.
function client_ip(): string {
$xff = (string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? '');
$ip = trim(explode(',', $xff)[0]);
if (filter_var($ip, FILTER_VALIDATE_IP)) return $ip;
return (string)($_SERVER['REMOTE_ADDR'] ?? '');
}
function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); }
// Node names come from ?node=, the form or a token; keep them hostname-shaped.
function clean_name($raw): ?string {
$n = trim((string)$raw);
return preg_match('~^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$~', $n) ? $n : null;
}
function gib($v, string $unit = 'GiB'): string {
return $v === null ? '—' : number_format((float)$v, 1) . ' ' . $unit;
}
// Hardware barely changes, so the board shows the newest row of each node.
function latest_hardware(PDO $pdo): array {
$sql = 'SELECT r.node, r.cpu, r.cores, r.ram_gib, r.swap_gib, r.disk_gb, r.os, r.vm, r.gpu,
r.disk_rota, r.disk_tran, r.disk_virt, r.tested_at
FROM runs r JOIN (SELECT node, MAX(tested_at) m FROM runs GROUP BY node) l
ON l.node = r.node AND l.m = r.tested_at GROUP BY r.node';
$out = [];
foreach ($pdo->query($sql, PDO::FETCH_ASSOC) as $h) { $out[$h['node']] = $h; }
return $out;
}
function fmt($v): string {
if ($v === null) return '—';
$v = (float)$v;
return $v >= 100 ? number_format($v, 1) : ($v >= 10 ? number_format($v, 2) : number_format($v, 3));
}
function json_out($x, int $code = 200): void {
http_response_code($code);
header('Content-Type: application/json');
echo json_encode($x);
exit;
}
function clean_nodes($raw): array {
if (is_string($raw)) $raw = explode(',', $raw);
if (!is_array($raw)) return [];
$out = [];
foreach (array_slice($raw, 0, 20) as $n) {
if (!is_scalar($n)) continue;
$n = trim((string)$n);
if ($n !== '' && strlen($n) <= 100) $out[$n] = true;
}
return array_keys($out);
}
function node_filter(array $pdoParams, string $sql, array $nodes): array {
if (!$nodes) return [$sql, $pdoParams];
$ph = [];
foreach ($nodes as $i => $n) { $ph[] = ":n$i"; $pdoParams[":n$i"] = $n; }
return [$sql . ' AND node IN (' . implode(',', $ph) . ')', $pdoParams];
}
// ---------- routing ----------
$pdo = db();
$nodesFilter = clean_nodes($_GET['nodes'] ?? '');
$do = (string)($_GET['do'] ?? '');
// A node installs the probe, which calls this once to get its own token.
if ($do === 'register') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { json_out(['ok'=>false,'error'=>'POST {"name":"<hostname>"}'], 405); }
$in = json_decode((string)file_get_contents('php://input', false, null, 0, 4096), true);
if (!is_array($in)) { json_out(['ok'=>false,'error'=>'body must be JSON'], 400); }
$name = clean_name($in['name'] ?? '');
if ($name === null) { json_out(['ok'=>false,'error'=>'name must look like a hostname'], 400); }
if ((int)$pdo->query('SELECT COUNT(*) FROM nodes')->fetchColumn() >= 500) {
json_out(['ok'=>false,'error'=>'registry is full'], 507);
}
$st = $pdo->prepare('SELECT name FROM nodes WHERE name = ?');
$st->execute([$name]);
if ($st->fetchColumn() !== false) {
// No token is ever handed out twice: a lost token needs a manual delete.
json_out(['ok'=>false,'error'=>"$name is already registered; ask the board owner to delete its row"], 409);
}
$token = bin2hex(random_bytes(16));
$gpu = clean_name($in['gpu'] ?? '') ?: null;
$pdo->prepare('INSERT INTO nodes (name, token, gpu, registered_at) VALUES (?,?,?,?)')
->execute([$name, $token, $gpu, time()]);
json_out(['ok'=>true, 'name'=>$name, 'token'=>$token, 'gpu'=>$gpu]);
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$raw = (string)file_get_contents('php://input', false, null, 0, MAX_BODY + 1);
if (strlen($raw) > MAX_BODY) { json_out(['ok'=>false,'error'=>'body too large'], 413); }
// Header for the web form; query string so a plain `yabs -s <url>` works too.
// The shared admin token can post any node; a registered node token only its
// own name, so a leaked node token cannot overwrite another node.
$tok = (string)($_SERVER['HTTP_X_UPLOAD_TOKEN'] ?? ($_GET['token'] ?? ''));
$ownNode = null;
if (!hash_equals(upload_token(), $tok)) {
$st = $pdo->prepare('SELECT name FROM nodes WHERE token = ?');
$st->execute([$tok]);
$ownNode = $st->fetchColumn();
if ($ownNode === false) { json_out(['ok'=>false,'error'=>'invalid token'], 401); }
}
$in = json_decode($raw, true);
if (!is_array($in)) { json_out(['ok'=>false,'error'=>'body must be JSON'], 400); }
// Accepted shapes: the raw `yabs.sh -j` JSON (node from ?node=), or
// {"nodes":["le4"], "yabs": {...}}.
$yabs = isset($in['yabs']) && is_array($in['yabs']) ? $in['yabs'] : $in;
$nodes = $ownNode !== null ? [$ownNode]
: clean_nodes($in['nodes'] ?? ($_GET['node'] ?? ($_GET['nodes'] ?? '')));
if (!$nodes) { json_out(['ok'=>false,'error'=>'missing node: pass ?node=le4 or {"nodes":[...]}'], 400); }
$nums = extract_numbers($yabs);
$hasData = false;
foreach ($nums as $k => $v) { if ($v !== null && $k !== 'gb6_single' && $k !== 'gb6_multi') $hasData = true; }
if (!$hasData) { json_out(['ok'=>false,'error'=>'no YABS disk or net numbers recognised'], 422); }
// Data cleaning at the door: small blocks cannot be fast on any disk, so
// these ceilings catch a swapped field or a unit mistake.
foreach (['d4k'=>200, 'd64k'=>2000, 'd512k'=>20000, 'd1m'=>20000] as $k => $max) {
if ($nums[$k] !== null && ($nums[$k] <= 0 || $nums[$k] > $max)) {
json_out(['ok'=>false,'error'=>"$k implausible: {$nums[$k]} MB/s"], 422);
}
}
foreach (['send_mbit','recv_mbit'] as $k) {
if ($nums[$k] !== null && ($nums[$k] <= 0 || $nums[$k] > 100000)) {
json_out(['ok'=>false,'error'=>"$k implausible: {$nums[$k]} Mbit/s"], 422);
}
}
$tested = parse_tested_at($yabs) ?? time();
$hw = extract_hardware($yabs);
// The probe passes its GPU name on the query string: yabs cannot report one.
$gpu = clean_name($_GET['gpu'] ?? '') ?: null;
// Same for the IO pressure during the run and the disk's media type.
$pct = fn(string $k) => is_numeric($_GET[$k] ?? null) && (float)$_GET[$k] >= 0 && (float)$_GET[$k] <= 100
? round((float)$_GET[$k], 1) : null;
$rota = in_array((string)($_GET['rota'] ?? ''), ['0', '1'], true) ? (int)$_GET['rota'] : null;
$tran = preg_match('~^[a-z0-9]{1,12}$~', strtolower((string)($_GET['tran'] ?? ''))) ? strtolower($_GET['tran']) : null;
$virt = ((string)($_GET['virt'] ?? '') === '1') ? 1 : null;
$cols = array_keys(HW_COLUMNS);
$st = $pdo->prepare('INSERT OR IGNORE INTO runs
(node, uploaded_at, tested_at, yabs_version, raw, d4k, d4k_iops, d64k, d512k, d1m,
send_mbit, recv_mbit, gb6_single, gb6_multi, uploader, psi_some, psi_full, disk_rota, disk_tran, disk_virt, '
. implode(', ', $cols) . ')
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,' . rtrim(str_repeat('?,', count($cols)), ',') . ')');
$saved = []; $skipped = [];
foreach ($nodes as $node) {
$st->execute(array_merge([
$node, time(), $tested,
(string)($yabs['version'] ?? 'unknown'), json_encode($yabs),
$nums['d4k'], $nums['d4k_iops'], $nums['d64k'], $nums['d512k'], $nums['d1m'],
$nums['send_mbit'], $nums['recv_mbit'], $nums['gb6_single'], $nums['gb6_multi'],
substr(client_ip(), 0, 45), $pct('psi_some'), $pct('psi_full'), $rota, $tran, $virt,
], array_values($hw), [$gpu]));
if ($st->rowCount() > 0) { $saved[] = $node; } else { $skipped[] = $node; }
}
if ($ownNode !== null) {
$pdo->prepare('UPDATE nodes SET gpu = COALESCE(?, gpu), last_seen = ? WHERE name = ?')
->execute([$gpu, time(), $ownNode]);
}
json_out([
'ok' => (bool)$saved,
'saved' => $saved,
'duplicate' => $skipped,
'error' => $saved ? null : 'already uploaded for this run time',
], $saved ? 200 : 409);
}
if ($do === 'api') {
$cut = time() - CUTOFF_MONTHS * 30 * 86400;
[$q, $p] = node_filter(['cut'=>$cut],
'SELECT node, COUNT(*) n, MIN(tested_at) first, MAX(tested_at) last,
' . wavg('d4k') . ' a4k, MIN(d4k) mn4k, MAX(d4k) mx4k, ' . wavg('d4k_iops') . ' aiops,
' . wavg('d64k') . ' a64, ' . wavg('d512k') . ' a512, ' . wavg('d1m') . ' a1m,
AVG(send_mbit) asend, AVG(recv_mbit) arecv, AVG(gb6_single) agbs, AVG(gb6_multi) agbm,
AVG(psi_full) astall
FROM runs WHERE tested_at >= :cut', $nodesFilter);
$q .= ' GROUP BY node ORDER BY node';
$st = $pdo->prepare($q); $st->execute($p);
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
$hw = latest_hardware($pdo);
foreach ($rows as $i => $r) {
$rows[$i] = array_merge($r, array_diff_key($hw[$r['node']] ?? [], ['node' => 1]));
$rows[$i]['disk'] = disk_media($rows[$i]['disk_rota'] ?? null, $rows[$i]['disk_tran'] ?? null, $rows[$i]['disk_virt'] ?? null);
}
foreach ($nodesFilter as $n) {
if (!in_array($n, array_column($rows, 'node'), true)) $rows[] = ['node'=>$n, 'n'=>0];
}
usort($rows, fn($a,$b) => strcmp($a['node'], $b['node']));
json_out(['cutoff_months'=>CUTOFF_MONTHS, 'nodes'=>$rows]);
}
if ($do === 'list') {
[$q, $p] = node_filter([], 'SELECT id, node, tested_at, uploaded_at, yabs_version,
d4k, d4k_iops, d64k, d512k, d1m, send_mbit, recv_mbit, gb6_single, gb6_multi,
psi_some, psi_full, disk_rota, disk_tran, disk_virt
FROM runs WHERE 1=1', $nodesFilter);
$q .= ' ORDER BY tested_at DESC LIMIT 500';
$st = $pdo->prepare($q); $st->execute($p);
json_out(['runs'=>$st->fetchAll(PDO::FETCH_ASSOC)]);
}
// ---------- HTML view ----------
$allNodes = $pdo->query('SELECT DISTINCT node FROM runs ORDER BY node')->fetchAll(PDO::FETCH_COLUMN);
$cut = time() - CUTOFF_MONTHS * 30 * 86400;
echo '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PM node YABS board</title>
<style>
body{font-family:ui-monospace,Menlo,Consolas,monospace;background:#101418;color:#cfd8dc;margin:0;padding:1.2rem}
h1{font-size:1.15rem;color:#fff} h2{font-size:1rem;color:#9ccc65;margin-top:1.6rem}
a{color:#4fc3f7;text-decoration:none}
table{border-collapse:collapse;margin:.5rem 0;font-size:.85rem}
th,td{border:1px solid #2c343c;padding:.25rem .6rem;text-align:right}
th{background:#1a222a;color:#fff;white-space:nowrap}
td.n{text-align:left;white-space:nowrap}
.mut{color:#78909c;font-size:.78rem}
.note{background:#1a222a;border-left:3px solid #ffb74d;padding:.6rem .8rem;font-size:.8rem;max-width:62rem;margin:.8rem 0}
code{color:#9ccc65}
pre.cmd{color:#cfd8dc;font-size:.78rem;margin:.3rem 0 .6rem;overflow-x:auto}
input,button,textarea{font:inherit;background:#1a222a;color:#cfd8dc;border:1px solid #37474f;padding:.2rem .5rem;border-radius:3px}
button{cursor:pointer;color:#9ccc65}
details{margin:.4rem 0}
</style></head><body>';
echo '<h1>Node benchmark board</h1>';
echo '<p class="mut">Verified YABS runs. Averages roll on a ' . CUTOFF_MONTHS . '-month cutoff (run date), so stale numbers drop out.</p>';
// PMSS serves the same docroot at two addresses: /public-<account>/ on its own
// domain, and / on a customer domain such as 05877bc1b477317f.mcx.fi. The
// customer vhost rewrites to the prefixed path internally, so strip the prefix
// and put it back only for the panel hostname: a copied URL must not name the
// account.
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$path = strtok($_SERVER['REQUEST_URI'], '?');
if (strpos($path, PANEL_PREFIX) === 0) {
$path = substr($path, strlen(PANEL_PREFIX)) ?: '/';
}
if (strpos($host, 'pulsedmedia.com') !== false && strpos($path, PANEL_PREFIX) !== 0) {
$path = PANEL_PREFIX . $path;
}
$scheme = (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https' || !empty($_SERVER['HTTPS'])) ? 'https' : 'http';
$board = $scheme . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . rtrim($path, '/') . '/';
$self = $board . '?token=TOKEN&node=$(hostname -s)';
$cmds = 'curl -fsSLO ' . $board . "probe.sh\n"
. "sh probe.sh # register this node, monthly cron\n"
. "sh probe.sh install daily hourly # rates: benchmark, self-update\n"
. "sh probe.sh update # refresh the probe.sh copy\n"
. "sh probe.sh now # one run on screen, 10 to 20 minutes\n"
. "sh probe.sh status # running or not, last log line\n";
echo '<p class="mut">Another node, run these on it:</p>';
echo '<pre class="cmd">' . h($cmds) . '</pre>';
echo '<p class="mut">From a node with the admin token: <code>yabs.sh -j -s ' . h($self) . '</code></p>';
echo '<p class="mut"><a href="README.md">README</a> · <a href="llms.txt">llms.txt</a>'
. ' (machine-readable install and API summary) · <a href="?do=api">JSON</a>'
. ' · <a href="racks">3D hangar</a> · <a href="history">history</a>'
. ' · <a href="source">source</a></p>';
echo '<details><summary class="mut">Add a run (paste YABS JSON)</summary>
<form method="post" id="up">
<p>Node name(s), comma-separated: <input name="nodes" required placeholder="le4, le6"> Upload token: <input name="t" type="password" required></p>
<p><textarea name="yabs" rows="10" cols="90" placeholder=\'paste raw `yabs.sh -j` output\'></textarea></p>
<p><button type="submit">Upload</button> <span id="upmsg"></span></p>
</form>
<script>
document.getElementById("up").addEventListener("submit", async (e) => {
e.preventDefault();
const f = new FormData(e.target), msg = document.getElementById("upmsg");
let body;
try { body = JSON.parse(f.get("yabs")); } catch (err) { msg.textContent = "not valid JSON"; return; }
const nodes = f.get("nodes").split(",").map(s=>s.trim()).filter(Boolean);
const wrap = (body.disk || body.iperf || body.geekbench) ? {nodes: nodes, yabs: body} : body;
const r = await fetch(location.pathname + "?src=web", {method:"POST", headers:{"Content-Type":"application/json","X-Upload-Token":f.get("t")}, body: JSON.stringify(wrap)});
const j = await r.json();
msg.textContent = j.ok ? "saved " + j.saved.join(", ") + " — reload to see it" : ("error: " + (j.error || r.status));
});
</script></details>';
if (!$allNodes) {
echo '<p class="note">No runs yet. Post one from a node, or paste JSON above.</p></body></html>';
exit;
}
$q = 'SELECT node, COUNT(*) n, MAX(tested_at) last,
' . wavg('d4k') . ' a4k, ' . wavg('d4k_iops') . ' aiops,
' . wavg('d64k') . ' a64, ' . wavg('d512k') . ' a512, ' . wavg('d1m') . ' a1m,
AVG(send_mbit) asend, AVG(recv_mbit) arecv, AVG(gb6_single) agbs, AVG(gb6_multi) agbm,
AVG(psi_full) astall
FROM runs WHERE tested_at >= ? GROUP BY node ORDER BY node';
$st = $pdo->prepare($q); $st->execute([$cut]);
$avgs = array_values(array_filter($st->fetchAll(PDO::FETCH_ASSOC), fn($a) => ($a['n'] ?? 0) > 0));
foreach ($nodesFilter as $n) {
if (!in_array($n, array_column($avgs, 'node'), true)) $avgs[] = ['node'=>$n, 'n'=>0];
}
usort($avgs, fn($a,$b) => strcmp($a['node'], $b['node']));
echo '<h2>Nodes</h2>';
echo '<table><tr><th>node</th><th>cpu</th><th>cores</th><th>ram</th><th>swap</th><th>disk</th><th>gpu</th><th>os</th><th>vm</th><th>last run</th></tr>';
$hw = latest_hardware($pdo);
foreach ($hw as $h) {
$disk = $h['disk_gb'] === null ? '—'
: ((float)$h['disk_gb'] >= 1000 ? number_format((float)$h['disk_gb'] / 1000, 1) . ' TB' : number_format((float)$h['disk_gb'], 0) . ' GB');
echo '<tr><td class="n">' . h($h['node']) . '</td>'
. '<td class="n">' . h((string)($h['cpu'] ?? '—')) . '</td>'
. '<td>' . ($h['cores'] === null ? '—' : (int)$h['cores']) . '</td>'
. '<td>' . gib($h['ram_gib']) . '</td><td>' . gib($h['swap_gib']) . '</td>'
. '<td>' . $disk . '</td>'
. '<td class="n">' . h((string)($h['gpu'] ?? '—')) . '</td>'
. '<td class="n">' . h((string)($h['os'] ?? '—')) . '</td>'
. '<td class="n">' . h((string)($h['vm'] ?? '—')) . '</td>'
. '<td class="n">' . date('Y-m-d', (int)$h['tested_at']) . '</td></tr>';
}
echo '</table>';
echo '<h2>Averages, last ' . CUTOFF_MONTHS . ' months</h2>';
echo '<table><tr><th>node</th><th>media</th><th>runs</th><th>4k MB/s</th><th>4k IOPS</th><th>64k</th><th>512k</th><th>1m</th><th>io stall</th><th>send Gbit/s</th><th>recv Gbit/s</th><th>GB s/m</th></tr>';
foreach ($avgs as $a) {
if (!$a['n']) { echo '<tr><td class="n">' . h($a['node']) . '</td><td>—</td><td>0</td><td colspan="9" style="text-align:left;color:#90a4ae;font-size:.8rem">no run in the window</td></tr>'; continue; }
echo '<tr><td class="n">' . h($a['node']) . '</td>'
. '<td>' . h(disk_media($hw[$a['node']]['disk_rota'] ?? null, $hw[$a['node']]['disk_tran'] ?? null, $hw[$a['node']]['disk_virt'] ?? null)) . '</td>'
. '<td>' . (int)$a['n'] . '</td>'
. '<td>' . fmt($a['a4k']) . '</td><td>' . ($a['aiops']!==null?number_format((float)$a['aiops'],0):'—') . '</td>'
. '<td>' . fmt($a['a64']) . '</td><td>' . fmt($a['a512']) . '</td><td>' . fmt($a['a1m']) . '</td>'
. '<td>' . ($a['astall']!==null?number_format((float)$a['astall'],0).'%':'—') . '</td>'
. '<td>' . ($a['asend']!==null?number_format($a['asend']/1000,2):'—') . '</td>'
. '<td>' . ($a['arecv']!==null?number_format($a['arecv']/1000,2):'—') . '</td>'
. '<td>' . ($a['agbs']!==null?fmt($a['agbs']).'/'.fmt($a['agbm']):'—') . '</td></tr>';
}
echo '</table>';
echo '<h2>Per-node runs (last ' . CUTOFF_MONTHS . ' months)</h2>';
foreach ($avgs as $a) {
$node = $a['node'];
$st2 = $pdo->prepare('SELECT id, tested_at, yabs_version, d4k, d4k_iops, d64k, d512k, d1m, send_mbit, recv_mbit, psi_full
FROM runs WHERE node = ? AND tested_at >= ? ORDER BY tested_at DESC');
$st2->execute([$node, $cut]);
$runs = $st2->fetchAll(PDO::FETCH_ASSOC);
echo '<h3 style="color:#4fc3f7;font-size:.95rem;margin-bottom:.2rem">' . h($node) . '</h3>';
if (!$runs) { echo '<p style="text-align:left;color:#90a4ae;font-size:.8rem">no run in the window (last overall: ' . (!empty($a['last']) ? date('Y-m-d', (int)$a['last']) : 'never') . ')</p>'; continue; }
echo '<table><tr><th>date</th><th>4k MB/s</th><th>IOPS</th><th>64k</th><th>512k</th><th>1m</th><th>io stall</th><th>send Gbit/s</th><th>recv Gbit/s</th><th>yabs</th></tr>';
foreach ($runs as $r) {
echo '<tr><td class="n">' . date('Y-m-d', (int)$r['tested_at']) . '</td>'
. '<td>' . fmt($r['d4k']) . '</td><td>' . ($r['d4k_iops']!==null?number_format((float)$r['d4k_iops'],0):'—') . '</td>'
. '<td>' . fmt($r['d64k']) . '</td><td>' . fmt($r['d512k']) . '</td><td>' . fmt($r['d1m']) . '</td>'
. '<td>' . ($r['psi_full']!==null?number_format((float)$r['psi_full'],0).'%':'—') . '</td>'
. '<td>' . ($r['send_mbit']!==null?number_format($r['send_mbit']/1000,2):'—') . '</td>'
. '<td>' . ($r['recv_mbit']!==null?number_format($r['recv_mbit']/1000,2):'—') . '</td>'
. '<td class="n mut">' . h((string)$r['yabs_version']) . '</td></tr>';
}
echo '</table>';
}
echo '<p class="note">Disk numbers are the fio mixed read/write figures (speed_rw, IOPS). Net numbers are the best iperf3 location, since a timed-out location still reports a few Kbit/s. On shared storage these swing widely between runs: read them as a trend, not a spec.</p>';
echo '<p class="note">Media is what the node kernel says about the disk under its home directory: <code>lsblk</code> transport <code>nvme</code>, else the <code>rotational</code> flag, with the ATA rotation rate overriding it. A <code>virtio</code> disk declares no media at all, because QEMU leaves the rotational flag at 1 whatever the backing store is, so the board names it <code>virtio</code> instead of publishing that default; a RAID shows its array, not the members. <strong>io stall</strong> is the share of the run window that a task spent blocked on IO (kernel PSI, <code>/proc/pressure/io</code>): a high value means the disk was congested while measured, not necessarily slow. The disk averages are therefore weighted by <code>1 - stall</code>, floored at 0.25, so a congested run counts less; the per-run tables keep the raw numbers. Runs from before 2026-10-27 carry no stall and weigh 1, and a virtual disk among them still reads HDD because it has no <code>virt</code> flag.</p>';
echo '</body></html>';