From 931dd662abb84efe63271aa20581bc6ddb5d490a Mon Sep 17 00:00:00 2001 From: jqh <841324345@qq.com> Date: Thu, 9 Jul 2020 23:42:22 +0800 Subject: [PATCH] init --- LICENSE | 21 ++ README.md | 75 ++++ composer.json | 29 ++ config/dcat-log-viewer.php | 15 + resources/view/log.blade.php | 392 +++++++++++++++++++++ src/DcatLogViewerServiceProvider.php | 34 ++ src/LogController.php | 81 +++++ src/LogViewer.php | 491 +++++++++++++++++++++++++++ 8 files changed, 1138 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/dcat-log-viewer.php create mode 100644 resources/view/log.blade.php create mode 100644 src/DcatLogViewerServiceProvider.php create mode 100644 src/LogController.php create mode 100644 src/LogViewer.php diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8394f48 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Jiang Qinghua + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f5f4e17 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +
+ +# Dcat Laravel Log Viewer + +

+ + + StyleCI + + +

+ +
+ +`Dcat Log Viewer`是一个`Laravel`日志查看工具,支持查看、搜索大文件。 + +## 功能 + +- [x] 支持多层级目录 +- [x] 支持查看大文件日志 +- [x] 支持日志关键词检索 +- [x] 支持多层级目录文件名称搜索 +- [x] 支持下载功能 +- [x] 支持分页 +- [x] 支持手机页面 + + + +## 环境 + +- PHP >= 7 +- laravel >= 5.5 + + +## 安装 + +```bash +composer require dcat/laravel-log-viewer +``` + +发布配置文件,此步骤可省略 + +```bash +php artisan vendor:publish --tag=dcat-log-viewer +``` + +然后访问 `http://hostname/dcat-logs` 即可 + +配置文件 + +```php + +return [ + 'route' => [ + // 路由前缀 + 'prefix' => 'dcat-logs', + // 命名空间 + 'namespace' => 'Dcat\LogViewer', + // 中间件 + 'middleware' => [], + ], + + // 日志目录 + 'directory' => storage_path('logs'), + + // 搜索页显示条目数(搜索后不分页,所以这个参数可以设置大一些) + 'search_page_items' => 500, + + // 默认每页条目数 + 'page_items' => 30, +]; +``` + +## License +[The MIT License (MIT)](LICENSE). diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..aac3e3d --- /dev/null +++ b/composer.json @@ -0,0 +1,29 @@ +{ + "name": "dcat/laravel-log-viewer", + "description": "Laravel Log Viewer", + "type": "library", + "keywords": ["laravel", "log viewer"], + "homepage": "https://github.com/jqhph/laravel-log-viewer", + "license": "MIT", + "authors": [ + { + "name": "jqh", + "email": "841324345@qq.com" + } + ], + "require": { + "php": ">=7.0" + }, + "autoload": { + "psr-4": { + "Dcat\\LogViewer\\": "src/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Dcat\\LogViewer\\DcatLogViewerServiceProvider" + ] + } + } +} diff --git a/config/dcat-log-viewer.php b/config/dcat-log-viewer.php new file mode 100644 index 0000000..dd55dab --- /dev/null +++ b/config/dcat-log-viewer.php @@ -0,0 +1,15 @@ + [ + 'prefix' => 'dcat-logs', + 'namespace' => 'Dcat\LogViewer', + 'middleware' => [], + ], + + 'directory' => storage_path('logs'), + + 'search_page_items' => 500, + + 'page_items' => 30, +]; diff --git a/resources/view/log.blade.php b/resources/view/log.blade.php new file mode 100644 index 0000000..9bfd106 --- /dev/null +++ b/resources/view/log.blade.php @@ -0,0 +1,392 @@ + + + + + + Laravel log viewer + + + + + + + + + + +
+ +
+ +
+ + +
+
+

+ logs + @if($dir) + @php($tmp = '') + @foreach(explode('/', $dir) as $v) + @php($tmp .= '/'.$v) + / + {{ $v }} + @endforeach + @endif +

