*/
+ 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 .= '| ' . $label . ' | ' . $value . ' |
';
+ }
+ $html .= '
';
+ 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 .= '| ' . htmlspecialchars($label) . ' | ' . $value . ' |
';
+ }
+ $html .= '
';
+ $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)
+
+
+
+
+
+Purge-Protokoll (letzte 30)
+
+ | Zeit | Auslöser | Pfade | Ergebnis | Dauer |
+
+
+
+ | @{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)
+
+ | Zeit | Status | Release | Cache | Antwortzeit |
+
+
+
+ | @{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)
+);