76c01e44df
對齊 A v3.2.0。型別強制會把隱式轉換變成 TypeError,所以一次全檔加入 並跑完整測試(unit 451 / integration 398 全綠,無迴歸)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
286 lines
9.3 KiB
PHP
286 lines
9.3 KiB
PHP
<?php
|
||
/**
|
||
* TMDO_Monthly_Summary — 30-day rollup report (v2.4.0 M11).
|
||
*
|
||
* Generates a monthly executive summary covering:
|
||
* - Health success / fail / rate-limit ratios over last 30 days
|
||
* - Zone size growth (deltas from 30 days ago snapshot if available)
|
||
* - Per-module FSM trajectory (who promoted, who rolled back)
|
||
* - Snapshot retention overview (count, oldest, newest)
|
||
*
|
||
* Hooks the existing `wpdo_health_snapshot_monthly` action (Core line ~489)
|
||
* so it runs once per month at 03:00 UTC on the 1st. Output:
|
||
* 1. Persisted to wp_wpdo_audit (op='monthly_summary').
|
||
* 2. Stored as wp_options.wpdo_monthly_summary_latest (autoload=no).
|
||
* 3. Rendered on the admin Doctor tab "📅 Monthly Summary" sub-section.
|
||
*
|
||
* @package WP_Data_Optimizer
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
if ( ! defined( 'ABSPATH' ) ) {
|
||
exit;
|
||
}
|
||
|
||
/**
|
||
* Monthly summary aggregator.
|
||
*/
|
||
class TMDO_Monthly_Summary {
|
||
|
||
public const OPTION_LATEST = 'wpdo_monthly_summary_latest';
|
||
|
||
/**
|
||
* Hook into Core's existing monthly cron.
|
||
*
|
||
* @return void
|
||
*/
|
||
public static function register(): void {
|
||
add_action( 'wpdo_health_snapshot_monthly', array( __CLASS__, 'generate' ), 20 );
|
||
}
|
||
|
||
/**
|
||
* Build + persist the monthly summary.
|
||
*
|
||
* @return array Summary structure.
|
||
*/
|
||
public static function generate(): array {
|
||
$started_at = microtime( true );
|
||
$summary = array(
|
||
'period_start' => gmdate( 'Y-m-d H:i:s', strtotime( '-30 days' ) ),
|
||
'period_end' => gmdate( 'Y-m-d H:i:s' ),
|
||
'health' => self::aggregate_health(),
|
||
'fsm_trajectory' => self::aggregate_fsm(),
|
||
'snapshots' => self::aggregate_snapshots(),
|
||
'zone_growth' => self::aggregate_zone_growth(),
|
||
'autoload_size' => self::measure_autoload(),
|
||
'duration_ms' => 0,
|
||
'generated_at' => gmdate( 'Y-m-d H:i:s' ),
|
||
);
|
||
$summary['duration_ms'] = (int) round( ( microtime( true ) - $started_at ) * 1000 );
|
||
|
||
update_option( self::OPTION_LATEST, $summary, false );
|
||
|
||
// v2.5.0 M14: archive into history (max 12 entries).
|
||
$history = (array) get_option( 'wpdo_monthly_summary_history', array() );
|
||
array_unshift( $history, $summary );
|
||
$history = array_slice( $history, 0, 12 );
|
||
update_option( 'wpdo_monthly_summary_history', $history, false );
|
||
|
||
if ( class_exists( 'TMDO_Logger' ) ) {
|
||
TMDO_Logger::info(
|
||
'monthly_summary',
|
||
array(
|
||
'health_success' => (int) ( $summary['health']['success'] ?? 0 ),
|
||
'health_critical' => (int) ( $summary['health']['critical'] ?? 0 ),
|
||
'fsm_promotions' => count( $summary['fsm_trajectory']['promotions'] ?? array() ),
|
||
'fsm_rollbacks' => count( $summary['fsm_trajectory']['rollbacks'] ?? array() ),
|
||
'snapshots_total' => (int) ( $summary['snapshots']['total'] ?? 0 ),
|
||
'autoload_kb' => (int) round( ( $summary['autoload_size'] ?? 0 ) / 1024 ),
|
||
)
|
||
);
|
||
}
|
||
|
||
do_action( 'wpdo/monthly_summary_generated', $summary );
|
||
return $summary;
|
||
}
|
||
|
||
/**
|
||
* Read the latest summary (from wp_options).
|
||
*
|
||
* @return array|null
|
||
*/
|
||
public static function get_latest(): ?array {
|
||
$v = get_option( self::OPTION_LATEST );
|
||
return is_array( $v ) ? $v : null;
|
||
}
|
||
|
||
// ─── private ────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Aggregate health-check audit log entries from last 30 days.
|
||
*
|
||
* @return array {success:int, recommended:int, critical:int, total:int}
|
||
*/
|
||
private static function aggregate_health(): array {
|
||
global $wpdb;
|
||
$out = array(
|
||
'success' => 0,
|
||
'recommended' => 0,
|
||
'critical' => 0,
|
||
'total' => 0,
|
||
);
|
||
|
||
// wp_wpdo_audit may not exist on early v1.x installs; check first.
|
||
$audit = $wpdb->prefix . 'wpdo_audit';
|
||
$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',
|
||
$audit
|
||
)
|
||
);
|
||
if ( 0 === $exists ) {
|
||
return $out;
|
||
}
|
||
// Health check audit rows have op='health_check_daily'.
|
||
$rows = (array) $wpdb->get_results( "SELECT * FROM `{$audit}` WHERE op = 'health_check_daily' AND ts >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 30 DAY)", ARRAY_A ); // phpcs:ignore WordPress.DB
|
||
foreach ( $rows as $r ) {
|
||
$ctx = isset( $r['value_after'] ) ? json_decode( (string) $r['value_after'], true ) : null;
|
||
if ( ! is_array( $ctx ) ) {
|
||
continue;
|
||
}
|
||
$crit = (int) ( $ctx['critical'] ?? 0 );
|
||
$rec = (int) ( $ctx['recommended'] ?? 0 );
|
||
++$out['total'];
|
||
if ( $crit > 0 ) {
|
||
++$out['critical'];
|
||
} elseif ( $rec > 0 ) {
|
||
++$out['recommended'];
|
||
} else {
|
||
++$out['success'];
|
||
}
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* Best-effort FSM trajectory: which modules changed state in the last 30
|
||
* days. Reads `wpdo_fsm_state_entered` + current state.
|
||
*
|
||
* @return array {promotions:array, rollbacks:array, stationary:array}
|
||
*/
|
||
private static function aggregate_fsm(): array {
|
||
$out = array(
|
||
'promotions' => array(),
|
||
'rollbacks' => array(),
|
||
'stationary' => array(),
|
||
);
|
||
if ( ! class_exists( 'TMDO_Feature_Flags' ) ) {
|
||
return $out;
|
||
}
|
||
$entered = (array) get_option( 'wpdo_fsm_state_entered', array() );
|
||
$now = time();
|
||
foreach ( TMDO_Feature_Flags::all() as $module => $state ) {
|
||
$ts = isset( $entered[ $module ]['entered_at'] ) ? strtotime( (string) $entered[ $module ]['entered_at'] . ' UTC' ) : 0;
|
||
$age_days = $ts > 0 ? (int) floor( ( $now - $ts ) / DAY_IN_SECONDS ) : null;
|
||
|
||
if ( $ts > 0 && ( $now - $ts ) <= ( 30 * DAY_IN_SECONDS ) ) {
|
||
// Recently changed — bucket by direction.
|
||
if ( 'idle' === $state ) {
|
||
$out['rollbacks'][ $module ] = array(
|
||
'state' => $state,
|
||
'days_in_state' => $age_days,
|
||
);
|
||
} else {
|
||
$out['promotions'][ $module ] = array(
|
||
'state' => $state,
|
||
'days_in_state' => $age_days,
|
||
);
|
||
}
|
||
} else {
|
||
$out['stationary'][ $module ] = array(
|
||
'state' => $state,
|
||
'days_in_state' => $age_days,
|
||
);
|
||
}
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* Aggregate snapshot retention stats from the snapshots table.
|
||
*
|
||
* @return array {total:int, oldest:string|null, newest:string|null, total_bytes:int}
|
||
*/
|
||
private static function aggregate_snapshots(): array {
|
||
global $wpdb;
|
||
$table = $wpdb->prefix . 'wpdo_snapshots';
|
||
$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',
|
||
$table
|
||
)
|
||
);
|
||
if ( 0 === $exists ) {
|
||
return array(
|
||
'total' => 0,
|
||
'oldest' => null,
|
||
'newest' => null,
|
||
'total_bytes' => 0,
|
||
);
|
||
}
|
||
$row = $wpdb->get_row( "SELECT COUNT(*) AS c, MIN(created_at) AS oldest, MAX(created_at) AS newest, COALESCE(SUM(size_bytes),0) AS bytes FROM `{$table}`", ARRAY_A ); // phpcs:ignore WordPress.DB
|
||
return array(
|
||
'total' => (int) ( $row['c'] ?? 0 ),
|
||
'oldest' => isset( $row['oldest'] ) ? (string) $row['oldest'] : null,
|
||
'newest' => isset( $row['newest'] ) ? (string) $row['newest'] : null,
|
||
'total_bytes' => (int) ( $row['bytes'] ?? 0 ),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Per-zone row count snapshot. Compares to previous month's value when
|
||
* available (delta_rows positive = growth).
|
||
*
|
||
* @return array<string,array>
|
||
*/
|
||
private static function aggregate_zone_growth(): array {
|
||
// Use historical site_metrics when available — avoids live COUNT(*) on large tables
|
||
// and provides a 30-day delta (current − oldest snapshot).
|
||
if ( class_exists( 'TMDO_Site_Metrics_Collector' ) ) {
|
||
$latest = TMDO_Site_Metrics_Collector::get_latest_snapshot();
|
||
if ( ! empty( $latest ) ) {
|
||
// Get oldest snapshot within 30 days for delta calculation.
|
||
$keys_of_interest = array( 'eav.postmeta_rows', 'flat.hot_rows', 'flat.cold_rows', 'flat.warm_rows', 'custom_tables.total_rows' );
|
||
$out = array();
|
||
foreach ( $keys_of_interest as $mk ) {
|
||
if ( ! isset( $latest[ $mk ] ) ) {
|
||
continue;
|
||
}
|
||
$history = TMDO_Site_Metrics_Collector::get_history( $mk, 30 );
|
||
$oldest = ! empty( $history ) ? (int) $history[0]['value'] : null;
|
||
$current = (int) $latest[ $mk ];
|
||
$out[ $mk ] = array(
|
||
'rows' => $current,
|
||
'delta_rows' => null !== $oldest ? $current - $oldest : null,
|
||
);
|
||
}
|
||
return $out;
|
||
}
|
||
}
|
||
|
||
// Fallback: live COUNT(*) for warm + archive tables (pre-v2.6.2 installs).
|
||
global $wpdb;
|
||
$out = array();
|
||
$keys = array( 'wpdo_warm', 'wpdo_archive' );
|
||
foreach ( $keys as $slug ) {
|
||
$table = $wpdb->prefix . $slug;
|
||
$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',
|
||
$table
|
||
)
|
||
);
|
||
if ( 0 === $exists ) {
|
||
continue;
|
||
}
|
||
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB
|
||
$out[ $slug ] = array(
|
||
'rows' => $count,
|
||
'delta_rows' => null,
|
||
);
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* Return the total byte size of autoloaded options.
|
||
*
|
||
* @return int Bytes in autoloaded options.
|
||
*/
|
||
private static function measure_autoload(): int {
|
||
global $wpdb;
|
||
return (int) $wpdb->get_var( "SELECT COALESCE(SUM(LENGTH(option_value)),0) FROM `{$wpdb->options}` WHERE autoload = 'yes'" ); // phpcs:ignore WordPress.DB
|
||
}
|
||
}
|