From 9d3983060d3470593b89ae7c76233fe7481c3e3d Mon Sep 17 00:00:00 2001 From: blaerf Date: Wed, 17 Dec 2025 12:07:50 +0100 Subject: [PATCH] =?UTF-8?q?Log-Viewer=20hinzugef=C3=BCgt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Controllers/LogViewerController.php | 123 +++++++++ app/Services/Logging/LogViewerService.php | 319 ++++++++++++++++++++++ public/index.php | 10 + public/views/logs.php | 163 +++++++++++ public/views/partials/sidebar.php | 11 + 5 files changed, 626 insertions(+) create mode 100644 app/Controllers/LogViewerController.php create mode 100644 app/Services/Logging/LogViewerService.php create mode 100644 public/views/logs.php diff --git a/app/Controllers/LogViewerController.php b/app/Controllers/LogViewerController.php new file mode 100644 index 0000000..8f91585 --- /dev/null +++ b/app/Controllers/LogViewerController.php @@ -0,0 +1,123 @@ + */ + private array $config; + + private LoggingService $logger; + private LogViewerService $logViewer; + + /** + * @param array $config + */ + public function __construct(array $config) + { + $this->config = $config; + + $loggingConfig = $config['logging'] ?? []; + $this->logger = new LoggingService($loggingConfig); + $this->logViewer = new LogViewerService($loggingConfig); + } + + /** + * Zeigt den Log Viewer an. + * + * Erwartet optionale GET-Parameter: + * - file (Dateiname, z.B. app.log) + * - level (DEBUG|INFO|WARNING|ERROR) + * - q (Suche) + * - lines (Anzahl Zeilen, Default 200) + * + * @return array + */ + public function show(): array + { + $files = $this->logViewer->listLogFiles(); + + $selectedFile = (string)($_GET['file'] ?? ''); + if ($selectedFile === '' && isset($files[0]['name'])) { + $selectedFile = (string)$files[0]['name']; + } + + $level = (string)($_GET['level'] ?? ''); + $level = strtoupper(trim($level)); + if ($level === '') { + $level = ''; + } + + $q = (string)($_GET['q'] ?? ''); + $q = trim($q); + + $lines = (int)($_GET['lines'] ?? 200); + if ($lines <= 0) { + $lines = 200; + } + if ($lines > 2000) { + $lines = 2000; + } + + $error = null; + $fileMeta = null; + $entries = []; + + try { + if ($selectedFile !== '') { + $fileMeta = $this->logViewer->getFileMeta($selectedFile); + $entries = $this->logViewer->getEntries( + $selectedFile, + $lines, + $level !== '' ? $level : null, + $q !== '' ? $q : null + ); + + if ($fileMeta === null) { + $error = 'Die ausgewählte Log-Datei ist nicht verfügbar.'; + $entries = []; + } + } else { + $error = 'Es wurde keine Log-Datei gefunden.'; + } + } catch (\Throwable $ex) { + $this->logger->logException( + 'LogViewerController: Fehler beim Laden der Logs.', + $ex, + [ + 'route' => 'logs', + 'file' => $selectedFile, + ] + ); + + $error = 'Technischer Fehler beim Laden der Logs. Details stehen im app.log.'; + } + + $viewPath = __DIR__ . '/../../public/views/logs.php'; + + return [ + 'view' => $viewPath, + 'data' => [ + 'loginPage' => false, + 'files' => $files, + 'selectedFile' => $selectedFile, + 'fileMeta' => $fileMeta, + 'entries' => $entries, + 'filterLevel' => $level, + 'searchQuery' => $q, + 'lines' => $lines, + 'error' => $error, + ], + 'pageTitle' => 'Logs', + 'activeMenu' => 'logs', + ]; + } +} diff --git a/app/Services/Logging/LogViewerService.php b/app/Services/Logging/LogViewerService.php new file mode 100644 index 0000000..31fb1d5 --- /dev/null +++ b/app/Services/Logging/LogViewerService.php @@ -0,0 +1,319 @@ + $loggingConfig Teilkonfiguration "logging" aus config.php + */ + public function __construct(array $loggingConfig) + { + $baseDir = $loggingConfig['log_dir'] ?? (__DIR__ . '/../../../public/logs'); + $this->logDir = rtrim((string)$baseDir, DIRECTORY_SEPARATOR); + } + + /** + * @return array + */ + public function listLogFiles(): array + { + $result = []; + + if (is_dir($this->logDir) === false) { + return $result; + } + + $entries = @scandir($this->logDir); + if ($entries === false) { + return $result; + } + + foreach ($entries as $name) { + if ($name === '.' || $name === '..') { + continue; + } + + // Nur normale Dateien, keine Unterordner. + $fullPath = $this->logDir . DIRECTORY_SEPARATOR . $name; + if (is_file($fullPath) === false) { + continue; + } + + // Safety: nur "logartige" Dateien anzeigen. + $lower = strtolower($name); + if ( + str_ends_with($lower, '.log') === false + && str_ends_with($lower, '.txt') === false + ) { + continue; + } + + $size = @filesize($fullPath); + $mtime = @filemtime($fullPath); + + $result[] = [ + 'name' => $name, + 'size' => is_int($size) ? $size : 0, + 'mtime' => is_int($mtime) ? $mtime : 0, + ]; + } + + // Neueste zuerst + usort( + $result, + static function (array $a, array $b): int { + return ($b['mtime'] ?? 0) <=> ($a['mtime'] ?? 0); + } + ); + + return $result; + } + + /** + * Liefert Metadaten zur ausgewählten Datei (oder null wenn ungültig). + * + * @return array{name:string, size:int, mtime:int}|null + */ + public function getFileMeta(string $fileName): ?array + { + $path = $this->resolveLogFilePath($fileName); + if ($path === null) { + return null; + } + + $size = @filesize($path); + $mtime = @filemtime($path); + + return [ + 'name' => basename($path), + 'size' => is_int($size) ? $size : 0, + 'mtime' => is_int($mtime) ? $mtime : 0, + ]; + } + + /** + * @param string $fileName + * @param int $maxLines + * @param string|null $levelFilter z.B. "ERROR"|"WARNING"|"INFO"|"DEBUG"|null + * @param string|null $search Freitextsuche in message/context/raw + * + * @return array|null, + * raw:string + * }> + */ + public function getEntries(string $fileName, int $maxLines = 200, ?string $levelFilter = null, ?string $search = null): array + { + $path = $this->resolveLogFilePath($fileName); + if ($path === null) { + return []; + } + + $lines = $this->tailLines($path, $maxLines); + + $entries = []; + foreach ($lines as $line) { + $parsed = $this->parseLine($line); + + if ($levelFilter !== null && $levelFilter !== '') { + $lvl = strtoupper((string)($parsed['level'] ?? '')); + if ($lvl !== strtoupper($levelFilter)) { + continue; + } + } + + if ($search !== null && $search !== '') { + $haystack = $parsed['raw'] . ' ' . $parsed['message']; + + if (is_array($parsed['context'])) { + $haystack .= ' ' . (string)json_encode($parsed['context'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + if (mb_stripos($haystack, $search) === false) { + continue; + } + } + + $entries[] = $parsed; + } + + return $entries; + } + + /** + * @return string|null Vollständiger Pfad oder null, wenn ungültig/Traversal + */ + private function resolveLogFilePath(string $fileName): ?string + { + $fileName = trim($fileName); + if ($fileName === '') { + return null; + } + + // Keine Pfade erlauben, nur Dateiname + $fileName = basename($fileName); + + $candidate = $this->logDir . DIRECTORY_SEPARATOR . $fileName; + + $realDir = realpath($this->logDir); + $realFile = realpath($candidate); + + if ($realDir === false || $realFile === false) { + return null; + } + + // Muss innerhalb des Log-Verzeichnisses liegen + $realDirNorm = rtrim($realDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + $realFileNorm = $realFile; + + if (str_starts_with($realFileNorm, $realDirNorm) === false) { + return null; + } + + if (is_file($realFileNorm) === false) { + return null; + } + + return $realFileNorm; + } + + /** + * Tail-Implementierung: liest die letzten $maxLines Zeilen. + * + * @return array + */ + private function tailLines(string $filePath, int $maxLines): array + { + $maxLines = max(1, min(2000, $maxLines)); + + $fh = @fopen($filePath, 'rb'); + if ($fh === false) { + return []; + } + + $chunkSize = 8192; + $buffer = ''; + $pos = -1; + $linesFound = 0; + + // ans Ende springen + @fseek($fh, 0, SEEK_END); + $fileSize = (int)@ftell($fh); + if ($fileSize <= 0) { + @fclose($fh); + return []; + } + + while ($linesFound <= $maxLines && -$pos < $fileSize) { + $readSize = $chunkSize; + if (-$pos + $chunkSize > $fileSize) { + $readSize = $fileSize - (-$pos); + } + + @fseek($fh, $pos - $readSize + 1, SEEK_END); + $chunk = (string)@fread($fh, $readSize); + + $buffer = $chunk . $buffer; + $linesFound = substr_count($buffer, "\n"); + + $pos -= $readSize; + } + + @fclose($fh); + + $lines = preg_split("/\r\n|\n|\r/", $buffer); + if (is_array($lines) === false) { + return []; + } + + // ggf. letzte leere Zeile raus + if (end($lines) === '') { + array_pop($lines); + } + + // letzte maxLines + $lines = array_slice($lines, -$maxLines); + + // Leere Zeilen am Anfang/Ende tolerieren, aber nicht komplett aufblasen + $clean = []; + foreach ($lines as $line) { + $line = (string)$line; + if ($line === '') { + continue; + } + $clean[] = $line; + } + + return $clean; + } + + /** + * @return array{ + * ts:string|null, + * level:string|null, + * message:string, + * context:array|null, + * raw:string + * } + */ + private function parseLine(string $line): array + { + $raw = $line; + + $ts = null; + $level = null; + $message = $line; + $context = null; + + // Basis: [timestamp] LEVEL ... + if (preg_match('/^\[(?[0-9\-:\s]{19})\]\s+(?[A-Z]+)\s+(?.*)$/', $line, $m) === 1) { + $ts = (string)$m['ts']; + $level = (string)$m['lvl']; + $rest = (string)$m['rest']; + + // Versuch: Context ist am Ende ein JSON-Objekt, das mit "{" beginnt und mit "}" endet. + $ctxCandidate = null; + + $lastBracePos = strrpos($rest, '{'); + if ($lastBracePos !== false) { + $maybeJson = substr($rest, $lastBracePos); + $maybeJson = trim($maybeJson); + + if ($maybeJson !== '' && str_starts_with($maybeJson, '{') && str_ends_with($maybeJson, '}')) { + $decoded = json_decode($maybeJson, true); + if (is_array($decoded)) { + $ctxCandidate = $decoded; + $rest = trim(substr($rest, 0, $lastBracePos)); + } + } + } + + $context = $ctxCandidate; + $message = $rest; + } + + return [ + 'ts' => $ts, + 'level' => $level, + 'message' => $message, + 'context' => $context, + 'raw' => $raw, + ]; + } +} diff --git a/public/index.php b/public/index.php index 0b153be..0357052 100644 --- a/public/index.php +++ b/public/index.php @@ -74,6 +74,8 @@ use App\Controllers\AuthController; use App\Controllers\DashboardController; use App\Controllers\UserManagementController; use App\Services\Logging\LoggingService; +use App\Controllers\LogViewerController; + // Globalen Logger initialisieren, damit auch Fehler außerhalb der Controller // (z. B. in index.php selbst) sauber protokolliert werden. @@ -199,6 +201,8 @@ function handleResult(?array $result): void $authController = new AuthController($config); $dashboardController = new DashboardController($config); $userManagementController = new UserManagementController($config); +$logViewerController = new LogViewerController($config); + // Route aus dem Query-Parameter lesen. Standardroute ist "login", // sodass nicht angemeldete Benutzer automatisch auf die Login-Seite geführt werden. @@ -246,6 +250,12 @@ switch ($route) { handleResult($result); break; + case 'logs': + requireLogin($config); + $result = $logViewerController->show(); + handleResult($result); + break; + default: http_response_code(404); echo 'Route nicht gefunden.'; diff --git a/public/views/logs.php b/public/views/logs.php new file mode 100644 index 0000000..e73d6f9 --- /dev/null +++ b/public/views/logs.php @@ -0,0 +1,163 @@ + $files + * @var string $selectedFile + * @var array{name:string, size:int, mtime:int}|null $fileMeta + * @var array|null, raw:string}> $entries + * @var string $filterLevel + * @var string $searchQuery + * @var int $lines + * @var string|null $error + */ +?> + +
+

Log Viewer

+
+ + + + + +
+
+
Filter
+
+
+
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ + +
+ Datei: + · Größe: Bytes + · Stand: 0 ? date('d.m.Y H:i:s', (int)$fileMeta['mtime']) : 'n/a'; ?> +
+ +
+
+
+ +
+
+
Einträge (neueste unten)
+ + Refresh + +
+ +
+ +
Keine Einträge gefunden.
+ +
+ + + + + + + + + + + + + + + + + + + + +
ZeitLevelMessageContext
+ + + + + +
+ + - + +
+
+ +
+ Hinweis: Dieser Viewer lädt bewusst nur die letzten Zeilen (Tail), damit große Logfiles die Oberfläche nicht killen. +
+ +
+
diff --git a/public/views/partials/sidebar.php b/public/views/partials/sidebar.php index dbe101c..ed8761c 100644 --- a/public/views/partials/sidebar.php +++ b/public/views/partials/sidebar.php @@ -59,6 +59,17 @@ + + + + + +