From 62f3f5394e5ebb28de969a254b939c606929977b Mon Sep 17 00:00:00 2001 From: Oliver Rasche Date: Wed, 11 Nov 2026 10:59:49 +0100 Subject: [PATCH] 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 --- .../vitec/Classes/Widgets/CockpitHtml.php | 143 ++++++++++++++++++ .../vitec/Classes/Widgets/SsrErrorsWidget.php | 79 +++++----- .../vitec/Classes/Widgets/SsrStatsWidget.php | 68 +++++---- .../vitec/Classes/Widgets/SsrStatusWidget.php | 86 +++++++---- .../Resources/Public/Css/dashboard-ssr.css | 130 ++++++++++++++++ 5 files changed, 409 insertions(+), 97 deletions(-) create mode 100644 packages/vitec/Classes/Widgets/CockpitHtml.php create mode 100644 packages/vitec/Resources/Public/Css/dashboard-ssr.css diff --git a/packages/vitec/Classes/Widgets/CockpitHtml.php b/packages/vitec/Classes/Widgets/CockpitHtml.php new file mode 100644 index 0000000..797930f --- /dev/null +++ b/packages/vitec/Classes/Widgets/CockpitHtml.php @@ -0,0 +1,143 @@ +' . $inner . ''; + } + + public static function pill(string $tone, string $text, string $title = ''): string + { + return '' . self::e($text) . ''; + } + + /** + * 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 '
' + . '' + . '' . self::e($center) . '
'; + } + + /** + * 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 = '
' . date('H:i', $end - 96 * $slot) . '' + . date('H:i', $end - 48 * $slot) . 'now
'; + 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 '' . self::e($label) . ' →'; + } +} diff --git a/packages/vitec/Classes/Widgets/SsrErrorsWidget.php b/packages/vitec/Classes/Widgets/SsrErrorsWidget.php index a668857..ceb4ea1 100644 --- a/packages/vitec/Classes/Widgets/SsrErrorsWidget.php +++ b/packages/vitec/Classes/Widgets/SsrErrorsWidget.php @@ -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 '

No /__stats samples yet. They arrive with the next ' + return $h::wrap('

No /__stats samples yet. They arrive with the next ' . 'vitec:ssr-health cron run (every 5 minutes) once the node release ' - . 'provides /__stats.

'; + . 'provides /__stats.

'); } - $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) - . ' (hit ' . (int)$latest['stat_hit'] - . ' / stale ' . (int)$latest['stat_stale'] - . ' / miss ' . (int)$latest['stat_miss'] . ')'], - ['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 - ? '' . $down24h['count'] . ' last: ' - . htmlspecialchars($down24h['last']) . '' - : '0'], - ['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 = '
' + . $h::ring($rate ?? 0.0, 'ok', $rate === null ? '–' : number_format($rate * 100, 1) . ' %', 84) + . '
Cache hit rate
' + . '
since the node started · ' . number_format($served, 0, '.', ',') . ' requests
' + . '' + . '
cache ' . $hit . '' + . 'stale ' . $stale . 'fresh ' . $miss . '
' + . '
'; - $html = ''; - foreach ($rows as [$label, $value]) { - $html .= ''; - } - $html .= '
' . $label . '' . $value . '
'; - $html .= '

Totals are cumulative since the node ' - . 'last (re)started; a deploy resets them. Error details: ' - . 'journalctl -u vitec-ssr on the VPS.

'; - return $html; + $tile = static fn(string $label, string $value, string $sub, string $tone): string => + '
' . $h::e($label) . '
' + . $value . '
' . $sub . '
'; + $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 .= '
' + . $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', '') + . '
'; + $html .= '

Totals count since the node last started; a deploy resets them. ' + . 'Error details: journalctl -u vitec-ssr on the VPS.

