diff --git a/packages/vitec/Classes/Controller/Backend/SsrController.php b/packages/vitec/Classes/Controller/Backend/SsrController.php index 70c123f..78378fd 100644 --- a/packages/vitec/Classes/Controller/Backend/SsrController.php +++ b/packages/vitec/Classes/Controller/Backend/SsrController.php @@ -50,7 +50,7 @@ final class SsrController $view->setTitle('VITEC SSR'); [$health, $healthError] = $this->fetchHealth(); - $this->pageRenderer->loadJavaScriptModule('@evomedien/vitec/ssr-warm-progress.js'); + $this->pageRenderer->loadJavaScriptModule('@evomedien/vitec/ssr-cockpit.js'); $this->pageRenderer->loadJavaScriptModule('@evomedien/vitec/ssr-module.js'); $this->pageRenderer->addCssFile('EXT:vitec/Resources/Public/Css/backend-ssr.css'); @@ -68,8 +68,32 @@ final class SsrController } $healthLog = $this->rows('tx_vitec_ssrhealthlog', 10); $lastSample = $healthLog[0] ?? null; + $clientErrors = $this->clientErrors(50); + $cockpit = $this->cockpitData($health, $lastSample); + + $settingsUri = ''; + if ($GLOBALS['BE_USER']->isAdmin()) { + try { + $sites = GeneralUtility::makeInstance(SiteFinder::class)->getAllSites(); + $site = reset($sites); + if ($site) { + $settingsUri = (string)$this->uriBuilder->buildUriFromRoute( + 'site_configuration.editSettings', + ['site' => $site->getIdentifier()] + ); + } + } catch (\Throwable) { + $settingsUri = ''; + } + } $view->assignMultiple([ + 'cockpit' => $cockpit, + 'cockpitJson' => json_encode($cockpit, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT), + 'settingsUri' => $settingsUri, + 'failoverAfter' => $this->purgeService->getFailoverAfter(), + 'chunkCount24h' => count(array_filter($clientErrors, static fn(array $r): bool => $r['is_chunk_reload'])), + 'jsErrorCount24h' => count(array_filter($clientErrors, static fn(array $r): bool => !$r['is_chunk_reload'])), 'flagOn' => file_exists(Environment::getVarPath() . '/vitec-ssr-on.flag'), 'configured' => $this->purgeService->isConfigured(), 'health' => $health, @@ -81,7 +105,7 @@ final class SsrController 'purgeLimit' => $limit, 'moduleUri' => (string)$this->uriBuilder->buildUriFromRoute('web_vitecssr'), 'healthLog' => $healthLog, - 'clientErrors' => $this->clientErrors(50), + 'clientErrors' => $clientErrors, 'clientErrorCount24h' => $this->clientErrorCount24h(), ]); return $view->renderResponse('Ssr/Index'); @@ -123,7 +147,20 @@ final class SsrController private function purgeRows(int $limit): array { $rows = $this->rows('tx_vitec_ssrpurgelog', $limit); + $today = date('Y-m-d'); + $yesterday = date('Y-m-d', time() - 86400); + $lastDay = ''; foreach ($rows as &$row) { + // Day separators for the activity timeline. + $day = date('Y-m-d', (int)$row['crdate']); + $row['day_label'] = match ($day) { + $today => 'Today', + $yesterday => 'Yesterday', + default => date('D, d.m.', (int)$row['crdate']), + }; + $row['day_first'] = $day !== $lastDay; + $lastDay = $day; + $trigger = (string)$row['trigger_source']; $who = ''; if (str_contains($trigger, ':')) { @@ -136,10 +173,13 @@ final class SsrController default => $trigger, }; $row['trigger_who'] = $who; - $row['trigger_badge'] = match ($trigger) { - 'module', 'contextmenu' => 'badge-info', - 'switch-on' => 'badge-warning', - default => 'badge-secondary', + // Cockpit tone per trigger (pill + timeline dot colour). + $row['trigger_tone'] = match ($trigger) { + 'editor-save', 'editor-command' => 'ok', + 'module' => 'orange', + 'contextmenu' => 'navy', + 'switch-on' => 'warn', + default => 'info', }; $paths = preg_split('/\s*,\s*/', trim((string)$row['paths']), -1, PREG_SPLIT_NO_EMPTY) ?: []; $row['paths_all'] = $paths === ['*']; @@ -151,6 +191,205 @@ final class SsrController return $rows; } + /** + * Everything the cockpit draws (ssr-cockpit.js reads it as JSON): + * availability strip, traffic buckets for 24 h / 7 days, response + * sparkline, hit rate, client errors per hour. Source is the health + * log (cron vitec:ssr-health, every 5 min, kept 14 days); the node's + * hit/miss/stale counters are cumulative since its last start, so + * traffic is the difference between two samples (a smaller value + * than before means the node restarted: then the new value counts). + */ + private function cockpitData(?array $health, ?array $lastSample): array + { + $now = time(); + $tz = (int)date('Z'); + $from = $now - 7 * 86400 - 4 * 3600; + + $samples = []; + $purges = []; + $errors = []; + try { + $samples = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog')->executeQuery( + 'SELECT crdate, ok, response_ms, cached_pages, cache_mb, stat_hit, stat_miss, stat_stale, stat_bypass,' + . ' stat_render_errors, stat_backend_errors FROM tx_vitec_ssrhealthlog WHERE crdate > ? ORDER BY crdate, uid', + [$from] + )->fetchAllAssociative(); + $purges = $this->connectionPool->getConnectionForTable('tx_vitec_ssrpurgelog')->executeQuery( + 'SELECT crdate, paths, success FROM tx_vitec_ssrpurgelog WHERE crdate > ? ORDER BY crdate', + [$from] + )->fetchAllAssociative(); + $errors = $this->connectionPool->getConnectionForTable('tx_vitec_clienterrorlog')->executeQuery( + 'SELECT crdate, message FROM tx_vitec_clienterrorlog WHERE crdate > ?', + [$now - 86400] + )->fetchAllAssociative(); + } catch (\Throwable) { + // Tables missing on a fresh system: the cockpit shows empty charts. + } + + // Traffic deltas between consecutive samples with counters. + $keys = ['hit', 'miss', 'stale', 'bypass', 'render_errors', 'backend_errors']; + $prev = null; + foreach ($samples as &$s) { + foreach ($s as $k => $v) { + $s[$k] = (int)$v; + } + $s['d'] = null; + if ($s['stat_hit'] >= 0) { + if ($prev !== null) { + $d = []; + foreach ($keys as $k) { + $c = max(0, $s['stat_' . $k]); + $p = max(0, $prev['stat_' . $k]); + $d[$k] = $c >= $p ? $c - $p : $c; + } + $s['d'] = $d; + } + $prev = $s; + } + } + unset($s); + + $buckets = function (int $size, int $count) use ($now, $tz, $samples, $purges): array { + $end = intdiv($now + $tz, $size) * $size - $tz + $size; + $start = $end - $size * $count; + $out = []; + for ($i = 0; $i < $count; $i++) { + $out[] = ['t' => $start + $i * $size, 'hit' => 0, 'stale' => 0, 'miss' => 0, 'bypass' => 0, + 'errors' => 0, 'msSum' => 0, 'msN' => 0, 'purges' => 0, 'purgeAll' => 0]; + } + foreach ($samples as $s) { + $i = intdiv($s['crdate'] - $start, $size); + if ($i < 0 || $i >= $count) { + continue; + } + if ($s['d'] !== null) { + $out[$i]['hit'] += $s['d']['hit']; + $out[$i]['stale'] += $s['d']['stale']; + $out[$i]['miss'] += $s['d']['miss']; + $out[$i]['bypass'] += $s['d']['bypass']; + $out[$i]['errors'] += $s['d']['render_errors'] + $s['d']['backend_errors']; + } + if ($s['ok'] === 1) { + $out[$i]['msSum'] += $s['response_ms']; + $out[$i]['msN']++; + } + } + foreach ($purges as $p) { + $i = intdiv((int)$p['crdate'] - $start, $size); + if ($i < 0 || $i >= $count) { + continue; + } + $out[$i]['purges']++; + if (trim((string)$p['paths']) === '*') { + $out[$i]['purgeAll']++; + } + } + foreach ($out as &$b) { + $b['ms'] = $b['msN'] > 0 ? (int)round($b['msSum'] / $b['msN']) : null; + unset($b['msSum'], $b['msN']); + } + return $out; + }; + + // Availability strip: 96 windows of 15 minutes. + $slot = 900; + $stripEnd = intdiv($now, $slot) * $slot + $slot; + $strip = array_fill(0, 96, null); + $ok24 = 0; + $n24 = 0; + $ms24 = []; + $slow = 0; + $down = 0; + foreach ($samples as $s) { + $i = 95 - intdiv($stripEnd - 1 - $s['crdate'], $slot); + if ($i < 0) { + continue; + } + $n24++; + $state = $s['ok'] !== 1 ? 2 : ($s['response_ms'] > 400 ? 1 : 0); + if ($s['ok'] === 1) { + $ok24++; + $ms24[] = $s['response_ms']; + } + $cur = $strip[$i]; + if ($cur === null || $state > $cur['s']) { + $strip[$i] = ['s' => $state, 'ms' => $s['response_ms']]; + } + } + foreach ($strip as $w) { + if ($w !== null && $w['s'] === 1) { + $slow++; + } + if ($w !== null && $w['s'] === 2) { + $down++; + } + } + sort($ms24); + $pct = static function (array $sorted, float $p): ?int { + if ($sorted === []) { + return null; + } + return $sorted[(int)min(count($sorted) - 1, floor($p * (count($sorted) - 1) + 0.5))]; + }; + + // Hit rate now (last 24 h) and the 24 h before. + $rate = static function (array $samples, int $a, int $b): ?float { + $hit = 0; + $all = 0; + foreach ($samples as $s) { + if ($s['d'] === null || $s['crdate'] <= $a || $s['crdate'] > $b) { + continue; + } + $hit += $s['d']['hit'] + $s['d']['stale']; + $all += $s['d']['hit'] + $s['d']['stale'] + $s['d']['miss']; + } + return $all > 0 ? round($hit / $all * 100, 1) : null; + }; + + // Response time sparkline: the last 48 good samples (4 hours). + $spark = []; + foreach (array_slice(array_values(array_filter($samples, static fn(array $s): bool => $s['ok'] === 1)), -48) as $s) { + $spark[] = [$s['crdate'], $s['response_ms']]; + } + + // Client errors per hour, last 24 h. + $errHours = array_fill(0, 24, 0); + $hourEnd = intdiv($now, 3600) * 3600 + 3600; + foreach ($errors as $e) { + $i = 23 - intdiv($hourEnd - 1 - (int)$e['crdate'], 3600); + if ($i >= 0 && $i < 24) { + $errHours[$i]++; + } + } + + $cachedPages = $health['cachedPages'] ?? ($lastSample['cached_pages'] ?? null); + $cacheMb = $health['cacheMB'] ?? ($lastSample['cache_mb'] ?? null); + + return [ + 'now' => $now, + 'online' => $health !== null, + 'warming' => $health['warming'] ?? null, + 'strip' => ['end' => $stripEnd, 'slot' => $slot, 'w' => $strip], + 'uptimePct' => $n24 > 0 ? round($ok24 / $n24 * 100, 1) : null, + 'slowWindows' => $slow, + 'downWindows' => $down, + 'msMedian' => $pct($ms24, 0.5), + 'msP95' => $pct($ms24, 0.95), + 'spark' => $spark, + 'hitRate' => $rate($samples, $now - 86400, $now), + 'hitRatePrev' => $rate($samples, $now - 2 * 86400, $now - 86400), + 'cachedPages' => $cachedPages !== null ? (int)$cachedPages : null, + 'cacheMb' => $cacheMb !== null ? (int)$cacheMb : null, + 'cacheLimitMb' => $this->purgeService->getCacheLimitMb(), + 'errHours' => $errHours, + 'range' => [ + '24h' => ['size' => 3600, 'b' => $buckets(3600, 24)], + '7d' => ['size' => 14400, 'b' => $buckets(14400, 42)], + ], + ]; + } + private function clientErrorCount24h(): int { try { @@ -258,8 +497,8 @@ final class SsrController } /** - * Ajax route vitec_ssr_health: live status for the module's - * "Cache warming" progress bar (ssr-warm-progress.js polls it). + * Ajax route vitec_ssr_health: live status for the cockpit (warm + * progress ring, cache fill, online state; ssr-cockpit.js polls it). */ public function healthJson(ServerRequestInterface $request): ResponseInterface { diff --git a/packages/vitec/Configuration/Backend/AjaxRoutes.php b/packages/vitec/Configuration/Backend/AjaxRoutes.php index 1b544d5..0dd1906 100755 --- a/packages/vitec/Configuration/Backend/AjaxRoutes.php +++ b/packages/vitec/Configuration/Backend/AjaxRoutes.php @@ -33,8 +33,8 @@ return [ 'path' => '/vitec/ssr/purge-page', 'target' => \Evomedien\Vitec\Controller\Backend\SsrController::class . '::purgePage', ], - // Live "Cache warming" progress bar in the VITEC SSR module - // (Resources/Public/Javascript/ssr-warm-progress.js polls it). + // Live status of the VITEC SSR cockpit + // (Resources/Public/Javascript/ssr-cockpit.js polls it). 'vitec_ssr_health' => [ 'path' => '/vitec/ssr/health', 'target' => \Evomedien\Vitec\Controller\Backend\SsrController::class . '::healthJson', diff --git a/packages/vitec/Configuration/JavaScriptModules.php b/packages/vitec/Configuration/JavaScriptModules.php index c75bb29..3c3d35c 100755 --- a/packages/vitec/Configuration/JavaScriptModules.php +++ b/packages/vitec/Configuration/JavaScriptModules.php @@ -9,7 +9,7 @@ declare(strict_types=1); * JavaScriptModuleInstruction::create('@evomedien/vitec/.js'). */ return [ - 'dependencies' => ['backend', 'core'], + 'dependencies' => ['backend', 'core', 'dashboard'], 'imports' => [ '@evomedien/vitec/' => 'EXT:vitec/Resources/Public/Javascript/', ], diff --git a/packages/vitec/Resources/Private/Templates/Ssr/Index.html b/packages/vitec/Resources/Private/Templates/Ssr/Index.html index 10b5dc8..4e5cc2c 100644 --- a/packages/vitec/Resources/Private/Templates/Ssr/Index.html +++ b/packages/vitec/Resources/Private/Templates/Ssr/Index.html @@ -6,299 +6,297 @@ -

VITEC SSR — Control & Protocol

+
-
-
-
-
-

Status

-
-
-
-
-
SSR delivery
-
- - ON pages are served from the VPS - OFF SPA as before, TYPO3 serves the shell - -
Toggle: Site Management › Settings › VITEC › SSR delivery. Switching on purges the node cache once.
-
-
-
-
SSR server
-
- - - running - {health.responseMs} ms -
Release {health.release.release} - commit {health.release.commit}
-
Cache: {health.cachedPages} pages · {health.cacheMB} MB of {cacheLimitMb} MB
-
- - not reachable - {healthError} -
After {failoverAfter} failed checks in a row the cron switches delivery back to the SPA.
-
-
-
-
-
-
Cache warming
-
-
-
0 %
-
-
- - - running - {health.warming.done} of {health.warming.queued} pages rendered - ({health.warming.skipped} still fresh, {health.warming.failed} failed) -
Live — updates every 2 seconds.
-
- - - - Last run: {health.warming.done} of {health.warming.queued} pages rendered - ({health.warming.skipped} still fresh, - - {health.warming.failed} failed - 0 failed - ) - aborted by restart - - no run since the server started - - -
-
-
-
-
-
Health check
-
- - - - ok - down - - {lastSampleAge} ago - · {lastSample.response_ms} ms · {lastSample.cached_pages} pages -
Every 5 minutes via cron vitec:ssr-health; alert mail and automatic fallback to the SPA after repeated failures.
-
- no samples yet -
-
-
-
-
Purge webhook
-
- - configured endpoint and secret from var/vitec-ssr-purge.env - not configured var/vitec-ssr-purge.env is missing or incomplete - -
-
-
-
-
-
+

VITEC SSR — Control & Protocol

-
-
-
-

Actions

-
-
-
-

Purge paths

- - -
One path per line, e.g. /products/aligo. Drops those pages (all host and query variants) from the SSR cache; they are rendered again on their next request.
- - -
+
-
-

Purge everything

- - -
For menu or footer changes while auto-purge is off, and after a frontend deploy.
-
- -
-

Warm cache

- -
Pre-renders every sitemap page so no visitor waits for a first render. Use it after “Purge everything” or a deploy; progress shows in the status card.
-
-
-
-
-
- -
-
-

Purge protocol

-
- 30 - 100 - 500 -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - -
TimeTriggerPathsResultDuration
@{row.crdate} - {row.trigger_label} - {row.trigger_who} - - - all (*) - - {row.paths_count} paths - {p} - -
- show all {row.paths_count} - {p} -
+ +
+
+
+ + + +
+

SSR online

+

+ + Pages are rendered on the VPS + Node is ready, but delivery is off: TYPO3 serves the SPA - + · last check {lastSampleAge} ago +

+
+
+ + +
+

SSR not reachable

+

{healthError} · after {failoverAfter} failed checks in a row the cron switches delivery back to the SPA

+
+
+
+
+
+
+
Delivery
+
+ + ON + OFF -
- - ok - HTTP {row.http_code} {row.message} + + Site settings + via Site settings - {row.duration_ms} ms
No purges logged yet.
-
-
+ +
+ +
Release
{health.release.release}
+
Commit
{health.release.commit}
+
+
+
Purge webhook
+
+ + configured + not configured + +
+
+
+
Auto failover
+
after {failoverAfter} failed checks
+
+ +
+
+
+ Availability, last 24 hours · one bar per 15 minutes + – +
+ +
+
+ -
-
-

Client errors last 24 h — {clientErrorCount24h} reports, newest 50 shown

-
-
-

- Browser-side failures reported by the React frontend via POST /api/vitec/client-error - (JS errors, unhandled promise rejections, element error boundaries and “chunk reload” after a deploy). - Limited to 30 reports per minute and IP, 8 KB per report; IPs are stored anonymized; kept for 30 days - (cron vitec:client-error-cleanup). A release older than the one running above means an open tab with an old bundle. -

-
-
- - - - - - - - - - - - - - - - - - - -
TimeReleaseMessagePageBrowserIP
@{row.crdate}{row.release}— - - chunk reload - error - - {row.message} -
- details - - - - - - - - -
Reported@{row.crdate} (entry #{row.uid})
Release{row.release}not transmitted
URL{row.url}not transmitted
Message{row.message}
Stack - -
{row.stack}
- no stack transmitted + +
+
+ +
+
Hit rate · 24 h
+
–%
+
served from cache or refreshed in the background
+
+
+
+ +
+
Response time
+
{health.responseMs}–ms
+
median 24 h – · p95 –
+ +
+ +
+
+ +
+
Cache fill
+
{cockpit.cacheMb}/ {cacheLimitMb} MBMB
+
{cockpit.cachedPages} pages cached
+
+
+
+ +
+
Client errors · 24 h
+
{clientErrorCount24h}reports
+
{chunkCount24h} chunk reloads · {jsErrorCount24h} JS errors
+ +
+ + +
+
+

Traffic and cache

+
+ + +
+
+
+ Served from cache + Stale, refreshed in background + Rendered fresh + Response time + Purge +
+
+ +
+
Requests
–
+
Hit rate
–
+
Render errors
–
+
Purges
–
+
+
+ + +
+

Actions

+
+ +
+

Warm cache

+
+
+ +
–
+
+
no run since the server started
+
+
+ +
+

Renders every sitemap page in advance, so no visitor waits for a first render. Run it after “Purge everything” or a deploy.

+
+ +
+

Purge paths

+ + + +
+

Drops those pages, all hosts and query variants, from the SSR cache. They render again on their next request.

+
+ +
+

Purge everything

+

For menu or footer changes while auto-purge is off, and after a frontend deploy.

+ +
+
+
+
+ + +
+
+

Purge activity

+
+ 30 + 100 + 500 +
+
+
+ auto-purge + manual + context menu + cli / deploy + switch-on +
+ + +
    + +
  1. {row.day_label}
  2. +
  3. + + +
    +
    + {row.trigger_label} + {row.trigger_who} +
    +
    + + all pages (*) + + {p} + +
    + + more + {p} +
    +
    +
    -
Browser{row.user_agent}not transmitted
IP{row.ip} (anonymized)
-
-
{row.path}{row.ua_short}{row.ip}
No client errors reported in the last 24 hours.
-
-
+ + +
+ + ok + HTTP {row.http_code} + + {row.duration_ms} ms +
+ + + + +

No purges logged yet. Saving a page, the context menu “Purge from SSR cache” or the buttons on the right create the first entry.

+ + -
-
-

Health samples last 10 — cron vitec:ssr-health, every 5 minutes

-
-
- - - - - - - - - - - - - - - - - - -
TimeStatusReleaseCacheResponse time
@{row.crdate} - - ok - down - - {row.release_id}{row.cached_pages} pages · {row.cache_mb} MB{row.response_ms} ms
No samples yet.
-
+ +
+
+

Client errors

+ kept 30 days +
+
+
24 h
{clientErrorCount24h}
+
Chunk reloads
{chunkCount24h}
+
JS errors
{jsErrorCount24h}
+
+ + +
+ +
+ + + chunk reload + error + + {row.message}{row.path} + @{row.crdate} + +
+
+
Reported
@{row.crdate} · entry #{row.uid}
+
Release
{row.release} old bundlenot transmitted
+
URL
{row.url}not transmitted
+
Browser
{row.user_agent}not transmitted
+
IP
{row.ip} anonymized
+
+ +
{row.stack}
+

No stack transmitted.

+
+
+
+
+
+
+

No client errors in the last 24 hours. The frontend reports JS errors and chunk reloads here as they happen.

+
+
+ +
diff --git a/packages/vitec/Resources/Public/Css/backend-ssr.css b/packages/vitec/Resources/Public/Css/backend-ssr.css index 6752f91..e3b8100 100644 --- a/packages/vitec/Resources/Public/Css/backend-ssr.css +++ b/packages/vitec/Resources/Public/Css/backend-ssr.css @@ -1,55 +1,229 @@ -/* VITEC SSR module (web_vitecssr): layout glue on top of TYPO3's own - components - cards, badges, buttons and tables come from the core. */ +/* VITEC SSR cockpit (module web_vitecssr). + Deliberately dark, in VITEC colours, independent of the backend theme + (decision Harry, 2026-11-11). The TYPO3 frame stays core: doc header, + flash messages, confirmation modals. Everything here is scoped to + .vitec-cockpit or to this module's body. + Layout: live band + availability / four gauges / traffic 8 + actions 4 / + purge activity 7 + client errors 5; below 1200 px the wide pairs stack. */ -.ssr-status { - margin: 0; +[data-module-name="web_vitecssr"] .module-body { + --module-bg: #080c26; + background-color: #080c26; } -.ssr-status-row { - display: grid; - grid-template-columns: 9rem minmax(0, 1fr); - gap: 0 1rem; - padding: .75rem 0; - border-bottom: 1px solid var(--typo3-component-border-color, #dee2e6); -} -.ssr-status-row:first-child { - padding-top: 0; -} -.ssr-status-row:last-child { - border-bottom: 0; - padding-bottom: 0; -} -.ssr-status-row dt { - font-weight: normal; - color: var(--typo3-text-color-secondary, #6c757d); -} -.ssr-status-row dd { - margin: 0; - min-width: 0; -} -@media (max-width: 575.98px) { - .ssr-status-row { - grid-template-columns: minmax(0, 1fr); - gap: .25rem 0; - } +[data-module-name="web_vitecssr"] .module-body-container { + max-width: 1680px; } -.ssr-action + .ssr-action { - margin-top: 1.5rem; - padding-top: 1.5rem; - border-top: 1px solid var(--typo3-component-border-color, #dee2e6); +.vitec-cockpit { + --vc-ink: #080c26; /* page: VITEC navy pushed to night */ + --vc-panel: #0f1640; /* card */ + --vc-panel-2: #151e52; /* raised inside a card */ + --vc-line: rgba(140, 156, 255, .14); + --vc-line-strong: rgba(140, 156, 255, .28); + --vc-navy: #26358c; /* VITEC blue */ + --vc-navy-hi: #4a5fd6; /* VITEC blue lifted for the dark ground */ + --vc-orange: #f47937; /* VITEC orange: actions and highlights */ + --vc-text: #e9ecfb; + --vc-muted: #8e97c6; + --vc-faint: #6a73a6; + --vc-ok: #35d39a; + --vc-warn: #ffc65c; + --vc-bad: #ff5f73; + --vc-sky: #6fc3ff; + --vc-mono: var(--typo3-font-family-monospace, ui-monospace, SFMono-Regular, Consolas, monospace); + --vc-r: 14px; + color-scheme: dark; + color: var(--vc-text); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; } -.ssr-action-title { - font-size: 1rem; - font-weight: 600; - margin: 0 0 .5rem; +.vitec-cockpit *, +.vitec-cockpit *::before, +.vitec-cockpit *::after { box-sizing: border-box; } +.vitec-cockpit code { font-family: var(--vc-mono); font-size: 12px; color: #cdd4ff; background: none; padding: 0; } +.vitec-cockpit h2, .vitec-cockpit h3 { color: inherit; } +.vc-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } + +.vc-grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 20px; } +.vc-card { background: var(--vc-panel); border: 1px solid var(--vc-line); border-radius: var(--vc-r); padding: 20px; min-width: 0; } +.vc-s12 { grid-column: span 12; } .vc-s8 { grid-column: span 8; } .vc-s4 { grid-column: span 4; } +.vc-s7 { grid-column: span 7; } .vc-s5 { grid-column: span 5; } .vc-s3 { grid-column: span 3; } +@media (max-width: 1199.98px) { + .vc-s8, .vc-s4, .vc-s7, .vc-s5 { grid-column: span 12; } + .vc-s3 { grid-column: span 6; } +} +@media (max-width: 619.98px) { .vc-s3 { grid-column: span 12; } .vc-grid { gap: 14px; } .vc-card { padding: 16px; } } + +.vc-card-h { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; } +.vc-card-t { font-size: 13px; font-weight: 700; line-height: 1.2; letter-spacing: .08em; text-transform: uppercase; color: var(--vc-muted) !important; margin: 0; display: flex; align-items: center; gap: 8px; } +.vc-card-t svg { color: var(--vc-navy-hi); } +.vc-dim { color: var(--vc-muted); font-size: 12px; } +.vc-ok-text { color: var(--vc-ok); } .vc-bad-text { color: var(--vc-bad); } .vc-warn-text { color: var(--vc-warn); } +.vc-link { color: var(--vc-sky); text-decoration: none; font-size: 12px; font-weight: 500; } +.vc-link:hover, .vc-link:focus { color: var(--vc-sky); text-decoration: underline; } + +/* Pills */ +.vc-pill { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 600; line-height: 1; letter-spacing: .04em; padding: 5px 9px; border-radius: 99px; white-space: nowrap; background: rgba(140, 156, 255, .12); color: #c4cbf0; } +.vc-pill.is-ok { background: rgba(53, 211, 154, .14); color: var(--vc-ok); } +.vc-pill.is-warn { background: rgba(255, 198, 92, .15); color: var(--vc-warn); } +.vc-pill.is-bad { background: rgba(255, 95, 115, .15); color: var(--vc-bad); } +.vc-pill.is-sky, .vc-pill.is-info { background: rgba(111, 195, 255, .14); color: var(--vc-sky); } +.vc-pill.is-navy { background: rgba(74, 95, 214, .24); color: #b8c3ff; } +.vc-pill.is-orange { background: rgba(244, 121, 55, .16); color: var(--vc-orange); } +.vc-pill.is-muted { background: rgba(140, 156, 255, .1); color: var(--vc-muted); } + +/* Live band */ +.vc-hero { padding: 24px 24px 20px; background: + radial-gradient(120% 140% at 0% 0%, rgba(74, 95, 214, .35), transparent 55%), + radial-gradient(80% 120% at 100% 0%, rgba(244, 121, 55, .12), transparent 50%), + var(--vc-panel); } +.vc-hero-top { display: flex; flex-wrap: wrap; align-items: center; gap: 18px 28px; } +.vc-live { display: flex; align-items: center; gap: 14px; min-width: 0; } +.vc-pulse { position: relative; width: 14px; height: 14px; border-radius: 50%; background: var(--vc-ok); flex: none; } +.vc-pulse::after { content: ""; position: absolute; inset: -6px; border-radius: 50%; border: 2px solid currentColor; color: var(--vc-ok); opacity: 0; animation: vc-ping 2.4s ease-out infinite; } +.vc-pulse.is-down { background: var(--vc-bad); } +.vc-pulse.is-down::after { color: var(--vc-bad); } +@keyframes vc-ping { 0% { transform: scale(.5); opacity: .8; } 100% { transform: scale(1.6); opacity: 0; } } +.vc-live-title { font-size: 30px; font-weight: 800; line-height: 1.1; letter-spacing: -.01em; margin: 0; } +.vc-live-sub { margin: 2px 0 0; color: var(--vc-muted); font-size: 13px; } +.vc-facts { display: flex; flex-wrap: wrap; gap: 10px 28px; margin: 0 0 0 auto; } +.vc-fact dt { font-size: 11px; font-weight: 400; letter-spacing: .08em; text-transform: uppercase; color: var(--vc-faint); } +.vc-fact dd { margin: 2px 0 0; font-weight: 600; display: flex; align-items: center; gap: 8px; white-space: nowrap; } +@media (max-width: 899.98px) { .vc-facts { margin-left: 0; } } + +.vc-uptime { margin-top: 22px; } +.vc-uptime-h { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--vc-muted); margin-bottom: 8px; } +.vc-uptime-h b { color: var(--vc-text); font-weight: 600; } +.vc-bars { display: grid; grid-template-columns: repeat(96, minmax(0, 1fr)); gap: 2px; height: 30px; align-items: end; } +.vc-bars i { display: block; height: 100%; border-radius: 2px; background: var(--vc-ok); opacity: .85; transition: opacity .15s, transform .15s; } +.vc-bars i.is-slow { background: var(--vc-warn); } +.vc-bars i.is-down { background: var(--vc-bad); } +.vc-bars i.is-none { background: rgba(140, 156, 255, .14); height: 40%; } +.vc-bars i:hover { opacity: 1; transform: scaleY(1.12); } +@media (max-width: 619.98px) { .vc-bars { gap: 1px; } } +.vc-uptime-f { display: flex; justify-content: space-between; font: 11px var(--vc-mono); color: var(--vc-faint); margin-top: 6px; } + +/* Gauges */ +.vc-tile { display: flex; gap: 14px; align-items: center; } +.vc-tile .vc-ring { width: 78px; height: 78px; } +.vc-tile-body { min-width: 0; flex: 1; } +.vc-label { font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--vc-muted); font-weight: 600; } +.vc-big { font-size: 32px; font-weight: 800; line-height: 1.05; margin: 6px 0 4px; font-variant-numeric: tabular-nums; letter-spacing: -.02em; white-space: nowrap; } +.vc-big small { font-size: 15px; font-weight: 600; color: var(--vc-muted); margin-left: 4px; letter-spacing: 0; } +.vc-sub { font-size: 12px; color: var(--vc-muted); } +.vc-sub b { color: var(--vc-text); } +.vc-sub .is-up { color: var(--vc-ok); } .vc-sub .is-down { color: var(--vc-bad); } +.vc-ring { flex: none; } +.vc-ring circle { fill: none; stroke-width: 9; } +.vc-ring .vc-track { stroke: rgba(140, 156, 255, .12); } +.vc-ring .vc-val { stroke-linecap: round; transform: rotate(-90deg); transform-origin: 50% 50%; stroke-dasharray: 251.33; stroke-dashoffset: 251.33; transition: stroke-dashoffset 1s cubic-bezier(.2, .8, .2, 1); } +.vc-ring .vc-val.is-fast { transition-duration: .4s; } +.vc-val.is-ok { stroke: var(--vc-ok); } .vc-val.is-sky { stroke: var(--vc-sky); } .vc-val.is-orange { stroke: var(--vc-orange); } +.vc-spark { display: block; width: 100%; height: 46px; margin-top: 6px; } +.vc-mini { display: grid; grid-template-columns: repeat(24, minmax(0, 1fr)); gap: 2px; align-items: end; height: 40px; margin-top: 8px; } +.vc-mini i { display: block; background: var(--vc-navy-hi); border-radius: 2px 2px 0 0; min-height: 2px; } +.vc-mini i.is-hot { background: var(--vc-bad); } + +/* Segmented control */ +.vc-seg { display: inline-flex; background: var(--vc-panel-2); border: 1px solid var(--vc-line); border-radius: 99px; padding: 3px; } +.vc-seg button, .vc-seg a { border: 0; background: transparent; color: var(--vc-muted); font-size: 12px; font-weight: 600; padding: 5px 12px; border-radius: 99px; cursor: pointer; text-decoration: none; line-height: 1.4; } +.vc-seg button:hover, .vc-seg a:hover { color: var(--vc-text); text-decoration: none; } +.vc-seg [aria-pressed="true"] { background: var(--vc-navy); color: #fff; } +.vitec-cockpit :is(button, a, textarea, summary):focus-visible { outline: 2px solid var(--vc-orange); outline-offset: 2px; } + +.vc-legend { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 12px; color: var(--vc-muted); } +.vc-legend span { display: inline-flex; align-items: center; gap: 6px; } +.vc-legend i { width: 10px; height: 10px; border-radius: 3px; display: inline-block; } +.vc-legend i.is-navy { background: var(--vc-navy-hi); } .vc-legend i.is-sky { background: var(--vc-sky); } +.vc-legend i.is-warn { background: var(--vc-warn); } .vc-legend i.is-orange { background: var(--vc-orange); } +.vc-legend i.is-bad { background: var(--vc-bad); } .vc-legend i.is-ok { background: var(--vc-ok); } +.vc-legend i.is-round { border-radius: 50%; } +.vc-legend i.is-tri { border-radius: 0; clip-path: polygon(50% 0, 100% 100%, 0 100%); } +.vc-legend-tl { margin: -6px 0 10px; } +.vc-chart-card { display: flex; flex-direction: column; } +.vc-chart { position: relative; flex: 1 1 auto; min-height: 320px; margin-top: 12px; overflow: hidden; } +.vc-chart canvas { max-width: 100%; } +@media (max-width: 619.98px) { .vc-chart { min-height: 260px; } } +.vc-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 16px 0 0; padding-top: 16px; border-top: 1px solid var(--vc-line); } +.vc-stats dt { font-size: 11px; font-weight: 400; color: var(--vc-faint); text-transform: uppercase; letter-spacing: .07em; } +.vc-stats dd { margin: 2px 0 0; font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; } +@media (max-width: 619.98px) { .vc-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +.vc-empty { color: var(--vc-muted); font-size: 13px; margin: 12px 0 0; } + +/* Actions */ +.vc-actions { display: flex; flex-direction: column; gap: 14px; } +.vc-act { background: var(--vc-panel-2); border: 1px solid var(--vc-line); border-radius: 12px; padding: 16px; margin: 0; } +.vc-act-h { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.vc-act-t { font-size: 15px; font-weight: 700; margin: 0; } +.vc-help { margin: 8px 0 0; color: var(--vc-muted); font-size: 12.5px; } +.vc-warm { display: flex; gap: 16px; align-items: center; margin-top: 12px; } +.vc-warm-ring { position: relative; flex: none; width: 92px; height: 92px; } +.vc-warm-pct { position: absolute; inset: 0; display: grid; place-items: center; font-size: 18px; font-weight: 800; font-variant-numeric: tabular-nums; } +.vc-warm-meta { font-size: 12.5px; color: var(--vc-muted); min-width: 0; } +.vc-warm-meta b { color: var(--vc-text); font-variant-numeric: tabular-nums; } +.vc-btn-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; } +.vc-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; border-radius: 9px; border: 1px solid transparent; font-size: 13px; font-weight: 700; line-height: 1.2; padding: 9px 14px; cursor: pointer; transition: transform .1s, filter .15s, background-color .15s, border-color .15s; } +.vc-btn:active { transform: scale(.98); } +.vc-btn.is-primary { background: var(--vc-orange); color: #1b0b02; } +.vc-btn.is-primary:hover { filter: brightness(1.08); } +.vc-btn.is-primary[disabled] { background: rgba(244, 121, 55, .3); color: rgba(255, 255, 255, .65); cursor: progress; filter: none; } +.vc-btn.is-ghost { background: transparent; color: var(--vc-text); border-color: var(--vc-line-strong); } +.vc-btn.is-ghost:hover { border-color: var(--vc-navy-hi); background: rgba(74, 95, 214, .14); } +.vc-btn.is-danger { background: transparent; color: var(--vc-bad); border-color: rgba(255, 95, 115, .45); } +.vc-btn.is-danger:hover { background: rgba(255, 95, 115, .15); } +.vc-lbl { display: block; font-size: 12px; color: var(--vc-muted); margin: 12px 0 6px; font-weight: 600; } +.vc-input { width: 100%; resize: vertical; min-height: 84px; background: var(--vc-ink); color: var(--vc-text); border: 1px solid var(--vc-line-strong); border-radius: 9px; padding: 10px 12px; font: 12.5px/1.6 var(--vc-mono); } +.vc-input::placeholder { color: var(--vc-faint); } +.vc-input:focus { border-color: var(--vc-navy-hi); outline: none; } + +/* Purge activity timeline */ +.vc-timeline { list-style: none; margin: 0; padding: 0; position: relative; } +.vc-timeline::before { content: ""; position: absolute; left: 62px; top: 6px; bottom: 6px; width: 1px; background: var(--vc-line-strong); } +.vc-ev { display: grid; grid-template-columns: 52px 22px minmax(0, 1fr) auto; gap: 0 10px; align-items: start; padding: 10px 0; } +.vc-ev + .vc-ev { border-top: 1px dashed rgba(140, 156, 255, .08); } +.vc-ev time { font: 500 12px var(--vc-mono); color: var(--vc-muted); text-align: right; padding-top: 2px; } +.vc-dot { width: 11px; height: 11px; border-radius: 50%; margin: 5px auto 0; box-shadow: 0 0 0 4px var(--vc-panel); position: relative; z-index: 1; background: var(--vc-sky); } +.vc-dot.is-ok { background: var(--vc-ok); } .vc-dot.is-orange { background: var(--vc-orange); } +.vc-dot.is-navy { background: var(--vc-navy-hi); } .vc-dot.is-warn { background: var(--vc-warn); } +.vc-ev-main { min-width: 0; } +.vc-ev-line { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; } +.vc-paths { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px; align-items: flex-start; } +.vc-path { font: 12px var(--vc-mono); background: rgba(140, 156, 255, .08); color: #cdd4ff; border-radius: 6px; padding: 2px 7px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.vc-more { font-size: 12px; } +.vc-more summary { color: var(--vc-sky); cursor: pointer; list-style: none; padding: 2px 4px; } +.vc-more summary::-webkit-details-marker { display: none; } +.vc-more[open] { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 6px; } +.vc-more[open] summary { flex-basis: 100%; } +.vc-ev-r { text-align: right; font: 500 12px var(--vc-mono); color: var(--vc-muted); white-space: nowrap; display: flex; flex-direction: column; align-items: flex-end; gap: 4px; } +.vc-day { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--vc-faint); padding: 14px 0 4px 74px; } +.vc-day:first-child { padding-top: 0; } +@media (max-width: 619.98px) { + .vc-timeline::before { left: 46px; } + .vc-ev { grid-template-columns: 38px 18px minmax(0, 1fr); } + .vc-ev-r { grid-column: 3; flex-direction: row; align-items: center; margin-top: 6px; } + .vc-day { padding-left: 58px; } } -#ssr-warm-button .ssr-warm-busy { - display: none; -} -#ssr-warm-button.is-running .ssr-warm-idle { - display: none; -} -#ssr-warm-button.is-running .ssr-warm-busy { - display: inline; +/* Client errors */ +.vc-err-sum { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin: 0 0 16px; } +.vc-err-sum div { background: var(--vc-panel-2); border-radius: 10px; padding: 10px 12px; min-width: 0; } +.vc-err-sum dt { font-size: 11px; font-weight: 400; color: var(--vc-faint); text-transform: uppercase; letter-spacing: .07em; } +.vc-err-sum dd { margin: 2px 0 0; font-size: 22px; font-weight: 800; font-variant-numeric: tabular-nums; } +.vc-errs { display: flex; flex-direction: column; gap: 8px; } +.vc-err { background: var(--vc-panel-2); border: 1px solid var(--vc-line); border-radius: 10px; } +.vc-err[open] { border-color: var(--vc-line-strong); } +.vc-err summary { list-style: none; cursor: pointer; padding: 12px 14px; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; align-items: center; } +.vc-err summary::-webkit-details-marker { display: none; } +.vc-err-msg { min-width: 0; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.vc-err-msg small { display: block; color: var(--vc-muted); font: 11.5px var(--vc-mono); overflow: hidden; text-overflow: ellipsis; } +.vc-err-t { font: 12px var(--vc-mono); color: var(--vc-muted); white-space: nowrap; } +.vc-err-body { padding: 0 14px 14px; font-size: 12.5px; } +.vc-kv { display: grid; grid-template-columns: 76px minmax(0, 1fr); gap: 6px 12px; margin: 0; } +.vc-kv dt { color: var(--vc-faint); font-weight: 400; } +.vc-kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; } +.vc-stack { margin: 10px 0 0; background: var(--vc-ink); border: 0; border-radius: 8px; padding: 10px 12px; font: 11.5px/1.55 var(--vc-mono); color: #b9c1ef; overflow: auto; max-height: 320px; white-space: pre; } + +@media (prefers-reduced-motion: reduce) { + .vitec-cockpit *, .vitec-cockpit *::after { animation: none !important; transition: none !important; } } diff --git a/packages/vitec/Resources/Public/Javascript/ssr-cockpit.js b/packages/vitec/Resources/Public/Javascript/ssr-cockpit.js new file mode 100644 index 0000000..5710a58 --- /dev/null +++ b/packages/vitec/Resources/Public/Javascript/ssr-cockpit.js @@ -0,0 +1,366 @@ +/** + * VITEC SSR cockpit (module web_vitecssr). + * Draws the gauges, the availability strip, the traffic chart and the + * sparkline from the JSON the controller puts on #ssr-cockpit + * (SsrController::cockpitData), and keeps the live parts current by + * polling the vitec_ssr_health ajax route (GET /__health of the node): + * every 2 s while a warm run is active, every 15 s otherwise. + * Chart.js is the copy TYPO3 ships with EXT:dashboard. + */ +import AjaxRequest from '@typo3/core/ajax/ajax-request.js'; +import { Chart, registerables } from '@typo3/dashboard/contrib/chartjs.js'; + +const root = document.getElementById('ssr-cockpit'); +if (root) { + Chart.register(...registerables); + + let data = {}; + try { + data = JSON.parse(root.dataset.json || '{}'); + } catch (e) { + data = {}; + } + + const C = 2 * Math.PI * 40; + const $ = (id) => document.getElementById(id); + const nf = new Intl.NumberFormat('en-GB'); + const num = (v) => (typeof v === 'number' && isFinite(v) ? v : 0); + const hhmm = (ts) => new Date(ts * 1000).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }); + const css = getComputedStyle(root); + const tok = (name) => css.getPropertyValue(name).trim(); + const col = { + navy: tok('--vc-navy-hi'), sky: tok('--vc-sky'), warn: tok('--vc-warn'), bad: tok('--vc-bad'), + orange: tok('--vc-orange'), ok: tok('--vc-ok'), faint: tok('--vc-faint'), panel: tok('--vc-panel'), + text: tok('--vc-text'), muted: tok('--vc-muted'), + }; + + const setRing = (el, frac) => { + if (!el) { + return; + } + el.style.strokeDasharray = String(C); + el.style.strokeDashoffset = String(C * (1 - Math.max(0, Math.min(1, frac)))); + }; + + /* Hit rate gauge */ + const hit = data.hitRate; + if (typeof hit === 'number') { + $('vc-hit').textContent = hit.toFixed(1); + requestAnimationFrame(() => setRing($('vc-ring-hit'), hit / 100)); + if (typeof data.hitRatePrev === 'number') { + const d = Math.round((hit - data.hitRatePrev) * 10) / 10; + const cls = d >= 0 ? 'is-up' : 'is-down'; + $('vc-hit-trend').innerHTML = '' + (d >= 0 ? '▲ ' : '▼ ') + Math.abs(d).toFixed(1) + + ' pts vs. the 24 h before'; + } + } else { + $('vc-hit-trend').textContent = 'no traffic counters yet'; + } + + /* Response time */ + if (data.msMedian !== null && data.msMedian !== undefined) { + $('vc-ms-median').textContent = data.msMedian + ' ms'; + $('vc-ms-p95').textContent = data.msP95 + ' ms'; + } + const drawSpark = () => { + const sp = (data.spark || []).map((p) => p[1]); + const el = $('vc-spark'); + if (!el || sp.length < 2) { + return; + } + const w = 300; + const h = 46; + const max = Math.max(...sp) * 1.08; + const min = Math.min(...sp) * 0.85; + const span = Math.max(1, max - min); + const pts = sp.map((v, i) => [(i / (sp.length - 1)) * w, h - ((v - min) / span) * h]); + const d = pts.map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' '); + const last = pts[pts.length - 1]; + el.innerHTML = '' + + '' + + '' + + '' + + '' + + ''; + }; + drawSpark(); + + /* Cache fill */ + const renderCache = (pages, mb) => { + const limit = num(data.cacheLimitMb); + if (typeof pages === 'number') { + $('vc-cache-pages').textContent = nf.format(pages); + } + if (typeof mb === 'number') { + $('vc-cache-mb').textContent = nf.format(mb); + if (limit > 0) { + setRing($('vc-ring-cache'), mb / limit); + $('vc-cache-pct').textContent = ' · ' + Math.round((mb / limit) * 100) + ' % used'; + } + } + }; + requestAnimationFrame(() => renderCache(data.cachedPages, data.cacheMb)); + + /* Client errors per hour */ + const mini = $('vc-mini'); + const eh = data.errHours || []; + const ehMax = Math.max(1, ...eh); + eh.forEach((v, i) => { + const b = document.createElement('i'); + b.style.height = (v ? Math.max(14, (v / ehMax) * 100) : 5) + '%'; + if (v > 0 && v >= ehMax && v >= 3) { + b.className = 'is-hot'; + } + b.title = (23 - i === 0 ? 'this hour' : (23 - i) + ' h ago') + ': ' + v + ' report' + (v === 1 ? '' : 's'); + mini.appendChild(b); + }); + + /* Availability strip */ + const strip = data.strip || { w: [], end: data.now, slot: 900 }; + const bars = $('vc-bars'); + const stripStart = strip.end - 96 * strip.slot; + (strip.w || []).forEach((w, i) => { + const b = document.createElement('i'); + const t = stripStart + i * strip.slot; + let label = 'no sample'; + if (w === null) { + b.className = 'is-none'; + } else if (w.s === 2) { + b.className = 'is-down'; + label = 'down'; + } else if (w.s === 1) { + b.className = 'is-slow'; + label = 'slow, ' + w.ms + ' ms'; + } else { + label = 'ok, ' + w.ms + ' ms'; + b.style.height = Math.round(78 + Math.min(22, (w.ms / 200) * 22)) + '%'; + } + b.title = hhmm(t) + '–' + hhmm(t + strip.slot) + ' · ' + label; + bars.appendChild(b); + }); + const axis = $('vc-uptime-axis'); + [0, 24, 48, 72, 96].forEach((i, k) => { + const s = document.createElement('span'); + s.textContent = k === 4 ? 'now' : hhmm(stripStart + i * strip.slot); + axis.appendChild(s); + }); + if (typeof data.uptimePct === 'number') { + const slow = num(data.slowWindows); + const down = num(data.downWindows); + $('vc-uptime-sum').innerHTML = '' + data.uptimePct.toFixed(1) + ' % up · ' + slow + ' slow window' + + (slow === 1 ? '' : 's') + ' · ' + down + ' outage' + (down === 1 ? '' : 's'); + } else { + $('vc-uptime-sum').textContent = 'no samples yet'; + } + + /* Traffic chart */ + let chart = null; + const label = (ts, size) => { + const d = new Date(ts * 1000); + const hm = d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }); + return size >= 14400 ? d.toLocaleDateString('en-GB', { weekday: 'short' }) + ' ' + hm : hm; + }; + const draw = (key) => { + const range = (data.range || {})[key]; + if (!range) { + return; + } + const b = range.b; + const sum = (k) => b.reduce((a, x) => a + num(x[k]), 0); + const h = sum('hit'); + const st = sum('stale'); + const m = sum('miss'); + const total = h + st + m; + $('vc-st-req').textContent = total ? nf.format(total + sum('bypass')) : '–'; + $('vc-st-hit').textContent = total ? (((h + st) / total) * 100).toFixed(1) + ' %' : '–'; + $('vc-st-err').textContent = nf.format(sum('errors')); + $('vc-st-purge').textContent = nf.format(sum('purges')); + const hasMs = b.some((x) => x.ms !== null); + $('vc-traffic-empty').hidden = total > 0 || hasMs; + + const maxY = Math.max(1, ...b.map((x) => num(x.hit) + num(x.stale) + num(x.miss))); + const maxMs = Math.max(100, ...b.map((x) => num(x.ms))); + const tick = { color: col.faint, font: { size: 11 } }; + const cfg = { + type: 'bar', + data: { + labels: b.map((x) => label(x.t, range.size)), + datasets: [ + { label: 'Served from cache', data: b.map((x) => x.hit), backgroundColor: col.navy, stack: 'r', yAxisID: 'y', order: 3 }, + { label: 'Stale, refreshed in background', data: b.map((x) => x.stale), backgroundColor: col.sky, stack: 'r', yAxisID: 'y', order: 3 }, + { label: 'Rendered fresh', data: b.map((x) => x.miss), backgroundColor: col.warn, stack: 'r', borderRadius: { topLeft: 3, topRight: 3 }, yAxisID: 'y', order: 3 }, + { + type: 'line', label: 'Response time', data: b.map((x) => x.ms), borderColor: col.orange, borderWidth: 2, + pointRadius: 0, pointHoverRadius: 4, pointBackgroundColor: col.orange, tension: 0.35, spanGaps: true, yAxisID: 'y1', order: 1, + }, + { + type: 'line', label: 'Purge', data: b.map((x) => (x.purges ? maxY * 1.06 : null)), showLine: false, + pointStyle: 'triangle', pointRadius: 7, pointHoverRadius: 9, pointBackgroundColor: col.bad, pointBorderColor: col.panel, + yAxisID: 'y', order: 0, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { duration: 600 }, + interaction: { mode: 'index', intersect: false }, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: '#0b1131', borderColor: 'rgba(140,156,255,.3)', borderWidth: 1, + titleColor: col.text, bodyColor: '#c4cbf0', padding: 10, + filter: (it) => it.raw !== null, + callbacks: { + label: (c) => { + if (c.dataset.label === 'Purge') { + const x = b[c.dataIndex]; + return ' ' + x.purges + ' purge' + (x.purges === 1 ? '' : 's') + (x.purgeAll ? ', ' + x.purgeAll + ' of them everything' : ''); + } + if (c.dataset.label === 'Response time') { + return ' Response time: ' + c.raw + ' ms'; + } + return ' ' + c.dataset.label + ': ' + nf.format(c.raw); + }, + }, + }, + }, + scales: { + x: { stacked: true, grid: { display: false }, ticks: { ...tick, maxRotation: 0, autoSkip: true, maxTicksLimit: 8 }, border: { color: 'rgba(140,156,255,.2)' } }, + y: { + stacked: true, beginAtZero: true, suggestedMax: maxY * 1.12, grid: { color: 'rgba(140,156,255,.08)' }, ticks: tick, + border: { display: false }, title: { display: true, text: 'requests', color: col.faint }, + }, + y1: { + position: 'right', beginAtZero: true, suggestedMax: Math.ceil(maxMs * 1.3), grid: { display: false }, + ticks: { ...tick, color: col.orange, callback: (v) => v + ' ms' }, border: { display: false }, + }, + }, + }, + }; + if (chart) { + chart.destroy(); + } + chart = new Chart($('vc-traffic'), cfg); + }; + Chart.defaults.font.family = getComputedStyle(document.body).fontFamily; + // Draw once the stylesheet has laid out the grid, and follow the box + // afterwards: Chart.js alone kept the width it saw on first paint. + const box = $('vc-traffic').parentElement; + const start = () => { + draw('24h'); + if (window.ResizeObserver) { + let size = box.clientWidth + 'x' + box.clientHeight; + new ResizeObserver(() => { + const now = box.clientWidth + 'x' + box.clientHeight; + if (chart && now !== size) { + size = now; + chart.resize(); + } + }).observe(box); + } + }; + if (document.readyState === 'complete') { + requestAnimationFrame(start); + } else { + window.addEventListener('load', () => requestAnimationFrame(start), { once: true }); + } + root.querySelectorAll('.vc-seg button[data-range]').forEach((btn) => { + btn.addEventListener('click', () => { + root.querySelectorAll('.vc-seg button[data-range]').forEach((x) => x.setAttribute('aria-pressed', x === btn ? 'true' : 'false')); + draw(btn.dataset.range); + }); + }); + + /* Warm cache + live status */ + const warmBtn = $('ssr-warm-button'); + const warmLabel = $('vc-warm-label'); + const warmPct = $('vc-warm-pct'); + const warmMeta = $('vc-warm-meta'); + const warmPill = $('vc-warm-pill'); + const warmRing = $('vc-ring-warm'); + const pill = (cls, text) => { + warmPill.hidden = !text; + warmPill.className = 'vc-pill ' + cls; + warmPill.textContent = text || ''; + }; + const renderWarm = (w) => { + w = w || {}; + const queued = num(w.queued); + const handled = num(w.done) + num(w.skipped) + num(w.failed); + const failed = num(w.failed); + const failedHtml = failed ? '' + failed + ' failed' : '0 failed'; + if (w.running) { + const f = queued > 0 ? handled / queued : 0; + setRing(warmRing, f); + warmPct.textContent = Math.round(f * 100) + ' %'; + warmMeta.innerHTML = '' + nf.format(handled) + ' of ' + nf.format(queued) + ' pages
' + + num(w.skipped) + ' still fresh · ' + failedHtml + '
live, every 2 seconds'; + pill('is-sky', 'running'); + } else if (w.startedAt) { + setRing(warmRing, queued > 0 ? handled / queued : 1); + warmPct.textContent = failed || w.aborted ? '!' : '✓'; + const started = new Date(w.startedAt); + const secs = w.finishedAt ? Math.round((new Date(w.finishedAt) - started) / 1000) : null; + const took = secs === null ? '' : '
took ' + (secs >= 120 ? Math.round(secs / 60) + ' min' : secs + ' s'); + warmMeta.innerHTML = 'Last run ' + started.toLocaleString('en-GB', { weekday: 'short', hour: '2-digit', minute: '2-digit' }) + + '
' + nf.format(num(w.done)) + ' of ' + nf.format(queued) + ' rendered · ' + + num(w.skipped) + ' still fresh · ' + failedHtml + took; + pill(w.aborted ? 'is-warn' : (failed ? 'is-bad' : 'is-ok'), w.aborted ? 'aborted by restart' : (failed ? 'with errors' : 'done')); + } else { + setRing(warmRing, 0); + warmPct.textContent = '–'; + warmMeta.textContent = 'No run since the server started.'; + pill('', ''); + } + if (warmBtn) { + warmBtn.disabled = !!w.running; + warmLabel.textContent = w.running ? 'Warming …' : 'Warm cache'; + } + return !!w.running; + }; + requestAnimationFrame(() => renderWarm(data.warming)); + + const renderOnline = (online) => { + const p = $('vc-pulse'); + const t = $('vc-live-title'); + if (!p || !t) { + return; + } + p.classList.toggle('is-down', !online); + t.innerHTML = online ? 'SSR online' : 'SSR not reachable'; + }; + + let timer = null; + const schedule = (ms) => { + clearTimeout(timer); + timer = setTimeout(poll, ms); + }; + const poll = () => { + new AjaxRequest(TYPO3.settings.ajaxUrls.vitec_ssr_health).get() + .then((response) => response.resolve()) + .then((res) => { + const h = res && res.health; + renderOnline(!!h); + let running = false; + if (h) { + running = renderWarm(h.warming); + renderCache(h.cachedPages, h.cacheMB); + if (typeof h.responseMs === 'number') { + $('vc-ms').textContent = h.responseMs; + } + } + schedule(running ? 2000 : 15000); + }) + .catch(() => schedule(15000)); + }; + schedule(data.warming && data.warming.running ? 1000 : 15000); + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + clearTimeout(timer); + } else { + poll(); + } + }); +}