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 · 608 lines · 31.4 KiB · sha256 93068914ffd1e865a2dc78e59c5aba5549e363d9f63c68f9f834e60cc0b97530 · 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);
  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);
// 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>';
// 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 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>';
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> &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>';

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"> &nbsp; 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 
'<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>';