'; + 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 - ? 'n/a' - : ($new24h > 0 - ? '+' . $new24h . '' - : '+0'); - return $newBadge . ' / ' . $total . ' total'; + return [CockpitHtml::CSS]; } private function latestStatsRow(): ?array diff --git a/packages/vitec/Classes/Widgets/SsrStatsWidget.php b/packages/vitec/Classes/Widgets/SsrStatsWidget.php index e8f3f6c..76c0001 100644 --- a/packages/vitec/Classes/Widgets/SsrStatsWidget.php +++ b/packages/vitec/Classes/Widgets/SsrStatsWidget.php @@ -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 - ? '' . $failed7d . '' - : '0'], - ['Availability (24 h)', htmlspecialchars($uptime) - . ' (' . $ok24h . '/' . $samples24h . ' samples)'], - ['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 - ? '' . $clientErrors24h . '' - : '0') . ' / ' . $clientErrors7d], - ]; + $strip = $h::strip($this->connectionPool); + $uptime = $strip['uptime']; + $uptimeTone = $uptime === null ? '' : ($uptime >= 99.5 ? 'vcw-ok' : ($uptime >= 95 ? 'vcw-warn' : 'vcw-bad')); - $html = ''; - foreach ($rows as [$label, $value]) { - $html .= ''; + $html = '
Availability · 24 h
' + . '
' . ($uptime === null ? '–' : number_format($uptime, 1)) . '%
' + . '
' . $strip['samples'] . ' samples
' . $strip['slow'] . ' slow · ' + . $strip['down'] . ' down
' + . $strip['html']; + + $tile = static fn(string $label, string $value, string $sub = '', string $tone = ''): string => + '
' . $h::e($label) . '
' + . $value . '
' . ($sub !== '' ? '
' . $sub . '
' : '') . '
'; + + $html .= '
' + . $tile('Purges', $purges24h . '/ ' . $purges7d . '', '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 . 'ms' : '–', 'last 24 h') + . $tile('Max. cache', $maxCache !== null ? $maxCache . 'MB' : '–', 'last 7 days') + . $tile('Client errors', $clientErrors24h . '/ ' . $clientErrors7d . '', '24 h / 7 days', $clientErrors24h > 0 ? 'vcw-warn' : 'vcw-ok') + . '
'; + + if ($strip['samples'] === 0) { + $html .= '

No samples yet: the cron job vitec:ssr-health fills this every 5 minutes.

'; } - $html .= '
' . $label . '' . $value . '
'; - if ($samples24h === 0) { - $html .= '

No samples yet: the cron job for vitec:ssr-health fills this every 5 minutes.

