feat: add developer error page with request ID tracking and structured logging
Custom-built error handler for the web channel that shows a tabbed debug dashboard (exception, request context, environment, SQL queries) in dev mode and a clean branded error page with copyable request ID in prod mode. All errors are logged as JSON lines to storage/logs/errors/ for lookup by request ID. Uses the project's design tokens for visual consistency. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
348
lib/Http/ErrorDataCollector.php
Normal file
348
lib/Http/ErrorDataCollector.php
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Http;
|
||||||
|
|
||||||
|
use MintyPHP\Debugger;
|
||||||
|
|
||||||
|
final class ErrorDataCollector
|
||||||
|
{
|
||||||
|
private const CODE_CONTEXT_LINES = 10;
|
||||||
|
private const MAX_TRACE_FRAMES = 50;
|
||||||
|
|
||||||
|
private const REDACTED_HEADER_PATTERNS = [
|
||||||
|
'authorization',
|
||||||
|
'cookie',
|
||||||
|
'x-api-key',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const REDACTED_KEY_PATTERNS = [
|
||||||
|
'password',
|
||||||
|
'secret',
|
||||||
|
'token',
|
||||||
|
'key',
|
||||||
|
'csrf',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const SAFE_ENV_KEYS = [
|
||||||
|
'APP_DEBUG',
|
||||||
|
'APP_ENV',
|
||||||
|
'APP_NAME',
|
||||||
|
'APP_TIMEZONE',
|
||||||
|
'APP_LOCALE',
|
||||||
|
'APP_URL',
|
||||||
|
'TENANT_SCOPE_STRICT',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{exception: array<string, mixed>, request: array<string, mixed>, environment: array<string, mixed>, queries: array<string, mixed>}
|
||||||
|
*/
|
||||||
|
public static function collect(\Throwable $exception): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'exception' => self::collectException($exception),
|
||||||
|
'request' => self::collectRequest(),
|
||||||
|
'environment' => self::collectEnvironment(),
|
||||||
|
'queries' => self::collectQueries(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function collectException(\Throwable $exception): array
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'class' => get_class($exception),
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
'code' => $exception->getCode(),
|
||||||
|
'file' => $exception->getFile(),
|
||||||
|
'line' => $exception->getLine(),
|
||||||
|
'trace' => self::collectStackFrames($exception),
|
||||||
|
];
|
||||||
|
|
||||||
|
$previous = $exception->getPrevious();
|
||||||
|
if ($previous !== null) {
|
||||||
|
$data['previous'] = self::collectException($previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private static function collectStackFrames(\Throwable $exception): array
|
||||||
|
{
|
||||||
|
$frames = [];
|
||||||
|
|
||||||
|
// Frame 0 is the exception origin itself.
|
||||||
|
$frames[] = [
|
||||||
|
'file' => $exception->getFile(),
|
||||||
|
'line' => $exception->getLine(),
|
||||||
|
'function' => null,
|
||||||
|
'class' => null,
|
||||||
|
'code_snippet' => self::extractCodeSnippet($exception->getFile(), $exception->getLine()),
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (array_slice($exception->getTrace(), 0, self::MAX_TRACE_FRAMES) as $frame) {
|
||||||
|
$file = $frame['file'] ?? null;
|
||||||
|
$line = $frame['line'] ?? null;
|
||||||
|
$function = $frame['function'];
|
||||||
|
$class = $frame['class'] ?? null;
|
||||||
|
|
||||||
|
$frames[] = [
|
||||||
|
'file' => $file,
|
||||||
|
'line' => $line,
|
||||||
|
'function' => $function,
|
||||||
|
'class' => $class,
|
||||||
|
'args_summary' => self::summarizeArgs($frame['args'] ?? []),
|
||||||
|
'code_snippet' => ($file !== null && $line !== null)
|
||||||
|
? self::extractCodeSnippet($file, $line)
|
||||||
|
: [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string> Line number => code line
|
||||||
|
*/
|
||||||
|
private static function extractCodeSnippet(string $file, int $line): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (!is_file($file) || !is_readable($file)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = @file($file, FILE_IGNORE_NEW_LINES);
|
||||||
|
if ($lines === false) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = max(0, $line - self::CODE_CONTEXT_LINES - 1);
|
||||||
|
$end = min(count($lines), $line + self::CODE_CONTEXT_LINES);
|
||||||
|
|
||||||
|
$snippet = [];
|
||||||
|
for ($i = $start; $i < $end; $i++) {
|
||||||
|
$snippet[$i + 1] = $lines[$i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $snippet;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<mixed> $args
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private static function summarizeArgs(array $args): array
|
||||||
|
{
|
||||||
|
$summary = [];
|
||||||
|
foreach ($args as $arg) {
|
||||||
|
$summary[] = match (true) {
|
||||||
|
is_null($arg) => 'null',
|
||||||
|
is_bool($arg) => $arg ? 'true' : 'false',
|
||||||
|
is_int($arg) => 'int',
|
||||||
|
is_float($arg) => 'float',
|
||||||
|
is_string($arg) => 'string(' . strlen($arg) . ')',
|
||||||
|
is_array($arg) => 'array(' . count($arg) . ')',
|
||||||
|
is_object($arg) => get_class($arg),
|
||||||
|
is_resource($arg) => 'resource',
|
||||||
|
default => 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function collectRequest(): array
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'request_id' => null,
|
||||||
|
'channel' => 'web',
|
||||||
|
'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET',
|
||||||
|
'url' => ($_SERVER['REQUEST_URI'] ?? ''),
|
||||||
|
'ip' => $_SERVER['REMOTE_ADDR'] ?? null,
|
||||||
|
'user_agent' => substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
|
||||||
|
'headers' => self::collectHeaders(),
|
||||||
|
'get' => $_GET,
|
||||||
|
'post' => self::redactParams($_POST),
|
||||||
|
'session_keys' => [],
|
||||||
|
'user_id' => null,
|
||||||
|
'tenant_id' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Safe request context access.
|
||||||
|
try {
|
||||||
|
$data['request_id'] = RequestContext::currentId() ?? RequestContext::id();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// RequestContext may not be initialized.
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ctx = RequestContext::context();
|
||||||
|
$data['channel'] = $ctx['channel'] ?? 'web';
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session data — keys and types only, never values.
|
||||||
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||||
|
$data['session_keys'] = self::describeSessionKeys($_SESSION ?? []);
|
||||||
|
$data['user_id'] = $_SESSION['user']['id'] ?? $_SESSION['user_id'] ?? null;
|
||||||
|
$data['tenant_id'] = $_SESSION['current_tenant']['id'] ?? $_SESSION['tenant_id'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private static function collectHeaders(): array
|
||||||
|
{
|
||||||
|
$headers = [];
|
||||||
|
foreach ($_SERVER as $key => $value) {
|
||||||
|
if (!str_starts_with($key, 'HTTP_')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = strtolower(str_replace('_', '-', substr($key, 5)));
|
||||||
|
$headers[$name] = self::shouldRedactHeader($name) ? '[REDACTED]' : (string) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function shouldRedactHeader(string $name): bool
|
||||||
|
{
|
||||||
|
$lower = strtolower($name);
|
||||||
|
foreach (self::REDACTED_HEADER_PATTERNS as $pattern) {
|
||||||
|
if (str_contains($lower, $pattern)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $params
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function redactParams(array $params): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
foreach ($params as $key => $value) {
|
||||||
|
$result[$key] = self::shouldRedactParam((string) $key) ? '[REDACTED]' : $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function shouldRedactParam(string $key): bool
|
||||||
|
{
|
||||||
|
$lower = strtolower($key);
|
||||||
|
foreach (self::REDACTED_KEY_PATTERNS as $pattern) {
|
||||||
|
if (str_contains($lower, $pattern)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private static function describeSessionKeys(array $session): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
foreach ($session as $key => $value) {
|
||||||
|
$result[(string) $key] = get_debug_type($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function collectEnvironment(): array
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'php_version' => PHP_VERSION,
|
||||||
|
'php_sapi' => PHP_SAPI,
|
||||||
|
'extensions' => get_loaded_extensions(),
|
||||||
|
'memory_current' => memory_get_usage(true),
|
||||||
|
'memory_peak' => memory_get_peak_usage(true),
|
||||||
|
'env' => self::collectSafeEnv(),
|
||||||
|
'modules' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Active modules — may fail if container is unavailable.
|
||||||
|
try {
|
||||||
|
if (function_exists('app')) {
|
||||||
|
$registry = app(\MintyPHP\App\Module\ModuleRegistry::class);
|
||||||
|
$modules = $registry->getModules();
|
||||||
|
$data['modules'] = array_map(
|
||||||
|
fn (object $m): string => $m->id ?? (string) $m,
|
||||||
|
$modules,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private static function collectSafeEnv(): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
foreach (self::SAFE_ENV_KEYS as $key) {
|
||||||
|
$value = $_ENV[$key] ?? $_SERVER[$key] ?? null;
|
||||||
|
if ($value !== null) {
|
||||||
|
$result[$key] = (string) $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function collectQueries(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (!class_exists(Debugger::class, false) || !Debugger::$enabled) {
|
||||||
|
return ['available' => false, 'queries' => [], 'start' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
$queries = Debugger::get('queries');
|
||||||
|
$start = Debugger::get('start');
|
||||||
|
|
||||||
|
if (!is_array($queries)) {
|
||||||
|
$queries = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'available' => true,
|
||||||
|
'queries' => $queries,
|
||||||
|
'start' => is_float($start) ? $start : null,
|
||||||
|
'count' => count($queries),
|
||||||
|
];
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return ['available' => false, 'queries' => [], 'start' => null];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
190
lib/Http/ErrorHandler.php
Normal file
190
lib/Http/ErrorHandler.php
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Http;
|
||||||
|
|
||||||
|
use MintyPHP\Debugger;
|
||||||
|
|
||||||
|
final class ErrorHandler
|
||||||
|
{
|
||||||
|
/** @phpstan-ignore property.onlyWritten (Memory reserve freed on OOM to allow error rendering.) */
|
||||||
|
private static ?string $reservedMemory = null;
|
||||||
|
private static bool $registered = false;
|
||||||
|
private static bool $handling = false;
|
||||||
|
|
||||||
|
public static function register(): void
|
||||||
|
{
|
||||||
|
if (self::$registered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$registered = true;
|
||||||
|
self::$reservedMemory = str_repeat('x', 64 * 1024); // 64 KB reserve for OOM recovery.
|
||||||
|
|
||||||
|
set_exception_handler([self::class, 'handleException']);
|
||||||
|
set_error_handler([self::class, 'handleError']);
|
||||||
|
register_shutdown_function([self::class, 'handleShutdown']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function handleException(\Throwable $exception): void
|
||||||
|
{
|
||||||
|
// Prevent recursive handling if the error page itself throws.
|
||||||
|
if (self::$handling) {
|
||||||
|
self::renderMinimalFallback($exception);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$handling = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Free reserved memory so we have room to work after OOM.
|
||||||
|
self::$reservedMemory = null;
|
||||||
|
|
||||||
|
// Discard any partial output from the failed request.
|
||||||
|
while (ob_get_level() > 0) {
|
||||||
|
ob_end_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set HTTP 500 if no error status has been set yet.
|
||||||
|
$currentStatus = http_response_code();
|
||||||
|
if ($currentStatus === false || $currentStatus < 400) {
|
||||||
|
http_response_code(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect diagnostic data.
|
||||||
|
$data = ErrorDataCollector::collect($exception);
|
||||||
|
|
||||||
|
// Log to file (always, in both dev and prod).
|
||||||
|
ErrorLogger::log($exception, $data);
|
||||||
|
|
||||||
|
// Bail out if headers were already sent (cannot render a page).
|
||||||
|
if (headers_sent()) {
|
||||||
|
self::$handling = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API requests get a JSON response, not HTML.
|
||||||
|
if (self::isApiRequest()) {
|
||||||
|
self::sendApiError($data['request']['request_id'] ?? null);
|
||||||
|
self::$handling = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render HTML error page.
|
||||||
|
if (self::shouldShowDebugPage()) {
|
||||||
|
echo ErrorRenderer::renderDev($data);
|
||||||
|
} else {
|
||||||
|
$requestId = (string) ($data['request']['request_id'] ?? 'unknown');
|
||||||
|
echo ErrorRenderer::renderProd($requestId);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $inner) {
|
||||||
|
self::renderMinimalFallback($exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$handling = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts PHP errors into ErrorException.
|
||||||
|
* Respects the error_reporting() bitmask (honors @ suppression operator).
|
||||||
|
*/
|
||||||
|
public static function handleError(int $severity, string $message, string $file, int $line): true
|
||||||
|
{
|
||||||
|
if (!(error_reporting() & $severity)) {
|
||||||
|
// Error was suppressed with @.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \ErrorException($message, 0, $severity, $file, $line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catches fatal errors that bypass set_error_handler (E_PARSE, E_COMPILE_ERROR, etc.).
|
||||||
|
*/
|
||||||
|
public static function handleShutdown(): void
|
||||||
|
{
|
||||||
|
self::$reservedMemory = null;
|
||||||
|
|
||||||
|
$error = error_get_last();
|
||||||
|
if (!is_array($error)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR];
|
||||||
|
if (!in_array($error['type'], $fatalTypes, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only act if we haven't already handled this (e.g. via set_exception_handler).
|
||||||
|
if (self::$handling) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$exception = new \ErrorException(
|
||||||
|
$error['message'],
|
||||||
|
0,
|
||||||
|
$error['type'],
|
||||||
|
$error['file'],
|
||||||
|
$error['line'],
|
||||||
|
);
|
||||||
|
|
||||||
|
self::handleException($exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isApiRequest(): bool
|
||||||
|
{
|
||||||
|
if (defined('MINTY_API_REQUEST')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$uri = $_SERVER['REQUEST_URI'] ?? '';
|
||||||
|
$path = ltrim(parse_url($uri, PHP_URL_PATH) ?? '', '/');
|
||||||
|
|
||||||
|
return str_starts_with($path, 'api/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function shouldShowDebugPage(): bool
|
||||||
|
{
|
||||||
|
// Check the Debugger flag, which mirrors APP_DEBUG.
|
||||||
|
if (class_exists(Debugger::class, false) && Debugger::$enabled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function sendApiError(?string $requestId): void
|
||||||
|
{
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
$payload = [
|
||||||
|
'ok' => false,
|
||||||
|
'error_code' => 'internal_error',
|
||||||
|
];
|
||||||
|
if ($requestId !== null) {
|
||||||
|
$payload['request_id'] = $requestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function renderMinimalFallback(\Throwable $exception): void
|
||||||
|
{
|
||||||
|
// Last-resort plain-text response when everything else is broken.
|
||||||
|
$requestId = 'unknown';
|
||||||
|
try {
|
||||||
|
$requestId = RequestContext::currentId() ?? RequestContext::id();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!headers_sent()) {
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "500 Internal Server Error\nRequest ID: {$requestId}\n";
|
||||||
|
|
||||||
|
// In debug mode, also show the exception for developers.
|
||||||
|
if (class_exists(Debugger::class, false) && Debugger::$enabled) {
|
||||||
|
echo "\n" . $exception->getMessage() . "\n" . $exception->getTraceAsString() . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
132
lib/Http/ErrorLogger.php
Normal file
132
lib/Http/ErrorLogger.php
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Http;
|
||||||
|
|
||||||
|
final class ErrorLogger
|
||||||
|
{
|
||||||
|
private const LOG_DIR = 'storage/logs/errors';
|
||||||
|
private const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||||
|
private const MAX_ROTATED_FILES = 5;
|
||||||
|
|
||||||
|
public static function log(\Throwable $exception, array $collectedData): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$logDir = self::ensureLogDirectory();
|
||||||
|
if ($logDir === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$logFile = $logDir . '/error-' . date('Y-m-d') . '.log';
|
||||||
|
self::rotateIfNeeded($logFile);
|
||||||
|
|
||||||
|
$entry = self::buildLogEntry($exception, $collectedData);
|
||||||
|
$json = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
if ($json === false) {
|
||||||
|
$json = json_encode(['error' => 'Failed to encode log entry', 'request_id' => $entry['request_id'] ?? 'unknown']);
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents($logFile, $json . "\n", FILE_APPEND | LOCK_EX);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Logging must never cause secondary failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ensureLogDirectory(): ?string
|
||||||
|
{
|
||||||
|
$dir = self::LOG_DIR;
|
||||||
|
if (is_dir($dir)) {
|
||||||
|
return $dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!@mkdir($dir, 0o775, true) && !is_dir($dir)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function rotateIfNeeded(string $filePath): void
|
||||||
|
{
|
||||||
|
if (!is_file($filePath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = @filesize($filePath);
|
||||||
|
if ($size === false || $size < self::MAX_FILE_SIZE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the next available rotation slot.
|
||||||
|
for ($i = self::MAX_ROTATED_FILES; $i >= 2; $i--) {
|
||||||
|
$older = $filePath . '.' . $i;
|
||||||
|
$newer = $filePath . '.' . ($i - 1);
|
||||||
|
if (is_file($newer)) {
|
||||||
|
@rename($newer, $older);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@rename($filePath, $filePath . '.1');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{exception?: array, request?: array, environment?: array, queries?: array} $collectedData
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function buildLogEntry(\Throwable $exception, array $collectedData): array
|
||||||
|
{
|
||||||
|
$exceptionData = $collectedData['exception'] ?? [];
|
||||||
|
$requestData = $collectedData['request'] ?? [];
|
||||||
|
$envData = $collectedData['environment'] ?? [];
|
||||||
|
|
||||||
|
$traceSummary = '';
|
||||||
|
$frames = $exceptionData['trace'] ?? [];
|
||||||
|
foreach (array_slice($frames, 0, 10) as $i => $frame) {
|
||||||
|
$file = $frame['file'] ?? '?';
|
||||||
|
$line = $frame['line'] ?? '?';
|
||||||
|
$traceSummary .= "#{$i} {$file}:{$line} ";
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'timestamp' => date('c'),
|
||||||
|
'level' => 'error',
|
||||||
|
'request_id' => $requestData['request_id'] ?? null,
|
||||||
|
'channel' => $requestData['channel'] ?? 'web',
|
||||||
|
'exception' => [
|
||||||
|
'class' => $exceptionData['class'] ?? get_class($exception),
|
||||||
|
'message' => $exceptionData['message'] ?? $exception->getMessage(),
|
||||||
|
'code' => $exceptionData['code'] ?? $exception->getCode(),
|
||||||
|
'file' => self::shortenPath($exceptionData['file'] ?? $exception->getFile()),
|
||||||
|
'line' => $exceptionData['line'] ?? $exception->getLine(),
|
||||||
|
],
|
||||||
|
'trace_summary' => trim($traceSummary),
|
||||||
|
'request' => [
|
||||||
|
'method' => $requestData['method'] ?? null,
|
||||||
|
'url' => $requestData['url'] ?? null,
|
||||||
|
'ip_hash' => self::hashIp($requestData['ip'] ?? null),
|
||||||
|
'user_id' => $requestData['user_id'] ?? null,
|
||||||
|
'tenant_id' => $requestData['tenant_id'] ?? null,
|
||||||
|
],
|
||||||
|
'php_version' => $envData['php_version'] ?? PHP_VERSION,
|
||||||
|
'memory_peak_mb' => round(($envData['memory_peak'] ?? memory_get_peak_usage(true)) / 1024 / 1024, 1),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function hashIp(?string $ip): ?string
|
||||||
|
{
|
||||||
|
if ($ip === null || $ip === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'sha256:' . hash('sha256', $ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function shortenPath(string $path): string
|
||||||
|
{
|
||||||
|
$root = getcwd();
|
||||||
|
if ($root !== false && str_starts_with($path, $root . '/')) {
|
||||||
|
return substr($path, strlen($root) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
421
lib/Http/ErrorRenderer.php
Normal file
421
lib/Http/ErrorRenderer.php
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Http;
|
||||||
|
|
||||||
|
final class ErrorRenderer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Renders the full developer debug page with all four panels.
|
||||||
|
*
|
||||||
|
* @param array{exception: array<string, mixed>, request: array<string, mixed>, environment: array<string, mixed>, queries: array<string, mixed>} $data
|
||||||
|
*/
|
||||||
|
public static function renderDev(array $data): string
|
||||||
|
{
|
||||||
|
$exc = $data['exception'];
|
||||||
|
$req = $data['request'];
|
||||||
|
$env = $data['environment'];
|
||||||
|
$qry = $data['queries'];
|
||||||
|
|
||||||
|
$requestId = self::esc((string) ($req['request_id'] ?? 'unknown'));
|
||||||
|
$statusCode = http_response_code() ?: 500;
|
||||||
|
$statusText = self::statusText($statusCode);
|
||||||
|
$queryCount = $qry['count'] ?? count($qry['queries'] ?? []);
|
||||||
|
|
||||||
|
$css = self::loadAsset(__DIR__ . '/../../web/css/pages/app-error-debug.css');
|
||||||
|
$js = self::loadAsset(__DIR__ . '/../../web/js/components/app-error-debug.js');
|
||||||
|
|
||||||
|
$excClass = self::esc($exc['class'] ?? 'Exception');
|
||||||
|
$excMessage = self::esc($exc['message'] ?? 'An error occurred');
|
||||||
|
$excFile = self::esc(self::shortenPath($exc['file'] ?? ''));
|
||||||
|
$excLine = self::esc((string) ($exc['line'] ?? '?'));
|
||||||
|
|
||||||
|
$exceptionPanel = self::renderExceptionPanel($exc);
|
||||||
|
$requestPanel = self::renderRequestPanel($req);
|
||||||
|
$environmentPanel = self::renderEnvironmentPanel($env);
|
||||||
|
$queryPanel = self::renderQueryPanel($qry);
|
||||||
|
|
||||||
|
return <<<HTML
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{$statusCode} {$statusText}</title>
|
||||||
|
<style>{$css}</style>
|
||||||
|
</head>
|
||||||
|
<body class="app-error-debug">
|
||||||
|
<header class="app-error-debug-header">
|
||||||
|
<div class="app-error-debug-container app-error-debug-header__inner">
|
||||||
|
<div class="app-error-debug-header__status">
|
||||||
|
<span class="app-error-debug-header__code">{$statusCode}</span>
|
||||||
|
<span class="app-error-debug-header__label">{$statusText}</span>
|
||||||
|
</div>
|
||||||
|
<div class="app-error-debug-request-id">
|
||||||
|
Request ID: {$requestId}
|
||||||
|
<button class="app-error-debug-copy-button" data-copy="{$requestId}" title="Copy request ID">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/><path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="app-error-debug-container">
|
||||||
|
<div class="app-error-debug-summary">
|
||||||
|
<div class="app-error-debug-summary__class">{$excClass}</div>
|
||||||
|
<div class="app-error-debug-summary__message">{$excMessage}</div>
|
||||||
|
<div class="app-error-debug-summary__location">in {$excFile} on line {$excLine}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="app-error-debug-tabs">
|
||||||
|
<button class="app-error-debug-tab active" data-panel="panel-exception">Exception</button>
|
||||||
|
<button class="app-error-debug-tab" data-panel="panel-request">Request</button>
|
||||||
|
<button class="app-error-debug-tab" data-panel="panel-environment">Environment</button>
|
||||||
|
<button class="app-error-debug-tab" data-panel="panel-queries">Queries <span class="app-error-debug-tab__count">{$queryCount}</span></button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div id="panel-exception" class="app-error-debug-panel active">{$exceptionPanel}</div>
|
||||||
|
<div id="panel-request" class="app-error-debug-panel">{$requestPanel}</div>
|
||||||
|
<div id="panel-environment" class="app-error-debug-panel">{$environmentPanel}</div>
|
||||||
|
<div id="panel-queries" class="app-error-debug-panel">{$queryPanel}</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>{$js}</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a clean production error page with request ID only.
|
||||||
|
* Uses the same design tokens as the dev page for visual consistency.
|
||||||
|
*/
|
||||||
|
public static function renderProd(string $requestId): string
|
||||||
|
{
|
||||||
|
$statusCode = http_response_code() ?: 500;
|
||||||
|
$requestId = self::esc($requestId);
|
||||||
|
$css = self::loadAsset(__DIR__ . '/../../web/css/pages/app-error-debug.css');
|
||||||
|
|
||||||
|
return <<<HTML
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{$statusCode} - Error</title>
|
||||||
|
<style>
|
||||||
|
{$css}
|
||||||
|
.app-error-prod {
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
min-height: 100dvh; padding: calc(var(--app-spacing) * 2); text-align: center;
|
||||||
|
font-family: var(--app-font-family); background: var(--app-background-color); color: var(--app-color);
|
||||||
|
}
|
||||||
|
.app-error-prod__code {
|
||||||
|
font-size: var(--text-5xl); font-weight: var(--font-bold);
|
||||||
|
line-height: var(--leading-none); color: var(--app-muted-color);
|
||||||
|
}
|
||||||
|
.app-error-prod__message {
|
||||||
|
margin-top: var(--app-spacing); font-size: var(--text-lg);
|
||||||
|
color: var(--app-color); max-width: 28rem;
|
||||||
|
}
|
||||||
|
.app-error-prod__back {
|
||||||
|
display: inline-block; margin-top: calc(var(--app-spacing) * 2);
|
||||||
|
padding: calc(var(--app-spacing) * 0.5) calc(var(--app-spacing) * 1.25);
|
||||||
|
font-size: var(--text-sm); text-decoration: none;
|
||||||
|
border: 1px solid var(--app-border); border-radius: var(--app-border-radius);
|
||||||
|
color: var(--app-color); background: var(--app-card-background-color);
|
||||||
|
}
|
||||||
|
.app-error-prod__back:hover { opacity: 0.8; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-error-prod">
|
||||||
|
<div class="app-error-prod__code">{$statusCode}</div>
|
||||||
|
<div class="app-error-prod__message">An unexpected error occurred. If the problem persists, please contact support with the reference below.</div>
|
||||||
|
<div class="app-error-debug-request-id" style="margin-top: calc(var(--app-spacing) * 1.5);">
|
||||||
|
{$requestId}
|
||||||
|
<button class="app-error-debug-copy-button" onclick="navigator.clipboard?.writeText('{$requestId}')" title="Copy"><svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/><path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<a class="app-error-prod__back" href="/">Back to start</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function renderExceptionPanel(array $exceptionData): string
|
||||||
|
{
|
||||||
|
$html = '<div class="app-error-debug-section"><div class="app-error-debug-section__title">Stack Trace</div>';
|
||||||
|
$html .= '<ol class="app-error-debug-trace">';
|
||||||
|
|
||||||
|
$frames = $exceptionData['trace'] ?? [];
|
||||||
|
foreach ($frames as $i => $frame) {
|
||||||
|
$file = self::esc(self::shortenPath($frame['file'] ?? '[internal]'));
|
||||||
|
$line = self::esc((string) ($frame['line'] ?? '?'));
|
||||||
|
$function = '';
|
||||||
|
if (isset($frame['class'])) {
|
||||||
|
$function = self::esc($frame['class'] . '::' . ($frame['function'] ?? '?'));
|
||||||
|
} elseif (isset($frame['function'])) {
|
||||||
|
$function = self::esc($frame['function']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$codeHtml = '';
|
||||||
|
$snippet = $frame['code_snippet'] ?? [];
|
||||||
|
if (!empty($snippet)) {
|
||||||
|
$highlightLine = (int) ($frame['line'] ?? 0);
|
||||||
|
$codeHtml = self::renderCodeBlock($snippet, $highlightLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= <<<FRAME
|
||||||
|
<li class="app-error-debug-frame">
|
||||||
|
<div class="app-error-debug-frame__header">
|
||||||
|
<span class="app-error-debug-frame__index">#{$i}</span>
|
||||||
|
<span class="app-error-debug-frame__file">{$file}</span>:<span class="app-error-debug-frame__line">{$line}</span>
|
||||||
|
<span class="app-error-debug-frame__function">{$function}</span>
|
||||||
|
</div>
|
||||||
|
<div class="app-error-debug-frame__code">{$codeHtml}</div>
|
||||||
|
</li>
|
||||||
|
FRAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '</ol></div>';
|
||||||
|
|
||||||
|
// Chained exceptions.
|
||||||
|
if (isset($exceptionData['previous'])) {
|
||||||
|
$prev = $exceptionData['previous'];
|
||||||
|
$html .= '<div class="app-error-debug-chain">';
|
||||||
|
$html .= '<div class="app-error-debug-chain__label">Caused by</div>';
|
||||||
|
$html .= '<div class="app-error-debug-summary__class">' . self::esc($prev['class'] ?? 'Exception') . '</div>';
|
||||||
|
$html .= '<div class="app-error-debug-summary__message">' . self::esc($prev['message'] ?? '') . '</div>';
|
||||||
|
$html .= '<div class="app-error-debug-summary__location">in ' . self::esc(self::shortenPath($prev['file'] ?? '')) . ' on line ' . self::esc((string) ($prev['line'] ?? '?')) . '</div>';
|
||||||
|
$html .= '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $lines
|
||||||
|
*/
|
||||||
|
private static function renderCodeBlock(array $lines, int $highlightLine): string
|
||||||
|
{
|
||||||
|
$html = '<table class="app-error-debug-code-table">';
|
||||||
|
foreach ($lines as $lineNo => $code) {
|
||||||
|
$isHighlight = ($lineNo === $highlightLine) ? ' highlight' : '';
|
||||||
|
$lineNoEsc = self::esc((string) $lineNo);
|
||||||
|
$codeEsc = self::esc($code);
|
||||||
|
$html .= "<tr class=\"{$isHighlight}\"><td class=\"app-error-debug-code-table__line-no\">{$lineNoEsc}</td><td class=\"app-error-debug-code-table__code\">{$codeEsc}</td></tr>";
|
||||||
|
}
|
||||||
|
$html .= '</table>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function renderRequestPanel(array $requestData): string
|
||||||
|
{
|
||||||
|
$html = '';
|
||||||
|
|
||||||
|
$html .= self::renderInfoSection('Request', [
|
||||||
|
'Method' => $requestData['method'] ?? 'GET',
|
||||||
|
'URL' => $requestData['url'] ?? '',
|
||||||
|
'IP' => $requestData['ip'] ?? '',
|
||||||
|
'User Agent' => $requestData['user_agent'] ?? '',
|
||||||
|
'Channel' => $requestData['channel'] ?? 'web',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$headers = $requestData['headers'] ?? [];
|
||||||
|
if (!empty($headers)) {
|
||||||
|
$html .= self::renderInfoSection('Headers', $headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
$get = $requestData['get'] ?? [];
|
||||||
|
if (!empty($get)) {
|
||||||
|
$html .= self::renderInfoSection('GET Parameters', array_map(
|
||||||
|
fn ($v): string => is_string($v) ? $v : json_encode($v, JSON_UNESCAPED_SLASHES),
|
||||||
|
$get,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$post = $requestData['post'] ?? [];
|
||||||
|
if (!empty($post)) {
|
||||||
|
$html .= self::renderInfoSection('POST Parameters', array_map(
|
||||||
|
fn ($v): string => is_string($v) ? $v : json_encode($v, JSON_UNESCAPED_SLASHES),
|
||||||
|
$post,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$session = $requestData['session_keys'] ?? [];
|
||||||
|
if (!empty($session)) {
|
||||||
|
$html .= self::renderInfoSection('Session (keys + types)', $session);
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= self::renderInfoSection('Context', array_filter([
|
||||||
|
'User ID' => $requestData['user_id'] ?? null,
|
||||||
|
'Tenant ID' => $requestData['tenant_id'] ?? null,
|
||||||
|
], fn ($v): bool => $v !== null));
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function renderEnvironmentPanel(array $envData): string
|
||||||
|
{
|
||||||
|
$html = '';
|
||||||
|
|
||||||
|
$html .= self::renderInfoSection('PHP', [
|
||||||
|
'Version' => $envData['php_version'] ?? PHP_VERSION,
|
||||||
|
'SAPI' => $envData['php_sapi'] ?? PHP_SAPI,
|
||||||
|
'Memory (current)' => self::formatBytes($envData['memory_current'] ?? 0),
|
||||||
|
'Memory (peak)' => self::formatBytes($envData['memory_peak'] ?? 0),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$env = $envData['env'] ?? [];
|
||||||
|
if (!empty($env)) {
|
||||||
|
$html .= self::renderInfoSection('Application', $env);
|
||||||
|
}
|
||||||
|
|
||||||
|
$modules = $envData['modules'] ?? [];
|
||||||
|
if (!empty($modules)) {
|
||||||
|
$html .= self::renderInfoSection('Active Modules', array_combine(
|
||||||
|
array_map(fn ($m): string => (string) $m, $modules),
|
||||||
|
array_fill(0, count($modules), 'active'),
|
||||||
|
) ?: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
$extensions = $envData['extensions'] ?? [];
|
||||||
|
if (!empty($extensions)) {
|
||||||
|
$html .= '<div class="app-error-debug-section"><div class="app-error-debug-section__title">Loaded Extensions (' . count($extensions) . ')</div>';
|
||||||
|
$html .= '<div style="font-family: var(--app-font-family-monospace); font-size: var(--text-xs); color: var(--app-muted-color); padding: calc(var(--app-spacing) * 0.5); background: var(--app-card-background-color); border-radius: var(--app-border-radius); border: 1px solid var(--app-border);">';
|
||||||
|
$html .= self::esc(implode(', ', $extensions));
|
||||||
|
$html .= '</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function renderQueryPanel(array $queryData): string
|
||||||
|
{
|
||||||
|
if (!($queryData['available'] ?? false)) {
|
||||||
|
return '<div class="app-error-debug-empty">Query tracking is not available (Debugger disabled or not initialized).</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$queries = $queryData['queries'] ?? [];
|
||||||
|
if (empty($queries)) {
|
||||||
|
return '<div class="app-error-debug-empty">No queries were executed before the error occurred.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html = '<div class="app-error-debug-section"><div class="app-error-debug-section__title">Queries (' . count($queries) . ')</div>';
|
||||||
|
|
||||||
|
foreach ($queries as $i => $query) {
|
||||||
|
$sql = is_string($query) ? $query : ($query[0] ?? $query['query'] ?? '?');
|
||||||
|
$duration = '';
|
||||||
|
if (is_array($query) && isset($query[1])) {
|
||||||
|
$durationMs = round((float) $query[1] * 1000, 2);
|
||||||
|
$duration = $durationMs . ' ms';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '<div class="app-error-debug-query">';
|
||||||
|
$html .= '<div class="app-error-debug-query__header">';
|
||||||
|
$html .= '<span>' . self::esc(self::truncate($sql, 120)) . '</span>';
|
||||||
|
if ($duration !== '') {
|
||||||
|
$html .= '<span class="app-error-debug-query__duration">' . self::esc($duration) . '</span>';
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '<div class="app-error-debug-query__sql">' . self::esc($sql) . '</div>';
|
||||||
|
$html .= '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $items
|
||||||
|
*/
|
||||||
|
private static function renderInfoSection(string $title, array $items): string
|
||||||
|
{
|
||||||
|
if (empty($items)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html = '<div class="app-error-debug-section"><div class="app-error-debug-section__title">' . self::esc($title) . '</div>';
|
||||||
|
$html .= '<table class="app-error-debug-info-table">';
|
||||||
|
|
||||||
|
foreach ($items as $key => $value) {
|
||||||
|
$keyEsc = self::esc((string) $key);
|
||||||
|
$valueStr = (string) $value;
|
||||||
|
$isRedacted = ($valueStr === '[REDACTED]');
|
||||||
|
$valueEsc = $isRedacted
|
||||||
|
? '<span class="app-error-debug-redacted">[REDACTED]</span>'
|
||||||
|
: self::esc($valueStr);
|
||||||
|
$html .= "<tr><th>{$keyEsc}</th><td>{$valueEsc}</td></tr>";
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '</table></div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function loadAsset(string $path): string
|
||||||
|
{
|
||||||
|
$resolved = realpath($path);
|
||||||
|
if ($resolved === false || !is_file($resolved)) {
|
||||||
|
return '/* asset not found: ' . basename($path) . ' */';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) file_get_contents($resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function esc(string $value): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function shortenPath(string $path): string
|
||||||
|
{
|
||||||
|
$root = getcwd();
|
||||||
|
if ($root !== false && str_starts_with($path, $root . '/')) {
|
||||||
|
return substr($path, strlen($root) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function statusText(int $code): string
|
||||||
|
{
|
||||||
|
return match ($code) {
|
||||||
|
400 => 'Bad Request',
|
||||||
|
401 => 'Unauthorized',
|
||||||
|
403 => 'Forbidden',
|
||||||
|
404 => 'Not Found',
|
||||||
|
405 => 'Method Not Allowed',
|
||||||
|
408 => 'Request Timeout',
|
||||||
|
422 => 'Unprocessable Entity',
|
||||||
|
429 => 'Too Many Requests',
|
||||||
|
500 => 'Internal Server Error',
|
||||||
|
502 => 'Bad Gateway',
|
||||||
|
503 => 'Service Unavailable',
|
||||||
|
default => 'Error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function formatBytes(int $bytes): string
|
||||||
|
{
|
||||||
|
if ($bytes < 1024) {
|
||||||
|
return $bytes . ' B';
|
||||||
|
}
|
||||||
|
if ($bytes < 1024 * 1024) {
|
||||||
|
return round($bytes / 1024, 1) . ' KB';
|
||||||
|
}
|
||||||
|
|
||||||
|
return round($bytes / (1024 * 1024), 1) . ' MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function truncate(string $text, int $length): string
|
||||||
|
{
|
||||||
|
if (mb_strlen($text) <= $length) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mb_substr($text, 0, $length) . '...';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ class TypographyTokenContractTest extends TestCase
|
|||||||
'web/css/vendor-overrides/',
|
'web/css/vendor-overrides/',
|
||||||
'web/css/base/variables.base.css',
|
'web/css/base/variables.base.css',
|
||||||
'web/css/base/typography.tokens.css',
|
'web/css/base/typography.tokens.css',
|
||||||
|
'web/css/pages/app-error-debug.css', // Self-contained error page — inlined by ErrorRenderer, cannot use app tokens.
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
163
tests/Http/ErrorDataCollectorTest.php
Normal file
163
tests/Http/ErrorDataCollectorTest.php
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Http;
|
||||||
|
|
||||||
|
use MintyPHP\Http\ErrorDataCollector;
|
||||||
|
use MintyPHP\Http\RequestContext;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class ErrorDataCollectorTest extends TestCase
|
||||||
|
{
|
||||||
|
private array $serverBackup = [];
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->serverBackup = $_SERVER;
|
||||||
|
RequestContext::resetForTests();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$_SERVER = $this->serverBackup;
|
||||||
|
RequestContext::resetForTests();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCollectReturnsAllFourSections(): void
|
||||||
|
{
|
||||||
|
$exception = new \RuntimeException('Test exception', 99);
|
||||||
|
$result = ErrorDataCollector::collect($exception);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('exception', $result);
|
||||||
|
$this->assertArrayHasKey('request', $result);
|
||||||
|
$this->assertArrayHasKey('environment', $result);
|
||||||
|
$this->assertArrayHasKey('queries', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExceptionSectionContainsCorrectDetails(): void
|
||||||
|
{
|
||||||
|
$exception = new \LogicException('Something wrong', 7);
|
||||||
|
$result = ErrorDataCollector::collect($exception);
|
||||||
|
|
||||||
|
$exc = $result['exception'];
|
||||||
|
$this->assertSame('LogicException', $exc['class']);
|
||||||
|
$this->assertSame('Something wrong', $exc['message']);
|
||||||
|
$this->assertSame(7, $exc['code']);
|
||||||
|
$this->assertStringContainsString('ErrorDataCollectorTest.php', $exc['file']);
|
||||||
|
$this->assertIsInt($exc['line']);
|
||||||
|
$this->assertIsArray($exc['trace']);
|
||||||
|
$this->assertNotEmpty($exc['trace']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testChainedExceptionsAreCollected(): void
|
||||||
|
{
|
||||||
|
$inner = new \InvalidArgumentException('inner cause');
|
||||||
|
$outer = new \RuntimeException('outer', 0, $inner);
|
||||||
|
|
||||||
|
$result = ErrorDataCollector::collect($outer);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('previous', $result['exception']);
|
||||||
|
$this->assertSame('InvalidArgumentException', $result['exception']['previous']['class']);
|
||||||
|
$this->assertSame('inner cause', $result['exception']['previous']['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCodeSnippetIsExtractedForKnownFile(): void
|
||||||
|
{
|
||||||
|
$exception = new \RuntimeException('snippet test');
|
||||||
|
$result = ErrorDataCollector::collect($exception);
|
||||||
|
|
||||||
|
$firstFrame = $result['exception']['trace'][0] ?? null;
|
||||||
|
$this->assertNotNull($firstFrame);
|
||||||
|
$this->assertArrayHasKey('code_snippet', $firstFrame);
|
||||||
|
// The snippet should contain lines around the throw site.
|
||||||
|
$this->assertNotEmpty($firstFrame['code_snippet']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRequestSectionContainsBasicData(): void
|
||||||
|
{
|
||||||
|
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||||
|
$_SERVER['REQUEST_URI'] = '/admin/users/edit?id=5';
|
||||||
|
$_SERVER['REMOTE_ADDR'] = '10.0.0.1';
|
||||||
|
$_SERVER['HTTP_USER_AGENT'] = 'TestAgent/1.0';
|
||||||
|
|
||||||
|
$exception = new \RuntimeException('request test');
|
||||||
|
$result = ErrorDataCollector::collect($exception);
|
||||||
|
|
||||||
|
$req = $result['request'];
|
||||||
|
$this->assertSame('POST', $req['method']);
|
||||||
|
$this->assertSame('/admin/users/edit?id=5', $req['url']);
|
||||||
|
$this->assertSame('10.0.0.1', $req['ip']);
|
||||||
|
$this->assertSame('TestAgent/1.0', $req['user_agent']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHeadersAreRedacted(): void
|
||||||
|
{
|
||||||
|
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer secret-token';
|
||||||
|
$_SERVER['HTTP_COOKIE'] = 'session=abc123';
|
||||||
|
$_SERVER['HTTP_X_API_KEY'] = 'my-key';
|
||||||
|
$_SERVER['HTTP_ACCEPT'] = 'text/html';
|
||||||
|
|
||||||
|
$exception = new \RuntimeException('header redaction');
|
||||||
|
$result = ErrorDataCollector::collect($exception);
|
||||||
|
|
||||||
|
$headers = $result['request']['headers'];
|
||||||
|
$this->assertSame('[REDACTED]', $headers['authorization']);
|
||||||
|
$this->assertSame('[REDACTED]', $headers['cookie']);
|
||||||
|
$this->assertSame('[REDACTED]', $headers['x-api-key']);
|
||||||
|
$this->assertSame('text/html', $headers['accept']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPostParamsAreRedacted(): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'username' => 'admin',
|
||||||
|
'password' => 'secret123',
|
||||||
|
'csrf_token' => 'abc',
|
||||||
|
'email' => 'test@example.com',
|
||||||
|
];
|
||||||
|
|
||||||
|
$exception = new \RuntimeException('post redaction');
|
||||||
|
$result = ErrorDataCollector::collect($exception);
|
||||||
|
|
||||||
|
$post = $result['request']['post'];
|
||||||
|
$this->assertSame('admin', $post['username']);
|
||||||
|
$this->assertSame('[REDACTED]', $post['password']);
|
||||||
|
$this->assertSame('[REDACTED]', $post['csrf_token']);
|
||||||
|
$this->assertSame('test@example.com', $post['email']);
|
||||||
|
|
||||||
|
$_POST = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEnvironmentSectionContainsPhpInfo(): void
|
||||||
|
{
|
||||||
|
$exception = new \RuntimeException('env test');
|
||||||
|
$result = ErrorDataCollector::collect($exception);
|
||||||
|
|
||||||
|
$env = $result['environment'];
|
||||||
|
$this->assertSame(PHP_VERSION, $env['php_version']);
|
||||||
|
$this->assertSame(PHP_SAPI, $env['php_sapi']);
|
||||||
|
$this->assertIsArray($env['extensions']);
|
||||||
|
$this->assertNotEmpty($env['extensions']);
|
||||||
|
$this->assertIsInt($env['memory_current']);
|
||||||
|
$this->assertIsInt($env['memory_peak']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testArgsAreSummarizedByTypeOnly(): void
|
||||||
|
{
|
||||||
|
// Trace frames should contain type summaries, not actual argument values.
|
||||||
|
$exception = new \RuntimeException('args test');
|
||||||
|
$result = ErrorDataCollector::collect($exception);
|
||||||
|
|
||||||
|
foreach ($result['exception']['trace'] as $frame) {
|
||||||
|
if (isset($frame['args_summary'])) {
|
||||||
|
foreach ($frame['args_summary'] as $summary) {
|
||||||
|
$this->assertIsString($summary);
|
||||||
|
// Should be type descriptions, not raw values.
|
||||||
|
$this->assertMatchesRegularExpression(
|
||||||
|
'/^(null|true|false|int|float|string\(\d+\)|array\(\d+\)|resource|unknown|[A-Z][\w\\\\]*)$/',
|
||||||
|
$summary,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
95
tests/Http/ErrorLoggerTest.php
Normal file
95
tests/Http/ErrorLoggerTest.php
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Http;
|
||||||
|
|
||||||
|
use MintyPHP\Http\ErrorLogger;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class ErrorLoggerTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $logFile;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->logFile = 'storage/logs/errors/error-' . date('Y-m-d') . '.log';
|
||||||
|
// Remove the file so each test starts clean.
|
||||||
|
if (is_file($this->logFile)) {
|
||||||
|
@unlink($this->logFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLogWritesJsonLineEntry(): void
|
||||||
|
{
|
||||||
|
$exception = new \RuntimeException('Test error', 42);
|
||||||
|
$data = [
|
||||||
|
'exception' => [
|
||||||
|
'class' => 'RuntimeException',
|
||||||
|
'message' => 'Test error',
|
||||||
|
'code' => 42,
|
||||||
|
'file' => '/app/web/index.php',
|
||||||
|
'line' => 10,
|
||||||
|
'trace' => [
|
||||||
|
['file' => '/app/web/index.php', 'line' => 10],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'request' => [
|
||||||
|
'request_id' => 'abc-123',
|
||||||
|
'channel' => 'web',
|
||||||
|
'method' => 'GET',
|
||||||
|
'url' => '/admin/dashboard',
|
||||||
|
'ip' => '127.0.0.1',
|
||||||
|
'user_id' => 5,
|
||||||
|
'tenant_id' => 1,
|
||||||
|
],
|
||||||
|
'environment' => [
|
||||||
|
'php_version' => '8.5.0',
|
||||||
|
'memory_peak' => 12 * 1024 * 1024,
|
||||||
|
],
|
||||||
|
'queries' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
ErrorLogger::log($exception, $data);
|
||||||
|
|
||||||
|
$this->assertFileExists($this->logFile);
|
||||||
|
|
||||||
|
$lines = array_filter(explode("\n", (string) file_get_contents($this->logFile)));
|
||||||
|
$this->assertCount(1, $lines);
|
||||||
|
|
||||||
|
$entry = json_decode($lines[0], true);
|
||||||
|
$this->assertIsArray($entry);
|
||||||
|
$this->assertSame('error', $entry['level']);
|
||||||
|
$this->assertSame('abc-123', $entry['request_id']);
|
||||||
|
$this->assertSame('RuntimeException', $entry['exception']['class']);
|
||||||
|
$this->assertSame('Test error', $entry['exception']['message']);
|
||||||
|
$this->assertArrayHasKey('ip_hash', $entry['request']);
|
||||||
|
$this->assertStringStartsWith('sha256:', $entry['request']['ip_hash']);
|
||||||
|
// Raw IP should NOT be in the log.
|
||||||
|
$this->assertStringNotContainsString('127.0.0.1', json_encode($entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLogRedactsIpAddress(): void
|
||||||
|
{
|
||||||
|
$exception = new \RuntimeException('IP test');
|
||||||
|
$data = [
|
||||||
|
'request' => ['ip' => '192.168.1.42', 'request_id' => 'test-id'],
|
||||||
|
'exception' => [],
|
||||||
|
'environment' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
ErrorLogger::log($exception, $data);
|
||||||
|
|
||||||
|
$this->assertFileExists($this->logFile);
|
||||||
|
$contents = (string) file_get_contents($this->logFile);
|
||||||
|
$this->assertStringNotContainsString('192.168.1.42', $contents);
|
||||||
|
$this->assertStringContainsString('sha256:', $contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLogNeverThrowsOnFailure(): void
|
||||||
|
{
|
||||||
|
// Even with broken data, log() must not throw.
|
||||||
|
$exception = new \RuntimeException('safe test');
|
||||||
|
ErrorLogger::log($exception, []);
|
||||||
|
|
||||||
|
$this->assertTrue(true); // If we get here, no exception was thrown.
|
||||||
|
}
|
||||||
|
}
|
||||||
155
tests/Http/ErrorRendererTest.php
Normal file
155
tests/Http/ErrorRendererTest.php
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Http;
|
||||||
|
|
||||||
|
use MintyPHP\Http\ErrorRenderer;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class ErrorRendererTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testRenderDevReturnsCompleteHtmlDocument(): void
|
||||||
|
{
|
||||||
|
$data = self::sampleData();
|
||||||
|
$html = ErrorRenderer::renderDev($data);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('<!DOCTYPE html>', $html);
|
||||||
|
$this->assertStringContainsString('</html>', $html);
|
||||||
|
$this->assertStringContainsString('<style>', $html);
|
||||||
|
$this->assertStringContainsString('<script>', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderDevContainsExceptionDetails(): void
|
||||||
|
{
|
||||||
|
$data = self::sampleData();
|
||||||
|
$html = ErrorRenderer::renderDev($data);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('RuntimeException', $html);
|
||||||
|
$this->assertStringContainsString('Test error message', $html);
|
||||||
|
$this->assertStringContainsString('test-request-id-123', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderDevContainsAllFourTabs(): void
|
||||||
|
{
|
||||||
|
$data = self::sampleData();
|
||||||
|
$html = ErrorRenderer::renderDev($data);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('panel-exception', $html);
|
||||||
|
$this->assertStringContainsString('panel-request', $html);
|
||||||
|
$this->assertStringContainsString('panel-environment', $html);
|
||||||
|
$this->assertStringContainsString('panel-queries', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderDevEscapesHtmlInExceptionMessage(): void
|
||||||
|
{
|
||||||
|
$data = self::sampleData();
|
||||||
|
$data['exception']['message'] = '<script>alert("xss")</script>';
|
||||||
|
$html = ErrorRenderer::renderDev($data);
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString('<script>alert("xss")</script>', $html);
|
||||||
|
$this->assertStringContainsString('<script>alert("xss")</script>', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderDevShowsRedactedValues(): void
|
||||||
|
{
|
||||||
|
$data = self::sampleData();
|
||||||
|
$data['request']['headers']['authorization'] = '[REDACTED]';
|
||||||
|
$html = ErrorRenderer::renderDev($data);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('[REDACTED]', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderProdShowsRequestIdOnly(): void
|
||||||
|
{
|
||||||
|
$html = ErrorRenderer::renderProd('abc-def-123');
|
||||||
|
|
||||||
|
$this->assertStringContainsString('<!DOCTYPE html>', $html);
|
||||||
|
$this->assertStringContainsString('abc-def-123', $html);
|
||||||
|
$this->assertStringContainsString('An unexpected error occurred', $html);
|
||||||
|
$this->assertStringContainsString('Back to start', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderProdDoesNotContainDebugInfo(): void
|
||||||
|
{
|
||||||
|
$html = ErrorRenderer::renderProd('request-id');
|
||||||
|
|
||||||
|
// Prod page must not render actual debug HTML elements (panels, stack frames).
|
||||||
|
// Note: CSS class names may appear in the inlined stylesheet — we check for HTML attributes only.
|
||||||
|
$this->assertStringNotContainsString('id="panel-exception"', $html);
|
||||||
|
$this->assertStringNotContainsString('id="panel-request"', $html);
|
||||||
|
$this->assertStringNotContainsString('data-panel=', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderProdEscapesRequestId(): void
|
||||||
|
{
|
||||||
|
$html = ErrorRenderer::renderProd('<script>alert(1)</script>');
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString('<script>alert(1)</script>', $html);
|
||||||
|
$this->assertStringContainsString('<script>', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEscMethodEscapesSpecialChars(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('<b>test</b>', ErrorRenderer::esc('<b>test</b>'));
|
||||||
|
$this->assertSame('a & b', ErrorRenderer::esc('a & b'));
|
||||||
|
$this->assertSame('"quoted"', ErrorRenderer::esc('"quoted"'));
|
||||||
|
$this->assertSame('it's', ErrorRenderer::esc("it's"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{exception: array<string, mixed>, request: array<string, mixed>, environment: array<string, mixed>, queries: array<string, mixed>}
|
||||||
|
*/
|
||||||
|
private static function sampleData(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'exception' => [
|
||||||
|
'class' => 'RuntimeException',
|
||||||
|
'message' => 'Test error message',
|
||||||
|
'code' => 0,
|
||||||
|
'file' => '/app/web/index.php',
|
||||||
|
'line' => 45,
|
||||||
|
'trace' => [
|
||||||
|
[
|
||||||
|
'file' => '/app/web/index.php',
|
||||||
|
'line' => 45,
|
||||||
|
'function' => null,
|
||||||
|
'class' => null,
|
||||||
|
'code_snippet' => [
|
||||||
|
44 => ' $expected = "abc";',
|
||||||
|
45 => ' throw new RuntimeException("Test");',
|
||||||
|
46 => ' // unreachable',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'request' => [
|
||||||
|
'request_id' => 'test-request-id-123',
|
||||||
|
'channel' => 'web',
|
||||||
|
'method' => 'GET',
|
||||||
|
'url' => '/admin/dashboard',
|
||||||
|
'ip' => '127.0.0.1',
|
||||||
|
'user_agent' => 'TestAgent/1.0',
|
||||||
|
'headers' => ['accept' => 'text/html'],
|
||||||
|
'get' => [],
|
||||||
|
'post' => [],
|
||||||
|
'session_keys' => [],
|
||||||
|
'user_id' => null,
|
||||||
|
'tenant_id' => null,
|
||||||
|
],
|
||||||
|
'environment' => [
|
||||||
|
'php_version' => '8.5.0',
|
||||||
|
'php_sapi' => 'cli',
|
||||||
|
'extensions' => ['Core', 'json', 'mbstring'],
|
||||||
|
'memory_current' => 8 * 1024 * 1024,
|
||||||
|
'memory_peak' => 12 * 1024 * 1024,
|
||||||
|
'env' => ['APP_DEBUG' => 'true', 'APP_ENV' => 'dev'],
|
||||||
|
'modules' => [],
|
||||||
|
],
|
||||||
|
'queries' => [
|
||||||
|
'available' => true,
|
||||||
|
'queries' => [],
|
||||||
|
'start' => microtime(true),
|
||||||
|
'count' => 0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
432
web/css/pages/app-error-debug.css
Normal file
432
web/css/pages/app-error-debug.css
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
/*
|
||||||
|
* Error Debug Page
|
||||||
|
*
|
||||||
|
* Self-contained: inlined by ErrorRenderer so it works even when the app's
|
||||||
|
* stylesheet fails to load. Uses the project's token names with hardcoded
|
||||||
|
* fallbacks so the page looks native but remains independent.
|
||||||
|
*
|
||||||
|
* Excluded from TypographyTokenContractTest (self-contained error page).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Fallback tokens (only apply when the app's CSS is NOT loaded) ────── */
|
||||||
|
:root {
|
||||||
|
/* typography — mirrors typography.tokens.css */
|
||||||
|
--text-2xs: 0.6875rem;
|
||||||
|
--text-xs: 0.75rem;
|
||||||
|
--text-sm: 0.8125rem;
|
||||||
|
--text-base: 0.875rem;
|
||||||
|
--text-lg: 1rem;
|
||||||
|
--text-xl: 1.125rem;
|
||||||
|
--text-3xl: clamp(1.375rem, 1.2rem + 0.75vw, 1.75rem);
|
||||||
|
--text-5xl: clamp(2rem, 1.7rem + 1.5vw, 2.75rem);
|
||||||
|
--leading-none: 1;
|
||||||
|
--leading-tight: 1.2;
|
||||||
|
--leading-normal: 1.5;
|
||||||
|
--font-medium: 500;
|
||||||
|
--font-semibold: 600;
|
||||||
|
--font-bold: 700;
|
||||||
|
--tracking-wider: 0.05em;
|
||||||
|
|
||||||
|
/* layout — mirrors variables.base.css */
|
||||||
|
--app-font-family: system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
|
||||||
|
Helvetica, Arial, sans-serif;
|
||||||
|
--app-font-family-monospace: ui-monospace, SFMono-Regular, "SF Mono", Menlo,
|
||||||
|
Consolas, "Liberation Mono", monospace;
|
||||||
|
--app-border-radius: 0.5rem;
|
||||||
|
--app-spacing: 1rem;
|
||||||
|
--app-transition: 0.2s ease-in-out;
|
||||||
|
|
||||||
|
/* light theme fallbacks — mirrors [data-theme="light"] */
|
||||||
|
--app-background-color: #fff;
|
||||||
|
--app-color: #373c44;
|
||||||
|
--app-muted-color: #646b79;
|
||||||
|
--app-muted-border-color: rgb(231, 234, 239.5);
|
||||||
|
--app-card-background-color: #fff;
|
||||||
|
--app-card-border-color: rgb(231, 234, 239.5);
|
||||||
|
--app-card-sectioning-background-color: rgb(251, 251.5, 252.25);
|
||||||
|
--app-form-element-background-color: rgb(251, 251.5, 252.25);
|
||||||
|
--app-form-element-border-color: #cfd5e2;
|
||||||
|
--app-border: var(--app-form-element-border-color);
|
||||||
|
--app-primary: hsl(180.75 37.38% 58.04%);
|
||||||
|
--badge-danger-bg: #fdecec;
|
||||||
|
--badge-danger-color: #8f2c2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* dark mode fallback (prefers-color-scheme or data-theme) */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) {
|
||||||
|
--app-background-color: rgb(19, 22.5, 30.5);
|
||||||
|
--app-color: #c2c7d0;
|
||||||
|
--app-muted-color: #7b8495;
|
||||||
|
--app-muted-border-color: #202632;
|
||||||
|
--app-card-background-color: #181c25;
|
||||||
|
--app-card-border-color: #181c25;
|
||||||
|
--app-card-sectioning-background-color: rgb(28, 33, 43.5);
|
||||||
|
--app-form-element-background-color: rgb(28, 33, 43.5);
|
||||||
|
--app-form-element-border-color: #2a3140;
|
||||||
|
--app-border: var(--app-form-element-border-color);
|
||||||
|
--badge-danger-bg: #3a1b1b;
|
||||||
|
--badge-danger-color: #f2a3a3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reset (scoped to error debug page) ──────────────────────────────── */
|
||||||
|
|
||||||
|
body.app-error-debug {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--app-font-family);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
color: var(--app-color);
|
||||||
|
background: var(--app-background-color);
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug *,
|
||||||
|
.app-error-debug *::before,
|
||||||
|
.app-error-debug *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Container ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.app-error-debug-container {
|
||||||
|
max-width: 72rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 var(--app-spacing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.app-error-debug-header {
|
||||||
|
background: var(--app-card-background-color);
|
||||||
|
border-bottom: 1px solid var(--app-border);
|
||||||
|
padding: calc(var(--app-spacing) * 1.25) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-header__inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--app-spacing);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-header__status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(var(--app-spacing) * 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-header__code {
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
line-height: var(--leading-none);
|
||||||
|
color: var(--badge-danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-header__label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--app-muted-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-request-id {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(var(--app-spacing) * 0.5);
|
||||||
|
font-family: var(--app-font-family-monospace);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--app-muted-color);
|
||||||
|
background: var(--app-form-element-background-color);
|
||||||
|
padding: calc(var(--app-spacing) * 0.375) calc(var(--app-spacing) * 0.75);
|
||||||
|
border-radius: var(--app-border-radius);
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-copy-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--app-muted-color);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: color var(--app-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-copy-button:hover { color: var(--app-color); }
|
||||||
|
.app-error-debug-copy-button.copied { color: var(--app-primary); }
|
||||||
|
|
||||||
|
/* ── Exception Summary ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.app-error-debug-summary {
|
||||||
|
padding: calc(var(--app-spacing) * 1.5) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-summary__class {
|
||||||
|
font-family: var(--app-font-family-monospace);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--app-primary);
|
||||||
|
margin-bottom: calc(var(--app-spacing) * 0.375);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-summary__message {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--app-color);
|
||||||
|
word-break: break-word;
|
||||||
|
margin-bottom: calc(var(--app-spacing) * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-summary__location {
|
||||||
|
font-family: var(--app-font-family-monospace);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--app-muted-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tabs ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.app-error-debug-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 1px solid var(--app-border);
|
||||||
|
margin-bottom: calc(var(--app-spacing) * 1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-tab {
|
||||||
|
padding: calc(var(--app-spacing) * 0.625) var(--app-spacing);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
color: var(--app-muted-color);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--app-transition), border-color var(--app-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-tab:hover { color: var(--app-color); }
|
||||||
|
|
||||||
|
.app-error-debug-tab.active {
|
||||||
|
color: var(--app-primary);
|
||||||
|
border-bottom-color: var(--app-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-tab__count {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
background: var(--app-muted-border-color);
|
||||||
|
color: var(--app-muted-color);
|
||||||
|
border-radius: 9999px;
|
||||||
|
margin-left: calc(var(--app-spacing) * 0.375);
|
||||||
|
padding: 0 calc(var(--app-spacing) * 0.375);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-panel { display: none; padding-bottom: calc(var(--app-spacing) * 2); }
|
||||||
|
.app-error-debug-panel.active { display: block; }
|
||||||
|
|
||||||
|
/* ── Stack Trace ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.app-error-debug-trace { list-style: none; padding: 0; margin: 0; }
|
||||||
|
|
||||||
|
.app-error-debug-frame {
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
border-radius: var(--app-border-radius);
|
||||||
|
margin-bottom: calc(var(--app-spacing) * 0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-frame__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: calc(var(--app-spacing) * 0.5);
|
||||||
|
padding: calc(var(--app-spacing) * 0.625) calc(var(--app-spacing) * 0.875);
|
||||||
|
font-family: var(--app-font-family-monospace);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--app-card-background-color);
|
||||||
|
transition: background var(--app-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-frame__header:hover {
|
||||||
|
background: var(--app-card-sectioning-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-frame__index {
|
||||||
|
color: var(--app-muted-color);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
min-width: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-frame__file { color: var(--app-color); }
|
||||||
|
.app-error-debug-frame__line { color: var(--badge-danger-color); }
|
||||||
|
|
||||||
|
.app-error-debug-frame__function {
|
||||||
|
color: var(--app-muted-color);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-frame__code { display: none; overflow-x: auto; }
|
||||||
|
.app-error-debug-frame.open .app-error-debug-frame__code { display: block; }
|
||||||
|
|
||||||
|
/* ── Code Table ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.app-error-debug-code-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-family: var(--app-font-family-monospace);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-code-table td {
|
||||||
|
padding: 0 calc(var(--app-spacing) * 0.875);
|
||||||
|
white-space: pre;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-code-table__line-no {
|
||||||
|
width: 3.5rem;
|
||||||
|
text-align: right;
|
||||||
|
color: var(--app-muted-color);
|
||||||
|
user-select: none;
|
||||||
|
padding-right: var(--app-spacing);
|
||||||
|
border-right: 1px solid var(--app-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-code-table__code {
|
||||||
|
padding-left: var(--app-spacing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-code-table tr.highlight {
|
||||||
|
background: var(--badge-danger-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-code-table tr.highlight .app-error-debug-code-table__line-no {
|
||||||
|
color: var(--badge-danger-color);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Info Tables ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.app-error-debug-section {
|
||||||
|
margin-bottom: calc(var(--app-spacing) * 1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-section__title {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
color: var(--app-muted-color);
|
||||||
|
margin-bottom: calc(var(--app-spacing) * 0.625);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-info-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
background: var(--app-card-background-color);
|
||||||
|
border-radius: var(--app-border-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-info-table tr + tr {
|
||||||
|
border-top: 1px solid var(--app-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-info-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: calc(var(--app-spacing) * 0.5) calc(var(--app-spacing) * 0.875);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
color: var(--app-muted-color);
|
||||||
|
width: 12rem;
|
||||||
|
vertical-align: top;
|
||||||
|
font-family: var(--app-font-family-monospace);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
background: var(--app-card-sectioning-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-info-table td {
|
||||||
|
padding: calc(var(--app-spacing) * 0.5) calc(var(--app-spacing) * 0.875);
|
||||||
|
font-family: var(--app-font-family-monospace);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--app-color);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-redacted {
|
||||||
|
color: var(--app-muted-color);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Query Timeline ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.app-error-debug-query {
|
||||||
|
background: var(--app-card-background-color);
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
border-radius: var(--app-border-radius);
|
||||||
|
margin-bottom: calc(var(--app-spacing) * 0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-query__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: calc(var(--app-spacing) * 0.5) calc(var(--app-spacing) * 0.875);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--app-font-family-monospace);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-query__duration {
|
||||||
|
color: var(--app-muted-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-query__sql {
|
||||||
|
display: none;
|
||||||
|
padding: calc(var(--app-spacing) * 0.75) calc(var(--app-spacing) * 0.875);
|
||||||
|
background: var(--app-card-sectioning-background-color);
|
||||||
|
font-family: var(--app-font-family-monospace);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
border-top: 1px solid var(--app-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-query.open .app-error-debug-query__sql { display: block; }
|
||||||
|
|
||||||
|
/* ── Empty State ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.app-error-debug-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: calc(var(--app-spacing) * 2);
|
||||||
|
color: var(--app-muted-color);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Previous Exception Chain ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.app-error-debug-chain {
|
||||||
|
margin-top: calc(var(--app-spacing) * 1.5);
|
||||||
|
padding-top: calc(var(--app-spacing) * 1.5);
|
||||||
|
border-top: 1px dashed var(--app-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-error-debug-chain__label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
color: var(--app-muted-color);
|
||||||
|
margin-bottom: calc(var(--app-spacing) * 0.5);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ chdir(__DIR__ . '/..');
|
|||||||
require 'vendor/autoload.php';
|
require 'vendor/autoload.php';
|
||||||
require 'config/config.php';
|
require 'config/config.php';
|
||||||
require 'lib/Support/helpers.php';
|
require 'lib/Support/helpers.php';
|
||||||
|
\MintyPHP\Http\ErrorHandler::register();
|
||||||
$container = require 'lib/App/registerContainer.php';
|
$container = require 'lib/App/registerContainer.php';
|
||||||
setAppContainer($container);
|
setAppContainer($container);
|
||||||
|
|
||||||
|
|||||||
58
web/js/components/app-error-debug.js
Normal file
58
web/js/components/app-error-debug.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/* Error Debug Page — interactive behavior (inlined by ErrorRenderer) */
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// ── Tab switching ──────────────────────────────────
|
||||||
|
const tabs = document.querySelectorAll('.app-error-debug-tab');
|
||||||
|
const panels = document.querySelectorAll('.app-error-debug-panel');
|
||||||
|
|
||||||
|
tabs.forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
const target = tab.dataset.panel;
|
||||||
|
tabs.forEach(t => t.classList.remove('active'));
|
||||||
|
panels.forEach(p => p.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
const panel = document.getElementById(target);
|
||||||
|
if (panel) panel.classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Frame expand/collapse ──────────────────────────
|
||||||
|
document.querySelectorAll('.app-error-debug-frame__header').forEach(header => {
|
||||||
|
header.addEventListener('click', () => {
|
||||||
|
header.closest('.app-error-debug-frame')?.classList.toggle('open');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open the first frame by default.
|
||||||
|
const firstFrame = document.querySelector('.app-error-debug-frame');
|
||||||
|
if (firstFrame) firstFrame.classList.add('open');
|
||||||
|
|
||||||
|
// ── Query expand/collapse ──────────────────────────
|
||||||
|
document.querySelectorAll('.app-error-debug-query__header').forEach(header => {
|
||||||
|
header.addEventListener('click', () => {
|
||||||
|
header.closest('.app-error-debug-query')?.classList.toggle('open');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Copy to clipboard ─────────────────────────────
|
||||||
|
document.querySelectorAll('.app-error-debug-copy-button').forEach(button => {
|
||||||
|
button.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const text = button.dataset.copy;
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
button.classList.add('copied');
|
||||||
|
const original = button.innerHTML;
|
||||||
|
button.innerHTML = '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/></svg>';
|
||||||
|
setTimeout(() => {
|
||||||
|
button.classList.remove('copied');
|
||||||
|
button.innerHTML = original;
|
||||||
|
}, 1500);
|
||||||
|
} catch {
|
||||||
|
// Clipboard API may not be available in all contexts.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user