__('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) { $table->row(array( $log['name'] => "./view/$log[name]/", "$log[modified] " . wireRelativeTimeStr($log['modified']), $this->wire('log')->getTotalEntries($log['name']), "$log[size] " . wireBytesStr($log['size']) )); } $cnt = count($logs); $out = "

" . sprintf($this->_n('%d log', '%d logs', $cnt), $cnt) . "

" . $table->render() . "

" . "" . $this->_('Create or add to log file from the API:') . "
" . "wire('log')->save('name', 'entry text');" . "

"; return $out; } protected function processAction($action, $name) { if(!$this->wire('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($this->wire('log')->delete($name)) $this->message(sprintf($this->_('Deleted log: %s'), $name)); $this->wire('session')->redirect($this->wire('page')->url); break; case 'prune': $days = (int) $this->wire('input')->post('prune_days'); $qty = $this->wire('log')->prune($name, $days); $this->message(sprintf($this->_('Pruned "%s" log file (now contains %d entries)'), $name, $qty)); $this->wire('session')->redirect('./'); break; case 'download': $filename = $this->wire('log')->getFilename($name); if(file_exists($filename)) wireSendFile($filename, array('forceDownload' => true)); break; case 'add': $text = $this->wire('sanitizer')->text($this->wire('input')->post('add_text')); if(strlen($text)) { $this->wire('log')->save($name, $text); $this->message(sprintf($this->_('Saved new log entry to "%s"'), $name)); } else { $this->error($this->_('Log entry text was blank')); } $this->wire('session')->redirect('./'); 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->redirect('../'); } $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); $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); $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() . "
" . $this->renderLog($items, $name) . "
"; } 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) { /** @var Sanitizer $sanitizer */ $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 = " $date"; } if(strpos($entry['text'], '&') !== false) { $entry['text'] = $this->wire('sanitizer')->unentities($entry['text']); } foreach($entry as $key => $value) { $entry[$key] = $sanitizer->entities($value); } $row = array("$date
$entry[date]"); 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); } $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 .= " " . "(" . ($pager->isLastPage() ? $this->_('actual') : $this->_('estimate')) . ")"; $iconClass = ''; } else { $pagerHeadline = $this->_('No matching log entries'); $iconClass = 'fa-rotate-270'; $pagerOut = ''; } $pageNum = $this->wire('input')->pageNum(); $time = time(); $out = "
" . $pagerOut . "

" . " $pagerHeadline " . "

" . $table->render() . "
$pagerOut
" . "
"; 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) . '…'; } 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], "
" . ucfirst($matches[1]) . "", $text); } else if(strpos($text, '(in ') && preg_match('!\((in /[^)]+? line \d+)\)!', $text, $matches)) { $text = str_replace($matches[0], "
" . ucfirst($matches[1]) . "", $text); } if(strpos($text, ' #0 /')) { list($text, $traces) = explode(' #0 /', $text, 2); $traces = preg_split('! #\d+ /!', $traces); $text .= ""; foreach($traces as $key => $trace) { $n = $key + 1; $text .= "
$n. /$trace"; } $text .= "
"; } } // identify recurring instances if(strpos($text, ' ^+')) { $_text = $text; list($text, $qty) = explode(' ^+', $text, 2); if(ctype_digit($qty)) { $text .= "
" . "" . sprintf($this->_n('Plus %d earlier duplicate ', 'Plus %d earlier duplicates', $qty), $qty) . ""; } else { // oops, restore $text = $_text; } } return $text; } }