Files
2meet-data-optimizer/admin/class-tmdo-dashboard-widget.php
wpdev ad7acc40b7 chore(2meet-data-optimizer): address the 2.1 audit notes
- The four public REST routes say why they are public (viewable posts only, show_in_rest fields only, per_page cap, one view per IP per post per hour).
- Admin ternary echoes of literal strings are escaped for their context: esc_attr() in attributes, esc_html() for text; the object-cache badge is an if/else and aria-current is always printed ("false" off the current tab).
- Exceptions in the migration engine and the health cron go to TMDO_Logger instead of error_log().
- Verify: audit greps (ternary echoes 23 → 0, error_log outside the logger 0); dev36 renders 8 admin tabs with the change, no PHP notice, one aria-current="page" each.
- PR: PR-078
2026-09-27 01:19:22 +08:00

449 lines
16 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* TMDO_Dashboard_Widget — wp-admin home dashboard widget (v2.4.0 M9).
*
* Adds a "WP Data Optimizer 健康狀態" widget on the WP admin dashboard for
* users with `manage_options`. Surfaces:
* - Today's health check status (green / yellow / red traffic light)
* - Consecutive green days streak
* - 3 quick stats:last snapshot age, largest zone table, oldest open conflict
* - Quick links to Run Health Check, Entity Bridge, Create Snapshot
*
* Uses transient cache (5 minutes) to keep the dashboard responsive.
*
* @package WP_Data_Optimizer
*/
// phpcs:ignore WPDO.AntiEAV -- platform admin UI: native postmeta count for dashboard widget
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Dashboard widget — stateless static API.
*/
class TMDO_Dashboard_Widget {
public const WIDGET_ID = 'wpdo_health_widget';
public const CACHE_TTL = 300;
/**
* Register the dashboard widget hook.
*
* @return void
*/
public static function register(): void {
add_action( 'wp_dashboard_setup', array( __CLASS__, 'add_widget' ) );
}
/**
* Add the widget if user has manage_options.
*
* @return void
*/
public static function add_widget(): void {
if ( ! TMDO_Capability::current_user_can_admin() ) {
return;
}
wp_add_dashboard_widget(
self::WIDGET_ID,
__( 'WP Data Optimizer 健康狀態', '2meet-data-optimizer' ),
array( __CLASS__, 'render' )
);
}
/**
* Render the widget HTML.
*
* @return void
*/
public static function render(): void {
$page_url = admin_url( 'tools.php?page=wp-data-optimizer' );
$bridge_url = add_query_arg( 'tab', 'entity-bridge', $page_url );
$status = self::compute_status();
$light = self::traffic_light( $status['level'] );
?>
<style>
.wpdo-widget-light { display: inline-block; width: 16px; height: 16px; border-radius: 50%; vertical-align: middle; margin-right: 6px; }
.wpdo-widget-light.good { background: #46b450; box-shadow: 0 0 8px rgba(70,180,80,0.5); }
.wpdo-widget-light.warn { background: #dba617; box-shadow: 0 0 8px rgba(219,166,23,0.5); }
.wpdo-widget-light.crit { background: #dc3232; box-shadow: 0 0 8px rgba(220,50,50,0.5); }
.wpdo-widget-light.gray { background: #c3c4c7; }
.wpdo-widget-stat { display: flex; justify-content: space-between; padding: 0.4em 0; border-bottom: 1px solid #f0f0f1; }
.wpdo-widget-stat:last-child { border-bottom: none; }
.wpdo-widget-actions { margin-top: 0.8em; padding-top: 0.8em; border-top: 1px solid #c3c4c7; }
.wpdo-widget-actions a { margin-right: 0.5em; }
</style>
<p class="wpdo-widget-intro">
<span class="wpdo-widget-light <?php echo esc_attr( $light['class'] ); ?>"></span>
<strong><?php echo esc_html( $light['label'] ); ?></strong>
<?php if ( $status['streak'] > 0 ) : ?>
·
<?php
printf(
/* translators: %d: days */
esc_html__( '連續綠 %d 天', '2meet-data-optimizer' ),
(int) $status['streak']
);
?>
<?php endif; ?>
</p>
<?php if ( '' !== $status['summary_msg'] ) : ?>
<p class="description wpdo-mb-2"><?php echo esc_html( $status['summary_msg'] ); ?></p>
<?php endif; ?>
<div class="wpdo-widget-stats">
<div class="wpdo-widget-stat">
<span><?php esc_html_e( '最近快照', '2meet-data-optimizer' ); ?></span>
<span><strong><?php echo esc_html( $status['last_snapshot'] ); ?></strong></span>
</div>
<div class="wpdo-widget-stat">
<span><?php esc_html_e( '最大 zone 表', '2meet-data-optimizer' ); ?></span>
<span><strong><?php echo esc_html( $status['largest_zone'] ); ?></strong></span>
</div>
<div class="wpdo-widget-stat">
<span><?php esc_html_e( '未處理衝突', '2meet-data-optimizer' ); ?></span>
<span>
<strong><?php echo esc_html( (string) (int) $status['conflicts'] ); ?></strong>
<?php if ( (int) $status['conflicts'] > 0 ) : ?>
<a href="<?php echo esc_url( add_query_arg( 'tab', 'conflicts', $page_url ) ); ?>"
class="wpdo-mt-1"><?php esc_html_e( '查看', '2meet-data-optimizer' ); ?></a>
<?php endif; ?>
</span>
</div>
<div class="wpdo-widget-stat">
<span><?php esc_html_e( 'wp_postmeta 行數', '2meet-data-optimizer' ); ?></span>
<span><strong><?php echo esc_html( $status['postmeta_human'] ); ?></strong></span>
</div>
</div>
<?php
// v2.5.0 M16: actionable module suggestions count.
$actionable_count = 0;
if ( class_exists( 'TMDO_Module_Detector' ) ) {
$cached = TMDO_Module_Detector::get_cached();
if ( is_array( $cached ) && isset( $cached['results'] ) ) {
foreach ( (array) $cached['results'] as $r ) {
// v2.5.0 polish: align threshold with admin tab's get_actionable() default (0.5).
if ( ! empty( $r['available'] ) && 'enable' === ( $r['recommendation'] ?? '' ) && (float) ( $r['confidence'] ?? 0 ) >= 0.5 ) {
++$actionable_count;
}
}
}
}
if ( $actionable_count > 0 ) :
$ms_url = add_query_arg( 'tab', 'module-suggestions', $page_url );
?>
<p class="wpdo-widget-notice wpdo-widget-notice-warn">
🤖
<?php
printf(
/* translators: %d: count */
esc_html__( '偵測到 %d 個建議啟用的 module —', '2meet-data-optimizer' ),
(int) $actionable_count
);
?>
<a href="<?php echo esc_url( $ms_url ); ?>"><?php esc_html_e( '看建議', '2meet-data-optimizer' ); ?></a>
</p>
<?php endif; ?>
<?php
// v2.8.1: surface user-meta migration residue → CTA to one-click wizard.
$attn = class_exists( 'TMDO_Migration_Orchestrator' )
? TMDO_Migration_Orchestrator::needs_attention()
: array( 'needs' => false );
if ( ! empty( $attn['needs'] ) && 'running' !== ( $attn['job_state'] ?? '' ) ) :
$wizard_url = add_query_arg( 'tab', 'migration-wizard', $page_url );
?>
<p class="wpdo-widget-notice wpdo-widget-notice-error">
🔴
<?php
/* translators: 1: EAV row count, 2: group count, 3: ratio. */
$msg = __( '偵測到 <strong>%1$s</strong> 行 wp_usermeta EAV 殘留橫跨 <strong>%2$s</strong> 個 entity group(當前 ratio 1:<strong>%3$s</strong>)—', '2meet-data-optimizer' );
printf(
wp_kses( $msg, array( 'strong' => array() ) ),
esc_html( number_format_i18n( (int) $attn['eav_rows'] ) ),
esc_html( (string) (int) $attn['groups_with_residue'] ),
esc_html( (string) $attn['ratio'] )
);
?>
<a href="<?php echo esc_url( $wizard_url ); ?>"><strong><?php esc_html_e( 'User 遷移精靈 →', '2meet-data-optimizer' ); ?></strong></a>
</p>
<?php elseif ( 'running' === ( $attn['job_state'] ?? '' ) ) : ?>
<p class="wpdo-widget-notice wpdo-widget-notice-info">
⏳ <?php esc_html_e( 'User 遷移精靈正在執行中 —', '2meet-data-optimizer' ); ?>
<a href="<?php echo esc_url( add_query_arg( 'tab', 'migration-wizard', $page_url ) ); ?>"><?php esc_html_e( '查看進度', '2meet-data-optimizer' ); ?></a>
</p>
<?php endif; ?>
<?php
// v2.9.0 Phase 0: wp_postmeta garbage CTA (transients/_wp_old_date/stale _edit_lock).
$gc = class_exists( 'TMDO_Postmeta_Cleaner' )
? TMDO_Postmeta_Cleaner::count_garbage( TMDO_Postmeta_Cleaner::TARGET_ALL )
: array( 'total' => 0 );
if ( ! empty( $gc['total'] ) && (int) $gc['total'] > 0 ) :
$confirm_msg = sprintf(
/* translators: %s: total garbage row count */
esc_html__( '即將從 wp_postmeta 刪除 %s 行垃圾資料(transients + _wp_old_date + 過期 _edit_lock)。確認執行?', '2meet-data-optimizer' ),
number_format_i18n( (int) $gc['total'] )
);
?>
<p class="wpdo-widget-notice wpdo-widget-notice-neutral">
🧹
<?php
/* translators: 1: total rows, 2: transients, 3: wp_old_date, 4: edit_locks */
$gc_msg = __( 'wp_postmeta 偵測到 <strong>%1$s</strong> 行可清理垃圾(transients %2$s + _wp_old_date %3$s + 過期 _edit_lock %4$s)—', '2meet-data-optimizer' );
printf(
wp_kses( $gc_msg, array( 'strong' => array() ) ),
esc_html( number_format_i18n( (int) $gc['total'] ) ),
esc_html( number_format_i18n( (int) $gc['transients'] ) ),
esc_html( number_format_i18n( (int) $gc['wp_old_date'] ) ),
esc_html( number_format_i18n( (int) $gc['edit_locks'] ) )
);
?>
<form method="post" action="<?php echo esc_url( $page_url ); ?>" class="wpdo-form-inline">
<input type="hidden" name="wpdo_postmeta_cleanup" value="1">
<?php wp_nonce_field( 'wpdo_postmeta_cleanup' ); ?>
<button type="submit" class="button-link"
onclick="return confirm(<?php echo wp_json_encode( $confirm_msg ); ?>);">
<strong><?php esc_html_e( '一鍵清理 →', '2meet-data-optimizer' ); ?></strong>
</button>
</form>
</p>
<?php endif; ?>
<?php
// v2.9.4: Post Entity diagnostics (independent of user-side needs_attention()).
// Shows wp_posts:wp_postmeta ratio, post mode, and total EAV residue
// across all 7 entity groups. Read-only summary; full wizard ships v2.9.5.
$post_diag = class_exists( 'TMDO_Post_Migration' ) ? TMDO_Post_Migration::diagnose() : null;
if ( null !== $post_diag && $post_diag['posts'] > 0 ) :
$total_eav = 0;
foreach ( $post_diag['groups'] as $g ) {
$total_eav += (int) $g['eav_rows'];
}
$post_color = $total_eav > 0 ? '#dba617' : '#46b450';
$post_bg = $total_eav > 0 ? '#fffbe5' : '#ecf7ed';
$post_label = $total_eav > 0
? sprintf(
/* translators: 1: total EAV rows, 2: ratio */
__( 'Post Entity:偵測到 <strong>%1$s</strong> 行 wp_postmeta EAV 殘留(當前 ratio 1:<strong>%2$s</strong>,mode=<strong>%3$s</strong>)—', '2meet-data-optimizer' ),
'%1$s',
'%2$s',
'%3$s'
)
: sprintf(
/* translators: 1: ratio */
__( 'Post Entity:無 EAV 殘留(ratio 1:<strong>%1$s</strong>,mode=<strong>%2$s</strong>)', '2meet-data-optimizer' ),
'%1$s',
'%2$s'
);
?>
<p class="wpdo-widget-notice <?php echo esc_attr( $total_eav > 0 ? 'wpdo-widget-notice-warn' : 'wpdo-widget-notice-ok' ); ?>">
📦
<?php
if ( $total_eav > 0 ) {
printf(
wp_kses(
/* translators: 1: total EAV rows, 2: ratio, 3: mode */
__( 'Post Entity:偵測到 <strong>%1$s</strong> 行 wp_postmeta EAV 殘留(當前 ratio 1:<strong>%2$s</strong>,mode=<strong>%3$s</strong>)— v2.9.5 將提供一鍵遷移 UI。', '2meet-data-optimizer' ),
array( 'strong' => array() )
),
esc_html( number_format_i18n( $total_eav ) ),
esc_html( (string) $post_diag['ratio'] ),
esc_html( $post_diag['mode'] )
);
} else {
printf(
wp_kses(
/* translators: 1: ratio, 2: mode */
__( 'Post Entity:✓ 無 EAV 殘留(ratio 1:<strong>%1$s</strong>,mode=<strong>%2$s</strong>)', '2meet-data-optimizer' ),
array( 'strong' => array() )
),
esc_html( (string) $post_diag['ratio'] ),
esc_html( $post_diag['mode'] )
);
}
?>
</p>
<?php endif; ?>
<div class="wpdo-widget-actions">
<form method="post" action="<?php echo esc_url( $page_url ); ?>" class="wpdo-form-inline">
<input type="hidden" name="wpdo_run_health" value="1">
<?php wp_nonce_field( 'wpdo_run_health' ); ?>
<button type="submit" class="button button-small button-primary"><?php esc_html_e( '跑健康檢查', '2meet-data-optimizer' ); ?></button>
</form>
<a class="button button-small" href="<?php echo esc_url( $bridge_url ); ?>">
<?php esc_html_e( 'Entity Bridge', '2meet-data-optimizer' ); ?>
</a>
<form method="post" action="<?php echo esc_url( $page_url ); ?>" class="wpdo-form-inline">
<input type="hidden" name="wpdo_create_snapshot" value="1">
<?php wp_nonce_field( 'wpdo_create_snapshot' ); ?>
<button type="submit" class="button button-small"><?php esc_html_e( '建立快照', '2meet-data-optimizer' ); ?></button>
</form>
<a href="<?php echo esc_url( $page_url ); ?>" class="wpdo-widget-link-right">
<?php esc_html_e( '完整儀表板 →', '2meet-data-optimizer' ); ?>
</a>
</div>
<?php
}
// ─── private ──────────────────────────────────────────────────────
/**
* Compute widget status. Cached via transient.
*
* @return array {level:string, summary_msg:string, streak:int,
* last_snapshot:string, largest_zone:string,
* conflicts:int, postmeta_human:string}
*/
private static function compute_status(): array {
$cached = get_transient( 'wpdo_dashboard_widget_status' );
if ( is_array( $cached ) ) {
return $cached;
}
global $wpdb;
// Health level from last cron run, falling back to "no data".
$last = class_exists( 'TMDO_Health_Cron' ) ? TMDO_Health_Cron::get_last_run() : null;
$level = 'unknown';
$msg = '';
$streak = 0;
if ( is_array( $last ) ) {
$crit = (int) ( $last['critical_count'] ?? 0 );
$rec = (int) ( $last['recommended_count'] ?? 0 );
if ( $crit > 0 ) {
$level = 'critical';
$msg = sprintf( /* translators: %d: count */ __( '%d 項 critical 警告', '2meet-data-optimizer' ), $crit );
} elseif ( $rec > 0 ) {
$level = 'warn';
$msg = sprintf( /* translators: %d: count */ __( '%d 項 recommended 提示', '2meet-data-optimizer' ), $rec );
} else {
$level = 'good';
$streak = TMDO_Health_Cron::consecutive_green_days();
}
$msg .= ' · ' . sprintf(
/* translators: %s: timestamp */
__( '最後執行:%s', '2meet-data-optimizer' ),
(string) $last['ran_at']
);
} else {
$msg = __( '尚未執行過健康檢查', '2meet-data-optimizer' );
}
// Last snapshot age.
$snap_table = $wpdb->prefix . 'wpdo_snapshots';
$snap_exists = (int) $wpdb->get_var(
$wpdb->prepare( // phpcs:ignore WordPress.DB
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
$snap_table
)
);
$last_snapshot = __( '從未', '2meet-data-optimizer' );
if ( 1 === $snap_exists ) {
$ts = (string) $wpdb->get_var( "SELECT created_at FROM `{$snap_table}` ORDER BY created_at DESC LIMIT 1" ); // phpcs:ignore WordPress.DB
if ( '' !== $ts ) {
$diff = time() - strtotime( $ts . ' UTC' );
$last_snapshot = $diff < 0 ? $ts : self::human_time_diff( $diff );
}
}
// Largest zone table by row count (rough sample).
$largest = '—';
if ( 1 === $snap_exists ) {
$row_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->prefix}wpdo_warm`" ); // phpcs:ignore WordPress.DB
if ( $row_count > 0 ) {
$largest = sprintf( 'wpdo_warm (%s)', number_format_i18n( $row_count ) );
}
}
// Conflict count (cheap — uses cached summary).
$conflicts = 0;
if ( class_exists( 'TMDO_Conflict_Monitor' ) ) {
$summary = TMDO_Conflict_Monitor::get_summary();
$conflicts = (int) ( $summary['total'] ?? 0 );
}
// wp_postmeta size (informational).
$pm_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->postmeta}`" ); // phpcs:ignore WordPress.DB
$result = array(
'level' => $level,
'summary_msg' => $msg,
'streak' => $streak,
'last_snapshot' => $last_snapshot,
'largest_zone' => $largest,
'conflicts' => $conflicts,
'postmeta_human' => number_format_i18n( $pm_count ),
);
set_transient( 'wpdo_dashboard_widget_status', $result, self::CACHE_TTL );
return $result;
}
/**
* Map level → CSS class + label.
*
* @param string $level Level slug.
* @return array {class:string,label:string}
*/
private static function traffic_light( string $level ): array {
switch ( $level ) {
case 'good':
return array(
'class' => 'good',
'label' => __( '正常', '2meet-data-optimizer' ),
);
case 'warn':
return array(
'class' => 'warn',
'label' => __( '注意', '2meet-data-optimizer' ),
);
case 'critical':
return array(
'class' => 'crit',
'label' => __( '警告', '2meet-data-optimizer' ),
);
default:
return array(
'class' => 'gray',
'label' => __( '未知', '2meet-data-optimizer' ),
);
}
}
/**
* Human-readable time-diff (seconds → "5 minutes ago" style). Uses WP
* `human_time_diff` when available; falls back to simple math otherwise.
*
* @param int $seconds Seconds delta (>=0).
* @return string
*/
private static function human_time_diff( int $seconds ): string {
if ( $seconds < 60 ) {
return $seconds . 's';
}
if ( function_exists( 'human_time_diff' ) ) {
return sprintf(
/* translators: %s: human-readable time difference */
__( '%s 前', '2meet-data-optimizer' ),
human_time_diff( time() - $seconds, time() )
);
}
if ( $seconds < 3600 ) {
return floor( $seconds / 60 ) . 'm';
}
if ( $seconds < 86400 ) {
return floor( $seconds / 3600 ) . 'h';
}
return floor( $seconds / 86400 ) . 'd';
}
}