Files
VITEC-website/packages/vitec/Classes/Widgets/SsrStatusWidget.php
T
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

147 lines
6.5 KiB
PHP

<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Widgets;
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. Same look
* as the "VITEC SSR" cockpit module (CockpitHtml).
*/
final class SsrStatusWidget implements WidgetInterface, AdditionalCssInterface
{
public function __construct(
private readonly WidgetConfigurationInterface $configuration,
private readonly SsrPurgeService $purgeService,
private readonly RequestFactory $requestFactory,
private readonly ConnectionPool $connectionPool,
) {}
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();
// Head: pulse + state
if ($health === null) {
$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 {
$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) {
[$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 .= '<div class="vcw-foot">' . $h::moduleLink() . '</div>';
return $h::wrap($html, 'Live state of the SSR node on the VPS: whether it answers and how fast, '
. 'how full its page cache is against the limit, whether SSR delivery is switched on, which frontend '
. 'release runs, and the most recent cache purge. The link opens the cockpit with charts, protocol '
. 'and the purge and warm buttons.', 'status');
}
public function getOptions(): array
{
return [];
}
public function getCssFiles(): array
{
return [CockpitHtml::CSS];
}
private function fetchHealth(): ?array
{
$endpoint = $this->purgeService->getHealthEndpoint();
if ($endpoint === '') {
return null;
}
$start = microtime(true);
try {
$response = $this->requestFactory->request($endpoint, 'GET', [
'timeout' => 3, 'connect_timeout' => 2, 'http_errors' => false,
]);
if ($response->getStatusCode() !== 200) {
return null;
}
$data = json_decode((string)$response->getBody(), true);
if (!is_array($data)) {
return null;
}
$data['responseMs'] = (int)round((microtime(true) - $start) * 1000);
return $data;
} catch (\Throwable) {
return null;
}
}
private function lastPurgeRow(): ?array
{
try {
$qb = $this->connectionPool->getQueryBuilderForTable('tx_vitec_ssrpurgelog');
$row = $qb->select('*')->from('tx_vitec_ssrpurgelog')
->orderBy('uid', 'DESC')->setMaxResults(1)
->executeQuery()->fetchAssociative();
return $row ?: null;
} catch (\Throwable) {
return null;
}
}
private function fmtDate(string $iso): string
{
$ts = strtotime($iso);
return $ts ? date('Y-m-d H:i', $ts) : '?';
}
}