Subversion Repositories web.creative

Rev

Blame | Last modification | View Log | Download

<?php namespace ProcessWire;

/**
 * ProcessWire Logger (Logs Viewer)
 *
 * ProcessWire 3.x, Copyright 2019 by Ryan Cramer
 * https://processwire.com
 * 
 *
 */

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) {
      $table->row(array(
        $log['name'] => "./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) {
    
    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() {
    
    $name = $this->wire('input')->urlSegment2;
    if(!$name) $this->wire('session')->redirect('../');
    $logs = $this->wire('log')->getLogs();
    if(!isset($logs[$name])) {
      $this->error(sprintf('Unknown log: %s', $name));
      $this->wire('session')->redirect('../');
    }
    $action = $this->wire('input')->post('action'); 
    if($action) $this->processAction($action, $name);
    $limit = 100;
    $options = array('limit' => $limit); 
    
    $q = $this->wire('input')->get('q');
    if(strlen($q)) {
      $options['text'] = $this->wire('sanitizer')->text($q);
      $this->wire('input')->whitelist('q', $options['text']); 
    }
    
    $dateFrom = $this->wire('input')->get('date_from');
    if(strlen($dateFrom)) {
      $options['dateFrom'] = ctype_digit("$dateFrom") ? (int) $dateFrom : strtotime("$dateFrom 00:00:00");
      $this->wire('input')->whitelist('date_from', $options['dateFrom']); 
    }
    
    $dateTo = $this->wire('input')->get('date_to');
    if(strlen($dateTo)) {
      $options['dateTo'] = ctype_digit("$dateTo") ? (int) $dateTo : strtotime("$dateTo 23:59:59");
      $this->wire('input')->whitelist('date_to', $options['dateTo']); 
    }

    $options['pageNum'] = $this->wire('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 = $this->wire('log')->getEntries($name, $options);
      if(count($items)) break;
      if($options['pageNum'] < 2) break;
      $options['pageNum']--;
    } while(1);
    
    if($this->wire('config')->ajax) return $this->renderLogAjax($items, $name); 
  
    /** @var InputfieldForm $form */
    $form = $this->wire('modules')->get('InputfieldForm');
    
    /** @var InputfieldFieldset $fieldset */
    $fieldset = $this->wire('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 = $this->wire('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 = $this->wire('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 = $this->wire('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 = $this->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 = $this->wire('modules')->get('InputfieldInteger');
      $f->attr('name', 'prune_days');
      $f->label = $this->_('Chop To # Days');
      $f->icon = 'cut';
      $f->description = $this->_('Reduce the size of the log file to contain only entries from the last [n] days.');
      $f->value = 30;
      $f->showIf = "action=prune";
      $fieldset->add($f);

      /** @var InputfieldText $f */
      $f = $this->wire('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 = $this->wire('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 = $this->wire('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 = $this->wire('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);
    }
    
    $f = $this->wire('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); 
  
    $out = $form->render() . 
      "<div id='ProcessLogEntries'>" . 
      $this->renderLog($items, $name) . 
      "</div>";
    return $out; 
  }
  
  protected function renderLogAjax(array $items, $name) {
    $time = (int) $this->wire('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' => $this->wire('input')->url() . '?' . $this->wire('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 = "<i class='fa fa-leaf ProcessLogNew'></i> $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<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);
    }

    $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;
  }

}