f524ea3f16
- phpcs.xml(自 A 移植):ruleset 改名、*/tools/* 例外換成 */back-compat/*、 中文註解全域排除 Squiz.Commenting.InlineComment.InvalidEndChar、 interface 別名檔排除 OneObjectStructurePerFile - 檔頭正規化:16 個檔案的 declare(strict_types=1) 與前導 // 註解移到 file docblock 之後,並移除 <?php 後多餘空行(phpcbf 另自動修 190 處) - phpstan.neon + .phpstan/stubs.php(TMDO_ 與 WPDO_ 兩套常數)+ 重新產生的 phpstan-baseline.neon(710 errors,A 的 3877 行 baseline 因前綴與路徑不同無法沿用) 現況:PHPCS 0 errors / 0 warnings、PHPStan L6 No errors、 unit 451 / integration 398 GREEN Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
449 lines
17 KiB
PHP
449 lines
17 KiB
PHP
<?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 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';
|
||
}
|
||
}
|