Files
VITEC-website/packages/vitec/Classes/Widgets/SsrErrorsWidget.php
T
orascheandClaude Opus 5.5 62f3f5394e SSR dashboard tiles in the cockpit look
The three SSR widgets render dark VITEC tiles like the cockpit module: status with pulse, cache ring and last purge, statistics with the 24 h availability strip and number tiles, errors with hit rate ring and split bar. Shared markup in CockpitHtml, styles in dashboard-ssr.css loaded per widget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 10:59:49 +01:00

167 lines
7.4 KiB
PHP
Raw 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>');
}
$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);
}
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';
}
}