340 lines
9.4 KiB
PHP
340 lines
9.4 KiB
PHP
<?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'])) {
|
|
$json = json_encode(
|
|
$parsed['context'],
|
|
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR
|
|
);
|
|
if ($json !== false) {
|
|
$haystack .= ' ' . $json;
|
|
}
|
|
}
|
|
|
|
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-Za-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;
|
|
}
|
|
|
|
if ($level === null) {
|
|
$upper = strtoupper($raw);
|
|
|
|
if (str_starts_with($upper, 'PHP WARNING') || str_starts_with($upper, 'WARNING')) {
|
|
$level = 'WARNING';
|
|
} elseif (str_starts_with($upper, 'PHP NOTICE') || str_starts_with($upper, 'NOTICE')) {
|
|
$level = 'INFO';
|
|
} elseif (str_starts_with($upper, 'PHP FATAL') || str_contains($upper, 'FATAL ERROR') || str_starts_with($upper, 'UNCAUGHT')) {
|
|
$level = 'ERROR';
|
|
} else {
|
|
$level = 'UNKNOWN';
|
|
}
|
|
}
|
|
|
|
return [
|
|
'ts' => $ts,
|
|
'level' => $level,
|
|
'message' => $message,
|
|
'context' => $context,
|
|
'raw' => $raw,
|
|
];
|
|
}
|
|
}
|