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:
parent
e684ae348b
commit
9a947c1a06
|
@ -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
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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 ) ) {
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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'];
|
||||
}
|
||||
}
|
|
@ -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'];
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = ' ';
|
||||
$line = esc_html( trim( $line ) );
|
||||
if ( empty( $line ) ) {
|
||||
$line = ' ';
|
||||
}
|
||||
|
||||
$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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'] );
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 );
|
||||
|
||||
|
|
Loading…
Reference in New Issue