diff --git a/packages/vitec/Classes/Command/SsrHealthSampleCommand.php b/packages/vitec/Classes/Command/SsrHealthSampleCommand.php
index 8ca9ec9..e2a6e79 100644
--- a/packages/vitec/Classes/Command/SsrHealthSampleCommand.php
+++ b/packages/vitec/Classes/Command/SsrHealthSampleCommand.php
@@ -57,6 +57,36 @@ final class SsrHealthSampleCommand extends Command
} catch (\Throwable $e) {
$row['response_ms'] = (int)round((microtime(true) - $start) * 1000);
}
+
+ // Cumulative node counters from /__stats (-1 = no data this sample,
+ // e.g. node down or release without the endpoint). The errors tile
+ // computes 24h deltas from these, skipping -1 rows; the counters
+ // reset whenever the node restarts, which the delta logic clamps.
+ $stats = [
+ 'stat_hit' => -1, 'stat_miss' => -1, 'stat_stale' => -1,
+ 'stat_bypass' => -1, 'stat_render_errors' => -1, 'stat_backend_errors' => -1,
+ ];
+ if ($row['ok'] === 1 && ($statsEndpoint = $this->purgeService->getStatsEndpoint()) !== '') {
+ try {
+ $statsResponse = $this->requestFactory->request($statsEndpoint, 'GET', [
+ 'timeout' => 4, 'connect_timeout' => 2, 'http_errors' => false,
+ ]);
+ if ($statsResponse->getStatusCode() === 200) {
+ $s = json_decode((string)$statsResponse->getBody(), true) ?: [];
+ $stats = [
+ 'stat_hit' => (int)($s['hit'] ?? -1),
+ 'stat_miss' => (int)($s['miss'] ?? -1),
+ 'stat_stale' => (int)($s['stale'] ?? -1),
+ 'stat_bypass' => (int)($s['bypass'] ?? -1),
+ 'stat_render_errors' => (int)($s['renderErrors'] ?? -1),
+ 'stat_backend_errors' => (int)($s['backendErrors'] ?? -1),
+ ];
+ }
+ } catch (\Throwable) {
+ // Stats are optional - the health sample must never fail on them.
+ }
+ }
+ $row = array_merge($row, $stats);
// Previous samples, newest first, read BEFORE this one is inserted:
// [0] is the state every transition decision compares against.
$failoverAfter = $this->purgeService->getFailoverAfter();
@@ -81,6 +111,17 @@ final class SsrHealthSampleCommand extends Command
$row['cache_mb'],
$row['response_ms']
));
+ if ($row['stat_hit'] >= 0) {
+ $output->writeln(sprintf(
+ 'stats: hit=%d miss=%d stale=%d bypass=%d renderErrors=%d backendErrors=%d',
+ $row['stat_hit'],
+ $row['stat_miss'],
+ $row['stat_stale'],
+ $row['stat_bypass'],
+ $row['stat_render_errors'],
+ $row['stat_backend_errors']
+ ));
+ }
$prevOk = $previous === [] ? null : $previous[0];
if ($prevOk !== null && $prevOk !== $row['ok']) {
if ($row['ok'] === 1) {
diff --git a/packages/vitec/Classes/Service/SsrPurgeService.php b/packages/vitec/Classes/Service/SsrPurgeService.php
index e4eac19..c26d643 100644
--- a/packages/vitec/Classes/Service/SsrPurgeService.php
+++ b/packages/vitec/Classes/Service/SsrPurgeService.php
@@ -42,6 +42,21 @@ final class SsrPurgeService
return $this->getConfig()['SSR_HEALTH_ENDPOINT'] ?? '';
}
+ /**
+ * The /__stats sibling of the health endpoint (cumulative hit/miss/
+ * error counters, in the node since release 20261109-211544). Derived
+ * from SSR_HEALTH_ENDPOINT; SSR_STATS_ENDPOINT overrides explicitly.
+ */
+ public function getStatsEndpoint(): string
+ {
+ $explicit = $this->getConfig()['SSR_STATS_ENDPOINT'] ?? '';
+ if ($explicit !== '') {
+ return $explicit;
+ }
+ $health = $this->getHealthEndpoint();
+ return $health === '' ? '' : str_replace('/__health', '/__stats', $health);
+ }
+
/**
* Comma-separated recipient list for vitec:ssr-health transition and
* failover alerts (SSR_ALERT_EMAIL); empty string = alerting off.
diff --git a/packages/vitec/Classes/Widgets/SsrErrorsWidget.php b/packages/vitec/Classes/Widgets/SsrErrorsWidget.php
new file mode 100644
index 0000000..a668857
--- /dev/null
+++ b/packages/vitec/Classes/Widgets/SsrErrorsWidget.php
@@ -0,0 +1,159 @@
+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';
+ }
+}
diff --git a/packages/vitec/Configuration/Services.yaml b/packages/vitec/Configuration/Services.yaml
index 492c8b9..d71471d 100644
--- a/packages/vitec/Configuration/Services.yaml
+++ b/packages/vitec/Configuration/Services.yaml
@@ -51,3 +51,14 @@ services:
iconIdentifier: 'content-widget-number'
height: 'medium'
width: 'medium'
+
+ Evomedien\Vitec\Widgets\SsrErrorsWidget:
+ tags:
+ - name: dashboard.widget
+ identifier: vitecSsrErrors
+ groupNames: systemInfo
+ title: 'VITEC: SSR errors & hit rate'
+ description: 'Render/backend error counters and cache hit rate of the SSR node (/__stats, sampled every 5 minutes)'
+ iconIdentifier: 'content-widget-list'
+ height: 'medium'
+ width: 'medium'
diff --git a/packages/vitec/ext_tables.sql b/packages/vitec/ext_tables.sql
index 3c88a6a..e53e1d7 100755
--- a/packages/vitec/ext_tables.sql
+++ b/packages/vitec/ext_tables.sql
@@ -490,6 +490,12 @@ CREATE TABLE tx_vitec_ssrhealthlog (
cached_pages int(11) DEFAULT '0' NOT NULL,
cache_mb int(11) DEFAULT '0' NOT NULL,
response_ms int(11) DEFAULT '0' NOT NULL,
+ stat_hit int(11) DEFAULT '-1' NOT NULL,
+ stat_miss int(11) DEFAULT '-1' NOT NULL,
+ stat_stale int(11) DEFAULT '-1' NOT NULL,
+ stat_bypass int(11) DEFAULT '-1' NOT NULL,
+ stat_render_errors int(11) DEFAULT '-1' NOT NULL,
+ stat_backend_errors int(11) DEFAULT '-1' NOT NULL,
PRIMARY KEY (uid),
KEY crdate (crdate)
);