diff --git a/src/DI.php b/src/DI.php
index 28ad130b4..cbc353161 100644
--- a/src/DI.php
+++ b/src/DI.php
@@ -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
//
diff --git a/src/Model/Log/ParsedLogIterator.php b/src/Model/Log/ParsedLogIterator.php
new file mode 100644
index 000000000..0228fcf05
--- /dev/null
+++ b/src/Model/Log/ParsedLogIterator.php
@@ -0,0 +1,220 @@
+.
+ *
+ */
+
+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);
+ }
+}
diff --git a/src/Module/Admin/Logs/View.php b/src/Module/Admin/Logs/View.php
index 91e8f2dd8..222052380 100644
--- a/src/Module/Admin/Logs/View.php
+++ b/src/Module/Admin/Logs/View.php
@@ -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 %1$s log file.\r\n
Check to see if file %1$s exist and is readable.', $f);
+ $error = DI::l10n()->t('Error trying to open %1$s log file.\r\n
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 %1$s log file.\r\n
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 %1$s log file.\r\n
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'),
]);
}
}
diff --git a/src/Object/Log/ParsedLogLine.php b/src/Object/Log/ParsedLogLine.php
new file mode 100644
index 000000000..93a15374b
--- /dev/null
+++ b/src/Object/Log/ParsedLogLine.php
@@ -0,0 +1,164 @@
+.
+ *
+ */
+
+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);
+ }
+}
diff --git a/src/Util/ReversedFileReader.php b/src/Util/ReversedFileReader.php
new file mode 100644
index 000000000..bdc31f0cb
--- /dev/null
+++ b/src/Util/ReversedFileReader.php
@@ -0,0 +1,166 @@
+.
+ *
+ */
+
+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);
+ }
+}
diff --git a/static/dependencies.config.php b/static/dependencies.config.php
index 5efe78ddf..f07a61807 100644
--- a/static/dependencies.config.php
+++ b/static/dependencies.config.php
@@ -46,6 +46,7 @@ use Friendica\Database\Database;
use Friendica\Factory;
use Friendica\Model\Storage\IWritableStorage;
use Friendica\Model\User\Cookie;
+use Friendica\Model\Log\ParsedLogIterator;
use Friendica\Network;
use Friendica\Util;
use Psr\Log\LoggerInterface;
@@ -227,4 +228,9 @@ return [
$_SERVER
],
],
+ ParsedLogIterator::class => [
+ 'constructParams' => [
+ [Dice::INSTANCE => Util\ReversedFileReader::class],
+ ]
+ ]
];
diff --git a/tests/datasets/log/friendica.log.txt b/tests/datasets/log/friendica.log.txt
new file mode 100644
index 000000000..ed695bd8e
--- /dev/null
+++ b/tests/datasets/log/friendica.log.txt
@@ -0,0 +1,3 @@
+2021-05-24T15:23:58Z index [INFO]: No HTTP_SIGNATURE header [] - {"file":"HTTPSignature.php","line":476,"function":"getSigner","uid":"0a3934","process_id":14826}
+2021-05-24T15:30:01Z worker [NOTICE]: Load: 0.01/20 - processes: 0/1/6 (0:0, 30:1) - maximum: 10/10 {"worker_id":"ece8fc8","worker_cmd":"Cron"} - {"file":"Worker.php","line":786,"function":"tooMuchWorkers","uid":"364d3c","process_id":20754}
+2021-05-24T15:40:01Z worker [WARNING]: Spool file does does not start with "item-" {"file":".","worker_id":"560c8b6","worker_cmd":"SpoolPost"} - {"file":"SpoolPost.php","line":40,"function":"execute","uid":"fd8c37","process_id":20846}
diff --git a/tests/src/Model/Log/ParsedLogIteratorTest.php b/tests/src/Model/Log/ParsedLogIteratorTest.php
new file mode 100644
index 000000000..df9f8393a
--- /dev/null
+++ b/tests/src/Model/Log/ParsedLogIteratorTest.php
@@ -0,0 +1,148 @@
+.
+ *
+ */
+
+namespace Friendica\Test\src\Object\Log;
+
+use Friendica\Util\ReversedFileReader;
+use Friendica\Model\Log\ParsedLogIterator;
+
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Parsed log iterator testing class
+ */
+class ParsedLogIteratorTest extends TestCase
+{
+ protected $pli;
+
+ public static function assertParsed($parsed, $expected_data)
+ {
+ foreach ($expected_data as $k => $v) {
+ self::assertSame($parsed->$k, $v, '"'.$k.'" does not match expectation');
+ }
+ }
+
+ protected function setUp(): void
+ {
+ $logfile = dirname(__DIR__) . '/../../datasets/log/friendica.log.txt';
+
+ $reader = new ReversedFileReader();
+ $this->pli = new ParsedLogIterator($reader);
+ $this->pli->open($logfile);
+ }
+
+ public function testIsIterable()
+ {
+ self::assertIsIterable($this->pli);
+ }
+
+ public function testEverything()
+ {
+ self::assertCount(3, iterator_to_array($this->pli, false));
+ }
+
+ public function testLimit()
+ {
+ $this->pli->withLimit(2);
+ self::assertCount(2, iterator_to_array($this->pli, false));
+ }
+
+ public function testFilterByLevel()
+ {
+ $this->pli->withFilters(['level' => 'INFO']);
+ $pls = iterator_to_array($this->pli, false);
+ self::assertCount(1, $pls);
+ self::assertParsed(
+ $pls[0],
+ [
+ 'date' => '2021-05-24T15:23:58Z',
+ 'context' => 'index',
+ 'level' => 'INFO',
+ 'message' => 'No HTTP_SIGNATURE header',
+ 'data' => null,
+ 'source' => '{"file":"HTTPSignature.php","line":476,"function":"getSigner","uid":"0a3934","process_id":14826}',
+ ]
+ );
+ }
+
+ public function testFilterByContext()
+ {
+ $this->pli->withFilters(['context' => 'worker']);
+ $pls = iterator_to_array($this->pli, false);
+ self::assertCount(2, $pls);
+ self::assertParsed(
+ $pls[0],
+ [
+ 'date' => '2021-05-24T15:40:01Z',
+ 'context' => 'worker',
+ 'level' => 'WARNING',
+ 'message' => 'Spool file does does not start with "item-"',
+ 'data' => '{"file":".","worker_id":"560c8b6","worker_cmd":"SpoolPost"}',
+ 'source' => '{"file":"SpoolPost.php","line":40,"function":"execute","uid":"fd8c37","process_id":20846}',
+ ]
+ );
+ }
+
+ public function testFilterCombined()
+ {
+ $this->pli->withFilters(['level' => 'NOTICE', 'context' => 'worker']);
+ $pls = iterator_to_array($this->pli, false);
+ self::assertCount(1, $pls);
+ self::assertParsed(
+ $pls[0],
+ [
+ 'date' => '2021-05-24T15:30:01Z',
+ 'context' => 'worker',
+ 'level' => 'NOTICE',
+ 'message' => 'Load: 0.01/20 - processes: 0/1/6 (0:0, 30:1) - maximum: 10/10',
+ 'data' => '{"worker_id":"ece8fc8","worker_cmd":"Cron"}',
+ 'source' => '{"file":"Worker.php","line":786,"function":"tooMuchWorkers","uid":"364d3c","process_id":20754}',
+ ]
+ );
+ }
+
+ public function testSearch()
+ {
+ $this->pli->withSearch("maximum");
+ $pls = iterator_to_array($this->pli, false);
+ self::assertCount(1, $pls);
+ self::assertParsed(
+ $pls[0],
+ [
+ 'date' => '2021-05-24T15:30:01Z',
+ 'context' => 'worker',
+ 'level' => 'NOTICE',
+ 'message' => 'Load: 0.01/20 - processes: 0/1/6 (0:0, 30:1) - maximum: 10/10',
+ 'data' => '{"worker_id":"ece8fc8","worker_cmd":"Cron"}',
+ 'source' => '{"file":"Worker.php","line":786,"function":"tooMuchWorkers","uid":"364d3c","process_id":20754}',
+ ]
+ );
+ }
+
+ public function testFilterAndSearch()
+ {
+ $this->pli
+ ->withFilters(['context' => 'worker'])
+ ->withSearch("header");
+ $pls = iterator_to_array($this->pli, false);
+ self::assertCount(0, $pls);
+ }
+}
diff --git a/tests/src/Object/Log/ParsedLogLineTest.php b/tests/src/Object/Log/ParsedLogLineTest.php
new file mode 100644
index 000000000..9705c34ea
--- /dev/null
+++ b/tests/src/Object/Log/ParsedLogLineTest.php
@@ -0,0 +1,183 @@
+.
+ *
+ */
+
+namespace Friendica\Test\src\Object\Log;
+
+use Friendica\Object\Log\ParsedLogLine;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Log parser testing class
+ */
+class ParsedLogLineTest extends TestCase
+{
+ public static function do_log_line($logline, $expected_data)
+ {
+ $parsed = new ParsedLogLine(0, $logline);
+ foreach ($expected_data as $k => $v) {
+ self::assertSame($parsed->$k, $v, '"'.$k.'" does not match expectation');
+ }
+ }
+
+ /**
+ * test parsing a generic log line
+ */
+ public function testGenericLogLine()
+ {
+ self::do_log_line(
+ '2021-05-24T15:40:01Z worker [WARNING]: Spool file does does not start with "item-" {"file":".","worker_id":"560c8b6","worker_cmd":"SpoolPost"} - {"file":"SpoolPost.php","line":40,"function":"execute","uid":"fd8c37","process_id":20846}',
+ [
+ 'date' => '2021-05-24T15:40:01Z',
+ 'context' => 'worker',
+ 'level' => 'WARNING',
+ 'message' => 'Spool file does does not start with "item-"',
+ 'data' => '{"file":".","worker_id":"560c8b6","worker_cmd":"SpoolPost"}',
+ 'source' => '{"file":"SpoolPost.php","line":40,"function":"execute","uid":"fd8c37","process_id":20846}',
+ ]
+ );
+ }
+
+ /**
+ * test parsing a log line with empty data
+ */
+ public function testEmptyDataLogLine()
+ {
+ self::do_log_line(
+ '2021-05-24T15:23:58Z index [INFO]: No HTTP_SIGNATURE header [] - {"file":"HTTPSignature.php","line":476,"function":"getSigner","uid":"0a3934","process_id":14826}',
+ [
+ 'date' => '2021-05-24T15:23:58Z',
+ 'context' => 'index',
+ 'level' => 'INFO',
+ 'message' => 'No HTTP_SIGNATURE header',
+ 'data' => null,
+ 'source' => '{"file":"HTTPSignature.php","line":476,"function":"getSigner","uid":"0a3934","process_id":14826}',
+ ]
+ );
+ }
+
+ /**
+ * test parsing a log line with various " - " in it
+ */
+ public function testTrickyDashLogLine()
+ {
+ self::do_log_line(
+ '2021-05-24T15:30:01Z worker [NOTICE]: Load: 0.01/20 - processes: 0/1/6 (0:0, 30:1) - maximum: 10/10 {"worker_id":"ece8fc8","worker_cmd":"Cron"} - {"file":"Worker.php","line":786,"function":"tooMuchWorkers","uid":"364d3c","process_id":20754}',
+ [
+ 'date' => '2021-05-24T15:30:01Z',
+ 'context' => 'worker',
+ 'level' => 'NOTICE',
+ 'message' => 'Load: 0.01/20 - processes: 0/1/6 (0:0, 30:1) - maximum: 10/10',
+ 'data' => '{"worker_id":"ece8fc8","worker_cmd":"Cron"}',
+ 'source' => '{"file":"Worker.php","line":786,"function":"tooMuchWorkers","uid":"364d3c","process_id":20754}',
+ ]
+ );
+ }
+
+ /**
+ * test non conforming log line
+ */
+ public function testNonConformingLogLine()
+ {
+ self::do_log_line(
+ 'this log line is not formatted as expected',
+ [
+ 'date' => null,
+ 'context' => null,
+ 'level' => null,
+ 'message' => 'this log line is not formatted as expected',
+ 'data' => null,
+ 'source' => null,
+ ]
+ );
+ }
+
+ /**
+ * test missing source
+ */
+ public function testMissingSource()
+ {
+ self::do_log_line(
+ '2021-05-24T15:30:01Z worker [NOTICE]: Load: 0.01/20 - processes: 0/1/6 (0:0, 30:1) - maximum: 10/10 {"worker_id":"ece8fc8","worker_cmd":"Cron"}',
+ [
+ 'date' => '2021-05-24T15:30:01Z',
+ 'context' => 'worker',
+ 'level' => 'NOTICE',
+ 'message' => 'Load: 0.01/20 - processes: 0/1/6 (0:0, 30:1) - maximum: 10/10',
+ 'data' => '{"worker_id":"ece8fc8","worker_cmd":"Cron"}',
+ 'source' => null,
+ ]
+ );
+ }
+
+ /**
+ * test missing data
+ */
+ public function testMissingData()
+ {
+ self::do_log_line(
+ '2021-05-24T15:30:01Z worker [NOTICE]: Load: 0.01/20 - processes: 0/1/6 (0:0, 30:1) - maximum: 10/10 - {"file":"Worker.php","line":786,"function":"tooMuchWorkers","uid":"364d3c","process_id":20754}',
+ [
+ 'date' => '2021-05-24T15:30:01Z',
+ 'context' => 'worker',
+ 'level' => 'NOTICE',
+ 'message' => 'Load: 0.01/20 - processes: 0/1/6 (0:0, 30:1) - maximum: 10/10',
+ 'data' => null,
+ 'source' => '{"file":"Worker.php","line":786,"function":"tooMuchWorkers","uid":"364d3c","process_id":20754}',
+ ]
+ );
+ }
+
+ /**
+ * test missing data and source
+ */
+ public function testMissingDataAndSource()
+ {
+ self::do_log_line(
+ '2021-05-24T15:30:01Z worker [NOTICE]: Load: 0.01/20 - processes: 0/1/6 (0:0, 30:1) - maximum: 10/10',
+ [
+ 'date' => '2021-05-24T15:30:01Z',
+ 'context' => 'worker',
+ 'level' => 'NOTICE',
+ 'message' => 'Load: 0.01/20 - processes: 0/1/6 (0:0, 30:1) - maximum: 10/10',
+ 'data' => null,
+ 'source' => null,
+ ]
+ );
+ }
+
+ /**
+ * test missing source and invalid data
+ */
+ public function testMissingSourceAndInvalidData()
+ {
+ self::do_log_line(
+ '2021-05-24T15:30:01Z worker [NOTICE]: Load: 0.01/20 - processes: 0/1/6 (0:0, 30:1) - maximum: 10/10 {"invalidjson {really',
+ [
+ 'date' => '2021-05-24T15:30:01Z',
+ 'context' => 'worker',
+ 'level' => 'NOTICE',
+ 'message' => 'Load: 0.01/20 - processes: 0/1/6 (0:0, 30:1) - maximum: 10/10 {"invalidjson {really',
+ 'data' => null,
+ 'source' => null,
+ ]
+ );
+ }
+}
diff --git a/view/js/module/admin/logs/view.js b/view/js/module/admin/logs/view.js
new file mode 100644
index 000000000..5ad4e0a71
--- /dev/null
+++ b/view/js/module/admin/logs/view.js
@@ -0,0 +1,33 @@
+(function(){
+ function log_show_details(elm) {
+ const id = elm.id;
+ var hidden = true;
+ document
+ .querySelectorAll('[data-id="' + id + '"]')
+ .forEach(edetails => {
+ hidden = edetails.classList.toggle('hidden');
+ });
+ document
+ .querySelectorAll('[aria-expanded="true"]')
+ .forEach(eexpanded => {
+ eexpanded.setAttribute('aria-expanded', false);
+ });
+
+ if (!hidden) {
+ elm.setAttribute('aria-expanded', true);
+ }
+ }
+
+ document
+ .querySelectorAll('.log-event')
+ .forEach(elm => {
+ elm.addEventListener("click", evt => {
+ log_show_details(evt.currentTarget);
+ });
+ elm.addEventListener("keydown", evt => {
+ if (evt.keyCode == 13 || evt.keyCode == 32) {
+ log_show_details(evt.currentTarget);
+ }
+ });
+ });
+})();
\ No newline at end of file
diff --git a/view/templates/admin/logs/view.tpl b/view/templates/admin/logs/view.tpl
index 9ac5acd9d..e15a4a01b 100644
--- a/view/templates/admin/logs/view.tpl
+++ b/view/templates/admin/logs/view.tpl
@@ -1,6 +1,78 @@
{{$data}}
{{$error nofilter}}
+").text(v)[0].outerHTML; + } + s += "
{{$error nofilter}}
+Date | ++ + Level {{if $filters.level}}({{$filters.level}}){{/if}} + + + | ++ + Context {{if $filters.context}}({{$filters.context}}){{/if}} + + + | +Message | +
---|---|---|---|
{{$row->date}} | +level == "ERROR"}}bg-danger + {{elseif $row->level == "WARNING"}}bg-warinig + {{elseif $row->level == "NOTICE"}}bg-info + {{elseif $row->level == "DEBUG"}}text-muted + {{/if}} + ">{{$row->level}} | +{{$row->context}} | +{{$row->message}} | +