latestStatsRow(); if ($latest === null) { return $h::wrap('

No /__stats samples yet. They arrive with the next ' . 'vitec:ssr-health cron run (every 5 minutes) once the node release ' . 'provides /__stats.

', 'Cache hit rate and error counters of the SSR node, ' . 'filled by the health cron every 5 minutes.', 'errors'); } $hit = (int)$latest['stat_hit']; $stale = (int)$latest['stat_stale']; $miss = (int)$latest['stat_miss']; $served = $hit + $miss + $stale; $rate = $served > 0 ? ($hit + $stale) / $served : null; [$newRender24h, $newBackend24h] = $this->errorDeltas24h(); $down24h = $this->downSamples24h(); // Hit rate ring + split bar hit / stale / miss $pct = static fn(int $v): string => $served > 0 ? round($v / $served * 100, 1) . '%' : '0%'; $html = '
' . $h::ring($rate ?? 0.0, 'ok', $rate === null ? '–' : number_format($rate * 100, 1) . ' %', 84) . '
Cache hit rate
' . '
since the node started · ' . number_format($served, 0, '.', ',') . ' requests
' . '' . '
cache ' . $hit . '' . 'stale ' . $stale . 'fresh ' . $miss . '
' . '
'; $tile = static fn(string $label, string $value, string $sub, string $tone): string => '
' . $h::e($label) . '
' . $value . '
' . $sub . '
'; $newCell = static fn(?int $new): array => $new === null ? ['n/a', ''] : ['+' . $new, $new > 0 ? 'vcw-bad' : 'vcw-ok']; [$renderVal, $renderTone] = $newCell($newRender24h); [$backendVal, $backendTone] = $newCell($newBackend24h); $html .= '
' . $tile('Render errors', $renderVal, 'last 24 h · ' . (int)$latest['stat_render_errors'] . ' since node start', $renderTone) . $tile('Backend errors', $backendVal, 'last 24 h · ' . (int)$latest['stat_backend_errors'] . ' since node start', $backendTone) . $tile('Down samples', (string)$down24h['count'], $down24h['count'] > 0 ? 'last ' . $h::e($down24h['last']) : 'last 24 h', $down24h['count'] > 0 ? 'vcw-bad' : 'vcw-ok') . $tile('Sample age', $h::e($this->age((int)$latest['crdate'])), 'cron every 5 min', '') . '
'; $html .= '

Totals count since the node last started; a deploy resets them. ' . 'Error details: journalctl -u vitec-ssr on the VPS.

'; return $h::wrap($html, 'How well the SSR cache works and whether the node has problems. The ring is the ' . 'share of requests answered from the cache since the node last started; the bar splits them into ' . 'served from cache, stale and refreshed in the background, and rendered fresh. Render errors are pages ' . 'the node could not render, backend errors are failed requests to TYPO3. Down samples are failed ' . 'health checks in the last 24 hours.', 'errors'); } public function getOptions(): array { return []; } public function getCssFiles(): array { return [CockpitHtml::CSS]; } 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'; } }