'; - } - return $html; + $html .= '
' . $h::moduleLink('Charts and protocol in the cockpit') . '
'; + 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 { diff --git a/packages/vitec/Classes/Widgets/SsrStatusWidget.php b/packages/vitec/Classes/Widgets/SsrStatusWidget.php index 468130e..4e8f874 100644 --- a/packages/vitec/Classes/Widgets/SsrStatusWidget.php +++ b/packages/vitec/Classes/Widgets/SsrStatusWidget.php @@ -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 - ? '' . htmlspecialchars($yes) . '' - : '' . htmlspecialchars($no) . ''; - }; - - $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', 'NOT REACHABLE']; + $head = '
' + . '
SSR not reachable
' + . '
The cron falls back to the SPA after ' + . $this->purgeService->getFailoverAfter() . ' failed checks in a row
'; } else { - $rows[] = ['SSR server', $badge((bool)($health['ok'] ?? false), 'running (' . (int)$health['responseMs'] . ' ms)', 'reports errors')]; - $rows[] = ['Release', htmlspecialchars((string)($health['release']['release'] ?? '?')) - . ' (commit ' . htmlspecialchars((string)($health['release']['commit'] ?? '?')) - . ', built ' . htmlspecialchars($this->fmtDate((string)($health['release']['builtAt'] ?? ''))) . ')']; - $rows[] = ['Page cache', (int)($health['cachedPages'] ?? 0) . ' pages / ' . (int)($health['cacheMB'] ?? 0) . ' MB']; + $ok = (bool)($health['ok'] ?? false); + $head = '
' + . '
SSR online' : 'vcw-warn">reports errors') . '
' + . '
' . ($flagOn ? 'Pages are rendered on the VPS' : 'Delivery is off: TYPO3 serves the SPA') + . ' · ' . (int)$health['responseMs'] . ' ms
'; } + $html = '
' . $head . '
'; + + // Cache ring + facts + $html .= '
'; + if ($health !== null) { + $mb = (int)($health['cacheMB'] ?? 0); + $pages = (int)($health['cachedPages'] ?? 0); + $frac = $limit > 0 ? $mb / $limit : 0.0; + $html .= '
' . $h::ring($frac, 'sky', $limit > 0 ? round($frac * 100) . ' %' : $mb . ' MB') + . '
Cache
' . number_format($pages, 0, '.', ',') + . 'pages
' . $mb . ($limit > 0 ? ' / ' . $limit : '') . ' MB
'; + } + $html .= '
' + . '
Delivery
' . ($flagOn ? $h::pill('ok', 'ON') : $h::pill('muted', 'OFF')) . '
'; + if ($health !== null) { + $html .= '
Release
' . $h::e((string)($health['release']['release'] ?? '?')) . '
' + . '
Commit
' . $h::e((string)($health['release']['commit'] ?? '?')) . '
'; + } + $html .= '
Webhook
' . ($this->purgeService->isConfigured() + ? $h::pill('ok', 'configured') + : $h::pill('warn', 'not configured', 'var/vitec-ssr-purge.env is missing or incomplete')) . '
' + . '
'; + + // Last purge if ($lastPurge !== null) { - $rows[] = ['Last purge', htmlspecialchars(date('m-d H:i', (int)$lastPurge['crdate'])) - . ' (' . htmlspecialchars((string)$lastPurge['trigger_source']) . ', ' - . ($lastPurge['success'] ? 'ok' : 'FAILED') . ', ' - . htmlspecialchars(mb_strimwidth((string)$lastPurge['paths'], 0, 60, '…')) . ')']; - } - if (!$this->purgeService->isConfigured()) { - $rows[] = ['Auto purge', 'not configured (var/vitec-ssr-purge.env missing)']; + [$label, $tone, $who] = $h::trigger((string)$lastPurge['trigger_source']); + $paths = trim((string)$lastPurge['paths']); + $html .= '
Last purge' + . '' . date('d.m. H:i', (int)$lastPurge['crdate']) . '' + . $h::pill($tone, $label) . ($who !== '' ? ' ' . $h::e($who) . '' : '') + . ' ' . ((int)$lastPurge['success'] ? $h::pill('ok', 'ok') : $h::pill('bad', 'failed')) + . '' + . ($paths === '*' ? 'all pages (*)' : $h::e(mb_strimwidth($paths, 0, 70, '…'))) . '
'; } - $html = ''; - foreach ($rows as [$label, $value]) { - $html .= ''; - } - $html .= '
' . htmlspecialchars($label) . '' . $value . '
'; - $html .= '

Switch: Site Management › Settings › VITEC. Module "VITEC SSR" for purge & protocol.

