Files
2meet-data-optimizer/admin/class-tmdo-dashboard-widget.php
T
wpdev 68f7f6871c fix(admin): 13 個破壞性動作由 GET 改 POST + nonce(A1-A4)
nonce 走 query string 會經 Referer 外洩,且 GET 觸發的破壞性動作(刪快照、
cutover、promote aeav_only、清 postmeta)可被 prefetch/爬蟲觸發。對應 A v3.3.1 P1-6。

Handler 端(admin/class-tmdo-admin.php:300-700):13 個動作與其附屬參數
(post_type / count / mode / samples)全部改讀 $_POST。唯讀的 tab /
classify_type / wpdo_msg / wpdo_module 維持 GET。

渲染端改為 <form method=post> + wp_nonce_field():
- admin:重置速率統計、建立/清除快照、刪除快照、啟用 module
- dashboard-widget:跑健康檢查、建立快照、一鍵清理 postmeta
- post-migration-wizard:5 個步驟動作
- setup-wizard:建立 baseline snapshot
- post-stress-test:移除已無呼叫端的 legacy GET $cleanup_url

註:A 的 dashboard-widget 仍以 wp_nonce_url 產生 postmeta_cleanup 連結,
但其 handler 已只收 POST → 該按鈕在 A 是壞的;B 這邊一併改成 form。

unit 379 / integration 398 GREEN

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 05:27:57 +08:00

446 lines
17 KiB
PHP
Raw 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
// phpcs:ignore WPDO.AntiEAV -- platform admin UI: native postmeta count for dashboard widget
/**
* 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 statslast 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
*/
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 style="font-size: 1.1em; margin-bottom: 0.6em;">
<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" style="margin-bottom: 0.8em;"><?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 ) ); ?>"
style="margin-left:0.5em;"><?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 style="margin: 0.6em 0; padding: 0.5em 0.7em; background: #fff8e5; border-left: 3px solid #dba617; border-radius: 3px;">
🤖
<?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 style="margin: 0.6em 0; padding: 0.6em 0.8em; background: #fef0f0; border-left: 3px solid #dc3232; border-radius: 3px;">
🔴
<?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 style="margin: 0.6em 0; padding: 0.6em 0.8em; background: #e5f5fa; border-left: 3px solid #00a0d2; border-radius: 3px;">
⏳ <?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 style="margin: 0.6em 0; padding: 0.6em 0.8em; background: #f6f7f7; border-left: 3px solid #2271b1; border-radius: 3px;">
🧹
<?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 ); ?>" style="display: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 style="margin: 0.6em 0; padding: 0.6em 0.8em; background: <?php echo esc_attr( $post_bg ); ?>; border-left: 3px solid <?php echo esc_attr( $post_color ); ?>; border-radius: 3px;">
📦
<?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 ); ?>" style="display: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 ); ?>" style="display: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 ); ?>" style="float: right; padding-top: 4px;">
<?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';
}
}