Log-Viewer hinzugefügt
This commit is contained in:
parent
d817f096bc
commit
9d3983060d
123
app/Controllers/LogViewerController.php
Normal file
123
app/Controllers/LogViewerController.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
319
app/Services/Logging/LogViewerService.php
Normal file
319
app/Services/Logging/LogViewerService.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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
163
public/views/logs.php
Normal 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&file=<?php echo urlencode((string)$selectedFile); ?>&level=<?php echo urlencode((string)$filterLevel); ?>&q=<?php echo urlencode((string)$searchQuery); ?>&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>
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user