Files
VITEC-website/packages/vitec/Classes/Controller/Backend/SsrController.php
T
orascheandClaude Fable 5.1 086a178341 SSR module: rebuilt on TYPO3 module layout, cards and core components
Ticket 0096-FE (aehm): the module now uses the core Module layout (DocHeader with reload and shortcut, module-body spacing), a Status card as description list with one badge style, an Actions card with core buttons and icons, help texts below the buttons and TYPO3 modal confirmations (ssr-module.js) instead of window.confirm, and three protocol cards. Purge protocol rows show a one-line time, the trigger as badge plus user, paths as count + first two + disclosure, result and duration right-aligned, with 30/100/500 row switches. Status shows the last health sample with relative age and the cache limit (new optional SSR_CACHE_LIMIT_MB in var/vitec-ssr-purge.env, SsrPurgeService::getCacheLimitMb). ssr-warm-progress.js now also disables the Warm button with a spinner while a run is active. backend-ssr.css holds only layout glue with core CSS variables (dark mode safe). Verified in the backend: layout, modal, running progress bar, protocol tables.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-11 10:15:42 +01:00

294 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Controller\Backend;
use Evomedien\Vitec\Service\SsrPurgeService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Http\RequestFactory;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Backend module "VITEC SSR": live state of the SSR node, manual purge
* (single paths or everything) and the purge/health protocol.
*/
#[AsController]
final class SsrController
{
public function __construct(
private readonly ModuleTemplateFactory $moduleTemplateFactory,
private readonly SsrPurgeService $purgeService,
private readonly RequestFactory $requestFactory,
private readonly ConnectionPool $connectionPool,
private readonly FlashMessageService $flashMessageService,
private readonly UriBuilder $uriBuilder,
private readonly PageRenderer $pageRenderer,
private readonly ComponentFactory $componentFactory,
) {}
public function indexAction(ServerRequestInterface $request): ResponseInterface
{
$view = $this->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);
}
}