SSR cockpit and dashboard tiles: info button with tooltip

Every tile of the module and the three SSR dashboard widgets has a round question mark button top right. Hovering it or focusing it with the keyboard shows a tooltip that explains what the tile shows. Pure CSS, anchored to the card so it never leaves the tile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Opus 5.5 committed 2026-11-11 11:05:33 +01:00
1 parent 62f3f5394e
commit 994f9f2a1b
7 files changed
+110 -6

No files matched your search

+13 -2
View File
@@ -24,9 +24,20 @@ final class CockpitHtml
return htmlspecialchars($value, ENT_QUOTES); return htmlspecialchars($value, ENT_QUOTES);
} }
public static function wrap(string $inner): string /**
* Tile wrapper; $info becomes the round "?" button top right whose
* tooltip explains the tile (CSS only: hover or keyboard focus).
*/
public static function wrap(string $inner, string $info = '', string $id = ''): string
{ {
return '<div class="vc-widget">' . $inner . '</div>'; $button = '';
if ($info !== '') {
$tipId = 'vcw-tip-' . ($id !== '' ? $id : substr(md5($info), 0, 8));
$button = '<span class="vcw-info"><button type="button" class="vcw-info-btn" aria-label="About this tile"'
. ' aria-describedby="' . $tipId . '">?</button><span class="vcw-tip" role="tooltip" id="' . $tipId . '">'
. self::e($info) . '</span></span>';
}
return '<div class="vc-widget">' . $button . $inner . '</div>';
} }
public static function pill(string $tone, string $text, string $title = ''): string public static function pill(string $tone, string $text, string $title = ''): string
@@ -33,7 +33,8 @@ final class SsrErrorsWidget implements WidgetInterface, AdditionalCssInterface
if ($latest === null) { if ($latest === null) {
return $h::wrap('<p class="vcw-dim">No /__stats samples yet. They arrive with the next ' return $h::wrap('<p class="vcw-dim">No /__stats samples yet. They arrive with the next '
. '<code>vitec:ssr-health</code> cron run (every 5 minutes) once the node release ' . '<code>vitec:ssr-health</code> cron run (every 5 minutes) once the node release '
. 'provides <code>/__stats</code>.</p>'); . 'provides <code>/__stats</code>.</p>', 'Cache hit rate and error counters of the SSR node, '
. 'filled by the health cron every 5 minutes.', 'errors');
} }
$hit = (int)$latest['stat_hit']; $hit = (int)$latest['stat_hit'];
@@ -76,7 +77,11 @@ final class SsrErrorsWidget implements WidgetInterface, AdditionalCssInterface
. '</div>'; . '</div>';
$html .= '<p class="vcw-note">Totals count since the node last started; a deploy resets them. ' $html .= '<p class="vcw-note">Totals count since the node last started; a deploy resets them. '
. 'Error details: <code>journalctl -u vitec-ssr</code> on the VPS.</p>'; . 'Error details: <code>journalctl -u vitec-ssr</code> on the VPS.</p>';
return $h::wrap($html); return $h::wrap($html, 'How well the SSR cache works and whether the node has problems. The ring is the '
. 'share of requests answered from the cache since the node last started; the bar splits them into '
. 'served from cache, stale and refreshed in the background, and rendered fresh. Render errors are pages '
. 'the node could not render, backend errors are failed requests to TYPO3. Down samples are failed '
. 'health checks in the last 24 hours.', 'errors');
} }
public function getOptions(): array public function getOptions(): array
@@ -66,7 +66,10 @@ final class SsrStatsWidget implements WidgetInterface, AdditionalCssInterface
$html .= '<p class="vcw-dim">No samples yet: the cron job <code>vitec:ssr-health</code> fills this every 5 minutes.</p>'; $html .= '<p class="vcw-dim">No samples yet: the cron job <code>vitec:ssr-health</code> fills this every 5 minutes.</p>';
} }
$html .= '<div class="vcw-foot">' . $h::moduleLink('Charts and protocol in the cockpit') . '</div>'; $html .= '<div class="vcw-foot">' . $h::moduleLink('Charts and protocol in the cockpit') . '</div>';
return $h::wrap($html); return $h::wrap($html, 'Availability of the SSR node over the last 24 hours, one bar per 15 minutes: '
. 'green ok, yellow slower than 400 ms, red down, grey no sample. Below are counts from the purge and '
. 'health logs: purges, full purges and failed purges, average response time, the largest cache size '
. 'and the browser errors reported by the frontend.', 'stats');
} }
public function getOptions(): array public function getOptions(): array
@@ -84,7 +84,10 @@ final class SsrStatusWidget implements WidgetInterface, AdditionalCssInterface
} }
$html .= '<div class="vcw-foot">' . $h::moduleLink() . '</div>'; $html .= '<div class="vcw-foot">' . $h::moduleLink() . '</div>';
return $h::wrap($html); return $h::wrap($html, 'Live state of the SSR node on the VPS: whether it answers and how fast, '
. 'how full its page cache is against the limit, whether SSR delivery is switched on, which frontend '
. 'release runs, and the most recent cache purge. The link opens the cockpit with charts, protocol '
. 'and the purge and warm buttons.', 'status');
} }
public function getOptions(): array public function getOptions(): array
@@ -14,6 +14,7 @@
<!-- Live band: node state, release, delivery switch, 24 h availability --> <!-- Live band: node state, release, delivery switch, 24 h availability -->
<section class="vc-card vc-hero vc-s12" aria-label="Live status"> <section class="vc-card vc-hero vc-s12" aria-label="Live status">
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-live">?</button><span class="vc-tip" role="tooltip" id="vc-tip-live">Live state of the SSR node on the VPS. The dot pulses green while the node answers its health check. Delivery shows whether visitors get server-rendered pages; the switch is in the Site settings. The strip shows the last 24 hours in 15-minute windows: green ok, yellow slower than 400 ms, red down, grey no sample. After {failoverAfter} failed checks in a row the cron switches back to the SPA and sends an alert mail.</span></span>
<div class="vc-hero-top"> <div class="vc-hero-top">
<div class="vc-live"> <div class="vc-live">
<f:if condition="{health}"> <f:if condition="{health}">
@@ -84,6 +85,7 @@
<!-- Gauges --> <!-- Gauges -->
<section class="vc-card vc-s3" aria-label="Cache hit rate"> <section class="vc-card vc-s3" aria-label="Cache hit rate">
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-hit">?</button><span class="vc-tip" role="tooltip" id="vc-tip-hit">Share of page requests in the last 24 hours that the node answered from its cache, either fresh or stale and refreshed in the background. All others had to be rendered first. Higher is better. It drops after a full purge until the cache is warm again. The arrow compares with the 24 hours before.</span></span>
<div class="vc-tile"> <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> <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-tile-body">
@@ -95,6 +97,7 @@
</section> </section>
<section class="vc-card vc-s3" aria-label="Response time"> <section class="vc-card vc-s3" aria-label="Response time">
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-ms">?</button><span class="vc-tip" role="tooltip" id="vc-tip-ms">How long TYPO3 needs to reach the node, measured when this page loads and every 15 seconds after. Median and p95 come from the cron samples of the last 24 hours, taken every 5 minutes. The curve shows the last 4 hours. Values above 400 ms turn the availability strip yellow.</span></span>
<div class="vc-label">Response time</div> <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-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> <div class="vc-sub">median 24 h <b id="vc-ms-median">–</b> · p95 <b id="vc-ms-p95">–</b></div>
@@ -102,6 +105,7 @@
</section> </section>
<section class="vc-card vc-s3" aria-label="Cache fill"> <section class="vc-card vc-s3" aria-label="Cache fill">
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-cache">?</button><span class="vc-tip" role="tooltip" id="vc-tip-cache">How much of the node page cache is in use: pages and megabytes against the configured limit (SSR_CACHE_LIMIT_MB). When the cache is full, the pages used least recently drop out. After “Purge everything” it falls to almost zero until “Warm cache” fills it again.</span></span>
<div class="vc-tile"> <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> <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-tile-body">
@@ -113,6 +117,7 @@
</section> </section>
<section class="vc-card vc-s3" aria-label="Client errors"> <section class="vc-card vc-s3" aria-label="Client errors">
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-errtile">?</button><span class="vc-tip" role="tooltip" id="vc-tip-errtile">Errors that visitor browsers reported from the React frontend in the last 24 hours. A chunk reload happens when an open tab still runs an older bundle after a deploy; that is usually harmless. JS errors point to a real bug. The bars show reports per hour, details are in the Client errors card below.</span></span>
<div class="vc-label">Client errors · 24 h</div> <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-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-sub">{chunkCount24h} chunk reload<f:if condition="{chunkCount24h} != 1">s</f:if> · {jsErrorCount24h} JS error<f:if condition="{jsErrorCount24h} != 1">s</f:if></div>
@@ -121,6 +126,7 @@
<!-- Traffic chart --> <!-- Traffic chart -->
<section class="vc-card vc-s8 vc-chart-card" aria-label="Traffic and cache"> <section class="vc-card vc-s8 vc-chart-card" aria-label="Traffic and cache">
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-traffic">?</button><span class="vc-tip" role="tooltip" id="vc-tip-traffic">Requests per hour, or per 4 hours in the 7-day view, split by how the node answered: served from cache, stale and refreshed in the background, or rendered fresh. The orange line is the response time, red triangles mark purges. Spikes of fresh renders usually follow a purge or a warm run. Source: node counters sampled every 5 minutes.</span></span>
<div class="vc-card-h"> <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> <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"> <div class="vc-seg" role="group" aria-label="Time range">
@@ -147,6 +153,7 @@
<!-- Actions --> <!-- Actions -->
<section class="vc-card vc-s4" aria-label="Actions"> <section class="vc-card vc-s4" aria-label="Actions">
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-actions">?</button><span class="vc-tip" role="tooltip" id="vc-tip-actions">Warm cache renders every sitemap page in advance, and the ring shows the progress live. Purge paths drops single pages from the cache, for example after a change the auto-purge did not catch. Purge everything empties the whole cache, which is needed after menu or footer changes and after a deploy; run Warm cache right after. Both heavy actions ask for confirmation.</span></span>
<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-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"> <div class="vc-actions">
@@ -193,6 +200,7 @@
<!-- Purge activity --> <!-- Purge activity -->
<section class="vc-card vc-s7" aria-label="Purge activity"> <section class="vc-card vc-s7" aria-label="Purge activity">
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-activity">?</button><span class="vc-tip" role="tooltip" id="vc-tip-activity">Every cache purge, newest first. The colour shows the trigger: auto-purge when an editor saves, manual from this module, context menu from the page tree, cli for commands and deploys, switch-on when SSR delivery was turned on. Result and duration are on the right. Switch between 30, 100 and 500 entries.</span></span>
<div class="vc-card-h"> <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> <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"> <div class="vc-seg" role="group" aria-label="Entries to show">
@@ -253,6 +261,7 @@
<!-- Client errors --> <!-- Client errors -->
<section class="vc-card vc-s5" aria-label="Client errors"> <section class="vc-card vc-s5" aria-label="Client errors">
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-errors">?</button><span class="vc-tip" role="tooltip" id="vc-tip-errors">Every report from visitor browsers in the last 24 hours, newest first. Open an entry for release, URL, browser, anonymized IP and stack trace. “old bundle” means the tab ran an older release than the node. Reports are limited to 30 per minute and IP and are kept for 30 days.</span></span>
<div class="vc-card-h"> <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> <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> <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>
@@ -224,6 +224,44 @@
.vc-kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; } .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; } .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; }
/* Info button with tooltip, top right in every card (pure CSS: hover
or keyboard focus shows the text). */
.vc-card { position: relative; }
.vc-card-h { padding-right: 34px; }
.vc-hero-top { padding-right: 34px; }
/* Button and tooltip are placed against the card, so the tooltip never
gets wider than its card or leaves the module at the left edge. */
.vc-info-btn {
position: absolute; top: 14px; right: 14px; z-index: 21;
display: grid; place-items: center; width: 24px; height: 24px; padding: 0;
border-radius: 50%; border: 1px solid var(--vc-line-strong);
background: rgba(140, 156, 255, .1); color: var(--vc-muted);
font-size: 13px; font-weight: 800; line-height: 1; cursor: help;
transition: color .15s, border-color .15s, background-color .15s;
}
.vc-info:hover .vc-info-btn, .vc-info:focus-within .vc-info-btn {
color: #fff; border-color: var(--vc-orange); background: rgba(244, 121, 55, .22);
}
.vc-tip {
position: absolute; top: 48px; right: 8px; z-index: 20; width: min(320px, calc(100% - 16px));
padding: 12px 14px; border-radius: 10px;
background: #1a2460; border: 1px solid var(--vc-navy-hi);
box-shadow: 0 14px 36px rgba(0, 0, 0, .5);
color: var(--vc-text); font-size: 12.5px; font-weight: 400; line-height: 1.55;
letter-spacing: 0; text-transform: none; text-align: left; white-space: normal;
opacity: 0; visibility: hidden; transform: translateY(-4px);
transition: opacity .15s, transform .15s, visibility 0s linear .15s;
}
.vc-tip::before {
content: ""; position: absolute; top: -6px; right: 13px; width: 10px; height: 10px;
background: #1a2460; border-left: 1px solid var(--vc-navy-hi); border-top: 1px solid var(--vc-navy-hi);
transform: rotate(45deg);
}
.vc-tip::after { content: ""; position: absolute; top: -14px; left: 0; right: 0; height: 14px; }
.vc-info:hover .vc-tip, .vc-info:focus-within .vc-tip {
opacity: 1; visibility: visible; transform: none; transition-delay: 0s;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.vitec-cockpit *, .vitec-cockpit *::after { animation: none !important; transition: none !important; } .vitec-cockpit *, .vitec-cockpit *::after { animation: none !important; transition: none !important; }
} }
@@ -125,6 +125,41 @@ circle.vcw-val.is-ok { stroke: var(--vcw-ok); } circle.vcw-val.is-sky { stroke:
.vcw-link:hover, .vcw-link:focus { color: var(--vcw-orange); text-decoration: underline; } .vcw-link:hover, .vcw-link:focus { color: var(--vcw-orange); text-decoration: underline; }
.vc-widget a:focus-visible { outline: 2px solid var(--vcw-orange); outline-offset: 2px; } .vc-widget a:focus-visible { outline: 2px solid var(--vcw-orange); outline-offset: 2px; }
/* Info button with tooltip, top right (pure CSS: hover or keyboard focus). */
.vc-widget { position: relative; }
.vc-widget > .vcw-info + * { padding-right: 32px; }
.vcw-info-btn {
position: absolute; top: 12px; right: 14px; z-index: 6;
display: grid; place-items: center; width: 24px; height: 24px; padding: 0;
border-radius: 50%; border: 1px solid rgba(140, 156, 255, .28);
background: rgba(140, 156, 255, .1); color: var(--vcw-muted);
font-size: 13px; font-weight: 800; line-height: 1; cursor: help;
transition: color .15s, border-color .15s, background-color .15s;
}
.vcw-info:hover .vcw-info-btn, .vcw-info:focus-within .vcw-info-btn {
color: #fff; border-color: var(--vcw-orange); background: rgba(244, 121, 55, .22);
}
.vcw-info-btn:focus-visible { outline: 2px solid var(--vcw-orange); outline-offset: 2px; }
.vcw-tip {
position: absolute; top: 46px; right: 8px; z-index: 5; width: min(320px, calc(100% - 16px));
padding: 12px 14px; border-radius: 10px;
background: #1a2460; border: 1px solid var(--vcw-navy);
box-shadow: 0 14px 36px rgba(0, 0, 0, .5);
color: var(--vcw-text); font-size: 12.5px; font-weight: 400; line-height: 1.55; text-align: left;
opacity: 0; visibility: hidden; transform: translateY(-4px);
transition: opacity .15s, transform .15s, visibility 0s linear .15s;
}
.vcw-tip::before {
content: ""; position: absolute; top: -6px; right: 13px; width: 10px; height: 10px;
background: #1a2460; border-left: 1px solid var(--vcw-navy); border-top: 1px solid var(--vcw-navy);
transform: rotate(45deg);
}
.vcw-tip::after { content: ""; position: absolute; top: -14px; left: 0; right: 0; height: 14px; }
.vcw-info:hover .vcw-tip, .vcw-info:focus-within .vcw-tip {
opacity: 1; visibility: visible; transform: none; transition-delay: 0s;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.vcw-pulse::after { animation: none; } .vcw-pulse::after { animation: none; }
.vcw-tip, .vcw-info-btn { transition: none; }
} }