SSR module: dark VITEC cockpit with live charts

Live band with 24 h availability strip, gauges for hit rate, response time, cache fill and client errors, traffic chart 24 h / 7 days from the health log counter deltas drawn with the core Chart.js of EXT:dashboard, warm progress ring with live polling, purge activity timeline, client error cards, link to the site settings. ssr-cockpit.js replaces ssr-warm-progress.js, whose deletion went into a9923e1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Opus 5.5 committed 2026-11-11 10:52:46 +01:00
1 parent a9923e19e0
commit 67ded6e502
6 files changed
+1119 -342

No files matched your search

@@ -50,7 +50,7 @@ final class SsrController
$view->setTitle('VITEC SSR');
[$health, $healthError] = $this->fetchHealth();
$this->pageRenderer->loadJavaScriptModule('@evomedien/vitec/ssr-warm-progress.js');
$this->pageRenderer->loadJavaScriptModule('@evomedien/vitec/ssr-cockpit.js');
$this->pageRenderer->loadJavaScriptModule('@evomedien/vitec/ssr-module.js');
$this->pageRenderer->addCssFile('EXT:vitec/Resources/Public/Css/backend-ssr.css');
@@ -68,8 +68,32 @@ final class SsrController
}
$healthLog = $this->rows('tx_vitec_ssrhealthlog', 10);
$lastSample = $healthLog[0] ?? null;
$clientErrors = $this->clientErrors(50);
$cockpit = $this->cockpitData($health, $lastSample);
$settingsUri = '';
if ($GLOBALS['BE_USER']->isAdmin()) {
try {
$sites = GeneralUtility::makeInstance(SiteFinder::class)->getAllSites();
$site = reset($sites);
if ($site) {
$settingsUri = (string)$this->uriBuilder->buildUriFromRoute(
'site_configuration.editSettings',
['site' => $site->getIdentifier()]
);
}
} catch (\Throwable) {
$settingsUri = '';
}
}
$view->assignMultiple([
'cockpit' => $cockpit,
'cockpitJson' => json_encode($cockpit, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT),
'settingsUri' => $settingsUri,
'failoverAfter' => $this->purgeService->getFailoverAfter(),
'chunkCount24h' => count(array_filter($clientErrors, static fn(array $r): bool => $r['is_chunk_reload'])),
'jsErrorCount24h' => count(array_filter($clientErrors, static fn(array $r): bool => !$r['is_chunk_reload'])),
'flagOn' => file_exists(Environment::getVarPath() . '/vitec-ssr-on.flag'),
'configured' => $this->purgeService->isConfigured(),
'health' => $health,
@@ -81,7 +105,7 @@ final class SsrController
'purgeLimit' => $limit,
'moduleUri' => (string)$this->uriBuilder->buildUriFromRoute('web_vitecssr'),
'healthLog' => $healthLog,
'clientErrors' => $this->clientErrors(50),
'clientErrors' => $clientErrors,
'clientErrorCount24h' => $this->clientErrorCount24h(),
]);
return $view->renderResponse('Ssr/Index');
@@ -123,7 +147,20 @@ final class SsrController
private function purgeRows(int $limit): array
{
$rows = $this->rows('tx_vitec_ssrpurgelog', $limit);
$today = date('Y-m-d');
$yesterday = date('Y-m-d', time() - 86400);
$lastDay = '';
foreach ($rows as &$row) {
// Day separators for the activity timeline.
$day = date('Y-m-d', (int)$row['crdate']);
$row['day_label'] = match ($day) {
$today => 'Today',
$yesterday => 'Yesterday',
default => date('D, d.m.', (int)$row['crdate']),
};
$row['day_first'] = $day !== $lastDay;
$lastDay = $day;
$trigger = (string)$row['trigger_source'];
$who = '';
if (str_contains($trigger, ':')) {
@@ -136,10 +173,13 @@ final class SsrController
default => $trigger,
};
$row['trigger_who'] = $who;
$row['trigger_badge'] = match ($trigger) {
'module', 'contextmenu' => 'badge-info',
'switch-on' => 'badge-warning',
default => 'badge-secondary',
// Cockpit tone per trigger (pill + timeline dot colour).
$row['trigger_tone'] = match ($trigger) {
'editor-save', 'editor-command' => 'ok',
'module' => 'orange',
'contextmenu' => 'navy',
'switch-on' => 'warn',
default => 'info',
};
$paths = preg_split('/\s*,\s*/', trim((string)$row['paths']), -1, PREG_SPLIT_NO_EMPTY) ?: [];
$row['paths_all'] = $paths === ['*'];
@@ -151,6 +191,205 @@ final class SsrController
return $rows;
}
/**
* Everything the cockpit draws (ssr-cockpit.js reads it as JSON):
* availability strip, traffic buckets for 24 h / 7 days, response
* sparkline, hit rate, client errors per hour. Source is the health
* log (cron vitec:ssr-health, every 5 min, kept 14 days); the node's
* hit/miss/stale counters are cumulative since its last start, so
* traffic is the difference between two samples (a smaller value
* than before means the node restarted: then the new value counts).
*/
private function cockpitData(?array $health, ?array $lastSample): array
{
$now = time();
$tz = (int)date('Z');
$from = $now - 7 * 86400 - 4 * 3600;
$samples = [];
$purges = [];
$errors = [];
try {
$samples = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog')->executeQuery(
'SELECT crdate, ok, response_ms, cached_pages, cache_mb, stat_hit, stat_miss, stat_stale, stat_bypass,'
. ' stat_render_errors, stat_backend_errors FROM tx_vitec_ssrhealthlog WHERE crdate > ? ORDER BY crdate, uid',
[$from]
)->fetchAllAssociative();
$purges = $this->connectionPool->getConnectionForTable('tx_vitec_ssrpurgelog')->executeQuery(
'SELECT crdate, paths, success FROM tx_vitec_ssrpurgelog WHERE crdate > ? ORDER BY crdate',
[$from]
)->fetchAllAssociative();
$errors = $this->connectionPool->getConnectionForTable('tx_vitec_clienterrorlog')->executeQuery(
'SELECT crdate, message FROM tx_vitec_clienterrorlog WHERE crdate > ?',
[$now - 86400]
)->fetchAllAssociative();
} catch (\Throwable) {
// Tables missing on a fresh system: the cockpit shows empty charts.
}
// Traffic deltas between consecutive samples with counters.
$keys = ['hit', 'miss', 'stale', 'bypass', 'render_errors', 'backend_errors'];
$prev = null;
foreach ($samples as &$s) {
foreach ($s as $k => $v) {
$s[$k] = (int)$v;
}
$s['d'] = null;
if ($s['stat_hit'] >= 0) {
if ($prev !== null) {
$d = [];
foreach ($keys as $k) {
$c = max(0, $s['stat_' . $k]);
$p = max(0, $prev['stat_' . $k]);
$d[$k] = $c >= $p ? $c - $p : $c;
}
$s['d'] = $d;
}
$prev = $s;
}
}
unset($s);
$buckets = function (int $size, int $count) use ($now, $tz, $samples, $purges): array {
$end = intdiv($now + $tz, $size) * $size - $tz + $size;
$start = $end - $size * $count;
$out = [];
for ($i = 0; $i < $count; $i++) {
$out[] = ['t' => $start + $i * $size, 'hit' => 0, 'stale' => 0, 'miss' => 0, 'bypass' => 0,
'errors' => 0, 'msSum' => 0, 'msN' => 0, 'purges' => 0, 'purgeAll' => 0];
}
foreach ($samples as $s) {
$i = intdiv($s['crdate'] - $start, $size);
if ($i < 0 || $i >= $count) {
continue;
}
if ($s['d'] !== null) {
$out[$i]['hit'] += $s['d']['hit'];
$out[$i]['stale'] += $s['d']['stale'];
$out[$i]['miss'] += $s['d']['miss'];
$out[$i]['bypass'] += $s['d']['bypass'];
$out[$i]['errors'] += $s['d']['render_errors'] + $s['d']['backend_errors'];
}
if ($s['ok'] === 1) {
$out[$i]['msSum'] += $s['response_ms'];
$out[$i]['msN']++;
}
}
foreach ($purges as $p) {
$i = intdiv((int)$p['crdate'] - $start, $size);
if ($i < 0 || $i >= $count) {
continue;
}
$out[$i]['purges']++;
if (trim((string)$p['paths']) === '*') {
$out[$i]['purgeAll']++;
}
}
foreach ($out as &$b) {
$b['ms'] = $b['msN'] > 0 ? (int)round($b['msSum'] / $b['msN']) : null;
unset($b['msSum'], $b['msN']);
}
return $out;
};
// Availability strip: 96 windows of 15 minutes.
$slot = 900;
$stripEnd = intdiv($now, $slot) * $slot + $slot;
$strip = array_fill(0, 96, null);
$ok24 = 0;
$n24 = 0;
$ms24 = [];
$slow = 0;
$down = 0;
foreach ($samples as $s) {
$i = 95 - intdiv($stripEnd - 1 - $s['crdate'], $slot);
if ($i < 0) {
continue;
}
$n24++;
$state = $s['ok'] !== 1 ? 2 : ($s['response_ms'] > 400 ? 1 : 0);
if ($s['ok'] === 1) {
$ok24++;
$ms24[] = $s['response_ms'];
}
$cur = $strip[$i];
if ($cur === null || $state > $cur['s']) {
$strip[$i] = ['s' => $state, 'ms' => $s['response_ms']];
}
}
foreach ($strip as $w) {
if ($w !== null && $w['s'] === 1) {
$slow++;
}
if ($w !== null && $w['s'] === 2) {
$down++;
}
}
sort($ms24);
$pct = static function (array $sorted, float $p): ?int {
if ($sorted === []) {
return null;
}
return $sorted[(int)min(count($sorted) - 1, floor($p * (count($sorted) - 1) + 0.5))];
};
// Hit rate now (last 24 h) and the 24 h before.
$rate = static function (array $samples, int $a, int $b): ?float {
$hit = 0;
$all = 0;
foreach ($samples as $s) {
if ($s['d'] === null || $s['crdate'] <= $a || $s['crdate'] > $b) {
continue;
}
$hit += $s['d']['hit'] + $s['d']['stale'];
$all += $s['d']['hit'] + $s['d']['stale'] + $s['d']['miss'];
}
return $all > 0 ? round($hit / $all * 100, 1) : null;
};
// Response time sparkline: the last 48 good samples (4 hours).
$spark = [];
foreach (array_slice(array_values(array_filter($samples, static fn(array $s): bool => $s['ok'] === 1)), -48) as $s) {
$spark[] = [$s['crdate'], $s['response_ms']];
}
// Client errors per hour, last 24 h.
$errHours = array_fill(0, 24, 0);
$hourEnd = intdiv($now, 3600) * 3600 + 3600;
foreach ($errors as $e) {
$i = 23 - intdiv($hourEnd - 1 - (int)$e['crdate'], 3600);
if ($i >= 0 && $i < 24) {
$errHours[$i]++;
}
}
$cachedPages = $health['cachedPages'] ?? ($lastSample['cached_pages'] ?? null);
$cacheMb = $health['cacheMB'] ?? ($lastSample['cache_mb'] ?? null);
return [
'now' => $now,
'online' => $health !== null,
'warming' => $health['warming'] ?? null,
'strip' => ['end' => $stripEnd, 'slot' => $slot, 'w' => $strip],
'uptimePct' => $n24 > 0 ? round($ok24 / $n24 * 100, 1) : null,
'slowWindows' => $slow,
'downWindows' => $down,
'msMedian' => $pct($ms24, 0.5),
'msP95' => $pct($ms24, 0.95),
'spark' => $spark,
'hitRate' => $rate($samples, $now - 86400, $now),
'hitRatePrev' => $rate($samples, $now - 2 * 86400, $now - 86400),
'cachedPages' => $cachedPages !== null ? (int)$cachedPages : null,
'cacheMb' => $cacheMb !== null ? (int)$cacheMb : null,
'cacheLimitMb' => $this->purgeService->getCacheLimitMb(),
'errHours' => $errHours,
'range' => [
'24h' => ['size' => 3600, 'b' => $buckets(3600, 24)],
'7d' => ['size' => 14400, 'b' => $buckets(14400, 42)],
],
];
}
private function clientErrorCount24h(): int
{
try {
@@ -258,8 +497,8 @@ final class SsrController
}
/**
* Ajax route vitec_ssr_health: live status for the module's
* "Cache warming" progress bar (ssr-warm-progress.js polls it).
* Ajax route vitec_ssr_health: live status for the cockpit (warm
* progress ring, cache fill, online state; ssr-cockpit.js polls it).
*/
public function healthJson(ServerRequestInterface $request): ResponseInterface
{