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:
Greg Sarjeant 2025-08-01 01:52:45 +00:00 committed by greg
parent dc0abf8c7c
commit a7e79796fa
10 changed files with 679 additions and 4 deletions

View File

@ -15,7 +15,7 @@ jobs:
tar \
--transform 's,^,tkr/,' \
--exclude='storage/db' \
--exclude='storage/ticks' \
--exclude='storage/logs' \
--exclude='storage/upload' \
-czvf tkr.${{ gitea.ref_name }}.tgz \
check-prerequisites.php config public src storage templates

View File

@ -18,6 +18,10 @@
--color-primary: gainsboro;
--color-required: crimson;
--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-thin: 1px;
@ -497,3 +501,53 @@ time {
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;
}

View 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;
}
}

View File

@ -76,7 +76,7 @@ class Database{
Log::debug("No pending migrations");
return;
}
Log::info("Found " . count($migrations) . "pending migrations.");
Log::info("Found " . count($migrations) . " pending migrations.");
Log::info("Updating database. Current Version: " . $this->getVersion());
$db = self::get();

View File

@ -12,8 +12,8 @@ class Log {
private static $maxFiles = 5;
private static $routeContext = '';
public static function init() {
self::$logFile = STORAGE_DIR . '/logs/tkr.log';
public static function init(?string $logFile = null) {
self::$logFile = $logFile ?? STORAGE_DIR . '/logs/tkr.log';
// Ensure log directory exists
// (should be handled by Prerequisites, but doesn't hurt)

View File

@ -12,6 +12,7 @@ class Router {
['admin/css', 'CssController@handlePost', ['POST']],
['admin/emoji', 'EmojiController'],
['admin/emoji', 'EmojiController@handlePost', ['POST']],
['admin/logs', 'LogController'],
['feed/rss', 'FeedController@rss'],
['feed/atom', 'FeedController@atom'],
['login', 'AuthController@showLogin'],

View 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>

View File

@ -25,6 +25,8 @@
href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'admin/css')) ?>">css</a>
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
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>
</details>
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>

View 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']);
}
}

View 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);
}
}