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>
This commit is contained in:
1 parent
cda26947f2
commit
62f3f5394e
5 files changed
+409
-97
No files matched your search
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Widgets;
|
||||
|
||||
use TYPO3\CMS\Backend\Routing\UriBuilder;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Shared markup for the three SSR dashboard tiles, in the look of the
|
||||
* "VITEC SSR" cockpit module (dark VITEC navy, orange accents).
|
||||
* The dashboard inserts widget HTML without running scripts, so rings
|
||||
* and bars are drawn here as plain SVG/HTML. Styles:
|
||||
* Resources/Public/Css/dashboard-ssr.css (scoped to .vc-widget).
|
||||
*/
|
||||
final class CockpitHtml
|
||||
{
|
||||
public const CSS = 'EXT:vitec/Resources/Public/Css/dashboard-ssr.css';
|
||||
|
||||
public static function e(string $value): string
|
||||
{
|
||||
return htmlspecialchars($value, ENT_QUOTES);
|
||||
}
|
||||
|
||||
public static function wrap(string $inner): string
|
||||
{
|
||||
return '<div class="vc-widget">' . $inner . '</div>';
|
||||
}
|
||||
|
||||
public static function pill(string $tone, string $text, string $title = ''): string
|
||||
{
|
||||
return '<span class="vcw-pill is-' . self::e($tone) . '"'
|
||||
. ($title !== '' ? ' title="' . self::e($title) . '"' : '') . '>' . self::e($text) . '</span>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ring gauge, 0..1, with a short text in the middle.
|
||||
*/
|
||||
public static function ring(float $fraction, string $tone, string $center, int $size = 76): string
|
||||
{
|
||||
$c = 2 * M_PI * 40;
|
||||
$offset = $c * (1 - max(0.0, min(1.0, $fraction)));
|
||||
return '<div class="vcw-ring" style="width:' . $size . 'px;height:' . $size . 'px">'
|
||||
. '<svg viewBox="0 0 96 96" width="' . $size . '" height="' . $size . '" aria-hidden="true">'
|
||||
. '<circle class="vcw-track" cx="48" cy="48" r="40"/>'
|
||||
. '<circle class="vcw-val is-' . self::e($tone) . '" cx="48" cy="48" r="40" stroke-dasharray="'
|
||||
. round($c, 2) . '" stroke-dashoffset="' . round($offset, 2) . '"/></svg>'
|
||||
. '<span class="vcw-ring-c">' . self::e($center) . '</span></div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Same labels and tones as the purge timeline in the module.
|
||||
*
|
||||
* @return array{0: string, 1: string, 2: string} [label, tone, user]
|
||||
*/
|
||||
public static function trigger(string $source): array
|
||||
{
|
||||
$who = '';
|
||||
if (str_contains($source, ':')) {
|
||||
[$source, $who] = explode(':', $source, 2);
|
||||
}
|
||||
return match ($source) {
|
||||
'module' => ['manual', 'orange', $who],
|
||||
'contextmenu' => ['context menu', 'navy', $who],
|
||||
'editor-save', 'editor-command' => ['auto-purge', 'ok', $who],
|
||||
'switch-on' => ['switch-on', 'warn', $who],
|
||||
default => [$source, 'sky', $who],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Availability strip of the last 24 hours, 96 windows of 15 minutes,
|
||||
* from the health log (cron vitec:ssr-health every 5 minutes).
|
||||
*
|
||||
* @return array{html: string, uptime: ?float, slow: int, down: int, samples: int}
|
||||
*/
|
||||
public static function strip(ConnectionPool $pool): array
|
||||
{
|
||||
$slot = 900;
|
||||
$end = intdiv(time(), $slot) * $slot + $slot;
|
||||
$windows = array_fill(0, 96, null);
|
||||
$ok = 0;
|
||||
$n = 0;
|
||||
try {
|
||||
$rows = $pool->getConnectionForTable('tx_vitec_ssrhealthlog')->executeQuery(
|
||||
'SELECT crdate, ok, response_ms FROM tx_vitec_ssrhealthlog WHERE crdate > ?',
|
||||
[$end - 96 * $slot]
|
||||
)->fetchAllAssociative();
|
||||
} catch (\Throwable) {
|
||||
$rows = [];
|
||||
}
|
||||
foreach ($rows as $r) {
|
||||
$i = 95 - intdiv($end - 1 - (int)$r['crdate'], $slot);
|
||||
if ($i < 0 || $i > 95) {
|
||||
continue;
|
||||
}
|
||||
$n++;
|
||||
$isOk = (int)$r['ok'] === 1;
|
||||
$ok += $isOk ? 1 : 0;
|
||||
$state = !$isOk ? 2 : ((int)$r['response_ms'] > 400 ? 1 : 0);
|
||||
if ($windows[$i] === null || $state > $windows[$i]['s']) {
|
||||
$windows[$i] = ['s' => $state, 'ms' => (int)$r['response_ms']];
|
||||
}
|
||||
}
|
||||
$slow = 0;
|
||||
$down = 0;
|
||||
$html = '<div class="vcw-bars" role="img" aria-label="Availability of the SSR node over the last 24 hours">';
|
||||
foreach ($windows as $i => $w) {
|
||||
$from = $end - (96 - $i) * $slot;
|
||||
$label = date('H:i', $from) . '–' . date('H:i', $from + $slot) . ' · ';
|
||||
if ($w === null) {
|
||||
$html .= '<i class="is-none" title="' . self::e($label . 'no sample') . '"></i>';
|
||||
continue;
|
||||
}
|
||||
$cls = ['', 'is-slow', 'is-down'][$w['s']];
|
||||
$slow += $w['s'] === 1 ? 1 : 0;
|
||||
$down += $w['s'] === 2 ? 1 : 0;
|
||||
$text = $w['s'] === 2 ? 'down' : ($w['s'] === 1 ? 'slow, ' : 'ok, ') . ($w['s'] === 2 ? '' : $w['ms'] . ' ms');
|
||||
$html .= '<i class="' . $cls . '" title="' . self::e($label . $text) . '"></i>';
|
||||
}
|
||||
$html .= '</div><div class="vcw-axis"><span>' . date('H:i', $end - 96 * $slot) . '</span><span>'
|
||||
. date('H:i', $end - 48 * $slot) . '</span><span>now</span></div>';
|
||||
return [
|
||||
'html' => $html,
|
||||
'uptime' => $n > 0 ? round($ok / $n * 100, 1) : null,
|
||||
'slow' => $slow,
|
||||
'down' => $down,
|
||||
'samples' => $n,
|
||||
];
|
||||
}
|
||||
|
||||
public static function moduleLink(string $label = 'Open the SSR cockpit'): string
|
||||
{
|
||||
try {
|
||||
$uri = (string)GeneralUtility::makeInstance(UriBuilder::class)->buildUriFromRoute('web_vitecssr');
|
||||
} catch (\Throwable) {
|
||||
return '';
|
||||
}
|
||||
return '<a class="vcw-link" href="' . self::e($uri) . '">' . self::e($label) . ' →</a>';
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
|
||||
@@ -18,7 +19,7 @@ use TYPO3\CMS\Dashboard\Widgets\WidgetInterface;
|
||||
* figures are sums of positive deltas between consecutive samples - a
|
||||
* reset simply contributes nothing.
|
||||
*/
|
||||
final class SsrErrorsWidget implements WidgetInterface
|
||||
final class SsrErrorsWidget implements WidgetInterface, AdditionalCssInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WidgetConfigurationInterface $configuration,
|
||||
@@ -27,44 +28,55 @@ final class SsrErrorsWidget implements WidgetInterface
|
||||
|
||||
public function renderWidgetContent(): string
|
||||
{
|
||||
$h = CockpitHtml::class;
|
||||
$latest = $this->latestStatsRow();
|
||||
if ($latest === null) {
|
||||
return '<p class="text-muted">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 '
|
||||
. 'provides <code>/__stats</code>.</p>';
|
||||
. '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) . ' %'
|
||||
: '-';
|
||||
$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();
|
||||
|
||||
$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']))],
|
||||
];
|
||||
// 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>';
|
||||
|
||||
$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;
|
||||
$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
|
||||
@@ -72,14 +84,9 @@ final class SsrErrorsWidget implements WidgetInterface
|
||||
return [];
|
||||
}
|
||||
|
||||
private function errorCell(?int $new24h, int $total): string
|
||||
public function getCssFiles(): array
|
||||
{
|
||||
$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>';
|
||||
return [CockpitHtml::CSS];
|
||||
}
|
||||
|
||||
private function latestStatsRow(): ?array
|
||||
|
||||
@@ -5,15 +5,17 @@ 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: purge and health statistics from the two protocol
|
||||
* tables. History depth = what vitec:ssr-health sampling (cron, every
|
||||
* 5 minutes) and the purge log hold (14 days).
|
||||
* 5 minutes) and the purge log hold (14 days). Cockpit look (CockpitHtml):
|
||||
* availability strip of the last 24 h plus number tiles.
|
||||
*/
|
||||
final class SsrStatsWidget implements WidgetInterface
|
||||
final class SsrStatsWidget implements WidgetInterface, AdditionalCssInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WidgetConfigurationInterface $configuration,
|
||||
@@ -22,6 +24,7 @@ final class SsrStatsWidget implements WidgetInterface
|
||||
|
||||
public function renderWidgetContent(): string
|
||||
{
|
||||
$h = CockpitHtml::class;
|
||||
$now = time();
|
||||
$d1 = $now - 86400;
|
||||
$d7 = $now - 7 * 86400;
|
||||
@@ -30,42 +33,40 @@ final class SsrStatsWidget implements WidgetInterface
|
||||
$purges7d = $this->count('tx_vitec_ssrpurgelog', 'crdate > ' . $d7);
|
||||
$failed7d = $this->count('tx_vitec_ssrpurgelog', 'crdate > ' . $d7 . ' AND success = 0');
|
||||
$full7d = $this->count('tx_vitec_ssrpurgelog', "crdate > " . $d7 . " AND paths = '*'");
|
||||
|
||||
$samples24h = $this->count('tx_vitec_ssrhealthlog', 'crdate > ' . $d1);
|
||||
$ok24h = $this->count('tx_vitec_ssrhealthlog', 'crdate > ' . $d1 . ' AND ok = 1');
|
||||
$uptime = $samples24h > 0 ? round($ok24h / $samples24h * 100, 1) . ' %' : '-';
|
||||
$avgMs = $this->scalar('SELECT ROUND(AVG(response_ms)) FROM tx_vitec_ssrhealthlog WHERE crdate > ' . $d1 . ' AND ok = 1');
|
||||
$maxCache = $this->scalar('SELECT MAX(cache_mb) FROM tx_vitec_ssrhealthlog WHERE crdate > ' . $d7);
|
||||
|
||||
// Browser-side errors reported via POST /api/vitec/client-error
|
||||
// (ClientErrorMiddleware); details in the VITEC SSR module.
|
||||
// Browser-side errors reported via POST /api/vitec/client-error.
|
||||
$clientErrors24h = $this->count('tx_vitec_clienterrorlog', 'crdate > ' . $d1);
|
||||
$clientErrors7d = $this->count('tx_vitec_clienterrorlog', 'crdate > ' . $d7);
|
||||
|
||||
$rows = [
|
||||
['Purges (24 h / 7 days)', $purges24h . ' / ' . $purges7d],
|
||||
['of which full purges (7 d)', (string)$full7d],
|
||||
['Failed purges (7 d)', $failed7d > 0
|
||||
? '<span class="badge badge-danger">' . $failed7d . '</span>'
|
||||
: '<span class="badge badge-success">0</span>'],
|
||||
['Availability (24 h)', htmlspecialchars($uptime)
|
||||
. ' <small>(' . $ok24h . '/' . $samples24h . ' samples)</small>'],
|
||||
['Avg. response time (24 h)', $avgMs !== null ? $avgMs . ' ms' : '-'],
|
||||
['Max. cache size (7 d)', $maxCache !== null ? $maxCache . ' MB' : '-'],
|
||||
['Client errors reported (24 h / 7 d)', ($clientErrors24h > 0
|
||||
? '<span class="badge badge-warning">' . $clientErrors24h . '</span>'
|
||||
: '<span class="badge badge-success">0</span>') . ' / ' . $clientErrors7d],
|
||||
];
|
||||
$strip = $h::strip($this->connectionPool);
|
||||
$uptime = $strip['uptime'];
|
||||
$uptimeTone = $uptime === null ? '' : ($uptime >= 99.5 ? 'vcw-ok' : ($uptime >= 95 ? 'vcw-warn' : 'vcw-bad'));
|
||||
|
||||
$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 = '<div class="vcw-strip-h"><div><div class="vcw-label">Availability · 24 h</div>'
|
||||
. '<div class="vcw-num ' . $uptimeTone . '">' . ($uptime === null ? '–' : number_format($uptime, 1)) . '<small>%</small></div></div>'
|
||||
. '<div class="vcw-sub vcw-right">' . $strip['samples'] . ' samples<br>' . $strip['slow'] . ' slow · '
|
||||
. $strip['down'] . ' down</div></div>'
|
||||
. $strip['html'];
|
||||
|
||||
$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>' . ($sub !== '' ? '<div class="vcw-sub">' . $sub . '</div>' : '') . '</div>';
|
||||
|
||||
$html .= '<div class="vcw-tiles">'
|
||||
. $tile('Purges', $purges24h . '<small>/ ' . $purges7d . '</small>', '24 h / 7 days')
|
||||
. $tile('Full purges', (string)$full7d, 'last 7 days', $full7d > 0 ? 'vcw-warn' : '')
|
||||
. $tile('Failed purges', (string)$failed7d, 'last 7 days', $failed7d > 0 ? 'vcw-bad' : 'vcw-ok')
|
||||
. $tile('Avg. response', $avgMs !== null ? $avgMs . '<small>ms</small>' : '–', 'last 24 h')
|
||||
. $tile('Max. cache', $maxCache !== null ? $maxCache . '<small>MB</small>' : '–', 'last 7 days')
|
||||
. $tile('Client errors', $clientErrors24h . '<small>/ ' . $clientErrors7d . '</small>', '24 h / 7 days', $clientErrors24h > 0 ? 'vcw-warn' : 'vcw-ok')
|
||||
. '</div>';
|
||||
|
||||
if ($strip['samples'] === 0) {
|
||||
$html .= '<p class="vcw-dim">No samples yet: the cron job <code>vitec:ssr-health</code> fills this every 5 minutes.</p>';
|
||||
}
|
||||
$html .= '</tbody></table>';
|
||||
if ($samples24h === 0) {
|
||||
$html .= '<p class="text-muted" style="font-size:11px">No samples yet: the cron job for <code>vitec:ssr-health</code> fills this every 5 minutes.</p>';
|
||||
}
|
||||
return $html;
|
||||
$html .= '<div class="vcw-foot">' . $h::moduleLink('Charts and protocol in the cockpit') . '</div>';
|
||||
return $h::wrap($html);
|
||||
}
|
||||
|
||||
public function getOptions(): array
|
||||
@@ -73,6 +74,11 @@ final class SsrStatsWidget implements WidgetInterface
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getCssFiles(): array
|
||||
{
|
||||
return [CockpitHtml::CSS];
|
||||
}
|
||||
|
||||
private function count(string $table, string $where): int
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -8,14 +8,16 @@ use Evomedien\Vitec\Service\SsrPurgeService;
|
||||
use TYPO3\CMS\Core\Core\Environment;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Http\RequestFactory;
|
||||
use TYPO3\CMS\Dashboard\Widgets\AdditionalCssInterface;
|
||||
use TYPO3\CMS\Dashboard\Widgets\WidgetConfigurationInterface;
|
||||
use TYPO3\CMS\Dashboard\Widgets\WidgetInterface;
|
||||
|
||||
/**
|
||||
* Dashboard tile: live state of the SSR node on the Solr VPS plus the
|
||||
* delivery switch - green/red at a glance after backend login.
|
||||
* delivery switch - green/red at a glance after backend login. Same look
|
||||
* as the "VITEC SSR" cockpit module (CockpitHtml).
|
||||
*/
|
||||
final class SsrStatusWidget implements WidgetInterface
|
||||
final class SsrStatusWidget implements WidgetInterface, AdditionalCssInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WidgetConfigurationInterface $configuration,
|
||||
@@ -26,44 +28,63 @@ final class SsrStatusWidget implements WidgetInterface
|
||||
|
||||
public function renderWidgetContent(): string
|
||||
{
|
||||
$h = CockpitHtml::class;
|
||||
$flagOn = file_exists(Environment::getVarPath() . '/vitec-ssr-on.flag');
|
||||
$health = $this->fetchHealth();
|
||||
$lastPurge = $this->lastPurgeRow();
|
||||
$limit = $this->purgeService->getCacheLimitMb();
|
||||
|
||||
$badge = static function (bool $ok, string $yes, string $no): string {
|
||||
return $ok
|
||||
? '<span class="badge badge-success">' . htmlspecialchars($yes) . '</span>'
|
||||
: '<span class="badge badge-danger">' . htmlspecialchars($no) . '</span>';
|
||||
};
|
||||
|
||||
$rows = [];
|
||||
$rows[] = ['SSR delivery', $badge($flagOn, 'ON - pages served from the VPS', 'OFF - SPA as before')];
|
||||
// Head: pulse + state
|
||||
if ($health === null) {
|
||||
$rows[] = ['SSR server', '<span class="badge badge-danger">NOT REACHABLE</span>'];
|
||||
$head = '<span class="vcw-pulse is-down" aria-hidden="true"></span><div>'
|
||||
. '<div class="vcw-title">SSR <span class="vcw-bad">not reachable</span></div>'
|
||||
. '<div class="vcw-sub">The cron falls back to the SPA after '
|
||||
. $this->purgeService->getFailoverAfter() . ' failed checks in a row</div></div>';
|
||||
} else {
|
||||
$rows[] = ['SSR server', $badge((bool)($health['ok'] ?? false), 'running (' . (int)$health['responseMs'] . ' ms)', 'reports errors')];
|
||||
$rows[] = ['Release', htmlspecialchars((string)($health['release']['release'] ?? '?'))
|
||||
. ' <small>(commit ' . htmlspecialchars((string)($health['release']['commit'] ?? '?'))
|
||||
. ', built ' . htmlspecialchars($this->fmtDate((string)($health['release']['builtAt'] ?? ''))) . ')</small>'];
|
||||
$rows[] = ['Page cache', (int)($health['cachedPages'] ?? 0) . ' pages / ' . (int)($health['cacheMB'] ?? 0) . ' MB'];
|
||||
$ok = (bool)($health['ok'] ?? false);
|
||||
$head = '<span class="vcw-pulse' . ($ok ? '' : ' is-warn') . '" aria-hidden="true"></span><div>'
|
||||
. '<div class="vcw-title">SSR <span class="' . ($ok ? 'vcw-ok">online' : 'vcw-warn">reports errors') . '</span></div>'
|
||||
. '<div class="vcw-sub">' . ($flagOn ? 'Pages are rendered on the VPS' : 'Delivery is off: TYPO3 serves the SPA')
|
||||
. ' · ' . (int)$health['responseMs'] . ' ms</div></div>';
|
||||
}
|
||||
$html = '<div class="vcw-head">' . $head . '</div>';
|
||||
|
||||
// Cache ring + facts
|
||||
$html .= '<div class="vcw-split">';
|
||||
if ($health !== null) {
|
||||
$mb = (int)($health['cacheMB'] ?? 0);
|
||||
$pages = (int)($health['cachedPages'] ?? 0);
|
||||
$frac = $limit > 0 ? $mb / $limit : 0.0;
|
||||
$html .= '<div class="vcw-gauge">' . $h::ring($frac, 'sky', $limit > 0 ? round($frac * 100) . ' %' : $mb . ' MB')
|
||||
. '<div><div class="vcw-label">Cache</div><div class="vcw-num">' . number_format($pages, 0, '.', ',')
|
||||
. '<small>pages</small></div><div class="vcw-sub">' . $mb . ($limit > 0 ? ' / ' . $limit : '') . ' MB</div></div></div>';
|
||||
}
|
||||
$html .= '<dl class="vcw-facts">'
|
||||
. '<div><dt>Delivery</dt><dd>' . ($flagOn ? $h::pill('ok', 'ON') : $h::pill('muted', 'OFF')) . '</dd></div>';
|
||||
if ($health !== null) {
|
||||
$html .= '<div><dt>Release</dt><dd><code title="built ' . $h::e($this->fmtDate((string)($health['release']['builtAt'] ?? '')))
|
||||
. '">' . $h::e((string)($health['release']['release'] ?? '?')) . '</code></dd></div>'
|
||||
. '<div><dt>Commit</dt><dd><code>' . $h::e((string)($health['release']['commit'] ?? '?')) . '</code></dd></div>';
|
||||
}
|
||||
$html .= '<div><dt>Webhook</dt><dd>' . ($this->purgeService->isConfigured()
|
||||
? $h::pill('ok', 'configured')
|
||||
: $h::pill('warn', 'not configured', 'var/vitec-ssr-purge.env is missing or incomplete')) . '</dd></div>'
|
||||
. '</dl></div>';
|
||||
|
||||
// Last purge
|
||||
if ($lastPurge !== null) {
|
||||
$rows[] = ['Last purge', htmlspecialchars(date('m-d H:i', (int)$lastPurge['crdate']))
|
||||
. ' <small>(' . htmlspecialchars((string)$lastPurge['trigger_source']) . ', '
|
||||
. ($lastPurge['success'] ? 'ok' : 'FAILED') . ', '
|
||||
. htmlspecialchars(mb_strimwidth((string)$lastPurge['paths'], 0, 60, '…')) . ')</small>'];
|
||||
}
|
||||
if (!$this->purgeService->isConfigured()) {
|
||||
$rows[] = ['Auto purge', '<span class="badge badge-warning">not configured (var/vitec-ssr-purge.env missing)</span>'];
|
||||
[$label, $tone, $who] = $h::trigger((string)$lastPurge['trigger_source']);
|
||||
$paths = trim((string)$lastPurge['paths']);
|
||||
$html .= '<div class="vcw-last"><span class="vcw-label">Last purge</span>'
|
||||
. '<span class="vcw-time">' . date('d.m. H:i', (int)$lastPurge['crdate']) . '</span>'
|
||||
. $h::pill($tone, $label) . ($who !== '' ? ' <span class="vcw-dim">' . $h::e($who) . '</span>' : '')
|
||||
. ' ' . ((int)$lastPurge['success'] ? $h::pill('ok', 'ok') : $h::pill('bad', 'failed'))
|
||||
. '<span class="vcw-path" title="' . $h::e($paths) . '">'
|
||||
. ($paths === '*' ? 'all pages (*)' : $h::e(mb_strimwidth($paths, 0, 70, '…'))) . '</span></div>';
|
||||
}
|
||||
|
||||
$html = '<table class="table table-striped table-sm"><tbody>';
|
||||
foreach ($rows as [$label, $value]) {
|
||||
$html .= '<tr><th style="width:38%">' . htmlspecialchars($label) . '</th><td>' . $value . '</td></tr>';
|
||||
}
|
||||
$html .= '</tbody></table>';
|
||||
$html .= '<p class="text-muted" style="font-size:11px">Switch: Site Management › Settings › VITEC. Module "VITEC SSR" for purge & protocol.</p>';
|
||||
return $html;
|
||||
$html .= '<div class="vcw-foot">' . $h::moduleLink() . '</div>';
|
||||
return $h::wrap($html);
|
||||
}
|
||||
|
||||
public function getOptions(): array
|
||||
@@ -71,6 +92,11 @@ final class SsrStatusWidget implements WidgetInterface
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getCssFiles(): array
|
||||
{
|
||||
return [CockpitHtml::CSS];
|
||||
}
|
||||
|
||||
private function fetchHealth(): ?array
|
||||
{
|
||||
$endpoint = $this->purgeService->getHealthEndpoint();
|
||||
|
||||
Reference in new issue
Block a user