This commit is contained in:
jqh 2020-07-09 23:42:22 +08:00
commit 931dd662ab
8 changed files with 1138 additions and 0 deletions

21
LICENSE Normal file
View File

@ -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.

75
README.md Normal file
View File

@ -0,0 +1,75 @@
<div align="center">
# Dcat Laravel Log Viewer
<p>
<a href="https://github.com/jqhph/laravel-log-viewer/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-7389D8.svg?style=flat" ></a>
<a href="https://styleci.io/repos/215738797">
<img src="https://github.styleci.io/repos/215738797/shield" alt="StyleCI">
</a>
<a href="https://github.com/jqhph/laravel-log-viewer/releases" ><img src="https://img.shields.io/github/release/jqhph/laravel-log-viewer.svg?color=4099DE" /></a>
</p>
</div>
`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).

29
composer.json Normal file
View File

@ -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"
]
}
}
}

View File

@ -0,0 +1,15 @@
<?php
return [
'route' => [
'prefix' => 'dcat-logs',
'namespace' => 'Dcat\LogViewer',
'middleware' => [],
],
'directory' => storage_path('logs'),
'search_page_items' => 500,
'page_items' => 30,
];

View File

@ -0,0 +1,392 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Laravel log viewer</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
<link href="https://cdn.bootcdn.net/ajax/libs/font-awesome/4.7.0/css/font-awesome.css" rel="stylesheet">
<!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
<!--[if lt IE 9]>
<script src="https://oss.maxcdn.com/html5shiv/3.7.2/html5shiv.min.js"></script>
<script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
<![endif]-->
<style>
body {
padding: 25px;
background: #eff3f8;
color: #414750;
font-size: 14px;
}
ol, ul {
margin-top: 0;
margin-bottom: 10px;
}
h3 {
font-weight: 400;
font-size: 20px;
margin-bottom: 0;
}
.logo {
padding-left: 15px;
font-weight: 400;
font-size: 25px;
}
.box {
background: #fff;
box-shadow: 0 2px 3px #cdd8df;
}
.box-header, .box-footer {
padding: 15px;
}
.box-footer {
border-top: 1px solid #efefef;
}
.box-title a {
color: #414750
}
.table th {
background-color: #f4f7fa;
font-weight: 400;
padding: .5rem 1.25rem;
border-bottom: 0;
}
.table td, .table th {
border-color: #efefef;
}
.table>thead>tr>th {
border-bottom: 1px solid #e3e7eb!important;
}
.table-hover tbody tr:hover {
background-color: #f4f8fb
}
.label {
max-width: 100%;
margin-bottom: 5px;
display: inline;
padding: .2em .6em .3em;
font-size: 75%;
font-weight: 700;
line-height: 1;
color: #fff;
text-align: center;
white-space: nowrap;
vertical-align: baseline;
border-radius: .25em;
}
a {
color: #586cb1
}
a:hover, a:focus {
color: #4b5ea0
}
.bg-danger {
color: #fff;
background-color: #ef5753!important;
}
.bg-primary {
color: #fff;
background-color: #586cb1!important;
}
.bg-black {
color: #fff;
background-color: #444;
}
.bg-light {
color: #555;
background-color: #d2d6de!important;
}
.bg-orange {
color: #fff;
background-color: #dda451;
}
.bg-light-blue {
color: #fff;
background-color: #59a9f8;
}
.bg-maroon {
color: #fff;
background-color: #c00;
}
.bg-navy {
color: #fff;
background-color: #6f42c1;
}
.btn-default {
color: #414750;
}
a.btn-default {
background-color: #efefef;
}
.btn-primary {
background-color: #586cb1;
border-color: #586cb1;
}
.btn-primary:hover, .btn-primary:focus, .btn-primary.active {
background-color: #4b5ea0;
border-color: #4b5ea0;
}
.btn-danger {
background-color: #ef5753;
border-color: #ef5753;
}
pre {
padding: 7px;
white-space: pre-wrap;
margin-bottom: 0;
word-break: break-all;
/*background-color: #f7f7f9;*/
display: block;
font-size: 90%;
color: #2a2e30;
}
strong {
color: #7c858e;
}
.trace-dump {
white-space: pre-wrap;
background: #222;
color: #fff;
padding: 1.5rem;
}
.nav {
padding-left: 0;
margin-bottom: 0;
list-style: none;
}
.nav>li {
position: relative;
display: block;
}
.nav-pills>li {
float: left;
}
.nav-stacked>li {
float: none;
width: 100%;
}
.nav>li>a {
position: relative;
display: block;
padding: 6px 15px;
}
.nav-pills>li>a {
border-radius: 4px;
}
.nav-pills>li>a {
border-radius: 0;
border-top: 3px solid transparent;
color: #444;
}
.nav-stacked>li>a {
border-radius: 0;
border-top: 0;
border-left: 3px solid transparent;
color: #444;
}
.nav-pills>li>a>.fa, .nav-pills>li>a>.glyphicon, .nav-pills>li>a>.ion {
margin-right: 5px;
}
.nav-stacked>li.active>a, .nav-stacked>li.active>a:hover {
background: transparent;
color: #4b5ea0;
border-top: 0;
font-weight: 600;
/*border-left-color: #586cb1;*/
}
.nav>li>a.dir {
font-size: 1.1rem;
}
</style>
</head>
<body>
<div class="wrapper pl-2 pr-2">
<div class="row">
<div class="col-md-2">
<div class="logo">
Dcat Log Viewer
</div>
<div class="">
<div class="box-header with-border">
<h3 class="box-title"><i class="fa fa-folder-open-o"></i>
<a href="{{ route('dcat-log-viewer') }}">logs</a>
@if($dir)
@php($tmp = '')
@foreach(explode('/', $dir) as $v)
@php($tmp .= '/'.$v)
/
<a href="{{ route('dcat-log-viewer', ['dir' => trim($tmp, '/')])}}">{{ $v }}</a>
@endforeach
@endif
</h3>
</div>
<form action="{{ route('dcat-log-viewer') }}" style="display: inline-block;width: 220px;padding-left: 15px">
<div class="input-group-sm" style="display: inline-block;width: 100%">
<input name="filename" class="form-control" value="{{ request('filename') }}" type="text" placeholder="Search..." />
</div>
</form>
<div class="box-body no-padding">
<ul class="nav nav-pills nav-stacked">
@if(! request('filename'))
@foreach($logDirs as $d)
<li @if($d === $fileName) class="active" @endif>
<a class="dir" href="{{ route('dcat-log-viewer', ['dir' => $d]) }}">
<i class="fa fa-folder-o"></i>{{ $d }}
</a>
</li>
@endforeach
@endif
@foreach($logFiles as $log)
<li @if($log['active'])class="active"@endif>
<a href="{{ $log['url'] }}">
<i class="fa fa-file-text{{ ($log['active']) ? '' : '-o' }}"></i>{{ $log['file'] }}
</a>
</li>
@endforeach
</ul>
</div>
<!-- /.box-body -->
</div>
<!-- /.box -->
</div>
<!-- /.col -->
<!-- /.col -->
<div class="col-md-10">
<div class="box box-primary">
<div class="box-header with-border">
<a href="{{ route('dcat-log-viewer.download', ['dir' => $dir, 'file' => $fileName]) }}" class="btn btn-primary btn-sm download" style="color: #fff"><i class="fa-download fa"></i> {{ __('Download') }}</a>
{{-- <button class="btn btn-default btn-sm download"><i class="fa-trash-o fa"></i> {{ __('Delete') }}</button>--}}
&nbsp;
<form style="display: inline-block;width: 180px">
<div class="input-group-sm" style="display: inline-block;width: 100%">
<input name="keyword" class="form-control" value="{{ request('keyword') }}" type="text" placeholder="Search..." />
</div>
</form>
<div class="float-right">
<a class=""><strong>Size:</strong> {{ $size }} &nbsp; <strong>Updated at:</strong>
{{ \Carbon\Carbon::create(date('Y-m-d H:i:s', filectime($filePath)))->diffForHumans() }}</a>
&nbsp;
<div class="btn-group">
@if ($prevUrl)
<a href="{{ $prevUrl }}" class="btn btn-default btn-sm"><i class="fa fa-chevron-left"></i> Previous</a>
@endif
@if ($nextUrl)
<a href="{{ $nextUrl }}" class="btn btn-default btn-sm">Next <i class="fa fa-chevron-right"></i></a>
@endif
</div>
<!-- /.btn-group -->
</div>
<!-- /.box-tools -->
</div>
<!-- /.box-header -->
<div class="box-body no-padding">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th></th>
<th>Level</th>
<th>Env</th>
<th>Time</th>
<th>Message</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach($logs as $index => $log)
<tr>
<td>{{ $index + 1 }}</td>
<td><span class="label bg-{{\Dcat\LogViewer\LogViewer::$levelColors[$log['level']]}}">{{ $log['level'] }}</span></td>
<td><strong>{{ $log['env'] }}</strong></td>
<td style="width:150px;">{{ $log['time'] }}</td>
<td><pre>{{ $log['info'] }}</pre></td>
<td>
@if(!empty($log['trace']))
<button class="btn btn-primary btn-sm" data-toggle="collapse" data-target=".trace-{{$index}}"><i class="fa fa-info"></i>&nbsp;&nbsp;Exception</button>
@endif
</td>
</tr>
@if (!empty($log['trace']))
<tr class="collapse trace-{{$index}}">
<td colspan="6"><div class="trace-dump">{{ $log['trace'] }}</div></td>
</tr>
@endif
@endforeach
</tbody>
</table>
<!-- /.table -->
</div>
</div>
<div class="box-footer">
<div class="float-left">
<a class=""><strong>Size:</strong> {{ $size }} &nbsp; <strong>Updated at:</strong>
{{ \Carbon\Carbon::create(date('Y-m-d H:i:s', filectime($filePath)))->diffForHumans() }}</a>
</div>
<div class="float-right">
<div class="btn-group">
@if ($prevUrl)
<a href="{{ $prevUrl }}" class="btn btn-default btn-sm"><i class="fa fa-chevron-left"></i> Previous</a>
@endif
@if ($nextUrl)
<a href="{{ $nextUrl }}" class="btn btn-default btn-sm">Next <i class="fa fa-chevron-right"></i></a>
@endif
</div>
<!-- /.btn-group -->
</div>
<div class="clearfix"></div>
</div>
</div>
<!-- /. box -->
</div>
</div>
</div>
<!-- jQuery for Bootstrap -->
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
{{--<script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" crossorigin="anonymous"></script>--}}
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" crossorigin="anonymous"></script>
</body>
</html>

View File

@ -0,0 +1,34 @@
<?php
namespace Dcat\LogViewer;
use Illuminate\Routing\Router;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
class DcatLogViewerServiceProvider extends ServiceProvider
{
public function boot()
{
$this->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');
});
}
}

81
src/LogController.php Normal file
View File

@ -0,0 +1,81 @@
<?php
namespace Dcat\LogViewer;
use Illuminate\Routing\Controller;
use Illuminate\Support\Str;
class LogController extends Controller
{
public function index($file = null)
{
$dir = trim(request('dir'));
$filename = trim(request('filename'));
$offset = request('offset');
$keyword = trim(request('keyword'));
$lines = $keyword ? (config('dcat-log-viewer.search_page_items') ?: 500) : (config('dcat-log-viewer.page_items') ?: 30);
$viewer = new LogViewer($this->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];
}
}

491
src/LogViewer.php Normal file
View File

@ -0,0 +1,491 @@
<?php
namespace Dcat\LogViewer;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Str;
/**
* Class LogViewer.
*
* @see https://github.com/laravel-admin-extensions/log-viewer/blob/master/src/LogViewer.php
*/
class LogViewer
{
/**
* The log file name.
*
* @var string
*/
public $file;
/**
* @var \Illuminate\Filesystem\Filesystem
*/
public $files;
/**
* @var string
*/
protected $basePath;
/**
* @var string
*/
protected $currentDirectory;
/**
* The path of log file.
*
* @var string
*/
protected $filePath;
/**
* Start and end offset in current page.
*
* @var array
*/
protected $pageOffset = [];
/**
* @var array
*/
public static $levelColors = [
'EMERGENCY' => '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 = "<a class=\"btn btn-primary btn-xs\" data-toggle=\"collapse\" data-target=\".trace-{$index}\"><i class=\"fa fa-info\"></i>&nbsp;&nbsp;Exception</a>";
}
$trace = '';
if (!empty($log['trace'])) {
$trace = "<tr class=\"collapse trace-{$index}\">
<td colspan=\"5\"><div style=\"white-space: pre-wrap;background: #333;color: #fff; padding: 10px;\">{$log['trace']}</div></td>
</tr>";
}
return <<<TPL
<tr style="background-color: rgb(255, 255, 213);">
<td><span class="label bg-{$color}">{$log['level']}</span></td>
<td><strong>{$log['env']}</strong></td>
<td style="width:150px;">{$log['time']}</td>
<td><code>{$log['info']}</code></td>
<td>$button</td>
</tr>
$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));
}
}