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>
This commit is contained in:
orascheandClaude Fable 5.1 committed 2026-11-11 10:15:42 +01:00
1 parent 07f74e372b
commit 086a178341
6 files changed
+477 -192

No files matched your search

@@ -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 {
@@ -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.
@@ -1,197 +1,305 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true">
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
xmlns:core="http://typo3.org/ns/TYPO3/CMS/Core/ViewHelpers"
data-namespace-typo3-fluid="true">
<f:layout name="Module" />
<f:section name="Content">
<h1>VITEC SSR — Control &amp; Protocol</h1>
<div class="row">
<div class="col-md-6">
<h3>Status</h3>
<table class="table table-striped">
<tr>
<th>SSR delivery</th>
<td>
<f:if condition="{flagOn}">
<f:then><span class="badge badge-success">ON — pages served from the VPS</span></f:then>
<f:else><span class="badge badge-secondary">OFF — SPA as before</span></f:else>
</f:if>
<br><small>Toggle: Site Management &rsaquo; Settings &rsaquo; VITEC</small>
</td>
</tr>
<tr>
<th>SSR server</th>
<td>
<f:if condition="{health}">
<f:then>
<span class="badge badge-success">running</span> {health.responseMs} ms<br>
Release <strong>{health.release.release}</strong>
(commit {health.release.commit})<br>
<span id="ssr-cache-line">Cache: {health.cachedPages} pages / {health.cacheMB} MB</span>
</f:then>
<f:else>
<span class="badge badge-danger">not reachable</span> {healthError}
</f:else>
</f:if>
</td>
</tr>
<tr>
<th>Cache warming</th>
<td id="ssr-warm-status" data-running="{f:if(condition: health.warming.running, then: '1', else: '0')}">
<div class="progress" id="ssr-warm-progress" style="height:16px;margin-bottom:6px;{f:if(condition: health.warming.running, else: 'display:none')}">
<div class="progress-bar progress-bar-striped progress-bar-animated bg-info" id="ssr-warm-bar" role="progressbar" style="width:0%;min-width:3em">0 %</div>
</div>
<div id="ssr-warm-text">
<f:if condition="{health.warming.running}">
<f:then>
<span class="badge badge-info">running</span>
{health.warming.done} of {health.warming.queued} pages rendered
({health.warming.skipped} still fresh, {health.warming.failed} failed)
<br><small class="text-muted">Live — updates every 2 seconds.</small>
</f:then>
<f:else>
<f:if condition="{health.warming.startedAt}">
<f:then>
Last run: {health.warming.done} of {health.warming.queued} pages rendered
({health.warming.skipped} still fresh,
<f:if condition="{health.warming.failed}">
<f:then><span class="badge badge-danger">{health.warming.failed} failed</span></f:then>
<f:else>0 failed</f:else>
</f:if>)
<f:if condition="{health.warming.aborted}"> <span class="badge badge-warning">aborted by restart</span></f:if>
</f:then>
<f:else><span class="text-muted">no run since the server started</span></f:else>
</f:if>
</f:else>
</f:if>
</div>
</td>
</tr>
<tr>
<th>Purge configuration</th>
<td>
<f:if condition="{configured}">
<f:then><span class="badge badge-success">ok</span></f:then>
<f:else><span class="badge badge-warning">var/vitec-ssr-purge.env missing</span></f:else>
</f:if>
</td>
</tr>
</table>
</div>
<div class="col-md-6">
<h3>Purge cache</h3>
<form action="{f:be.uri(route: 'web_vitecssr.purge')}" method="post" class="form-group">
<label for="purge-paths">Paths (one per line or comma-separated):</label>
<textarea class="form-control" id="purge-paths" name="paths" rows="3"
placeholder="/products/aligo&#10;/insights/news"></textarea>
<input type="hidden" name="mode" value="paths">
<div style="margin-top:8px">
<button type="submit" class="btn btn-primary">Purge these paths</button>
<div class="row g-4">
<div class="col-xl-7 d-flex">
<div class="card w-100 mb-0">
<div class="card-header">
<h2 class="card-title">Status</h2>
</div>
</form>
<form action="{f:be.uri(route: 'web_vitecssr.purge')}" method="post"
onsubmit="return confirm('Really purge the ENTIRE SSR cache?');">
<input type="hidden" name="mode" value="all">
<button type="submit" class="btn btn-warning">Purge everything (*)</button>
<small class="text-muted"> — needed after menu/footer changes while auto-purge is off</small>
</form>
<div class="card-body">
<dl class="ssr-status">
<div class="ssr-status-row">
<dt>SSR delivery</dt>
<dd>
<f:if condition="{flagOn}">
<f:then><span class="badge badge-success">ON</span> pages are served from the VPS</f:then>
<f:else><span class="badge badge-secondary">OFF</span> SPA as before, TYPO3 serves the shell</f:else>
</f:if>
<div class="form-text">Toggle: Site Management › Settings › VITEC › <em>SSR delivery</em>. Switching on purges the node cache once.</div>
</dd>
</div>
<div class="ssr-status-row">
<dt>SSR server</dt>
<dd>
<f:if condition="{health}">
<f:then>
<span class="badge badge-success">running</span>
<span class="text-body-secondary">{health.responseMs} ms</span>
<div class="mt-1">Release <code>{health.release.release}</code>
<span class="text-body-secondary">commit <code>{health.release.commit}</code></span></div>
<div id="ssr-cache-line" data-limit="{cacheLimitMb}">Cache: {health.cachedPages} pages · {health.cacheMB} MB<f:if condition="{cacheLimitMb}"> of {cacheLimitMb} MB</f:if></div>
</f:then>
<f:else>
<span class="badge badge-danger">not reachable</span>
<span class="text-body-secondary">{healthError}</span>
<div class="form-text">After {failoverAfter} failed checks in a row the cron switches delivery back to the SPA.</div>
</f:else>
</f:if>
</dd>
</div>
<div class="ssr-status-row">
<dt>Cache warming</dt>
<dd id="ssr-warm-status" data-running="{f:if(condition: health.warming.running, then: '1', else: '0')}">
<div class="progress" id="ssr-warm-progress" style="height:16px;margin-bottom:6px;{f:if(condition: health.warming.running, else: 'display:none')}">
<div class="progress-bar progress-bar-striped progress-bar-animated bg-info" id="ssr-warm-bar" role="progressbar" style="width:0%;min-width:3em">0 %</div>
</div>
<div id="ssr-warm-text">
<f:if condition="{health.warming.running}">
<f:then>
<span class="badge badge-info">running</span>
{health.warming.done} of {health.warming.queued} pages rendered
({health.warming.skipped} still fresh, {health.warming.failed} failed)
<div class="form-text">Live — updates every 2 seconds.</div>
</f:then>
<f:else>
<f:if condition="{health.warming.startedAt}">
<f:then>
Last run: {health.warming.done} of {health.warming.queued} pages rendered
({health.warming.skipped} still fresh,
<f:if condition="{health.warming.failed}">
<f:then><span class="badge badge-danger">{health.warming.failed} failed</span></f:then>
<f:else>0 failed</f:else>
</f:if>)
<f:if condition="{health.warming.aborted}"> <span class="badge badge-warning">aborted by restart</span></f:if>
</f:then>
<f:else><span class="text-body-secondary">no run since the server started</span></f:else>
</f:if>
</f:else>
</f:if>
</div>
</dd>
</div>
<div class="ssr-status-row">
<dt>Health check</dt>
<dd>
<f:if condition="{lastSample}">
<f:then>
<f:if condition="{lastSample.ok}">
<f:then><span class="badge badge-success">ok</span></f:then>
<f:else><span class="badge badge-danger">down</span></f:else>
</f:if>
<span title="{f:format.date(date: '@{lastSample.crdate}', format: 'Y-m-d H:i:s')}">{lastSampleAge} ago</span>
<span class="text-body-secondary">· {lastSample.response_ms} ms · {lastSample.cached_pages} pages</span>
<div class="form-text">Every 5 minutes via cron <code>vitec:ssr-health</code>; alert mail and automatic fallback to the SPA after repeated failures.</div>
</f:then>
<f:else><span class="text-body-secondary">no samples yet</span></f:else>
</f:if>
</dd>
</div>
<div class="ssr-status-row">
<dt>Purge webhook</dt>
<dd>
<f:if condition="{configured}">
<f:then><span class="badge badge-success">configured</span> <span class="text-body-secondary">endpoint and secret from <code>var/vitec-ssr-purge.env</code></span></f:then>
<f:else><span class="badge badge-warning">not configured</span> <span class="text-body-secondary"><code>var/vitec-ssr-purge.env</code> is missing or incomplete</span></f:else>
</f:if>
</dd>
</div>
</dl>
</div>
</div>
</div>
<h3 style="margin-top:24px">Warm cache</h3>
<form action="{f:be.uri(route: 'web_vitecssr.warm')}" method="post"
onsubmit="return confirm('This renders every sitemap page (800+) in the background and sends several thousand requests to TYPO3 within a few minutes. Continue?');">
<button type="submit" class="btn btn-success">Warm cache (all sitemap pages)</button>
<small class="text-muted"> — 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.</small>
</form>
<div class="col-xl-5 d-flex">
<div class="card w-100 mb-0">
<div class="card-header">
<h2 class="card-title">Actions</h2>
</div>
<div class="card-body">
<form action="{f:be.uri(route: 'web_vitecssr.purge')}" method="post" class="ssr-action">
<h3 class="ssr-action-title">Purge paths</h3>
<label class="form-label" for="purge-paths">Paths</label>
<textarea class="form-control font-monospace" id="purge-paths" name="paths" rows="4"
placeholder="/products/aligo&#10;/insights/news"></textarea>
<div class="form-text">One path per line, e.g. <code>/products/aligo</code>. Drops those pages (all host and query variants) from the SSR cache; they are rendered again on their next request.</div>
<input type="hidden" name="mode" value="paths">
<button type="submit" class="btn btn-default mt-2"><core:icon identifier="actions-refresh" size="small" /> Purge these paths</button>
</form>
<form action="{f:be.uri(route: 'web_vitecssr.purge')}" method="post" class="ssr-action"
data-confirm-title="Purge the entire SSR cache?"
data-confirm="Every cached page is dropped and rendered again on its next request. Run “Warm cache” afterwards so visitors do not wait for the first render."
data-confirm-ok="Purge everything" data-confirm-severity="danger">
<h3 class="ssr-action-title">Purge everything</h3>
<input type="hidden" name="mode" value="all">
<button type="submit" class="btn btn-danger"><core:icon identifier="actions-delete" size="small" /> Purge everything</button>
<div class="form-text">For menu or footer changes while auto-purge is off, and after a frontend deploy.</div>
</form>
<form action="{f:be.uri(route: 'web_vitecssr.warm')}" method="post" class="ssr-action"
data-confirm-title="Warm the whole cache?"
data-confirm="Renders every sitemap page (about 850) in the background and sends a few thousand requests to TYPO3. Takes about one minute with a warm TYPO3 cache, up to ten minutes with a cold one."
data-confirm-ok="Start warming" data-confirm-severity="primary">
<h3 class="ssr-action-title">Warm cache</h3>
<button type="submit" class="btn btn-primary {f:if(condition: health.warming.running, then: 'is-running')}" id="ssr-warm-button" {f:if(condition: health.warming.running, then: 'disabled="disabled"')}>
<span class="ssr-warm-idle"><core:icon identifier="actions-play" size="small" /> Warm cache</span>
<span class="ssr-warm-busy"><core:icon identifier="spinner-circle" size="small" /> Warming …</span>
</button>
<div class="form-text">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.</div>
</form>
</div>
</div>
</div>
</div>
<h3>Purge protocol <small>(last 30)</small></h3>
<table class="table table-striped table-sm">
<thead><tr><th>Time</th><th>Trigger</th><th>Paths</th><th>Result</th><th>Duration</th></tr></thead>
<tbody>
<f:for each="{purgeLog}" as="row">
<tr>
<td><f:format.date format="Y-m-d H:i:s">@{row.crdate}</f:format.date></td>
<td>{row.trigger_source}</td>
<td><code>{row.paths}</code></td>
<td>
<f:if condition="{row.success}">
<f:then><span class="badge badge-success">ok</span></f:then>
<f:else><span class="badge badge-danger">HTTP {row.http_code}</span> {row.message}</f:else>
</f:if>
</td>
<td>{row.duration_ms} ms</td>
</tr>
</f:for>
</tbody>
</table>
<div class="card mt-4">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<h2 class="card-title mb-0">Purge protocol</h2>
<div class="btn-group btn-group-sm" role="group" aria-label="Rows to show">
<a class="btn btn-default {f:if(condition: '{purgeLimit} == 30', then: 'active')}" href="{moduleUri}&amp;limit=30">30</a>
<a class="btn btn-default {f:if(condition: '{purgeLimit} == 100', then: 'active')}" href="{moduleUri}&amp;limit=100">100</a>
<a class="btn btn-default {f:if(condition: '{purgeLimit} == 500', then: 'active')}" href="{moduleUri}&amp;limit=500">500</a>
</div>
</div>
<div class="table-fit mb-0">
<table class="table table-striped table-hover mb-0">
<thead>
<tr>
<th class="col-nowrap">Time</th>
<th>Trigger</th>
<th>Paths</th>
<th class="text-end">Result</th>
<th class="text-end">Duration</th>
</tr>
</thead>
<tbody>
<f:for each="{purgeLog}" as="row">
<tr>
<td class="col-nowrap"><f:format.date format="d.m. H:i:s">@{row.crdate}</f:format.date></td>
<td class="col-nowrap">
<span class="badge {row.trigger_badge}">{row.trigger_label}</span>
<f:if condition="{row.trigger_who}"> <small class="text-body-secondary">{row.trigger_who}</small></f:if>
</td>
<td>
<f:if condition="{row.paths_all}">
<f:then><span class="badge badge-warning">all (*)</span></f:then>
<f:else>
<span class="text-body-secondary small">{row.paths_count} path<f:if condition="{row.paths_count} != 1">s</f:if></span>
<f:for each="{row.paths_first}" as="p"><code class="d-block">{p}</code></f:for>
<f:if condition="{row.paths_rest}">
<details class="small">
<summary class="text-body-secondary" style="cursor:pointer">show all {row.paths_count}</summary>
<f:for each="{row.paths_rest}" as="p"><code class="d-block">{p}</code></f:for>
</details>
</f:if>
</f:else>
</f:if>
</td>
<td class="text-end col-nowrap">
<f:if condition="{row.success}">
<f:then><span class="badge badge-success">ok</span></f:then>
<f:else><span class="badge badge-danger">HTTP {row.http_code}</span> <small>{row.message}</small></f:else>
</f:if>
</td>
<td class="text-end col-nowrap">{row.duration_ms} ms</td>
</tr>
</f:for>
<f:if condition="!{purgeLog}">
<tr><td colspan="5" class="text-body-secondary">No purges logged yet.</td></tr>
</f:if>
</tbody>
</table>
</div>
</div>
<h3>Health samples <small>(last 10 — history via cron <code>vitec:ssr-health</code>, every 5 minutes)</small></h3>
<table class="table table-striped table-sm">
<thead><tr><th>Time</th><th>Status</th><th>Release</th><th>Cache</th><th>Response time</th></tr></thead>
<tbody>
<f:for each="{healthLog}" as="row">
<tr>
<td><f:format.date format="Y-m-d H:i:s">@{row.crdate}</f:format.date></td>
<td>
<f:if condition="{row.ok}">
<f:then><span class="badge badge-success">ok</span></f:then>
<f:else><span class="badge badge-danger">down</span></f:else>
</f:if>
</td>
<td>{row.release_id}</td>
<td>{row.cached_pages} pages / {row.cache_mb} MB</td>
<td>{row.response_ms} ms</td>
</tr>
</f:for>
</tbody>
</table>
<div class="card mt-4">
<div class="card-header">
<h2 class="card-title mb-0">Client errors <small class="text-body-secondary fw-normal">last 24 h — {clientErrorCount24h} report<f:if condition="{clientErrorCount24h} != 1">s</f:if>, newest 50 shown</small></h2>
</div>
<div class="card-body pb-0">
<p class="form-text mt-0">
Browser-side failures reported by the React frontend via <code>POST /api/vitec/client-error</code>
(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 <code>vitec:client-error-cleanup</code>). A <code>release</code> older than the one running above means an open tab with an old bundle.
</p>
</div>
<div class="table-fit mb-0">
<table class="table table-striped table-hover mb-0">
<thead>
<tr><th class="col-nowrap">Time</th><th>Release</th><th>Message</th><th>Page</th><th>Browser</th><th>IP</th></tr>
</thead>
<tbody>
<f:for each="{clientErrors}" as="row">
<tr>
<td class="col-nowrap"><f:format.date format="d.m. H:i:s">@{row.crdate}</f:format.date></td>
<td><code>{row.release}</code><f:if condition="!{row.release}"><span class="text-body-secondary">—</span></f:if></td>
<td>
<f:if condition="{row.is_chunk_reload}">
<f:then><span class="badge badge-warning">chunk reload</span> </f:then>
<f:else><span class="badge badge-danger">error</span> </f:else>
</f:if>
{row.message}
<details class="small mt-1">
<summary class="text-body-secondary" style="cursor:pointer">details</summary>
<table class="table table-sm mt-2 mb-0">
<tr><th style="width:90px">Reported</th><td><f:format.date format="Y-m-d H:i:s">@{row.crdate}</f:format.date> (entry #{row.uid})</td></tr>
<tr><th>Release</th><td><code>{row.release}</code><f:if condition="!{row.release}"><span class="text-body-secondary">not transmitted</span></f:if></td></tr>
<tr><th>URL</th><td style="word-break:break-all"><a href="{row.url}" target="_blank" rel="noopener">{row.url}</a><f:if condition="!{row.url}"><span class="text-body-secondary">not transmitted</span></f:if></td></tr>
<tr><th>Message</th><td style="white-space:pre-wrap;word-break:break-word">{row.message}</td></tr>
<tr><th>Stack</th><td>
<f:if condition="{row.stack}">
<f:then><pre class="mb-0" style="white-space:pre-wrap;max-height:320px;overflow:auto">{row.stack}</pre></f:then>
<f:else><span class="text-body-secondary">no stack transmitted</span></f:else>
</f:if>
</td></tr>
<tr><th>Browser</th><td style="word-break:break-all">{row.user_agent}<f:if condition="!{row.user_agent}"><span class="text-body-secondary">not transmitted</span></f:if></td></tr>
<tr><th>IP</th><td>{row.ip} <span class="text-body-secondary">(anonymized)</span></td></tr>
</table>
</details>
</td>
<td><a href="{row.url}" target="_blank" rel="noopener" title="{row.url}">{row.path}</a></td>
<td title="{row.user_agent}"><small>{row.ua_short}</small></td>
<td><small>{row.ip}</small></td>
</tr>
</f:for>
<f:if condition="!{clientErrors}">
<tr><td colspan="6" class="text-body-secondary">No client errors reported in the last 24 hours.</td></tr>
</f:if>
</tbody>
</table>
</div>
</div>
<h3>Client errors <small>(last 24 h — {clientErrorCount24h} report<f:if condition="{clientErrorCount24h} != 1">s</f:if>, newest 50 shown)</small></h3>
<p class="text-muted" style="font-size:11px">
Browser-side failures reported by the React frontend via <code>POST /api/vitec/client-error</code>
(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 <code>vitec:client-error-cleanup</code>).
</p>
<table class="table table-striped table-sm">
<thead><tr><th>Time</th><th>Release</th><th>Message</th><th>Page</th><th>Browser</th><th>IP</th></tr></thead>
<tbody>
<f:for each="{clientErrors}" as="row">
<tr>
<td style="white-space:nowrap"><f:format.date format="Y-m-d H:i:s">@{row.crdate}</f:format.date></td>
<td><code>{row.release}</code></td>
<td>
<f:if condition="{row.is_chunk_reload}">
<f:then><span class="badge badge-warning">chunk reload</span> </f:then>
<f:else><span class="badge badge-danger">error</span> </f:else>
</f:if>
{row.message}
<details style="margin-top:4px">
<summary style="cursor:pointer" class="text-muted">details</summary>
<table class="table table-sm" style="margin:6px 0 0;font-size:11px;background:#fff">
<tr><th style="width:90px">Reported</th><td><f:format.date format="Y-m-d H:i:s">@{row.crdate}</f:format.date> (entry #{row.uid})</td></tr>
<tr><th>Release</th><td><code>{row.release}</code><f:if condition="!{row.release}"><span class="text-muted">not transmitted</span></f:if></td></tr>
<tr><th>URL</th><td style="word-break:break-all"><a href="{row.url}" target="_blank" rel="noopener">{row.url}</a><f:if condition="!{row.url}"><span class="text-muted">not transmitted</span></f:if></td></tr>
<tr><th>Message</th><td style="white-space:pre-wrap;word-break:break-word">{row.message}</td></tr>
<tr><th>Stack</th><td>
<f:if condition="{row.stack}">
<f:then><pre style="font-size:11px;white-space:pre-wrap;max-height:320px;overflow:auto;margin:0">{row.stack}</pre></f:then>
<f:else><span class="text-muted">no stack transmitted</span></f:else>
</f:if>
</td></tr>
<tr><th>Browser</th><td style="word-break:break-all">{row.user_agent}<f:if condition="!{row.user_agent}"><span class="text-muted">not transmitted</span></f:if></td></tr>
<tr><th>IP</th><td>{row.ip} <span class="text-muted">(anonymized)</span></td></tr>
</table>
</details>
</td>
<td><a href="{row.url}" target="_blank" rel="noopener" title="{row.url}">{row.path}</a></td>
<td title="{row.user_agent}"><small>{row.ua_short}</small></td>
<td><small>{row.ip}</small></td>
</tr>
</f:for>
<f:if condition="!{clientErrors}">
<tr><td colspan="6" class="text-muted">No client errors reported in the last 24 hours.</td></tr>
</f:if>
</tbody>
</table>
<div class="card mt-4">
<div class="card-header">
<h2 class="card-title mb-0">Health samples <small class="text-body-secondary fw-normal">last 10 — cron <code>vitec:ssr-health</code>, every 5 minutes</small></h2>
</div>
<div class="table-fit mb-0">
<table class="table table-striped table-hover mb-0">
<thead>
<tr><th class="col-nowrap">Time</th><th>Status</th><th>Release</th><th>Cache</th><th class="text-end">Response time</th></tr>
</thead>
<tbody>
<f:for each="{healthLog}" as="row">
<tr>
<td class="col-nowrap"><f:format.date format="d.m. H:i:s">@{row.crdate}</f:format.date></td>
<td>
<f:if condition="{row.ok}">
<f:then><span class="badge badge-success">ok</span></f:then>
<f:else><span class="badge badge-danger">down</span></f:else>
</f:if>
</td>
<td><code>{row.release_id}</code></td>
<td>{row.cached_pages} pages · {row.cache_mb} MB</td>
<td class="text-end col-nowrap">{row.response_ms} ms</td>
</tr>
</f:for>
<f:if condition="!{healthLog}">
<tr><td colspan="5" class="text-body-secondary">No samples yet.</td></tr>
</f:if>
</tbody>
</table>
</div>
</div>
</f:section>
</html>
@@ -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;
}
@@ -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();
},
},
],
);
});
});
@@ -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 = '<span class="badge badge-info">running</span> '
+ handled + ' of ' + queued + ' pages ('
+ num(w.done) + ' rendered, ' + num(w.skipped) + ' still fresh, ' + failed + ')'
+ '<br><small class="text-muted">Live - updates every 2 seconds.</small>';
+ '<div class="form-text">Live - updates every 2 seconds.</div>';
} else if (w.startedAt) {
wrap.style.display = 'none';
const finished = w.finishedAt
? ' <small class="text-muted">finished ' + new Date(w.finishedAt).toLocaleTimeString() + '</small>'
? ' <span class="text-body-secondary">finished ' + new Date(w.finishedAt).toLocaleTimeString() + '</span>'
: '';
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 = '<span class="text-muted">no run since the server started</span>';
text.innerHTML = '<span class="text-body-secondary">no run since the server started</span>';
}
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;
};