Source of the YABS board

These are the exact bytes the server runs. Compare the hash with your own sha256sum of the downloaded copy: if the two match, the page you read is the program you reviewed.
index.php · 625 lines · 33.1 KiB · sha256 e4e1788c5b985909cb28b502f6d3568251ee59678e4cfe9c4e8221c3946e21cb · download · board · README

<?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');
const 
TZ = 'Europe/Helsinki'; // the node's own clock: every PMSS box reports EET/EEST
date_default_timezone_set(TZ);

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 {
  
$t = trim((string)@file_get_contents(TOKEN_FILE));
  
// hash_equals('', '') is true, so an empty or unreadable file must never be
  // the admin token: mint a new one instead. If it cannot be written, the
  // stranger token below fails closed.
  
if ($t !== '') return $t;
  
$t = bin2hex(random_bytes(16));
  @
file_put_contents(TOKEN_FILE, $t);
  @
chmod(TOKEN_FILE, 0600);
  return 
$t;
}

// 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" in the node's own local time, and every
// PMSS node reports Europe/Helsinki, so the board reads it there and shows
// local times. Stored as a unix timestamp, so the zone lives in this one line.
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(TZ));
  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;
}

// One notation everywhere: a dot for decimals, a space for thousands
// ("10 644", "1 299.90"). A comma reads as a decimal mark to a French reader,
// and a decimal count that follows the magnitude makes 6.688 look larger than
// 43.70. The JSON API keeps raw numbers, so this only touches the HTML.
function num($v, int $d = 2): string {
  return 
$v === null ? '—' : number_format((float)$v, $d, '.', ' ');
}

function 
gib($v, string $unit = 'GiB'): string {
  return 
num($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 {
  return 
num($v, 2);
}

function 
json_out($x, int $code = 200): void {
  
http_response_code($code);
  
header('Content-Type: application/json');
  echo 
json_encode($x, JSON_UNESCAPED_SLASHES);
  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]);
}

// 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, '/') . '/';

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. No token at
  // all is the one-line path: the name comes from the request and is a label,
  // which is the same trust the open registry already gives a name.
  
$tok = (string)($_SERVER['HTTP_X_UPLOAD_TOKEN'] ?? ($_GET['token'] ?? ''));
  
