moduleTemplateFactory->create($request); $view->setTitle('VITEC SSR'); [$health, $healthError] = $this->fetchHealth(); $this->pageRenderer->loadJavaScriptModule('@evomedien/vitec/ssr-warm-progress.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; $view->assignMultiple([ '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' => $this->clientErrors(50), '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); foreach ($rows as &$row) { $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; $row['trigger_badge'] = match ($trigger) { 'module', 'contextmenu' => 'badge-info', 'switch-on' => 'badge-warning', default => 'badge-secondary', }; $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; } 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 module's * "Cache warming" progress bar (ssr-warm-progress.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); } }