diff --git a/CHANGELOG.md b/CHANGELOG.md index 362c5f7b6..27ea5346a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ 1. [](#new) * Added index file support for Flex Objects + * Added `LogViewer` helper class and CLI command: `bin/grav logviewer` 1. [](#improved) * Improved error detection for broken Flex Objects * Removed apc and xcache support, made apc alias of apcu diff --git a/bin/grav b/bin/grav index 4fef12faf..c80dfd7d7 100755 --- a/bin/grav +++ b/bin/grav @@ -45,5 +45,6 @@ $app->addCommands(array( new \Grav\Console\Cli\NewProjectCommand(), new \Grav\Console\Cli\SchedulerCommand(), new \Grav\Console\Cli\SecurityCommand(), + new \Grav\Console\Cli\LogViewerCommand(), )); $app->run(); diff --git a/system/src/Grav/Common/Helpers/LogViewer.php b/system/src/Grav/Common/Helpers/LogViewer.php new file mode 100644 index 000000000..6a7fb3445 --- /dev/null +++ b/system/src/Grav/Common/Helpers/LogViewer.php @@ -0,0 +1,130 @@ +.*)\] (?P\w+).(?P\w+): (?P.*[^ ]+) (?P[^ ]+) (?P[^ ]+)/'; + + /** + * Get the objects of a tailed file + * + * @param $filepath + * @param int $lines + * @param bool $desc + * @return array + */ + public function objectTail($filepath, $lines = 1, $desc = true) + { + $data = $this->tail($filepath, $lines); + $tailed_log = explode(PHP_EOL, $data); + $line_objects = []; + + foreach ($tailed_log as $line) { + $line_objects[] = $this->parse($line); + } + + return $desc ? $line_objects : array_reverse($line_objects); + } + + /** + * Optimized way to get just the last few entries of a log file + * + * @param $filepath + * @param int $lines + * @return bool|string + */ + public function tail($filepath, $lines = 1) { + + $f = @fopen($filepath, "rb"); + if ($f === false) return false; + + else $buffer = ($lines < 2 ? 64 : ($lines < 10 ? 512 : 4096)); + + fseek($f, -1, SEEK_END); + if (fread($f, 1) != "\n") $lines -= 1; + + // Start reading + $output = ''; + $chunk = ''; + // While we would like more + while (ftell($f) > 0 && $lines >= 0) { + // Figure out how far back we should jump + $seek = min(ftell($f), $buffer); + // Do the jump (backwards, relative to where we are) + fseek($f, -$seek, SEEK_CUR); + // Read a chunk and prepend it to our output + $output = ($chunk = fread($f, $seek)) . $output; + // Jump back to where we started reading + fseek($f, -mb_strlen($chunk, '8bit'), SEEK_CUR); + // Decrease our line counter + $lines -= substr_count($chunk, "\n"); + } + // While we have too many lines + // (Because of buffer size we might have read too many) + while ($lines++ < 0) { + // Find first newline and remove all text before that + $output = substr($output, strpos($output, "\n") + 1); + } + // Close file and return + fclose($f); + + return trim($output); + } + + public static function levelColor($level) + { + $colors = [ + 'DEBUG' => 'green', + 'INFO' => 'cyan', + 'NOTICE' => 'yellow', + 'WARNING' => 'yellow', + 'ERROR' => 'red', + 'CRITICAL' => 'red', + 'ALERT' => 'red', + 'EMERGENCY' => 'magenta' + ]; + return $colors[$level] ?? 'white'; + } + + /** + * Parse a monolog row into array bits + * + * @param $line + * @return array + */ + public function parse($line) + { + if( !is_string($line) || strlen($line) === 0) { + return array(); + } + preg_match($this->pattern, $line, $data); + if (!isset($data['date'])) { + return array(); + } + + preg_match('/(.*)- Trace:(.*)/', $data['message'], $matches); + if (is_array($matches) && isset($matches[1])) { + $data['message'] = trim($matches[1]); + $data['trace'] = trim($matches[2]); + } + + return array( + 'date' => \DateTime::createFromFormat('Y-m-d H:i:s', $data['date']), + 'logger' => $data['logger'], + 'level' => $data['level'], + 'message' => $data['message'], + 'trace' => $data['trace'] ?? null, + 'context' => json_decode($data['context'], true), + 'extra' => json_decode($data['extra'], true) + ); + } + +} diff --git a/system/src/Grav/Console/Cli/LogViewerCommand.php b/system/src/Grav/Console/Cli/LogViewerCommand.php new file mode 100644 index 000000000..09eed4a63 --- /dev/null +++ b/system/src/Grav/Console/Cli/LogViewerCommand.php @@ -0,0 +1,89 @@ +setName('logviewer') + ->addOption( + 'file', + 'f', + InputOption::VALUE_OPTIONAL, + 'custom log file location (default = grav.log)' + ) + ->addOption( + 'lines', + 'l', + InputOption::VALUE_OPTIONAL, + 'number of lines (default = 10)' + ) + ->setDescription('Display the last few entries of Grav log') + ->setHelp("Display the last few entries of Grav log"); + } + + /** + * @return int|null|void + */ + protected function serve() + { + $grav = Grav::instance(); + $grav->setup(); + + $file = $this->input->getOption('file') ?? 'grav.log'; + $lines = $this->input->getOption('lines') ?? 20; + $verbose = $this->input->getOption('verbose', false); + + $io = new SymfonyStyle($this->input, $this->output); + + $io->title('Log Viewer'); + + $io->writeln(sprintf('viewing last %s entries in %s', $lines, $file)); + $io->newLine(); + + $viewer = new LogViewer(); + + $logfile = $grav['locator']->findResource("log://" . $file); + + if ($logfile) { + $rows = $viewer->objectTail($logfile, $lines, true); + foreach ($rows as $log) { + $date = $log['date']; + $level_color = LogViewer::levelColor($log['level']); + + if ($date instanceof \DateTime) { + $output = "{$log['date']->format('Y-m-d h:i:s')} [<{$level_color}>{$log['level']}]"; + if ($log['trace'] && $verbose) { + $output .= " {$log['message']} - {$log['trace']}"; + } else { + $output .= " {$log['message']}"; + } + $io->writeln($output); + if ($verbose) { + $io->newLine(); + } + } + } + } else { + $io->error('cannot find the log file: logs/' . $file); + } + + } +}