SSR dashboard: new errors-and-hit-rate tile fed by /__stats samples in vitec:ssr-health (stat_* columns in healthlog)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bc2e031b0c
commit
cb53ff67ee
5 files changed
+232
No files matched your search
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Widgets;
|
||||
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
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
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WidgetConfigurationInterface $configuration,
|
||||
private readonly ConnectionPool $connectionPool,
|
||||
) {}
|
||||
|
||||
public function renderWidgetContent(): string
|
||||
{
|
||||
$latest = $this->latestStatsRow();
|
||||
if ($latest === null) {
|
||||
return '<p class="text-muted">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>';
|
||||
}
|
||||
|
||||
$served = (int)$latest['stat_hit'] + (int)$latest['stat_miss'] + (int)$latest['stat_stale'];
|
||||
$hitRate = $served > 0
|
||||
? number_format(((int)$latest['stat_hit'] + (int)$latest['stat_stale']) / $served * 100, 1) . ' %'
|
||||
: '-';
|
||||
|
||||
[$newRender24h, $newBackend24h] = $this->errorDeltas24h();
|
||||
$down24h = $this->downSamples24h();
|
||||
|
||||
$rows = [
|
||||
['Cache hit rate (since node start)', htmlspecialchars($hitRate)
|
||||
. ' <small>(hit ' . (int)$latest['stat_hit']
|
||||
. ' / stale ' . (int)$latest['stat_stale']
|
||||
. ' / miss ' . (int)$latest['stat_miss'] . ')</small>'],
|
||||
['Render errors (new in 24 h / total)', $this->errorCell($newRender24h, (int)$latest['stat_render_errors'])],
|
||||
['Backend errors (new in 24 h / total)', $this->errorCell($newBackend24h, (int)$latest['stat_backend_errors'])],
|
||||
['DOWN samples (24 h)', $down24h['count'] > 0
|
||||
? '<span class="badge badge-danger">' . $down24h['count'] . '</span> <small>last: '
|
||||
. htmlspecialchars($down24h['last']) . '</small>'
|
||||
: '<span class="badge badge-success">0</span>'],
|
||||
['Sample age', htmlspecialchars($this->age((int)$latest['crdate']))],
|
||||
];
|
||||
|
||||
$html = '<table class="table table-striped table-sm"><tbody>';
|
||||
foreach ($rows as [$label, $value]) {
|
||||
$html .= '<tr><th style="width:55%">' . $label . '</th><td>' . $value . '</td></tr>';
|
||||
}
|
||||
$html .= '</tbody></table>';
|
||||
$html .= '<p class="text-muted" style="font-size:11px">Totals are cumulative since the node '
|
||||
. 'last (re)started; a deploy resets them. Error details: '
|
||||
. '<code>journalctl -u vitec-ssr</code> on the VPS.</p>';
|
||||
return $html;
|
||||
}
|
||||
|
||||
public function getOptions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
private function errorCell(?int $new24h, int $total): string
|
||||
{
|
||||
$newBadge = $new24h === null
|
||||
? '<span class="badge badge-secondary">n/a</span>'
|
||||
: ($new24h > 0
|
||||
? '<span class="badge badge-danger">+' . $new24h . '</span>'
|
||||
: '<span class="badge badge-success">+0</span>');
|
||||
return $newBadge . ' <small>/ ' . $total . ' total</small>';
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user