Queue Triage screen. * * @package QueueTriage */ namespace QueueTriage; defined( 'ABSPATH' ) || exit; /** * Registers and renders the read-only screen. It has no forms and handles no input. */ class Admin_Page { const SLUG = 'queue-triage'; /** * The screen's hook suffix. * * @var string */ private static $hook_suffix = ''; /** * Hooks the screen in. */ public static function register() { add_action( 'admin_menu', array( __CLASS__, 'add_page' ) ); add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue' ) ); add_filter( 'plugin_action_links_' . plugin_basename( QUEUE_TRIAGE_FILE ), array( __CLASS__, 'action_links' ) ); } /** * The capability needed to see the screen. * * @return string */ public static function capability() { /** * Filters the capability needed to see Queue Triage's screen, Site Health tests and report. * * @param string $capability Default manage_options. */ return (string) apply_filters( 'queue_triage_capability', 'manage_options' ); } /** * Adds the page under Tools. */ public static function add_page() { $suffix = add_management_page( __( 'Asyncra Queue Triage for Action Scheduler', 'asyncra-queue-triage' ), __( 'Queue Triage', 'asyncra-queue-triage' ), self::capability(), self::SLUG, array( __CLASS__, 'render' ) ); self::$hook_suffix = $suffix ? $suffix : ''; } /** * The screen's URL. * * @return string */ public static function url() { return admin_url( 'tools.php?page=' . self::SLUG ); } /** * Adds a link to the screen on the Plugins list. * * @param string[] $links Action links. * @return string[] */ public static function action_links( $links ) { if ( current_user_can( self::capability() ) ) { array_unshift( $links, '' . esc_html__( 'Open report', 'asyncra-queue-triage' ) . '' ); } return $links; } /** * Loads the stylesheet and the copy button's script on this screen only. * * @param string $hook_suffix Current screen. */ public static function enqueue( $hook_suffix ) { if ( '' === self::$hook_suffix || $hook_suffix !== self::$hook_suffix ) { return; } wp_enqueue_style( 'queue-triage-admin', plugins_url( 'assets/admin.css', QUEUE_TRIAGE_FILE ), array(), QUEUE_TRIAGE_VERSION ); wp_enqueue_script( 'queue-triage-admin', plugins_url( 'assets/admin.js', QUEUE_TRIAGE_FILE ), array(), QUEUE_TRIAGE_VERSION, true ); wp_localize_script( 'queue-triage-admin', 'queueTriage', array( 'copied' => __( 'Copied', 'asyncra-queue-triage' ), 'failed' => __( 'Select the text and copy it', 'asyncra-queue-triage' ), ) ); } /** * Link to Action Scheduler's own screen, filtered by status and hook. * * @param string $status Status. * @param string $hook Hook to search for. * @return string */ public static function scheduled_actions_url( $status = '', $hook = '' ) { $args = array( 'page' => 'action-scheduler' ); if ( '' !== $status ) { $args['status'] = $status; } if ( '' !== $hook ) { $args['s'] = $hook; } return add_query_arg( array_map( 'rawurlencode', $args ), admin_url( 'tools.php' ) ); } /** * Renders the screen. */ public static function render() { if ( ! current_user_can( self::capability() ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to view this page.', 'asyncra-queue-triage' ) ); } $report = Plugin::report(); $data = $report['data']; $env = $data['environment']; $now = (int) $data['now']; echo '
'; echo '

' . esc_html__( 'Asyncra Queue Triage for Action Scheduler', 'asyncra-queue-triage' ) . '

'; echo '

'; esc_html_e( 'A read-only report on the Action Scheduler queue. Nothing on this page runs, retries, cancels or deletes an action.', 'asyncra-queue-triage' ); if ( $env['loaded'] ) { echo ' ' . esc_html__( 'Open Scheduled Actions', 'asyncra-queue-triage' ) . ''; } echo '

'; self::render_checks( $report['checks'] ); if ( ! $data['has_tables'] ) { self::render_environment( $data ); echo '
'; return; } self::render_failures( $report ); self::render_past_due( $data ); self::render_queue( $data ); self::render_storage( $data ); self::render_environment( $data ); self::render_support( $report ); echo '

'; printf( /* translators: %s: date and time. */ esc_html__( 'Read at %s. Reload the page to read again.', 'asyncra-queue-triage' ), esc_html( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $now ) ) ); echo '

'; echo ''; } /** * The status name shown on a check. * * @param string $status Status. * @return string */ private static function status_name( $status ) { if ( Report::CRITICAL === $status ) { return __( 'Critical', 'asyncra-queue-triage' ); } if ( Report::RECOMMENDED === $status ) { return __( 'Recommended', 'asyncra-queue-triage' ); } return __( 'Good', 'asyncra-queue-triage' ); } /** * The checks at the top. * * @param array $checks Checks. */ private static function render_checks( array $checks ) { echo ''; } /** * Failed actions grouped by hook and error. * * @param array $report Report. */ private static function render_failures( array $report ) { $data = $report['data']; $groups = $report['groups']; $now = (int) $data['now']; $total = isset( $data['counts']['failed'] ) ? (int) $data['counts']['failed'] : 0; $read = count( $data['failed']['actions'] ); echo '

' . esc_html__( 'Failed actions by hook and error', 'asyncra-queue-triage' ) . '

'; if ( 0 === $read ) { echo '

' . esc_html__( 'There are no failed actions.', 'asyncra-queue-triage' ) . '

'; return; } echo '

'; printf( /* translators: 1: number of actions read, 2: number of failed actions, 3: number of groups. */ esc_html__( 'The newest %1$s of %2$s failed actions, grouped by hook and by their error with ids, dates, amounts and other values taken out: %3$s groups.', 'asyncra-queue-triage' ), esc_html( Format::number( $read ) ), esc_html( Format::number( $total ) ), esc_html( Format::number( count( $groups ) ) ) ); echo ' '; esc_html_e( 'The labels come from fixed rules that read the error and the hook name. They cannot tell whether a rerun is safe, so read each group before you act on it.', 'asyncra-queue-triage' ); echo '

'; $by_label = array( Triage::FIX => 0, Triage::RETRY => 0, Triage::REVIEW => 0, ); foreach ( $groups as $group ) { $by_label[ $group['label'] ] += $group['count']; } echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; foreach ( array_slice( $groups, 0, 100 ) as $group ) { echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
' . esc_html__( 'Label', 'asyncra-queue-triage' ) . '' . esc_html__( 'Hook', 'asyncra-queue-triage' ) . '' . esc_html__( 'Failures', 'asyncra-queue-triage' ) . '' . esc_html__( 'Error and what to check', 'asyncra-queue-triage' ) . '' . esc_html__( 'Latest', 'asyncra-queue-triage' ) . '
' . esc_html( Report::label_name( $group['label'] ) ) . '' . esc_html( $group['hook'] ) . ''; if ( $group['groups'] ) { echo '
' . esc_html( implode( ', ', $group['groups'] ) ) . ''; } if ( $data['environment']['loaded'] ) { echo '
' . esc_html__( 'View these actions', 'asyncra-queue-triage' ) . ''; } echo '
' . esc_html( Format::number( $group['count'] ) ); if ( $group['recurring'] > 0 ) { echo '
' . esc_html( sprintf( /* translators: %s: number of actions. */ __( '%s recurring', 'asyncra-queue-triage' ), Format::number( $group['recurring'] ) ) ) . ''; } echo '
'; if ( '' === $group['example'] ) { echo '' . esc_html__( 'No failure message in the log.', 'asyncra-queue-triage' ) . ''; } else { echo '' . esc_html( $group['example'] ) . ''; } echo '

' . esc_html( Report::cause_text( $group['cause'] ) ) . '

'; foreach ( Report::group_notes( $group ) as $note ) { echo '

' . esc_html( $note ) . '

'; } echo '
' . ( null === $group['latest'] ? '–' : esc_html( sprintf( /* translators: %s: duration, such as "3 hours". */ __( '%s ago', 'asyncra-queue-triage' ), Format::duration( $now - $group['latest'] ) ) ) ) . '
'; if ( count( $groups ) > 100 ) { echo '

' . esc_html( sprintf( /* translators: %s: number of groups. */ __( '%s smaller groups are not shown.', 'asyncra-queue-triage' ), Format::number( count( $groups ) - 100 ) ) ) . '

'; } } /** * Past-due actions by hook. * * @param array $data Collected data. */ private static function render_past_due( array $data ) { $past_due = $data['past_due']; $now = (int) $data['now']; echo '

' . esc_html__( 'Past-due actions by hook', 'asyncra-queue-triage' ) . '

'; if ( 0 === $past_due['count'] ) { echo '

' . esc_html__( 'No pending action is past its scheduled time.', 'asyncra-queue-triage' ) . '

'; return; } echo '

'; printf( /* translators: 1: number of actions, 2: number of actions. */ esc_html__( '%1$s pending actions are past their scheduled time; %2$s of them by more than a day. The ten hooks with the most:', 'asyncra-queue-triage' ), esc_html( Format::number( $past_due['count'] ) ), esc_html( Format::number( $past_due['over_day'] ) ) ); echo '

'; echo ''; echo ''; echo ''; echo ''; echo ''; foreach ( $past_due['hooks'] as $row ) { echo ''; echo ''; } echo '
' . esc_html__( 'Hook', 'asyncra-queue-triage' ) . '' . esc_html__( 'Past due', 'asyncra-queue-triage' ) . '' . esc_html__( 'Oldest is late by', 'asyncra-queue-triage' ) . '
' . esc_html( $row['hook'] ) . ''; if ( $data['environment']['loaded'] ) { echo ' ' . esc_html__( 'View', 'asyncra-queue-triage' ) . ''; } echo '' . esc_html( Format::number( $row['count'] ) ) . '' . ( null === $row['oldest'] ? '–' : esc_html( Format::duration( $now - $row['oldest'] ) ) ) . '
'; } /** * Counts by status and the runner. * * @param array $data Collected data. */ private static function render_queue( array $data ) { $now = (int) $data['now']; $runner = $data['runner']; $last = max( (int) $data['activity']['last_log'], (int) $data['activity']['last_complete'] ); echo '

' . esc_html__( 'Queue and runner', 'asyncra-queue-triage' ) . '

'; echo '
'; echo ''; foreach ( $data['counts'] as $status => $count ) { echo ''; } echo ''; if ( $data['claims'] ) { echo ''; } echo '
' . esc_html__( 'Status', 'asyncra-queue-triage' ) . '' . esc_html__( 'Actions', 'asyncra-queue-triage' ) . '
'; if ( $data['environment']['loaded'] ) { echo '' . esc_html( $status ) . ''; } else { echo esc_html( $status ); } echo '' . esc_html( Format::number( $count ) ) . '
' . esc_html__( 'in progress for more than 5 minutes', 'asyncra-queue-triage' ) . '' . esc_html( Format::number( $data['running']['long'] ) ) . '
' . esc_html__( 'open claims (older than 5 minutes)', 'asyncra-queue-triage' ) . '' . esc_html( Format::number( $data['claims']['count'] ) . ' (' . Format::number( $data['claims']['stale'] ) . ')' ) . '
'; $rows = array( __( 'WP-Cron', 'asyncra-queue-triage' ) => $runner['wp_cron_disabled'] ? __( 'Disabled by DISABLE_WP_CRON. A server cron job or WP-CLI has to run it.', 'asyncra-queue-triage' ) : ( $runner['alternate_wp_cron'] ? __( 'Runs on page visits (ALTERNATE_WP_CRON is set).', 'asyncra-queue-triage' ) : __( 'Runs on page visits.', 'asyncra-queue-triage' ) ), __( 'Next queue run', 'asyncra-queue-triage' ) => null === $runner['next_run'] ? __( 'Not scheduled', 'asyncra-queue-triage' ) : ( $runner['next_run'] < $now /* translators: %s: duration. */ ? sprintf( __( 'Due %s ago', 'asyncra-queue-triage' ), Format::duration( $now - $runner['next_run'] ) ) /* translators: %s: duration. */ : sprintf( __( 'In %s', 'asyncra-queue-triage' ), Format::duration( $runner['next_run'] - $now ) ) ), __( 'Last activity', 'asyncra-queue-triage' ) => $last > 0 /* translators: %s: duration. */ ? sprintf( __( '%s ago (newest log entry or completed action)', 'asyncra-queue-triage' ), Format::duration( $now - $last ) ) : __( 'None recorded', 'asyncra-queue-triage' ), ); if ( null !== $runner['batch_size'] ) { $rows[ __( 'Runner limits', 'asyncra-queue-triage' ) ] = sprintf( /* translators: 1: number of actions, 2: number of batches, 3: seconds. */ __( '%1$s actions per batch, %2$s concurrent batches, %3$s seconds per run', 'asyncra-queue-triage' ), Format::number( $runner['batch_size'] ), Format::number( $runner['concurrent_batches'] ), Format::number( $runner['time_limit'] ) ); $rows[ __( 'Async runner', 'asyncra-queue-triage' ) ] = $runner['async_filtered_off'] ? __( 'Turned off by a filter', 'asyncra-queue-triage' ) : __( 'Allowed: admin page loads can start the queue', 'asyncra-queue-triage' ); } self::render_rows( $rows ); echo '
'; } /** * Table sizes and retention. * * @param array $data Collected data. */ private static function render_storage( array $data ) { $now = (int) $data['now']; $retention = $data['retention']; echo '

' . esc_html__( 'Storage and retention', 'asyncra-queue-triage' ) . '

'; echo '
'; echo ''; echo ''; echo ''; echo ''; echo ''; foreach ( $data['storage'] as $key => $table ) { echo ''; echo ''; echo ''; } echo '
' . esc_html__( 'Table', 'asyncra-queue-triage' ) . '' . esc_html__( 'Rows (estimate)', 'asyncra-queue-triage' ) . '' . esc_html__( 'Size', 'asyncra-queue-triage' ) . '
' . esc_html( $data['tables'][ $key ] ) . '' . esc_html( Format::number( $table['rows'] ) ) . '' . esc_html( Format::bytes( $table['bytes'] ) ) . '
'; echo '

' . esc_html__( 'Row counts and sizes are the database server\'s estimates and can lag behind.', 'asyncra-queue-triage' ) . '

'; echo '
'; $oldest = function ( $time ) use ( $now ) { return null === $time ? __( 'none', 'asyncra-queue-triage' ) : Format::duration( $now - $time ); }; $rows = array( __( 'Kept after finishing', 'asyncra-queue-triage' ) => sprintf( /* translators: 1: list of statuses, 2: duration. */ __( '%1$s: %2$s', 'asyncra-queue-triage' ), implode( ', ', $retention['statuses'] ), Format::duration( $retention['period'] ) ), __( 'Failed actions kept', 'asyncra-queue-triage' ) => $retention['failed_cleanup'] ? Format::duration( $retention['failed_period'] ) : __( 'Until deleted by hand (Action Scheduler before 4.0, or cleanup turned off)', 'asyncra-queue-triage' ), __( 'Oldest completed action', 'asyncra-queue-triage' ) => $oldest( $data['oldest']['complete'] ), __( 'Oldest canceled action', 'asyncra-queue-triage' ) => $oldest( $data['oldest']['canceled'] ), __( 'Oldest failed action', 'asyncra-queue-triage' ) => $oldest( $data['oldest']['failed'] ), ); self::render_rows( $rows ); echo '
'; } /** * Action Scheduler version and the plugins that bundle it. * * @param array $data Collected data. */ private static function render_environment( array $data ) { $env = $data['environment']; echo '

' . esc_html__( 'Action Scheduler on this site', 'asyncra-queue-triage' ) . '

'; if ( ! $env['loaded'] ) { echo '

' . esc_html__( 'No active plugin or theme loads Action Scheduler.', 'asyncra-queue-triage' ) . '

'; return; } $rows = array( __( 'Version in use', 'asyncra-queue-triage' ) => $env['version'] . ( '' !== $env['source'] ? ' (' . $env['source'] . ')' : '' ), __( 'Data store', 'asyncra-queue-triage' ) => $env['store'], ); if ( $env['sources'] ) { $copies = array(); foreach ( $env['sources'] as $source ) { $copies[] = $source['version'] . ' ' . $source['path']; } $rows[ __( 'Copies bundled by plugins', 'asyncra-queue-triage' ) ] = implode( "\n", $copies ); } self::render_rows( $rows ); if ( ! $env['custom_tables'] ) { echo '

' . esc_html__( 'This site does not use Action Scheduler\'s own tables as its data store, so the figures above may be incomplete.', 'asyncra-queue-triage' ) . '

'; } } /** * The copyable support report. * * @param array $report Report. */ private static function render_support( array $report ) { echo '

' . esc_html__( 'Report for a support ticket', 'asyncra-queue-triage' ) . '

'; echo '

' . esc_html__( 'Counts, settings, hook names and error signatures, with ids, values, email addresses, IP addresses and server paths left out. Read it before you share it.', 'asyncra-queue-triage' ) . '

'; echo ''; echo '

'; } /** * A two-column table of labels and values. * * @param array $rows Label to value. */ private static function render_rows( array $rows ) { echo ''; foreach ( $rows as $label => $value ) { echo ''; } echo '
' . esc_html( $label ) . '' . nl2br( esc_html( $value ) ) . '
'; } }