Database error: = h($error) ?>
Show every run.' : 'No runs yet. Install the probe, then reload.'; ?>
['4k read-write', 'MB/s', 2], 'd4k_iops' => ['4k IOPS', 'IOPS', 0], 'd64k' => ['64k read-write', 'MB/s', 2], 'd512k' => ['512k read-write', 'MB/s', 2], 'd1m' => ['1M read-write', 'MB/s', 2], 'send_mbit' => ['Network send', 'Mbit/s', 0], 'recv_mbit' => ['Network receive', 'Mbit/s', 0], 'psi_full' => ['IO stall during the run', '%', 1], ]; function h(?string $s): string { return htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8'); } function num($v, int $d): string { // Dot for decimals, space for thousands: a comma reads as a decimal mark // to a French reader, so "10,644" looked like ten and a bit, not 10644. return $v === null ? 'n/a' : number_format((float) $v, $d, '.', ' '); } $error = null; $rows = []; try { $db = new PDO('sqlite:' . DB_PATH, null, null, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ]); // psi_full is added by index.php's migration; read NULL until it exists so // this page never breaks on a fresh deployment order. $cols = array_column($db->query('PRAGMA table_info(runs)')->fetchAll(), 'name'); // Columns added by later versions: read NULL until index.php has migrated, // so this page never breaks on a fresh deployment order. $col = static fn(string $c): string => in_array($c, $cols, true) ? $c : 'NULL AS ' . $c; $rows = $db->query( 'SELECT node, tested_at, d4k, d4k_iops, d64k, d512k, d1m, send_mbit, recv_mbit, ' . $col('psi_full') . ', ' . $col('disk_rota') . ', ' . $col('disk_tran') . ', ' . $col('disk_virt') . ' FROM runs WHERE tested_at IS NOT NULL ORDER BY tested_at' )->fetchAll(); } catch (Throwable $e) { $error = $e->getMessage(); } $series = []; // node => list of rows // Same rule as index.php: a virtio disk does not declare its media, so its // rotational flag is QEMU's default and says nothing about the hardware. function media_of(array $r): string { if ((int) ($r['disk_virt'] ?? 0) === 1) { return 'virtio'; } if (strtolower(trim((string) ($r['disk_tran'] ?? ''))) === 'nvme') { return 'NVMe'; } if ($r['disk_rota'] === null || $r['disk_rota'] === '') { return 'unknown'; } return ((int) $r['disk_rota']) === 0 ? 'SSD' : 'HDD'; } $split = static fn($raw): array => array_values(array_filter( array_map('trim', explode(',', (string) $raw)), static fn($v) => $v !== '' )); // ?nodes=a,b and ?disk=virtio,HDD narrow the page. Every chart shares one y // axis per metric, so a flash disk flattens the platters beside it. $filter = $split($_GET['nodes'] ?? ''); $diskFilter = $split($_GET['disk'] ?? ''); if ($filter) { $rows = array_values(array_filter($rows, static fn(array $r) => in_array((string) $r['node'], $filter, true))); } // Counts come from the node-filtered set, so the chips still show the other groups. $mediaCount = []; foreach ($rows as $r) { $m = media_of($r); $mediaCount[$m] = ($mediaCount[$m] ?? 0) + 1; } ksort($mediaCount); if ($diskFilter) { $wantMedia = array_map('strtolower', $diskFilter); $rows = array_values(array_filter($rows, static fn(array $r) => in_array(strtolower(media_of($r)), $wantMedia, true))); } // This page's URL with one parameter replaced. $here = static function (array $over) use ($filter, $diskFilter): string { $q = array_filter( array_merge(['nodes' => implode(',', $filter), 'disk' => implode(',', $diskFilter)], $over), static fn($v) => $v !== '' && $v !== null ); return $q ? '?' . http_build_query($q) : './history'; }; foreach ($rows as $row) { $series[(string) $row['node']][] = $row; } ksort($series); $colours = []; $i = 0; foreach (array_keys($series) as $node) { $colours[$node] = COLOURS[$i++ % count(COLOURS)]; } $xs = array_map(static fn($r) => (int) $r['tested_at'], $rows); $t0 = $xs ? min($xs) : 0; $t1 = $xs ? max($xs) : 1; if ($t1 - $t0 < 3600) { // one run, or runs within the hour: widen the axis $t0 -= 1800; $t1 += 1800; } $span = $t1 - $t0; $stamp = static fn(int $t): string => date($span > 3 * 86400 ? 'm-d' : 'H:i', $t); // Keeps a y axis readable: round the ceiling up to 1, 2 or 5 times a power of ten. function ceiling(float $max): float { if ($max <= 0) { return 1.0; } $mag = 10 ** floor(log10($max)); foreach ([1, 2, 5, 10] as $step) { if ($max <= $step * $mag) { return $step * $mag; } } return 10 * $mag; } function chart(string $key, array $meta, array $series, array $colours, int $t0, int $t1, callable $stamp): string { [$label, $unit, $decimals] = $meta; $w = 420; $hgt = 190; $left = 58; $right = 12; $top = 12; $bottom = 26; $pw = $w - $left - $right; $ph = $hgt - $top - $bottom; $max = 0.0; $points = 0; foreach ($series as $list) { foreach ($list as $r) { if ($r[$key] !== null) { $max = max($max, (float) $r[$key]); $points++; } } } $ymax = ceiling($max); $sx = static fn(int $t): float => $left + ($t - $t0) / max(1, $t1 - $t0) * $pw; $sy = static fn(float $v): float => $top + $ph - ($v / $ymax) * $ph; $svg = ''; return $points ? $svg : '
No ' . h(strtolower($label)) . ' data yet.
'; } ?>virtio: its node cannot see what is behind it.
Database error: = h($error) ?>
Show every run.' : 'No runs yet. Install the probe, then reload.'; ?>