latestStatsRow();
if ($latest === null) {
return '
No /__stats samples yet. They arrive with the next '
. 'vitec:ssr-health cron run (every 5 minutes) once the node release '
. 'provides /__stats.
';
}
$served = (int)$latest['stat_hit'] + (int)$latest['stat_miss'] + (int)$latest['stat_stale'];
$hitRate = $served > 0
? number_format(((int)$latest['stat_hit'] + (int)$latest['stat_stale']) / $served * 100, 1) . ' %'
: '-';
[$newRender24h, $newBackend24h] = $this->errorDeltas24h();
$down24h = $this->downSamples24h();
$rows = [
['Cache hit rate (since node start)', htmlspecialchars($hitRate)
. ' (hit ' . (int)$latest['stat_hit']
. ' / stale ' . (int)$latest['stat_stale']
. ' / miss ' . (int)$latest['stat_miss'] . ')'],
['Render errors (new in 24 h / total)', $this->errorCell($newRender24h, (int)$latest['stat_render_errors'])],
['Backend errors (new in 24 h / total)', $this->errorCell($newBackend24h, (int)$latest['stat_backend_errors'])],
['DOWN samples (24 h)', $down24h['count'] > 0
? '' . $down24h['count'] . ' last: '
. htmlspecialchars($down24h['last']) . ''
: '0'],
['Sample age', htmlspecialchars($this->age((int)$latest['crdate']))],
];
$html = '';
foreach ($rows as [$label, $value]) {
$html .= '| ' . $label . ' | ' . $value . ' |
';
}
$html .= '
';
$html .= 'Totals are cumulative since the node '
. 'last (re)started; a deploy resets them. Error details: '
. 'journalctl -u vitec-ssr on the VPS.
';
return $html;
}
public function getOptions(): array
{
return [];
}
private function errorCell(?int $new24h, int $total): string
{
$newBadge = $new24h === null
? 'n/a'
: ($new24h > 0
? '+' . $new24h . ''
: '+0');
return $newBadge . ' / ' . $total . ' total';
}
private function latestStatsRow(): ?array
{
try {
$row = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog')
->executeQuery(
'SELECT crdate, stat_hit, stat_miss, stat_stale, stat_render_errors, stat_backend_errors'
. ' FROM tx_vitec_ssrhealthlog WHERE stat_hit >= 0 ORDER BY crdate DESC, uid DESC LIMIT 1'
)->fetchAssociative();
return $row === false ? null : $row;
} catch (\Throwable) {
return null; // Column missing before database:updateschema - render the empty state.
}
}
/**
* New errors in the last 24 h = sum of positive deltas between
* consecutive stats samples. Rows with -1 (node down / no stats) are
* skipped; counter resets produce a negative step, clamped to 0.
*
* @return array{0: ?int, 1: ?int} [renderErrors, backendErrors]
*/
private function errorDeltas24h(): array
{
try {
$rows = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog')
->executeQuery(
'SELECT stat_render_errors, stat_backend_errors FROM tx_vitec_ssrhealthlog'
. ' WHERE crdate > ' . (time() - 86400) . ' AND stat_hit >= 0 ORDER BY crdate ASC, uid ASC'
)->fetchAllAssociative();
} catch (\Throwable) {
return [null, null];
}
if (count($rows) < 2) {
return [null, null];
}
$render = 0;
$backend = 0;
$prev = null;
foreach ($rows as $r) {
if ($prev !== null) {
$render += max(0, (int)$r['stat_render_errors'] - (int)$prev['stat_render_errors']);
$backend += max(0, (int)$r['stat_backend_errors'] - (int)$prev['stat_backend_errors']);
}
$prev = $r;
}
return [$render, $backend];
}
/**
* @return array{count: int, last: string}
*/
private function downSamples24h(): array
{
try {
$conn = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog');
$count = (int)$conn->executeQuery(
'SELECT COUNT(*) FROM tx_vitec_ssrhealthlog WHERE crdate > ' . (time() - 86400) . ' AND ok = 0'
)->fetchOne();
$last = $count > 0
? date('d.m. H:i', (int)$conn->executeQuery(
'SELECT MAX(crdate) FROM tx_vitec_ssrhealthlog WHERE ok = 0'
)->fetchOne())
: '-';
return ['count' => $count, 'last' => $last];
} catch (\Throwable) {
return ['count' => 0, 'last' => '-'];
}
}
private function age(int $crdate): string
{
$minutes = (int)floor((time() - $crdate) / 60);
return $minutes < 1 ? 'just now' : $minutes . ' min';
}
}