Client-Error-Endpoint POST /api/vitec/client-error (0072-FE / 0077-BE)
Neue PSR-15-Middleware ClientErrorMiddleware: nimmt Browser-Fehler des
React-Frontends als JSON {url, message, stack, release, userAgent}
entgegen und antwortet 204 ohne Body. Schutz: 30 Meldungen pro Minute
und IP (Sliding Window ueber TYPO3-RateLimiter, 429 + Retry-After),
8-KB-Body-Limit (413, Content-Length und tatsaechliche Bytes), kein
JSON-Objekt oder leere message = 400, GET = 405. Keine Session, keine
Cookies; der /_json-Praefix funktioniert ebenfalls.
Speicherung in der neuen Tabelle tx_vitec_clienterrorlog mit
anonymisierter IP (IPv4 letztes Oktett 0, IPv6 /48). Sichtbar als
Tabelle "Client errors (last 24 h)" im Modul VITEC SSR (mit Stack als
Aufklapper) und als Zeile "Client errors reported (24 h / 7 d)" in der
Dashboard-Kachel VITEC: SSR statistics.
CLI vitec:client-error-cleanup [--days=30] loescht Eintraege aelter als
30 Tage (taeglich per Cron).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
ff6f86b829
commit
5121eaa000
7 files changed
+360
No files matched your search
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Command;
|
||||||
|
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'vitec:client-error-cleanup',
|
||||||
|
description: 'Delete client error reports (tx_vitec_clienterrorlog) older than 30 days; run daily via cron',
|
||||||
|
)]
|
||||||
|
final class ClientErrorCleanupCommand extends Command
|
||||||
|
{
|
||||||
|
private const TABLE = 'tx_vitec_clienterrorlog';
|
||||||
|
private const DEFAULT_DAYS = 30;
|
||||||
|
|
||||||
|
public function __construct(private readonly ConnectionPool $connectionPool)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption('days', 'd', InputOption::VALUE_REQUIRED, 'Keep entries younger than this many days', (string)self::DEFAULT_DAYS);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$days = (int)$input->getOption('days');
|
||||||
|
if ($days < 1) {
|
||||||
|
$output->writeln('<error>--days must be at least 1</error>');
|
||||||
|
return Command::INVALID;
|
||||||
|
}
|
||||||
|
$cutoff = time() - $days * 86400;
|
||||||
|
$conn = $this->connectionPool->getConnectionForTable(self::TABLE);
|
||||||
|
$deleted = $conn->executeStatement('DELETE FROM ' . self::TABLE . ' WHERE crdate < ?', [$cutoff]);
|
||||||
|
$remaining = (int)$conn->executeQuery('SELECT COUNT(*) FROM ' . self::TABLE)->fetchOne();
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
'deleted %d client error report(s) older than %d days (before %s), %d remaining',
|
||||||
|
$deleted,
|
||||||
|
$days,
|
||||||
|
date('Y-m-d H:i', $cutoff),
|
||||||
|
$remaining
|
||||||
|
));
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ use TYPO3\CMS\Backend\Attribute\AsController;
|
|||||||
use TYPO3\CMS\Backend\Routing\UriBuilder;
|
use TYPO3\CMS\Backend\Routing\UriBuilder;
|
||||||
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
|
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
|
||||||
use TYPO3\CMS\Core\Core\Environment;
|
use TYPO3\CMS\Core\Core\Environment;
|
||||||
|
use TYPO3\CMS\Core\Database\Connection;
|
||||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
use TYPO3\CMS\Core\Http\JsonResponse;
|
use TYPO3\CMS\Core\Http\JsonResponse;
|
||||||
use TYPO3\CMS\Core\Http\RedirectResponse;
|
use TYPO3\CMS\Core\Http\RedirectResponse;
|
||||||
@@ -73,10 +74,51 @@ final class SsrController
|
|||||||
'healthError' => $healthError,
|
'healthError' => $healthError,
|
||||||
'purgeLog' => $this->rows('tx_vitec_ssrpurgelog', 30),
|
'purgeLog' => $this->rows('tx_vitec_ssrpurgelog', 30),
|
||||||
'healthLog' => $this->rows('tx_vitec_ssrhealthlog', 10),
|
'healthLog' => $this->rows('tx_vitec_ssrhealthlog', 10),
|
||||||
|
'clientErrors' => $this->clientErrors(50),
|
||||||
|
'clientErrorCount24h' => $this->clientErrorCount24h(),
|
||||||
]);
|
]);
|
||||||
return $view->renderResponse('Ssr/Index');
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
public function purgeAction(ServerRequestInterface $request): ResponseInterface
|
||||||
{
|
{
|
||||||
$body = (array)$request->getParsedBody();
|
$body = (array)$request->getParsedBody();
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Middleware;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\Http\NormalizedParams;
|
||||||
|
use TYPO3\CMS\Core\Http\Response;
|
||||||
|
use TYPO3\CMS\Core\RateLimiter\RateLimiterFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client error endpoint: POST /api/vitec/client-error
|
||||||
|
*
|
||||||
|
* The React frontend reports browser-side failures here (window.onerror,
|
||||||
|
* unhandledrejection, chunk reloads after a deploy, element error
|
||||||
|
* boundaries) - contract 0072-FE section 4, accepted in 0077-BE:
|
||||||
|
*
|
||||||
|
* {url, message, stack, release, userAgent} -> 204 No Content
|
||||||
|
*
|
||||||
|
* Guards, all answered with a 4xx and no body:
|
||||||
|
* 405 method other than POST
|
||||||
|
* 413 body larger than 8 KB (Content-Length or actual bytes)
|
||||||
|
* 400 not a JSON object, or "message" missing/empty
|
||||||
|
* 429 more than 30 reports per minute from one IP (sliding window
|
||||||
|
* via TYPO3's rate limiter, cache "ratelimiter")
|
||||||
|
*
|
||||||
|
* Entries land in tx_vitec_clienterrorlog with the IP anonymized (IPv4
|
||||||
|
* last octet zeroed, IPv6 cut to /48); the full IP is only the key of
|
||||||
|
* the rate limiter and is never stored. No session, no cookies - a
|
||||||
|
* navigator.sendBeacon() call must get through unchanged. Rows older
|
||||||
|
* than 30 days are removed by vitec:client-error-cleanup.
|
||||||
|
*/
|
||||||
|
final class ClientErrorMiddleware implements MiddlewareInterface
|
||||||
|
{
|
||||||
|
private const PATH = '/api/vitec/client-error';
|
||||||
|
private const MAX_BODY_BYTES = 8192;
|
||||||
|
private const LIMIT_PER_MINUTE = 30;
|
||||||
|
private const TABLE = 'tx_vitec_clienterrorlog';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ConnectionPool $connectionPool,
|
||||||
|
private readonly RateLimiterFactory $rateLimiterFactory,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||||
|
{
|
||||||
|
$path = rtrim($request->getUri()->getPath(), '/');
|
||||||
|
if ($path !== self::PATH) {
|
||||||
|
return $handler->handle($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$method = strtoupper($request->getMethod());
|
||||||
|
if ($method === 'OPTIONS') {
|
||||||
|
return $this->empty(204)->withHeader('Allow', 'POST, OPTIONS');
|
||||||
|
}
|
||||||
|
if ($method !== 'POST') {
|
||||||
|
return $this->empty(405)->withHeader('Allow', 'POST, OPTIONS');
|
||||||
|
}
|
||||||
|
|
||||||
|
$declared = (int)$request->getHeaderLine('Content-Length');
|
||||||
|
if ($declared > self::MAX_BODY_BYTES) {
|
||||||
|
return $this->empty(413);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit first, so a flood of oversized or broken bodies is
|
||||||
|
// cheap to refuse as well.
|
||||||
|
$ip = '';
|
||||||
|
try {
|
||||||
|
$normalized = $request->getAttribute('normalizedParams') ?? NormalizedParams::createFromRequest($request);
|
||||||
|
$ip = (string)$normalized->getRemoteAddress();
|
||||||
|
$limiter = $this->rateLimiterFactory->createLimiter([
|
||||||
|
'id' => 'vitec-client-error',
|
||||||
|
'policy' => 'sliding_window',
|
||||||
|
'limit' => self::LIMIT_PER_MINUTE,
|
||||||
|
'interval' => '1 minute',
|
||||||
|
], $ip);
|
||||||
|
$limit = $limiter->consume(1);
|
||||||
|
if (!$limit->isAccepted()) {
|
||||||
|
$retry = max(1, $limit->getRetryAfter()->getTimestamp() - time());
|
||||||
|
return $this->empty(429)->withHeader('Retry-After', (string)$retry);
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// A broken limiter must not take the endpoint down; the
|
||||||
|
// body limit and the 30-day cleanup still bound the damage.
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = $request->getBody();
|
||||||
|
if ($body->isSeekable()) {
|
||||||
|
$body->rewind();
|
||||||
|
}
|
||||||
|
$raw = $body->read(self::MAX_BODY_BYTES + 1);
|
||||||
|
if (strlen($raw) > self::MAX_BODY_BYTES) {
|
||||||
|
return $this->empty(413);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
|
||||||
|
} catch (\JsonException) {
|
||||||
|
return $this->empty(400);
|
||||||
|
}
|
||||||
|
if (!\is_array($data) || array_is_list($data)) {
|
||||||
|
return $this->empty(400);
|
||||||
|
}
|
||||||
|
$message = trim($this->str($data['message'] ?? ''));
|
||||||
|
if ($message === '') {
|
||||||
|
return $this->empty(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = [
|
||||||
|
'pid' => 0,
|
||||||
|
'crdate' => time(),
|
||||||
|
'url' => $this->cut($this->str($data['url'] ?? ''), 1024),
|
||||||
|
'message' => $this->cut($message, 2000),
|
||||||
|
'stack' => $this->cut($this->str($data['stack'] ?? ''), 6000),
|
||||||
|
'release' => $this->cut($this->str($data['release'] ?? ''), 100),
|
||||||
|
'user_agent' => $this->cut($this->str($data['userAgent'] ?? $request->getHeaderLine('User-Agent')), 500),
|
||||||
|
'ip' => self::anonymizeIp($ip),
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
$this->connectionPool->getConnectionForTable(self::TABLE)->insert(self::TABLE, $row);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Table missing (extension:setup not run yet) or DB hiccup:
|
||||||
|
// the client gets its 204 anyway - a reporting endpoint must
|
||||||
|
// never turn into a second error in the browser console.
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->empty(204);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IPv4: last octet zeroed (192.0.2.0). IPv6: first three groups
|
||||||
|
* kept (2001:db8:1::). Anything else: empty.
|
||||||
|
*/
|
||||||
|
public static function anonymizeIp(string $ip): string
|
||||||
|
{
|
||||||
|
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
|
||||||
|
$parts = explode('.', $ip);
|
||||||
|
$parts[3] = '0';
|
||||||
|
return implode('.', $parts);
|
||||||
|
}
|
||||||
|
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
|
||||||
|
$packed = inet_pton($ip);
|
||||||
|
if ($packed === false) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$hex = bin2hex($packed);
|
||||||
|
$groups = [];
|
||||||
|
for ($i = 0; $i < 3; $i++) {
|
||||||
|
$groups[] = ltrim(substr($hex, $i * 4, 4), '0') ?: '0';
|
||||||
|
}
|
||||||
|
return implode(':', $groups) . '::';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function str(mixed $value): string
|
||||||
|
{
|
||||||
|
if (\is_string($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
if (\is_int($value) || \is_float($value) || \is_bool($value)) {
|
||||||
|
return (string)$value;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cut(string $value, int $max): string
|
||||||
|
{
|
||||||
|
$value = str_replace("\0", '', $value);
|
||||||
|
return mb_strlen($value) > $max ? mb_substr($value, 0, $max) : $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function empty(int $status): ResponseInterface
|
||||||
|
{
|
||||||
|
return (new Response())
|
||||||
|
->withStatus($status)
|
||||||
|
->withHeader('Cache-Control', 'no-store');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,11 @@ final class SsrStatsWidget implements WidgetInterface
|
|||||||
$avgMs = $this->scalar('SELECT ROUND(AVG(response_ms)) FROM tx_vitec_ssrhealthlog WHERE crdate > ' . $d1 . ' AND ok = 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);
|
$maxCache = $this->scalar('SELECT MAX(cache_mb) FROM tx_vitec_ssrhealthlog WHERE crdate > ' . $d7);
|
||||||
|
|
||||||
|
// Browser-side errors reported via POST /api/vitec/client-error
|
||||||
|
// (ClientErrorMiddleware); details in the VITEC SSR module.
|
||||||
|
$clientErrors24h = $this->count('tx_vitec_clienterrorlog', 'crdate > ' . $d1);
|
||||||
|
$clientErrors7d = $this->count('tx_vitec_clienterrorlog', 'crdate > ' . $d7);
|
||||||
|
|
||||||
$rows = [
|
$rows = [
|
||||||
['Purges (24 h / 7 days)', $purges24h . ' / ' . $purges7d],
|
['Purges (24 h / 7 days)', $purges24h . ' / ' . $purges7d],
|
||||||
['of which full purges (7 d)', (string)$full7d],
|
['of which full purges (7 d)', (string)$full7d],
|
||||||
@@ -47,6 +52,9 @@ final class SsrStatsWidget implements WidgetInterface
|
|||||||
. ' <small>(' . $ok24h . '/' . $samples24h . ' samples)</small>'],
|
. ' <small>(' . $ok24h . '/' . $samples24h . ' samples)</small>'],
|
||||||
['Avg. response time (24 h)', $avgMs !== null ? $avgMs . ' ms' : '-'],
|
['Avg. response time (24 h)', $avgMs !== null ? $avgMs . ' ms' : '-'],
|
||||||
['Max. cache size (7 d)', $maxCache !== null ? $maxCache . ' MB' : '-'],
|
['Max. cache size (7 d)', $maxCache !== null ? $maxCache . ' MB' : '-'],
|
||||||
|
['Client errors reported (24 h / 7 d)', ($clientErrors24h > 0
|
||||||
|
? '<span class="badge badge-warning">' . $clientErrors24h . '</span>'
|
||||||
|
: '<span class="badge badge-success">0</span>') . ' / ' . $clientErrors7d],
|
||||||
];
|
];
|
||||||
|
|
||||||
$html = '<table class="table table-striped table-sm"><tbody>';
|
$html = '<table class="table table-striped table-sm"><tbody>';
|
||||||
|
|||||||
@@ -56,6 +56,22 @@ return [
|
|||||||
'typo3/cms-core/normalized-params-attribute',
|
'typo3/cms-core/normalized-params-attribute',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
// Client error reports from the React frontend:
|
||||||
|
// POST /api/vitec/client-error -> tx_vitec_clienterrorlog, 204
|
||||||
|
// (0072-FE section 4 / 0077-BE). Answers before site resolution,
|
||||||
|
// no session, rate-limited per IP, 8 KB body limit. Placed after
|
||||||
|
// the /_json strip so the prefixed form reaches it as well.
|
||||||
|
'vitec/client-error' => [
|
||||||
|
'target' => \Evomedien\Vitec\Middleware\ClientErrorMiddleware::class,
|
||||||
|
'after' => [
|
||||||
|
'typo3/cms-core/normalized-params-attribute',
|
||||||
|
'vitec/json-prefix-rewrite',
|
||||||
|
],
|
||||||
|
'before' => [
|
||||||
|
'typo3/cms-frontend/site',
|
||||||
|
'vitec/form-submission',
|
||||||
|
],
|
||||||
|
],
|
||||||
// Outer response filter: Cache-Control: no-cache + ETag/304 for
|
// Outer response filter: Cache-Control: no-cache + ETag/304 for
|
||||||
// /_json (tickets 0011-FE / 0055-BE).
|
// /_json (tickets 0011-FE / 0055-BE).
|
||||||
'vitec/json-cache-headers' => [
|
'vitec/json-cache-headers' => [
|
||||||
|
|||||||
@@ -140,4 +140,42 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<h3>Client errors <small>(last 24 h — {clientErrorCount24h} report<f:if condition="{clientErrorCount24h} != 1">s</f:if>, newest 50 shown)</small></h3>
|
||||||
|
<p class="text-muted" style="font-size:11px">
|
||||||
|
Browser-side failures reported by the React frontend via <code>POST /api/vitec/client-error</code>
|
||||||
|
(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 <code>vitec:client-error-cleanup</code>).
|
||||||
|
</p>
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead><tr><th>Time</th><th>Release</th><th>Message</th><th>Page</th><th>Browser</th><th>IP</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<f:for each="{clientErrors}" as="row">
|
||||||
|
<tr>
|
||||||
|
<td style="white-space:nowrap"><f:format.date format="Y-m-d H:i:s">@{row.crdate}</f:format.date></td>
|
||||||
|
<td><code>{row.release}</code></td>
|
||||||
|
<td>
|
||||||
|
<f:if condition="{row.is_chunk_reload}">
|
||||||
|
<f:then><span class="badge badge-warning">chunk reload</span> </f:then>
|
||||||
|
<f:else><span class="badge badge-danger">error</span> </f:else>
|
||||||
|
</f:if>
|
||||||
|
{row.message}
|
||||||
|
<f:if condition="{row.stack}">
|
||||||
|
<details style="margin-top:4px">
|
||||||
|
<summary style="cursor:pointer" class="text-muted">stack</summary>
|
||||||
|
<pre style="font-size:11px;white-space:pre-wrap;max-height:240px;overflow:auto">{row.stack}</pre>
|
||||||
|
</details>
|
||||||
|
</f:if>
|
||||||
|
</td>
|
||||||
|
<td><a href="{row.url}" target="_blank" rel="noopener" title="{row.url}">{row.path}</a></td>
|
||||||
|
<td title="{row.user_agent}"><small>{row.ua_short}</small></td>
|
||||||
|
<td><small>{row.ip}</small></td>
|
||||||
|
</tr>
|
||||||
|
</f:for>
|
||||||
|
<f:if condition="!{clientErrors}">
|
||||||
|
<tr><td colspan="6" class="text-muted">No client errors reported in the last 24 hours.</td></tr>
|
||||||
|
</f:if>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -499,3 +499,22 @@ CREATE TABLE tx_vitec_ssrhealthlog (
|
|||||||
PRIMARY KEY (uid),
|
PRIMARY KEY (uid),
|
||||||
KEY crdate (crdate)
|
KEY crdate (crdate)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
#
|
||||||
|
# Client error reports from the React frontend
|
||||||
|
# (POST /api/vitec/client-error, ClientErrorMiddleware; 30-day retention
|
||||||
|
# via vitec:client-error-cleanup; shown in the VITEC SSR module)
|
||||||
|
#
|
||||||
|
CREATE TABLE tx_vitec_clienterrorlog (
|
||||||
|
uid int(11) NOT NULL auto_increment,
|
||||||
|
pid int(11) DEFAULT '0' NOT NULL,
|
||||||
|
crdate int(11) DEFAULT '0' NOT NULL,
|
||||||
|
url varchar(1024) DEFAULT '' NOT NULL,
|
||||||
|
message text,
|
||||||
|
stack text,
|
||||||
|
release varchar(100) DEFAULT '' NOT NULL,
|
||||||
|
user_agent varchar(500) DEFAULT '' NOT NULL,
|
||||||
|
ip varchar(64) DEFAULT '' NOT NULL,
|
||||||
|
PRIMARY KEY (uid),
|
||||||
|
KEY crdate (crdate)
|
||||||
|
);
|
||||||
Reference in new issue
Block a user