+
+ +
+
+ +
+
+ +
+ +
+ +
+ + +
+ + + +
+
+
+ {{ __('Download') }} + +{{-- --}} +   +
+
+ +
+
+ + +
+ +
+ +
+ + + + + + + + + + + + + + + + @foreach($logs as $index => $log) + + + + + + + + + + @if (!empty($log['trace'])) + + + + @endif + + @endforeach + + +
LevelEnvTimeMessage
{{ $index + 1 }}{{ $log['level'] }}{{ $log['env'] }}{{ $log['time'] }}
{{ $log['info'] }}
+ @if(!empty($log['trace'])) + + @endif +
{{ $log['trace'] }}
+ +
+ + +
+ +
+ +
+ +
+
+ + + + +{{----}} + + + + \ No newline at end of file diff --git a/src/DcatLogViewerServiceProvider.php b/src/DcatLogViewerServiceProvider.php new file mode 100644 index 0000000..f20a8d9 --- /dev/null +++ b/src/DcatLogViewerServiceProvider.php @@ -0,0 +1,34 @@ +loadViewsFrom(__DIR__.'/../resources/view', 'dcat-log-viewer'); + + if ($this->app->runningInConsole()) { + $this->publishes([__DIR__.'/../config' => config_path()], 'dcat-log-viewer'); + } + + $this->registerRoutes(); + } + + protected function registerRoutes() + { + Route::group([ + 'prefix' => config('dcat-log-viewer.route.prefix', 'dcat-logs'), + 'namespace' => config('dcat-log-viewer.route.namespace', 'Dcat\LogViewer'), + 'middleware' => config('dcat-log-viewer.route.middleware'), + ], function (Router $router) { + $router->get('/', 'LogController@index')->name('dcat-log-viewer'); + $router->get('{file}', 'LogController@index')->name('dcat-log-viewer.file'); + $router->get('download/{file}', 'LogController@download')->name('dcat-log-viewer.download'); + }); + } +} diff --git a/src/LogController.php b/src/LogController.php new file mode 100644 index 0000000..736de3b --- /dev/null +++ b/src/LogController.php @@ -0,0 +1,81 @@ +getDirectory(), $dir, $file); + + $viewer->setKeyword($keyword); + $viewer->setFilename($filename); + + return view('dcat-log-viewer::log', [ + 'dir' => $dir, + 'logs' => $viewer->fetch($offset, $lines), + 'logFiles' => $this->formatLogFiles($viewer, $dir), + 'logDirs' => $viewer->getLogDirectories(), + 'fileName' => $viewer->file, + 'end' => $viewer->getFilesize(), + 'prevUrl' => $viewer->getPrevPageUrl(), + 'nextUrl' => $viewer->getNextPageUrl(), + 'filePath' => $viewer->getFilePath(), + 'size' => static::bytesToHuman($viewer->getFilesize()), + ]); + } + + public function download($file = null) + { + $viewer = new LogViewer($this->getDirectory(), request('dir'), $file); + + return response()->download($viewer->getFilePath()); + } + + protected function getDirectory() + { + return config('dcat-log-viewer.directory') ?: storage_path('logs'); + } + + protected function formatLogFiles(LogViewer $logViewer, $currentDir) + { + return array_map(function ($value) use ($logViewer, $currentDir) { + $file = $value; + $dir = $currentDir; + + if (Str::contains($value, '/')) { + $array = explode('/', $value); + $file = end($array); + + array_pop($array); + $dir = implode('/', $array); + } + + return [ + 'file' => $value, + 'url' => route('dcat-log-viewer.file', ['file' => $file, 'dir' => $dir]), + 'active' => $logViewer->isCurrentFile($value), + ]; + }, $logViewer->getLogFiles()); + } + + protected static function bytesToHuman($bytes) + { + $units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + + for ($i = 0; $bytes > 1024; $i++) { + $bytes /= 1024; + } + + return round($bytes, 2).' '.$units[$i]; + } +} diff --git a/src/LogViewer.php b/src/LogViewer.php new file mode 100644 index 0000000..8882018 --- /dev/null +++ b/src/LogViewer.php @@ -0,0 +1,491 @@ + 'black', + 'ALERT' => 'navy', + 'CRITICAL' => 'maroon', + 'ERROR' => 'danger', + 'WARNING' => 'orange', + 'NOTICE' => 'light-blue', + 'INFO' => 'primary', + 'DEBUG' => 'light', + ]; + + protected $keyword; + + protected $filename; + + /** + * LogViewer constructor. + * + * @param null $file + */ + public function __construct($basePath, $dir, $file = null) + { + $this->basePath = trim($basePath, '/'); + $this->currentDirectory = trim($dir, '/'); + $this->file = $file; + $this->files = new Filesystem(); + } + + /** + * Get file path by giving log file name. + * + * @return string + * + */ + public function getFilePath() + { + if (!$this->filePath) { + $path = $this->mergeDirectory().'/'.$this->getFile(); + + $this->filePath = is_file($path) ? $path : false; + } + + return $this->filePath; + } + + public function setKeyword($value) + { + $this->keyword = $value; + } + + public function setFilename($value) + { + $this->filename = $value; + } + + /** + * Get size of log file. + * + * @return int + */ + public function getFilesize() + { + if (!$this->getFilePath()) { + return 0; + } + + return filesize($this->getFilePath()); + } + + /** + * Get log file list in storage. + * + * @return array + */ + public function getLogFiles() + { + if ($this->filename) { + return collect($this->files->allFiles($this->mergeDirectory()))->map(function (\SplFileInfo $fileInfo) { + return $this->replaceBasePath($fileInfo->getRealPath()); + })->filter(function ($v) { + return Str::contains($v, $this->filename); + })->toArray(); + } + + $files = glob($this->mergeDirectory().'/*.*'); + $files = array_combine($files, array_map('filemtime', $files)); + arsort($files); + + return array_map('basename', array_keys($files)); + } + + public function getLogDirectories() + { + return array_map([$this, 'replaceBasePath'], $this->files->directories($this->mergeDirectory())); + } + + protected function replaceBasePath($v) + { + $basePath = str_replace('\\', '/', $this->getLogBasePath()); + + return str_replace($basePath.'/', '', str_replace('\\', '/', $v)); + } + + public function mergeDirectory() + { + if (!$this->currentDirectory) { + return $this->getLogBasePath(); + } + + return $this->getLogBasePath() . '/' . $this->currentDirectory; + } + + /** + * @return string + */ + public function getLogBasePath() + { + return $this->basePath; + } + + /** + * Get the last modified log file. + * + * @return string + */ + public function getLastModifiedLog() + { + return current($this->getLogFiles()); + } + + public function getFile() + { + if (! $this->file) { + $this->file = $this->getLastModifiedLog(); + } + + return $this->file; + } + + public function isCurrentFile($file) + { + return $this->replaceBasePath($this->getFilePath()) === trim($this->currentDirectory.'/'.$file, '/'); + } + + /** + * Get previous page url. + * + * @return bool|string + */ + public function getPrevPageUrl() + { + if ( + !$this->getFilePath() + || $this->pageOffset['end'] >= $this->getFilesize() - 1 + || $this->keyword + ) { + return false; + } + + return route('dcat-log-viewer.file', [ + 'file' => $this->getFile(), + 'offset' => $this->pageOffset['end'], + 'keyword' => $this->keyword, + ]); + } + + /** + * Get Next page url. + * + * @return bool|string + */ + public function getNextPageUrl() + { + if ( + !$this->getFilePath() + || $this->pageOffset['start'] == 0 + || $this->keyword + ) { + return false; + } + + return route('dcat-log-viewer.file', [ + 'file' => $this->getFile(), + 'offset' => -$this->pageOffset['start'], + 'keyword' => $this->keyword, + ]); + } + + /** + * Fetch logs by giving offset. + * + * @param int $seek + * @param int $lines + * @param int $buffer + * + * @return array + * + * @see http://www.geekality.net/2011/05/28/php-tail-tackling-large-files/ + */ + public function fetch($seek = 0, $lines = 20, $buffer = 4096) + { + $logs = $this->read($seek, $lines, $buffer); + + if (!$this->keyword || !$logs) { + return $logs; + } + + $result = []; + + foreach ($logs as $log) { + if (Str::contains(implode(' ', $log), $this->keyword)) { + $result[] = $log; + } + } + + if (count($result) >= $lines || !$this->getNextOffset()) { + return $result; + } + + return array_merge($result, $this->fetch($this->getNextOffset(), $lines - count($result), $buffer)); + } + + public function getNextOffset() + { + if ($this->pageOffset['start'] == 0) { + return false; + } + + return -$this->pageOffset['start']; + } + + protected function read($seek = 0, $lines = 20, $buffer = 4096) + { + if (! $this->getFilePath()) { + return []; + } + + $f = fopen($this->getFilePath(), 'rb'); + + if ($seek) { + fseek($f, abs($seek)); + } else { + fseek($f, 0, SEEK_END); + } + + if (fread($f, 1) != "\n") { + $lines -= 1; + } + fseek($f, -1, SEEK_CUR); + + // 从前往后读,上一页 + // Start reading + if ($seek > 0) { + $output = $this->readPrevPage($f, $lines, $buffer); + // 从后往前读,下一页 + } else { + $output = $this->readNextPage($f, $lines, $buffer); + } + + fclose($f); + + return $this->parseLog($output); + } + + protected function readPrevPage($f, &$lines, $buffer) + { + $output = ''; + + $this->pageOffset['start'] = ftell($f); + + while (!feof($f) && $lines >= 0) { + $output = $output . ($chunk = fread($f, $buffer)); + $lines -= substr_count($chunk, "\n[20"); + } + + $this->pageOffset['end'] = ftell($f); + + while ($lines++ < 0) { + $strpos = strrpos($output, "\n[20") + 1; + $_ = mb_strlen($output, '8bit') - $strpos; + $output = substr($output, 0, $strpos); + $this->pageOffset['end'] -= $_; + } + + return $output; + } + + protected function readNextPage($f, &$lines, $buffer) + { + $output = ''; + + $this->pageOffset['end'] = ftell($f); + + while (ftell($f) > 0 && $lines >= 0) { + $offset = min(ftell($f), $buffer); + fseek($f, -$offset, SEEK_CUR); + $output = ($chunk = fread($f, $offset)) . $output; + fseek($f, -mb_strlen($chunk, '8bit'), SEEK_CUR); + $lines -= substr_count($chunk, "\n[20"); + } + + $this->pageOffset['start'] = ftell($f); + + while ($lines++ < 0) { + $strpos = strpos($output, "\n[20") + 1; + $output = substr($output, $strpos); + $this->pageOffset['start'] += $strpos; + } + + return $output; + } + + /** + * Get tail logs in log file. + * + * @param int $seek + * + * @return array + */ + public function tail($seek) + { + // Open the file + $f = fopen($this->getFilePath(), 'rb'); + + if (!$seek) { + // Jump to last character + fseek($f, -1, SEEK_END); + } else { + fseek($f, abs($seek)); + } + + $output = ''; + + while (!feof($f)) { + $output .= fread($f, 4096); + } + + $pos = ftell($f); + + fclose($f); + + $logs = []; + + foreach ($this->parseLog(trim($output)) as $log) { + $logs[] = $this->renderTableRow($log); + } + + return [$pos, $logs]; + } + + /** + * Render table row. + * + * @param $log + * + * @return string + */ + protected function renderTableRow($log) + { + $color = self::$levelColors[$log['level']] ?? 'black'; + + $index = uniqid(); + + $button = ''; + + if (!empty($log['trace'])) { + $button = "  Exception"; + } + + $trace = ''; + + if (!empty($log['trace'])) { + $trace = " +
{$log['trace']}
+"; + } + + return << + {$log['level']} + {$log['env']} + {$log['time']} + {$log['info']} + $button + +$trace +TPL; + } + + /** + * Parse raw log text to array. + * + * @param $raw + * + * @return array + */ + protected function parseLog($raw) + { + $logs = preg_split('/\[(\d{4}(?:-\d{2}){2} \d{2}(?::\d{2}){2})\] (\w+)\.(\w+):((?:(?!{"exception").)*)?/', trim($raw), -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); + + foreach ($logs as $index => $log) { + if (preg_match('/^\d{4}/', $log)) { + break; + } else { + unset($logs[$index]); + } + } + + if (empty($logs)) { + return []; + } + + $parsed = []; + + foreach (array_chunk($logs, 5) as $log) { + $parsed[] = [ + 'time' => $log[0] ?? '', + 'env' => $log[1] ?? '', + 'level' => $log[2] ?? '', + 'info' => $log[3] ?? '', + 'trace' => $this->replaceRootPath(trim($log[4] ?? '')), + ]; + } + + unset($logs); + + rsort($parsed); + + return $parsed; + } + + protected function replaceRootPath($content) + { + $basePath = str_replace('\\', '/', base_path() . '/'); + + return str_replace($basePath, '', str_replace(['\\\\', '\\'], '/', $content)); + } +}