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
+
+
+
+
+
+
+
+
+
+
+
+`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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Dcat Log Viewer
+
+
+
+
+
+
+
+
+
+ @if(! request('filename'))
+ @foreach($logDirs as $d)
+
+
+ {{ $d }}
+
+
+ @endforeach
+ @endif
+
+ @foreach($logFiles as $log)
+
+
+ {{ $log['file'] }}
+
+
+ @endforeach
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Level
+ Env
+ Time
+ Message
+
+
+
+
+
+
+ @foreach($logs as $index => $log)
+
+ {{ $index + 1 }}
+ {{ $log['level'] }}
+ {{ $log['env'] }}
+ {{ $log['time'] }}
+ {{ $log['info'] }}
+
+ @if(!empty($log['trace']))
+ Exception
+ @endif
+
+
+
+ @if (!empty($log['trace']))
+
+ {{ $log['trace'] }}
+
+ @endif
+
+ @endforeach
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{{----}}
+
+
+
+
\ 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));
+ }
+}