<?php
// History of every stored run, one chart per metric, plain server-rendered SVG.
// No JavaScript and no chart library: a few polylines and circles.
// Reads the board's database read-only.
declare(strict_types=1);
error_reporting(E_ALL & ~E_DEPRECATED);
ini_set('display_errors', '0');
date_default_timezone_set('UTC');
const DB_PATH = '/home/vasilves/yabs-data/yabs.sqlite';
const COLOURS = ['#58a6ff', '#7ee787', '#f0883e', '#d2a8ff', '#ffa198', '#76e3ea', '#e3b341', '#79c0ff'];
const METRICS = [
'd4k' => ['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
{
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');
$psi = in_array('psi_full', $cols, true) ? 'psi_full' : 'NULL AS psi_full';
$rows = $db->query(
'SELECT node, tested_at, d4k, d4k_iops, d64k, d512k, d1m, send_mbit, recv_mbit, ' . $psi . '
FROM runs WHERE tested_at IS NOT NULL ORDER BY tested_at'
)->fetchAll();
} catch (Throwable $e) {
$error = $e->getMessage();
}
$series = []; // node => list of rows
// ?nodes=a,b narrows the page to those nodes: with a flash disk on the same
// chart as platters, the y axis scales to the fastest run and the rest flattens.
$filter = array_values(array_filter(array_map('trim', explode(',', (string) ($_GET['nodes'] ?? ''))),
static fn($n) => $n !== ''));
if ($filter) {
$rows = array_values(array_filter($rows, static fn(array $r) => in_array((string) $r['node'], $filter, true)));
}
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 => gmdate($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 = '<svg viewBox="0 0 ' . $w . ' ' . $hgt . '" role="img" aria-label="' . h($label) . '">';
// Grid and y labels at 0, half and full scale.
for ($g = 0; $g <= 2; $g++) {
$v = $ymax * $g / 2;
$y = $sy($v);
$svg .= sprintf('<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" stroke="#21262d"/>', $left, $y, $left + $pw, $y);
$tick = $v === 0.0 ? '0' : number_format($v, $ymax < 10 ? 2 : 0);
$svg .= sprintf('<text x="%d" y="%.1f" fill="#6e7681" font-size="10" text-anchor="end">%s</text>',
$left - 6, $y + 3.5, h($tick));
}
// x labels at both ends.
$svg .= sprintf('<text x="%d" y="%d" fill="#6e7681" font-size="10">%s</text>', $left, $hgt - 8, h($stamp($t0)));
$svg .= sprintf('<text x="%d" y="%d" fill="#6e7681" font-size="10" text-anchor="end">%s</text>',
$left + $pw, $hgt - 8, h($stamp($t1)));
foreach ($series as $node => $list) {
$pts = [];
foreach ($list as $r) {
if ($r[$key] === null) {
continue;
}
$pts[] = [$sx((int) $r['tested_at']), $sy((float) $r[$key]), $r];
}
if (!$pts) {
continue;
}
if (count($pts) > 1) {
$d = [];
foreach ($pts as $p) {
$d[] = sprintf('%.1f,%.1f', $p[0], $p[1]);
}
$svg .= sprintf('<polyline fill="none" stroke="%s" stroke-width="1.6" points="%s"/>',
$colours[$node], implode(' ', $d));
}
foreach ($pts as $p) {
$svg .= sprintf('<circle cx="%.1f" cy="%.1f" r="2.6" fill="%s"><title>%s</title></circle>',
$p[0], $p[1], $colours[$node],
h(sprintf("%s\n%s UTC\n%s %s", $node, gmdate('Y-m-d H:i', (int) $p[2]['tested_at']), num($p[2][$key], $decimals), $unit)));
}
}
$svg .= '</svg>';
return $points ? $svg : '<p class="empty">No ' . h(strtolower($label)) . ' data yet.</p>';
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>YABS history</title>
<link rel="icon" href="data:,">
<style>
:root { color-scheme: dark; }
body { margin: 0; background: #0d1117; color: #c9d1d9;
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
header { padding: 14px 18px 10px; border-bottom: 1px solid #21262d; }
h1 { margin: 0 0 4px; font-size: 15px; font-weight: 600; letter-spacing: .04em; }
h2 { margin: 0 0 6px; font-size: 12px; font-weight: 600; color: #8b949e; }
main { display: grid; grid-template-columns: repeat(auto-fit, minmax(440px, 1fr));
gap: 18px; padding: 16px 18px 40px; }
section { border: 1px solid #21262d; border-radius: 7px; padding: 10px 12px 4px; background: #0f141b; }
svg { width: 100%; height: auto; display: block; }
.mut { color: #8b949e; }
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
.empty { color: #6e7681; padding: 12px 4px; }
.legend { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 6px; }
.chip { display: inline-flex; align-items: center; gap: 6px; }
.dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; }
.delta.up { color: #7ee787; }
.delta.down { color: #ffa198; }
</style>
</head>
<body>
<header>
<h1>YABS history</h1>
<nav class="mut" style="margin-bottom:6px">
<a href="./">board</a> ·
<a href="./?do=api">JSON</a> ·
<a href="racks">3D hangar</a> ·
<a href="source?f=history.php">source</a>
</nav>
<div class="mut">
Every stored run, one point per run, one colour per node. Hover a point for
its date and value. The delta compares the last run with the one before it.
The y axis scales to the fastest run on the page, so click a node name to
hide the others and keep the slow ones readable.
</div>
<div class="legend">
<?php foreach ($series as $node => $list):
$vals = array_values(array_filter(array_column($list, 'd1m'), static fn($v) => $v !== null));
$last = $vals ? (float) end($vals) : null;
$prev = count($vals) > 1 ? (float) $vals[count($vals) - 2] : null;
$delta = ($last !== null && $prev !== null && $prev > 0) ? ($last - $prev) / $prev * 100 : null; ?>
<span class="chip">
<span class="dot" style="background:<?= h($colours[$node]) ?>"></span>
<a href="?nodes=<?= h(rawurlencode($node)) ?>"><?= h($node) ?></a>
<span class="mut"><?= $last === null ? '1m n/a' : num($last, 2) . ' MB/s' ?></span>
<?php if ($delta !== null): ?>
<span class="delta <?= $delta >= 0 ? 'up' : 'down' ?>"><?= ($delta >= 0 ? '+' : '') . number_format($delta, 1) ?>%</span>
<?php endif; ?>
</span>
<?php endforeach; ?>
<?php if ($filter): ?><span class="chip"><a href="./">← all nodes</a></span><?php endif; ?>
</div>
</header>
<main>
<?php if ($error !== null): ?>
<section><p class="empty">Database error: <?= h($error) ?></p></section>
<?php elseif (!$series): ?>
<section><p class="empty"><?= $filter
? 'No run for ' . h(implode(', ', $filter)) . '. <a href="./">Show every node</a>.'
: 'No runs yet. Install the probe, then reload.' ?></p></section>
<?php else: ?>
<?php foreach (METRICS as $key => $meta): ?>
<section>
<h2><?= h($meta[0] . ' (' . $meta[1] . ')') ?></h2>
<?= chart($key, $meta, $series, $colours, $t0, $t1, $stamp) ?>
</section>
<?php endforeach; ?>
<?php endif; ?>
</main>
<footer style="padding:10px 18px;border-top:1px solid #21262d" class="mut">
<?= count($rows) ?> run(s), <?= count($series) ?> node(s) ·
<a href="./">board</a> ·
<a href="./?do=api">JSON</a> ·
<a href="racks">3D hangar</a> ·
<a href="source?f=history.php">source</a>
</footer>
</body>
</html>