diff --git a/migrations/091_system_events_level.sql b/migrations/091_system_events_level.sql new file mode 100644 index 0000000..5e7fffd --- /dev/null +++ b/migrations/091_system_events_level.sql @@ -0,0 +1,18 @@ +-- Ereignisse führen künftig nicht nur den Nextcloud-Import, sondern auch +-- Fehlermeldungen und Warnungen aus dem übrigen System (Issue #165). +-- Dafür bekommt jeder Eintrag einen Schweregrad. + +ALTER TABLE system_events + ADD COLUMN IF NOT EXISTS level ENUM('info','warning','error') NOT NULL DEFAULT 'info' AFTER channel; + +ALTER TABLE system_events + ADD INDEX IF NOT EXISTS idx_created (created_at); + +ALTER TABLE system_events + ADD INDEX IF NOT EXISTS idx_level_created (level, created_at); + +-- Bestehende Nextcloud-Einträge nachträglich einstufen. Bis hierher stand der +-- Schweregrad nur im Text; die Zuordnung folgt denselben Präfixen, nach denen +-- die Ereignisliste die Zeilen bisher eingefärbt hat. +UPDATE system_events SET level = 'error' WHERE level = 'info' AND message LIKE 'FEHLER%'; +UPDATE system_events SET level = 'warning' WHERE level = 'info' AND message LIKE 'Duplikat%'; diff --git a/public/index.php b/public/index.php index 7bd028c..2ba4d83 100644 --- a/public/index.php +++ b/public/index.php @@ -26,7 +26,17 @@ set_error_handler(static function (int $severity, string $message, string $file, throw new \ErrorException($message, 0, $severity, $file, $line); }); -set_exception_handler(function (\Throwable $e) use ($steuerkramShowGenericError): void { +// Fehler landen zusätzlich in der Ereignisliste, damit sie ohne Zugriff auf +// das Server-Log sichtbar sind (Issue #165). Der Aufruf darf nie selbst +// scheitern – ist die Datenbank die Fehlerursache, bleibt es beim error_log. +$steuerkramLogEvent = static function (string $channel, string $message, string $level): void { + if (!class_exists(\Steuerkram\Models\EventLog::class, true)) { + return; + } + \Steuerkram\Models\EventLog::tryAdd($channel, $message, $level); +}; + +set_exception_handler(function (\Throwable $e) use ($steuerkramShowGenericError, $steuerkramLogEvent): void { error_log(sprintf( 'Unhandled exception: %s in %s:%d%s%s', $e->getMessage(), @@ -35,13 +45,30 @@ set_exception_handler(function (\Throwable $e) use ($steuerkramShowGenericError) PHP_EOL, $e->getTraceAsString() )); + $steuerkramLogEvent('system', sprintf( + '%s bei %s %s: %s (%s:%d)', + (new \ReflectionClass($e))->getShortName(), + $_SERVER['REQUEST_METHOD'] ?? 'CLI', + parse_url($_SERVER['REQUEST_URI'] ?? '-', PHP_URL_PATH) ?: '-', + $e->getMessage(), + basename($e->getFile()), + $e->getLine() + ), \Steuerkram\Models\EventLog::LEVEL_ERROR); $steuerkramShowGenericError(); }); -register_shutdown_function(function () use ($steuerkramShowGenericError): void { +register_shutdown_function(function () use ($steuerkramShowGenericError, $steuerkramLogEvent): void { $error = error_get_last(); if ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) { error_log(sprintf('Fatal error: %s in %s:%d', $error['message'], $error['file'], $error['line'])); + $steuerkramLogEvent('system', sprintf( + 'Schwerer Fehler bei %s %s: %s (%s:%d)', + $_SERVER['REQUEST_METHOD'] ?? 'CLI', + parse_url($_SERVER['REQUEST_URI'] ?? '-', PHP_URL_PATH) ?: '-', + $error['message'], + basename($error['file']), + $error['line'] + ), \Steuerkram\Models\EventLog::LEVEL_ERROR); $steuerkramShowGenericError(); } }); @@ -235,6 +262,7 @@ $router->post('/belege/{belegId}/vorlage/{vorlageId}/buchen', fn($p) = // ── Ereignisse ─────────────────────────────────────────────── $router->get('/ereignisse', fn($p) => (new EventController())->index($p)); +$router->get('/ereignisse/data', fn($p) => (new EventController())->indexData($p)); $router->get('/ereignisse/nextcloud-import', fn($p) => (new EventController())->nextcloudImport($p)); $router->get('/ereignisse/nextcloud-import/data', fn($p) => (new EventController())->nextcloudImportData($p)); diff --git a/src/Controllers/BelegController.php b/src/Controllers/BelegController.php index aa6a92f..f7a17dc 100644 --- a/src/Controllers/BelegController.php +++ b/src/Controllers/BelegController.php @@ -9,6 +9,7 @@ use Steuerkram\Models\Beleg; use Steuerkram\Models\Booking; use Steuerkram\Models\Account; use Steuerkram\Models\Asset; +use Steuerkram\Models\EventLog; use Steuerkram\Models\Supplier; use Steuerkram\View; use Steuerkram\Services\ImageCropService; @@ -216,6 +217,15 @@ class BelegController extends BaseController */ private function renderUploadErrors(array $errors): void { + $dateiname = isset($_FILES['beleg_file']['name']) && $_FILES['beleg_file']['name'] !== '' + ? pathinfo((string)$_FILES['beleg_file']['name'], PATHINFO_BASENAME) + : 'ohne Dateiname'; + EventLog::tryAdd( + 'beleg-upload', + "Upload fehlgeschlagen ({$dateiname}): " . implode(' ', $errors), + EventLog::LEVEL_ERROR + ); + if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') { http_response_code(422); header('Content-Type: application/json'); diff --git a/src/Controllers/EventController.php b/src/Controllers/EventController.php index c077783..08eadbc 100644 --- a/src/Controllers/EventController.php +++ b/src/Controllers/EventController.php @@ -9,37 +9,76 @@ use Steuerkram\View; class EventController extends BaseController { + private const LIMIT = 500; + public function index(array $params): void { - View::redirect('/ereignisse/nextcloud-import'); - } - - public function nextcloudImport(array $params): void - { - $events = EventLog::findByChannel('nextcloud-import', 500); + $filter = $this->filterFromRequest(); + $events = EventLog::find($filter, self::LIMIT); $view = new View(); $view->render('layout/base.php', [ - 'pageTitle' => 'Ereignisse – Nextcloud-Import', - 'content' => $this->renderView('ereignisse/nextcloud_import', [ + 'pageTitle' => 'Ereignisse', + 'content' => $this->renderView('ereignisse/index', [ 'events' => $events, - 'eventsHtml' => $this->renderView('ereignisse/_nextcloud_import_body', [ + 'filter' => $filter, + 'channels' => EventLog::channels(), + 'eventsHtml' => $this->renderView('ereignisse/_body', [ 'events' => $events, ]), ]), ]); } + public function indexData(array $params): void + { + $this->respondWithEvents($this->filterFromRequest()); + } + + public function nextcloudImport(array $params): void + { + View::redirect('/ereignisse?channel=nextcloud-import'); + } + + /** Datenquelle der bis Issue #165 eigenständigen Nextcloud-Ereignisseite. */ public function nextcloudImportData(array $params): void { - $events = EventLog::findByChannel('nextcloud-import', 500); + $this->respondWithEvents(['channel' => 'nextcloud-import']); + } + + /** + * @param array{channel?:string,level?:string} $filter + */ + private function respondWithEvents(array $filter): void + { + $events = EventLog::find($filter, self::LIMIT); header('Content-Type: application/json'); echo json_encode([ 'count' => count($events), - 'html' => $this->renderView('ereignisse/_nextcloud_import_body', [ + 'html' => $this->renderView('ereignisse/_body', [ 'events' => $events, ]), ], JSON_UNESCAPED_UNICODE); } + + /** + * @return array{channel?:string,level?:string} + */ + private function filterFromRequest(): array + { + $filter = []; + + $channel = trim((string)($_GET['channel'] ?? '')); + if ($channel !== '') { + $filter['channel'] = $channel; + } + + $level = trim((string)($_GET['level'] ?? '')); + if (in_array($level, EventLog::LEVELS, true)) { + $filter['level'] = $level; + } + + return $filter; + } } diff --git a/src/Models/EventLog.php b/src/Models/EventLog.php index 6899012..dabb8ec 100644 --- a/src/Models/EventLog.php +++ b/src/Models/EventLog.php @@ -8,23 +8,94 @@ use Steuerkram\Database; class EventLog { - public static function add(string $channel, string $message): void + public const LEVEL_INFO = 'info'; + public const LEVEL_WARNING = 'warning'; + public const LEVEL_ERROR = 'error'; + + public const LEVELS = [self::LEVEL_INFO, self::LEVEL_WARNING, self::LEVEL_ERROR]; + + public static function add(string $channel, string $message, string $level = self::LEVEL_INFO): void { + if (!in_array($level, self::LEVELS, true)) { + $level = self::LEVEL_INFO; + } + Database::execute( - 'INSERT INTO system_events (channel, message) VALUES (?, ?)', - [$channel, $message] + 'INSERT INTO system_events (channel, level, message) VALUES (?, ?, ?)', + [$channel, $level, $message] ); } + public static function warning(string $channel, string $message): void + { + self::add($channel, $message, self::LEVEL_WARNING); + } + + public static function error(string $channel, string $message): void + { + self::add($channel, $message, self::LEVEL_ERROR); + } + + /** + * Schreibt ein Ereignis, ohne selbst einen Fehler auszulösen. Für + * Aufrufer, die bereits einen Fehler behandeln – dort darf ein nicht + * erreichbares Datenbankprotokoll die eigentliche Fehlerausgabe nicht + * verdrängen. + */ + public static function tryAdd(string $channel, string $message, string $level = self::LEVEL_INFO): void + { + try { + self::add($channel, $message, $level); + } catch (\Throwable $e) { + error_log('EventLog: Ereignis konnte nicht gespeichert werden: ' . $e->getMessage()); + } + } + public static function findByChannel(string $channel, int $limit = 500): array { + return self::find(['channel' => $channel], $limit); + } + + /** + * @param array{channel?:string,level?:string} $filter + */ + public static function find(array $filter = [], int $limit = 500): array + { + $where = []; + $params = []; + + if (!empty($filter['channel'])) { + $where[] = 'channel = ?'; + $params[] = $filter['channel']; + } + + if (!empty($filter['level']) && in_array($filter['level'], self::LEVELS, true)) { + $where[] = 'level = ?'; + $params[] = $filter['level']; + } + + $params[] = $limit; + return Database::fetchAll( - 'SELECT id, channel, message, created_at - FROM system_events - WHERE channel = ? - ORDER BY created_at DESC, id DESC + 'SELECT id, channel, level, message, created_at + FROM system_events' + . ($where ? ' WHERE ' . implode(' AND ', $where) : '') + . ' ORDER BY created_at DESC, id DESC LIMIT ?', - [$channel, $limit] + $params + ); + } + + /** + * Alle bisher benutzten Kanäle – Grundlage für den Filter in der Ansicht. + * + * @return string[] + */ + public static function channels(): array + { + return array_column( + Database::fetchAll('SELECT DISTINCT channel FROM system_events ORDER BY channel'), + 'channel' ); } } diff --git a/src/View.php b/src/View.php index 0ee0849..ec0d51c 100644 --- a/src/View.php +++ b/src/View.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Steuerkram; use Steuerkram\Auth; +use Steuerkram\Models\EventLog; class View { @@ -37,11 +38,37 @@ class View Auth::startSession(); if ($message !== '') { $_SESSION['flash'][] = ['type' => $type, 'message' => $message]; + self::logFlash($type, $message); } header('Location: ' . $path); exit; } + /** + * Fehler- und Warnhinweise, die dem Nutzer angezeigt werden, wandern + * zusätzlich in die Ereignisliste – dort sind sie auch dann noch + * nachvollziehbar, wenn die Meldung längst weggeklickt ist (Issue #165). + */ + private static function logFlash(string $type, string $message): void + { + $level = match ($type) { + 'error' => EventLog::LEVEL_ERROR, + 'warning' => EventLog::LEVEL_WARNING, + default => null, + }; + + if ($level === null) { + return; + } + + $pfad = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH); + EventLog::tryAdd( + 'meldungen', + $pfad ? "{$message} ({$pfad})" : $message, + $level + ); + } + public static function h(mixed $value): string { return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); diff --git a/src/Views/ereignisse/_body.php b/src/Views/ereignisse/_body.php new file mode 100644 index 0000000..025a26b --- /dev/null +++ b/src/Views/ereignisse/_body.php @@ -0,0 +1,37 @@ + View::h($v); + +$levelStyles = [ + EventLog::LEVEL_ERROR => ['Fehler', 'var(--color-danger,#dc2626)'], + EventLog::LEVEL_WARNING => ['Warnung', 'var(--color-warning,#d97706)'], + EventLog::LEVEL_INFO => ['Info', 'var(--color-muted,#64748b)'], +]; +?> + +
Keine Ereignisse vorhanden.
+ +
+ + + + + + + + + + + + +
[]
+
+ diff --git a/src/Views/ereignisse/_nextcloud_import_body.php b/src/Views/ereignisse/_nextcloud_import_body.php deleted file mode 100644 index 2e19645..0000000 --- a/src/Views/ereignisse/_nextcloud_import_body.php +++ /dev/null @@ -1,28 +0,0 @@ - View::h($v); ?> - -
Keine Ereignisse vorhanden. Einträge entstehen, sobald der Nextcloud-Import Dateien importiert, überspringt oder auf Fehler stößt.
- -
- - - - - - - - - - - -
[]
-
- diff --git a/src/Views/ereignisse/nextcloud_import.php b/src/Views/ereignisse/index.php similarity index 63% rename from src/Views/ereignisse/nextcloud_import.php rename to src/Views/ereignisse/index.php index e5a6048..e9d078c 100644 --- a/src/Views/ereignisse/nextcloud_import.php +++ b/src/Views/ereignisse/index.php @@ -1,3 +1,17 @@ + View::h($v); + +$aktiverKanal = $filter['channel'] ?? ''; +$aktivesLevel = $filter['level'] ?? ''; + +$levelLabels = [ + EventLog::LEVEL_ERROR => 'Nur Fehler', + EventLog::LEVEL_WARNING => 'Nur Warnungen', + EventLog::LEVEL_INFO => 'Nur Infos', +]; +?>
-
- Nextcloud-Import +
+
+ + + + Filter zurücksetzen + +
Einträge (neueste zuerst)