praiadeseselle/wire/modules/Process/ProcessLogger/ProcessLogger.module

575 lines
16 KiB
Text

<?php namespace ProcessWire;
/**
* ProcessWire Logger (Logs Viewer)
*
* ProcessWire 3.x, Copyright 2023 by Ryan Cramer
* https://processwire.com
*
* @method string formatLogText($text, $logName = '')
*
*
*/
class ProcessLogger extends Process {
public static function getModuleInfo() {
return array(
'title' => __('Logs', __FILE__),
'summary' => __('View and manage system logs.', __FILE__),
'version' => 2,
'author' => 'Ryan Cramer',
'icon' => 'tree',
'permission' => 'logs-view',
'permissions' => array(
'logs-view' => 'Can view system logs',
'logs-edit' => 'Can manage system logs',
),
'page' => array(
'name' => 'logs',
'parent' => 'setup',
'title' => 'Logs',
),
'useNavJSON' => true,
);
}
public function __construct() {
require_once(dirname(__FILE__) . '/LogEntriesArray.php');
parent::__construct();
}
/**
* Provides output navigation logs list
*
* @param array $options
* @return string
*
*/
public function ___executeNavJSON(array $options = array()) {
$options['itemLabel'] = 'name';
$options['itemLabel2'] = 'when';
$options['add'] = false;
$options['edit'] = 'view/{name}/';
$options['items'] = $this->wire()->log->getLogs(true);
$options['sort'] = false;
foreach($options['items'] as $key => $item) {
$item['when'] = wireRelativeTimeStr($item['modified'], true, false);
if(time() - $item['modified'] > 86400) {
$item['icon'] = 'file-text-o';
} else {
$item['icon'] = 'file-text';
}
$options['items'][$key] = $item;
}
return parent::___executeNavJSON($options);
}
public function ___execute() {
/** @var MarkupAdminDataTable $table */
$table = $this->wire('modules')->get('MarkupAdminDataTable');
$table->setEncodeEntities(false);
$table->headerRow(array(
$this->_x('Name', 'th'),
$this->_x('Modified', 'th'),
$this->_x('Entries', 'th'),
$this->_x('Size', 'th'),
));
$logs = $this->wire()->log->getLogs();
foreach($logs as $log) {
$logName = $log['name'];
if(ctype_digit($logName)) $logName = " $logName";
$table->row(array(
$logName => "./view/$log[name]/",
"<span style='font-size: 0;'>$log[modified] </span>" . wireRelativeTimeStr($log['modified']),
$this->wire()->log->getTotalEntries($log['name']),
"<span style='font-size: 0;'>$log[size] </span>" . wireBytesStr($log['size'])
));
}
$cnt = count($logs);
$out =
"<h2><i class='fa fa-lg fa-fw fa-tree'></i> " . sprintf($this->_n('%d log', '%d logs', $cnt), $cnt) . "</h2>" .
$table->render() .
"<p>" .
"<span class='detail'>" . $this->_('Create or add to log file from the API:') . "</span><br />" .
"<code class='notes'>wire('log')->save('name', 'entry text');</code>" .
"</p>";
return $out;
}
protected function processAction($action, $name) {
$log = $this->wire()->log;
$input = $this->wire()->input;
$session = $this->wire()->session;
if(!$input->post("submit_$action")) {
throw new WireException("Action missing submit");
}
if($action != 'download' && !$this->wire()->user->hasPermission('logs-edit')) {
throw new WirePermissionException("You don't have permission to execute that action");
}
switch($action) {
case 'delete':
if($log->delete($name)) $this->message(sprintf($this->_('Deleted log: %s'), $name));
$session->location($this->wire()->page->url);
break;
case 'prune':
$days = (int) $input->post('prune_days');
$qty = $log->prune($name, $days);
$this->message(sprintf($this->_('Pruned "%s" log file (now contains %d entries)'), $name, $qty));
$session->location('./');
break;
case 'download':
$filename = $log->getFilename($name);
if(file_exists($filename)) wireSendFile($filename, array('forceDownload' => true));
break;
case 'add':
$text = $this->wire()->sanitizer->text($input->post('add_text'));
if(strlen($text)) {
$log->save($name, $text);
$this->message(sprintf($this->_('Saved new log entry to "%s"'), $name));
} else {
$this->error($this->_('Log entry text was blank'));
}
$session->location('./');
break;
}
}
public function ___executeView() {
$input = $this->wire()->input;
$session = $this->wire()->session;
$config = $this->wire()->config;
$modules = $this->wire()->modules;
$sanitizer = $this->wire()->sanitizer;
$log = $this->wire()->log;
$name = $input->urlSegment2;
if(!$name) $session->redirect('../');
$logs = $log->getLogs();
if(!isset($logs[$name])) {
$this->error(sprintf('Unknown log: %s', $name));
$session->location('../');
}
$action = $input->post('action');
if($action) $this->processAction($action, $name);
$limit = 100;
$options = array('limit' => $limit);
$q = $input->get('q');
if($q !== null && strlen($q)) {
$options['text'] = $sanitizer->text($q);
$input->whitelist('q', $options['text']);
}
$dateFrom = $input->get('date_from');
if($dateFrom !== null && strlen($dateFrom)) {
$options['dateFrom'] = ctype_digit("$dateFrom") ? (int) $dateFrom : strtotime("$dateFrom 00:00:00");
$input->whitelist('date_from', $options['dateFrom']);
}
$dateTo = $input->get('date_to');
if($dateTo !== null && strlen($dateTo)) {
$options['dateTo'] = ctype_digit("$dateTo") ? (int) $dateTo : strtotime("$dateTo 23:59:59");
$input->whitelist('date_to', $options['dateTo']);
}
$options['pageNum'] = (int) $input->pageNum;
do {
// since the total count the pagination is based on may not always be accurate (dups, etc.)
// we migrate to the last populated pagination when items turn up empty
$items = $log->getEntries($name, $options);
if(count($items)) break;
if($options['pageNum'] < 2) break;
$options['pageNum']--;
} while(1);
if($config->ajax) return $this->renderLogAjax($items, $name);
/** @var InputfieldForm $form */
$form = $modules->get('InputfieldForm');
/** @var InputfieldFieldset $fieldset */
$fieldset = $modules->get('InputfieldFieldset');
$fieldset->attr('id', 'FieldsetTools');
$fieldset->label = $this->_('Helpers');
$fieldset->collapsed = Inputfield::collapsedYes;
$fieldset->icon = 'sun-o';
$form->add($fieldset);
/** @var InputfieldText $f */
$f = $modules->get('InputfieldText');
$f->attr('name', 'q');
$f->label = $this->_('Text Search');
$f->icon = 'search';
$f->columnWidth = 50;
$fieldset->add($f);
/** @var InputfieldDatetime $f */
$f = $modules->get('InputfieldDatetime');
$f->attr('name', 'date_from');
$f->label = $this->_('Date From');
$f->icon = 'calendar';
$f->columnWidth = 25;
$f->datepicker = InputfieldDatetime::datepickerFocus;
$f->attr('placeholder', 'yyyy-mm-dd');
$fieldset->add($f);
/** @var InputfieldDatetime $f */
$f = $modules->get('InputfieldDatetime');
$f->attr('name', 'date_to');
$f->icon = 'calendar';
$f->label = $this->_('Date To');
$f->columnWidth = 25;
$f->attr('placeholder', 'yyyy-mm-dd');
$f->datepicker = InputfieldDatetime::datepickerFocus;
$fieldset->add($f);
/** @var InputfieldSelect $f */
$f = $modules->get('InputfieldSelect');
$f->attr('name', 'action');
$f->label = $this->_('Actions');
$f->description = $this->_('Select an action below. You will be asked to click a button before the action is executed.');
$f->icon = 'fire';
$f->collapsed = Inputfield::collapsedYes;
$f->addOption('download', $this->_('Download'));
$fieldset->add($f);
if($this->wire()->user->hasPermission('logs-edit')) {
$f->addOption('add', $this->_('Grow (Add Entry)'));
$f->addOption('prune', $this->_('Chop (Prune)'));
$f->addOption('delete', $this->_('Burn (Delete)'));
/** @var InputfieldInteger $f */
$f = $modules->get('InputfieldInteger');
$f->attr('name', 'prune_days');
$f->label = $this->_('Chop To # Days');
$f->inputType = 'number';
$f->min = 1;
$f->icon = 'cut';
$f->description = $this->_('Reduce the size of the log file to contain only entries from the last [n] days.');
$f->notes = $this->_('Must be 1 or greater.');
$f->value = 30;
$f->showIf = "action=prune";
$fieldset->add($f);
/** @var InputfieldText $f */
$f = $modules->get('InputfieldText');
$f->attr('name', 'add_text');
$f->label = $this->_('New Log Entry');
$f->icon = 'leaf';
$f->showIf = "action=add";
$fieldset->add($f);
/** @var InputfieldSubmit $f */
$f = $modules->get('InputfieldSubmit');
$f->value = $this->_('Chop this log file now');
$f->icon = 'cut';
$f->attr('name', 'submit_prune');
$f->showIf = 'action=prune';
$fieldset->add($f);
/** @var InputfieldSubmit $f */
$f = $modules->get('InputfieldSubmit');
$f->value = $this->_('Burn this log now (permanently delete)');
$f->icon = 'fire';
$f->attr('name', 'submit_delete');
$f->showIf = 'action=delete';
$fieldset->add($f);
/** @var InputfieldSubmit $f */
$f = $modules->get('InputfieldSubmit');
$f->value = $this->_('Add this log entry');
$f->icon = 'leaf';
$f->attr('name', 'submit_add');
$f->showIf = 'action=add';
$fieldset->add($f);
}
/** @var InputfieldSubmit $f */
$f = $modules->get('InputfieldSubmit');
$f->value = $this->_('Download this log file now');
$f->icon = 'download';
$f->attr('name', 'submit_download');
$f->showIf = 'action=download';
$fieldset->add($f);
$this->headline(ucfirst($name));
$this->breadcrumb('../../', $this->wire()->page->title);
return
$form->render() .
"<div id='ProcessLogEntries'>" .
$this->renderLog($items, $name) .
"</div>";
}
protected function renderLogAjax(array $items, $name) {
$input = $this->wire()->input;
$time = (int) $input->get('time');
$render = true;
$qtyNew = 0;
$note = '';
if($time) {
foreach($items as $entry) {
$entryTime = strtotime($entry['date']);
if($entryTime > $time) $qtyNew++;
}
if(!$qtyNew) $render = false;
}
if($qtyNew) {
$note = sprintf($this->_n('One new log entry on page 1', 'Multiple new log entries on page 1', $qtyNew), $qtyNew);
$note .= " (" . date('H:i:s') . ")";
}
$data = array(
'qty' => -1,
'qtyNew' => 0,
'out' => '',
'note' => $note,
'time' => time(),
'url' => $input->url() . '?' . $input->queryString()
);
if($render) {
$data = array_merge($data, array(
'qty' => count($items),
'qtyNew' => $qtyNew,
'out' => $this->renderLog($items, $name, $time),
));
} else {
// leave default data, which tells it not to render anything
}
header("Content-type: application/json;");
return json_encode($data);
}
protected function renderLog(array $items, $name, $time = 0) {
$sanitizer = $this->wire()->sanitizer;
/** @var MarkupAdminDataTable $table */
$table = $this->wire()->modules->get('MarkupAdminDataTable');
$table->setSortable(false);
$table->setEncodeEntities(false);
$templateItem = reset($items);
$headers = array(
'date' => $this->_x('Date/Time', 'th'),
'user' => $this->_x('User', 'th'),
'url' => $this->_x('URL', 'th'),
'text' => $this->_x('Text', 'th'),
);
if(empty($templateItem['user']) && empty($templateItem['url'])) {
unset($templateItem['user'], $templateItem['url']);
$table->headerRow(array(
$headers['date'],
$headers['text'],
));
} else {
$table->headerRow($headers);
}
foreach($items as $entry) {
$ts = strtotime($entry['date']);
$date = wireRelativeTimeStr($entry['date']);
if($time && $ts > $time) {
// highlight new items
$date = "<i class='fa fa-leaf ProcessLogNew'></i> $date";
}
if(strpos($entry['text'], '&') !== false) {
$entry['text'] = $sanitizer->unentities($entry['text']);
}
foreach($entry as $key => $value) {
$entry[$key] = $sanitizer->entities($value);
}
$row = array("$date<br /><span class='detail'>$entry[date]</span>");
if(count($templateItem) >= 4) {
$row[] = $entry['user'];
$entry['url'] = preg_replace('{^https?://[^/]+}', '', $entry['url']);
$url = $entry['url'];
if($url == '/?/') {
$url = 2; // array key
$entry['url'] = '?';
}
$urlLabel = $this->formatLogUrlLabel($entry['url']);
$row[$urlLabel] = $url;
}
$row[] = $this->formatLogText($entry['text'], $name);
$table->row($row);
}
/** @var LogEntriesArray $entries */
$entries = $this->wire(new LogEntriesArray());
if(count($items)) {
reset($items);
$key = key($items);
list($n, $total, $start, $end, $limit) = explode('/', $key);
if($n && $end) {} // ignore
$entries->import($items);
$entries->setLimit($limit);
$entries->setStart($start);
$entries->setTotal($total);
/** @var MarkupPagerNav $pager */
$pager = $this->wire()->modules->get('MarkupPagerNav');
$options = array('baseUrl' => "../$name/");
$pagerOut = $pager->render($entries, $options);
$pagerHeadline = $entries->getPaginationString();
$pagerHeadline .= " " .
"<small class='ui-priority-secondary'>(" .
($pager->isLastPage() ? $this->_('actual') : $this->_('estimate')) .
")</small>";
$iconClass = '';
} else {
$pagerHeadline = $this->_('No matching log entries');
$iconClass = 'fa-rotate-270';
$pagerOut = '';
}
$pageNum = $this->wire('input')->pageNum();
$time = time();
$out =
"<div id='ProcessLogPage' data-page='$pageNum' data-time='$time'>" .
$pagerOut .
"<h2 id='ProcessLogHeadline'>" .
"<i id='ProcessLogSpinner' class='fa fa-fw fa-lg fa-tree $iconClass'></i> $pagerHeadline " .
"<small class='notes'></small></h2>" .
$table->render() .
"<div class='ui-helper-clearfix'>$pagerOut</div>" .
"</div>";
return $out;
}
/**
* Format log URL label
*
* @param string $url
* @return string
*
*/
protected function formatLogUrlLabel($url) {
if($url === '?') return $url;
if(strpos($url, '://') !== false) {
$url = preg_replace('{^https?://[^/]+}', '', $url);
}
$config = $this->wire()->config;
$rootUrl = $config->urls->root;
$adminUrl = $config->urls->admin;
$isAdmin = false;
if(strpos($url, $adminUrl) === 0) {
$isAdmin = true;
$url = substr($url, strlen($adminUrl));
} else if($rootUrl !== '/' && strpos($url, $rootUrl) === 0) {
$url = substr($url, strlen($rootUrl)-1);
}
if($isAdmin && strpos($url, 'page/edit/') !== false && preg_match('/[?&]id=(\d+)/', $url, $matches)) {
$url = 'page/edit/?id=' . $matches[1];
} else if($url === '/http404/') {
$url = $this->_('404 not found');
}
if(strlen($url) > 50) {
$url = substr($url, 0, 50) . '&hellip;';
}
return $url;
}
/**
* Format log line txt
*
* @param string $text
* @param string $logName
* @return string
*
*/
protected function ___formatLogText($text, $logName = '') {
$config = $this->wire()->config;
// shorten paths
foreach(array('site', 'wire') as $name) {
if(strpos($text, "/$name/") === false) continue;
$path = $config->paths($name);
if(strpos($text, $path) !== false) {
$text = str_replace($path, "/$name/", $text);
} else {
// $text = preg_replace('![-_/\\:a-zA-Z0-9]+/' . $name . '/!', "/$name/", $text);
}
}
// shorten assumed namespaces
if(strpos($text, 'ProcessWire\\') !== false) {
$text = str_replace('ProcessWire\\', '', $text);
}
// formatting of stack traces in errors/exceptions logs
if($logName === 'errors' || $logName === 'exceptions') {
if(strpos($text, '(line ') && preg_match('/\((line \d+ of [^)]+)\)/', $text, $matches)) {
$text = str_replace($matches[0], "<br /><span class='notes'>" . ucfirst($matches[1]) . "</span>", $text);
} else if(strpos($text, '(in ') && preg_match('!\((in /[^)]+? line \d+)\)!', $text, $matches)) {
$text = str_replace($matches[0], "<br /><span class='notes'>" . ucfirst($matches[1]) . "</span>", $text);
}
if(strpos($text, ' #0 /')) {
list($text, $traces) = explode(' #0 /', $text, 2);
$traces = preg_split('! #\d+ /!', $traces);
$text .= "<span class='detail'>";
foreach($traces as $key => $trace) {
$n = $key + 1;
$text .= "<br />$n. /$trace";
}
$text .= "</span>";
}
}
// identify recurring instances
if(strpos($text, ' ^+')) {
$_text = $text;
list($text, $qty) = explode(' ^+', $text, 2);
if(ctype_digit($qty)) {
$text .= "<br />" .
"<span class='detail'>" .
sprintf($this->_n('Plus %d earlier duplicate ', 'Plus %d earlier duplicates', $qty), $qty) .
"</span>";
} else {
// oops, restore
$text = $_text;
}
}
return $text;
}
}