From 086a178341d4eb55842b4232a29ddfd2df7cadb3 Mon Sep 17 00:00:00 2001 From: Oliver Rasche Date: Wed, 11 Nov 2026 10:15:42 +0100 Subject: [PATCH] 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 --- .../Controller/Backend/SsrController.php | 66 ++- .../vitec/Classes/Service/SsrPurgeService.php | 10 + .../Private/Templates/Ssr/Index.html | 478 +++++++++++------- .../Resources/Public/Css/backend-ssr.css | 55 ++ .../Resources/Public/Javascript/ssr-module.js | 42 ++ .../Public/Javascript/ssr-warm-progress.js | 18 +- 6 files changed, 477 insertions(+), 192 deletions(-) create mode 100644 packages/vitec/Resources/Public/Css/backend-ssr.css create mode 100644 packages/vitec/Resources/Public/Javascript/ssr-module.js diff --git a/packages/vitec/Classes/Controller/Backend/SsrController.php b/packages/vitec/Classes/Controller/Backend/SsrController.php index 9ddf537..70c123f 100644 --- a/packages/vitec/Classes/Controller/Backend/SsrController.php +++ b/packages/vitec/Classes/Controller/Backend/SsrController.php @@ -9,7 +9,10 @@ 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; @@ -38,6 +41,7 @@ final class SsrController private readonly FlashMessageService $flashMessageService, private readonly UriBuilder $uriBuilder, private readonly PageRenderer $pageRenderer, + private readonly ComponentFactory $componentFactory, ) {} public function indexAction(ServerRequestInterface $request): ResponseInterface @@ -47,14 +51,36 @@ final class SsrController [$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, - 'purgeLog' => $this->rows('tx_vitec_ssrpurgelog', 30), - 'healthLog' => $this->rows('tx_vitec_ssrhealthlog', 10), + '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(), ]); @@ -89,6 +115,42 @@ final class SsrController 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 { diff --git a/packages/vitec/Classes/Service/SsrPurgeService.php b/packages/vitec/Classes/Service/SsrPurgeService.php index b198583..9e30f46 100644 --- a/packages/vitec/Classes/Service/SsrPurgeService.php +++ b/packages/vitec/Classes/Service/SsrPurgeService.php @@ -76,6 +76,16 @@ final class SsrPurgeService return max(1, (int)($this->getConfig()['SSR_FAILOVER_AFTER'] ?? 2)); } + /** + * SSR_CACHE_MB of the node (/etc/vitec-ssr.env on the VPS) as the + * module knows it — the node does not report its limit in /__health. + * Optional SSR_CACHE_LIMIT_MB in var/vitec-ssr-purge.env; 0 = unknown. + */ + public function getCacheLimitMb(): int + { + return max(0, (int)($this->getConfig()['SSR_CACHE_LIMIT_MB'] ?? 0)); + } + /** * POST /__warm on the node (contract 0066-FE), derived from * SSR_PURGE_ENDPOINT; SSR_WARM_ENDPOINT overrides explicitly. diff --git a/packages/vitec/Resources/Private/Templates/Ssr/Index.html b/packages/vitec/Resources/Private/Templates/Ssr/Index.html index de889d9..10b5dc8 100644 --- a/packages/vitec/Resources/Private/Templates/Ssr/Index.html +++ b/packages/vitec/Resources/Private/Templates/Ssr/Index.html @@ -1,197 +1,305 @@ - + + + + +

VITEC SSR — Control & Protocol

-
-
-

Status

- - - - - - - - - - - - - - - - - -
SSR delivery - - ON — pages served from the VPS - OFF — SPA as before - -
Toggle: Site Management › Settings › VITEC -
SSR server - - - running {health.responseMs} ms
- Release {health.release.release} - (commit {health.release.commit})
- Cache: {health.cachedPages} pages / {health.cacheMB} MB -
- - not reachable {healthError} - -
-
Cache warming -
-
0 %
-
-
- - - running - {health.warming.done} of {health.warming.queued} pages rendered - ({health.warming.skipped} still fresh, {health.warming.failed} failed) -
Live — updates every 2 seconds. -
- - - - Last run: {health.warming.done} of {health.warming.queued} pages rendered - ({health.warming.skipped} still fresh, - - {health.warming.failed} failed - 0 failed - ) - aborted by restart - - no run since the server started - - -
-
-
Purge configuration - - ok - var/vitec-ssr-purge.env missing - -
-
-
-

Purge cache

-
- - - -
- +
+
+
+
+

Status

- -
- - - — needed after menu/footer changes while auto-purge is off -
+
+
+
+
SSR delivery
+
+ + ON pages are served from the VPS + OFF SPA as before, TYPO3 serves the shell + +
Toggle: Site Management › Settings › VITEC › SSR delivery. Switching on purges the node cache once.
+
+
+
+
SSR server
+
+ + + running + {health.responseMs} ms +
Release {health.release.release} + commit {health.release.commit}
+
Cache: {health.cachedPages} pages · {health.cacheMB} MB of {cacheLimitMb} MB
+
+ + not reachable + {healthError} +
After {failoverAfter} failed checks in a row the cron switches delivery back to the SPA.
+
+
+
+
+
+
Cache warming
+
+
+
0 %
+
+
+ + + running + {health.warming.done} of {health.warming.queued} pages rendered + ({health.warming.skipped} still fresh, {health.warming.failed} failed) +
Live — updates every 2 seconds.
+
+ + + + Last run: {health.warming.done} of {health.warming.queued} pages rendered + ({health.warming.skipped} still fresh, + + {health.warming.failed} failed + 0 failed + ) + aborted by restart + + no run since the server started + + +
+
+
+
+
+
Health check
+
+ + + + ok + down + + {lastSampleAge} ago + · {lastSample.response_ms} ms · {lastSample.cached_pages} pages +
Every 5 minutes via cron vitec:ssr-health; alert mail and automatic fallback to the SPA after repeated failures.
+
+ no samples yet +
+
+
+
+
Purge webhook
+
+ + configured endpoint and secret from var/vitec-ssr-purge.env + not configured var/vitec-ssr-purge.env is missing or incomplete + +
+
+
+
+
+
-

Warm cache

-
- - — pre-renders every page in the background, so no visitor has to - wait for a first render. Useful after "Purge everything" or a deploy; takes a few minutes. -
+
+
+
+

Actions

+
+
+
+

Purge paths

+ + +
One path per line, e.g. /products/aligo. Drops those pages (all host and query variants) from the SSR cache; they are rendered again on their next request.
+ + +
+ +
+

Purge everything

+ + +
For menu or footer changes while auto-purge is off, and after a frontend deploy.
+
+ +
+

Warm cache

+ +
Pre-renders every sitemap page so no visitor waits for a first render. Use it after “Purge everything” or a deploy; progress shows in the status card.
+
+
+
-

Purge protocol (last 30)

- - - - - - - - - - - - - -
TimeTriggerPathsResultDuration
@{row.crdate}{row.trigger_source}{row.paths} - - ok - HTTP {row.http_code} {row.message} - - {row.duration_ms} ms
+
+
+

Purge protocol

+
+ 30 + 100 + 500 +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
TimeTriggerPathsResultDuration
@{row.crdate} + {row.trigger_label} + {row.trigger_who} + + + all (*) + + {row.paths_count} paths + {p} + +
+ show all {row.paths_count} + {p} +
+
+
+
+
+ + ok + HTTP {row.http_code} {row.message} + + {row.duration_ms} ms
No purges logged yet.
+
+
-

Health samples (last 10 — history via cron vitec:ssr-health, every 5 minutes)

- - - - - - - - - - - - - -
TimeStatusReleaseCacheResponse time
@{row.crdate} - - ok - down - - {row.release_id}{row.cached_pages} pages / {row.cache_mb} MB{row.response_ms} ms
+
+
+

Client errors last 24 h — {clientErrorCount24h} reports, newest 50 shown

+
+
+

+ Browser-side failures reported by the React frontend via POST /api/vitec/client-error + (JS errors, unhandled promise rejections, element error boundaries and “chunk reload” after a deploy). + Limited to 30 reports per minute and IP, 8 KB per report; IPs are stored anonymized; kept for 30 days + (cron vitec:client-error-cleanup). A release older than the one running above means an open tab with an old bundle. +

+
+
+ + + + + + + + + + + + + + + + + + + +
TimeReleaseMessagePageBrowserIP
@{row.crdate}{row.release}— + + chunk reload + error + + {row.message} +
+ details + + + + + + + + +
Reported@{row.crdate} (entry #{row.uid})
Release{row.release}not transmitted
URL{row.url}not transmitted
Message{row.message}
Stack + +
{row.stack}
+ no stack transmitted +
+
Browser{row.user_agent}not transmitted
IP{row.ip} (anonymized)
+
+
{row.path}{row.ua_short}{row.ip}
No client errors reported in the last 24 hours.
+
+
-

Client errors (last 24 h — {clientErrorCount24h} reports, newest 50 shown)

-

- Browser-side failures reported by the React frontend via POST /api/vitec/client-error - (JS errors, unhandled promise rejections, element error boundaries and "chunk reload" after a deploy). - Limited to 30 reports per minute and IP, 8 KB per report; IPs are stored anonymized. - Kept for 30 days (cron vitec:client-error-cleanup). -

- - - - - - - - - - - - - - - - - -
TimeReleaseMessagePageBrowserIP
@{row.crdate}{row.release} - - chunk reload - error - - {row.message} -
- details - - - - - - - - -
Reported@{row.crdate} (entry #{row.uid})
Release{row.release}not transmitted
URL{row.url}not transmitted
Message{row.message}
Stack - -
{row.stack}
- no stack transmitted -
-
Browser{row.user_agent}not transmitted
IP{row.ip} (anonymized)
-
-
{row.path}{row.ua_short}{row.ip}
No client errors reported in the last 24 hours.
+
+
+

Health samples last 10 — cron vitec:ssr-health, every 5 minutes

+
+
+ + + + + + + + + + + + + + + + + + +
TimeStatusReleaseCacheResponse time
@{row.crdate} + + ok + down + + {row.release_id}{row.cached_pages} pages · {row.cache_mb} MB{row.response_ms} ms
No samples yet.
+
+
+ diff --git a/packages/vitec/Resources/Public/Css/backend-ssr.css b/packages/vitec/Resources/Public/Css/backend-ssr.css new file mode 100644 index 0000000..6752f91 --- /dev/null +++ b/packages/vitec/Resources/Public/Css/backend-ssr.css @@ -0,0 +1,55 @@ +/* VITEC SSR module (web_vitecssr): layout glue on top of TYPO3's own + components - cards, badges, buttons and tables come from the core. */ + +.ssr-status { + margin: 0; +} +.ssr-status-row { + display: grid; + grid-template-columns: 9rem minmax(0, 1fr); + gap: 0 1rem; + padding: .75rem 0; + border-bottom: 1px solid var(--typo3-component-border-color, #dee2e6); +} +.ssr-status-row:first-child { + padding-top: 0; +} +.ssr-status-row:last-child { + border-bottom: 0; + padding-bottom: 0; +} +.ssr-status-row dt { + font-weight: normal; + color: var(--typo3-text-color-secondary, #6c757d); +} +.ssr-status-row dd { + margin: 0; + min-width: 0; +} +@media (max-width: 575.98px) { + .ssr-status-row { + grid-template-columns: minmax(0, 1fr); + gap: .25rem 0; + } +} + +.ssr-action + .ssr-action { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--typo3-component-border-color, #dee2e6); +} +.ssr-action-title { + font-size: 1rem; + font-weight: 600; + margin: 0 0 .5rem; +} + +#ssr-warm-button .ssr-warm-busy { + display: none; +} +#ssr-warm-button.is-running .ssr-warm-idle { + display: none; +} +#ssr-warm-button.is-running .ssr-warm-busy { + display: inline; +} diff --git a/packages/vitec/Resources/Public/Javascript/ssr-module.js b/packages/vitec/Resources/Public/Javascript/ssr-module.js new file mode 100644 index 0000000..8a5ce5c --- /dev/null +++ b/packages/vitec/Resources/Public/Javascript/ssr-module.js @@ -0,0 +1,42 @@ +/** + * VITEC SSR module: TYPO3 modal confirmation for the destructive / heavy + * actions (Purge everything, Warm cache) instead of window.confirm. + * A form opts in with data-confirm="text" (+ data-confirm-title, + * data-confirm-ok, data-confirm-severity="danger|primary"). + */ +import Modal from '@typo3/backend/modal.js'; +import Severity from '@typo3/backend/severity.js'; + +document.querySelectorAll('form[data-confirm]').forEach((form) => { + form.addEventListener('submit', (event) => { + if (form.dataset.confirmed === '1') { + return; + } + event.preventDefault(); + const danger = form.dataset.confirmSeverity === 'danger'; + Modal.confirm( + form.dataset.confirmTitle || 'Please confirm', + form.dataset.confirm, + danger ? Severity.error : Severity.warning, + [ + { + text: 'Cancel', + btnClass: 'btn-default', + name: 'cancel', + trigger: (e, modal) => modal.hideModal(), + }, + { + text: form.dataset.confirmOk || 'Continue', + btnClass: danger ? 'btn-danger' : 'btn-primary', + name: 'ok', + active: true, + trigger: (e, modal) => { + modal.hideModal(); + form.dataset.confirmed = '1'; + form.requestSubmit(); + }, + }, + ], + ); + }); +}); diff --git a/packages/vitec/Resources/Public/Javascript/ssr-warm-progress.js b/packages/vitec/Resources/Public/Javascript/ssr-warm-progress.js index 98c873c..6caaea1 100644 --- a/packages/vitec/Resources/Public/Javascript/ssr-warm-progress.js +++ b/packages/vitec/Resources/Public/Javascript/ssr-warm-progress.js @@ -3,7 +3,8 @@ * Polls the vitec_ssr_health ajax route (GET /__health of the node): * every 2 s while a warm run is active, every 15 s otherwise so runs * started from the CLI or the context menu show up without a reload. - * Also refreshes the "Cache: N pages / M MB" line. + * Also refreshes the "Cache: N pages / M MB" line and disables the + * "Warm cache" button while a run is active. */ import AjaxRequest from '@typo3/core/ajax/ajax-request.js'; @@ -13,6 +14,7 @@ if (cell) { const bar = document.getElementById('ssr-warm-bar'); const text = document.getElementById('ssr-warm-text'); const cacheLine = document.getElementById('ssr-cache-line'); + const warmButton = document.getElementById('ssr-warm-button'); let timer = null; const num = (v) => (typeof v === 'number' ? v : 0); @@ -33,11 +35,11 @@ if (cell) { text.innerHTML = 'running ' + handled + ' of ' + queued + ' pages (' + num(w.done) + ' rendered, ' + num(w.skipped) + ' still fresh, ' + failed + ')' - + '
Live - updates every 2 seconds.'; + + '
Live - updates every 2 seconds.
'; } else if (w.startedAt) { wrap.style.display = 'none'; const finished = w.finishedAt - ? ' finished ' + new Date(w.finishedAt).toLocaleTimeString() + '' + ? ' finished ' + new Date(w.finishedAt).toLocaleTimeString() + '' : ''; text.innerHTML = 'Last run: ' + num(w.done) + ' of ' + queued + ' pages rendered (' + num(w.skipped) + ' still fresh, ' + failed + ')' @@ -45,11 +47,17 @@ if (cell) { + finished; } else { wrap.style.display = 'none'; - text.innerHTML = 'no run since the server started'; + text.innerHTML = 'no run since the server started'; } if (cacheLine && typeof h.cachedPages === 'number') { - cacheLine.textContent = 'Cache: ' + h.cachedPages + ' pages / ' + num(h.cacheMB) + ' MB'; + const limit = parseInt(cacheLine.dataset.limit || '0', 10); + cacheLine.textContent = 'Cache: ' + h.cachedPages + ' pages · ' + num(h.cacheMB) + ' MB' + + (limit > 0 ? ' of ' + limit + ' MB' : ''); + } + if (warmButton) { + warmButton.disabled = !!w.running; + warmButton.classList.toggle('is-running', !!w.running); } return !!w.running; };