diff --git a/index.php b/index.php
index b1d23af..4d4751e 100644
--- a/index.php
+++ b/index.php
@@ -1,6 +1,154 @@
date('H:i:s'), 'stage' => 'boot:script_loaded'],
+];
+$GLOBALS['app_runtime_debug_rendered'] = false;
+
+if (!function_exists('runtime_debug_boot_mark')) {
+ function runtime_debug_boot_mark(string $stage, array $context = []): void {
+ $GLOBALS['app_runtime_debug_stage'] = $stage;
+ $timeline = $GLOBALS['app_runtime_debug_timeline'] ?? [];
+ $entry = [
+ 'time' => date('H:i:s'),
+ 'stage' => $stage,
+ ];
+
+ if ($context !== []) {
+ $entry['context'] = $context;
+ }
+
+ $timeline[] = $entry;
+ if (count($timeline) > 20) {
+ $timeline = array_slice($timeline, -20);
+ }
+
+ $GLOBALS['app_runtime_debug_timeline'] = $timeline;
+ }
+}
+
+if (!function_exists('runtime_debug_boot_force_details')) {
+ function runtime_debug_boot_force_details(): bool {
+ $candidates = [
+ $_GET['debug'] ?? null,
+ $_GET['app_debug'] ?? null,
+ getenv('APP_RUNTIME_DEBUG'),
+ $_ENV['APP_RUNTIME_DEBUG'] ?? null,
+ $_SERVER['APP_RUNTIME_DEBUG'] ?? null,
+ ];
+
+ foreach ($candidates as $candidate) {
+ if ($candidate === false || $candidate === null) {
+ continue;
+ }
+
+ $value = strtolower(trim((string)$candidate));
+ if (in_array($value, ['1', 'true', 'yes', 'on'], true)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
+
+if (!defined('APP_RUNTIME_DEBUG_BOOTSTRAP_SHUTDOWN_REGISTERED')) {
+ define('APP_RUNTIME_DEBUG_BOOTSTRAP_SHUTDOWN_REGISTERED', true);
+ register_shutdown_function(static function (): void {
+ if (!empty($GLOBALS['app_runtime_debug_rendered']) || defined('APP_RUNTIME_DEBUG_FATAL_HANDLER_REGISTERED')) {
+ return;
+ }
+
+ $error = error_get_last();
+ if (!is_array($error)) {
+ return;
+ }
+
+ $fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR];
+ if (!in_array((int)($error['type'] ?? 0), $fatalTypes, true)) {
+ return;
+ }
+
+ $stage = (string)($GLOBALS['app_runtime_debug_stage'] ?? 'unknown');
+ $timeline = $GLOBALS['app_runtime_debug_timeline'] ?? [];
+ $parts = [
+ date('Y-m-d H:i:s'),
+ '[FatalError]',
+ (string)($error['message'] ?? 'Fatal error'),
+ 'file=' . (string)($error['file'] ?? 'unknown') . ':' . (int)($error['line'] ?? 0),
+ 'page=' . (string)($_GET['page'] ?? 'dashboard'),
+ 'uri=' . (string)($_SERVER['REQUEST_URI'] ?? 'cli'),
+ 'stage=' . $stage,
+ ];
+
+ if ($timeline !== []) {
+ $parts[] = 'timeline=' . json_encode(array_slice($timeline, -8), JSON_UNESCAPED_UNICODE);
+ }
+
+ @file_put_contents(__DIR__ . '/runtime_debug.log', implode(' || ', $parts) . PHP_EOL, FILE_APPEND);
+
+ if (!headers_sent()) {
+ http_response_code(500);
+ header('Content-Type: text/html; charset=UTF-8');
+ header('X-Robots-Tag: noindex, nofollow');
+ }
+
+ $roleName = (string)($_SESSION['user_role_name'] ?? '');
+ $showDetails = runtime_debug_boot_force_details()
+ || PHP_SAPI === 'cli'
+ || strcasecmp($roleName, 'Administrator') === 0
+ || (int)($_SESSION['user_id'] ?? 0) === 1;
+
+ $message = (string)($error['message'] ?? 'Fatal error');
+ $safeMessage = $showDetails
+ ? $message
+ : 'A fatal application error occurred before the page finished loading.';
+ $timelineText = json_encode(array_slice($timeline, -8), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
+
+ $GLOBALS['app_runtime_debug_rendered'] = true;
+ ?>
+
+
+
+
+
+
+ Application Boot Error
+
+
+
+
+
+ Application Boot Error
+ The request failed before the main page finished loading.
+ Stage: = htmlspecialchars($stage) ?>
+ Message: = htmlspecialchars($safeMessage) ?>
+ A copy of this failure was written to runtime_debug.log.
+
+ = htmlspecialchars((string)$timelineText) ?>
+
+
+
+
+
+ $file]);
+ require $file;
+ }
+}
+
+if (!function_exists('runtime_debug_extract_table_name')) {
+ function runtime_debug_extract_table_name(Throwable $throwable): ?string {
+ $message = $throwable->getMessage();
+ $patterns = [
+ "/Table '[^']+\.([^']+)' doesn't exist/i",
+ '/(?:INSERT\s+INTO|UPDATE|FROM|JOIN|DELETE\s+FROM|ALTER\s+TABLE)\s+`?([a-zA-Z0-9_]+)`?/i',
+ ];
+
+ foreach ($patterns as $pattern) {
+ if (preg_match($pattern, $message, $matches)) {
+ return (string)$matches[1];
+ }
+ }
+
+ return null;
+ }
+}
+
+if (!function_exists('runtime_debug_extract_column_name')) {
+ function runtime_debug_extract_column_name(Throwable $throwable): ?string {
+ $message = $throwable->getMessage();
+
+ if (preg_match("/Unknown column '([^']+)'/i", $message, $matches)) {
+ return (string)$matches[1];
+ }
+
+ if (preg_match('/Column not found: [0-9]+ ([a-zA-Z0-9_]+)/i', $message, $matches)) {
+ return (string)$matches[1];
+ }
+
+ return null;
+ }
+}
+
+if (!function_exists('runtime_debug_find_related_migrations')) {
+ function runtime_debug_find_related_migrations(?string $tableName, ?string $columnName = null, int $limit = 6): array {
+ $directory = __DIR__ . '/db/migrations';
+ if (!is_dir($directory)) {
+ return [];
+ }
+
+ $needles = array_values(array_filter([
+ $tableName ? strtolower($tableName) : null,
+ $columnName ? strtolower($columnName) : null,
+ ]));
+
+ if ($needles === []) {
+ return [];
+ }
+
+ $matches = [];
+ foreach (glob($directory . '/*.{sql,php}', GLOB_BRACE) ?: [] as $migrationPath) {
+ if (!is_readable($migrationPath)) {
+ continue;
+ }
+
+ $contents = @file_get_contents($migrationPath);
+ if ($contents === false) {
+ continue;
+ }
+
+ $haystack = strtolower($contents);
+ $matched = true;
+ foreach ($needles as $needle) {
+ if (!str_contains($haystack, $needle)) {
+ $matched = false;
+ break;
+ }
+ }
+
+ if ($matched) {
+ $matches[] = basename($migrationPath);
+ if (count($matches) >= $limit) {
+ break;
+ }
+ }
+ }
+
+ return $matches;
+ }
+}
+
+if (!function_exists('runtime_debug_schema_snapshot')) {
+ function runtime_debug_schema_snapshot(Throwable $throwable): array {
+ $tableName = runtime_debug_extract_table_name($throwable);
+ $columnName = runtime_debug_extract_column_name($throwable);
+ $snapshot = [
+ 'table' => $tableName,
+ 'column' => $columnName,
+ 'table_exists' => null,
+ 'columns' => [],
+ 'database_error' => null,
+ 'related_migrations' => runtime_debug_find_related_migrations($tableName, $columnName),
+ ];
+
+ if ($tableName === null) {
+ return $snapshot;
+ }
+
+ try {
+ $pdo = db();
+ $snapshot['table_exists'] = db_table_exists($tableName);
+ if ($snapshot['table_exists']) {
+ $stmt = $pdo->prepare(
+ "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION"
+ );
+ $stmt->execute([$tableName]);
+ $snapshot['columns'] = array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN) ?: []);
+ }
+ } catch (Throwable $e) {
+ $snapshot['database_error'] = $e->getMessage();
+ }
+
+ return $snapshot;
+ }
+}
+
+if (!function_exists('runtime_debug_is_fatal_error')) {
+ function runtime_debug_is_fatal_error($error): bool {
+ if (!is_array($error)) {
+ return false;
+ }
+
+ return in_array((int)($error['type'] ?? 0), [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR], true);
+ }
+}
+
if (!function_exists('runtime_debug_can_render_details')) {
function runtime_debug_can_render_details(): bool {
+ if (runtime_debug_force_details_enabled()) {
+ return true;
+ }
+
if (PHP_SAPI === 'cli') {
return true;
}
@@ -412,6 +735,7 @@ if (!function_exists('runtime_debug_log')) {
'page=' . ($_GET['page'] ?? 'dashboard'),
'uri=' . ($_SERVER['REQUEST_URI'] ?? 'cli'),
'user_id=' . (string)($_SESSION['user_id'] ?? 0),
+ 'stage=' . runtime_debug_current_stage(),
];
$hint = runtime_debug_infer_schema_hint($throwable);
@@ -423,8 +747,12 @@ if (!function_exists('runtime_debug_log')) {
$parts[] = 'error_info=' . json_encode($throwable->errorInfo, JSON_UNESCAPED_UNICODE);
}
- $trace = explode("
-", $throwable->getTraceAsString());
+ $timeline = runtime_debug_recent_timeline(10);
+ if ($timeline !== []) {
+ $parts[] = 'timeline=' . json_encode($timeline, JSON_UNESCAPED_UNICODE);
+ }
+
+ $trace = explode("\n", $throwable->getTraceAsString());
if ($trace !== []) {
$parts[] = 'trace=' . implode(' | ', array_slice($trace, 0, 5));
}
@@ -435,6 +763,17 @@ if (!function_exists('runtime_debug_log')) {
if (!function_exists('runtime_debug_render_exception')) {
function runtime_debug_render_exception(Throwable $throwable): void {
+ if (!empty($GLOBALS['app_runtime_debug_rendered'])) {
+ if (!headers_sent()) {
+ http_response_code(500);
+ header('Content-Type: text/plain; charset=UTF-8');
+ }
+
+ echo 'Application Error';
+ exit;
+ }
+
+ $GLOBALS['app_runtime_debug_rendered'] = true;
runtime_debug_log($throwable);
$showDetails = runtime_debug_can_render_details();
@@ -451,14 +790,19 @@ if (!function_exists('runtime_debug_render_exception')) {
$hint = runtime_debug_infer_schema_hint($throwable);
$requestUri = (string)($_SERVER['REQUEST_URI'] ?? 'cli');
$page = (string)($_GET['page'] ?? 'dashboard');
- $tracePreview = array_slice(explode("
-", $throwable->getTraceAsString()), 0, 8);
- $traceText = implode("
-", $tracePreview);
+ $currentStage = runtime_debug_current_stage();
+ $timeline = runtime_debug_recent_timeline(10);
+ $timelineText = json_encode($timeline, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
+ $schemaSnapshot = runtime_debug_schema_snapshot($throwable);
+ $tableExistsText = $schemaSnapshot['table_exists'] === null ? 'Unknown' : ($schemaSnapshot['table_exists'] ? 'Yes' : 'No');
+ $schemaColumnsText = !empty($schemaSnapshot['columns']) ? implode(', ', $schemaSnapshot['columns']) : '—';
+ $migrationText = !empty($schemaSnapshot['related_migrations']) ? implode(', ', $schemaSnapshot['related_migrations']) : '—';
+ $tracePreview = array_slice(explode("\n", $throwable->getTraceAsString()), 0, 8);
+ $traceText = implode("\n", $tracePreview);
$title = $showDetails ? 'Application Debug' : 'Application Error';
$summary = $showDetails
- ? 'The request failed. The details below should help identify the missing table or column.'
- : 'An unexpected error occurred while loading this page.';
+ ? 'The request failed. The details below should help identify the missing table, column, or view file.'
+ : 'An unexpected error occurred while loading this page. The diagnostic snapshot below may still point to what is missing.';
?>
@@ -469,9 +813,10 @@ if (!function_exists('runtime_debug_render_exception')) {
= htmlspecialchars($title) ?>
-
+ 'script', 'page' => (string)$page]); ?>