Logging: Implement "search within log files" (#41353)

* Normalize render method names

* Refactor get_query_params to allow key filtering

* Scaffold the search results view

* Add missing unslash

* First pass at functional search

* Fix memory leak and recursive highlighting

* Fix various search string edge cases

* Move match highlighting to format_match method

* Tweak match line formatting

* Rename ListTable to FileListTable

* Switch search results view to a list table

* Add notice about max files for search

* Remove unused function

* Only use monospace font on the matched line part of search results

* Add notice about search result limit

* Fix font in table header

* phpcs cleanup

* Remove unnecessary search form action

* Add caching to search results

* Add unit test for search method

* Caching improvements

* phpcs cleanup

* Add unit test for close_stream

* Remove unneeded linting exception

* Add changelog file

* Remove unnecessary usage of get_class()

* Make sure file stream gets closed when we break the loop early

* Make the returned results an even 200 when hitting the limit
This commit is contained in:
Corey McKrill 2023-11-29 06:52:37 -08:00 committed by GitHub
parent e684ae348b
commit 9a947c1a06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 696 additions and 66 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Adds a search field to the log files list table screen that will do a partial match string search on the contents of all the files currently shown in the list table

View File

@ -1351,9 +1351,35 @@ table.wc_status_table--tools {
.wc-logs-single-file-actions {
margin-left: auto;
display: flex;
flex-flow: row wrap;
gap: 0.5em;
}
.wc-logs-search {
margin-left: auto;
display: flex;
flex-flow: column;
}
.wc-logs-search-fieldset {
display: flex;
gap: 0.5em;
justify-content: flex-end;
}
.wc-logs-search-notice {
font-size: 0.9em;
line-height: 2;
text-align: right;
visibility: hidden;
height: 0;
.wc-logs-search:focus-within & {
visibility: visible;
height: auto;
}
}
.wc-logs-entries {
background: #f6f7f7;
border: 1px solid #c3c4c7;
@ -1418,6 +1444,44 @@ table.wc_status_table--tools {
}
}
.wc-logs-search-results {
tbody {
word-wrap: break-word;
.column-file_id {
line-height: 2;
}
.column-line_number,
.column-line {
line-height: 2.3;
}
.column-line {
font-family: Consolas, Monaco, monospace;
}
}
td {
vertical-align: middle;
}
.column-file_id {
width: 16%;
}
.column-line_number {
width: 8%;
}
.search-match {
background: #fff8c5;
padding: 0.46em 0;
border: 1px dashed #c3c4c7;
line-height: 2.3;
}
}
/**
* Log severity level colors.
*

View File

@ -12,7 +12,7 @@ use Automattic\WooCommerce\Internal\Admin\Marketplace;
use Automattic\WooCommerce\Internal\Admin\Orders\COTRedirectionController;
use Automattic\WooCommerce\Internal\Admin\Orders\PageController as Custom_Orders_PageController;
use Automattic\WooCommerce\Internal\Admin\Logging\PageController as LoggingPageController;
use Automattic\WooCommerce\Internal\Admin\Logging\FileV2\ListTable as LoggingListTable;
use Automattic\WooCommerce\Internal\Admin\Logging\FileV2\{ FileListTable, SearchListTable };
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
@ -324,7 +324,8 @@ class WC_Admin_Menus {
$screen_options = array(
'woocommerce_keys_per_page',
'woocommerce_webhooks_per_page',
LoggingListTable::PER_PAGE_USER_OPTION_KEY,
FileListTable::PER_PAGE_USER_OPTION_KEY,
SearchListTable::PER_PAGE_USER_OPTION_KEY,
);
if ( in_array( $option, $screen_options, true ) ) {

View File

@ -153,7 +153,7 @@ class File {
}
/**
* Open a read-only stream file this file.
* Open a read-only stream for this file.
*
* @return resource|false
*/
@ -166,6 +166,19 @@ class File {
return $this->stream;
}
/**
* Close the stream for this file.
*
* The stream will also close automatically when the class instance destructs, but this can be useful for
* avoiding having a large number of streams open simultaneously.
*
* @return bool
*/
public function close_stream(): bool {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fclose -- No suitable alternative.
return fclose( $this->stream );
}
/**
* Get the name of the file, with extension, but without full path.
*

View File

@ -4,6 +4,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\Admin\Logging\FileV2;
use Automattic\Jetpack\Constants;
use WC_Cache_Helper;
use WP_Error;
/**
@ -23,6 +24,44 @@ class FileController {
'source' => '',
);
/**
* Default values for arguments for the search_within_files method.
*
* @const array
*/
public const DEFAULTS_SEARCH_WITHIN_FILES = array(
'offset' => 0,
'per_page' => 50,
);
/**
* The maximum number of files that can be searched at one time.
*
* @const int
*/
public const SEARCH_MAX_FILES = 100;
/**
* The maximum number of search results that can be returned at one time.
*
* @const int
*/
public const SEARCH_MAX_RESULTS = 200;
/**
* The cache group name to use for caching operations.
*
* @const string
*/
private const CACHE_GROUP = 'log-files';
/**
* A cache key for storing and retrieving the results of the last logs search.
*
* @const string
*/
private const SEARCH_CACHE_KEY = 'logs_previous_search';
/**
* The absolute path to the log directory.
*
@ -299,9 +338,114 @@ class FileController {
}
}
if ( $deleted > 0 ) {
$this->invalidate_cache();
}
return $deleted;
}
/**
* Search within a set of log files for a particular string.
*
* @param string $search The string to search for.
* @param array $args Optional. Arguments for pagination of search results.
* @param array $file_args Optional. Arguments to filter and sort the files that are returned. See get_files().
* @param bool $count_only Optional. True to return a total count of the matches.
*
* @return array|int|WP_Error When matches are found, each array item is an associative array that includes the
* file ID, line number, and the matched string with HTML markup around the matched parts.
*/
public function search_within_files( string $search, array $args = array(), array $file_args = array(), bool $count_only = false ) {
if ( '' === $search ) {
return array();
}
$search = esc_html( $search );
$args = wp_parse_args( $args, self::DEFAULTS_SEARCH_WITHIN_FILES );
$file_args = array_merge(
$file_args,
array(
'offset' => 0,
'per_page' => self::SEARCH_MAX_FILES,
)
);
$cache_key = WC_Cache_Helper::get_prefixed_key( self::SEARCH_CACHE_KEY, self::CACHE_GROUP );
$query = wp_json_encode( array( $search, $args, $file_args ) );
$cache = wp_cache_get( $cache_key );
$is_cached = isset( $cache['query'], $cache['results'] ) && $query === $cache['query'];
if ( true === $is_cached ) {
$matched_lines = $cache['results'];
} else {
$files = $this->get_files( $file_args );
if ( is_wp_error( $files ) ) {
return $files;
}
// Max string size * SEARCH_MAX_RESULTS = ~1MB largest possible cache entry.
$max_string_size = 5 * KB_IN_BYTES;
$matched_lines = array();
foreach ( $files as $file ) {
$stream = $file->get_stream();
$line_number = 1;
while ( ! feof( $stream ) ) {
$line = fgets( $stream, $max_string_size );
if ( ! is_string( $line ) ) {
continue;
}
$sanitized_line = esc_html( trim( $line ) );
if ( false !== stripos( $sanitized_line, $search ) ) {
$matched_lines[] = array(
'file_id' => $file->get_file_id(),
'line_number' => $line_number,
'line' => $sanitized_line,
);
}
if ( count( $matched_lines ) >= self::SEARCH_MAX_RESULTS ) {
$file->close_stream();
break 2;
}
if ( false !== strstr( $line, PHP_EOL ) ) {
$line_number ++;
}
}
$file->close_stream();
}
$to_cache = array(
'query' => $query,
'results' => $matched_lines,
);
wp_cache_set( $cache_key, $to_cache, self::CACHE_GROUP, DAY_IN_SECONDS );
}
if ( true === $count_only ) {
return count( $matched_lines );
}
return array_slice( $matched_lines, $args['offset'], $args['per_page'] );
}
/**
* Invalidate the cache group related to log file data.
*
* @return bool True on successfully invalidating the cache.
*/
public function invalidate_cache(): bool {
return WC_Cache_Helper::invalidate_cache_group( self::CACHE_GROUP );
}
/**
* Sanitize the source property of a log file.
*

View File

@ -8,9 +8,9 @@ use Automattic\WooCommerce\Internal\Admin\Logging\PageController;
use WP_List_Table;
/**
* ListTable class.
* FileListTable class.
*/
class ListTable extends WP_List_Table {
class FileListTable extends WP_List_Table {
/**
* The user option key for saving the preferred number of files displayed per page.
*
@ -33,7 +33,7 @@ class ListTable extends WP_List_Table {
private $page_controller;
/**
* ListTable class.
* FileListTable class.
*
* @param FileController $file_controller Instance of FileController.
* @param PageController $page_controller Instance of PageController.
@ -150,14 +150,17 @@ class ListTable extends WP_List_Table {
public function prepare_items(): void {
$per_page = $this->get_items_per_page(
self::PER_PAGE_USER_OPTION_KEY,
$this->file_controller::DEFAULTS_GET_FILES['per_page']
$this->get_per_page_default()
);
$defaults = array(
'per_page' => $per_page,
'offset' => ( $this->get_pagenum() - 1 ) * $per_page,
);
$file_args = wp_parse_args( $this->page_controller->get_query_params(), $defaults );
$file_args = wp_parse_args(
$this->page_controller->get_query_params( array( 'order', 'orderby', 'source' ) ),
$defaults
);
$total_items = $this->file_controller->get_files( $file_args, true );
if ( is_wp_error( $total_items ) ) {
@ -318,4 +321,13 @@ class ListTable extends WP_List_Table {
return size_format( $size );
}
/**
* Helper to get the default value for the per_page arg.
*
* @return int
*/
public function get_per_page_default(): int {
return $this->file_controller::DEFAULTS_GET_FILES['per_page'];
}
}

View File

@ -0,0 +1,227 @@
<?php
declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\Admin\Logging\FileV2;
use Automattic\WooCommerce\Internal\Admin\Logging\PageController;
use WP_List_Table;
/**
* SearchListTable class.
*/
class SearchListTable extends WP_List_Table {
/**
* The user option key for saving the preferred number of search results displayed per page.
*
* @const string
*/
public const PER_PAGE_USER_OPTION_KEY = 'woocommerce_logging_search_results_per_page';
/**
* Instance of FileController.
*
* @var FileController
*/
private $file_controller;
/**
* Instance of PageController.
*
* @var PageController
*/
private $page_controller;
/**
* SearchListTable class.
*
* @param FileController $file_controller Instance of FileController.
* @param PageController $page_controller Instance of PageController.
*/
public function __construct( FileController $file_controller, PageController $page_controller ) {
$this->file_controller = $file_controller;
$this->page_controller = $page_controller;
parent::__construct(
array(
'singular' => 'wc-logs-search-result',
'plural' => 'wc-logs-search-results',
'ajax' => false,
)
);
}
/**
* Render message when there are no items.
*
* @return void
*/
public function no_items(): void {
esc_html_e( 'No search results.', 'woocommerce' );
}
/**
* Set up the column header info.
*
* @return void
*/
public function prepare_column_headers(): void {
$this->_column_headers = array(
$this->get_columns(),
array(),
array(),
$this->get_primary_column(),
);
}
/**
* Prepares the list of items for displaying.
*
* @return void
*/
public function prepare_items(): void {
$per_page = $this->get_items_per_page(
self::PER_PAGE_USER_OPTION_KEY,
$this->get_per_page_default()
);
$args = array(
'per_page' => $per_page,
'offset' => ( $this->get_pagenum() - 1 ) * $per_page,
);
$file_args = $this->page_controller->get_query_params( array( 'order', 'orderby', 'search', 'source' ) );
$search = $file_args['search'];
unset( $file_args['search'] );
$total_items = $this->file_controller->search_within_files( $search, $args, $file_args, true );
if ( is_wp_error( $total_items ) ) {
printf(
'<div class="notice notice-warning"><p>%s</p></div>',
esc_html( $total_items->get_error_message() )
);
return;
}
if ( $total_items >= $this->file_controller::SEARCH_MAX_RESULTS ) {
printf(
'<div class="notice notice-info"><p>%s</p></div>',
sprintf(
// translators: %s is a number.
esc_html__( 'The number of search results has reached the limit of %s. Try refining your search.', 'woocommerce' ),
esc_html( number_format_i18n( $this->file_controller::SEARCH_MAX_RESULTS ) )
)
);
}
$total_pages = ceil( $total_items / $per_page );
$results = $this->file_controller->search_within_files( $search, $args, $file_args );
$this->items = $results;
$this->set_pagination_args(
array(
'per_page' => $per_page,
'total_items' => $total_items,
'total_pages' => $total_pages,
)
);
}
/**
* Gets a list of columns.
*
* @return array
*/
public function get_columns(): array {
$columns = array(
'file_id' => esc_html__( 'File', 'woocommerce' ),
'line_number' => esc_html__( 'Line #', 'woocommerce' ),
'line' => esc_html__( 'Matched Line', 'woocommerce' ),
);
return $columns;
}
/**
* Render the file_id column.
*
* @param array $item The current search result being rendered.
*
* @return string
*/
public function column_file_id( array $item ): string {
// Add a word break after the rotation number, if it exists.
$file_id = preg_replace( '/\.([0-9])+\-/', '.\1<wbr>-', $item['file_id'] );
return wp_kses( $file_id, array( 'wbr' => array() ) );
}
/**
* Render the line_number column.
*
* @param array $item The current search result being rendered.
*
* @return string
*/
public function column_line_number( array $item ): string {
$match_url = add_query_arg(
array(
'view' => 'single_file',
'file_id' => $item['file_id'],
),
$this->page_controller->get_logs_tab_url() . '#L' . absint( $item['line_number'] )
);
return sprintf(
'<a href="%1$s">%2$s</a>',
esc_url( $match_url ),
sprintf(
// translators: %s is a line number in a file.
esc_html__( 'Line %s', 'woocommerce' ),
number_format_i18n( absint( $item['line_number'] ) )
)
);
}
/**
* Render the line column.
*
* @param array $item The current search result being rendered.
*
* @return string
*/
public function column_line( array $item ): string {
$params = $this->page_controller->get_query_params( array( 'search' ) );
$line = $item['line'];
// Highlight matches within the line.
$pattern = preg_quote( $params['search'], '/' );
preg_match_all( "/$pattern/i", $line, $matches, PREG_OFFSET_CAPTURE );
if ( is_array( $matches[0] ) && count( $matches[0] ) >= 1 ) {
$length_change = 0;
foreach ( $matches[0] as $match ) {
$replace = '<span class="search-match">' . $match[0] . '</span>';
$offset = $match[1] + $length_change;
$orig_length = strlen( $match[0] );
$replace_length = strlen( $replace );
$line = substr_replace( $line, $replace, $offset, $orig_length );
$length_change += $replace_length - $orig_length;
}
}
return wp_kses_post( $line );
}
/**
* Helper to get the default value for the per_page arg.
*
* @return int
*/
public function get_per_page_default(): int {
return $this->file_controller::DEFAULTS_SEARCH_WITHIN_FILES['per_page'];
}
}

View File

@ -2,9 +2,36 @@
namespace Automattic\WooCommerce\Internal\Admin\Logging;
use Automattic\WooCommerce\Internal\Admin\Logging\FileV2\FileController;
use WC_Log_Handler_File;
/**
* LogHandlerFileV2 class.
*/
class LogHandlerFileV2 extends WC_Log_Handler_File {}
class LogHandlerFileV2 extends WC_Log_Handler_File {
/**
* Handle a log entry.
*
* @param int $timestamp Log timestamp.
* @param string $level emergency|alert|critical|error|warning|notice|info|debug.
* @param string $message Log message.
* @param array $context {
* Additional information for log handlers.
*
* @type string $source Optional. Determines log file to write to. Default 'log'.
* @type bool $_legacy Optional. Default false. True to use outdated log format
* originally used in deprecated WC_Logger::add calls.
* }
*
* @return bool False if value was not handled and true if value was handled.
*/
public function handle( $timestamp, $level, $message, $context ) {
$written = parent::handle( $timestamp, $level, $message, $context );
if ( $written ) {
wc_get_container()->get( FileController::class )->invalidate_cache();
}
return $written;
}
}

View File

@ -4,9 +4,10 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\Admin\Logging;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Admin\Logging\FileV2\{ FileController, ListTable };
use Automattic\WooCommerce\Internal\Admin\Logging\FileV2\{ FileController, FileListTable, SearchListTable };
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use WC_Admin_Status;
use WP_List_Table;
use WC_Log_Levels;
/**
@ -24,9 +25,9 @@ class PageController {
private $file_controller;
/**
* Instance of ListTable.
* Instance of FileListTable or SearchListTable.
*
* @var ListTable
* @var FileListTable|SearchListTable
*/
private $list_table;
@ -97,8 +98,7 @@ class PageController {
switch ( $handler ) {
case LogHandlerFileV2::class:
$params = $this->get_query_params();
$this->render_filev2( $params );
$this->render_filev2();
break;
case 'WC_Log_Handler_DB':
WC_Admin_Status::status_logs_db();
@ -112,20 +112,21 @@ class PageController {
/**
* Render the views for the FileV2 log handler.
*
* @param array $params Args for rendering the views.
*
* @return void
*/
private function render_filev2( array $params = array() ): void {
$view = $params['view'] ?? '';
private function render_filev2(): void {
$params = $this->get_query_params( array( 'view' ) );
switch ( $view ) {
switch ( $params['view'] ) {
case 'list_files':
default:
$this->render_file_list_page( $params );
$this->render_list_files_view();
break;
case 'search_results':
$this->render_search_results_view();
break;
case 'single_file':
$this->render_single_file_page( $params );
$this->render_single_file_view();
break;
}
}
@ -133,18 +134,21 @@ class PageController {
/**
* Render the file list view.
*
* @param array $params Args for rendering the view.
*
* @return void
*/
private function render_file_list_page( array $params = array() ): void {
$defaults = $this->get_query_param_defaults();
private function render_list_files_view(): void {
$params = $this->get_query_params( array( 'order', 'orderby', 'source', 'view' ) );
$defaults = $this->get_query_param_defaults();
$list_table = $this->get_list_table( $params['view'] );
$list_table->prepare_items();
?>
<header id="logs-header" class="wc-logs-header">
<h2>
<?php esc_html_e( 'Browse log files', 'woocommerce' ); ?>
</h2>
<?php $this->render_search_field(); ?>
</header>
<form id="logs-list-table-form" method="get">
<input type="hidden" name="page" value="wc-status" />
@ -158,8 +162,7 @@ class PageController {
/>
<?php endif; ?>
<?php endforeach; ?>
<?php $this->get_list_table()->prepare_items(); ?>
<?php $this->get_list_table()->display(); ?>
<?php $list_table->display(); ?>
</form>
<?php
}
@ -167,12 +170,11 @@ class PageController {
/**
* Render the single file view.
*
* @param array $params Args for rendering the view.
*
* @return void
*/
private function render_single_file_page( array $params ): void {
$file = $this->file_controller->get_file_by_id( $params['file_id'] );
private function render_single_file_view(): void {
$params = $this->get_query_params( array( 'file_id', 'view' ) );
$file = $this->file_controller->get_file_by_id( $params['file_id'] );
if ( is_wp_error( $file ) ) {
?>
@ -279,6 +281,26 @@ class PageController {
<?php
}
/**
* Render the search results view.
*
* @return void
*/
private function render_search_results_view(): void {
$params = $this->get_query_params( array( 'order', 'orderby', 'search', 'source', 'view' ) );
$list_table = $this->get_list_table( $params['view'] );
$list_table->prepare_items();
?>
<header id="logs-header" class="wc-logs-header">
<h2><?php esc_html_e( 'Search results', 'woocommerce' ); ?></h2>
<?php $this->render_search_field(); ?>
</header>
<?php $list_table->display(); ?>
<?php
}
/**
* Get the default values for URL query params for FileV2 views.
*
@ -289,6 +311,7 @@ class PageController {
'file_id' => '',
'order' => $this->file_controller::DEFAULTS_GET_FILES['order'],
'orderby' => $this->file_controller::DEFAULTS_GET_FILES['orderby'],
'search' => '',
'source' => $this->file_controller::DEFAULTS_GET_FILES['source'],
'view' => 'list_files',
);
@ -297,9 +320,11 @@ class PageController {
/**
* Get and validate URL query params for FileV2 views.
*
* @param array $param_keys Optional. The names of the params you want to get.
*
* @return array
*/
public function get_query_params(): array {
public function get_query_params( array $param_keys = array() ): array {
$defaults = $this->get_query_param_defaults();
$params = filter_input_array(
INPUT_GET,
@ -307,7 +332,7 @@ class PageController {
'file_id' => array(
'filter' => FILTER_CALLBACK,
'options' => function( $file_id ) {
return sanitize_file_name( $file_id );
return sanitize_file_name( wp_unslash( $file_id ) );
},
),
'order' => array(
@ -324,6 +349,12 @@ class PageController {
'default' => $defaults['orderby'],
),
),
'search' => array(
'filter' => FILTER_CALLBACK,
'options' => function( $search ) {
return esc_html( wp_unslash( $search ) );
},
),
'source' => array(
'filter' => FILTER_CALLBACK,
'options' => function( $source ) {
@ -333,7 +364,7 @@ class PageController {
'view' => array(
'filter' => FILTER_VALIDATE_REGEXP,
'options' => array(
'regexp' => '/^(list_files|single_file)$/',
'regexp' => '/^(list_files|single_file|search_results)$/',
'default' => $defaults['view'],
),
),
@ -342,20 +373,33 @@ class PageController {
);
$params = wp_parse_args( $params, $defaults );
if ( count( $param_keys ) > 0 ) {
$params = array_intersect_key( $params, array_flip( $param_keys ) );
}
return $params;
}
/**
* Get and cache an instance of the list table.
*
* @return ListTable
* @param string $view The current view, which determines which list table class to get.
*
* @return FileListTable|SearchListTable
*/
private function get_list_table(): ListTable {
if ( $this->list_table instanceof ListTable ) {
private function get_list_table( string $view ) {
if ( $this->list_table instanceof WP_List_Table ) {
return $this->list_table;
}
$this->list_table = new ListTable( $this->file_controller, $this );
switch ( $view ) {
case 'list_files':
$this->list_table = new FileListTable( $this->file_controller, $this );
break;
case 'search_results':
$this->list_table = new SearchListTable( $this->file_controller, $this );
break;
}
return $this->list_table;
}
@ -366,17 +410,19 @@ class PageController {
* @return void
*/
private function setup_screen_options(): void {
$params = $this->get_query_params();
$params = $this->get_query_params( array( 'view' ) );
if ( 'list_files' === $params['view'] ) {
// Ensure list table columns are initialized early enough to enable column hiding.
$this->get_list_table()->prepare_column_headers();
if ( in_array( $params['view'], array( 'list_files', 'search_results' ), true ) ) {
$list_table = $this->get_list_table( $params['view'] );
// Ensure list table columns are initialized early enough to enable column hiding, if available.
$list_table->prepare_column_headers();
add_screen_option(
'per_page',
array(
'default' => 20,
'option' => ListTable::PER_PAGE_USER_OPTION_KEY,
'default' => $list_table->get_per_page_default(),
'option' => $list_table::PER_PAGE_USER_OPTION_KEY,
)
);
}
@ -388,13 +434,14 @@ class PageController {
* @return void
*/
private function handle_list_table_bulk_actions(): void {
$params = $this->get_query_params( array( 'file_id', 'view' ) );
// Bail if this is not the list table view.
$params = $this->get_query_params();
if ( 'list_files' !== $params['view'] ) {
return;
}
$action = $this->get_list_table()->current_action();
$action = $this->get_list_table( $params['view'] )->current_action();
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : $this->get_logs_tab_url();
@ -409,16 +456,7 @@ class PageController {
$sendback = remove_query_arg( array( 'deleted' ), wp_get_referer() );
// Multiple file_id[] params will be filtered separately, but assigned to $files as an array.
$file_ids = filter_input(
INPUT_GET,
'file_id',
FILTER_CALLBACK,
array(
'options' => function( $file ) {
return sanitize_file_name( wp_unslash( $file ) );
},
)
);
$file_ids = $params['file_id'];
if ( ! is_array( $file_ids ) || count( $file_ids ) < 1 ) {
wp_safe_redirect( $sendback );
@ -475,21 +513,21 @@ class PageController {
/**
* Format a log file line.
*
* @param string $text The unformatted log file line.
* @param string $line The unformatted log file line.
* @param int $line_number The line number.
*
* @return string
*/
private function format_line( string $text, int $line_number ): string {
private function format_line( string $line, int $line_number ): string {
$severity_levels = WC_Log_Levels::get_all_severity_levels();
$classes = array( 'line' );
$text = esc_html( trim( $text ) );
if ( empty( $text ) ) {
$text = '&nbsp;';
$line = esc_html( trim( $line ) );
if ( empty( $line ) ) {
$line = '&nbsp;';
}
$segments = explode( ' ', $text, 3 );
$segments = explode( ' ', $line, 3 );
if ( isset( $segments[0] ) && false !== strtotime( $segments[0] ) ) {
$classes[] = 'log-entry';
@ -508,7 +546,7 @@ class PageController {
}
if ( count( $segments ) > 1 ) {
$text = implode( ' ', $segments );
$line = implode( ' ', $segments );
}
$classes = implode( ' ', $classes );
@ -523,8 +561,65 @@ class PageController {
),
sprintf(
'<span class="line-content">%s</span>',
wp_kses_post( $text )
wp_kses_post( $line )
)
);
}
/**
* Render a form for searching within log files.
*
* @return void
*/
private function render_search_field(): void {
$params = $this->get_query_params( array( 'search', 'source' ) );
$defaults = $this->get_query_param_defaults();
$file_count = $this->file_controller->get_files( $params, true );
if ( $file_count > 0 ) {
?>
<form id="logs-search" class="wc-logs-search" method="get">
<fieldset class="wc-logs-search-fieldset">
<input type="hidden" name="page" value="wc-status" />
<input type="hidden" name="tab" value="logs" />
<input type="hidden" name="view" value="search_results" />
<?php foreach ( $params as $key => $value ) : ?>
<?php if ( $value !== $defaults[ $key ] ) : ?>
<input
type="hidden"
name="<?php echo esc_attr( $key ); ?>"
value="<?php echo esc_attr( $value ); ?>"
/>
<?php endif; ?>
<?php endforeach; ?>
<label for="logs-search-field">
<?php esc_html_e( 'Search within these files', 'woocommerce' ); ?>
<input
id="logs-search-field"
class="wc-logs-search-field"
type="text"
name="search"
value="<?php echo esc_attr( $params['search'] ); ?>"
/>
</label>
<?php submit_button( __( 'Search', 'woocommerce' ), 'secondary', null, false ); ?>
</fieldset>
<?php if ( $file_count >= $this->file_controller::SEARCH_MAX_FILES ) : ?>
<div class="wc-logs-search-notice">
<?php
printf(
// translators: %s is a number.
esc_html__(
'⚠️ Only %s files can be searched at one time. Try filtering the file list before searching.',
'woocommerce'
),
esc_html( number_format_i18n( $this->file_controller::SEARCH_MAX_FILES ) )
);
?>
</div>
<?php endif; ?>
</form>
<?php
}
}
}

View File

@ -233,4 +233,43 @@ class FileControllerTest extends WC_Unit_Test_Case {
$this->assertEquals( 2, $deleted );
$this->assertEquals( 0, $this->sut->get_files( array(), true ) );
}
/**
* @testdox The search_within_files method should return an associative array of case-insensitive search results.
*/
public function test_search_within_files(): void {
$log_time = time();
$this->handler->handle( $log_time, 'debug', 'Foo', array( 'source' => 'unit-testing1' ) );
$this->handler->handle( $log_time, 'debug', 'Bar', array( 'source' => 'unit-testing1' ) );
$this->handler->handle( $log_time, 'debug', 'foobar', array( 'source' => 'unit-testing1' ) );
$this->handler->handle( $log_time, 'debug', 'A trip to the food bar', array( 'source' => 'unit-testing2' ) );
$this->handler->handle( $log_time, 'debug', 'Hello world', array( 'source' => 'unit-testing2' ) );
$this->assertEquals( 2, $this->sut->get_files( array(), true ) );
$file_args = array(
'order' => 'asc',
'orderby' => 'source',
);
$results = $this->sut->search_within_files( 'foo', array(), $file_args );
$this->assertCount( 3, $results );
$match = array_shift( $results );
$this->assertArrayHasKey( 'file_id', $match );
$this->assertArrayHasKey( 'line_number', $match );
$this->assertArrayHasKey( 'line', $match );
$this->assertEquals( 'unit-testing1-' . gmdate( 'Y-m-d', $log_time ), $match['file_id'] );
$this->assertEquals( 1, $match['line_number'] );
$this->assertStringContainsString( 'Foo', $match['line'] );
$match = array_shift( $results );
$this->assertEquals( 3, $match['line_number'] );
$this->assertStringContainsString( 'foobar', $match['line'] );
$match = array_shift( $results );
$this->assertEquals( 1, $match['line_number'] );
$this->assertStringContainsString( 'A trip to the food bar', $match['line'] );
}
}

View File

@ -125,7 +125,7 @@ class FileTest extends WC_Unit_Test_Case {
/**
* @testdox Check that get_stream returns a PHP resource representation of the file.
*/
public function test_get_stream() {
public function test_get_and_close_stream() {
$filename = Constants::get_constant( 'WC_LOG_DIR' ) . 'test-Source_1-1-2023-10-23-' . wp_hash( 'cheddar' ) . '.log';
$resource = fopen( $filename, 'a' );
fclose( $resource );
@ -134,6 +134,10 @@ class FileTest extends WC_Unit_Test_Case {
$stream = $file->get_stream();
$this->assertTrue( is_resource( $stream ) );
$file->close_stream();
$this->assertFalse( is_resource( $stream ) );
}
/**
@ -141,7 +145,7 @@ class FileTest extends WC_Unit_Test_Case {
*/
public function test_delete() {
$filename = Constants::get_constant( 'WC_LOG_DIR' ) . 'test-Source_1-1-' . wp_hash( 'cheddar' ) . '.5.log';
$resource = fopen( $filename, 'a' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
$resource = fopen( $filename, 'a' );
fclose( $resource );
$file = new File( $filename );