moduleTemplateFactory->create($request); $view->setTitle('VITEC SSR'); [$health, $healthError] = $this->fetchHealth(); $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'); // DocHeader: shortcut button (0096-FE); the reload button comes from the core. $view->addButtonToButtonBar( $this->componentFactory->createShortcutButton() ->setRouteIdentifier('web_vitecssr') ->setDisplayName('VITEC SSR'), ButtonBar::BUTTON_POSITION_RIGHT ); $limit = (int)($request->getQueryParams()['limit'] ?? 30); if (!in_array($limit, [30, 100, 500], true)) { $limit = 30; } $healthLog = $this->rows('tx_vitec_ssrhealthlog', 10); $lastSample = $healthLog[0] ?? null; $clientErrors = $this->clientErrors(50); $cockpit = $this->cockpitData($health, $lastSample); $alert = $this->purgeService->getAlertRecipients(); $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(), 'alert' => $alert, 'alertList' => implode(', ', $alert['recipients']), 'alertCount' => count($alert['recipients']), 'alertInvalid' => implode(', ', $alert['invalid']), '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, 'healthError' => $healthError, 'cacheLimitMb' => $this->purgeService->getCacheLimitMb(), 'lastSample' => $lastSample, 'lastSampleAge' => $lastSample ? BackendUtility::calcAge(time() - (int)$lastSample['crdate']) : '', 'purgeLog' => $this->purgeRows($limit), 'purgeLimit' => $limit, 'moduleUri' => (string)$this->uriBuilder->buildUriFromRoute('web_vitecssr'), 'healthLog' => $healthLog, 'clientErrors' => $clientErrors, 'clientErrorCount24h' => $this->clientErrorCount24h(), ]); return $view->renderResponse('Ssr/Index'); } /** * Client error reports of the last 24 hours (POST /api/vitec/client-error, * ClientErrorMiddleware), newest first. Rows carry a "host" (URL without * scheme/query) and a shortened user agent for the table; the full * values stay in the row for the details toggle. */ private function clientErrors(int $limit): array { try { $qb = $this->connectionPool->getQueryBuilderForTable('tx_vitec_clienterrorlog'); $rows = $qb->select('*')->from('tx_vitec_clienterrorlog') ->where($qb->expr()->gt('crdate', $qb->createNamedParameter(time() - 86400, Connection::PARAM_INT))) ->orderBy('uid', 'DESC')->setMaxResults($limit) ->executeQuery()->fetchAllAssociative(); } catch (\Throwable) { return []; } foreach ($rows as &$row) { $path = (string)(parse_url((string)$row['url'], PHP_URL_PATH) ?? ''); $row['path'] = $path !== '' ? $path : (string)$row['url']; $ua = (string)$row['user_agent']; $row['ua_short'] = mb_strlen($ua) > 60 ? mb_substr($ua, 0, 57) . '...' : $ua; $row['is_chunk_reload'] = str_contains(strtolower((string)$row['message']), 'chunk reload'); } unset($row); return $rows; } /** * Purge log rows prepared for the template: trigger split into a short * label plus the user, paths as count + first two + the rest behind a * disclosure (the raw list was a wall of text, 0096-FE). */ 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, ':')) { [$trigger, $who] = explode(':', $trigger, 2); } $row['trigger_label'] = match ($trigger) { 'module' => 'manual', 'contextmenu' => 'context menu', 'editor-save', 'editor-command' => 'auto-purge', default => $trigger, }; $row['trigger_who'] = $who; // 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 === ['*']; $row['paths_count'] = count($paths); $row['paths_first'] = array_slice($paths, 0, 2); $row['paths_rest'] = array_slice($paths, 2); } unset($row); 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 { return (int)$this->connectionPool->getConnectionForTable('tx_vitec_clienterrorlog') ->executeQuery('SELECT COUNT(*) FROM tx_vitec_clienterrorlog WHERE crdate > ?', [time() - 86400]) ->fetchOne(); } catch (\Throwable) { return 0; } } public function purgeAction(ServerRequestInterface $request): ResponseInterface { $body = (array)$request->getParsedBody(); $mode = (string)($body['mode'] ?? 'paths'); $user = (string)($GLOBALS['BE_USER']->user['username'] ?? '?'); if ($mode === 'all') { $paths = ['*']; } else { $paths = preg_split('/[\s,]+/', (string)($body['paths'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: []; } if ($paths === []) { $this->flash('No paths given.', 'Purge', false); } else { $result = $this->purgeService->purge($paths, 'module:' . $user); $this->flash( sprintf('HTTP %d — %s', $result['httpCode'], $result['message']), $result['success'] ? 'Purge successful' : 'Purge failed', $result['success'] ); } return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecssr'), 303); } /** * "Warm cache" button: asks the node to render every page of the * sitemap in the background (0066-FE). Returns at once; progress is * the "Cache warming" row of the status table. */ public function warmAction(ServerRequestInterface $request): ResponseInterface { $result = $this->purgeService->warm(); $this->flash( $result['message'], $result['success'] ? 'Cache warming' : 'Cache warming failed', $result['success'] ); return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecssr'), 303); } /** * Ajax route vitec_ssr_purge_page: purge exactly one page, called by * the page tree context menu item (ssr-context-menu.js). */ public function purgePage(ServerRequestInterface $request): ResponseInterface { $body = (array)($request->getParsedBody() ?? []); $pageUid = (int)($body['pageUid'] ?? ($request->getQueryParams()['pageUid'] ?? 0)); if ($pageUid <= 0) { return new JsonResponse(['success' => false, 'message' => 'No page uid given'], 400); } try { $site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageUid); $path = $site->getRouter()->generateUri($pageUid)->getPath() ?: '/'; } catch (\Throwable) { return new JsonResponse(['success' => false, 'message' => 'Page has no routable URL (folder?)']); } $user = (string)($GLOBALS['BE_USER']->user['username'] ?? '?'); $result = $this->purgeService->purge([$path], 'contextmenu:' . $user); return new JsonResponse([ 'success' => $result['success'], 'path' => $path, 'message' => 'HTTP ' . $result['httpCode'] . ' - ' . $result['message'], ]); } /** * GET /__health of the SSR node. Returns [health array|null, error string]. */ private function fetchHealth(): array { $endpoint = $this->purgeService->getHealthEndpoint(); if ($endpoint === '') { return [null, 'var/vitec-ssr-purge.env missing or incomplete.']; } $start = microtime(true); try { $response = $this->requestFactory->request($endpoint, 'GET', [ 'timeout' => 3, 'connect_timeout' => 2, 'http_errors' => false, ]); if ($response->getStatusCode() !== 200) { return [null, 'HTTP ' . $response->getStatusCode()]; } $health = json_decode((string)$response->getBody(), true) ?: null; if ($health === null) { return [null, 'invalid JSON from /__health']; } $health['responseMs'] = (int)round((microtime(true) - $start) * 1000); return [$health, '']; } catch (\Throwable $e) { return [null, $e->getMessage()]; } } /** * 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 { [$health, $error] = $this->fetchHealth(); return new JsonResponse(['health' => $health, 'error' => $error]); } private function rows(string $table, int $limit): array { try { $qb = $this->connectionPool->getQueryBuilderForTable($table); return $qb->select('*')->from($table) ->orderBy('uid', 'DESC')->setMaxResults($limit) ->executeQuery()->fetchAllAssociative(); } catch (\Throwable) { return []; } } private function flash(string $message, string $title, bool $ok): void { $flash = GeneralUtility::makeInstance( FlashMessage::class, $message, $title, $ok ? ContextualFeedbackSeverity::OK : ContextualFeedbackSeverity::ERROR, true ); $this->flashMessageService->getMessageQueueByIdentifier()->addMessage($flash); } }