'; - return $html; + $html .= '
' . $h::moduleLink() . '
'; + 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(); diff --git a/packages/vitec/Resources/Public/Css/dashboard-ssr.css b/packages/vitec/Resources/Public/Css/dashboard-ssr.css new file mode 100644 index 0000000..35b78bb --- /dev/null +++ b/packages/vitec/Resources/Public/Css/dashboard-ssr.css @@ -0,0 +1,130 @@ +/* VITEC SSR dashboard tiles in the look of the "VITEC SSR" cockpit module + (dark VITEC navy, orange accents; decision Harry, 2026-11-11). + Loaded per widget via AdditionalCssInterface; markup from + Classes/Widgets/CockpitHtml.php. Only touches widgets that contain + .vc-widget, so the rest of the dashboard keeps the core look. */ + +.widget:has(.vc-widget) { + --widget-bg: #0f1640; + --widget-color: #e9ecfb; + --widget-border-color: rgba(140, 156, 255, .18); + --widget-title-bg: #121b4c; + --widget-title-color: #e9ecfb; + --widget-box-shadow: 0 6px 24px rgba(8, 12, 38, .25); + color-scheme: dark; +} + +.vc-widget { + --vcw-panel-2: #151e52; + --vcw-line: rgba(140, 156, 255, .14); + --vcw-navy: #4a5fd6; + --vcw-orange: #f47937; + --vcw-text: #e9ecfb; + --vcw-muted: #8e97c6; + --vcw-faint: #6a73a6; + --vcw-ok: #35d39a; + --vcw-warn: #ffc65c; + --vcw-bad: #ff5f73; + --vcw-sky: #6fc3ff; + --vcw-mono: var(--typo3-font-family-monospace, ui-monospace, SFMono-Regular, Consolas, monospace); + /* Widgets based on WidgetInterface get no inner padding from the core. */ + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 16px 20px 18px; + background: radial-gradient(120% 90% at 0% 0%, rgba(74, 95, 214, .22), transparent 60%), #0f1640; + display: flex; + flex-direction: column; + gap: 14px; + color: var(--vcw-text); + font-size: 13px; + line-height: 1.45; +} +.vc-widget code { font-family: var(--vcw-mono); font-size: 12px; color: #cdd4ff; background: none; padding: 0; } + +.vcw-ok { color: var(--vcw-ok); } .vcw-warn { color: var(--vcw-warn); } .vcw-bad { color: var(--vcw-bad); } +.vcw-dim { color: var(--vcw-muted); font-size: 12px; margin: 0; } +.vcw-label { font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--vcw-muted); } +.vcw-sub { font-size: 12px; color: var(--vcw-muted); } +.vcw-right { text-align: right; } +.vcw-num { font-size: 26px; font-weight: 800; line-height: 1.1; letter-spacing: -.01em; font-variant-numeric: tabular-nums; white-space: nowrap; } +.vcw-num small, .vcw-val small { font-size: 12px; font-weight: 600; color: var(--vcw-muted); margin-left: 3px; letter-spacing: 0; } + +/* Head with pulse */ +.vcw-head { display: flex; align-items: center; gap: 12px; } +.vcw-title { font-size: 22px; font-weight: 800; line-height: 1.15; } +.vcw-pulse { position: relative; flex: none; width: 12px; height: 12px; border-radius: 50%; background: var(--vcw-ok); color: var(--vcw-ok); } +.vcw-pulse::after { content: ""; position: absolute; inset: -5px; border-radius: 50%; border: 2px solid currentColor; opacity: 0; animation: vcw-ping 2.4s ease-out infinite; } +.vcw-pulse.is-warn { background: var(--vcw-warn); color: var(--vcw-warn); } +.vcw-pulse.is-down { background: var(--vcw-bad); color: var(--vcw-bad); } +@keyframes vcw-ping { 0% { transform: scale(.5); opacity: .8; } 100% { transform: scale(1.6); opacity: 0; } } + +/* Ring gauge */ +.vcw-ring { position: relative; flex: none; } +.vcw-ring svg { display: block; transform: rotate(-90deg); } +.vcw-ring circle { fill: none; stroke-width: 9; } +.vcw-track { stroke: rgba(140, 156, 255, .12); } +.vcw-val { stroke-linecap: round; } +circle.vcw-val.is-ok { stroke: var(--vcw-ok); } circle.vcw-val.is-sky { stroke: var(--vcw-sky); } circle.vcw-val.is-orange { stroke: var(--vcw-orange); } +.vcw-ring-c { position: absolute; inset: 0; display: grid; place-items: center; font-size: 14px; font-weight: 800; font-variant-numeric: tabular-nums; } +.vcw-gauge { display: flex; align-items: center; gap: 14px; min-width: 0; } +.vcw-grow { flex: 1; min-width: 0; } + +.vcw-split { display: flex; flex-direction: column; gap: 14px; } +.vcw-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 8px 16px; margin: 0; } +.vcw-facts > div { min-width: 0; } +.vcw-facts dt { font-size: 10.5px; font-weight: 400; letter-spacing: .08em; text-transform: uppercase; color: var(--vcw-faint); } +.vcw-facts dd { margin: 2px 0 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* Pills */ +.vcw-pill { display: inline-flex; align-items: center; font-size: 11px; font-weight: 600; line-height: 1; letter-spacing: .03em; padding: 4px 8px; border-radius: 99px; white-space: nowrap; background: rgba(140, 156, 255, .12); color: #c4cbf0; } +.vcw-pill.is-ok { background: rgba(53, 211, 154, .14); color: var(--vcw-ok); } +.vcw-pill.is-warn { background: rgba(255, 198, 92, .15); color: var(--vcw-warn); } +.vcw-pill.is-bad { background: rgba(255, 95, 115, .15); color: var(--vcw-bad); } +.vcw-pill.is-sky { background: rgba(111, 195, 255, .14); color: var(--vcw-sky); } +.vcw-pill.is-navy { background: rgba(74, 95, 214, .24); color: #b8c3ff; } +.vcw-pill.is-orange { background: rgba(244, 121, 55, .16); color: var(--vcw-orange); } +.vcw-pill.is-muted { color: var(--vcw-muted); } + +/* Last purge line */ +.vcw-last { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; padding: 10px 12px; border-radius: 10px; background: var(--vcw-panel-2); border: 1px solid var(--vcw-line); } +.vcw-last .vcw-label { margin-right: 4px; } +.vcw-time { font: 500 12px var(--vcw-mono); color: var(--vcw-muted); } +.vcw-path { flex-basis: 100%; font: 12px var(--vcw-mono); color: #cdd4ff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* Availability strip */ +.vcw-strip-h { display: flex; justify-content: space-between; align-items: flex-end; gap: 12px; } +.vcw-bars { display: grid; grid-template-columns: repeat(96, minmax(0, 1fr)); gap: 1px; height: 28px; align-items: end; margin-top: -4px; } +.vcw-bars i { display: block; height: 100%; border-radius: 1px; background: var(--vcw-ok); opacity: .85; } +.vcw-bars i.is-slow { background: var(--vcw-warn); } +.vcw-bars i.is-down { background: var(--vcw-bad); } +.vcw-bars i.is-none { background: rgba(140, 156, 255, .14); height: 40%; } +.vcw-bars i:hover { opacity: 1; } +.vcw-axis { display: flex; justify-content: space-between; font: 10.5px var(--vcw-mono); color: var(--vcw-faint); margin-top: -8px; } + +/* Number tiles */ +.vcw-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); gap: 8px; } +.vcw-tile { background: var(--vcw-panel-2); border: 1px solid var(--vcw-line); border-radius: 10px; padding: 9px 11px; min-width: 0; } +.vcw-tile .vcw-label { font-size: 10px; } +.vcw-val { font-size: 20px; font-weight: 800; line-height: 1.2; font-variant-numeric: tabular-nums; white-space: nowrap; } +.vcw-tile .vcw-sub { font-size: 11px; } + +/* Hit rate split bar */ +.vcw-split-bar { display: flex; height: 8px; border-radius: 99px; overflow: hidden; background: rgba(140, 156, 255, .12); margin-top: 8px; } +.vcw-split-bar i { display: block; height: 100%; } +.vcw-split-bar i.is-navy, .vcw-legend i.is-navy { background: var(--vcw-navy); } +.vcw-split-bar i.is-sky, .vcw-legend i.is-sky { background: var(--vcw-sky); } +.vcw-split-bar i.is-warn, .vcw-legend i.is-warn { background: var(--vcw-warn); } +.vcw-legend { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 6px; font-size: 11px; color: var(--vcw-muted); font-variant-numeric: tabular-nums; } +.vcw-legend span { display: inline-flex; align-items: center; gap: 5px; } +.vcw-legend i { width: 8px; height: 8px; border-radius: 2px; display: inline-block; } +.vcw-note { margin: 0; font-size: 11px; color: var(--vcw-faint); } + +.vcw-foot { margin-top: auto; padding-top: 2px; } +.vcw-link { color: var(--vcw-orange); font-weight: 700; font-size: 12.5px; text-decoration: none; } +.vcw-link:hover, .vcw-link:focus { color: var(--vcw-orange); text-decoration: underline; } +.vc-widget a:focus-visible { outline: 2px solid var(--vcw-orange); outline-offset: 2px; } + +@media (prefers-reduced-motion: reduce) { + .vcw-pulse::after { animation: none; } +}