diff --git a/.gitignore b/.gitignore index 96fd026..d32ce77 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ - vendor/ - composer.lock +.phpunit.result.cache +*.code-workspace \ No newline at end of file diff --git a/composer.json b/composer.json index 5e4ae31..3a9122e 100644 --- a/composer.json +++ b/composer.json @@ -27,5 +27,22 @@ "issues": "https://github.com/schuhwerk/php-error-log-viewer/issues", "source": "https://github.com/schuhwerk/php-error-log-viewer" }, - "type": "development-tool" -} + "type": "development-tool", + "config": { + "sort-packages": true, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + }, + "require-dev": { + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^3", + "dealerdirect/phpcodesniffer-composer-installer": "^0.7" + }, + "scripts": { + "test": "phpunit", + "phpcs": [ + "vendor/bin/phpcs --standard=phpcs.xml --extensions=php" + ] + } +} \ No newline at end of file diff --git a/index.php b/index.php new file mode 100644 index 0000000..8542f7c --- /dev/null +++ b/index.php @@ -0,0 +1,28 @@ +handle_ajax_requests(); + +readfile('./src/error-log-viewer-frontend.html'); diff --git a/php-error-log-screenshot.png b/php-error-log-screenshot.png new file mode 100644 index 0000000..4cb4e3c Binary files /dev/null and b/php-error-log-screenshot.png differ diff --git a/php-error-log-viewer.php b/php-error-log-viewer.php deleted file mode 100644 index 8b2da70..0000000 --- a/php-error-log-viewer.php +++ /dev/null @@ -1,456 +0,0 @@ -handle_ajax_requests(); - -/** - * Read, process, delete log files. Output as json. - */ -class Pelv_Log_Handler { - - /** - * Contains grouped content of the log file. - * - * @var array - */ - public $content = array(); - - /** - * Index used for grouping same messages. - * Created via crc32(). - * - * @var int[] - */ - public $index = array(); - - /** - * The size of the file. - * - * @var int - */ - public $filesize = 0; - - /** - * The settings which are being applied. - * - * @var array - */ - public $settings = array(); - - /** - * The default setting - * - * @var array - */ - public $default_settings = array( - 'file_path' => 'debug.log', - 'vscode_links' => true, // Stack trace references files. link them to your repo (https://code.visualstudio.com/docs/editor/command-line#_opening-vs-code-with-urls). - 'vscode_path_search' => '', // This is needed if you develop on a vm. like '/srv/www/...'. - 'vscode_path_replace' => '', // The local path to your repo. like 'c:/users/...'. - ); - - public function __construct( $settings ) { - $this->settings = array_merge( $this->default_settings, $settings ); - } - - public function handle_ajax_requests() { - $this->ajax_handle_errors(); - if ( isset( $_GET['get_log'] ) ) { - $this->ajax_json_log(); - } - if ( isset( $_GET['delete_log'] ) ) { - $this->ajax_delete(); - } - if ( isset( $_GET['filesize'] ) ) { - $this->ajax_filesize(); - } - } - - public function ajax_handle_errors() { - $used = array_diff( array( 'get_log', 'delete_log', 'filesize' ), array_keys( $_GET ) ); - if ( count( $used ) === 3 ) { - return; - } - $log_file_valid = $this->is_file_valid(); - if ( ! $log_file_valid ) { - $this->ajax_header(); - echo $log_file_valid; - die(); - } - } - - /** - * Read the log-file. - * - * @return string|false The read string or false on failure. - */ - public function get_file() { - $my_file = fopen( $this->settings['file_path'], 'r' ); - $size = $this->get_size(); - return ( $my_file && $size ) ? fread( $my_file, $size ) : false; - } - - /** - * Get the size of the log-file. - * - * @return int|false The size of the log file in bytes or false. - */ - public function get_size() { - if ( empty( $this->filesize ) ) { - $this->filesize = filesize( $this->settings['file_path'] ); - } - return $this->filesize; - } - - /** - * Check if a file is valid. - * - * @return boolean|string true or error message. - */ - public function is_file_valid() { - if ( ! file_exists( $this->settings['file_path'] ) ) { - return 'The file you specified does not exist (' . $this->settings['file_path'] . ')'; - } - if ( 0 == $this->get_size() ) { - return 'Your log file is empty.'; - } - $mbs = $this->get_size() / 1024 / 1024; // in MB. - if ( $mbs > 100 ) { - if ( ! isset( $_GET['ignore'] ) ) { - return( "Aborting. debug.log is larger than 100 MB ($mbs). - If you want to continue anyway add the 'ignore' queryvar" - ); - } - } - return true; - } - - /** - * Triggers preg_replace_callback which calls - * replace_callback function which stores values in $this->content. - * - * @param string $raw The content of the log file. - * @return void - */ - public function parse( $raw ) { - $error = preg_replace_callback( '~^\[([^\]]*)\]((?:[^\r\n]*[\r\n]?(?!\[).*)*)~m', array( $this, 'replace_callback' ), $raw ); - } - - public function link_vscode_files( $string ) { - $string = preg_replace_callback( '$([A-Z]:)?([\\\/][^:(\s]+)(?: on line |[:\(])([0-9]+)\)?$', array( $this, 'vscode_link_filter' ), $string ); - return $string; - } - - public function vscode_link_filter( $matches ) { - $link = 'vscode://file/' . $matches[1] . $matches[2] . ':' . $matches[3]; - $root = isset( $_SERVER['HTTP_REFERER'] ) ? $_SERVER['HTTP_REFERER'] : $_SERVER['DOCUMENT_ROOT']; - $val = parse_url( $root, PHP_URL_QUERY ); - parse_str( $val, $get_array ); - $link = str_replace( $this->settings['vscode_path_search'], $this->settings['vscode_path_replace'], $link ); - return "" . $matches[0] . ''; - } - - /** - * Callback function which is triggered by preg_replace_callback. - * Doesn't return but writes to $this->content. - * - * @param array $arr - * looks like that: - * array ( - * 0 => [01-Jun-2016 09:24:02 UTC] PHP Fatal error: Allowed memory size of 456 bytes exhausted (tried to allocate 27 bytes) in ... - * 1 => [01-Jun-2016 09:24:02 UTC] - * 2 => PHP Fatal error: Allowed memory size of 56 bytes exhausted (tried to allocate 15627 bytes) in ... * - * ) - * @return void - */ - public function replace_callback( $arr ) { - $err_id = crc32( trim( $arr[2] ) ); // create a unique identifier for the error message. - if ( ! isset( $this->content[ $err_id ] ) ) { // we have a new error. - $this->content[ $err_id ] = array(); - $this->content[ $err_id ]['id'] = $err_id; // err_id. - $this->content[ $err_id ]['cnt'] = 1; // counter. - $this->index[] = $err_id; - } else { // we already have that error... - $this->content[ $err_id ]['cnt']++; // counter. - } - - $date = date_create( $arr[1] ); // false if no valid date. - $this->content[ $err_id ]['time'] = $date ? $date->format( DateTime::ATOM ) : $arr[1]; // ISO8601, readable in js - $message = htmlspecialchars( trim( $arr[2] ), ENT_QUOTES ); - $this->content[ $err_id ]['msg'] = $this->settings['vscode_links'] ? $this->link_vscode_files( $message ) : $message; - $this->content[ $err_id ]['cls'] = implode( - ' ', - array_slice( - str_word_count( $this->content[ $err_id ]['msg'], 2 ), - 1, - 2 - ) - ); // the first few words of the message become class items. - } - - public function delete() { - if ( ! file_exists( $this->settings['file_path'] ) ) { - return 'there was no file to delete'; - } - if ( ! is_writeable( realpath( $this->settings['file_path'] ) ) ) { - return 'debug.log is not writable'; - } - $f = @fopen( $this->settings['file_path'], 'r+' ); - if ( $f !== false ) { - ftruncate( $f, 0 ); - fclose( $f ); - return 'emptied file'; - } else { - return 'file could not be emptied'; - } - } - - public function ajax_header() { - header( 'Content-Type: application/json' ); - header( 'Cache-Control: no-store, no-cache, must-revalidate, max-age=0' ); - header( 'Cache-Control: post-check=0, pre-check=0', false ); - header( 'Pragma: no-cache' ); - } - - public function ajax_json_log() { - $this->ajax_header(); - $file = $this->get_file(); - if ( ! $file ) { - die( "File is empty or can't be opened." ); - } - $this->parse( $file ); // writes to $this->content. preg_replace_callback is odd. - echo( json_encode( array_values( $this->content ) ) ); - die(); - } - - public function ajax_delete() { - $this->ajax_header(); - echo $this->delete(); - die(); - } - - public function ajax_filesize() { - $this->ajax_header(); - echo json_encode( $this->get_size() ); - die(); - } -} -?> - - - - - - - - - - - - -
-
- -
- - {{ readableFilesize() }} - -

Debug.log {{ readableFilesize() }}

-
-
- - - -
- Autoreload - - delete - Empty file. This can not be undone. - -
-
- - - - - - - - - - {{ item.cnt }} - - - -
{{ item.msg }}
-
-
-
- - - - diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..cae7199 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,19 @@ + + + + + PHPCS configuration file. + + . + + vendor/ + + + + + + + + \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..06b4768 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,8 @@ + + + + + ./tests + + + \ No newline at end of file diff --git a/readme.md b/readme.md index c2877d1..a3d67ed 100644 --- a/readme.md +++ b/readme.md @@ -1,23 +1,27 @@ # PHP Error Log Viewer -A single-file GUI for your php log files which groups similar errors. Written in PHP and Vue.js. + +![Build Status](https://travis-ci.org/schuhwerk/php-error-log-viewer.svg?branch=master) + +A GUI for your php log files which groups similar errors. Written in PHP and Vue.js. - Reads the specified log file and automatically shows you new errors when they appear. - Groups errors with similar text. This is far from perfect and just works with the (default) log-format like: ```[12-Jun-2030 12:58:55] PHP Notice: ...``` - Can be configured so clicking on an error will directly bring you to the mentioned file and line in vscode ([more below](#linking)). + - Requires > PHP 7.4 ## Disclaimer - This contains code for deleting your log-file. - Might be heavy for you server for large log files (regexp-parsing). - It is meant for development-environments. - - Created for log-files in the format ``[31-Mar-2021 14:25:56 UTC] PHP Notice: ...`` + - Created for log-files in the format ``[31-Mar-2021 14:25:56 UTC] PHP Notice: ...`` (nginx logs are currently not supported). - There is still room for improvement (especially where the log-file is parsed). - It does not work offline, as we rely on [cdns](https://en.wikipedia.org/wiki/Content_delivery_network) to load dependencies like vue. ## Getting Started -Just copy the file next to your debug.log. +Just copy the folder next to your debug.log. or ```bash @@ -26,8 +30,8 @@ composer require-dev schuhwerk/php-error-log-viewer ## Usage -Open the file in your browser (like http://mydomain.local/php-error-log-viewer.php) -![Screenshot of the viewer interface](screenshot.png) +Open the folder in your browser (like http://mydomain.local/php-error-log-viewer) +![Screenshot of the viewer interface](php-error-log-screenshot.png) ## Settings @@ -53,3 +57,11 @@ This works for the following samples: - PHP Fatal error: Uncaught TypeError: ..., called in C:\foo\bar/themes/defaultspace/functions.php on line 605 - ... and defined in C:\foo\bar/themes/defaultspace/functions.php:63 +## Ideas +- This was a single-file gui. As it went bigger we separated files (to improve readability). There could be a build-step which brings things back to a single file (like [adminer](https://github.com/vrana/adminer) uses). +- Update to vue3 +- Use vuetify instead vue-material. +- Make offline useable. +- Find a way to keep stack-traces together (while sorting) + + diff --git a/screenshot.png b/screenshot.png deleted file mode 100644 index 2d9abbf..0000000 Binary files a/screenshot.png and /dev/null differ diff --git a/src/AjaxHandler.php b/src/AjaxHandler.php new file mode 100644 index 0000000..540948f --- /dev/null +++ b/src/AjaxHandler.php @@ -0,0 +1,78 @@ +log_handler = $log_handler; + } + + public function handle_ajax_requests() + { + $this->ajax_handle_errors(); + if (isset($_GET['get_log'])) { + $this->ajax_json_log(); + } + if (isset($_GET['delete_log'])) { + $this->ajax_delete(); + } + if (isset($_GET['filesize'])) { + $this->ajax_filesize(); + } + } + + public function ajax_handle_errors() + { + $used = array_diff(array( 'get_log', 'delete_log', 'filesize' ), array_keys($_GET)); + if (count($used) === 3) { + return; + } + $file_issues = $this->log_handler->get_file_issues(); + + if ( ! empty( $file_issues ) ) { + $this->ajax_header(); + echo $file_issues; + die(); + } + } + + public function ajax_header() + { + header('Content-Type: application/json'); + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); + header('Cache-Control: post-check=0, pre-check=0', false); + header('Pragma: no-cache'); + } + + public function ajax_json_log() + { + $this->ajax_header(); + $content = $this->log_handler->get_parsed_content(); + echo( json_encode(array_values($content)) ); + die(); + } + + public function ajax_delete() + { + $this->ajax_header(); + echo $this->log_handler->delete(); + die(); + } + + public function ajax_filesize() + { + $this->ajax_header(); + echo json_encode($this->log_handler->get_size()); + die(); + } +} diff --git a/src/LogHandler.php b/src/LogHandler.php new file mode 100644 index 0000000..9ca8480 --- /dev/null +++ b/src/LogHandler.php @@ -0,0 +1,201 @@ + '../debug.log', + 'vscode_links' => true, // Stack trace references files. link them to your repo (https://code.visualstudio.com/docs/editor/command-line#_opening-vs-code-with-urls). + 'vscode_path_search' => '', // This is needed if you develop on a vm. like '/srv/www/...'. + 'vscode_path_replace' => '', // The local path to your repo. like 'c:/users/...'. + ); + + public function __construct($settings) + { + $this->settings = array_merge($this->default_settings, $settings); + } + + /** + * Read the log-file. + * + * @return string|false The read string or false on failure. + */ + public function get_file() + { + $my_file = fopen($this->settings['file_path'], 'r'); + $size = $this->get_size(); + return ( $my_file && $size ) ? fread($my_file, $size) : false; + } + + /** + * Get the size of the log-file. + * + * @return int|false The size of the log file in bytes or false. + */ + public function get_size() + { + if (empty($this->filesize)) { + $this->filesize = filesize($this->settings['file_path']); + } + return $this->filesize; + } + + /** + * Get a description of any issue with the log-file. Empty string if no issue. + * + * @return string The description of the issue (or empty string). + */ + public function get_file_issues() + { + if (! file_exists($this->settings['file_path'])) { + return "The file ({$this->settings['file_path']}) was not found. " . + 'You can specify a different file/location in the settings (check readme.md).'; + } + if (0 == $this->get_size()) { + return 'Your log file is empty.'; + } + $mbs = $this->get_size() / 1024 / 1024; // in MB. + if ($mbs > 100) { + if (! isset($_GET['ignore'])) { + return( "Aborting. debug.log is larger than 100 MB ($mbs). + If you want to continue anyway add the 'ignore' queryvar" + ); + } + } + return ''; + } + + /** + * Triggers preg_replace_callback which calls + * replace_callback function which stores values in $this->content. + * + * @param string $raw The content of the log file. + * @return void + */ + private function parse($raw) + { + $error = preg_replace_callback('~^\[([^\]]*)\]((?:[^\r\n]*[\r\n]?(?!\[).*)*)~m', array( $this, 'replace_callback' ), $raw); + } + + public function get_parsed_content() + { + $file = $this->get_file(); + if (! $file) { + die("File is empty or can't be opened."); + } + $this->parse($file); // writes to $this->content. preg_replace_callback is odd. + return array_values($this->content); + } + + public function link_vscode_files($string) + { + $string = preg_replace_callback('$([A-Z]:)?([\\\/][^:(\s]+)(?: on line |[:\(])([0-9]+)\)?$', array( $this, 'vscode_link_filter' ), $string); + return $string; + } + + public function vscode_link_filter($matches) + { + $link = 'vscode://file/' . $matches[1] . $matches[2] . ':' . $matches[3]; + // $root = isset( $_SERVER['HTTP_REFERER'] ) ? $_SERVER['HTTP_REFERER'] : $_SERVER['DOCUMENT_ROOT']; + // $val = parse_url( $root, PHP_URL_QUERY ); + $link = str_replace($this->settings['vscode_path_search'], $this->settings['vscode_path_replace'], $link); + return "" . $matches[0] . ''; + } + + /** + * Callback function which is triggered by preg_replace_callback. + * Doesn't return but writes to $this->content. + * + * @param array $arr + * looks like that: + * array ( + * 0 => [01-Jun-2016 09:24:02 UTC] PHP Fatal error: Allowed memory size of 456 bytes exhausted (tried to allocate 27 bytes) in ... + * 1 => [01-Jun-2016 09:24:02 UTC] + * 2 => PHP Fatal error: Allowed memory size of 56 bytes exhausted (tried to allocate 15627 bytes) in ... * + * ) + * @return void + */ + public function replace_callback($arr) + { + $err_id = crc32(trim($arr[2])); // create a unique identifier for the error message. + if (! isset($this->content[ $err_id ])) { // we have a new error. + $this->content[ $err_id ] = array(); + $this->content[ $err_id ]['id'] = $err_id; // err_id. + $this->content[ $err_id ]['cnt'] = 1; // counter. + $this->index[] = $err_id; + } else { // we already have that error... + $this->content[ $err_id ]['cnt']++; // counter. + } + + $date = date_create($arr[1]); // false if no valid date. + $this->content[ $err_id ]['time'] = $date ? $date->format(\DateTime::ATOM) : $arr[1]; // ISO8601, readable in js + $message = htmlspecialchars(trim($arr[2]), ENT_QUOTES); + $this->content[ $err_id ]['msg'] = $this->settings['vscode_links'] ? $this->link_vscode_files($message) : $message; + $this->content[ $err_id ]['cls'] = implode( + ' ', + array_slice( + str_word_count($this->content[ $err_id ]['msg'], 2), + 1, + 2 + ) + ); // the first few words of the message become class items. + } + + public function delete() + { + if (! file_exists($this->settings['file_path'])) { + return 'There was no file to delete'; + } + if (! is_writeable(realpath($this->settings['file_path']))) { + return 'Your log file is not writable'; + } + $f = @fopen($this->settings['file_path'], 'r+'); + if ($f !== false) { + ftruncate($f, 0); + fclose($f); + return 'Emptied file'; + } else { + return 'File could not be emptied'; + } + } +} diff --git a/src/error-log-viewer-frontend.html b/src/error-log-viewer-frontend.html new file mode 100644 index 0000000..54fa979 --- /dev/null +++ b/src/error-log-viewer-frontend.html @@ -0,0 +1,261 @@ + + + + + + + + + + + + + + +
+
+ +
+ + {{ readableFilesize() }} + + + +

Log Viewer {{ readableFilesize() }} +

+
+
+ + + +
+ Autoreload + + delete + Empty file. This can not be undone. + +
+
+ + + + + + + + + + {{ item.cnt }} + + + + +
{{ item.msg }}
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/tests/Bootstrap.php b/tests/Bootstrap.php new file mode 100644 index 0000000..9e1ee2d --- /dev/null +++ b/tests/Bootstrap.php @@ -0,0 +1,2 @@ + dirname(__FILE__) . '/test-data/test-debug-1' )); + + $content = $log_handler->get_parsed_content(); + + // multiple tests in one, we could split them up for better readability. + $this->assertEquals($content[0], array ( + "id" => "2450744674", + "cnt" => "3", // grouping works + "time" => "2022-01-29T16:02:48+01:00", // timezone is parsed properly & most recent time is used + "msg" => "PHP Fatal error: require_once(): Failed opening required", + "cls" => "Fatal error", // proper class is assigned + )); + + // Make sure multiline strings (like print_r) are parsed properly. + $this->assertEquals(crc32($content[14]['msg']), 2230106746); + } +} diff --git a/tests/test-data/test-debug-1 b/tests/test-data/test-debug-1 new file mode 100644 index 0000000..f70f499 --- /dev/null +++ b/tests/test-data/test-debug-1 @@ -0,0 +1,29 @@ +[13-Jun-2021 10:30:08 Europe/Berlin] PHP Fatal error: require_once(): Failed opening required +[13-Jun-2021 10:30:08 Europe/Berlin] WARNING: ... +[13-Jun-2021 10:30:08 Europe/Berlin] WARNING: ... +[13-Jun-2021 10:30:08 Europe/Berlin] INFO: ... +[13-Jun-2021 10:30:10 Europe/Berlin] PHP Fatal error: require_once(): Failed opening required +[05-Dec-2021 15:12:04 Europe/Berlin] PHP Notice: Undefined index: version in /srv/www/spaces/current/web/app/plugins/s2/s2.php on line 4394 +[05-Dec-2021 15:12:04 Europe/Berlin] PHP Stack trace: +[05-Dec-2021 15:12:04 Europe/Berlin] PHP 1. {main}() /srv/www/spaces/current/web/index.php:0 +[05-Dec-2021 15:12:04 Europe/Berlin] PHP 2. require() /srv/www/spaces/current/web/index.php:5 +[05-Dec-2021 15:12:04 Europe/Berlin] PHP 3. require_once() /srv/www/spaces/current/web/wp/wp-blog-header.php:13 +[05-Dec-2021 15:12:04 Europe/Berlin] PHP 4. require_once() /srv/www/spaces/current/web/wp/wp-load.php:42 +[05-Dec-2021 15:12:04 Europe/Berlin] PHP 5. require_once() /srv/www/spaces/current/web/wp-config.php:24 +[05-Dec-2021 15:12:04 Europe/Berlin] PHP 6. do_action() /srv/www/spaces/current/web/wp/wp-settings.php:523 +[05-Dec-2021 15:12:04 Europe/Berlin] PHP 7. WP_Hook->do_action() /srv/www/spaces/current/web/wp/wp-includes/plugin.php:478 +[05-Dec-2021 15:12:04 Europe/Berlin] PHP 8. WP_Hook->apply_filters() /srv/www/spaces/current/web/wp/wp-includes/class-wp-hook.php:312 +[05-Dec-2021 15:12:04 Europe/Berlin] PHP 9. s2class->s2() /srv/www/spaces/current/web/wp/wp-includes/class-wp-hook.php:288 +[29-Jan-2022 16:02:47 Europe/Berlin] Array +( + [wpdb] => wpdb Object + ( + [show_errors] => + [suppress_errors] => + [last_error] => + [num_queries] => 37 + [num_rows] => 1 + ) + +) +[29-Jan-2022 16:02:48 Europe/Berlin] PHP Fatal error: require_once(): Failed opening required diff --git a/travis.yml b/travis.yml new file mode 100644 index 0000000..69925c5 --- /dev/null +++ b/travis.yml @@ -0,0 +1,15 @@ +language: php + +php: + - '7.4' + - '8.0' + +notifications: + email: false + +before_script: + - composer self-update + - composer update --prefer-source + +script: + - composer test \ No newline at end of file