Files
orascheandClaude Opus 5.5 994f9f2a1b 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>
2026-11-11 11:05:33 +01:00

172 lines
8.0 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Widgets;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Dashboard\Widgets\AdditionalCssInterface;
use TYPO3\CMS\Dashboard\Widgets\WidgetConfigurationInterface;
use TYPO3\CMS\Dashboard\Widgets\WidgetInterface;
/**
* Dashboard tile: SSR error counters and cache hit rate.
*
* Source: the cumulative /__stats counters of the SSR node, sampled every
* 5 minutes by vitec:ssr-health into tx_vitec_ssrhealthlog (stat_* columns,
* -1 = no data for that sample). The counters reset whenever the node
* restarts (deploy!), so "totals" are "since node start" and the 24h error
* figures are sums of positive deltas between consecutive samples - a
* reset simply contributes nothing.
*/
final class SsrErrorsWidget implements WidgetInterface, AdditionalCssInterface
{
public function __construct(
private readonly WidgetConfigurationInterface $configuration,
private readonly ConnectionPool $connectionPool,
) {}
public function renderWidgetContent(): string
{
$h = CockpitHtml::class;
$latest = $this->latestStatsRow();
if ($latest === null) {
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 '
. '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'];
$stale = (int)$latest['stat_stale'];
$miss = (int)$latest['stat_miss'];
$served = $hit + $miss + $stale;
$rate = $served > 0 ? ($hit + $stale) / $served : null;
[$newRender24h, $newBackend24h] = $this->errorDeltas24h();
$down24h = $this->downSamples24h();
// Hit rate ring + split bar hit / stale / miss
$pct = static fn(int $v): string => $served > 0 ? round($v / $served * 100, 1) . '%' : '0%';
$html = '<div class="vcw-gauge vcw-gauge-wide">'
. $h::ring($rate ?? 0.0, 'ok', $rate === null ? '–' : number_format($rate * 100, 1) . ' %', 84)
. '<div class="vcw-grow"><div class="vcw-label">Cache hit rate</div>'
. '<div class="vcw-sub">since the node started · ' . number_format($served, 0, '.', ',') . ' requests</div>'
. '<div class="vcw-split-bar" role="img" aria-label="Share of requests by cache result">'
. '<i class="is-navy" style="width:' . $pct($hit) . '" title="served from cache: ' . $hit . '"></i>'
. '<i class="is-sky" style="width:' . $pct($stale) . '" title="stale, refreshed in background: ' . $stale . '"></i>'
. '<i class="is-warn" style="width:' . $pct($miss) . '" title="rendered fresh: ' . $miss . '"></i></div>'
. '<div class="vcw-legend"><span><i class="is-navy"></i>cache ' . $hit . '</span>'
. '<span><i class="is-sky"></i>stale ' . $stale . '</span><span><i class="is-warn"></i>fresh ' . $miss . '</span></div>'
. '</div></div>';
$tile = static fn(string $label, string $value, string $sub, string $tone): string =>
'<div class="vcw-tile"><div class="vcw-label">' . $h::e($label) . '</div><div class="vcw-val ' . $tone . '">'
. $value . '</div><div class="vcw-sub">' . $sub . '</div></div>';
$newCell = static fn(?int $new): array => $new === null
? ['n/a', '']
: ['+' . $new, $new > 0 ? 'vcw-bad' : 'vcw-ok'];
[$renderVal, $renderTone] = $newCell($newRender24h);
[$backendVal, $backendTone] = $newCell($newBackend24h);
$html .= '<div class="vcw-tiles">'
. $tile('Render errors', $renderVal, 'last 24 h · ' . (int)$latest['stat_render_errors'] . ' since node start', $renderTone)
. $tile('Backend errors', $backendVal, 'last 24 h · ' . (int)$latest['stat_backend_errors'] . ' since node start', $backendTone)
. $tile('Down samples', (string)$down24h['count'], $down24h['count'] > 0 ? 'last ' . $h::e($down24h['last']) : 'last 24 h', $down24h['count'] > 0 ? 'vcw-bad' : 'vcw-ok')
. $tile('Sample age', $h::e($this->age((int)$latest['crdate'])), 'cron every 5 min', '')
. '</div>';
$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>';
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
{
return [];
}
public function getCssFiles(): array
{
return [CockpitHtml::CSS];
}
private function latestStatsRow(): ?array
{
try {
$row = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog')
->executeQuery(
'SELECT crdate, stat_hit, stat_miss, stat_stale, stat_render_errors, stat_backend_errors'
. ' FROM tx_vitec_ssrhealthlog WHERE stat_hit >= 0 ORDER BY crdate DESC, uid DESC LIMIT 1'
)->fetchAssociative();
return $row === false ? null : $row;
} catch (\Throwable) {
return null; // Column missing before database:updateschema - render the empty state.
}
}
/**
* New errors in the last 24 h = sum of positive deltas between
* consecutive stats samples. Rows with -1 (node down / no stats) are
* skipped; counter resets produce a negative step, clamped to 0.
*
* @return array{0: ?int, 1: ?int} [renderErrors, backendErrors]
*/
private function errorDeltas24h(): array
{
try {
$rows = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog')
->executeQuery(
'SELECT stat_render_errors, stat_backend_errors FROM tx_vitec_ssrhealthlog'
. ' WHERE crdate > ' . (time() - 86400) . ' AND stat_hit >= 0 ORDER BY crdate ASC, uid ASC'
)->fetchAllAssociative();
} catch (\Throwable) {
return [null, null];
}
if (count($rows) < 2) {
return [null, null];
}
$render = 0;
$backend = 0;
$prev = null;
foreach ($rows as $r) {
if ($prev !== null) {
$render += max(0, (int)$r['stat_render_errors'] - (int)$prev['stat_render_errors']);
$backend += max(0, (int)$r['stat_backend_errors'] - (int)$prev['stat_backend_errors']);
}
$prev = $r;
}
return [$render, $backend];
}
/**
* @return array{count: int, last: string}
*/
private function downSamples24h(): array
{
try {
$conn = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog');
$count = (int)$conn->executeQuery(
'SELECT COUNT(*) FROM tx_vitec_ssrhealthlog WHERE crdate > ' . (time() - 86400) . ' AND ok = 0'
)->fetchOne();
$last = $count > 0
? date('d.m. H:i', (int)$conn->executeQuery(
'SELECT MAX(crdate) FROM tx_vitec_ssrhealthlog WHERE ok = 0'
)->fetchOne())
: '-';
return ['count' => $count, 'last' => $last];
} catch (\Throwable) {
return ['count' => 0, 'last' => '-'];
}
}
private function age(int $crdate): string
{
$minutes = (int)floor((time() - $crdate) / 60);
return $minutes < 1 ? 'just now' : $minutes . ' min';
}
}