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

{{$title}} - {{$page}}

- -

{{$logname}}

-
{{$data}}
+ +

{{$logname}}

+ {{if $error }} +
+

{{$error nofilter}}

+
+ {{else}} +
+

+ + + clear +

+ + + + + + + + + + + + + {{foreach $data as $row}} + + + + + + + + {{foreach $row->getData() as $k=>$v}} + + + + + {{/foreach}} + + {{foreach $row->getSource() as $k=>$v}} + + + + + {{/foreach}} + {{/foreach}} + +
Date + + + + Message
+
+ {{/if}}
diff --git a/view/theme/frio/css/style.css b/view/theme/frio/css/style.css index ba15534d5..bcf1557ae 100644 --- a/view/theme/frio/css/style.css +++ b/view/theme/frio/css/style.css @@ -84,6 +84,27 @@ blockquote { overflow: hidden !important; } +/** + * details tag + */ +details { + padding: .5em .5em 0; +} +details details { + padding-left: .5em; +} +details summary { + font-weight: bold; + display: list-item; +} + +/** + * clickable table rows + */ +.table > tbody > td[role="button"] { + cursor: pointer; +} + /** * mobile aside */ diff --git a/view/theme/frio/js/module/admin/logs/view.js b/view/theme/frio/js/module/admin/logs/view.js new file mode 100644 index 000000000..245518822 --- /dev/null +++ b/view/theme/frio/js/module/admin/logs/view.js @@ -0,0 +1,98 @@ +$(function(){ + + /* column filter */ + $("a[data-filter]").on("click", function(ev) { + var filter = this.dataset.filter; + var value = this.dataset.filterValue; + var re = RegExp(filter+"=[a-z]*"); + var newhref = location.href; + if (!location.href.indexOf("?") < 0) { + newhref = location.href + "?" + filter + "=" + value; + } else if (location.href.match(re)) { + newhref = location.href.replace(RegExp(filter+"=[a-z]*"), filter+"="+value); + } else { + newhref = location.href + "&" + filter + "=" + value; + } + location.href = newhref; + return false; + }); + + /* log details dialog */ + $(".log-event").on("click", function(ev) { + show_details_for_element(ev.currentTarget); + }); + $(".log-event").on("keydown", function(ev) { + if (ev.keyCode == 13 || ev.keyCode == 32) { + show_details_for_element(ev.currentTarget); + } + }); + + + $("[data-previous").on("click", function(ev){ + var currentid = document.getElementById("logdetail").dataset.rowId; + var $elm = $("#" + currentid).prev(); + if ($elm.length == 0) return; + show_details_for_element($elm[0]); + }); + + $("[data-next").on("click", function(ev){ + var currentid = document.getElementById("logdetail").dataset.rowId; + var $elm = $("#" + currentid).next(); + if ($elm.length == 0) return; + show_details_for_element($elm[0]); + }); + + + const $modal = $("#logdetail"); + + $modal.on("hidden.bs.modal", function(ev){ + document + .querySelectorAll('[aria-expanded="true"]') + .forEach(elm => elm.setAttribute("aria-expanded", false)) + }); + + function show_details_for_element(element) { + $modal[0].dataset.rowId = element.id; + + var tr = $modal.find(".main-data tbody tr")[0]; + tr.innerHTML = element.innerHTML; + + var data = JSON.parse(element.dataset.source); + $modal.find(".source-data td").each(function(i,elm){ + var k = elm.dataset.value; + elm.innerText = data[k]; + }); + + var elm = $modal.find(".event-data")[0]; + elm.innerHTML = ""; + var data = element.dataset.data; + if (data !== "") { + elm.innerHTML = "

Data

"; + data = JSON.parse(data); + elm.innerHTML += recursive_details("", data); + } + + $("[data-previous").prop("disabled", $(element).prev().length == 0); + $("[data-next").prop("disabled", $(element).next().length == 0); + + $modal.modal({}) + element.setAttribute("aria-expanded", true); + } + + function recursive_details(s, data, lev=0) { + for(var k in data) { + if (data.hasOwnProperty(k)) { + var v = data[k]; + var open = lev > 1 ? "" : "open"; + s += "
" + k + ""; + if (typeof v === 'object' && v !== null) { + s = recursive_details(s, v, lev+1); + } else { + s += $("
").text(v)[0].outerHTML;
+				}
+				s += "
"; + } + } + return s; + } +}); diff --git a/view/theme/frio/templates/admin/logs/view.tpl b/view/theme/frio/templates/admin/logs/view.tpl new file mode 100755 index 000000000..1e1123fbd --- /dev/null +++ b/view/theme/frio/templates/admin/logs/view.tpl @@ -0,0 +1,138 @@ +
+

{{$title}} - {{$page}}

+ +

{{$logname}}

+ {{if $error }} +
+

{{$error nofilter}}

+
+ {{else}} +
+
+ +
+
+ Show all +
+
+ + + + + + + + + + + + {{foreach $data as $row}} + + + + + + + {{/foreach}} + +
Date + + + + + + Message
+ {{/if}} +
+ +