diff --git a/packages/vitec/Classes/Command/SsrHealthSampleCommand.php b/packages/vitec/Classes/Command/SsrHealthSampleCommand.php new file mode 100644 index 0000000..6c321ee --- /dev/null +++ b/packages/vitec/Classes/Command/SsrHealthSampleCommand.php @@ -0,0 +1,71 @@ +purgeService->getHealthEndpoint(); + if ($endpoint === '') { + $output->writeln('SSR_HEALTH_ENDPOINT missing in var/vitec-ssr-purge.env'); + return Command::INVALID; + } + $row = ['crdate' => time(), 'ok' => 0, 'release_id' => '', 'cached_pages' => 0, 'cache_mb' => 0, 'response_ms' => 0]; + $start = microtime(true); + try { + $response = $this->requestFactory->request($endpoint, 'GET', [ + 'timeout' => 4, 'connect_timeout' => 2, 'http_errors' => false, + ]); + $row['response_ms'] = (int)round((microtime(true) - $start) * 1000); + if ($response->getStatusCode() === 200) { + $data = json_decode((string)$response->getBody(), true); + $row['ok'] = !empty($data['ok']) ? 1 : 0; + $row['release_id'] = substr((string)($data['release']['release'] ?? ''), 0, 100); + $row['cached_pages'] = (int)($data['cachedPages'] ?? 0); + $row['cache_mb'] = (int)($data['cacheMB'] ?? 0); + } + } catch (\Throwable $e) { + $row['response_ms'] = (int)round((microtime(true) - $start) * 1000); + } + $conn = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog'); + $conn->insert('tx_vitec_ssrhealthlog', $row); + $conn->executeStatement( + 'DELETE FROM tx_vitec_ssrhealthlog WHERE crdate < ?', + [time() - self::KEEP_DAYS * 86400] + ); + $output->writeln(sprintf( + '%s release=%s pages=%d cache=%dMB %dms', + $row['ok'] ? 'ok' : 'DOWN', + $row['release_id'] ?: '-', + $row['cached_pages'], + $row['cache_mb'], + $row['response_ms'] + )); + return Command::SUCCESS; + } +} diff --git a/packages/vitec/Classes/Command/SsrPurgeCommand.php b/packages/vitec/Classes/Command/SsrPurgeCommand.php new file mode 100644 index 0000000..3dd1ac5 --- /dev/null +++ b/packages/vitec/Classes/Command/SsrPurgeCommand.php @@ -0,0 +1,49 @@ +addArgument('paths', InputArgument::IS_ARRAY, 'Paths to purge, e.g. /products/aligo'); + $this->addOption('all', null, InputOption::VALUE_NONE, 'Purge the whole cache ("*")'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $paths = $input->getOption('all') ? ['*'] : (array)$input->getArgument('paths'); + if ($paths === []) { + $output->writeln('No paths given (use arguments or --all).'); + return Command::INVALID; + } + $result = $this->purgeService->purge($paths, 'cli'); + $output->writeln(sprintf( + '%s HTTP %d - %s (paths: %s)', + $result['success'] ? 'OK' : 'FAILED', + $result['httpCode'], + $result['message'], + implode(', ', $result['paths']) + )); + return $result['success'] ? Command::SUCCESS : Command::FAILURE; + } +} diff --git a/packages/vitec/Classes/Controller/Backend/SsrController.php b/packages/vitec/Classes/Controller/Backend/SsrController.php new file mode 100644 index 0000000..af352bb --- /dev/null +++ b/packages/vitec/Classes/Controller/Backend/SsrController.php @@ -0,0 +1,126 @@ +moduleTemplateFactory->create($request); + $view->setTitle('VITEC SSR'); + + $health = null; + $healthError = ''; + $endpoint = $this->purgeService->getHealthEndpoint(); + if ($endpoint === '') { + $healthError = 'var/vitec-ssr-purge.env fehlt oder ist unvollständig.'; + } else { + $start = microtime(true); + try { + $response = $this->requestFactory->request($endpoint, 'GET', [ + 'timeout' => 3, 'connect_timeout' => 2, 'http_errors' => false, + ]); + if ($response->getStatusCode() === 200) { + $health = json_decode((string)$response->getBody(), true) ?: null; + if ($health !== null) { + $health['responseMs'] = (int)round((microtime(true) - $start) * 1000); + } + } else { + $healthError = 'HTTP ' . $response->getStatusCode(); + } + } catch (\Throwable $e) { + $healthError = $e->getMessage(); + } + } + + $view->assignMultiple([ + 'flagOn' => file_exists(Environment::getVarPath() . '/vitec-ssr-on.flag'), + 'configured' => $this->purgeService->isConfigured(), + 'health' => $health, + 'healthError' => $healthError, + 'purgeLog' => $this->rows('tx_vitec_ssrpurgelog', 30), + 'healthLog' => $this->rows('tx_vitec_ssrhealthlog', 10), + ]); + return $view->renderResponse('Ssr/Index'); + } + + public function purgeAction(ServerRequestInterface $request): ResponseInterface + { + $body = (array)$request->getParsedBody(); + $mode = (string)($body['mode'] ?? 'paths'); + $user = (string)($GLOBALS['BE_USER']->user['username'] ?? '?'); + + if ($mode === 'all') { + $paths = ['*']; + } else { + $paths = preg_split('/[\s,]+/', (string)($body['paths'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: []; + } + + if ($paths === []) { + $this->flash('Keine Pfade angegeben.', 'Purge', false); + } else { + $result = $this->purgeService->purge($paths, 'module:' . $user); + $this->flash( + sprintf('HTTP %d — %s', $result['httpCode'], $result['message']), + $result['success'] ? 'Purge erfolgreich' : 'Purge fehlgeschlagen', + $result['success'] + ); + } + return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecssr'), 303); + } + + private function rows(string $table, int $limit): array + { + try { + $qb = $this->connectionPool->getQueryBuilderForTable($table); + return $qb->select('*')->from($table) + ->orderBy('uid', 'DESC')->setMaxResults($limit) + ->executeQuery()->fetchAllAssociative(); + } catch (\Throwable) { + return []; + } + } + + private function flash(string $message, string $title, bool $ok): void + { + $flash = GeneralUtility::makeInstance( + FlashMessage::class, + $message, + $title, + $ok ? ContextualFeedbackSeverity::OK : ContextualFeedbackSeverity::ERROR, + true + ); + $this->flashMessageService->getMessageQueueByIdentifier()->addMessage($flash); + } +} diff --git a/packages/vitec/Classes/Hooks/DataHandlerSsrPurge.php b/packages/vitec/Classes/Hooks/DataHandlerSsrPurge.php new file mode 100644 index 0000000..49d5a9b --- /dev/null +++ b/packages/vitec/Classes/Hooks/DataHandlerSsrPurge.php @@ -0,0 +1,176 @@ + '*' (menus are embedded in EVERY page), + * otherwise just that page's path. + * - tt_content: the hosting page's path. + * - tx_news_* and tx_vitec_*: '*' (news feed the megamenu spotlight, + * domain models render on many pages). + * - delete/move/undelete commands: pages -> '*', records -> hosting page. + * + * Changes are queued per request and flushed ONCE in afterAllOperations, + * so bulk operations (importer!) end up as a single purge call. + * Runs only while the SSR routing flag is ON and the Site Set switch + * vitec.ssr.autoPurge is enabled. + */ +final class DataHandlerSsrPurge +{ + private const NAV_FIELDS = ['title', 'nav_title', 'slug', 'hidden', 'nav_hide', 'doktype', 'fe_group', 'starttime', 'endtime']; + + /** @var array */ + private static array $queue = []; + + public function processDatamap_afterDatabaseOperations( + string $status, + string $table, + $id, + array $fieldArray, + DataHandler $dataHandler + ): void { + if (!$this->isActive()) { + return; + } + if ($table === 'pages') { + if (array_intersect(array_keys($fieldArray), self::NAV_FIELDS) !== []) { + self::$queue['*'] = true; + return; + } + $pageId = $this->realId($id, $dataHandler); + if ($pageId > 0) { + $this->queuePagePath($pageId); + } + return; + } + if ($table === 'tt_content') { + $recordId = $this->realId($id, $dataHandler); + $pid = $fieldArray['pid'] ?? null; + if (!is_numeric($pid) || (int)$pid <= 0) { + $pid = $this->pidOfRecord($table, $recordId); + } + if ((int)$pid > 0) { + $this->queuePagePath((int)$pid); + } + return; + } + if (str_starts_with($table, 'tx_news_') || str_starts_with($table, 'tx_vitec_')) { + self::$queue['*'] = true; + } + } + + public function processDatamap_afterAllOperations(DataHandler $dataHandler): void + { + $this->flush('editor-save'); + } + + public function processCmdmap_postProcess( + string $command, + string $table, + $id, + $value, + DataHandler $dataHandler + ): void { + if (!$this->isActive()) { + return; + } + if ($table === 'pages') { + // Delete/move/copy of a page changes menus everywhere. + self::$queue['*'] = true; + return; + } + if ($table === 'tt_content') { + $pid = $this->pidOfRecord($table, (int)$id); + if ($pid > 0) { + $this->queuePagePath($pid); + } + return; + } + if (str_starts_with($table, 'tx_news_') || str_starts_with($table, 'tx_vitec_')) { + self::$queue['*'] = true; + } + } + + public function processCmdmap_afterFinish(DataHandler $dataHandler): void + { + $this->flush('editor-command'); + } + + private function flush(string $trigger): void + { + if (self::$queue === []) { + return; + } + $paths = array_keys(self::$queue); + self::$queue = []; + GeneralUtility::makeInstance(SsrPurgeService::class)->purgeQuietly($paths, $trigger); + } + + private function isActive(): bool + { + if (!file_exists(Environment::getVarPath() . '/vitec-ssr-on.flag')) { + return false; + } + try { + foreach (GeneralUtility::makeInstance(SiteFinder::class)->getAllSites() as $site) { + if ($site->getSettings()->get('vitec.ssr.autoPurge', true)) { + return true; + } + } + } catch (\Throwable) { + return false; + } + return false; + } + + private function queuePagePath(int $pageId): void + { + try { + $site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageId); + $uri = $site->getRouter()->generateUri($pageId); + $path = $uri->getPath() ?: '/'; + self::$queue[$path] = true; + } catch (\Throwable) { + // Page outside a site (folders etc.) - nothing cached for it. + } + } + + private function realId($id, DataHandler $dataHandler): int + { + if (is_numeric($id)) { + return (int)$id; + } + return (int)($dataHandler->substNEWwithIDs[$id] ?? 0); + } + + private function pidOfRecord(string $table, int $uid): int + { + if ($uid <= 0) { + return 0; + } + try { + $qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table); + $qb->getRestrictions()->removeAll(); + $pid = $qb->select('pid')->from($table) + ->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid, \Doctrine\DBAL\ParameterType::INTEGER))) + ->executeQuery()->fetchOne(); + return (int)$pid; + } catch (\Throwable) { + return 0; + } + } +} diff --git a/packages/vitec/Classes/Service/SsrFlagService.php b/packages/vitec/Classes/Service/SsrFlagService.php index adbdfc3..00358bf 100644 --- a/packages/vitec/Classes/Service/SsrFlagService.php +++ b/packages/vitec/Classes/Service/SsrFlagService.php @@ -81,6 +81,10 @@ final class SsrFlagService . "flip the switch in Site Management > Settings instead.\n" . 'Last sync: ' . date('c') . "\n" ); + // Fresh ON: whatever the node cached while the switch was OFF + // is stale by definition - drop its whole page cache once. + \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\Evomedien\Vitec\Service\SsrPurgeService::class) + ->purgeQuietly(['*'], 'switch-on'); } else { @unlink($this->getFlagPath()); } diff --git a/packages/vitec/Classes/Service/SsrPurgeService.php b/packages/vitec/Classes/Service/SsrPurgeService.php new file mode 100644 index 0000000..96edae7 --- /dev/null +++ b/packages/vitec/Classes/Service/SsrPurgeService.php @@ -0,0 +1,165 @@ +getConfig(); + return ($c['SSR_PURGE_ENDPOINT'] ?? '') !== '' && ($c['SSR_PURGE_SECRET'] ?? '') !== ''; + } + + public function getHealthEndpoint(): string + { + return $this->getConfig()['SSR_HEALTH_ENDPOINT'] ?? ''; + } + + /** + * Normalizes, dedupes and sends the paths; logs the outcome. + * Never throws - editors' saves must not fail on a purge problem. + * + * @param string[] $paths page paths, or ['*'] for everything + * @return array{success:bool, httpCode:int, message:string, paths:string[]} + */ + public function purge(array $paths, string $trigger): array + { + $paths = $this->normalize($paths); + if ($paths === []) { + return ['success' => true, 'httpCode' => 0, 'message' => 'nothing to purge', 'paths' => []]; + } + if (!$this->isConfigured()) { + $result = ['success' => false, 'httpCode' => 0, 'message' => 'not configured (var/vitec-ssr-purge.env missing)', 'paths' => $paths]; + $this->log($trigger, $result, 0); + return $result; + } + + $c = $this->getConfig(); + $start = microtime(true); + try { + $response = $this->requestFactory->request($c['SSR_PURGE_ENDPOINT'], 'POST', [ + 'headers' => [ + 'X-Purge-Secret' => $c['SSR_PURGE_SECRET'], + 'Content-Type' => 'application/json', + ], + 'body' => json_encode(['paths' => $paths], JSON_THROW_ON_ERROR), + 'timeout' => 4, + 'connect_timeout' => 2, + 'http_errors' => false, + ]); + $code = $response->getStatusCode(); + $body = substr((string)$response->getBody(), 0, 200); + $result = [ + 'success' => $code === 200, + 'httpCode' => $code, + 'message' => $body, + 'paths' => $paths, + ]; + } catch (\Throwable $e) { + $result = ['success' => false, 'httpCode' => 0, 'message' => substr($e->getMessage(), 0, 200), 'paths' => $paths]; + } + $this->log($trigger, $result, (int)round((microtime(true) - $start) * 1000)); + return $result; + } + + public function purgeQuietly(array $paths, string $trigger): void + { + try { + $this->purge($paths, $trigger); + } catch (\Throwable) { + // Logged inside purge(); never disturb the caller. + } + } + + /** + * Path rules from 0055-BE Q3 / 0054-FE section 3: path only, query + * stripped, no trailing slash (root stays '/'), '*' wins over all. + * + * @param string[] $paths + * @return string[] + */ + public function normalize(array $paths): array + { + $out = []; + foreach ($paths as $p) { + $p = trim((string)$p); + if ($p === '') { + continue; + } + if ($p === '*') { + return ['*']; + } + $p = parse_url($p, PHP_URL_PATH) ?: '/'; + if ($p !== '/' ) { + $p = '/' . trim($p, '/'); + } + $out[$p] = true; + } + return array_keys($out); + } + + private function getConfig(): array + { + if ($this->config !== null) { + return $this->config; + } + $this->config = []; + $file = self::getConfigPath(); + if (is_readable($file)) { + foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { + if (str_starts_with(ltrim($line), '#') || !str_contains($line, '=')) { + continue; + } + [$k, $v] = explode('=', $line, 2); + $this->config[trim($k)] = trim($v); + } + } + return $this->config; + } + + private function log(string $trigger, array $result, int $durationMs): void + { + try { + $this->connectionPool->getConnectionForTable('tx_vitec_ssrpurgelog')->insert( + 'tx_vitec_ssrpurgelog', + [ + 'crdate' => time(), + 'trigger_source' => substr($trigger, 0, 100), + 'paths' => implode(', ', $result['paths']), + 'success' => $result['success'] ? 1 : 0, + 'http_code' => $result['httpCode'], + 'message' => substr($result['message'], 0, 255), + 'duration_ms' => $durationMs, + ] + ); + } catch (\Throwable) { + // Table missing (before schema update) must not break anything. + } + } +} diff --git a/packages/vitec/Classes/Widgets/SsrStatsWidget.php b/packages/vitec/Classes/Widgets/SsrStatsWidget.php new file mode 100644 index 0000000..3e54ba0 --- /dev/null +++ b/packages/vitec/Classes/Widgets/SsrStatsWidget.php @@ -0,0 +1,88 @@ +count('tx_vitec_ssrpurgelog', 'crdate > ' . $d1); + $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); + + $rows = [ + ['Purges (24 h / 7 Tage)', $purges24h . ' / ' . $purges7d], + ['davon Komplett-Purges (7 T.)', (string)$full7d], + ['Fehlgeschlagene Purges (7 T.)', $failed7d > 0 + ? '' . $failed7d . '' + : '0'], + ['Verfügbarkeit (24 h)', htmlspecialchars($uptime) + . ' (' . $ok24h . '/' . $samples24h . ' Messungen)'], + ['∅ Antwortzeit (24 h)', $avgMs !== null ? $avgMs . ' ms' : '-'], + ['Max. Cache-Größe (7 T.)', $maxCache !== null ? $maxCache . ' MB' : '-'], + ]; + + $html = ''; + foreach ($rows as [$label, $value]) { + $html .= ''; + } + $html .= '
' . $label . '' . $value . '
'; + if ($samples24h === 0) { + $html .= '

Noch keine Messwerte: Scheduler-Task für vitec:ssr-health einrichten (z. B. alle 5 Minuten).

'; + } + return $html; + } + + public function getOptions(): array + { + return []; + } + + private function count(string $table, string $where): int + { + try { + return (int)$this->connectionPool->getConnectionForTable($table) + ->executeQuery('SELECT COUNT(*) FROM ' . $table . ' WHERE ' . $where)->fetchOne(); + } catch (\Throwable) { + return 0; + } + } + + private function scalar(string $sql): ?string + { + try { + $v = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog') + ->executeQuery($sql)->fetchOne(); + return $v === null || $v === false ? null : (string)$v; + } catch (\Throwable) { + return null; + } + } +} diff --git a/packages/vitec/Classes/Widgets/SsrStatusWidget.php b/packages/vitec/Classes/Widgets/SsrStatusWidget.php new file mode 100644 index 0000000..42da2e5 --- /dev/null +++ b/packages/vitec/Classes/Widgets/SsrStatusWidget.php @@ -0,0 +1,117 @@ +fetchHealth(); + $lastPurge = $this->lastPurgeRow(); + + $badge = static function (bool $ok, string $yes, string $no): string { + return $ok + ? '' . htmlspecialchars($yes) . '' + : '' . htmlspecialchars($no) . ''; + }; + + $rows = []; + $rows[] = ['SSR-Auslieferung', $badge($flagOn, 'AN - Seiten kommen vom VPS', 'AUS - SPA wie bisher')]; + if ($health === null) { + $rows[] = ['SSR-Server', 'NICHT ERREICHBAR']; + } else { + $rows[] = ['SSR-Server', $badge((bool)($health['ok'] ?? false), 'läuft (' . (int)$health['responseMs'] . ' ms)', 'meldet Fehler')]; + $rows[] = ['Release', htmlspecialchars((string)($health['release']['release'] ?? '?')) + . ' (Commit ' . htmlspecialchars((string)($health['release']['commit'] ?? '?')) + . ', gebaut ' . htmlspecialchars($this->fmtDate((string)($health['release']['builtAt'] ?? ''))) . ')']; + $rows[] = ['Seiten-Cache', (int)($health['cachedPages'] ?? 0) . ' Seiten / ' . (int)($health['cacheMB'] ?? 0) . ' MB']; + } + if ($lastPurge !== null) { + $rows[] = ['Letzter Purge', htmlspecialchars(date('d.m. H:i', (int)$lastPurge['crdate'])) + . ' (' . htmlspecialchars((string)$lastPurge['trigger_source']) . ', ' + . ($lastPurge['success'] ? 'ok' : 'FEHLER') . ', ' + . htmlspecialchars(mb_strimwidth((string)$lastPurge['paths'], 0, 60, '…')) . ')']; + } + if (!$this->purgeService->isConfigured()) { + $rows[] = ['Auto-Purge', 'nicht konfiguriert (var/vitec-ssr-purge.env fehlt)']; + } + + $html = ''; + foreach ($rows as [$label, $value]) { + $html .= ''; + } + $html .= '
' . htmlspecialchars($label) . '' . $value . '
'; + $html .= '

