New site setting vitec.ssr.alertEmails (Site Management, Settings, VITEC; one address per entry). vitec:ssr-health reads the recipients from there, invalid entries are skipped and logged, SSR_ALERT_EMAIL in the env file stays as fallback. The cockpit shows the recipients in the live band. Pre-filled with the address that was in the env file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
538 lines
22 KiB
PHP
538 lines
22 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Evomedien\Vitec\Controller\Backend;
|
|
|
|
use Evomedien\Vitec\Service\SsrPurgeService;
|
|
use Psr\Http\Message\ResponseInterface;
|
|
use Psr\Http\Message\ServerRequestInterface;
|
|
use TYPO3\CMS\Backend\Attribute\AsController;
|
|
use TYPO3\CMS\Backend\Routing\UriBuilder;
|
|
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
|
|
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
|
|
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
|
|
use TYPO3\CMS\Backend\Utility\BackendUtility;
|
|
use TYPO3\CMS\Core\Core\Environment;
|
|
use TYPO3\CMS\Core\Database\Connection;
|
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
|
use TYPO3\CMS\Core\Http\JsonResponse;
|
|
use TYPO3\CMS\Core\Http\RedirectResponse;
|
|
use TYPO3\CMS\Core\Http\RequestFactory;
|
|
use TYPO3\CMS\Core\Messaging\FlashMessage;
|
|
use TYPO3\CMS\Core\Site\SiteFinder;
|
|
use TYPO3\CMS\Core\Messaging\FlashMessageService;
|
|
use TYPO3\CMS\Core\Page\PageRenderer;
|
|
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
|
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|
|
|
/**
|
|
* Backend module "VITEC SSR": live state of the SSR node, manual purge
|
|
* (single paths or everything) and the purge/health protocol.
|
|
*/
|
|
#[AsController]
|
|
final class SsrController
|
|
{
|
|
public function __construct(
|
|
private readonly ModuleTemplateFactory $moduleTemplateFactory,
|
|
private readonly SsrPurgeService $purgeService,
|
|
private readonly RequestFactory $requestFactory,
|
|
private readonly ConnectionPool $connectionPool,
|
|
private readonly FlashMessageService $flashMessageService,
|
|
private readonly UriBuilder $uriBuilder,
|
|
private readonly PageRenderer $pageRenderer,
|
|
private readonly ComponentFactory $componentFactory,
|
|
) {}
|
|
|
|
public function indexAction(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$view = $this->moduleTemplateFactory->create($request);
|
|
$view->setTitle('VITEC SSR');
|
|
|
|
[$health, $healthError] = $this->fetchHealth();
|
|
$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');
|
|
|
|
// DocHeader: shortcut button (0096-FE); the reload button comes from the core.
|
|
$view->addButtonToButtonBar(
|
|
$this->componentFactory->createShortcutButton()
|
|
->setRouteIdentifier('web_vitecssr')
|
|
->setDisplayName('VITEC SSR'),
|
|
ButtonBar::BUTTON_POSITION_RIGHT
|
|
);
|
|
|
|
$limit = (int)($request->getQueryParams()['limit'] ?? 30);
|
|
if (!in_array($limit, [30, 100, 500], true)) {
|
|
$limit = 30;
|
|
}
|
|
$healthLog = $this->rows('tx_vitec_ssrhealthlog', 10);
|
|
$lastSample = $healthLog[0] ?? null;
|
|
$clientErrors = $this->clientErrors(50);
|
|
$cockpit = $this->cockpitData($health, $lastSample);
|
|
$alert = $this->purgeService->getAlertRecipients();
|
|
|
|
$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(),
|
|
'alert' => $alert,
|
|
'alertList' => implode(', ', $alert['recipients']),
|
|
'alertCount' => count($alert['recipients']),
|
|
'alertInvalid' => implode(', ', $alert['invalid']),
|
|
'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,
|
|
'healthError' => $healthError,
|
|
'cacheLimitMb' => $this->purgeService->getCacheLimitMb(),
|
|
'lastSample' => $lastSample,
|
|
'lastSampleAge' => $lastSample ? BackendUtility::calcAge(time() - (int)$lastSample['crdate']) : '',
|
|
'purgeLog' => $this->purgeRows($limit),
|
|
'purgeLimit' => $limit,
|
|
'moduleUri' => (string)$this->uriBuilder->buildUriFromRoute('web_vitecssr'),
|
|
'healthLog' => $healthLog,
|
|
'clientErrors' => $clientErrors,
|
|
'clientErrorCount24h' => $this->clientErrorCount24h(),
|
|
]);
|
|
return $view->renderResponse('Ssr/Index');
|
|
}
|
|
|
|
/**
|
|
* Client error reports of the last 24 hours (POST /api/vitec/client-error,
|
|
* ClientErrorMiddleware), newest first. Rows carry a "host" (URL without
|
|
* scheme/query) and a shortened user agent for the table; the full
|
|
* values stay in the row for the details toggle.
|
|
*/
|
|
private function clientErrors(int $limit): array
|
|
{
|
|
try {
|
|
$qb = $this->connectionPool->getQueryBuilderForTable('tx_vitec_clienterrorlog');
|
|
$rows = $qb->select('*')->from('tx_vitec_clienterrorlog')
|
|
->where($qb->expr()->gt('crdate', $qb->createNamedParameter(time() - 86400, Connection::PARAM_INT)))
|
|
->orderBy('uid', 'DESC')->setMaxResults($limit)
|
|
->executeQuery()->fetchAllAssociative();
|
|
} catch (\Throwable) {
|
|
return [];
|
|
}
|
|
foreach ($rows as &$row) {
|
|
$path = (string)(parse_url((string)$row['url'], PHP_URL_PATH) ?? '');
|
|
$row['path'] = $path !== '' ? $path : (string)$row['url'];
|
|
$ua = (string)$row['user_agent'];
|
|
$row['ua_short'] = mb_strlen($ua) > 60 ? mb_substr($ua, 0, 57) . '...' : $ua;
|
|
$row['is_chunk_reload'] = str_contains(strtolower((string)$row['message']), 'chunk reload');
|
|
}
|
|
unset($row);
|
|
return $rows;
|
|
}
|
|
|
|
/**
|
|
* Purge log rows prepared for the template: trigger split into a short
|
|
* label plus the user, paths as count + first two + the rest behind a
|
|
* disclosure (the raw list was a wall of text, 0096-FE).
|
|
*/
|
|
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, ':')) {
|
|
[$trigger, $who] = explode(':', $trigger, 2);
|
|
}
|
|
$row['trigger_label'] = match ($trigger) {
|
|
'module' => 'manual',
|
|
'contextmenu' => 'context menu',
|
|
'editor-save', 'editor-command' => 'auto-purge',
|
|
default => $trigger,
|
|
};
|
|
$row['trigger_who'] = $who;
|
|
// 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 === ['*'];
|
|
$row['paths_count'] = count($paths);
|
|
$row['paths_first'] = array_slice($paths, 0, 2);
|
|
$row['paths_rest'] = array_slice($paths, 2);
|
|
}
|
|
unset($row);
|
|
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 {
|
|
return (int)$this->connectionPool->getConnectionForTable('tx_vitec_clienterrorlog')
|
|
->executeQuery('SELECT COUNT(*) FROM tx_vitec_clienterrorlog WHERE crdate > ?', [time() - 86400])
|
|
->fetchOne();
|
|
} catch (\Throwable) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
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('No paths given.', 'Purge', false);
|
|
} else {
|
|
$result = $this->purgeService->purge($paths, 'module:' . $user);
|
|
$this->flash(
|
|
sprintf('HTTP %d — %s', $result['httpCode'], $result['message']),
|
|
$result['success'] ? 'Purge successful' : 'Purge failed',
|
|
$result['success']
|
|
);
|
|
}
|
|
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecssr'), 303);
|
|
}
|
|
|
|
/**
|
|
* "Warm cache" button: asks the node to render every page of the
|
|
* sitemap in the background (0066-FE). Returns at once; progress is
|
|
* the "Cache warming" row of the status table.
|
|
*/
|
|
public function warmAction(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$result = $this->purgeService->warm();
|
|
$this->flash(
|
|
$result['message'],
|
|
$result['success'] ? 'Cache warming' : 'Cache warming failed',
|
|
$result['success']
|
|
);
|
|
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecssr'), 303);
|
|
}
|
|
|
|
/**
|
|
* Ajax route vitec_ssr_purge_page: purge exactly one page, called by
|
|
* the page tree context menu item (ssr-context-menu.js).
|
|
*/
|
|
public function purgePage(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$body = (array)($request->getParsedBody() ?? []);
|
|
$pageUid = (int)($body['pageUid'] ?? ($request->getQueryParams()['pageUid'] ?? 0));
|
|
if ($pageUid <= 0) {
|
|
return new JsonResponse(['success' => false, 'message' => 'No page uid given'], 400);
|
|
}
|
|
try {
|
|
$site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageUid);
|
|
$path = $site->getRouter()->generateUri($pageUid)->getPath() ?: '/';
|
|
} catch (\Throwable) {
|
|
return new JsonResponse(['success' => false, 'message' => 'Page has no routable URL (folder?)']);
|
|
}
|
|
$user = (string)($GLOBALS['BE_USER']->user['username'] ?? '?');
|
|
$result = $this->purgeService->purge([$path], 'contextmenu:' . $user);
|
|
return new JsonResponse([
|
|
'success' => $result['success'],
|
|
'path' => $path,
|
|
'message' => 'HTTP ' . $result['httpCode'] . ' - ' . $result['message'],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* GET /__health of the SSR node. Returns [health array|null, error string].
|
|
*/
|
|
private function fetchHealth(): array
|
|
{
|
|
$endpoint = $this->purgeService->getHealthEndpoint();
|
|
if ($endpoint === '') {
|
|
return [null, 'var/vitec-ssr-purge.env missing or incomplete.'];
|
|
}
|
|
$start = microtime(true);
|
|
try {
|
|
$response = $this->requestFactory->request($endpoint, 'GET', [
|
|
'timeout' => 3, 'connect_timeout' => 2, 'http_errors' => false,
|
|
]);
|
|
if ($response->getStatusCode() !== 200) {
|
|
return [null, 'HTTP ' . $response->getStatusCode()];
|
|
}
|
|
$health = json_decode((string)$response->getBody(), true) ?: null;
|
|
if ($health === null) {
|
|
return [null, 'invalid JSON from /__health'];
|
|
}
|
|
$health['responseMs'] = (int)round((microtime(true) - $start) * 1000);
|
|
return [$health, ''];
|
|
} catch (\Throwable $e) {
|
|
return [null, $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
[$health, $error] = $this->fetchHealth();
|
|
return new JsonResponse(['health' => $health, 'error' => $error]);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|