Merge pull request #10309 from fabrixxm/feature/advanced-logsview
Display structured logs in admin
This commit is contained in:
@@ -394,6 +394,14 @@ abstract class DI
|
||||
return self::$dice->create(Model\Storage\IWritableStorage::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Model\Log\ParsedLogIterator
|
||||
*/
|
||||
public static function parsedLogIterator()
|
||||
{
|
||||
return self::$dice->create(Model\Log\ParsedLogIterator::class);
|
||||
}
|
||||
|
||||
//
|
||||
// "Network" namespace
|
||||
//
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
/**
|
||||
* @copyright Copyright (C) 2021, Friendica
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
namespace Friendica\Model\Log;
|
||||
|
||||
use Friendica\Util\ReversedFileReader;
|
||||
use Friendica\Object\Log\ParsedLogLine;
|
||||
|
||||
/**
|
||||
* An iterator which returns `\Friendica\Objec\Log\ParsedLogLine` instances
|
||||
*
|
||||
* Uses `\Friendica\Util\ReversedFileReader` to fetch log lines
|
||||
* from newest to oldest.
|
||||
*/
|
||||
class ParsedLogIterator implements \Iterator
|
||||
{
|
||||
/** @var \Iterator */
|
||||
private $reader;
|
||||
|
||||
/** @var ParsedLogLine current iterator value*/
|
||||
private $value = null;
|
||||
|
||||
/** @var int max number of lines to read */
|
||||
private $limit = 0;
|
||||
|
||||
/** @var array filters per column */
|
||||
private $filters = [];
|
||||
|
||||
/** @var string search term */
|
||||
private $search = "";
|
||||
|
||||
|
||||
/**
|
||||
* @param ReversedFileReader $reader
|
||||
*/
|
||||
public function __construct(ReversedFileReader $reader)
|
||||
{
|
||||
$this->reader = $reader;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $filename File to open
|
||||
* @return $this
|
||||
*/
|
||||
public function open(string $filename)
|
||||
{
|
||||
$this->reader->open($filename);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $limit Max num of lines to read
|
||||
* @return $this
|
||||
*/
|
||||
public function withLimit(int $limit)
|
||||
{
|
||||
$this->limit = $limit;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $filters filters per column
|
||||
* @return $this
|
||||
*/
|
||||
public function withFilters(array $filters)
|
||||
{
|
||||
$this->filters = $filters;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $search string to search to filter lines
|
||||
* @return $this
|
||||
*/
|
||||
public function withSearch(string $search)
|
||||
{
|
||||
$this->search = $search;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if parsed log line match filters.
|
||||
* Always match if no filters are set.
|
||||
*
|
||||
* @param ParsedLogLine $parsedlogline
|
||||
* @return bool
|
||||
*/
|
||||
private function filter($parsedlogline)
|
||||
{
|
||||
$match = true;
|
||||
foreach ($this->filters as $filter => $filtervalue) {
|
||||
switch ($filter) {
|
||||
case "level":
|
||||
$match = $match && ($parsedlogline->level == strtoupper($filtervalue));
|
||||
break;
|
||||
case "context":
|
||||
$match = $match && ($parsedlogline->context == $filtervalue);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $match;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if parsed log line match search.
|
||||
* Always match if no search query is set.
|
||||
*
|
||||
* @param ParsedLogLine $parsedlogline
|
||||
* @return bool
|
||||
*/
|
||||
private function search($parsedlogline)
|
||||
{
|
||||
if ($this->search != "") {
|
||||
return strstr($parsedlogline->logline, $this->search) !== false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a line from reader and parse.
|
||||
* Returns null if limit is reached or the reader is invalid.
|
||||
*
|
||||
* @param ParsedLogLine $parsedlogline
|
||||
* @return ?ParsedLogLine
|
||||
*/
|
||||
private function read()
|
||||
{
|
||||
$this->reader->next();
|
||||
if ($this->limit > 0 && $this->reader->key() > $this->limit || !$this->reader->valid()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$line = $this->reader->current();
|
||||
return new ParsedLogLine($this->reader->key(), $line);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fetch next parsed log line which match with filters or search and
|
||||
* set it as current iterator value.
|
||||
*
|
||||
* @see Iterator::next()
|
||||
* @return void
|
||||
*/
|
||||
public function next()
|
||||
{
|
||||
$parsed = $this->read();
|
||||
|
||||
while (is_null($parsed) == false && !($this->filter($parsed) && $this->search($parsed))) {
|
||||
$parsed = $this->read();
|
||||
}
|
||||
$this->value = $parsed;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Rewind the iterator to the first matching log line
|
||||
*
|
||||
* @see Iterator::rewind()
|
||||
* @return void
|
||||
*/
|
||||
public function rewind()
|
||||
{
|
||||
$this->value = null;
|
||||
$this->reader->rewind();
|
||||
$this->next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return current parsed log line number
|
||||
*
|
||||
* @see Iterator::key()
|
||||
* @see ReversedFileReader::key()
|
||||
* @return int
|
||||
*/
|
||||
public function key()
|
||||
{
|
||||
return $this->reader->key();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return current iterator value
|
||||
*
|
||||
* @see Iterator::current()
|
||||
* @return ?ParsedLogLing
|
||||
*/
|
||||
public function current()
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if current iterator value is valid, that is, not null
|
||||
*
|
||||
* @see Iterator::valid()
|
||||
* @return bool
|
||||
*/
|
||||
public function valid()
|
||||
{
|
||||
return ! is_null($this->value);
|
||||
}
|
||||
}
|
||||
@@ -21,50 +21,74 @@
|
||||
|
||||
namespace Friendica\Module\Admin\Logs;
|
||||
|
||||
use Friendica\Core\Renderer;
|
||||
use Friendica\DI;
|
||||
use Friendica\Core\Renderer;
|
||||
use Friendica\Core\Theme;
|
||||
use Friendica\Module\BaseAdmin;
|
||||
use Friendica\Util\Strings;
|
||||
use Friendica\Model\Log\ParsedLogIterator;
|
||||
use Psr\Log\LogLevel;
|
||||
|
||||
class View extends BaseAdmin
|
||||
{
|
||||
const LIMIT = 500;
|
||||
|
||||
public static function content(array $parameters = [])
|
||||
{
|
||||
parent::content($parameters);
|
||||
|
||||
$t = Renderer::getMarkupTemplate('admin/logs/view.tpl');
|
||||
$f = DI::config()->get('system', 'logfile');
|
||||
$data = '';
|
||||
DI::page()->registerFooterScript(Theme::getPathForFile('js/module/admin/logs/view.js'));
|
||||
|
||||
$f = DI::config()->get('system', 'logfile');
|
||||
$data = null;
|
||||
$error = null;
|
||||
|
||||
$search = $_GET['q'] ?? '';
|
||||
|
||||
$filters_valid_values = [
|
||||
'level' => [
|
||||
'',
|
||||
LogLevel::CRITICAL,
|
||||
LogLevel::ERROR,
|
||||
LogLevel::WARNING,
|
||||
LogLevel::NOTICE,
|
||||
LogLevel::INFO,
|
||||
LogLevel::DEBUG,
|
||||
],
|
||||
'context' => ['', 'index', 'worker'],
|
||||
];
|
||||
$filters = [
|
||||
'level' => $_GET['level'] ?? '',
|
||||
'context' => $_GET['context'] ?? '',
|
||||
];
|
||||
foreach ($filters as $k => $v) {
|
||||
if ($v == '' || !in_array($v, $filters_valid_values[$k])) {
|
||||
unset($filters[$k]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!file_exists($f)) {
|
||||
$data = DI::l10n()->t('Error trying to open <strong>%1$s</strong> log file.\r\n<br/>Check to see if file %1$s exist and is readable.', $f);
|
||||
$error = DI::l10n()->t('Error trying to open <strong>%1$s</strong> log file.\r\n<br/>Check to see if file %1$s exist and is readable.', $f);
|
||||
} else {
|
||||
$fp = fopen($f, 'r');
|
||||
if (!$fp) {
|
||||
$data = DI::l10n()->t('Couldn\'t open <strong>%1$s</strong> log file.\r\n<br/>Check to see if file %1$s is readable.', $f);
|
||||
} else {
|
||||
$fstat = fstat($fp);
|
||||
$size = $fstat['size'];
|
||||
if ($size != 0) {
|
||||
if ($size > 5000000 || $size < 0) {
|
||||
$size = 5000000;
|
||||
}
|
||||
$seek = fseek($fp, 0 - $size, SEEK_END);
|
||||
if ($seek === 0) {
|
||||
$data = Strings::escapeHtml(fread($fp, $size));
|
||||
while (!feof($fp)) {
|
||||
$data .= Strings::escapeHtml(fread($fp, 4096));
|
||||
}
|
||||
}
|
||||
}
|
||||
fclose($fp);
|
||||
try {
|
||||
$data = DI::parsedLogIterator()
|
||||
->open($f)
|
||||
->withLimit(self::LIMIT)
|
||||
->withFilters($filters)
|
||||
->withSearch($search);
|
||||
} catch (Exception $e) {
|
||||
$error = DI::l10n()->t('Couldn\'t open <strong>%1$s</strong> log file.\r\n<br/>Check to see if file %1$s is readable.', $f);
|
||||
}
|
||||
}
|
||||
return Renderer::replaceMacros($t, [
|
||||
'$title' => DI::l10n()->t('Administration'),
|
||||
'$page' => DI::l10n()->t('View Logs'),
|
||||
'$data' => $data,
|
||||
'$logname' => DI::config()->get('system', 'logfile')
|
||||
'$title' => DI::l10n()->t('Administration'),
|
||||
'$page' => DI::l10n()->t('View Logs'),
|
||||
'$data' => $data,
|
||||
'$q' => $search,
|
||||
'$filters' => $filters,
|
||||
'$filtersvalues' => $filters_valid_values,
|
||||
'$error' => $error,
|
||||
'$logname' => DI::config()->get('system', 'logfile'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
/**
|
||||
* @copyright Copyright (C) 2021, Friendica
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
namespace Friendica\Object\Log;
|
||||
|
||||
/**
|
||||
* Parse a log line and offer some utility methods
|
||||
*/
|
||||
class ParsedLogLine
|
||||
{
|
||||
const REGEXP = '/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[^ ]*) (\w+) \[(\w*)\]: (.*)/';
|
||||
|
||||
/** @var int */
|
||||
public $id = 0;
|
||||
|
||||
/** @var string */
|
||||
public $date = null;
|
||||
|
||||
/** @var string */
|
||||
public $context = null;
|
||||
|
||||
/** @var string */
|
||||
public $level = null;
|
||||
|
||||
/** @var string */
|
||||
public $message = null;
|
||||
|
||||
/** @var string */
|
||||
public $data = null;
|
||||
|
||||
/** @var string */
|
||||
public $source = null;
|
||||
|
||||
/** @var string */
|
||||
public $logline;
|
||||
|
||||
/**
|
||||
* @param int line id
|
||||
* @param string $logline Source log line to parse
|
||||
*/
|
||||
public function __construct(int $id, string $logline)
|
||||
{
|
||||
$this->id = $id;
|
||||
$this->parse($logline);
|
||||
}
|
||||
|
||||
private function parse($logline)
|
||||
{
|
||||
$this->logline = $logline;
|
||||
|
||||
// if data is empty is serialized as '[]'. To ease the parsing
|
||||
// let's replace it with '{""}'. It will be replaced by null later
|
||||
$logline = str_replace(' [] - {', ' {""} - {', $logline);
|
||||
|
||||
|
||||
if (strstr($logline, ' - {') === false) {
|
||||
// the log line is not well formed
|
||||
$jsonsource = null;
|
||||
} else {
|
||||
// here we hope that there will not be the string ' - {' inside the $jsonsource value
|
||||
list($logline, $jsonsource) = explode(' - {', $logline);
|
||||
$jsonsource = '{' . $jsonsource;
|
||||
}
|
||||
|
||||
$jsondata = null;
|
||||
if (strpos($logline, '{"') > 0) {
|
||||
list($logline, $jsondata) = explode('{"', $logline, 2);
|
||||
|
||||
$jsondata = '{"' . $jsondata;
|
||||
}
|
||||
|
||||
preg_match(self::REGEXP, $logline, $matches);
|
||||
|
||||
if (count($matches) == 0) {
|
||||
// regexp not matching
|
||||
$this->message = $this->logline;
|
||||
} else {
|
||||
$this->date = $matches[1];
|
||||
$this->context = $matches[2];
|
||||
$this->level = $matches[3];
|
||||
$this->message = $matches[4];
|
||||
$this->data = $jsondata == '{""}' ? null : $jsondata;
|
||||
$this->source = $jsonsource;
|
||||
$this->tryfixjson();
|
||||
}
|
||||
|
||||
$this->message = trim($this->message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix message / data split
|
||||
*
|
||||
* In log boundary between message and json data is not specified.
|
||||
* If message contains '{' the parser thinks there starts the json data.
|
||||
* This method try to parse the found json and if it fails, search for next '{'
|
||||
* in json data and retry
|
||||
*/
|
||||
private function tryfixjson()
|
||||
{
|
||||
if (is_null($this->data) || $this->data == '') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$d = json_decode($this->data, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
// try to find next { in $str and move string before to 'message'
|
||||
|
||||
$pos = strpos($this->data, '{', 1);
|
||||
if ($pos === false) {
|
||||
$this->message .= $this->data;
|
||||
$this->data = null;
|
||||
return;
|
||||
}
|
||||
|
||||
$this->message .= substr($this->data, 0, $pos);
|
||||
$this->data = substr($this->data, $pos);
|
||||
$this->tryfixjson();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return decoded `data` as array suitable for template
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getData()
|
||||
{
|
||||
$data = json_decode($this->data, true);
|
||||
if ($data) {
|
||||
foreach ($data as $k => $v) {
|
||||
$data[$k] = print_r($v, true);
|
||||
}
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return decoded `source` as array suitable for template
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getSource()
|
||||
{
|
||||
return json_decode($this->source, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
/**
|
||||
* @copyright Copyright (C) 2021, Friendica
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
namespace Friendica\Util;
|
||||
|
||||
/**
|
||||
* An iterator which returns lines from file in reversed order
|
||||
*
|
||||
* original code https://stackoverflow.com/a/10494801
|
||||
*/
|
||||
class ReversedFileReader implements \Iterator
|
||||
{
|
||||
const BUFFER_SIZE = 4096;
|
||||
const SEPARATOR = "\n";
|
||||
|
||||
/** @var resource */
|
||||
private $fh = null;
|
||||
|
||||
/** @var int */
|
||||
private $filesize = -1;
|
||||
|
||||
/** @var int */
|
||||
private $pos = -1;
|
||||
|
||||
/** @var array */
|
||||
private $buffer = null;
|
||||
|
||||
/** @var int */
|
||||
private $key = -1;
|
||||
|
||||
/** @var string */
|
||||
private $value = null;
|
||||
|
||||
/**
|
||||
* Open $filename for read and reset iterator
|
||||
*
|
||||
* @param string $filename File to open
|
||||
* @return $this
|
||||
*/
|
||||
public function open(string $filename)
|
||||
{
|
||||
$this->fh = fopen($filename, 'r');
|
||||
if (!$this->fh) {
|
||||
// this should use a custom exception.
|
||||
throw \Exception("Unable to open $filename");
|
||||
}
|
||||
$this->filesize = filesize($filename);
|
||||
$this->pos = -1;
|
||||
$this->buffer = null;
|
||||
$this->key = -1;
|
||||
$this->value = null;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read $size bytes behind last position
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function _read($size)
|
||||
{
|
||||
$this->pos -= $size;
|
||||
fseek($this->fh, $this->pos);
|
||||
return fread($this->fh, $size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read next line from end of file
|
||||
* Return null if no lines are left to read
|
||||
*
|
||||
* @return ?string
|
||||
*/
|
||||
private function _readline()
|
||||
{
|
||||
$buffer = & $this->buffer;
|
||||
while (true) {
|
||||
if ($this->pos == 0) {
|
||||
return array_pop($buffer);
|
||||
}
|
||||
if (count($buffer) > 1) {
|
||||
return array_pop($buffer);
|
||||
}
|
||||
$buffer = explode(self::SEPARATOR, $this->_read(self::BUFFER_SIZE) . $buffer[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch next line from end and set it as current iterator value.
|
||||
*
|
||||
* @see Iterator::next()
|
||||
* @return void
|
||||
*/
|
||||
public function next()
|
||||
{
|
||||
++$this->key;
|
||||
$this->value = $this->_readline();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewind iterator to the first line at the end of file
|
||||
*
|
||||
* @see Iterator::rewind()
|
||||
* @return void
|
||||
*/
|
||||
public function rewind()
|
||||
{
|
||||
if ($this->filesize > 0) {
|
||||
$this->pos = $this->filesize;
|
||||
$this->value = null;
|
||||
$this->key = -1;
|
||||
$this->buffer = explode(self::SEPARATOR, $this->_read($this->filesize % self::BUFFER_SIZE ?: self::BUFFER_SIZE));
|
||||
$this->next();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return current line number, starting from zero at the end of file
|
||||
*
|
||||
* @see Iterator::key()
|
||||
* @return int
|
||||
*/
|
||||
public function key()
|
||||
{
|
||||
return $this->key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return current line
|
||||
*
|
||||
* @see Iterator::current()
|
||||
* @return string
|
||||
*/
|
||||
public function current()
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if current iterator value is valid, that is, we readed all lines in files
|
||||
*
|
||||
* @see Iterator::valid()
|
||||
* @return bool
|
||||
*/
|
||||
public function valid()
|
||||
{
|
||||
return ! is_null($this->value);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user