SSR module: dark VITEC cockpit with live charts

Live band with 24 h availability strip, gauges for hit rate, response time, cache fill and client errors, traffic chart 24 h / 7 days from the health log counter deltas drawn with the core Chart.js of EXT:dashboard, warm progress ring with live polling, purge activity timeline, client error cards, link to the site settings. ssr-cockpit.js replaces ssr-warm-progress.js, whose deletion went into a9923e1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Opus 5.5 committed 2026-11-11 10:52:46 +01:00
1 parent a9923e19e0
commit 67ded6e502
6 files changed
+1119 -342

No files matched your search

@@ -6,299 +6,297 @@
<f:section name="Content">
<h1>VITEC SSR — Control &amp; Protocol</h1>
<div class="vitec-cockpit" id="ssr-cockpit" data-json="{cockpitJson}">
<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>
<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>
<h1 class="vc-sr">VITEC SSR — Control &amp; Protocol</h1>
<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>
<div class="vc-grid">
<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>
<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>
<!-- Live band: node state, release, delivery switch, 24 h availability -->
<section class="vc-card vc-hero vc-s12" aria-label="Live status">
<div class="vc-hero-top">
<div class="vc-live">
<f:if condition="{health}">
<f:then>
<span class="vc-pulse" id="vc-pulse" aria-hidden="true"></span>
<div>
<h2 class="vc-live-title" id="vc-live-title">SSR <span class="vc-ok-text">online</span></h2>
<p class="vc-live-sub">
<f:if condition="{flagOn}">
<f:then>Pages are rendered on the VPS</f:then>
<f:else>Node is ready, but delivery is off: TYPO3 serves the SPA</f:else>
</f:if>
</f:else>
<f:if condition="{lastSample}"> · last check <span title="{f:format.date(date: '@{lastSample.crdate}', format: 'Y-m-d H:i:s')}">{lastSampleAge} ago</span></f:if>
</p>
</div>
</f:then>
<f:else>
<span class="vc-pulse is-down" id="vc-pulse" aria-hidden="true"></span>
<div>
<h2 class="vc-live-title" id="vc-live-title">SSR <span class="vc-bad-text">not reachable</span></h2>
<p class="vc-live-sub">{healthError} · after {failoverAfter} failed checks in a row the cron switches delivery back to the SPA</p>
</div>
</f:else>
</f:if>
</div>
<dl class="vc-facts">
<div class="vc-fact">
<dt>Delivery</dt>
<dd>
<f:if condition="{flagOn}">
<f:then><span class="vc-pill is-ok">ON</span></f:then>
<f:else><span class="vc-pill is-muted">OFF</span></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 condition="{settingsUri}">
<f:then><a href="{settingsUri}" class="vc-link" title="Site Management › Settings › VITEC › SSR delivery. Switching on purges the node cache once.">Site settings</a></f:then>
<f:else><span class="vc-dim" title="Site Management › Settings › VITEC › SSR delivery">via Site settings</span></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>
</dd>
</div>
<f:if condition="{health}">
<div class="vc-fact"><dt>Release</dt><dd><code>{health.release.release}</code></dd></div>
<div class="vc-fact"><dt>Commit</dt><dd><code>{health.release.commit}</code></dd></div>
</f:if>
<div class="vc-fact">
<dt>Purge webhook</dt>
<dd>
<f:if condition="{configured}">
<f:then><span class="vc-pill is-ok" title="Endpoint and secret from var/vitec-ssr-purge.env">configured</span></f:then>
<f:else><span class="vc-pill is-warn" title="var/vitec-ssr-purge.env is missing or incomplete">not configured</span></f:else>
</f:if>
</dd>
</div>
<div class="vc-fact">
<dt>Auto failover</dt>
<dd><span class="vc-pill is-navy" title="Cron vitec:ssr-health every 5 minutes: alert mail and fallback to the SPA after {failoverAfter} failed checks in a row">after {failoverAfter} failed checks</span></dd>
</div>
</dl>
</div>
<div class="vc-uptime">
<div class="vc-uptime-h">
<span>Availability, last 24 hours · one bar per 15 minutes</span>
<span id="vc-uptime-sum">–</span>
</div>
<div class="vc-bars" id="vc-bars" role="img" aria-label="Availability of the SSR node over the last 24 hours"></div>
<div class="vc-uptime-f" id="vc-uptime-axis"></div>
</div>
</section>
<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>
<!-- Gauges -->
<section class="vc-card vc-s3" aria-label="Cache hit rate">
<div class="vc-tile">
<svg class="vc-ring" width="96" height="96" viewBox="0 0 96 96" aria-hidden="true"><circle class="vc-track" cx="48" cy="48" r="40"/><circle class="vc-val is-ok" id="vc-ring-hit" cx="48" cy="48" r="40"/></svg>
<div class="vc-tile-body">
<div class="vc-label">Hit rate · 24 h</div>
<div class="vc-big"><span id="vc-hit">–</span><small>%</small></div>
<div class="vc-sub" id="vc-hit-trend">served from cache or refreshed in the background</div>
</div>
</div>
</section>
<section class="vc-card vc-s3" aria-label="Response time">
<div class="vc-label">Response time</div>
<div class="vc-big"><span id="vc-ms"><f:if condition="{health}"><f:then>{health.responseMs}</f:then><f:else>–</f:else></f:if></span><small>ms</small></div>
<div class="vc-sub">median 24 h <b id="vc-ms-median">–</b> · p95 <b id="vc-ms-p95">–</b></div>
<svg class="vc-spark" id="vc-spark" viewBox="0 0 300 46" preserveAspectRatio="none" aria-hidden="true"></svg>
</section>
<section class="vc-card vc-s3" aria-label="Cache fill">
<div class="vc-tile">
<svg class="vc-ring" width="96" height="96" viewBox="0 0 96 96" aria-hidden="true"><circle class="vc-track" cx="48" cy="48" r="40"/><circle class="vc-val is-sky" id="vc-ring-cache" cx="48" cy="48" r="40"/></svg>
<div class="vc-tile-body">
<div class="vc-label">Cache fill</div>
<div class="vc-big"><span id="vc-cache-mb">{cockpit.cacheMb}</span><small id="vc-cache-limit"><f:if condition="{cacheLimitMb}"><f:then>/ {cacheLimitMb} MB</f:then><f:else>MB</f:else></f:if></small></div>
<div class="vc-sub"><b id="vc-cache-pages">{cockpit.cachedPages}</b> pages cached<span id="vc-cache-pct"></span></div>
</div>
</div>
</section>
<section class="vc-card vc-s3" aria-label="Client errors">
<div class="vc-label">Client errors · 24 h</div>
<div class="vc-big">{clientErrorCount24h}<small>report<f:if condition="{clientErrorCount24h} != 1">s</f:if></small></div>
<div class="vc-sub">{chunkCount24h} chunk reload<f:if condition="{chunkCount24h} != 1">s</f:if> · {jsErrorCount24h} JS error<f:if condition="{jsErrorCount24h} != 1">s</f:if></div>
<div class="vc-mini" id="vc-mini" role="img" aria-label="Client errors per hour, last 24 hours"></div>
</section>
<!-- Traffic chart -->
<section class="vc-card vc-s8 vc-chart-card" aria-label="Traffic and cache">
<div class="vc-card-h">
<h2 class="vc-card-t"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 3v18h18M7 15l4-5 4 3 5-7"/></svg>Traffic and cache</h2>
<div class="vc-seg" role="group" aria-label="Time range">
<button type="button" data-range="24h" aria-pressed="true">24 h</button>
<button type="button" data-range="7d" aria-pressed="false">7 days</button>
</div>
</div>
<div class="vc-legend">
<span><i class="is-navy"></i>Served from cache</span>
<span><i class="is-sky"></i>Stale, refreshed in background</span>
<span><i class="is-warn"></i>Rendered fresh</span>
<span><i class="is-orange is-round"></i>Response time</span>
<span><i class="is-bad is-tri"></i>Purge</span>
</div>
<div class="vc-chart"><canvas id="vc-traffic" aria-label="Requests by cache result, with response time and purges"></canvas></div>
<p class="vc-empty" id="vc-traffic-empty" hidden>No traffic counters in this range yet. The cron <code>vitec:ssr-health</code> collects them every 5 minutes.</p>
<dl class="vc-stats">
<div><dt>Requests</dt><dd id="vc-st-req">–</dd></div>
<div><dt>Hit rate</dt><dd id="vc-st-hit">–</dd></div>
<div><dt>Render errors</dt><dd id="vc-st-err">–</dd></div>
<div><dt>Purges</dt><dd id="vc-st-purge">–</dd></div>
</dl>
</section>
<!-- Actions -->
<section class="vc-card vc-s4" aria-label="Actions">
<div class="vc-card-h"><h2 class="vc-card-t"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M13 2 4 14h7l-1 8 9-12h-7z"/></svg>Actions</h2></div>
<div class="vc-actions">
<form action="{f:be.uri(route: 'web_vitecssr.warm')}" method="post" class="vc-act"
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">
<div class="vc-act-h"><h3 class="vc-act-t">Warm cache</h3><span class="vc-pill" id="vc-warm-pill" hidden></span></div>
<div class="vc-warm">
<div class="vc-warm-ring">
<svg width="92" height="92" viewBox="0 0 96 96" aria-hidden="true" class="vc-ring"><circle class="vc-track" cx="48" cy="48" r="40"/><circle class="vc-val is-orange is-fast" id="vc-ring-warm" cx="48" cy="48" r="40"/></svg>
<div class="vc-warm-pct" id="vc-warm-pct">–</div>
</div>
<div class="vc-warm-meta" id="vc-warm-meta">no run since the server started</div>
</div>
<div class="vc-btn-row">
<button type="submit" class="vc-btn is-primary" id="ssr-warm-button" {f:if(condition: health.warming.running, then: 'disabled="disabled"')}>
<core:icon identifier="actions-play" size="small" /><span id="vc-warm-label">{f:if(condition: health.warming.running, then: 'Warming …', else: 'Warm cache')}</span>
</button>
</div>
<p class="vc-help">Renders every sitemap page in advance, so no visitor waits for a first render. Run it after “Purge everything” or a deploy.</p>
</form>
<form action="{f:be.uri(route: 'web_vitecssr.purge')}" method="post" class="vc-act">
<h3 class="vc-act-t">Purge paths</h3>
<label class="vc-lbl" for="purge-paths">Paths, one per line</label>
<textarea class="vc-input" id="purge-paths" name="paths" rows="3" placeholder="/products/aligo&#10;/insights/news"></textarea>
<input type="hidden" name="mode" value="paths">
<div class="vc-btn-row"><button type="submit" class="vc-btn is-ghost"><core:icon identifier="actions-refresh" size="small" />Purge these paths</button></div>
<p class="vc-help">Drops those pages, all hosts and query variants, from the SSR cache. They render again on their next request.</p>
</form>
<form action="{f:be.uri(route: 'web_vitecssr.purge')}" method="post" class="vc-act"
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="vc-act-t">Purge everything</h3>
<p class="vc-help">For menu or footer changes while auto-purge is off, and after a frontend deploy.</p>
<input type="hidden" name="mode" value="all">
<div class="vc-btn-row"><button type="submit" class="vc-btn is-danger"><core:icon identifier="actions-delete" size="small" />Purge everything</button></div>
</form>
</div>
</section>
<!-- Purge activity -->
<section class="vc-card vc-s7" aria-label="Purge activity">
<div class="vc-card-h">
<h2 class="vc-card-t"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>Purge activity</h2>
<div class="vc-seg" role="group" aria-label="Entries to show">
<a href="{moduleUri}&amp;limit=30" aria-pressed="{f:if(condition: '{purgeLimit} == 30', then: 'true', else: 'false')}">30</a>
<a href="{moduleUri}&amp;limit=100" aria-pressed="{f:if(condition: '{purgeLimit} == 100', then: 'true', else: 'false')}">100</a>
<a href="{moduleUri}&amp;limit=500" aria-pressed="{f:if(condition: '{purgeLimit} == 500', then: 'true', else: 'false')}">500</a>
</div>
</div>
<div class="vc-legend vc-legend-tl">
<span><i class="is-ok is-round"></i>auto-purge</span>
<span><i class="is-orange is-round"></i>manual</span>
<span><i class="is-navy is-round"></i>context menu</span>
<span><i class="is-sky is-round"></i>cli / deploy</span>
<span><i class="is-warn is-round"></i>switch-on</span>
</div>
<f:if condition="{purgeLog}">
<f:then>
<ol class="vc-timeline">
<f:for each="{purgeLog}" as="row">
<f:if condition="{row.day_first}"><li class="vc-day">{row.day_label}</li></f:if>
<li class="vc-ev">
<time title="{f:format.date(date: '@{row.crdate}', format: 'Y-m-d H:i:s')}"><f:format.date format="H:i">@{row.crdate}</f:format.date></time>
<span class="vc-dot is-{row.trigger_tone}"></span>
<div class="vc-ev-main">
<div class="vc-ev-line">
<span class="vc-pill is-{row.trigger_tone}">{row.trigger_label}</span>
<f:if condition="{row.trigger_who}"><span class="vc-dim">{row.trigger_who}</span></f:if>
</div>
<div class="vc-paths">
<f:if condition="{row.paths_all}">
<f:then><span class="vc-pill is-warn">all pages (*)</span></f:then>
<f:else>
<f:for each="{row.paths_first}" as="p"><span class="vc-path" title="{p}">{p}</span></f:for>
<f:if condition="{row.paths_rest}">
<details class="vc-more">
<summary>+<f:count subject="{row.paths_rest}" /> more</summary>
<f:for each="{row.paths_rest}" as="p"><span class="vc-path" title="{p}">{p}</span></f:for>
</details>
</f:if>
</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>
</div>
</div>
<div class="vc-ev-r">
<f:if condition="{row.success}">
<f:then><span class="vc-pill is-ok">ok</span></f:then>
<f:else><span class="vc-pill is-bad" title="{row.message}">HTTP {row.http_code}</span></f:else>
</f:if>
<span>{row.duration_ms} ms</span>
</div>
</li>
</f:for>
</ol>
</f:then>
<f:else><p class="vc-empty">No purges logged yet. Saving a page, the context menu “Purge from SSR cache” or the buttons on the right create the first entry.</p></f:else>
</f:if>
</section>
<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>
<!-- Client errors -->
<section class="vc-card vc-s5" aria-label="Client errors">
<div class="vc-card-h">
<h2 class="vc-card-t"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 3 2 21h20zM12 10v4M12 17.5v.5"/></svg>Client errors</h2>
<span class="vc-pill is-sky" title="Reported by the React frontend via POST /api/vitec/client-error; max. 30 per minute and IP, IPs anonymized, cleaned up after 30 days by cron vitec:client-error-cleanup">kept 30 days</span>
</div>
<dl class="vc-err-sum">
<div><dt>24 h</dt><dd>{clientErrorCount24h}</dd></div>
<div><dt>Chunk reloads</dt><dd class="vc-warn-text">{chunkCount24h}</dd></div>
<div><dt>JS errors</dt><dd class="vc-bad-text">{jsErrorCount24h}</dd></div>
</dl>
<f:if condition="{clientErrors}">
<f:then>
<div class="vc-errs">
<f:for each="{clientErrors}" as="row">
<details class="vc-err">
<summary>
<f:if condition="{row.is_chunk_reload}">
<f:then><span class="vc-pill is-warn">chunk reload</span></f:then>
<f:else><span class="vc-pill is-bad">error</span></f:else>
</f:if>
<span class="vc-err-msg">{row.message}<small>{row.path}</small></span>
<span class="vc-err-t"><f:format.date format="H:i">@{row.crdate}</f:format.date></span>
</summary>
<div class="vc-err-body">
<dl class="vc-kv">
<dt>Reported</dt><dd><f:format.date format="Y-m-d H:i:s">@{row.crdate}</f:format.date> · entry #{row.uid}</dd>
<dt>Release</dt><dd><f:if condition="{row.release}"><f:then><code>{row.release}</code><f:if condition="{health.release.release}"><f:if condition="{row.release} != {health.release.release}"> <span class="vc-pill is-warn">old bundle</span></f:if></f:if></f:then><f:else><span class="vc-dim">not transmitted</span></f:else></f:if></dd>
<dt>URL</dt><dd><f:if condition="{row.url}"><f:then><a class="vc-link" href="{row.url}" target="_blank" rel="noopener">{row.url}</a></f:then><f:else><span class="vc-dim">not transmitted</span></f:else></f:if></dd>
<dt>Browser</dt><dd><f:if condition="{row.user_agent}"><f:then>{row.user_agent}</f:then><f:else><span class="vc-dim">not transmitted</span></f:else></f:if></dd>
<dt>IP</dt><dd><code>{row.ip}</code> <span class="vc-dim">anonymized</span></dd>
</dl>
<f:if condition="{row.stack}">
<f:then><pre class="vc-stack">{row.stack}</pre></f:then>
<f:else><p class="vc-dim">No stack transmitted.</p></f:else>
</f:if>
</div>
</details>
</f:for>
</div>
</f:then>
<f:else><p class="vc-empty">No client errors in the last 24 hours. The frontend reports JS errors and chunk reloads here as they happen.</p></f:else>
</f:if>
</section>
</div>
</div>
</f:section>