Log-Viewer hinzugefügt

This commit is contained in:
blaerf 2025-12-17 12:07:50 +01:00
parent d817f096bc
commit 9d3983060d
5 changed files with 626 additions and 0 deletions

View File

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Services\Logging\LoggingService;
use App\Services\Logging\LogViewerService;
/**
* Controller für den Log Viewer (read-only).
*/
class LogViewerController
{
/** @var array<string, mixed> */
private array $config;
private LoggingService $logger;
private LogViewerService $logViewer;
/**
* @param array<string, mixed> $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<string, mixed>
*/
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',
];
}
}

View File

@ -0,0 +1,319 @@
<?php
declare(strict_types=1);
namespace App\Services\Logging;
/**
* LogViewerService
*
* - Listet Log-Dateien im konfigurierten Logging-Verzeichnis.
* - Liest performant die letzten N Zeilen (Tail), ohne komplette Datei zu laden.
* - Parst das LoggingService-Format:
* "[YYYY-MM-DD HH:MM:SS] LEVEL message {json}"
*/
class LogViewerService
{
private string $logDir;
/**
* @param array<string, mixed> $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<int, array{name:string, size:int, mtime:int}>
*/
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<int, array{
* ts:string|null,
* level:string|null,
* message:string,
* context:array<string,mixed>|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<int, string>
*/
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<string,mixed>|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('/^\[(?<ts>[0-9\-:\s]{19})\]\s+(?<lvl>[A-Z]+)\s+(?<rest>.*)$/', $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,
];
}
}

View File

@ -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.';

163
public/views/logs.php Normal file
View File

@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
/**
* Log Viewer (Ansicht).
*
* Erwartete Variablen:
* @var array<int, array{name:string, size:int, mtime:int}> $files
* @var string $selectedFile
* @var array{name:string, size:int, mtime:int}|null $fileMeta
* @var array<int, array{ts:string|null, level:string|null, message:string, context:array<string,mixed>|null, raw:string}> $entries
* @var string $filterLevel
* @var string $searchQuery
* @var int $lines
* @var string|null $error
*/
?>
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Log Viewer</h1>
</div>
<?php if (!empty($error)) : ?>
<div class="alert alert-danger" role="alert">
<?php echo htmlspecialchars((string)$error, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?>
</div>
<?php endif; ?>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Filter</h6>
</div>
<div class="card-body">
<form method="get" action="index.php">
<input type="hidden" name="route" value="logs">
<div class="form-row">
<div class="form-group col-md-4">
<label for="file">Log-Datei</label>
<select class="form-control" id="file" name="file">
<?php foreach (($files ?? []) as $f) : ?>
<?php $name = (string)($f['name'] ?? ''); ?>
<option value="<?php echo htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?>"
<?php echo ($name === (string)$selectedFile) ? 'selected' : ''; ?>>
<?php echo htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group col-md-2">
<label for="level">Level</label>
<select class="form-control" id="level" name="level">
<?php
$levels = ['', 'ERROR', 'WARNING', 'INFO', 'DEBUG'];
foreach ($levels as $lvl) :
$label = ($lvl === '') ? 'Alle' : $lvl;
?>
<option value="<?php echo htmlspecialchars($lvl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?>"
<?php echo ((string)$filterLevel === (string)$lvl) ? 'selected' : ''; ?>>
<?php echo htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group col-md-3">
<label for="q">Suche</label>
<input type="text" class="form-control" id="q" name="q"
value="<?php echo htmlspecialchars((string)$searchQuery, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?>"
placeholder="Text in message/context">
</div>
<div class="form-group col-md-2">
<label for="lines">Zeilen</label>
<input type="number" class="form-control" id="lines" name="lines"
value="<?php echo (int)$lines; ?>" min="1" max="2000">
</div>
<div class="form-group col-md-1 d-flex align-items-end">
<button type="submit" class="btn btn-primary btn-block">
Anzeigen
</button>
</div>
</div>
<?php if (is_array($fileMeta)) : ?>
<div class="small text-gray-600">
Datei: <strong><?php echo htmlspecialchars((string)$fileMeta['name'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?></strong>
· Größe: <?php echo number_format((int)$fileMeta['size'], 0, ',', '.'); ?> Bytes
· Stand: <?php echo ($fileMeta['mtime'] ?? 0) > 0 ? date('d.m.Y H:i:s', (int)$fileMeta['mtime']) : 'n/a'; ?>
</div>
<?php endif; ?>
</form>
</div>
</div>
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex align-items-center justify-content-between">
<h6 class="m-0 font-weight-bold text-primary">Einträge (neueste unten)</h6>
<a class="btn btn-sm btn-outline-secondary"
href="index.php?route=logs&amp;file=<?php echo urlencode((string)$selectedFile); ?>&amp;level=<?php echo urlencode((string)$filterLevel); ?>&amp;q=<?php echo urlencode((string)$searchQuery); ?>&amp;lines=<?php echo (int)$lines; ?>">
Refresh
</a>
</div>
<div class="card-body">
<?php if (empty($entries)) : ?>
<div class="text-gray-600">Keine Einträge gefunden.</div>
<?php else : ?>
<div class="table-responsive">
<table class="table table-bordered table-sm" width="100%" cellspacing="0">
<thead>
<tr>
<th style="width: 170px;">Zeit</th>
<th style="width: 110px;">Level</th>
<th>Message</th>
<th style="width: 35%;">Context</th>
</tr>
</thead>
<tbody>
<?php foreach ($entries as $e) : ?>
<?php
$lvl = strtoupper((string)($e['level'] ?? ''));
$badge = 'badge-secondary';
if ($lvl === 'ERROR') { $badge = 'badge-danger'; }
if ($lvl === 'WARNING') { $badge = 'badge-warning'; }
if ($lvl === 'INFO') { $badge = 'badge-info'; }
if ($lvl === 'DEBUG') { $badge = 'badge-light'; }
$ctx = $e['context'] ?? null;
$ctxPretty = '';
if (is_array($ctx)) {
$ctxPretty = (string)json_encode($ctx, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
?>
<tr>
<td><?php echo htmlspecialchars((string)($e['ts'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?></td>
<td>
<span class="badge <?php echo $badge; ?>">
<?php echo htmlspecialchars($lvl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?>
</span>
</td>
<td><?php echo htmlspecialchars((string)($e['message'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?></td>
<td>
<?php if ($ctxPretty !== '') : ?>
<pre class="mb-0" style="white-space: pre-wrap;"><?php echo htmlspecialchars($ctxPretty, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?></pre>
<?php else : ?>
<span class="text-gray-600">-</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="small text-gray-600">
Hinweis: Dieser Viewer lädt bewusst nur die letzten Zeilen (Tail), damit große Logfiles die Oberfläche nicht killen.
</div>
<?php endif; ?>
</div>
</div>

View File

@ -59,6 +59,17 @@
<!-- Divider -->
<hr class="sidebar-divider d-none d-md-block">
<!-- Nav Item - Logs -->
<li class="nav-item<?= (isset($activeMenu) && $activeMenu === 'logs') ? ' active' : '' ?>">
<a class="nav-link" href="../../index.php?route=logs">
<i class="fas fa-fw fa-file-alt"></i>
<span>Logs</span>
</a>
</li>
<!-- Divider -->
<hr class="sidebar-divider d-none d-md-block">
<!-- Sidebar Toggler (Sidebar) -->
<div class="text-center d-none d-md-inline">
<button class="rounded-circle border-0" id="sidebarToggle"></button>