Add log viewer and tests for the logs and the viewer (#41)
Now that I'm adding more logging, I wanted to add a log viewer so people don't have to ssh to their servers to inspect logs. Also added tests around logging and the viewer. Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/41 Co-authored-by: Greg Sarjeant <greg@subcultureofone.org> Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
This commit is contained in:
parent
dc0abf8c7c
commit
a7e79796fa
@ -15,7 +15,7 @@ jobs:
|
|||||||
tar \
|
tar \
|
||||||
--transform 's,^,tkr/,' \
|
--transform 's,^,tkr/,' \
|
||||||
--exclude='storage/db' \
|
--exclude='storage/db' \
|
||||||
--exclude='storage/ticks' \
|
--exclude='storage/logs' \
|
||||||
--exclude='storage/upload' \
|
--exclude='storage/upload' \
|
||||||
-czvf tkr.${{ gitea.ref_name }}.tgz \
|
-czvf tkr.${{ gitea.ref_name }}.tgz \
|
||||||
check-prerequisites.php config public src storage templates
|
check-prerequisites.php config public src storage templates
|
||||||
|
@ -18,6 +18,10 @@
|
|||||||
--color-primary: gainsboro;
|
--color-primary: gainsboro;
|
||||||
--color-required: crimson;
|
--color-required: crimson;
|
||||||
--color-text: black;
|
--color-text: black;
|
||||||
|
--color-log-info-bg: #f8f9fa;
|
||||||
|
--color-log-warning-bg: #fff8e1;
|
||||||
|
--color-log-error-bg: #ffebee;
|
||||||
|
--color-log-muted: #666;
|
||||||
|
|
||||||
--border-width: 2px;
|
--border-width: 2px;
|
||||||
--border-width-thin: 1px;
|
--border-width-thin: 1px;
|
||||||
@ -497,3 +501,53 @@ time {
|
|||||||
min-width: auto;
|
min-width: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Log viewer styles */
|
||||||
|
.log-monospace {
|
||||||
|
font-family: 'Courier New', Consolas, Monaco, 'Lucida Console', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-table th,
|
||||||
|
.log-table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: var(--border-width-thin) solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-table th {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-level-badge {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Log level row colors - subtle background tints */
|
||||||
|
.log-debug { background-color: var(--color-bg); }
|
||||||
|
.log-info { background-color: var(--color-log-info-bg); }
|
||||||
|
.log-warning { background-color: var(--color-log-warning-bg); }
|
||||||
|
.log-error { background-color: var(--color-log-error-bg); }
|
||||||
|
|
||||||
|
.log-no-route {
|
||||||
|
color: var(--color-log-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-info {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
122
src/Controller/LogController/LogController.php
Normal file
122
src/Controller/LogController/LogController.php
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
class LogController extends Controller {
|
||||||
|
private string $storageDir;
|
||||||
|
|
||||||
|
public function __construct(?string $storageDir = null) {
|
||||||
|
$this->storageDir = $storageDir ?? STORAGE_DIR;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index() {
|
||||||
|
// Ensure user is logged in
|
||||||
|
if (!Session::isLoggedIn()) {
|
||||||
|
global $config;
|
||||||
|
header('Location: ' . Util::buildRelativeUrl($config->basePath, 'login'));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get filter parameters
|
||||||
|
$levelFilter = $_GET['level'] ?? '';
|
||||||
|
$routeFilter = $_GET['route'] ?? '';
|
||||||
|
|
||||||
|
// Get the data for the template
|
||||||
|
$data = $this->getLogData($levelFilter, $routeFilter);
|
||||||
|
|
||||||
|
$this->render('logs.php', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLogData(string $levelFilter = '', string $routeFilter = ''): array {
|
||||||
|
global $config;
|
||||||
|
|
||||||
|
$limit = 300; // Show last 300 log entries
|
||||||
|
|
||||||
|
// Read and parse log entries
|
||||||
|
$logEntries = $this->getLogEntries($limit, $levelFilter, $routeFilter);
|
||||||
|
|
||||||
|
// Get available routes and levels for filter dropdowns
|
||||||
|
$availableRoutes = $this->getAvailableRoutes();
|
||||||
|
$availableLevels = ['DEBUG', 'INFO', 'WARNING', 'ERROR'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'config' => $config,
|
||||||
|
'logEntries' => $logEntries,
|
||||||
|
'availableRoutes' => $availableRoutes,
|
||||||
|
'availableLevels' => $availableLevels,
|
||||||
|
'currentLevelFilter' => $levelFilter,
|
||||||
|
'currentRouteFilter' => $routeFilter,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getLogEntries(int $limit, string $levelFilter = '', string $routeFilter = ''): array {
|
||||||
|
$logFile = $this->storageDir . '/logs/tkr.log';
|
||||||
|
$entries = [];
|
||||||
|
|
||||||
|
// Read from current log file and rotated files
|
||||||
|
$logFiles = [$logFile];
|
||||||
|
for ($i = 1; $i <= 5; $i++) {
|
||||||
|
$rotatedFile = $logFile . '.' . $i;
|
||||||
|
if (file_exists($rotatedFile)) {
|
||||||
|
$logFiles[] = $rotatedFile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($logFiles as $file) {
|
||||||
|
if (file_exists($file)) {
|
||||||
|
$lines = file($file, FILE_IGNORE_NEW_LINES);
|
||||||
|
foreach (array_reverse($lines) as $line) {
|
||||||
|
if (count($entries) >= $limit) break 2;
|
||||||
|
|
||||||
|
$entry = $this->parseLogLine($line);
|
||||||
|
if ($entry && $this->matchesFilters($entry, $levelFilter, $routeFilter)) {
|
||||||
|
$entries[] = $entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseLogLine(string $line): ?array {
|
||||||
|
// Parse format: [2025-01-31 08:30:15] DEBUG: 192.168.1.100 [GET feed/rss] - message
|
||||||
|
$pattern = '/^\[([^\]]+)\] (\w+): ([^\s]+)(?:\s+\[([^\]]+)\])? - (.+)$/';
|
||||||
|
|
||||||
|
if (preg_match($pattern, $line, $matches)) {
|
||||||
|
return [
|
||||||
|
'timestamp' => $matches[1],
|
||||||
|
'level' => $matches[2],
|
||||||
|
'ip' => $matches[3],
|
||||||
|
'route' => $matches[4] ?? '',
|
||||||
|
'message' => $matches[5],
|
||||||
|
'raw' => $line
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function matchesFilters(array $entry, string $levelFilter, string $routeFilter): bool {
|
||||||
|
if ($levelFilter && $entry['level'] !== $levelFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($routeFilter && $entry['route'] !== $routeFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getAvailableRoutes(): array {
|
||||||
|
$routes = [];
|
||||||
|
$entries = $this->getLogEntries(1000); // Sample more entries to get route list
|
||||||
|
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if ($entry['route'] && !in_array($entry['route'], $routes)) {
|
||||||
|
$routes[] = $entry['route'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort($routes);
|
||||||
|
return $routes;
|
||||||
|
}
|
||||||
|
}
|
@ -12,8 +12,8 @@ class Log {
|
|||||||
private static $maxFiles = 5;
|
private static $maxFiles = 5;
|
||||||
private static $routeContext = '';
|
private static $routeContext = '';
|
||||||
|
|
||||||
public static function init() {
|
public static function init(?string $logFile = null) {
|
||||||
self::$logFile = STORAGE_DIR . '/logs/tkr.log';
|
self::$logFile = $logFile ?? STORAGE_DIR . '/logs/tkr.log';
|
||||||
|
|
||||||
// Ensure log directory exists
|
// Ensure log directory exists
|
||||||
// (should be handled by Prerequisites, but doesn't hurt)
|
// (should be handled by Prerequisites, but doesn't hurt)
|
||||||
|
@ -12,6 +12,7 @@ class Router {
|
|||||||
['admin/css', 'CssController@handlePost', ['POST']],
|
['admin/css', 'CssController@handlePost', ['POST']],
|
||||||
['admin/emoji', 'EmojiController'],
|
['admin/emoji', 'EmojiController'],
|
||||||
['admin/emoji', 'EmojiController@handlePost', ['POST']],
|
['admin/emoji', 'EmojiController@handlePost', ['POST']],
|
||||||
|
['admin/logs', 'LogController'],
|
||||||
['feed/rss', 'FeedController@rss'],
|
['feed/rss', 'FeedController@rss'],
|
||||||
['feed/atom', 'FeedController@atom'],
|
['feed/atom', 'FeedController@atom'],
|
||||||
['login', 'AuthController@showLogin'],
|
['login', 'AuthController@showLogin'],
|
||||||
|
90
templates/partials/logs.php
Normal file
90
templates/partials/logs.php
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
<?php /** @var ConfigModel $config */ ?>
|
||||||
|
<?php /** @var array $logEntries */ ?>
|
||||||
|
<?php /** @var array $availableRoutes */ ?>
|
||||||
|
<?php /** @var array $availableLevels */ ?>
|
||||||
|
<?php /** @var string $currentLevelFilter */ ?>
|
||||||
|
<?php /** @var string $currentRouteFilter */ ?>
|
||||||
|
<h1>System Logs</h1>
|
||||||
|
<main>
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="log-filters">
|
||||||
|
<form method="get" action="<?= Util::buildRelativeUrl($config->basePath, 'admin/logs') ?>">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Filter Logs</legend>
|
||||||
|
<div class="fieldset-items">
|
||||||
|
<label for="level-filter">Level:</label>
|
||||||
|
<select id="level-filter" name="level">
|
||||||
|
<option value="">All Levels</option>
|
||||||
|
<?php foreach ($availableLevels as $level): ?>
|
||||||
|
<option value="<?= Util::escape_html($level) ?>"
|
||||||
|
<?= $currentLevelFilter === $level ? 'selected' : '' ?>>
|
||||||
|
<?= Util::escape_html($level) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="route-filter">Route:</label>
|
||||||
|
<select id="route-filter" name="route">
|
||||||
|
<option value="">All Routes</option>
|
||||||
|
<?php foreach ($availableRoutes as $route): ?>
|
||||||
|
<option value="<?= Util::escape_html($route) ?>"
|
||||||
|
<?= $currentRouteFilter === $route ? 'selected' : '' ?>>
|
||||||
|
<?= Util::escape_html($route) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div></div><button type="submit">Filter</button>
|
||||||
|
<div></div><a href="<?= Util::buildRelativeUrl($config->basePath, 'admin/logs') ?>">Clear</a>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Log entries table -->
|
||||||
|
<div class="log-entries">
|
||||||
|
<?php if (empty($logEntries)): ?>
|
||||||
|
<p>No log entries found matching the current filters.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<table class="log-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Level</th>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>Route</th>
|
||||||
|
<th>Message</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($logEntries as $entry): ?>
|
||||||
|
<tr class="log-entry log-<?= strtolower($entry['level']) ?>">
|
||||||
|
<td class="log-timestamp log-monospace">
|
||||||
|
<time datetime="<?= Util::escape_html($entry['timestamp']) ?>">
|
||||||
|
<?= Util::escape_html($entry['timestamp']) ?>
|
||||||
|
</time>
|
||||||
|
</td>
|
||||||
|
<td class="log-level">
|
||||||
|
<span class="log-level-badge"><?= Util::escape_html($entry['level']) ?></span>
|
||||||
|
</td>
|
||||||
|
<td class="log-ip log-monospace"><?= Util::escape_html($entry['ip']) ?></td>
|
||||||
|
<td class="log-route log-monospace">
|
||||||
|
<?php if ($entry['route']): ?>
|
||||||
|
<?= Util::escape_html($entry['route']) ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="log-no-route">-</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td class="log-message log-monospace"><?= Util::escape_html($entry['message']) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="log-info">
|
||||||
|
<p>Showing <?= count($logEntries) ?> recent log entries.
|
||||||
|
Log files are automatically rotated when they reach 1000 lines.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
@ -25,6 +25,8 @@
|
|||||||
href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'admin/css')) ?>">css</a>
|
href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'admin/css')) ?>">css</a>
|
||||||
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
|
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
|
||||||
href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'admin/emoji')) ?>">emoji</a>
|
href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'admin/emoji')) ?>">emoji</a>
|
||||||
|
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
|
||||||
|
href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'admin/logs')) ?>">logs</a>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
|
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
|
||||||
|
237
tests/Controller/LogController/LogControllerTest.php
Normal file
237
tests/Controller/LogController/LogControllerTest.php
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
<?php
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class LogControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $tempLogDir;
|
||||||
|
private string $testLogFile;
|
||||||
|
private $originalGet;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->tempLogDir = sys_get_temp_dir() . '/tkr_test_logs_' . uniqid();
|
||||||
|
mkdir($this->tempLogDir, 0777, true);
|
||||||
|
|
||||||
|
$this->testLogFile = $this->tempLogDir . '/logs/tkr.log';
|
||||||
|
mkdir(dirname($this->testLogFile), 0777, true);
|
||||||
|
|
||||||
|
// Store original $_GET and clear it
|
||||||
|
$this->originalGet = $_GET;
|
||||||
|
$_GET = [];
|
||||||
|
|
||||||
|
// Mock global config
|
||||||
|
global $config;
|
||||||
|
$config = new ConfigModel();
|
||||||
|
$config->baseUrl = 'https://example.com';
|
||||||
|
$config->basePath = '/tkr/';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
// Restore original $_GET
|
||||||
|
$_GET = $this->originalGet;
|
||||||
|
|
||||||
|
if (is_dir($this->tempLogDir)) {
|
||||||
|
$this->deleteDirectory($this->tempLogDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteDirectory(string $dir): void
|
||||||
|
{
|
||||||
|
if (!is_dir($dir)) return;
|
||||||
|
|
||||||
|
$files = array_diff(scandir($dir), ['.', '..']);
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$path = $dir . '/' . $file;
|
||||||
|
is_dir($path) ? $this->deleteDirectory($path) : unlink($path);
|
||||||
|
}
|
||||||
|
rmdir($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetLogDataWithNoLogFiles(): void
|
||||||
|
{
|
||||||
|
$controller = new LogController($this->tempLogDir);
|
||||||
|
$data = $controller->getLogData();
|
||||||
|
|
||||||
|
// Should return empty log entries but valid structure
|
||||||
|
$this->assertArrayHasKey('logEntries', $data);
|
||||||
|
$this->assertArrayHasKey('availableRoutes', $data);
|
||||||
|
$this->assertArrayHasKey('availableLevels', $data);
|
||||||
|
$this->assertArrayHasKey('currentLevelFilter', $data);
|
||||||
|
$this->assertArrayHasKey('currentRouteFilter', $data);
|
||||||
|
|
||||||
|
$this->assertEmpty($data['logEntries']);
|
||||||
|
$this->assertEmpty($data['availableRoutes']);
|
||||||
|
$this->assertEquals(['DEBUG', 'INFO', 'WARNING', 'ERROR'], $data['availableLevels']);
|
||||||
|
$this->assertEquals('', $data['currentLevelFilter']);
|
||||||
|
$this->assertEquals('', $data['currentRouteFilter']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetLogDataWithValidEntries(): void
|
||||||
|
{
|
||||||
|
// Create test log content with various scenarios
|
||||||
|
$logContent = implode("\n", [
|
||||||
|
'[2025-01-31 12:00:00] DEBUG: 127.0.0.1 [GET /] - Debug home page',
|
||||||
|
'[2025-01-31 12:01:00] INFO: 127.0.0.1 [GET /admin] - Info admin page',
|
||||||
|
'[2025-01-31 12:02:00] WARNING: 127.0.0.1 [POST /admin] - Warning admin save',
|
||||||
|
'[2025-01-31 12:03:00] ERROR: 127.0.0.1 [GET /feed/rss] - Error feed generation',
|
||||||
|
'[2025-01-31 12:04:00] INFO: 127.0.0.1 - Info without route',
|
||||||
|
'Invalid log line that should be ignored'
|
||||||
|
]);
|
||||||
|
|
||||||
|
file_put_contents($this->testLogFile, $logContent);
|
||||||
|
|
||||||
|
$controller = new LogController($this->tempLogDir);
|
||||||
|
$data = $controller->getLogData();
|
||||||
|
|
||||||
|
// Should parse all valid entries and ignore invalid ones
|
||||||
|
$this->assertCount(5, $data['logEntries']);
|
||||||
|
|
||||||
|
// Verify entries are in reverse chronological order (newest first)
|
||||||
|
$entries = $data['logEntries'];
|
||||||
|
$this->assertEquals('Info without route', $entries[0]['message']);
|
||||||
|
$this->assertEquals('Debug home page', $entries[4]['message']);
|
||||||
|
|
||||||
|
// Verify entry structure
|
||||||
|
$firstEntry = $entries[0];
|
||||||
|
$this->assertArrayHasKey('timestamp', $firstEntry);
|
||||||
|
$this->assertArrayHasKey('level', $firstEntry);
|
||||||
|
$this->assertArrayHasKey('ip', $firstEntry);
|
||||||
|
$this->assertArrayHasKey('route', $firstEntry);
|
||||||
|
$this->assertArrayHasKey('message', $firstEntry);
|
||||||
|
|
||||||
|
// Test route extraction
|
||||||
|
$adminEntry = array_filter($entries, fn($e) => $e['message'] === 'Info admin page');
|
||||||
|
$adminEntry = array_values($adminEntry)[0];
|
||||||
|
$this->assertEquals('GET /admin', $adminEntry['route']);
|
||||||
|
$this->assertEquals('INFO', $adminEntry['level']);
|
||||||
|
|
||||||
|
// Test entry without route
|
||||||
|
$noRouteEntry = array_filter($entries, fn($e) => $e['message'] === 'Info without route');
|
||||||
|
$noRouteEntry = array_values($noRouteEntry)[0];
|
||||||
|
$this->assertEquals('', $noRouteEntry['route']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetLogDataWithLevelFilter(): void
|
||||||
|
{
|
||||||
|
$logContent = implode("\n", [
|
||||||
|
'[2025-01-31 12:00:00] DEBUG: 127.0.0.1 - Debug message',
|
||||||
|
'[2025-01-31 12:01:00] INFO: 127.0.0.1 - Info message',
|
||||||
|
'[2025-01-31 12:02:00] ERROR: 127.0.0.1 - Error message'
|
||||||
|
]);
|
||||||
|
|
||||||
|
file_put_contents($this->testLogFile, $logContent);
|
||||||
|
|
||||||
|
$controller = new LogController($this->tempLogDir);
|
||||||
|
$data = $controller->getLogData('ERROR');
|
||||||
|
|
||||||
|
// Should only include ERROR entries
|
||||||
|
$this->assertCount(1, $data['logEntries']);
|
||||||
|
$this->assertEquals('ERROR', $data['logEntries'][0]['level']);
|
||||||
|
$this->assertEquals('Error message', $data['logEntries'][0]['message']);
|
||||||
|
$this->assertEquals('ERROR', $data['currentLevelFilter']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetLogDataWithRouteFilter(): void
|
||||||
|
{
|
||||||
|
$logContent = implode("\n", [
|
||||||
|
'[2025-01-31 12:00:00] INFO: 127.0.0.1 [GET /] - Home page',
|
||||||
|
'[2025-01-31 12:01:00] INFO: 127.0.0.1 [GET /admin] - Admin page',
|
||||||
|
'[2025-01-31 12:02:00] INFO: 127.0.0.1 [POST /admin] - Admin save'
|
||||||
|
]);
|
||||||
|
|
||||||
|
file_put_contents($this->testLogFile, $logContent);
|
||||||
|
|
||||||
|
$controller = new LogController($this->tempLogDir);
|
||||||
|
$data = $controller->getLogData('', 'GET /admin');
|
||||||
|
|
||||||
|
// Should only include GET /admin entries
|
||||||
|
$this->assertCount(1, $data['logEntries']);
|
||||||
|
$this->assertEquals('GET /admin', $data['logEntries'][0]['route']);
|
||||||
|
$this->assertEquals('Admin page', $data['logEntries'][0]['message']);
|
||||||
|
$this->assertEquals('GET /admin', $data['currentRouteFilter']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetLogDataWithBothFilters(): void
|
||||||
|
{
|
||||||
|
$logContent = implode("\n", [
|
||||||
|
'[2025-01-31 12:00:00] ERROR: 127.0.0.1 [GET /admin] - Admin error',
|
||||||
|
'[2025-01-31 12:01:00] INFO: 127.0.0.1 [GET /admin] - Admin info',
|
||||||
|
'[2025-01-31 12:02:00] ERROR: 127.0.0.1 [GET /] - Home error'
|
||||||
|
]);
|
||||||
|
|
||||||
|
file_put_contents($this->testLogFile, $logContent);
|
||||||
|
|
||||||
|
$controller = new LogController($this->tempLogDir);
|
||||||
|
$data = $controller->getLogData('ERROR', 'GET /admin');
|
||||||
|
|
||||||
|
// Should only include entries matching both filters
|
||||||
|
$this->assertCount(1, $data['logEntries']);
|
||||||
|
$this->assertEquals('ERROR', $data['logEntries'][0]['level']);
|
||||||
|
$this->assertEquals('GET /admin', $data['logEntries'][0]['route']);
|
||||||
|
$this->assertEquals('Admin error', $data['logEntries'][0]['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetLogDataWithRotatedLogs(): void
|
||||||
|
{
|
||||||
|
// Create main log file
|
||||||
|
$mainLogContent = '[2025-01-31 14:00:00] INFO: 127.0.0.1 - Current log entry';
|
||||||
|
file_put_contents($this->testLogFile, $mainLogContent);
|
||||||
|
|
||||||
|
// Create rotated log files
|
||||||
|
$rotatedLog1 = '[2025-01-31 13:00:00] ERROR: 127.0.0.1 - Rotated log entry 1';
|
||||||
|
file_put_contents($this->testLogFile . '.1', $rotatedLog1);
|
||||||
|
|
||||||
|
$rotatedLog2 = '[2025-01-31 12:00:00] WARNING: 127.0.0.1 - Rotated log entry 2';
|
||||||
|
file_put_contents($this->testLogFile . '.2', $rotatedLog2);
|
||||||
|
|
||||||
|
$controller = new LogController($this->tempLogDir);
|
||||||
|
$data = $controller->getLogData();
|
||||||
|
|
||||||
|
// Should read from all log files, newest first
|
||||||
|
$this->assertCount(3, $data['logEntries']);
|
||||||
|
$this->assertEquals('Current log entry', $data['logEntries'][0]['message']);
|
||||||
|
$this->assertEquals('Rotated log entry 1', $data['logEntries'][1]['message']);
|
||||||
|
$this->assertEquals('Rotated log entry 2', $data['logEntries'][2]['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetLogDataExtractsAvailableRoutes(): void
|
||||||
|
{
|
||||||
|
$logContent = implode("\n", [
|
||||||
|
'[2025-01-31 12:00:00] INFO: 127.0.0.1 [GET /] - Home',
|
||||||
|
'[2025-01-31 12:01:00] INFO: 127.0.0.1 [GET /admin] - Admin',
|
||||||
|
'[2025-01-31 12:02:00] INFO: 127.0.0.1 [POST /admin] - Admin post',
|
||||||
|
'[2025-01-31 12:03:00] INFO: 127.0.0.1 [GET /admin] - Admin again',
|
||||||
|
'[2025-01-31 12:04:00] INFO: 127.0.0.1 - No route'
|
||||||
|
]);
|
||||||
|
|
||||||
|
file_put_contents($this->testLogFile, $logContent);
|
||||||
|
|
||||||
|
$controller = new LogController($this->tempLogDir);
|
||||||
|
$data = $controller->getLogData();
|
||||||
|
|
||||||
|
// Should extract unique routes, sorted
|
||||||
|
$expectedRoutes = ['GET /', 'GET /admin', 'POST /admin'];
|
||||||
|
$this->assertEquals($expectedRoutes, $data['availableRoutes']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetLogDataHandlesInvalidLogLines(): void
|
||||||
|
{
|
||||||
|
$logContent = implode("\n", [
|
||||||
|
'[2025-01-31 12:00:00] INFO: 127.0.0.1 - Valid entry',
|
||||||
|
'This is not a valid log line',
|
||||||
|
'Neither is this one',
|
||||||
|
'[2025-01-31 12:01:00] ERROR: 127.0.0.1 - Another valid entry'
|
||||||
|
]);
|
||||||
|
|
||||||
|
file_put_contents($this->testLogFile, $logContent);
|
||||||
|
|
||||||
|
$controller = new LogController($this->tempLogDir);
|
||||||
|
$data = $controller->getLogData();
|
||||||
|
|
||||||
|
// Should only include valid entries, ignore invalid ones
|
||||||
|
$this->assertCount(2, $data['logEntries']);
|
||||||
|
$this->assertEquals('Another valid entry', $data['logEntries'][0]['message']);
|
||||||
|
$this->assertEquals('Valid entry', $data['logEntries'][1]['message']);
|
||||||
|
}
|
||||||
|
}
|
169
tests/Framework/Log/LogTest.php
Normal file
169
tests/Framework/Log/LogTest.php
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class LogTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $tempLogDir;
|
||||||
|
private string $testLogFile;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
// Create a temporary directory for test logs
|
||||||
|
$this->tempLogDir = sys_get_temp_dir() . '/tkr_test_logs_' . uniqid();
|
||||||
|
mkdir($this->tempLogDir, 0777, true);
|
||||||
|
|
||||||
|
$this->testLogFile = $this->tempLogDir . '/tkr.log';
|
||||||
|
|
||||||
|
// Initialize Log with test file and reset route context
|
||||||
|
Log::init($this->testLogFile);
|
||||||
|
Log::setRouteContext('');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
// Clean up temp directory
|
||||||
|
if (is_dir($this->tempLogDir)) {
|
||||||
|
$this->deleteDirectory($this->tempLogDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteDirectory(string $dir): void
|
||||||
|
{
|
||||||
|
if (!is_dir($dir)) return;
|
||||||
|
|
||||||
|
$files = array_diff(scandir($dir), ['.', '..']);
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$path = $dir . '/' . $file;
|
||||||
|
is_dir($path) ? $this->deleteDirectory($path) : unlink($path);
|
||||||
|
}
|
||||||
|
rmdir($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSetRouteContext(): void
|
||||||
|
{
|
||||||
|
Log::setRouteContext('GET /admin');
|
||||||
|
|
||||||
|
// Create a mock config for log level
|
||||||
|
global $config;
|
||||||
|
$config = new stdClass();
|
||||||
|
$config->logLevel = 1; // DEBUG level
|
||||||
|
|
||||||
|
Log::debug('Test message');
|
||||||
|
|
||||||
|
$this->assertFileExists($this->testLogFile);
|
||||||
|
|
||||||
|
$logContent = file_get_contents($this->testLogFile);
|
||||||
|
$this->assertStringContainsString('[GET /admin]', $logContent);
|
||||||
|
$this->assertStringContainsString('Test message', $logContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmptyRouteContext(): void
|
||||||
|
{
|
||||||
|
Log::setRouteContext('');
|
||||||
|
|
||||||
|
global $config;
|
||||||
|
$config = new stdClass();
|
||||||
|
$config->logLevel = 1;
|
||||||
|
|
||||||
|
Log::info('Test without route');
|
||||||
|
|
||||||
|
$logContent = file_get_contents($this->testLogFile);
|
||||||
|
|
||||||
|
// Should match format without route context: [timestamp] LEVEL: IP - message
|
||||||
|
$this->assertMatchesRegularExpression(
|
||||||
|
'/\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\] INFO: .+ - Test without route/',
|
||||||
|
$logContent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLogLevelFiltering(): void
|
||||||
|
{
|
||||||
|
global $config;
|
||||||
|
$config = new stdClass();
|
||||||
|
$config->logLevel = 3; // WARNING level
|
||||||
|
|
||||||
|
Log::debug('Debug message'); // Should be filtered out
|
||||||
|
Log::info('Info message'); // Should be filtered out
|
||||||
|
Log::warning('Warning message'); // Should be logged
|
||||||
|
Log::error('Error message'); // Should be logged
|
||||||
|
|
||||||
|
$logContent = file_get_contents($this->testLogFile);
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString('Debug message', $logContent);
|
||||||
|
$this->assertStringNotContainsString('Info message', $logContent);
|
||||||
|
$this->assertStringContainsString('Warning message', $logContent);
|
||||||
|
$this->assertStringContainsString('Error message', $logContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLogMessageFormat(): void
|
||||||
|
{
|
||||||
|
Log::setRouteContext('POST /admin');
|
||||||
|
|
||||||
|
global $config;
|
||||||
|
$config = new stdClass();
|
||||||
|
$config->logLevel = 1;
|
||||||
|
|
||||||
|
Log::error('Test error message');
|
||||||
|
|
||||||
|
$logContent = file_get_contents($this->testLogFile);
|
||||||
|
|
||||||
|
// Check log format: [timestamp] LEVEL: IP [route] - message
|
||||||
|
$this->assertMatchesRegularExpression(
|
||||||
|
'/\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\] ERROR: .+ \[POST \/admin\] - Test error message/',
|
||||||
|
$logContent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInitCreatesLogDirectory(): void
|
||||||
|
{
|
||||||
|
$newLogFile = $this->tempLogDir . '/nested/logs/test.log';
|
||||||
|
|
||||||
|
// Directory doesn't exist yet
|
||||||
|
$this->assertDirectoryDoesNotExist(dirname($newLogFile));
|
||||||
|
|
||||||
|
Log::init($newLogFile);
|
||||||
|
|
||||||
|
// init() should create the directory
|
||||||
|
$this->assertDirectoryExists(dirname($newLogFile));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLogRotation(): void
|
||||||
|
{
|
||||||
|
global $config;
|
||||||
|
$config = new stdClass();
|
||||||
|
$config->logLevel = 1;
|
||||||
|
|
||||||
|
// Create a log file with exactly 1000 lines (the rotation threshold)
|
||||||
|
$logLines = str_repeat("[2025-01-31 12:00:00] INFO: 127.0.0.1 - Test line\n", 1000);
|
||||||
|
file_put_contents($this->testLogFile, $logLines);
|
||||||
|
|
||||||
|
// This should trigger rotation
|
||||||
|
Log::info('This should trigger rotation');
|
||||||
|
|
||||||
|
// Original log should be rotated to .1
|
||||||
|
$this->assertFileExists($this->testLogFile . '.1');
|
||||||
|
|
||||||
|
// New log should contain the new message
|
||||||
|
$newLogContent = file_get_contents($this->testLogFile);
|
||||||
|
$this->assertStringContainsString('This should trigger rotation', $newLogContent);
|
||||||
|
|
||||||
|
// Rotated log should contain old content
|
||||||
|
$rotatedContent = file_get_contents($this->testLogFile . '.1');
|
||||||
|
$this->assertStringContainsString('Test line', $rotatedContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDefaultLogLevelWhenConfigMissing(): void
|
||||||
|
{
|
||||||
|
// Clear global config
|
||||||
|
global $config;
|
||||||
|
$config = null;
|
||||||
|
|
||||||
|
// Should not throw errors and should default to INFO level
|
||||||
|
Log::debug('Debug message'); // Should be filtered out (default INFO level = 2)
|
||||||
|
Log::info('Info message'); // Should be logged
|
||||||
|
|
||||||
|
$logContent = file_get_contents($this->testLogFile);
|
||||||
|
$this->assertStringNotContainsString('Debug message', $logContent);
|
||||||
|
$this->assertStringContainsString('Info message', $logContent);
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user