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:
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 & 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 & 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 /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}&limit=30">30</a>
|
||||
<a class="btn btn-default {f:if(condition: '{purgeLimit} == 100', then: 'active')}" href="{moduleUri}&limit=100">100</a>
|
||||
<a class="btn btn-default {f:if(condition: '{purgeLimit} == 500', then: 'active')}" href="{moduleUri}&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 /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}&limit=30" aria-pressed="{f:if(condition: '{purgeLimit} == 30', then: 'true', else: 'false')}">30</a>
|
||||
<a href="{moduleUri}&limit=100" aria-pressed="{f:if(condition: '{purgeLimit} == 100', then: 'true', else: 'false')}">100</a>
|
||||
<a href="{moduleUri}&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>
|
||||
|
||||
@@ -1,55 +1,229 @@
|
||||
/* VITEC SSR module (web_vitecssr): layout glue on top of TYPO3's own
|
||||
components - cards, badges, buttons and tables come from the core. */
|
||||
/* VITEC SSR cockpit (module web_vitecssr).
|
||||
Deliberately dark, in VITEC colours, independent of the backend theme
|
||||
(decision Harry, 2026-11-11). The TYPO3 frame stays core: doc header,
|
||||
flash messages, confirmation modals. Everything here is scoped to
|
||||
.vitec-cockpit or to this module's body.
|
||||
Layout: live band + availability / four gauges / traffic 8 + actions 4 /
|
||||
purge activity 7 + client errors 5; below 1200 px the wide pairs stack. */
|
||||
|
||||
.ssr-status {
|
||||
margin: 0;
|
||||
[data-module-name="web_vitecssr"] .module-body {
|
||||
--module-bg: #080c26;
|
||||
background-color: #080c26;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
[data-module-name="web_vitecssr"] .module-body-container {
|
||||
max-width: 1680px;
|
||||
}
|
||||
|
||||
.ssr-action + .ssr-action {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--typo3-component-border-color, #dee2e6);
|
||||
.vitec-cockpit {
|
||||
--vc-ink: #080c26; /* page: VITEC navy pushed to night */
|
||||
--vc-panel: #0f1640; /* card */
|
||||
--vc-panel-2: #151e52; /* raised inside a card */
|
||||
--vc-line: rgba(140, 156, 255, .14);
|
||||
--vc-line-strong: rgba(140, 156, 255, .28);
|
||||
--vc-navy: #26358c; /* VITEC blue */
|
||||
--vc-navy-hi: #4a5fd6; /* VITEC blue lifted for the dark ground */
|
||||
--vc-orange: #f47937; /* VITEC orange: actions and highlights */
|
||||
--vc-text: #e9ecfb;
|
||||
--vc-muted: #8e97c6;
|
||||
--vc-faint: #6a73a6;
|
||||
--vc-ok: #35d39a;
|
||||
--vc-warn: #ffc65c;
|
||||
--vc-bad: #ff5f73;
|
||||
--vc-sky: #6fc3ff;
|
||||
--vc-mono: var(--typo3-font-family-monospace, ui-monospace, SFMono-Regular, Consolas, monospace);
|
||||
--vc-r: 14px;
|
||||
color-scheme: dark;
|
||||
color: var(--vc-text);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.ssr-action-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 .5rem;
|
||||
.vitec-cockpit *,
|
||||
.vitec-cockpit *::before,
|
||||
.vitec-cockpit *::after { box-sizing: border-box; }
|
||||
.vitec-cockpit code { font-family: var(--vc-mono); font-size: 12px; color: #cdd4ff; background: none; padding: 0; }
|
||||
.vitec-cockpit h2, .vitec-cockpit h3 { color: inherit; }
|
||||
.vc-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
|
||||
.vc-grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 20px; }
|
||||
.vc-card { background: var(--vc-panel); border: 1px solid var(--vc-line); border-radius: var(--vc-r); padding: 20px; min-width: 0; }
|
||||
.vc-s12 { grid-column: span 12; } .vc-s8 { grid-column: span 8; } .vc-s4 { grid-column: span 4; }
|
||||
.vc-s7 { grid-column: span 7; } .vc-s5 { grid-column: span 5; } .vc-s3 { grid-column: span 3; }
|
||||
@media (max-width: 1199.98px) {
|
||||
.vc-s8, .vc-s4, .vc-s7, .vc-s5 { grid-column: span 12; }
|
||||
.vc-s3 { grid-column: span 6; }
|
||||
}
|
||||
@media (max-width: 619.98px) { .vc-s3 { grid-column: span 12; } .vc-grid { gap: 14px; } .vc-card { padding: 16px; } }
|
||||
|
||||
.vc-card-h { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.vc-card-t { font-size: 13px; font-weight: 700; line-height: 1.2; letter-spacing: .08em; text-transform: uppercase; color: var(--vc-muted) !important; margin: 0; display: flex; align-items: center; gap: 8px; }
|
||||
.vc-card-t svg { color: var(--vc-navy-hi); }
|
||||
.vc-dim { color: var(--vc-muted); font-size: 12px; }
|
||||
.vc-ok-text { color: var(--vc-ok); } .vc-bad-text { color: var(--vc-bad); } .vc-warn-text { color: var(--vc-warn); }
|
||||
.vc-link { color: var(--vc-sky); text-decoration: none; font-size: 12px; font-weight: 500; }
|
||||
.vc-link:hover, .vc-link:focus { color: var(--vc-sky); text-decoration: underline; }
|
||||
|
||||
/* Pills */
|
||||
.vc-pill { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 600; line-height: 1; letter-spacing: .04em; padding: 5px 9px; border-radius: 99px; white-space: nowrap; background: rgba(140, 156, 255, .12); color: #c4cbf0; }
|
||||
.vc-pill.is-ok { background: rgba(53, 211, 154, .14); color: var(--vc-ok); }
|
||||
.vc-pill.is-warn { background: rgba(255, 198, 92, .15); color: var(--vc-warn); }
|
||||
.vc-pill.is-bad { background: rgba(255, 95, 115, .15); color: var(--vc-bad); }
|
||||
.vc-pill.is-sky, .vc-pill.is-info { background: rgba(111, 195, 255, .14); color: var(--vc-sky); }
|
||||
.vc-pill.is-navy { background: rgba(74, 95, 214, .24); color: #b8c3ff; }
|
||||
.vc-pill.is-orange { background: rgba(244, 121, 55, .16); color: var(--vc-orange); }
|
||||
.vc-pill.is-muted { background: rgba(140, 156, 255, .1); color: var(--vc-muted); }
|
||||
|
||||
/* Live band */
|
||||
.vc-hero { padding: 24px 24px 20px; background:
|
||||
radial-gradient(120% 140% at 0% 0%, rgba(74, 95, 214, .35), transparent 55%),
|
||||
radial-gradient(80% 120% at 100% 0%, rgba(244, 121, 55, .12), transparent 50%),
|
||||
var(--vc-panel); }
|
||||
.vc-hero-top { display: flex; flex-wrap: wrap; align-items: center; gap: 18px 28px; }
|
||||
.vc-live { display: flex; align-items: center; gap: 14px; min-width: 0; }
|
||||
.vc-pulse { position: relative; width: 14px; height: 14px; border-radius: 50%; background: var(--vc-ok); flex: none; }
|
||||
.vc-pulse::after { content: ""; position: absolute; inset: -6px; border-radius: 50%; border: 2px solid currentColor; color: var(--vc-ok); opacity: 0; animation: vc-ping 2.4s ease-out infinite; }
|
||||
.vc-pulse.is-down { background: var(--vc-bad); }
|
||||
.vc-pulse.is-down::after { color: var(--vc-bad); }
|
||||
@keyframes vc-ping { 0% { transform: scale(.5); opacity: .8; } 100% { transform: scale(1.6); opacity: 0; } }
|
||||
.vc-live-title { font-size: 30px; font-weight: 800; line-height: 1.1; letter-spacing: -.01em; margin: 0; }
|
||||
.vc-live-sub { margin: 2px 0 0; color: var(--vc-muted); font-size: 13px; }
|
||||
.vc-facts { display: flex; flex-wrap: wrap; gap: 10px 28px; margin: 0 0 0 auto; }
|
||||
.vc-fact dt { font-size: 11px; font-weight: 400; letter-spacing: .08em; text-transform: uppercase; color: var(--vc-faint); }
|
||||
.vc-fact dd { margin: 2px 0 0; font-weight: 600; display: flex; align-items: center; gap: 8px; white-space: nowrap; }
|
||||
@media (max-width: 899.98px) { .vc-facts { margin-left: 0; } }
|
||||
|
||||
.vc-uptime { margin-top: 22px; }
|
||||
.vc-uptime-h { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--vc-muted); margin-bottom: 8px; }
|
||||
.vc-uptime-h b { color: var(--vc-text); font-weight: 600; }
|
||||
.vc-bars { display: grid; grid-template-columns: repeat(96, minmax(0, 1fr)); gap: 2px; height: 30px; align-items: end; }
|
||||
.vc-bars i { display: block; height: 100%; border-radius: 2px; background: var(--vc-ok); opacity: .85; transition: opacity .15s, transform .15s; }
|
||||
.vc-bars i.is-slow { background: var(--vc-warn); }
|
||||
.vc-bars i.is-down { background: var(--vc-bad); }
|
||||
.vc-bars i.is-none { background: rgba(140, 156, 255, .14); height: 40%; }
|
||||
.vc-bars i:hover { opacity: 1; transform: scaleY(1.12); }
|
||||
@media (max-width: 619.98px) { .vc-bars { gap: 1px; } }
|
||||
.vc-uptime-f { display: flex; justify-content: space-between; font: 11px var(--vc-mono); color: var(--vc-faint); margin-top: 6px; }
|
||||
|
||||
/* Gauges */
|
||||
.vc-tile { display: flex; gap: 14px; align-items: center; }
|
||||
.vc-tile .vc-ring { width: 78px; height: 78px; }
|
||||
.vc-tile-body { min-width: 0; flex: 1; }
|
||||
.vc-label { font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--vc-muted); font-weight: 600; }
|
||||
.vc-big { font-size: 32px; font-weight: 800; line-height: 1.05; margin: 6px 0 4px; font-variant-numeric: tabular-nums; letter-spacing: -.02em; white-space: nowrap; }
|
||||
.vc-big small { font-size: 15px; font-weight: 600; color: var(--vc-muted); margin-left: 4px; letter-spacing: 0; }
|
||||
.vc-sub { font-size: 12px; color: var(--vc-muted); }
|
||||
.vc-sub b { color: var(--vc-text); }
|
||||
.vc-sub .is-up { color: var(--vc-ok); } .vc-sub .is-down { color: var(--vc-bad); }
|
||||
.vc-ring { flex: none; }
|
||||
.vc-ring circle { fill: none; stroke-width: 9; }
|
||||
.vc-ring .vc-track { stroke: rgba(140, 156, 255, .12); }
|
||||
.vc-ring .vc-val { stroke-linecap: round; transform: rotate(-90deg); transform-origin: 50% 50%; stroke-dasharray: 251.33; stroke-dashoffset: 251.33; transition: stroke-dashoffset 1s cubic-bezier(.2, .8, .2, 1); }
|
||||
.vc-ring .vc-val.is-fast { transition-duration: .4s; }
|
||||
.vc-val.is-ok { stroke: var(--vc-ok); } .vc-val.is-sky { stroke: var(--vc-sky); } .vc-val.is-orange { stroke: var(--vc-orange); }
|
||||
.vc-spark { display: block; width: 100%; height: 46px; margin-top: 6px; }
|
||||
.vc-mini { display: grid; grid-template-columns: repeat(24, minmax(0, 1fr)); gap: 2px; align-items: end; height: 40px; margin-top: 8px; }
|
||||
.vc-mini i { display: block; background: var(--vc-navy-hi); border-radius: 2px 2px 0 0; min-height: 2px; }
|
||||
.vc-mini i.is-hot { background: var(--vc-bad); }
|
||||
|
||||
/* Segmented control */
|
||||
.vc-seg { display: inline-flex; background: var(--vc-panel-2); border: 1px solid var(--vc-line); border-radius: 99px; padding: 3px; }
|
||||
.vc-seg button, .vc-seg a { border: 0; background: transparent; color: var(--vc-muted); font-size: 12px; font-weight: 600; padding: 5px 12px; border-radius: 99px; cursor: pointer; text-decoration: none; line-height: 1.4; }
|
||||
.vc-seg button:hover, .vc-seg a:hover { color: var(--vc-text); text-decoration: none; }
|
||||
.vc-seg [aria-pressed="true"] { background: var(--vc-navy); color: #fff; }
|
||||
.vitec-cockpit :is(button, a, textarea, summary):focus-visible { outline: 2px solid var(--vc-orange); outline-offset: 2px; }
|
||||
|
||||
.vc-legend { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 12px; color: var(--vc-muted); }
|
||||
.vc-legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.vc-legend i { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
|
||||
.vc-legend i.is-navy { background: var(--vc-navy-hi); } .vc-legend i.is-sky { background: var(--vc-sky); }
|
||||
.vc-legend i.is-warn { background: var(--vc-warn); } .vc-legend i.is-orange { background: var(--vc-orange); }
|
||||
.vc-legend i.is-bad { background: var(--vc-bad); } .vc-legend i.is-ok { background: var(--vc-ok); }
|
||||
.vc-legend i.is-round { border-radius: 50%; }
|
||||
.vc-legend i.is-tri { border-radius: 0; clip-path: polygon(50% 0, 100% 100%, 0 100%); }
|
||||
.vc-legend-tl { margin: -6px 0 10px; }
|
||||
.vc-chart-card { display: flex; flex-direction: column; }
|
||||
.vc-chart { position: relative; flex: 1 1 auto; min-height: 320px; margin-top: 12px; overflow: hidden; }
|
||||
.vc-chart canvas { max-width: 100%; }
|
||||
@media (max-width: 619.98px) { .vc-chart { min-height: 260px; } }
|
||||
.vc-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 16px 0 0; padding-top: 16px; border-top: 1px solid var(--vc-line); }
|
||||
.vc-stats dt { font-size: 11px; font-weight: 400; color: var(--vc-faint); text-transform: uppercase; letter-spacing: .07em; }
|
||||
.vc-stats dd { margin: 2px 0 0; font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
@media (max-width: 619.98px) { .vc-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
.vc-empty { color: var(--vc-muted); font-size: 13px; margin: 12px 0 0; }
|
||||
|
||||
/* Actions */
|
||||
.vc-actions { display: flex; flex-direction: column; gap: 14px; }
|
||||
.vc-act { background: var(--vc-panel-2); border: 1px solid var(--vc-line); border-radius: 12px; padding: 16px; margin: 0; }
|
||||
.vc-act-h { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.vc-act-t { font-size: 15px; font-weight: 700; margin: 0; }
|
||||
.vc-help { margin: 8px 0 0; color: var(--vc-muted); font-size: 12.5px; }
|
||||
.vc-warm { display: flex; gap: 16px; align-items: center; margin-top: 12px; }
|
||||
.vc-warm-ring { position: relative; flex: none; width: 92px; height: 92px; }
|
||||
.vc-warm-pct { position: absolute; inset: 0; display: grid; place-items: center; font-size: 18px; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||
.vc-warm-meta { font-size: 12.5px; color: var(--vc-muted); min-width: 0; }
|
||||
.vc-warm-meta b { color: var(--vc-text); font-variant-numeric: tabular-nums; }
|
||||
.vc-btn-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
||||
.vc-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; border-radius: 9px; border: 1px solid transparent; font-size: 13px; font-weight: 700; line-height: 1.2; padding: 9px 14px; cursor: pointer; transition: transform .1s, filter .15s, background-color .15s, border-color .15s; }
|
||||
.vc-btn:active { transform: scale(.98); }
|
||||
.vc-btn.is-primary { background: var(--vc-orange); color: #1b0b02; }
|
||||
.vc-btn.is-primary:hover { filter: brightness(1.08); }
|
||||
.vc-btn.is-primary[disabled] { background: rgba(244, 121, 55, .3); color: rgba(255, 255, 255, .65); cursor: progress; filter: none; }
|
||||
.vc-btn.is-ghost { background: transparent; color: var(--vc-text); border-color: var(--vc-line-strong); }
|
||||
.vc-btn.is-ghost:hover { border-color: var(--vc-navy-hi); background: rgba(74, 95, 214, .14); }
|
||||
.vc-btn.is-danger { background: transparent; color: var(--vc-bad); border-color: rgba(255, 95, 115, .45); }
|
||||
.vc-btn.is-danger:hover { background: rgba(255, 95, 115, .15); }
|
||||
.vc-lbl { display: block; font-size: 12px; color: var(--vc-muted); margin: 12px 0 6px; font-weight: 600; }
|
||||
.vc-input { width: 100%; resize: vertical; min-height: 84px; background: var(--vc-ink); color: var(--vc-text); border: 1px solid var(--vc-line-strong); border-radius: 9px; padding: 10px 12px; font: 12.5px/1.6 var(--vc-mono); }
|
||||
.vc-input::placeholder { color: var(--vc-faint); }
|
||||
.vc-input:focus { border-color: var(--vc-navy-hi); outline: none; }
|
||||
|
||||
/* Purge activity timeline */
|
||||
.vc-timeline { list-style: none; margin: 0; padding: 0; position: relative; }
|
||||
.vc-timeline::before { content: ""; position: absolute; left: 62px; top: 6px; bottom: 6px; width: 1px; background: var(--vc-line-strong); }
|
||||
.vc-ev { display: grid; grid-template-columns: 52px 22px minmax(0, 1fr) auto; gap: 0 10px; align-items: start; padding: 10px 0; }
|
||||
.vc-ev + .vc-ev { border-top: 1px dashed rgba(140, 156, 255, .08); }
|
||||
.vc-ev time { font: 500 12px var(--vc-mono); color: var(--vc-muted); text-align: right; padding-top: 2px; }
|
||||
.vc-dot { width: 11px; height: 11px; border-radius: 50%; margin: 5px auto 0; box-shadow: 0 0 0 4px var(--vc-panel); position: relative; z-index: 1; background: var(--vc-sky); }
|
||||
.vc-dot.is-ok { background: var(--vc-ok); } .vc-dot.is-orange { background: var(--vc-orange); }
|
||||
.vc-dot.is-navy { background: var(--vc-navy-hi); } .vc-dot.is-warn { background: var(--vc-warn); }
|
||||
.vc-ev-main { min-width: 0; }
|
||||
.vc-ev-line { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; }
|
||||
.vc-paths { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px; align-items: flex-start; }
|
||||
.vc-path { font: 12px var(--vc-mono); background: rgba(140, 156, 255, .08); color: #cdd4ff; border-radius: 6px; padding: 2px 7px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.vc-more { font-size: 12px; }
|
||||
.vc-more summary { color: var(--vc-sky); cursor: pointer; list-style: none; padding: 2px 4px; }
|
||||
.vc-more summary::-webkit-details-marker { display: none; }
|
||||
.vc-more[open] { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.vc-more[open] summary { flex-basis: 100%; }
|
||||
.vc-ev-r { text-align: right; font: 500 12px var(--vc-mono); color: var(--vc-muted); white-space: nowrap; display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
|
||||
.vc-day { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--vc-faint); padding: 14px 0 4px 74px; }
|
||||
.vc-day:first-child { padding-top: 0; }
|
||||
@media (max-width: 619.98px) {
|
||||
.vc-timeline::before { left: 46px; }
|
||||
.vc-ev { grid-template-columns: 38px 18px minmax(0, 1fr); }
|
||||
.vc-ev-r { grid-column: 3; flex-direction: row; align-items: center; margin-top: 6px; }
|
||||
.vc-day { padding-left: 58px; }
|
||||
}
|
||||
|
||||
#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;
|
||||
/* Client errors */
|
||||
.vc-err-sum { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin: 0 0 16px; }
|
||||
.vc-err-sum div { background: var(--vc-panel-2); border-radius: 10px; padding: 10px 12px; min-width: 0; }
|
||||
.vc-err-sum dt { font-size: 11px; font-weight: 400; color: var(--vc-faint); text-transform: uppercase; letter-spacing: .07em; }
|
||||
.vc-err-sum dd { margin: 2px 0 0; font-size: 22px; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||
.vc-errs { display: flex; flex-direction: column; gap: 8px; }
|
||||
.vc-err { background: var(--vc-panel-2); border: 1px solid var(--vc-line); border-radius: 10px; }
|
||||
.vc-err[open] { border-color: var(--vc-line-strong); }
|
||||
.vc-err summary { list-style: none; cursor: pointer; padding: 12px 14px; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; align-items: center; }
|
||||
.vc-err summary::-webkit-details-marker { display: none; }
|
||||
.vc-err-msg { min-width: 0; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.vc-err-msg small { display: block; color: var(--vc-muted); font: 11.5px var(--vc-mono); overflow: hidden; text-overflow: ellipsis; }
|
||||
.vc-err-t { font: 12px var(--vc-mono); color: var(--vc-muted); white-space: nowrap; }
|
||||
.vc-err-body { padding: 0 14px 14px; font-size: 12.5px; }
|
||||
.vc-kv { display: grid; grid-template-columns: 76px minmax(0, 1fr); gap: 6px 12px; margin: 0; }
|
||||
.vc-kv dt { color: var(--vc-faint); font-weight: 400; }
|
||||
.vc-kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||
.vc-stack { margin: 10px 0 0; background: var(--vc-ink); border: 0; border-radius: 8px; padding: 10px 12px; font: 11.5px/1.55 var(--vc-mono); color: #b9c1ef; overflow: auto; max-height: 320px; white-space: pre; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vitec-cockpit *, .vitec-cockpit *::after { animation: none !important; transition: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* VITEC SSR cockpit (module web_vitecssr).
|
||||
* Draws the gauges, the availability strip, the traffic chart and the
|
||||
* sparkline from the JSON the controller puts on #ssr-cockpit
|
||||
* (SsrController::cockpitData), and keeps the live parts current by
|
||||
* polling the vitec_ssr_health ajax route (GET /__health of the node):
|
||||
* every 2 s while a warm run is active, every 15 s otherwise.
|
||||
* Chart.js is the copy TYPO3 ships with EXT:dashboard.
|
||||
*/
|
||||
import AjaxRequest from '@typo3/core/ajax/ajax-request.js';
|
||||
import { Chart, registerables } from '@typo3/dashboard/contrib/chartjs.js';
|
||||
|
||||
const root = document.getElementById('ssr-cockpit');
|
||||
if (root) {
|
||||
Chart.register(...registerables);
|
||||
|
||||
let data = {};
|
||||
try {
|
||||
data = JSON.parse(root.dataset.json || '{}');
|
||||
} catch (e) {
|
||||
data = {};
|
||||
}
|
||||
|
||||
const C = 2 * Math.PI * 40;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const nf = new Intl.NumberFormat('en-GB');
|
||||
const num = (v) => (typeof v === 'number' && isFinite(v) ? v : 0);
|
||||
const hhmm = (ts) => new Date(ts * 1000).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
||||
const css = getComputedStyle(root);
|
||||
const tok = (name) => css.getPropertyValue(name).trim();
|
||||
const col = {
|
||||
navy: tok('--vc-navy-hi'), sky: tok('--vc-sky'), warn: tok('--vc-warn'), bad: tok('--vc-bad'),
|
||||
orange: tok('--vc-orange'), ok: tok('--vc-ok'), faint: tok('--vc-faint'), panel: tok('--vc-panel'),
|
||||
text: tok('--vc-text'), muted: tok('--vc-muted'),
|
||||
};
|
||||
|
||||
const setRing = (el, frac) => {
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
el.style.strokeDasharray = String(C);
|
||||
el.style.strokeDashoffset = String(C * (1 - Math.max(0, Math.min(1, frac))));
|
||||
};
|
||||
|
||||
/* Hit rate gauge */
|
||||
const hit = data.hitRate;
|
||||
if (typeof hit === 'number') {
|
||||
$('vc-hit').textContent = hit.toFixed(1);
|
||||
requestAnimationFrame(() => setRing($('vc-ring-hit'), hit / 100));
|
||||
if (typeof data.hitRatePrev === 'number') {
|
||||
const d = Math.round((hit - data.hitRatePrev) * 10) / 10;
|
||||
const cls = d >= 0 ? 'is-up' : 'is-down';
|
||||
$('vc-hit-trend').innerHTML = '<span class="' + cls + '">' + (d >= 0 ? '▲ ' : '▼ ') + Math.abs(d).toFixed(1)
|
||||
+ ' pts</span> vs. the 24 h before';
|
||||
}
|
||||
} else {
|
||||
$('vc-hit-trend').textContent = 'no traffic counters yet';
|
||||
}
|
||||
|
||||
/* Response time */
|
||||
if (data.msMedian !== null && data.msMedian !== undefined) {
|
||||
$('vc-ms-median').textContent = data.msMedian + ' ms';
|
||||
$('vc-ms-p95').textContent = data.msP95 + ' ms';
|
||||
}
|
||||
const drawSpark = () => {
|
||||
const sp = (data.spark || []).map((p) => p[1]);
|
||||
const el = $('vc-spark');
|
||||
if (!el || sp.length < 2) {
|
||||
return;
|
||||
}
|
||||
const w = 300;
|
||||
const h = 46;
|
||||
const max = Math.max(...sp) * 1.08;
|
||||
const min = Math.min(...sp) * 0.85;
|
||||
const span = Math.max(1, max - min);
|
||||
const pts = sp.map((v, i) => [(i / (sp.length - 1)) * w, h - ((v - min) / span) * h]);
|
||||
const d = pts.map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' ');
|
||||
const last = pts[pts.length - 1];
|
||||
el.innerHTML = '<defs><linearGradient id="vc-sg" x1="0" y1="0" x2="0" y2="1">'
|
||||
+ '<stop offset="0" stop-color="' + col.orange + '" stop-opacity=".45"/>'
|
||||
+ '<stop offset="1" stop-color="' + col.orange + '" stop-opacity="0"/></linearGradient></defs>'
|
||||
+ '<path d="' + d + ' L' + w + ' ' + h + ' L0 ' + h + 'Z" fill="url(#vc-sg)"/>'
|
||||
+ '<path d="' + d + '" fill="none" stroke="' + col.orange + '" stroke-width="2" vector-effect="non-scaling-stroke"/>'
|
||||
+ '<circle cx="' + last[0].toFixed(1) + '" cy="' + last[1].toFixed(1) + '" r="3.5" fill="' + col.orange
|
||||
+ '" stroke="' + col.panel + '" stroke-width="2"/>';
|
||||
};
|
||||
drawSpark();
|
||||
|
||||
/* Cache fill */
|
||||
const renderCache = (pages, mb) => {
|
||||
const limit = num(data.cacheLimitMb);
|
||||
if (typeof pages === 'number') {
|
||||
$('vc-cache-pages').textContent = nf.format(pages);
|
||||
}
|
||||
if (typeof mb === 'number') {
|
||||
$('vc-cache-mb').textContent = nf.format(mb);
|
||||
if (limit > 0) {
|
||||
setRing($('vc-ring-cache'), mb / limit);
|
||||
$('vc-cache-pct').textContent = ' · ' + Math.round((mb / limit) * 100) + ' % used';
|
||||
}
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(() => renderCache(data.cachedPages, data.cacheMb));
|
||||
|
||||
/* Client errors per hour */
|
||||
const mini = $('vc-mini');
|
||||
const eh = data.errHours || [];
|
||||
const ehMax = Math.max(1, ...eh);
|
||||
eh.forEach((v, i) => {
|
||||
const b = document.createElement('i');
|
||||
b.style.height = (v ? Math.max(14, (v / ehMax) * 100) : 5) + '%';
|
||||
if (v > 0 && v >= ehMax && v >= 3) {
|
||||
b.className = 'is-hot';
|
||||
}
|
||||
b.title = (23 - i === 0 ? 'this hour' : (23 - i) + ' h ago') + ': ' + v + ' report' + (v === 1 ? '' : 's');
|
||||
mini.appendChild(b);
|
||||
});
|
||||
|
||||
/* Availability strip */
|
||||
const strip = data.strip || { w: [], end: data.now, slot: 900 };
|
||||
const bars = $('vc-bars');
|
||||
const stripStart = strip.end - 96 * strip.slot;
|
||||
(strip.w || []).forEach((w, i) => {
|
||||
const b = document.createElement('i');
|
||||
const t = stripStart + i * strip.slot;
|
||||
let label = 'no sample';
|
||||
if (w === null) {
|
||||
b.className = 'is-none';
|
||||
} else if (w.s === 2) {
|
||||
b.className = 'is-down';
|
||||
label = 'down';
|
||||
} else if (w.s === 1) {
|
||||
b.className = 'is-slow';
|
||||
label = 'slow, ' + w.ms + ' ms';
|
||||
} else {
|
||||
label = 'ok, ' + w.ms + ' ms';
|
||||
b.style.height = Math.round(78 + Math.min(22, (w.ms / 200) * 22)) + '%';
|
||||
}
|
||||
b.title = hhmm(t) + '–' + hhmm(t + strip.slot) + ' · ' + label;
|
||||
bars.appendChild(b);
|
||||
});
|
||||
const axis = $('vc-uptime-axis');
|
||||
[0, 24, 48, 72, 96].forEach((i, k) => {
|
||||
const s = document.createElement('span');
|
||||
s.textContent = k === 4 ? 'now' : hhmm(stripStart + i * strip.slot);
|
||||
axis.appendChild(s);
|
||||
});
|
||||
if (typeof data.uptimePct === 'number') {
|
||||
const slow = num(data.slowWindows);
|
||||
const down = num(data.downWindows);
|
||||
$('vc-uptime-sum').innerHTML = '<b>' + data.uptimePct.toFixed(1) + ' %</b> up · ' + slow + ' slow window'
|
||||
+ (slow === 1 ? '' : 's') + ' · ' + down + ' outage' + (down === 1 ? '' : 's');
|
||||
} else {
|
||||
$('vc-uptime-sum').textContent = 'no samples yet';
|
||||
}
|
||||
|
||||
/* Traffic chart */
|
||||
let chart = null;
|
||||
const label = (ts, size) => {
|
||||
const d = new Date(ts * 1000);
|
||||
const hm = d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
||||
return size >= 14400 ? d.toLocaleDateString('en-GB', { weekday: 'short' }) + ' ' + hm : hm;
|
||||
};
|
||||
const draw = (key) => {
|
||||
const range = (data.range || {})[key];
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
const b = range.b;
|
||||
const sum = (k) => b.reduce((a, x) => a + num(x[k]), 0);
|
||||
const h = sum('hit');
|
||||
const st = sum('stale');
|
||||
const m = sum('miss');
|
||||
const total = h + st + m;
|
||||
$('vc-st-req').textContent = total ? nf.format(total + sum('bypass')) : '–';
|
||||
$('vc-st-hit').textContent = total ? (((h + st) / total) * 100).toFixed(1) + ' %' : '–';
|
||||
$('vc-st-err').textContent = nf.format(sum('errors'));
|
||||
$('vc-st-purge').textContent = nf.format(sum('purges'));
|
||||
const hasMs = b.some((x) => x.ms !== null);
|
||||
$('vc-traffic-empty').hidden = total > 0 || hasMs;
|
||||
|
||||
const maxY = Math.max(1, ...b.map((x) => num(x.hit) + num(x.stale) + num(x.miss)));
|
||||
const maxMs = Math.max(100, ...b.map((x) => num(x.ms)));
|
||||
const tick = { color: col.faint, font: { size: 11 } };
|
||||
const cfg = {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: b.map((x) => label(x.t, range.size)),
|
||||
datasets: [
|
||||
{ label: 'Served from cache', data: b.map((x) => x.hit), backgroundColor: col.navy, stack: 'r', yAxisID: 'y', order: 3 },
|
||||
{ label: 'Stale, refreshed in background', data: b.map((x) => x.stale), backgroundColor: col.sky, stack: 'r', yAxisID: 'y', order: 3 },
|
||||
{ label: 'Rendered fresh', data: b.map((x) => x.miss), backgroundColor: col.warn, stack: 'r', borderRadius: { topLeft: 3, topRight: 3 }, yAxisID: 'y', order: 3 },
|
||||
{
|
||||
type: 'line', label: 'Response time', data: b.map((x) => x.ms), borderColor: col.orange, borderWidth: 2,
|
||||
pointRadius: 0, pointHoverRadius: 4, pointBackgroundColor: col.orange, tension: 0.35, spanGaps: true, yAxisID: 'y1', order: 1,
|
||||
},
|
||||
{
|
||||
type: 'line', label: 'Purge', data: b.map((x) => (x.purges ? maxY * 1.06 : null)), showLine: false,
|
||||
pointStyle: 'triangle', pointRadius: 7, pointHoverRadius: 9, pointBackgroundColor: col.bad, pointBorderColor: col.panel,
|
||||
yAxisID: 'y', order: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 600 },
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: '#0b1131', borderColor: 'rgba(140,156,255,.3)', borderWidth: 1,
|
||||
titleColor: col.text, bodyColor: '#c4cbf0', padding: 10,
|
||||
filter: (it) => it.raw !== null,
|
||||
callbacks: {
|
||||
label: (c) => {
|
||||
if (c.dataset.label === 'Purge') {
|
||||
const x = b[c.dataIndex];
|
||||
return ' ' + x.purges + ' purge' + (x.purges === 1 ? '' : 's') + (x.purgeAll ? ', ' + x.purgeAll + ' of them everything' : '');
|
||||
}
|
||||
if (c.dataset.label === 'Response time') {
|
||||
return ' Response time: ' + c.raw + ' ms';
|
||||
}
|
||||
return ' ' + c.dataset.label + ': ' + nf.format(c.raw);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: { stacked: true, grid: { display: false }, ticks: { ...tick, maxRotation: 0, autoSkip: true, maxTicksLimit: 8 }, border: { color: 'rgba(140,156,255,.2)' } },
|
||||
y: {
|
||||
stacked: true, beginAtZero: true, suggestedMax: maxY * 1.12, grid: { color: 'rgba(140,156,255,.08)' }, ticks: tick,
|
||||
border: { display: false }, title: { display: true, text: 'requests', color: col.faint },
|
||||
},
|
||||
y1: {
|
||||
position: 'right', beginAtZero: true, suggestedMax: Math.ceil(maxMs * 1.3), grid: { display: false },
|
||||
ticks: { ...tick, color: col.orange, callback: (v) => v + ' ms' }, border: { display: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
}
|
||||
chart = new Chart($('vc-traffic'), cfg);
|
||||
};
|
||||
Chart.defaults.font.family = getComputedStyle(document.body).fontFamily;
|
||||
// Draw once the stylesheet has laid out the grid, and follow the box
|
||||
// afterwards: Chart.js alone kept the width it saw on first paint.
|
||||
const box = $('vc-traffic').parentElement;
|
||||
const start = () => {
|
||||
draw('24h');
|
||||
if (window.ResizeObserver) {
|
||||
let size = box.clientWidth + 'x' + box.clientHeight;
|
||||
new ResizeObserver(() => {
|
||||
const now = box.clientWidth + 'x' + box.clientHeight;
|
||||
if (chart && now !== size) {
|
||||
size = now;
|
||||
chart.resize();
|
||||
}
|
||||
}).observe(box);
|
||||
}
|
||||
};
|
||||
if (document.readyState === 'complete') {
|
||||
requestAnimationFrame(start);
|
||||
} else {
|
||||
window.addEventListener('load', () => requestAnimationFrame(start), { once: true });
|
||||
}
|
||||
root.querySelectorAll('.vc-seg button[data-range]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
root.querySelectorAll('.vc-seg button[data-range]').forEach((x) => x.setAttribute('aria-pressed', x === btn ? 'true' : 'false'));
|
||||
draw(btn.dataset.range);
|
||||
});
|
||||
});
|
||||
|
||||
/* Warm cache + live status */
|
||||
const warmBtn = $('ssr-warm-button');
|
||||
const warmLabel = $('vc-warm-label');
|
||||
const warmPct = $('vc-warm-pct');
|
||||
const warmMeta = $('vc-warm-meta');
|
||||
const warmPill = $('vc-warm-pill');
|
||||
const warmRing = $('vc-ring-warm');
|
||||
const pill = (cls, text) => {
|
||||
warmPill.hidden = !text;
|
||||
warmPill.className = 'vc-pill ' + cls;
|
||||
warmPill.textContent = text || '';
|
||||
};
|
||||
const renderWarm = (w) => {
|
||||
w = w || {};
|
||||
const queued = num(w.queued);
|
||||
const handled = num(w.done) + num(w.skipped) + num(w.failed);
|
||||
const failed = num(w.failed);
|
||||
const failedHtml = failed ? '<span class="vc-bad-text">' + failed + ' failed</span>' : '0 failed';
|
||||
if (w.running) {
|
||||
const f = queued > 0 ? handled / queued : 0;
|
||||
setRing(warmRing, f);
|
||||
warmPct.textContent = Math.round(f * 100) + ' %';
|
||||
warmMeta.innerHTML = '<b>' + nf.format(handled) + '</b> of ' + nf.format(queued) + ' pages<br>'
|
||||
+ num(w.skipped) + ' still fresh · ' + failedHtml + '<br>live, every 2 seconds';
|
||||
pill('is-sky', 'running');
|
||||
} else if (w.startedAt) {
|
||||
setRing(warmRing, queued > 0 ? handled / queued : 1);
|
||||
warmPct.textContent = failed || w.aborted ? '!' : '✓';
|
||||
const started = new Date(w.startedAt);
|
||||
const secs = w.finishedAt ? Math.round((new Date(w.finishedAt) - started) / 1000) : null;
|
||||
const took = secs === null ? '' : '<br>took ' + (secs >= 120 ? Math.round(secs / 60) + ' min' : secs + ' s');
|
||||
warmMeta.innerHTML = 'Last run ' + started.toLocaleString('en-GB', { weekday: 'short', hour: '2-digit', minute: '2-digit' })
|
||||
+ '<br><b>' + nf.format(num(w.done)) + '</b> of ' + nf.format(queued) + ' rendered · '
|
||||
+ num(w.skipped) + ' still fresh · ' + failedHtml + took;
|
||||
pill(w.aborted ? 'is-warn' : (failed ? 'is-bad' : 'is-ok'), w.aborted ? 'aborted by restart' : (failed ? 'with errors' : 'done'));
|
||||
} else {
|
||||
setRing(warmRing, 0);
|
||||
warmPct.textContent = '–';
|
||||
warmMeta.textContent = 'No run since the server started.';
|
||||
pill('', '');
|
||||
}
|
||||
if (warmBtn) {
|
||||
warmBtn.disabled = !!w.running;
|
||||
warmLabel.textContent = w.running ? 'Warming …' : 'Warm cache';
|
||||
}
|
||||
return !!w.running;
|
||||
};
|
||||
requestAnimationFrame(() => renderWarm(data.warming));
|
||||
|
||||
const renderOnline = (online) => {
|
||||
const p = $('vc-pulse');
|
||||
const t = $('vc-live-title');
|
||||
if (!p || !t) {
|
||||
return;
|
||||
}
|
||||
p.classList.toggle('is-down', !online);
|
||||
t.innerHTML = online ? 'SSR <span class="vc-ok-text">online</span>' : 'SSR <span class="vc-bad-text">not reachable</span>';
|
||||
};
|
||||
|
||||
let timer = null;
|
||||
const schedule = (ms) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(poll, ms);
|
||||
};
|
||||
const poll = () => {
|
||||
new AjaxRequest(TYPO3.settings.ajaxUrls.vitec_ssr_health).get()
|
||||
.then((response) => response.resolve())
|
||||
.then((res) => {
|
||||
const h = res && res.health;
|
||||
renderOnline(!!h);
|
||||
let running = false;
|
||||
if (h) {
|
||||
running = renderWarm(h.warming);
|
||||
renderCache(h.cachedPages, h.cacheMB);
|
||||
if (typeof h.responseMs === 'number') {
|
||||
$('vc-ms').textContent = h.responseMs;
|
||||
}
|
||||
}
|
||||
schedule(running ? 2000 : 15000);
|
||||
})
|
||||
.catch(() => schedule(15000));
|
||||
};
|
||||
schedule(data.warming && data.warming.running ? 1000 : 15000);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
clearTimeout(timer);
|
||||
} else {
|
||||
poll();
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user