SSR control center: auto-purge webhook, dashboard tiles, backend module

Package 1: SsrPurgeService posts to the node /__purge (endpoint+secret in var/vitec-ssr-purge.env, outside git) and logs every attempt to tx_vitec_ssrpurgelog. DataHandler hooks queue per request and flush once: pages with nav-relevant field changes and all tx_news_/tx_vitec_ records purge everything, other page/content edits purge the hosting page path. Gated by the routing flag and the new Site Set switch vitec.ssr.autoPurge. Flipping the SSR switch ON purges everything once. CLI: vitec:ssr-purge, vitec:ssr-health (samples /__health into tx_vitec_ssrhealthlog, 14 days kept, scheduler-ready). Package 2: dashboard widgets vitecSsrStatus (live health, release, switch, last purge) and vitecSsrStats (purge counts, failures, uptime, response time). Package 3: backend module web_vitecssr with manual purge (paths or everything) and both protocols. Tested end to end against the running node (purge single/all 200, health sample logged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Fable 5 committed 2026-11-09 20:46:16 +01:00
1 parent d52d49974f
commit 63f950f9db
14 files changed
+1003

No files matched your search

@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Hooks;
use TYPO3\CMS\Core\Core\Environment;
use Evomedien\Vitec\Service\SsrPurgeService;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Database\ConnectionPool;
/**
* Auto-purge: tells the SSR node which pages to drop from its full-page
* cache after editors change content (ticket 0054-FE section 3).
*
* Mapping rules (v1, deliberately conservative):
* - pages: navigation-relevant field changed (title, nav_title, slug,
* hidden, nav_hide, doktype) -> '*' (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<string,true> */
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;
}
}
}