Schalter: Site Management › Settings › VITEC. Modul "VITEC SSR" für Purge & Protokoll.

'; + return $html; + } + + public function getOptions(): array + { + return []; + } + + 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('d.m.Y H:i', $ts) : '?'; + } +} diff --git a/packages/vitec/Configuration/Backend/Modules.php b/packages/vitec/Configuration/Backend/Modules.php index 854efaa..debfeed 100644 --- a/packages/vitec/Configuration/Backend/Modules.php +++ b/packages/vitec/Configuration/Backend/Modules.php @@ -127,4 +127,26 @@ return [ ], ], ], + 'web_vitecssr' => [ + 'parent' => 'web', + 'position' => ['after' => 'web_vitecimport'], + 'access' => 'user', + 'workspaces' => 'live', + 'path' => '/module/web/vitec-ssr', + 'labels' => [ + 'title' => 'VITEC SSR', + 'shortDescription' => 'SSR delivery: VPS status, manual purge, purge/health protocol', + ], + 'extensionName' => 'Vitec', + 'iconIdentifier' => 'actions-system-cache-clear-impact-medium', + 'routes' => [ + '_default' => [ + 'target' => \Evomedien\Vitec\Controller\Backend\SsrController::class . '::indexAction', + ], + 'purge' => [ + 'target' => \Evomedien\Vitec\Controller\Backend\SsrController::class . '::purgeAction', + 'methods' => ['POST'], + ], + ], + ], ]; diff --git a/packages/vitec/Configuration/Services.yaml b/packages/vitec/Configuration/Services.yaml index 4846f8b..2baa3a4 100644 --- a/packages/vitec/Configuration/Services.yaml +++ b/packages/vitec/Configuration/Services.yaml @@ -22,3 +22,32 @@ services: # the final page title (seo.title) into meta.title as well. FriendsOfTYPO3\Headless\Seo\MetaHandlerInterface: alias: Evomedien\Vitec\Seo\VitecMetaHandler + + # The DataHandler hook fetches this via GeneralUtility::makeInstance, + # which resolves constructor arguments only for container-public services. + Evomedien\Vitec\Service\SsrPurgeService: + public: true + + # SSR dashboard tiles (the WidgetConfigurationInterface argument is + # injected by the dashboard compiler pass via the tag). + Evomedien\Vitec\Widgets\SsrStatusWidget: + tags: + - name: dashboard.widget + identifier: vitecSsrStatus + groupNames: systemInformation + title: 'VITEC: SSR / VPS-Status' + description: 'Live-Zustand des SSR-Servers (Release, Cache, Erreichbarkeit) und der Auslieferungs-Schalter' + iconIdentifier: 'content-widget-text' + height: 'medium' + width: 'medium' + + Evomedien\Vitec\Widgets\SsrStatsWidget: + tags: + - name: dashboard.widget + identifier: vitecSsrStats + groupNames: systemInformation + title: 'VITEC: SSR-Statistik' + description: 'Purge-Protokoll und Verfügbarkeit des SSR-Servers (14 Tage Historie)' + iconIdentifier: 'content-widget-number' + height: 'medium' + width: 'medium' diff --git a/packages/vitec/Configuration/Sets/Vitecset/settings.definitions.yaml b/packages/vitec/Configuration/Sets/Vitecset/settings.definitions.yaml index 469761f..d818f98 100644 --- a/packages/vitec/Configuration/Sets/Vitecset/settings.definitions.yaml +++ b/packages/vitec/Configuration/Sets/Vitecset/settings.definitions.yaml @@ -127,3 +127,10 @@ settings: category: VITEC type: bool default: false + + vitec.ssr.autoPurge: + label: 'SSR auto-purge on editor changes' + description: 'ON: saving pages/content/news automatically evicts the affected pages from the SSR page cache (menu-relevant changes evict everything). OFF: the SSR cache refreshes only by its TTL (5 min) or manual purge. Only relevant while SSR delivery is ON.' + category: VITEC + type: bool + default: true diff --git a/packages/vitec/Resources/Private/Templates/Ssr/Index.html b/packages/vitec/Resources/Private/Templates/Ssr/Index.html new file mode 100644 index 0000000..a4e559d --- /dev/null +++ b/packages/vitec/Resources/Private/Templates/Ssr/Index.html @@ -0,0 +1,108 @@ + + +

