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 .= ''; } $html .= '
' . $label . '' . $value . '
'; $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'; } }