From a7e79796fa57522edef097c1c318b0fa99f59be7 Mon Sep 17 00:00:00 2001 From: Greg Sarjeant Date: Fri, 1 Aug 2025 01:52:45 +0000 Subject: [PATCH] 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 Co-committed-by: Greg Sarjeant --- .gitea/workflows/build_and_publish.yaml | 2 +- public/css/default.css | 54 ++++ .../LogController/LogController.php | 122 +++++++++ src/Framework/Database/Database.php | 2 +- src/Framework/Log/Log.php | 4 +- src/Framework/Router/Router.php | 1 + templates/partials/logs.php | 90 +++++++ templates/partials/navbar.php | 2 + .../LogController/LogControllerTest.php | 237 ++++++++++++++++++ tests/Framework/Log/LogTest.php | 169 +++++++++++++ 10 files changed, 679 insertions(+), 4 deletions(-) create mode 100644 src/Controller/LogController/LogController.php create mode 100644 templates/partials/logs.php create mode 100644 tests/Controller/LogController/LogControllerTest.php create mode 100644 tests/Framework/Log/LogTest.php diff --git a/.gitea/workflows/build_and_publish.yaml b/.gitea/workflows/build_and_publish.yaml index f1acf45..b8a379f 100644 --- a/.gitea/workflows/build_and_publish.yaml +++ b/.gitea/workflows/build_and_publish.yaml @@ -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 diff --git a/public/css/default.css b/public/css/default.css index 0283a64..ca54f5b 100644 --- a/public/css/default.css +++ b/public/css/default.css @@ -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; +} diff --git a/src/Controller/LogController/LogController.php b/src/Controller/LogController/LogController.php new file mode 100644 index 0000000..ff807ca --- /dev/null +++ b/src/Controller/LogController/LogController.php @@ -0,0 +1,122 @@ +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; + } +} \ No newline at end of file diff --git a/src/Framework/Database/Database.php b/src/Framework/Database/Database.php index 2631176..14cf62b 100644 --- a/src/Framework/Database/Database.php +++ b/src/Framework/Database/Database.php @@ -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(); diff --git a/src/Framework/Log/Log.php b/src/Framework/Log/Log.php index 7cc414e..5c41fdc 100644 --- a/src/Framework/Log/Log.php +++ b/src/Framework/Log/Log.php @@ -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) diff --git a/src/Framework/Router/Router.php b/src/Framework/Router/Router.php index 99d7619..fefb28b 100644 --- a/src/Framework/Router/Router.php +++ b/src/Framework/Router/Router.php @@ -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'], diff --git a/templates/partials/logs.php b/templates/partials/logs.php new file mode 100644 index 0000000..327612c --- /dev/null +++ b/templates/partials/logs.php @@ -0,0 +1,90 @@ + + + + + + +

System Logs

+
+ +
+
+
+ Filter Logs +
+ + + + + + +
+
Clear +
+
+
+
+ + +
+ +

No log entries found matching the current filters.

+ + + + + + + + + + + + + + + + + + + + + + +
TimeLevelIPRouteMessage
+ + + + + + + + - + +
+ +
+ +
+

Showing recent log entries. + Log files are automatically rotated when they reach 1000 lines.

+
+
\ No newline at end of file diff --git a/templates/partials/navbar.php b/templates/partials/navbar.php index ed2bafb..4659bc1 100644 --- a/templates/partials/navbar.php +++ b/templates/partials/navbar.php @@ -25,6 +25,8 @@ href="basePath, 'admin/css')) ?>">css strictAccessibility): ?>tabindex="0" href="basePath, 'admin/emoji')) ?>">emoji + strictAccessibility): ?>tabindex="0" + href="basePath, 'admin/logs')) ?>">logs strictAccessibility): ?>tabindex="0" diff --git a/tests/Controller/LogController/LogControllerTest.php b/tests/Controller/LogController/LogControllerTest.php new file mode 100644 index 0000000..0858c5f --- /dev/null +++ b/tests/Controller/LogController/LogControllerTest.php @@ -0,0 +1,237 @@ +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']); + } +} \ No newline at end of file diff --git a/tests/Framework/Log/LogTest.php b/tests/Framework/Log/LogTest.php new file mode 100644 index 0000000..0f9e4d0 --- /dev/null +++ b/tests/Framework/Log/LogTest.php @@ -0,0 +1,169 @@ +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); + } +} \ No newline at end of file