$ownNode = null;
  if (
$tok !== '' && !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'] ?? '')));
  
// Every answer carries the page that takes this run's JSON, so a rejected
  // upload still ends with a link to click in the same terminal.
  
$form = $board . ($nodes ? '?node=' . rawurlencode($nodes[0]) : '');
  if (!
$nodes) { json_out(['ok'=>false,'error'=>'missing node: pass ?node=le4 or {"nodes":[...]}', 'form'=>$form], 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', 'form'=>$form], 422); }

  
// Data cleaning at the door: a 4k number far above what an NVMe does, or a
  // network figure above 100 Gbit/s, is a swapped field or a unit mistake.
  
foreach (['d4k'=>5000, 'd64k'=>10000, '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", 'form'=>$form], 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", 'form'=>$form], 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',
    
'form' => $form,
  ], 
$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);
// A requested node with no run still gets a filter box, so it stays reachable.
foreach ($nodesFilter as $n) { if (!in_array($n, $allNodes, true)) { $allNodes[] = $n; } }
// Rows carry a small integer instead of the name: a node name comes from the
// upload, so it may hold characters that would break a CSS selector.
$nodeKey = array_flip($allNodes);
$filterCss = '';
foreach (
$allNodes as $i => $n) {
  
$filterCss .= 'body:has(#f' . $i . ':checked) [data-node="' . $i . '"]{display:revert}';
}
$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}
.nfbar{display:flex;flex-wrap:wrap;align-items:center;gap:.4rem;margin:.7rem 0 .4rem}
.nfbar .lbl{font-size:.72rem;letter-spacing:.08em;text-transform:uppercase;color:#607d8b}
.nfbar input{position:absolute;opacity:0;width:0;height:0}
.nfbar label{font-size:.75rem;padding:.18rem .6rem;border:1px solid #37474f;border-radius:999px;color:#90a4ae;cursor:pointer}
.nfbar label:hover{border-color:#4fc3f7;color:#cfd8dc}
.nfbar input:checked+label{background:#123243;border-color:#4fc3f7;color:#e8f6ff;box-shadow:inset 0 0 0 1px #4fc3f7}
.nfbar input:focus-visible+label{outline:2px solid #4fc3f7;outline-offset:2px}
.nfbar .hint{font-size:.72rem}
/* The hint only makes sense while nothing is ticked. */
body:has(.nf:checked) .nfbar .hint{display:none}
/* Checkbox filtering, no JavaScript. One rule per node re-shows its rows when
   its box is ticked; the id in the :has() outranks the blanket hide, so order
   does not matter. revert gives each element back its own display value. */
body:has(.nf:checked) [data-node]{display:none}
' 
. $filterCss . '</style></head><body>';
echo 
'<h1>Node benchmark board</h1>';
// Runs stored and runs started today, on the same node filter as the tables.
[$cq, $cp] = node_filter(['cut' => strtotime('today')], 'SELECT COUNT(*), SUM(tested_at >= :cut) FROM runs WHERE 1=1', $nodesFilter);
$cst = $pdo->prepare($cq); $cst->execute($cp);
[
$tot, $today] = array_map('intval', $cst->fetch(PDO::FETCH_NUM));
echo 
'<p class="mut">' . $tot . ' runs stored, ' . $today . ' today (' . date('Y-m-d T') . '). '
  
. 'Verified YABS runs. Averages roll on a ' . CUTOFF_MONTHS . '-month cutoff (run date), so stale numbers drop out.</p>';
$cmds = 'curl -fsSLO ' . $board . "probe.sh\n"
  
. "sh probe.sh                        # register this node, monthly cron\n"
  
. "sh probe.sh schedule daily         # benchmark rate: daily|weekly|monthly\n"
  
. "sh probe.sh update hourly          # self-update: hourly|daily|weekly|monthly|off\n"
  
. "sh probe.sh refresh                # update the probe.sh copy, cron untouched\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>';
// No install is needed to publish one run: one request mints a node token, and
// that token can only write its own name, so the page never prints the admin one.
echo '<p class="mut">No probe? Register the node name once, then post the JSON. '
  
. 'Both commands are under <em>Add a run</em> below.</p>';
echo 
'<p class="mut"><a href="README.md">README</a> &middot; <a href="llms.txt">llms.txt</a>'
  
. ' (machine-readable install and API summary) &middot; <a href="?do=api">JSON</a>'
  
. ' &middot; <a href="racks">3D hangar</a> &middot; <a href="history">history</a>'
  
. ' &middot; <a href="source">source</a></p>';

$prefill = clean_name($_GET['node'] ?? '') ?? '';
echo 
'<details' . ($prefill !== '' ? ' open' : '') . '><summary class="mut">Add a run: one command, no probe, no token</summary>
<pre class="cmd">curl -sL yabs.sh | bash -s -- -i -g -j -s ' 
. h($board) . '?node=$(hostname -s)</pre>
<p class="mut">Run it on the machine to measure: your shell fills in <code>$(hostname -s)</code>. The board\'s answer prints first, then the JSON, so you can copy it. The answer ends with a link to this form for that node, so a rejected run still gives you something to click. <code>-i</code> skips iperf3, <code>-g</code> skips Geekbench. YABS does not check the upload: if no answer line appears the post failed, and the JSON is still printed for the form below. The JSON alone is enough, because the GPU, the disk media and the IO stall are optional and missing ones stay empty.</p>
<form method="post" id="upf">
<p id="nrow">' 
. ($prefill !== ''
  
? 'Uploading as <code>' . h($prefill) . '</code><input type="hidden" name="nodes" value="' . h($prefill) . '">'
  
: 'Node name: <input name="nodes" required placeholder="le6-3-58feros"> <span class="mut">or open this page as <code>?node=NAME</code></span>') . '</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>
const upf = document.getElementById("upf"), msg = document.getElementById("upmsg");
upf.addEventListener("submit", async (e) => {
  e.preventDefault();
  const f = new FormData(e.target);
  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);
  // A pasted yabs JSON has no nodes/yabs key, so wrap it with the typed names.
  // A v2026 run with -i has neither disk nor iperf keys, so keep the test simple.
  const wrap = (body.nodes || body.yabs) ? body : {nodes: nodes, yabs: body};
  const r = await fetch(location.pathname + "?src=web", {method:"POST", headers:{"Content-Type":"application/json"}, 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 
'<p class="nfbar"><span class="lbl">show</span>';
foreach (
$allNodes as $i => $n) {
  echo 
'<input type="checkbox" class="nf" id="f' . $i . '"><label for="f' . $i . '">' . h($n) . '</label>';
}
echo 
'<span class="hint mut">tick one or more; none shows all</span></p>';
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 ? num((float)$h['disk_gb'] / 1000, 1) . ' TB' : num($h['disk_gb'], 0) . ' GB');
  echo 
'<tr data-node="' . (int)($nodeKey[$h['node']] ?? -1) . '"><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 MB/s</th><th>512k MB/s</th><th>1m MB/s</th><th>io stall</th><th>send Gbit/s</th><th>recv Gbit/s</th><th>Geekbench s/m</th></tr>';
foreach (
$avgs as $a) {
  if (!
$a['n']) { echo '<tr data-node="' . (int)($nodeKey[$a['node']] ?? -1) . '"><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 data-node="' . (int)($nodeKey[$a['node']] ?? -1) . '"><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>' . num($a['aiops'], 0) . '</td>'
     
. '<td>' . fmt($a['a64']) . '</td><td>' . fmt($a['a512']) . '</td><td>' . fmt($a['a1m']) . '</td>'
     
. '<td>' . ($a['astall']!==null?num($a['astall'],0).'%':'—') . '</td>'
     
. '<td>' . num($a['asend']!==null?$a['asend']/1000:null, 2) . '</td>'
     
. '<td>' . num($a['arecv']!==null?$a['arecv']/1000:null, 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 
'<section data-node="' . (int)($nodeKey[$node] ?? -1) . '">';
  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></section>'; continue; }
  echo 
'<table><tr><th>date</th><th>4k MB/s</th><th>4k IOPS</th><th>64k MB/s</th><th>512k MB/s</th><th>1m MB/s</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>' . num($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?num($r['psi_full'],0).'%':'—') . '</td>'
       
. '<td>' . num($r['send_mbit']!==null?$r['send_mbit']/1000:null, 2) . '</td>'
       
. '<td>' . num($r['recv_mbit']!==null?$r['recv_mbit']/1000:null, 2) . '</td>'
       
. '<td class="n mut">' . h((string)$r['yabs_version']) . '</td></tr>';
  }
  echo 
'</table>';
  echo 
'</section>';
}

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 their KVM rows were backfilled to <code>virtio</code> on 2026-10-27; rows from bare-metal nodes keep the flag verdict.</p>';
echo 
'</body></html>';