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>
172 lines
8.0 KiB
PHP
172 lines
8.0 KiB
PHP
<?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';
|
||
}
|
||
}
|