VITEC SSR — Steuerung & Protokoll

+ +
+
+

Status

+ + + + + + + + + + + + + +
SSR-Auslieferung + + AN — Seiten kommen vom VPS + AUS — SPA wie bisher + +
Umschalten: Site Management › Settings › VITEC +
SSR-Server + + + läuft {health.responseMs} ms
+ Release {health.release.release} + (Commit {health.release.commit})
+ Cache: {health.cachedPages} Seiten / {health.cacheMB} MB +
+ + nicht erreichbar {healthError} + +
+
Purge-Konfiguration + + ok + var/vitec-ssr-purge.env fehlt + +
+
+
+

Cache leeren (Purge)

+
+ + + +
+ +
+
+
+ + + — nötig nach Menü-/Footer-Änderungen bei abgeschaltetem Auto-Purge +
+
+
+ +

Purge-Protokoll (letzte 30)

+ + + + + + + + + + + + + +
ZeitAuslöserPfadeErgebnisDauer
@{row.crdate}{row.trigger_source}{row.paths} + + ok + HTTP {row.http_code} {row.message} + + {row.duration_ms} ms
+ +

Gesundheits-Messungen (letzte 10 — Historie via Scheduler-Task vitec:ssr-health)

+ + + + + + + + + + + + + +
ZeitStatusReleaseCacheAntwortzeit
@{row.crdate} + + ok + down + + {row.release_id}{row.cached_pages} Seiten / {row.cache_mb} MB{row.response_ms} ms
+ + diff --git a/packages/vitec/ext_localconf.php b/packages/vitec/ext_localconf.php index 97443c0..aa49d07 100755 --- a/packages/vitec/ext_localconf.php +++ b/packages/vitec/ext_localconf.php @@ -260,3 +260,11 @@ $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'page'; $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'filter'; $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'market'; $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'category'; + +// SSR auto-purge: editors' changes evict the matching pages from the SSR +// node's full-page cache (queued per request, one call per save/import). +// Active only while the SSR flag is ON and vitec.ssr.autoPurge is enabled. +$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['vitec_ssrpurge'] + = \Evomedien\Vitec\Hooks\DataHandlerSsrPurge::class; +$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass']['vitec_ssrpurge'] + = \Evomedien\Vitec\Hooks\DataHandlerSsrPurge::class; diff --git a/packages/vitec/ext_tables.sql b/packages/vitec/ext_tables.sql index ec73a75..3c88a6a 100755 --- a/packages/vitec/ext_tables.sql +++ b/packages/vitec/ext_tables.sql @@ -460,3 +460,36 @@ CREATE TABLE tx_vitec_domain_model_searchcard ( link varchar(1024) DEFAULT '' NOT NULL, link_label varchar(80) DEFAULT '' NOT NULL ); + +# +# SSR purge protocol (auto-purge webhook + backend module, 2026-11-09) +# +CREATE TABLE tx_vitec_ssrpurgelog ( + uid int(11) NOT NULL auto_increment, + pid int(11) DEFAULT '0' NOT NULL, + crdate int(11) DEFAULT '0' NOT NULL, + trigger_source varchar(100) DEFAULT '' NOT NULL, + paths text, + success tinyint(1) DEFAULT '0' NOT NULL, + http_code int(11) DEFAULT '0' NOT NULL, + message varchar(255) DEFAULT '' NOT NULL, + duration_ms int(11) DEFAULT '0' NOT NULL, + PRIMARY KEY (uid), + KEY crdate (crdate) +); + +# +# SSR health history (vitec:ssr-health via scheduler, dashboard widgets) +# +CREATE TABLE tx_vitec_ssrhealthlog ( + uid int(11) NOT NULL auto_increment, + pid int(11) DEFAULT '0' NOT NULL, + crdate int(11) DEFAULT '0' NOT NULL, + ok tinyint(1) DEFAULT '0' NOT NULL, + release_id varchar(100) DEFAULT '' NOT NULL, + cached_pages int(11) DEFAULT '0' NOT NULL, + cache_mb int(11) DEFAULT '0' NOT NULL, + response_ms int(11) DEFAULT '0' NOT NULL, + PRIMARY KEY (uid), + KEY crdate (crdate) +);