chore: initial snapshot of 2meet-data-optimizer v0.1.0
Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
This commit is contained in:
@@ -0,0 +1,515 @@
|
||||
/**
|
||||
* WP Data Optimizer — Admin Styles (Morandi Design System)
|
||||
*
|
||||
* Morandi tokens are provided via the 'morandi-design-system' CSS dependency
|
||||
* registered in WPDO_Admin::enqueue_assets(). No @import needed.
|
||||
*/
|
||||
|
||||
/* ── KPI Hero Strip (v2.16.0) ────────────────────────────────────────── */
|
||||
|
||||
.wpdo-kpi-hero {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: var(--space-4);
|
||||
margin: var(--space-5) 0;
|
||||
}
|
||||
|
||||
.wpdo-kpi-card {
|
||||
background: var(--color-bg-secondary, #FAF9F6);
|
||||
border: 1px solid var(--color-bg-divider, #E8E3D9);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
box-shadow: var(--shadow-sm, 0 1px 3px rgba(58,53,48,0.08));
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: transform var(--duration-normal, 200ms) var(--ease-default, ease-out),
|
||||
box-shadow var(--duration-normal, 200ms) var(--ease-default, ease-out);
|
||||
}
|
||||
|
||||
.wpdo-kpi-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg,
|
||||
var(--color-accent-warm, #c4a986),
|
||||
var(--color-accent-warm, #c4a986));
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.wpdo-kpi-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md, 0 4px 12px rgba(58,53,48,0.10));
|
||||
}
|
||||
|
||||
.wpdo-kpi-label {
|
||||
font-family: var(--font-family-body);
|
||||
font-size: var(--text-sm, 13px);
|
||||
color: var(--color-text-secondary, #6b6256);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.wpdo-kpi-value {
|
||||
font-family: var(--font-family-display);
|
||||
font-size: 2.25rem;
|
||||
line-height: 1.1;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary, #3a3530);
|
||||
margin-bottom: var(--space-2);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.wpdo-kpi-unit {
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text-secondary, #6b6256);
|
||||
font-weight: 500;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.wpdo-kpi-empty {
|
||||
color: var(--color-text-tertiary, #9a8e7e);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.wpdo-kpi-sub {
|
||||
font-family: var(--font-family-body);
|
||||
font-size: var(--text-xs, 12px);
|
||||
color: var(--color-text-secondary, #6b6256);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Per-KPI accent stripe color */
|
||||
.wpdo-kpi--ratio::before { background: linear-gradient(90deg, #c4a986, #d4b89a); }
|
||||
.wpdo-kpi--speedup::before { background: linear-gradient(90deg, #5a9a85, #7eb39e); }
|
||||
.wpdo-kpi--coverage::before { background: linear-gradient(90deg, #8b7eb3, #a397c9); }
|
||||
|
||||
.wpdo-kpi--health-excellent::before { background: linear-gradient(90deg, #5a9a85, #7eb39e); }
|
||||
.wpdo-kpi--health-good::before { background: linear-gradient(90deg, #c4a986, #d4b89a); }
|
||||
.wpdo-kpi--health-warn::before { background: linear-gradient(90deg, #d4a574, #e0b888); }
|
||||
.wpdo-kpi--health-crit::before { background: linear-gradient(90deg, #c47a7a, #d49090); }
|
||||
|
||||
.wpdo-kpi--health-excellent .wpdo-kpi-value { color: #4a8270; }
|
||||
.wpdo-kpi--health-good .wpdo-kpi-value { color: #a8906f; }
|
||||
.wpdo-kpi--health-warn .wpdo-kpi-value { color: #b88955; }
|
||||
.wpdo-kpi--health-crit .wpdo-kpi-value { color: #a86060; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.wpdo-kpi-hero {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.wpdo-kpi-value {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Grouped Tab Navigation (v2.16.0) ────────────────────────────────── */
|
||||
|
||||
.wpdo-tab-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3) var(--space-4);
|
||||
align-items: flex-end;
|
||||
padding-bottom: 0;
|
||||
border-bottom: 1px solid var(--color-bg-divider, #E8E3D9);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.wpdo-tab-group {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-right: var(--space-2);
|
||||
padding-right: var(--space-3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wpdo-tab-group:not(:last-child)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 24px;
|
||||
bottom: 4px;
|
||||
width: 1px;
|
||||
background: var(--color-bg-divider, #E8E3D9);
|
||||
}
|
||||
|
||||
.wpdo-tab-group-label {
|
||||
font-family: var(--font-family-body);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-tertiary, #9a8e7e);
|
||||
font-weight: 500;
|
||||
padding-left: var(--space-2);
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wpdo-tab-group .nav-tab {
|
||||
margin: 0 2px -1px 0;
|
||||
transition: background-color var(--duration-fast, 120ms) ease,
|
||||
color var(--duration-fast, 120ms) ease;
|
||||
}
|
||||
|
||||
.wpdo-tab-group .nav-tab:hover {
|
||||
background-color: var(--color-bg-tertiary, #F2EEE8);
|
||||
color: var(--color-text-primary, #3a3530);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.wpdo-tab-nav {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.wpdo-tab-group {
|
||||
margin-right: 0;
|
||||
padding-right: var(--space-2);
|
||||
}
|
||||
.wpdo-tab-group-label {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Layout ──────────────────────────────────────────────────────────── */
|
||||
.wpdo-wrap .wpdo-tab-content {
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
|
||||
.wpdo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: var(--space-5);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.wpdo-card {
|
||||
background: var(--color-bg-secondary, #FAF9F6);
|
||||
border: 1px solid var(--color-bg-divider, #E8E3D9);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
box-shadow: var(--shadow-sm, 0 1px 3px rgba(58,53,48,0.08));
|
||||
overflow-x: auto; /* WCAG 1.4.10 Reflow: tables scroll within card, not page */
|
||||
}
|
||||
|
||||
.wpdo-card h2 {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-bottom: 1px solid var(--color-bg-divider, #E8E3D9);
|
||||
padding-bottom: var(--space-3);
|
||||
font-family: var(--font-family-display);
|
||||
}
|
||||
|
||||
.wpdo-card h3,
|
||||
.wpdo-card h4 {
|
||||
margin-top: var(--space-3);
|
||||
font-family: var(--font-family-display);
|
||||
}
|
||||
|
||||
.wpdo-card-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* Utility spacers replacing inline style="margin-top:N" */
|
||||
.wpdo-mt-1 { margin-top: var(--space-2); }
|
||||
.wpdo-mt-2 { margin-top: var(--space-3); }
|
||||
.wpdo-mt-3 { margin-top: var(--space-5); }
|
||||
.wpdo-mb-2 { margin-bottom: var(--space-3); }
|
||||
.wpdo-table--narrow { max-width: 480px; }
|
||||
.wpdo-table--medium { max-width: 700px; }
|
||||
.wpdo-block--wide { max-width: 900px; }
|
||||
.wpdo-col-param { width: 180px; }
|
||||
|
||||
/* Inline form row */
|
||||
.wpdo-form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
margin: var(--space-3) 0;
|
||||
}
|
||||
|
||||
/* ── Tables ──────────────────────────────────────────────────────────── */
|
||||
.wpdo-table {
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-family-body);
|
||||
}
|
||||
|
||||
.wpdo-table code,
|
||||
.wpdo-wrap code {
|
||||
font-size: var(--text-xs);
|
||||
background: var(--color-bg-tertiary, #F2EEE8);
|
||||
padding: 2px var(--space-1);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
|
||||
.wpdo-log-msg {
|
||||
max-width: 400px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ── Touch targets (CLAUDE.md: 44px minimum) ─────────────────────────── */
|
||||
.wpdo-wrap .button,
|
||||
.wpdo-wrap .button-secondary,
|
||||
.wpdo-wrap .button-primary,
|
||||
.wpdo-wrap input[type="number"],
|
||||
.wpdo-wrap input[type="text"],
|
||||
.wpdo-wrap input[type="search"],
|
||||
.wpdo-wrap select {
|
||||
min-height: 44px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.wpdo-wrap .button-small {
|
||||
min-height: 40px; /* WCAG 2.2 AA: 24px min; 40px exceeds 2.5.8 while remaining compact */
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
|
||||
.wpdo-wrap input[type="number"] {
|
||||
padding: 6px var(--space-2);
|
||||
}
|
||||
|
||||
.wpdo-wrap input[type="number"].wpdo-input--xs {
|
||||
width: 72px; /* was inline 60px — bumped to fit 44px height without clipping digits */
|
||||
}
|
||||
|
||||
.wpdo-wrap .button:focus-visible,
|
||||
.wpdo-wrap input:focus-visible,
|
||||
.wpdo-wrap select:focus-visible {
|
||||
outline: 2px solid var(--color-terra-primary, #C4897A);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Method pill for REST API tab (was inline #0073aa) ───────────────── */
|
||||
.wpdo-method-pill {
|
||||
background: var(--color-blue-primary, #9EB3C2);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
|
||||
/* Code block for curl examples (was inline #EFEFEF) */
|
||||
.wpdo-wrap .wpdo-code-block {
|
||||
background: var(--color-bg-tertiary, #F2EEE8);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-family-mono);
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
border: 1px solid var(--color-bg-divider, #E8E3D9);
|
||||
}
|
||||
|
||||
/* ── Zone badges (icon + text, not colour alone — WCAG 1.4.1) ────────── */
|
||||
.wpdo-zone {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.wpdo-zone::before {
|
||||
font-family: var(--font-family-mono);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.wpdo-zone-hot {
|
||||
background: var(--color-terra-primary, #C4897A);
|
||||
}
|
||||
.wpdo-zone-hot::before { content: "🔥"; }
|
||||
|
||||
.wpdo-zone-warm {
|
||||
background: var(--color-apricot-medium, #C8B090);
|
||||
}
|
||||
.wpdo-zone-warm::before { content: "◐"; }
|
||||
|
||||
.wpdo-zone-cold {
|
||||
background: var(--color-blue-medium, #8AA0B2);
|
||||
}
|
||||
.wpdo-zone-cold::before { content: "❄"; }
|
||||
|
||||
.wpdo-zone-archive {
|
||||
background: var(--color-gray-medium, #AFA8A0);
|
||||
}
|
||||
.wpdo-zone-archive::before { content: "▦"; }
|
||||
|
||||
/* ── State badges (shape-prefixed for colour-blind legibility) ───────── */
|
||||
.wpdo-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.wpdo-state::before {
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
|
||||
.wpdo-state-idle {
|
||||
background: var(--color-bg-tertiary, #F2EEE8);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
}
|
||||
.wpdo-state-idle::before { content: "○"; }
|
||||
|
||||
.wpdo-state-dual_write {
|
||||
background: var(--color-apricot-lightest, #EDD9C0);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
}
|
||||
.wpdo-state-dual_write::before { content: "⇄"; }
|
||||
|
||||
.wpdo-state-backfill {
|
||||
background: var(--color-blue-lightest, #C8D6DE);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
}
|
||||
.wpdo-state-backfill::before { content: "⟲"; }
|
||||
|
||||
.wpdo-state-verify {
|
||||
background: var(--color-apricot-lightest, #EDD9C0);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
}
|
||||
.wpdo-state-verify::before { content: "⚠"; }
|
||||
|
||||
.wpdo-state-cutover {
|
||||
background: var(--color-green-lightest, #C8D5C4);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
}
|
||||
.wpdo-state-cutover::before { content: "✓"; }
|
||||
|
||||
.wpdo-state-cleanup {
|
||||
background: var(--color-blue-lightest, #C8D6DE);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
}
|
||||
.wpdo-state-cleanup::before { content: "✂"; }
|
||||
|
||||
.wpdo-state-complete {
|
||||
background: var(--color-green-primary, #A8B9A4);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
}
|
||||
.wpdo-state-complete::before { content: "✔"; }
|
||||
|
||||
/* ── Generic badges ──────────────────────────────────────────────────── */
|
||||
.wpdo-badge {
|
||||
display: inline-block;
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
background: var(--color-bg-tertiary, #F2EEE8);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
}
|
||||
|
||||
.wpdo-badge-ok {
|
||||
background: var(--color-green-lightest, #C8D5C4);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
}
|
||||
|
||||
.wpdo-badge-warn {
|
||||
background: var(--color-apricot-lightest, #EDD9C0);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
}
|
||||
|
||||
/* ── Progress bars (transform-based — no layout-thrashing) ───────────── */
|
||||
.wpdo-progress {
|
||||
display: inline-block;
|
||||
width: 120px;
|
||||
height: 14px;
|
||||
background: var(--color-bg-tertiary, #F2EEE8);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
margin-right: var(--space-2);
|
||||
}
|
||||
|
||||
.wpdo-progress-bar {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--color-blue-primary, #9EB3C2);
|
||||
border-radius: var(--radius-full);
|
||||
transform-origin: left center;
|
||||
transform: scaleX(var(--wpdo-progress, 0));
|
||||
transition: transform var(--duration-slow, 0.4s) var(--ease-default, ease);
|
||||
}
|
||||
|
||||
.wpdo-progress-text {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
}
|
||||
|
||||
/* ── Confidence bar ──────────────────────────────────────────────────── */
|
||||
.wpdo-confidence {
|
||||
display: inline-block;
|
||||
width: 60px;
|
||||
height: 10px;
|
||||
background: var(--color-bg-tertiary, #F2EEE8);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
margin-right: var(--space-1);
|
||||
}
|
||||
|
||||
.wpdo-confidence-bar {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--color-green-primary, #A8B9A4);
|
||||
border-radius: var(--radius-full);
|
||||
transform-origin: left center;
|
||||
transform: scaleX(var(--wpdo-confidence, 0));
|
||||
}
|
||||
|
||||
/* ── Screen reader only ──────────────────────────────────────────────── */
|
||||
.wpdo-sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ── Tab red-dot badge (v2.8.1) ──────────────────────────────────────── */
|
||||
.nav-tab .wpdo-tab-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #dc3232;
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
box-shadow: 0 0 6px rgba(220, 50, 50, 0.6);
|
||||
animation: wpdo-tab-dot-pulse 2s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes wpdo-tab-dot-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(220, 50, 50, 0.7); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(220, 50, 50, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(220, 50, 50, 0); }
|
||||
}
|
||||
|
||||
/* ── Reduced motion ──────────────────────────────────────────────────── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wpdo-progress-bar {
|
||||
transition-duration: 0.01ms;
|
||||
}
|
||||
.nav-tab .wpdo-tab-dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* WP Data Optimizer — Admin JS (native fetch, no jQuery)
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
( function () {
|
||||
'use strict';
|
||||
|
||||
var WPDO = window.wpdoAdmin || {};
|
||||
var i18n = WPDO.i18n || {};
|
||||
|
||||
document.addEventListener(
|
||||
'DOMContentLoaded',
|
||||
function () {
|
||||
bindFlushCacheButtons();
|
||||
bindStatusRefresh();
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Announce a message via the shared aria-live region.
|
||||
* Falls back to creating the region on first call.
|
||||
*/
|
||||
function announce( message ) {
|
||||
var region = document.getElementById( 'wpdo-aria-live' );
|
||||
if ( ! region ) {
|
||||
region = document.createElement( 'div' );
|
||||
region.id = 'wpdo-aria-live';
|
||||
region.className = 'wpdo-sr-only';
|
||||
region.setAttribute( 'role', 'status' );
|
||||
region.setAttribute( 'aria-live', 'polite' );
|
||||
region.setAttribute( 'aria-atomic', 'true' );
|
||||
document.body.appendChild( region );
|
||||
}
|
||||
region.textContent = '';
|
||||
setTimeout(
|
||||
function () {
|
||||
region.textContent = message; },
|
||||
50
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an inline admin notice at the top of .wpdo-wrap.
|
||||
* Dismissable by user. Replaces legacy alert() for errors.
|
||||
*/
|
||||
function showNotice( message, type ) {
|
||||
type = type || 'error';
|
||||
var wrap = document.querySelector( '.wpdo-wrap' );
|
||||
if ( ! wrap ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var notice = document.createElement( 'div' );
|
||||
notice.className = 'notice notice-' + type + ' is-dismissible';
|
||||
notice.setAttribute( 'role', type === 'error' ? 'alert' : 'status' );
|
||||
|
||||
var p = document.createElement( 'p' );
|
||||
p.textContent = message;
|
||||
notice.appendChild( p );
|
||||
|
||||
var dismiss = document.createElement( 'button' );
|
||||
dismiss.type = 'button';
|
||||
dismiss.className = 'notice-dismiss';
|
||||
dismiss.setAttribute( 'aria-label', i18n.dismiss || 'Dismiss' );
|
||||
dismiss.addEventListener(
|
||||
'click',
|
||||
function () {
|
||||
notice.remove();
|
||||
}
|
||||
);
|
||||
notice.appendChild( dismiss );
|
||||
|
||||
// Insert after the H1 so it appears at the top of the content area.
|
||||
var h1 = wrap.querySelector( 'h1' );
|
||||
if ( h1 && h1.nextSibling ) {
|
||||
wrap.insertBefore( notice, h1.nextSibling );
|
||||
} else {
|
||||
wrap.prepend( notice );
|
||||
}
|
||||
|
||||
announce( message );
|
||||
}
|
||||
|
||||
/**
|
||||
* POST to admin-ajax.php using native fetch + FormData.
|
||||
* Returns a Promise resolving to the parsed JSON response.
|
||||
*/
|
||||
function wpdoAjax( actionType, data ) {
|
||||
var body = new FormData();
|
||||
body.append( 'action', 'wpdo_admin_action' );
|
||||
body.append( 'nonce', WPDO.nonce || '' );
|
||||
body.append( 'action_type', actionType );
|
||||
Object.keys( data || {} ).forEach(
|
||||
function ( key ) {
|
||||
body.append( key, data[ key ] );
|
||||
}
|
||||
);
|
||||
|
||||
return fetch(
|
||||
WPDO.ajaxUrl,
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
body: body,
|
||||
}
|
||||
)
|
||||
.then(
|
||||
function ( res ) {
|
||||
if ( ! res.ok ) {
|
||||
throw new Error( 'HTTP ' + res.status );
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush cache button handler — delegated click listener.
|
||||
*/
|
||||
function bindFlushCacheButtons() {
|
||||
document.addEventListener(
|
||||
'click',
|
||||
function ( e ) {
|
||||
var btn = e.target.closest( '.wpdo-flush-cache' );
|
||||
if ( ! btn ) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
|
||||
var postType = btn.getAttribute( 'data-post-type' );
|
||||
if ( ! postType ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var originalLabel = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = i18n.flushing || 'Flushing…';
|
||||
announce( btn.textContent );
|
||||
|
||||
wpdoAjax( 'flush_cache', { post_type: postType } )
|
||||
.then(
|
||||
function ( response ) {
|
||||
if ( response && response.success ) {
|
||||
btn.textContent = i18n.flushed || 'Flushed!';
|
||||
announce( btn.textContent );
|
||||
setTimeout(
|
||||
function () {
|
||||
btn.disabled = false;
|
||||
btn.textContent = i18n.flushLabel || originalLabel;
|
||||
},
|
||||
2000
|
||||
);
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.textContent = i18n.flushLabel || originalLabel;
|
||||
var msg = ( response && response.data ) ? String( response.data ) : ( i18n.error || 'Unknown error' );
|
||||
showNotice( msg, 'error' );
|
||||
}
|
||||
}
|
||||
)
|
||||
.catch(
|
||||
function () {
|
||||
btn.disabled = false;
|
||||
btn.textContent = i18n.flushLabel || originalLabel;
|
||||
showNotice( i18n.error || 'Request failed.', 'error' );
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-refresh module status on the Dashboard tab (every 30s).
|
||||
*/
|
||||
function bindStatusRefresh() {
|
||||
var isDashboard = document.querySelector( '.wpdo-wrap' )
|
||||
&& window.location.search.indexOf( 'tab=dashboard' ) !== -1;
|
||||
if ( ! isDashboard ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var timer = setInterval(
|
||||
function () {
|
||||
if ( ! document.hidden ) {
|
||||
refreshStatus();
|
||||
}
|
||||
},
|
||||
30000
|
||||
);
|
||||
|
||||
window.addEventListener(
|
||||
'beforeunload',
|
||||
function () {
|
||||
clearInterval( timer );
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function refreshStatus() {
|
||||
wpdoAjax( 'module_status', {} )
|
||||
.then(
|
||||
function ( response ) {
|
||||
if ( ! response || ! response.success || ! response.data ) {
|
||||
return;
|
||||
}
|
||||
var data = response.data;
|
||||
var changed = 0;
|
||||
var changedSummaries = [];
|
||||
|
||||
document.querySelectorAll( '.wpdo-state' ).forEach(
|
||||
function ( el ) {
|
||||
var row = el.closest( 'tr' );
|
||||
var codeEl = row ? row.querySelector( 'code' ) : null;
|
||||
if ( ! codeEl ) {
|
||||
return;
|
||||
}
|
||||
var module = codeEl.textContent;
|
||||
if ( ! module || ! ( module in data ) ) {
|
||||
return;
|
||||
}
|
||||
var newState = data[ module ];
|
||||
if ( el.textContent === newState ) {
|
||||
return;
|
||||
}
|
||||
el.textContent = newState;
|
||||
el.className = 'wpdo-state wpdo-state-' + newState;
|
||||
changed++;
|
||||
changedSummaries.push( module + ' → ' + newState );
|
||||
}
|
||||
);
|
||||
|
||||
if ( changed > 0 ) {
|
||||
announce( changedSummaries.join( '、' ) );
|
||||
}
|
||||
}
|
||||
)
|
||||
.catch(
|
||||
function () {
|
||||
// Fail silently on polling — user will see the stale state; not worth a notice.
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
} )();
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* WPDO Comment Stress Test — admin tab JS (v2.13.1)
|
||||
*
|
||||
* Mirrors wpdo-term-stress-test.js (v2.13.0) but:
|
||||
* - Talks to /wpdo/v1/comment-stress-test/* endpoints
|
||||
* - Sends post_id in start payload (not taxonomy)
|
||||
* - DOM IDs prefixed wpdo-cst-* (comment stress test)
|
||||
*
|
||||
* @since 2.13.1
|
||||
*/
|
||||
( function () {
|
||||
'use strict';
|
||||
|
||||
const cfg = window.wpdoCommentStressTest;
|
||||
if ( ! cfg || ! cfg.restUrl ) {
|
||||
return;
|
||||
}
|
||||
if ( ! document.querySelector( '.wpdo-comment-stress-test-tab' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $ = ( sel ) => document.querySelector( sel );
|
||||
const restUrl = cfg.restUrl.replace( /\/$/, '' );
|
||||
const headers = { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce };
|
||||
|
||||
let pollTimer = null;
|
||||
|
||||
// ── API helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
async function apiCall( path, method = 'GET', body = null ) {
|
||||
const opts = { method, headers, credentials: 'same-origin' };
|
||||
if ( body ) {
|
||||
opts.body = JSON.stringify( body );
|
||||
}
|
||||
const resp = await fetch( restUrl + path, opts );
|
||||
const text = await resp.text();
|
||||
try {
|
||||
return { ok: resp.ok, status: resp.status, data: text ? JSON.parse( text ) : null };
|
||||
} catch ( e ) {
|
||||
return { ok: false, status: resp.status, data: { error: text } };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────────────
|
||||
|
||||
function fmt( n ) {
|
||||
if ( n === null || n === undefined ) {
|
||||
return '—';
|
||||
}
|
||||
return Number( n ).toLocaleString();
|
||||
}
|
||||
|
||||
function setText( sel, text ) {
|
||||
const el = $( sel );
|
||||
if ( el ) {
|
||||
el.textContent = String( text );
|
||||
}
|
||||
}
|
||||
|
||||
function esc( s ) {
|
||||
if ( s === null || s === undefined ) {
|
||||
return '';
|
||||
}
|
||||
return String( s ).replace( /[&<>"']/g, ( c ) => ( {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[ c ] ) );
|
||||
}
|
||||
|
||||
function renderProgress( state ) {
|
||||
const isRunning = state.status === 'running' || state.status === 'benchmarking';
|
||||
const card = $( '#wpdo-cst-progress-card' );
|
||||
if ( card ) {
|
||||
card.style.display = ( isRunning || state.status === 'completed' || state.status === 'failed' || state.status === 'cancelled' ) ? '' : 'none';
|
||||
}
|
||||
setText( '#wpdo-cst-pg-status', state.status || 'idle' );
|
||||
setText( '#wpdo-cst-pg-post-id', state.post_id ? ( 'post #' + state.post_id ) : '' );
|
||||
setText( '#wpdo-cst-pg-mode', state.mode || '' );
|
||||
setText( '#wpdo-cst-pg-pct', ( state.pct || 0 ) + '%' );
|
||||
setText( '#wpdo-cst-pg-processed', fmt( state.processed || 0 ) );
|
||||
setText( '#wpdo-cst-pg-target', fmt( state.target || 0 ) );
|
||||
setText( '#wpdo-cst-pg-rate', state.rate_per_sec || 0 );
|
||||
setText( '#wpdo-cst-pg-elapsed', state.elapsed_sec || 0 );
|
||||
setText( '#wpdo-cst-pg-eta', state.eta_sec || 0 );
|
||||
setText( '#wpdo-cst-pg-batches', state.batches_done || 0 );
|
||||
setText( '#wpdo-cst-pg-mem', ( ( state.peak_memory || 0 ) / 1048576 ).toFixed( 1 ) );
|
||||
|
||||
const bar = $( '#wpdo-cst-pg-bar' );
|
||||
if ( bar ) {
|
||||
bar.style.width = ( state.pct || 0 ) + '%';
|
||||
}
|
||||
|
||||
const count = state.test_comment_count || 0;
|
||||
setText( '#wpdo-cst-count', fmt( count ) );
|
||||
setText( '#wpdo-cst-count-mirror', fmt( count ) );
|
||||
|
||||
const startBtn = $( '#wpdo-cst-start' );
|
||||
const cancelBtn = $( '#wpdo-cst-cancel' );
|
||||
const cleanupBtn = $( '#wpdo-cst-cleanup' );
|
||||
const benchBtn = $( '#wpdo-cst-rerun-bench' );
|
||||
if ( startBtn ) {
|
||||
startBtn.disabled = isRunning;
|
||||
}
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.disabled = ! isRunning;
|
||||
}
|
||||
if ( cleanupBtn ) {
|
||||
cleanupBtn.disabled = isRunning || count === 0;
|
||||
}
|
||||
if ( benchBtn ) {
|
||||
benchBtn.disabled = isRunning || count === 0;
|
||||
}
|
||||
}
|
||||
|
||||
function renderBenchmark( bench ) {
|
||||
if ( ! bench ) {
|
||||
return;
|
||||
}
|
||||
const card = $( '#wpdo-cst-bench-card' );
|
||||
const content = $( '#wpdo-cst-bench-content' );
|
||||
if ( ! card || ! content ) {
|
||||
return;
|
||||
}
|
||||
card.style.display = '';
|
||||
|
||||
const w = bench.write || {};
|
||||
const dbSizes = bench.db_sizes || [];
|
||||
const q = bench.query || {};
|
||||
|
||||
const labels = {
|
||||
point_rating_5: 'Point lookup (hp_rating = 5)',
|
||||
range_rating_top: 'Range scan (hp_rating >= 4 ORDER BY DESC)',
|
||||
eav_baseline: 'EAV baseline (wp_commentmeta 直查 hp_rating)',
|
||||
};
|
||||
|
||||
let html = '';
|
||||
// Write metrics
|
||||
html += '<h4 style="margin-bottom:6px;">▍ 寫入指標</h4>';
|
||||
html += '<table class="widefat" style="margin-bottom:14px;"><tbody>';
|
||||
html += `<tr><td>模式</td><td><code>${ esc( w.mode ) }</code></td></tr>`;
|
||||
html += `<tr><td>Post ID</td><td><code>#${ esc( w.post_id ) }</code></td></tr>`;
|
||||
html += `<tr><td>完成 / 目標</td><td>${ fmt( w.processed ) } / ${ fmt( w.target ) }</td></tr>`;
|
||||
html += `<tr><td>總耗時</td><td>${ fmt( w.elapsed_sec ) } 秒</td></tr>`;
|
||||
html += `<tr><td>平均速率</td><td><strong>${ fmt( w.rate_per_sec ) }</strong> comments/sec</td></tr>`;
|
||||
html += `<tr><td>批次數</td><td>${ fmt( w.batches_done ) }</td></tr>`;
|
||||
html += `<tr><td>批次最快/平均/最慢</td><td>${ fmt( w.batch_min_ms ) } / ${ fmt( w.batch_avg_ms ) } / ${ fmt( w.batch_max_ms ) } ms</td></tr>`;
|
||||
html += `<tr><td>PHP Peak Memory</td><td>${ fmt( w.peak_memory_mb ) } MB</td></tr>`;
|
||||
html += '</tbody></table>';
|
||||
|
||||
// DB sizes
|
||||
html += '<h4 style="margin-bottom:6px;">▍ DB 容量(comment 相關表)</h4>';
|
||||
html += '<table class="widefat striped" style="margin-bottom:14px;"><thead><tr>';
|
||||
html += '<th>Table</th><th>Rows</th><th>Data MB</th><th>Index MB</th><th>Total MB</th><th>Avg bytes/row</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
dbSizes.forEach( ( r ) => {
|
||||
html += `<tr><td><code>${ esc( r.table ) }</code></td><td>${ fmt( r.rows ) }</td>`;
|
||||
html += `<td>${ r.data_mb ?? '—' }</td><td>${ r.index_mb ?? '—' }</td>`;
|
||||
html += `<td><strong>${ r.total_mb ?? '—' }</strong></td><td>${ fmt( r.avg_bytes ) }</td></tr>`;
|
||||
} );
|
||||
html += '</tbody></table>';
|
||||
|
||||
// Query perf — 3 probes with EAV baseline last for visual speedup comparison
|
||||
html += '<h4 style="margin-bottom:6px;">▍ 查詢效能</h4>';
|
||||
html += '<table class="widefat striped" style="margin-bottom:8px;"><thead><tr>';
|
||||
html += '<th>測試項目</th><th>耗時 (ms)</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
const baselineMs = q.eav_baseline?.duration_ms ?? null;
|
||||
Object.keys( q ).forEach( ( key ) => {
|
||||
const v = q[ key ];
|
||||
if ( ! v || typeof v.duration_ms !== 'number' ) {
|
||||
return;
|
||||
}
|
||||
const label = labels[ key ] || key;
|
||||
let speedup = '';
|
||||
if ( baselineMs !== null && key !== 'eav_baseline' && v.duration_ms > 0 ) {
|
||||
const ratio = baselineMs / v.duration_ms;
|
||||
if ( ratio >= 1 ) {
|
||||
speedup = ` <span style="color:#28a745;font-weight:600;">(${ ratio.toFixed( 2 ) }× faster)</span>`;
|
||||
}
|
||||
}
|
||||
html += `<tr><td>${ esc( label ) }${ speedup }</td><td><strong>${ v.duration_ms }</strong></td></tr>`;
|
||||
} );
|
||||
html += '</tbody></table>';
|
||||
html += '<p class="description">EAV baseline 走 wp_commentmeta,flat probes 走 wpdo_comment_hp_review。倍率即此規模下反 EAV 的查詢加速。</p>';
|
||||
|
||||
content.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Polling ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function poll() {
|
||||
const r = await apiCall( '/comment-stress-test/status' );
|
||||
if ( ! r.ok || ! r.data ) {
|
||||
return;
|
||||
}
|
||||
renderProgress( r.data );
|
||||
if ( r.data.benchmark ) {
|
||||
renderBenchmark( r.data.benchmark );
|
||||
}
|
||||
if ( r.data.status !== 'running' && r.data.status !== 'benchmarking' ) {
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
poll();
|
||||
pollTimer = setInterval( poll, 2000 );
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if ( pollTimer ) {
|
||||
clearInterval( pollTimer );
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event handlers ──────────────────────────────────────────────────────
|
||||
|
||||
async function handleStart() {
|
||||
const post_id = parseInt( $( '#wpdo-cst-post-id' ).value, 10 );
|
||||
const target = parseInt( $( '#wpdo-cst-target' ).value, 10 );
|
||||
const batch = parseInt( $( '#wpdo-cst-batch' ).value, 10 );
|
||||
const mode = document.querySelector( 'input[name="wpdo-cst-mode"]:checked' ).value;
|
||||
|
||||
if ( ! post_id || post_id < 1 ) {
|
||||
alert( '請選擇目標 post' );
|
||||
return;
|
||||
}
|
||||
if ( ! target || target < 1 ) {
|
||||
alert( '請輸入有效的 comment 數量' );
|
||||
return;
|
||||
}
|
||||
if ( mode === 'realistic' && batch > 50 ) {
|
||||
if ( ! confirm( `Realistic 模式每個 comment 約需 30-100 ms(wp_insert_comment + update_comment_meta),batch_size=${ batch } 可能超過後端 8s deadline。建議 batch=10-30。要繼續嗎?` ) ) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Soft warn for combinations that won't demonstrate反 EAV 優化效果
|
||||
const commentMode = String( cfg.commentMode || 'disabled' );
|
||||
if ( mode === 'fast' && commentMode === 'aeav_only' ) {
|
||||
if ( ! confirm( `⚠️ Fast 模式直接 $wpdb->insert 繞過 Hook Bus,即使 comment mode=aeav_only 也會寫滿 wp_commentmeta。\n\n要驗證反 EAV 優化效果(wp_commentmeta 應為 0),請改用 🐢 Realistic 模式。\n\n仍以 Fast 模式繼續嗎?` ) ) {
|
||||
return;
|
||||
}
|
||||
} else if ( mode === 'realistic' && commentMode !== 'aeav_only' ) {
|
||||
if ( ! confirm( `⚠️ 目前 comment mode = ${ commentMode },Realistic 寫入仍會雙寫 wp_commentmeta(不展示優化效果)。\n\n要看 wp_commentmeta 完全短路請先升 mode 至 aeav_only(設定 tab)。\n\n仍以 ${ commentMode } 模式繼續嗎?` ) ) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const big = target >= 5000;
|
||||
const msg = `即將以 ${ mode } 模式建立 ${ target.toLocaleString() } 筆 comment(attached to post #${ post_id })${ big ? '(規模較大)' : '' }。\n\n所有 comment 的 author email 會以 @wpdo-stress.local 結尾,可一鍵清除。\n\n確定要開始嗎?`;
|
||||
if ( ! confirm( msg ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const r = await apiCall( '/comment-stress-test/start', 'POST', {
|
||||
post_id,
|
||||
target,
|
||||
mode,
|
||||
batch_size: batch,
|
||||
} );
|
||||
if ( ! r.ok ) {
|
||||
alert( '啟動失敗:' + ( r.data?.error || r.status ) );
|
||||
return;
|
||||
}
|
||||
const card = $( '#wpdo-cst-progress-card' );
|
||||
if ( card ) {
|
||||
card.style.display = '';
|
||||
}
|
||||
const benchCard = $( '#wpdo-cst-bench-card' );
|
||||
if ( benchCard ) {
|
||||
benchCard.style.display = 'none';
|
||||
}
|
||||
setText( '#wpdo-cst-pg-status', 'running' );
|
||||
setText( '#wpdo-cst-pg-post-id', 'post #' + post_id );
|
||||
setText( '#wpdo-cst-pg-mode', mode );
|
||||
setText( '#wpdo-cst-pg-target', target.toLocaleString() );
|
||||
startPolling();
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if ( ! confirm( '確定要取消當前測試?已建立的 comment 不會被刪除。' ) ) {
|
||||
return;
|
||||
}
|
||||
const cancelBtn = $( '#wpdo-cst-cancel' );
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.disabled = true;
|
||||
cancelBtn.textContent = '⏹ 取消中…';
|
||||
}
|
||||
setText( '#wpdo-cst-pg-status', 'cancelling' );
|
||||
|
||||
const r = await apiCall( '/comment-stress-test/cancel', 'POST' );
|
||||
if ( ! r.ok ) {
|
||||
alert( '取消失敗:' + ( r.data?.error || r.status ) );
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.disabled = false;
|
||||
cancelBtn.textContent = '⏹ 取消';
|
||||
}
|
||||
return;
|
||||
}
|
||||
poll();
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.textContent = '⏹ 取消';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCleanup() {
|
||||
if ( ! confirm( '確定要清除所有 stress test comments?\n\n此動作會:\n- DELETE 所有 email 後綴 @wpdo-stress.local 的 comment\n- DELETE 對應 wp_commentmeta\n- DELETE flat 表中對應 comment_id 的列\n\n不可復原!' ) ) {
|
||||
return;
|
||||
}
|
||||
const r = await apiCall( '/comment-stress-test/cleanup', 'DELETE' );
|
||||
if ( ! r.ok ) {
|
||||
alert( '清除失敗:' + ( r.data?.error || r.status ) );
|
||||
return;
|
||||
}
|
||||
alert( `已清除 ${ r.data.deleted } 筆測試 comment。` );
|
||||
const card = $( '#wpdo-cst-progress-card' );
|
||||
const benchCard = $( '#wpdo-cst-bench-card' );
|
||||
if ( card ) card.style.display = 'none';
|
||||
if ( benchCard ) benchCard.style.display = 'none';
|
||||
poll();
|
||||
}
|
||||
|
||||
async function handleRerunBench() {
|
||||
const btn = $( '#wpdo-cst-rerun-bench' );
|
||||
if ( btn ) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ 執行中...';
|
||||
}
|
||||
const r = await apiCall( '/comment-stress-test/benchmark', 'POST' );
|
||||
if ( btn ) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '📊 重跑 Benchmark(不新增資料)';
|
||||
}
|
||||
if ( ! r.ok ) {
|
||||
alert( 'Benchmark 失敗:' + ( r.data?.error || r.status ) );
|
||||
return;
|
||||
}
|
||||
renderBenchmark( r.data.benchmark );
|
||||
}
|
||||
|
||||
// ── Init ────────────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener( 'DOMContentLoaded', () => {
|
||||
const startBtn = $( '#wpdo-cst-start' );
|
||||
const cancelBtn = $( '#wpdo-cst-cancel' );
|
||||
const cleanupBtn = $( '#wpdo-cst-cleanup' );
|
||||
const benchBtn = $( '#wpdo-cst-rerun-bench' );
|
||||
|
||||
if ( startBtn ) startBtn.addEventListener( 'click', handleStart );
|
||||
if ( cancelBtn ) cancelBtn.addEventListener( 'click', handleCancel );
|
||||
if ( cleanupBtn ) cleanupBtn.addEventListener( 'click', handleCleanup );
|
||||
if ( benchBtn ) benchBtn.addEventListener( 'click', handleRerunBench );
|
||||
|
||||
poll().then( () => {
|
||||
const status = ( $( '#wpdo-cst-pg-status' )?.textContent || '' ).trim();
|
||||
if ( status === 'running' || status === 'benchmarking' ) {
|
||||
startPolling();
|
||||
}
|
||||
} );
|
||||
} );
|
||||
} )();
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* WPDO Entity Bridge — Admin UI controller
|
||||
*
|
||||
* Features:
|
||||
* - Polls /wp-json/wpdo/v1/entity-bridge/health every 5 s during active backfill
|
||||
* - Updates coverage progress bars and migration badges in real time
|
||||
* - Handles Backfill / Promote / Demote button clicks with confirmation
|
||||
*
|
||||
* Depends on: wpdoEntityBridge (wp_localize_script data)
|
||||
*/
|
||||
|
||||
/* global wpdoEntityBridge */
|
||||
( function () {
|
||||
'use strict';
|
||||
|
||||
var cfg = window.wpdoEntityBridge || {};
|
||||
var restUrl = cfg.restUrl || '';
|
||||
var nonce = cfg.nonce || '';
|
||||
var pollInterval = 5000; // ms between polls
|
||||
var timer = null;
|
||||
var activeBackfills = {}; // {entity_type: {group_name: true}} — track in-progress
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function apiFetch( method, path, body ) {
|
||||
var url = restUrl + path;
|
||||
var opts = {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': nonce,
|
||||
},
|
||||
};
|
||||
if ( body ) {
|
||||
opts.body = JSON.stringify( body );
|
||||
}
|
||||
return fetch( url, opts ).then( function ( res ) {
|
||||
if ( ! res.ok ) {
|
||||
return res.json().then( function ( e ) { throw new Error( e.message || e.error || res.status ); } );
|
||||
}
|
||||
return res.json();
|
||||
} );
|
||||
}
|
||||
|
||||
function modeBadgeClass( mode ) {
|
||||
return {
|
||||
disabled: 'wpdo-mode-disabled',
|
||||
dual_write: 'wpdo-mode-dual-write',
|
||||
shadow_read: 'wpdo-mode-shadow-read',
|
||||
aeav_only: 'wpdo-mode-aeav-only',
|
||||
}[ mode ] || 'wpdo-mode-disabled';
|
||||
}
|
||||
|
||||
function modeLabel( mode ) {
|
||||
return {
|
||||
disabled: 'disabled',
|
||||
dual_write: 'dual_write',
|
||||
shadow_read: 'shadow_read',
|
||||
aeav_only: 'aeav_only',
|
||||
}[ mode ] || mode;
|
||||
}
|
||||
|
||||
function pct( n ) {
|
||||
return Math.min( 100, Math.max( 0, parseFloat( n ) || 0 ) ).toFixed( 1 );
|
||||
}
|
||||
|
||||
// ─── Update UI from health data ──────────────────────────────────────────
|
||||
|
||||
function updateAll( data ) {
|
||||
var hasActive = false;
|
||||
|
||||
Object.keys( data ).forEach( function ( type ) {
|
||||
var card = document.querySelector( '[data-entity-type="' + type + '"]' );
|
||||
if ( ! card ) return;
|
||||
|
||||
var info = data[ type ];
|
||||
|
||||
// Mode badge
|
||||
var badge = card.querySelector( '.wpdo-mode-badge' );
|
||||
if ( badge ) {
|
||||
badge.textContent = modeLabel( info.mode );
|
||||
badge.className = 'wpdo-mode-badge ' + modeBadgeClass( info.mode );
|
||||
}
|
||||
|
||||
// Pipeline dots
|
||||
var pipeline = card.querySelector( '.wpdo-pipeline' );
|
||||
if ( pipeline ) {
|
||||
pipeline.innerHTML = buildPipeline( info.mode );
|
||||
}
|
||||
|
||||
// Mode days
|
||||
var daysEl = card.querySelector( '.wpdo-mode-days' );
|
||||
if ( daysEl ) {
|
||||
daysEl.textContent = info.mode_days + ' 天';
|
||||
}
|
||||
|
||||
// Groups
|
||||
( info.groups || [] ).forEach( function ( g ) {
|
||||
var groupEl = card.querySelector( '[data-group="' + g.name + '"]' );
|
||||
if ( ! groupEl ) return;
|
||||
|
||||
// Progress bar
|
||||
var bar = groupEl.querySelector( '.wpdo-cov-bar-fill' );
|
||||
if ( bar ) bar.style.width = pct( g.coverage_pct ) + '%';
|
||||
|
||||
var pctEl = groupEl.querySelector( '.wpdo-cov-pct' );
|
||||
if ( pctEl ) pctEl.textContent = pct( g.coverage_pct ) + '%';
|
||||
|
||||
var rowsEl = groupEl.querySelector( '.wpdo-cov-rows' );
|
||||
if ( rowsEl ) rowsEl.textContent = g.flat_rows + ' / ' + g.eav_rows;
|
||||
|
||||
// Migration badge
|
||||
var migBadge = groupEl.querySelector( '.wpdo-mig-status' );
|
||||
if ( migBadge ) {
|
||||
migBadge.textContent = g.migration_status;
|
||||
migBadge.className = 'wpdo-mig-status wpdo-mig-' + g.migration_status.replace( /_/g, '-' );
|
||||
}
|
||||
|
||||
// Is this group's backfill active?
|
||||
if ( g.migration_status === 'running' ) {
|
||||
hasActive = true;
|
||||
}
|
||||
} );
|
||||
|
||||
// Shadow diffs
|
||||
var diffsEl = card.querySelector( '.wpdo-shadow-diffs' );
|
||||
if ( diffsEl ) diffsEl.textContent = info.shadow_diffs;
|
||||
|
||||
// Recommendation
|
||||
var recEl = card.querySelector( '.wpdo-recommendation' );
|
||||
if ( recEl ) recEl.textContent = info.recommendation;
|
||||
|
||||
// Auto-promote eligible badge
|
||||
var apEl = card.querySelector( '.wpdo-auto-promote-eligible' );
|
||||
if ( apEl ) {
|
||||
if ( info.auto_promote && info.auto_promote.eligible ) {
|
||||
apEl.textContent = '✓ 可升級';
|
||||
apEl.style.color = '#155724';
|
||||
} else {
|
||||
apEl.textContent = '— ' + ( ( info.auto_promote || {} ).reason || '' );
|
||||
apEl.style.color = '#856404';
|
||||
}
|
||||
}
|
||||
|
||||
// Button states — update promote/demote targets
|
||||
var promoteBtn = card.querySelector( '.wpdo-btn-promote' );
|
||||
if ( promoteBtn ) {
|
||||
promoteBtn.disabled = ! info.next_mode;
|
||||
promoteBtn.dataset.nextMode = info.next_mode || '';
|
||||
promoteBtn.title = info.next_mode ? '升級到 ' + info.next_mode : '已在最高模式';
|
||||
}
|
||||
|
||||
var demoteBtn = card.querySelector( '.wpdo-btn-demote' );
|
||||
if ( demoteBtn ) {
|
||||
demoteBtn.disabled = ! info.prev_mode;
|
||||
demoteBtn.dataset.prevMode = info.prev_mode || '';
|
||||
demoteBtn.title = info.prev_mode ? '降級到 ' + info.prev_mode : '已在最低模式';
|
||||
}
|
||||
|
||||
// Backfill active indicator
|
||||
if ( info.backfill_active ) hasActive = true;
|
||||
} );
|
||||
|
||||
// Manage poll timer
|
||||
if ( hasActive ) {
|
||||
startPolling();
|
||||
}
|
||||
}
|
||||
|
||||
function buildPipeline( currentMode ) {
|
||||
var modes = [ 'disabled', 'dual_write', 'shadow_read', 'aeav_only' ];
|
||||
var labels = { disabled: 'disabled', dual_write: 'dual_write', shadow_read: 'shadow_read', aeav_only: 'aeav_only' };
|
||||
return modes.map( function ( m ) {
|
||||
var active = m === currentMode ? ' wpdo-pipeline-active' : '';
|
||||
return '<span class="wpdo-pipeline-dot' + active + '" title="' + labels[ m ] + '">' +
|
||||
'<span class="wpdo-dot"></span>' +
|
||||
'<span class="wpdo-dot-label">' + labels[ m ] + '</span>' +
|
||||
'</span>';
|
||||
} ).join( '<span class="wpdo-pipeline-arrow">→</span>' );
|
||||
}
|
||||
|
||||
// ─── Polling ─────────────────────────────────────────────────────────────
|
||||
|
||||
function poll() {
|
||||
apiFetch( 'GET', '/entity-bridge/health' )
|
||||
.then( updateAll )
|
||||
.catch( function ( e ) { console.warn( '[WPDO] Health poll error:', e ); } );
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if ( timer ) return;
|
||||
timer = setInterval( poll, pollInterval );
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if ( timer ) { clearInterval( timer ); timer = null; }
|
||||
}
|
||||
|
||||
// ─── Button handlers ─────────────────────────────────────────────────────
|
||||
|
||||
function handleBackfill( btn ) {
|
||||
var entityType = btn.dataset.entityType;
|
||||
var groupName = btn.dataset.groupName;
|
||||
if ( ! confirm( '確定要啟動 ' + entityType + '/' + groupName + ' 的 Backfill 遷移嗎?這將清除現有進度並重新開始。' ) ) return;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '排程中…';
|
||||
|
||||
apiFetch( 'POST', '/entity-bridge/backfill', { entity_type: entityType, group_name: groupName } )
|
||||
.then( function () {
|
||||
btn.textContent = '已排程 ✓';
|
||||
startPolling();
|
||||
// Refresh once immediately
|
||||
setTimeout( poll, 1000 );
|
||||
} )
|
||||
.catch( function ( e ) {
|
||||
alert( '啟動 Backfill 失敗:' + e.message );
|
||||
btn.disabled = false;
|
||||
btn.textContent = '啟動 Backfill';
|
||||
} );
|
||||
}
|
||||
|
||||
function handlePromote( btn ) {
|
||||
var entityType = btn.dataset.entityType;
|
||||
var nextMode = btn.dataset.nextMode;
|
||||
if ( ! nextMode ) return;
|
||||
|
||||
if ( ! confirm( '確定要將 ' + entityType + ' 升級到 ' + nextMode + ' 嗎?' ) ) return;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '更新中…';
|
||||
|
||||
apiFetch( 'POST', '/entity-bridge/promote', { entity_type: entityType } )
|
||||
.then( function () {
|
||||
poll(); // immediate refresh
|
||||
} )
|
||||
.catch( function ( e ) {
|
||||
alert( '升級失敗:' + e.message );
|
||||
btn.disabled = false;
|
||||
btn.textContent = '升級模式';
|
||||
} );
|
||||
}
|
||||
|
||||
function handleDemote( btn ) {
|
||||
var entityType = btn.dataset.entityType;
|
||||
var prevMode = btn.dataset.prevMode;
|
||||
if ( ! prevMode ) return;
|
||||
|
||||
if ( ! confirm( '確定要將 ' + entityType + ' 降級到 ' + prevMode + ' 嗎?降級後讀取會切回 EAV。' ) ) return;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '更新中…';
|
||||
|
||||
apiFetch( 'POST', '/entity-bridge/demote', { entity_type: entityType } )
|
||||
.then( function () {
|
||||
poll();
|
||||
} )
|
||||
.catch( function ( e ) {
|
||||
alert( '降級失敗:' + e.message );
|
||||
btn.disabled = false;
|
||||
btn.textContent = '降級模式';
|
||||
} );
|
||||
}
|
||||
|
||||
// ─── Event delegation ────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener( 'click', function ( e ) {
|
||||
var btn = e.target.closest( 'button[data-wpdo-action]' );
|
||||
if ( ! btn ) return;
|
||||
|
||||
var action = btn.dataset.wpdoAction;
|
||||
|
||||
if ( action === 'backfill' ) {
|
||||
handleBackfill( btn );
|
||||
} else if ( action === 'promote' ) {
|
||||
handlePromote( btn );
|
||||
} else if ( action === 'demote' ) {
|
||||
handleDemote( btn );
|
||||
}
|
||||
} );
|
||||
|
||||
// ─── Init ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Initial poll on page load if we're on the entity-bridge tab.
|
||||
if ( document.querySelector( '.wpdo-entity-bridge-tab' ) ) {
|
||||
poll();
|
||||
|
||||
// Auto-start polling if any backfill is already running (check server state).
|
||||
// A subsequent poll() response will call startPolling() if needed.
|
||||
}
|
||||
|
||||
// Stop polling when navigating away.
|
||||
window.addEventListener( 'beforeunload', stopPolling );
|
||||
|
||||
} )();
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* WP Data Optimizer — Migration Wizard styling.
|
||||
*
|
||||
* Consumes the Morandi design system tokens (var(--color-*), var(--space-*),
|
||||
* var(--radius-*), var(--shadow-*), var(--ease-default), var(--duration-normal),
|
||||
* var(--font-family-display), var(--font-family-body)).
|
||||
*
|
||||
* @since 2.8.0
|
||||
*/
|
||||
|
||||
.wpdo-migration-wizard {
|
||||
max-width: 1100px;
|
||||
font-family: var(--font-family-body, system-ui);
|
||||
color: var(--color-ink, #2c2618);
|
||||
}
|
||||
|
||||
.wpdo-mw-header h2 {
|
||||
font-family: var(--font-family-display, serif);
|
||||
font-size: 1.6rem;
|
||||
margin-bottom: var(--space-2, 8px);
|
||||
}
|
||||
|
||||
.wpdo-mw-panel {
|
||||
background: var(--color-surface-card, #fbf7f0);
|
||||
border: 1px solid var(--color-border-soft, #e5dccd);
|
||||
border-radius: var(--radius-md, 12px);
|
||||
padding: var(--space-6, 24px);
|
||||
margin-bottom: var(--space-5, 20px);
|
||||
box-shadow: var(--shadow-sm, 0 1px 3px rgba(60, 40, 20, 0.06));
|
||||
}
|
||||
|
||||
.wpdo-mw-panel h3 {
|
||||
margin-top: 0;
|
||||
font-family: var(--font-family-display, serif);
|
||||
}
|
||||
|
||||
/* ── Pre-flight metrics grid ─────────────────────────────────── */
|
||||
.wpdo-mw-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: var(--space-3, 12px);
|
||||
margin-bottom: var(--space-4, 16px);
|
||||
}
|
||||
|
||||
.wpdo-mw-metric {
|
||||
background: var(--color-surface, #fff);
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
padding: var(--space-3, 12px) var(--space-4, 16px);
|
||||
border: 1px solid var(--color-border-soft, #e5dccd);
|
||||
}
|
||||
|
||||
.wpdo-mw-metric-primary {
|
||||
background: var(--color-accent-soft, #f4e9d4);
|
||||
border-color: var(--color-accent, #c9a55a);
|
||||
}
|
||||
|
||||
.wpdo-mw-metric-label {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-ink-soft, #6b5d44);
|
||||
margin-bottom: var(--space-1, 4px);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.wpdo-mw-metric-value {
|
||||
display: block;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-family-display, serif);
|
||||
}
|
||||
|
||||
.wpdo-mw-mode {
|
||||
font-size: 0.95rem;
|
||||
font-family: var(--font-family-body, monospace);
|
||||
padding: var(--space-1, 4px) var(--space-2, 8px);
|
||||
border-radius: var(--radius-pill, 999px);
|
||||
}
|
||||
|
||||
.wpdo-mw-mode-aeav_only { background: #d8efd8; color: #2c5a2c; }
|
||||
.wpdo-mw-mode-shadow_read { background: #fff3cd; color: #7c5e1e; }
|
||||
.wpdo-mw-mode-dual_write { background: #d8e4f0; color: #2c4a6a; }
|
||||
.wpdo-mw-mode-disabled { background: #eaeaea; color: #555; }
|
||||
|
||||
.wpdo-mw-zero { color: var(--color-ink-soft, #aaa); }
|
||||
|
||||
.wpdo-mw-groups summary {
|
||||
cursor: pointer;
|
||||
margin-bottom: var(--space-2, 8px);
|
||||
color: var(--color-ink-soft, #6b5d44);
|
||||
}
|
||||
|
||||
.wpdo-mw-groups table {
|
||||
margin-top: var(--space-2, 8px);
|
||||
}
|
||||
|
||||
/* ── Run panel ───────────────────────────────────────────────── */
|
||||
.wpdo-mw-nothing-todo {
|
||||
text-align: center;
|
||||
padding: var(--space-8, 32px);
|
||||
color: var(--color-ink-soft, #6b5d44);
|
||||
}
|
||||
|
||||
.wpdo-mw-nothing-todo h3 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: var(--space-3, 12px);
|
||||
}
|
||||
|
||||
.wpdo-mw-estimate {
|
||||
background: var(--color-surface, #fff);
|
||||
border-left: 3px solid var(--color-accent, #c9a55a);
|
||||
padding: var(--space-3, 12px) var(--space-4, 16px);
|
||||
margin: var(--space-4, 16px) 0;
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
}
|
||||
|
||||
.wpdo-mw-options {
|
||||
border: 1px solid var(--color-border-soft, #e5dccd);
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
padding: var(--space-4, 16px);
|
||||
margin: var(--space-4, 16px) 0;
|
||||
}
|
||||
|
||||
.wpdo-mw-options legend {
|
||||
padding: 0 var(--space-2, 8px);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wpdo-mw-options label {
|
||||
display: block;
|
||||
margin: var(--space-2, 8px) 0;
|
||||
cursor: pointer;
|
||||
transition: color var(--duration-normal, 200ms) var(--ease-default, ease);
|
||||
}
|
||||
|
||||
.wpdo-mw-options label:hover {
|
||||
color: var(--color-accent, #c9a55a);
|
||||
}
|
||||
|
||||
.wpdo-mw-confirm {
|
||||
background: var(--color-warn-soft, #fff3cd);
|
||||
border: 1px solid var(--color-warn, #d4a04f);
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
padding: var(--space-3, 12px) var(--space-4, 16px);
|
||||
margin: var(--space-4, 16px) 0;
|
||||
}
|
||||
|
||||
.wpdo-mw-confirm label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#wpdo-mw-start {
|
||||
min-height: 44px;
|
||||
font-size: 1.05rem;
|
||||
padding: var(--space-3, 12px) var(--space-6, 24px);
|
||||
border-radius: var(--radius-md, 12px);
|
||||
transition: transform var(--duration-normal, 200ms) var(--ease-default, ease);
|
||||
}
|
||||
|
||||
#wpdo-mw-start:not([disabled]):hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ── Progress panel ──────────────────────────────────────────── */
|
||||
.wpdo-mw-progress-bar {
|
||||
position: relative;
|
||||
background: var(--color-surface, #fff);
|
||||
border: 1px solid var(--color-border-soft, #e5dccd);
|
||||
border-radius: var(--radius-pill, 999px);
|
||||
height: 28px;
|
||||
overflow: hidden;
|
||||
margin: var(--space-4, 16px) 0;
|
||||
}
|
||||
|
||||
.wpdo-mw-progress-fill {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-accent, #c9a55a),
|
||||
var(--color-accent-strong, #b08c3a)
|
||||
);
|
||||
height: 100%;
|
||||
transition: width var(--duration-normal, 300ms) var(--ease-default, ease);
|
||||
}
|
||||
|
||||
.wpdo-mw-progress-pct {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-family-display, serif);
|
||||
mix-blend-mode: difference;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wpdo-mw-current {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: var(--space-3, 12px) 0;
|
||||
}
|
||||
|
||||
.wpdo-mw-current code {
|
||||
background: var(--color-surface, #fff);
|
||||
padding: var(--space-1, 4px) var(--space-2, 8px);
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
font-family: var(--font-family-body, monospace);
|
||||
}
|
||||
|
||||
.wpdo-mw-elapsed {
|
||||
font-family: var(--font-family-display, serif);
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-ink-soft, #6b5d44);
|
||||
min-width: 70px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.wpdo-mw-live-ratio {
|
||||
background: var(--color-accent-soft, #f4e9d4);
|
||||
padding: var(--space-2, 8px) var(--space-4, 16px);
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
margin: var(--space-3, 12px) 0;
|
||||
font-family: var(--font-family-display, serif);
|
||||
}
|
||||
|
||||
.wpdo-mw-log-wrap h4 {
|
||||
margin: var(--space-4, 16px) 0 var(--space-2, 8px);
|
||||
font-family: var(--font-family-display, serif);
|
||||
}
|
||||
|
||||
.wpdo-mw-log {
|
||||
background: var(--color-ink, #2c2618);
|
||||
color: var(--color-paper, #f4ecdb);
|
||||
padding: var(--space-4, 16px);
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
font-family: var(--font-family-body, monospace);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wpdo-mw-log-flash {
|
||||
animation: wpdo-mw-log-pulse var(--duration-normal, 350ms) var(--ease-default, ease);
|
||||
}
|
||||
|
||||
@keyframes wpdo-mw-log-pulse {
|
||||
0% { box-shadow: inset 0 0 0 0 var(--color-accent, #c9a55a); }
|
||||
30% { box-shadow: inset 0 0 30px -8px var(--color-accent, #c9a55a); }
|
||||
100% { box-shadow: inset 0 0 0 0 transparent; }
|
||||
}
|
||||
|
||||
.wpdo-mw-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2, 8px);
|
||||
margin-top: var(--space-4, 16px);
|
||||
}
|
||||
|
||||
.wpdo-mw-failed {
|
||||
color: var(--color-error, #b03d3d);
|
||||
}
|
||||
|
||||
/* ── Done panel ──────────────────────────────────────────────── */
|
||||
.wpdo-mw-done {
|
||||
background: var(--color-success-soft, #d8efd8);
|
||||
border-color: var(--color-success, #5a8c5a);
|
||||
}
|
||||
|
||||
.wpdo-mw-done-summary {
|
||||
margin: var(--space-4, 16px) 0;
|
||||
}
|
||||
|
||||
.wpdo-mw-done-line {
|
||||
padding: var(--space-2, 8px) 0;
|
||||
border-bottom: 1px dashed var(--color-border-soft, #e5dccd);
|
||||
}
|
||||
|
||||
.wpdo-mw-done-line:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.wpdo-mw-done-line span {
|
||||
display: inline-block;
|
||||
min-width: 100px;
|
||||
color: var(--color-ink-soft, #6b5d44);
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* WP Data Optimizer — One-click User Migration Wizard frontend.
|
||||
*
|
||||
* - Confirms options + checkbox before starting.
|
||||
* - Calls REST endpoints under /wp-json/wpdo/v1/migration/.
|
||||
* - Polls /status every 500ms while job is active.
|
||||
* - Streams log lines with fade-in; live-updates ratio + progress bar.
|
||||
*
|
||||
* @since 2.8.0
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const POLL_INTERVAL_MS = 500;
|
||||
const config = window.wpdoMigrationWizard || {};
|
||||
const i18n = config.i18n || {};
|
||||
const restUrl = (config.restUrl || '').replace(/\/$/, '');
|
||||
const nonce = config.nonce || '';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const root = document.querySelector('.wpdo-migration-wizard');
|
||||
if (!root) return;
|
||||
|
||||
const els = {
|
||||
runPanel: $('wpdo-mw-run-panel'),
|
||||
progressPanel: $('wpdo-mw-progress-panel'),
|
||||
donePanel: $('wpdo-mw-done-panel'),
|
||||
startBtn: $('wpdo-mw-start'),
|
||||
cancelBtn: $('wpdo-mw-cancel'),
|
||||
resumeBtn: $('wpdo-mw-resume'),
|
||||
resetBtn: $('wpdo-mw-reset'),
|
||||
confirmBox: $('wpdo-mw-confirm-backup'),
|
||||
optBackup: $('wpdo-mw-opt-backup'),
|
||||
optStrict: $('wpdo-mw-opt-strict'),
|
||||
opt24h: $('wpdo-mw-opt-24h'),
|
||||
optAsync: $('wpdo-mw-opt-async'),
|
||||
optDryRun: $('wpdo-mw-opt-dryrun'),
|
||||
progressFill: $('wpdo-mw-progress-fill'),
|
||||
progressPct: $('wpdo-mw-progress-pct'),
|
||||
progressTitle: $('wpdo-mw-progress-title'),
|
||||
phaseName: $('wpdo-mw-current-phase-name'),
|
||||
elapsed: $('wpdo-mw-elapsed'),
|
||||
liveRatio: $('wpdo-mw-live-ratio-text'),
|
||||
log: $('wpdo-mw-log'),
|
||||
ratio: $('wpdo-mw-ratio'),
|
||||
residue: $('wpdo-mw-residue'),
|
||||
usermeta: $('wpdo-mw-usermeta'),
|
||||
doneSummary: $('wpdo-mw-done-summary'),
|
||||
};
|
||||
|
||||
let pollTimer = null;
|
||||
let startedAt = 0;
|
||||
let elapsedTimer = null;
|
||||
|
||||
function show(panel) {
|
||||
[els.runPanel, els.progressPanel, els.donePanel].forEach((p) => {
|
||||
if (!p) return;
|
||||
if (p === panel) {
|
||||
p.removeAttribute('hidden');
|
||||
} else {
|
||||
p.setAttribute('hidden', '');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fetchJson(path, options = {}) {
|
||||
const url = restUrl + path;
|
||||
const opts = Object.assign(
|
||||
{
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-WP-Nonce': nonce, 'Content-Type': 'application/json' },
|
||||
},
|
||||
options
|
||||
);
|
||||
return fetch(url, opts).then(async (r) => {
|
||||
let body;
|
||||
try {
|
||||
body = await r.json();
|
||||
} catch (e) {
|
||||
body = null;
|
||||
}
|
||||
return { ok: r.ok, status: r.status, body };
|
||||
});
|
||||
}
|
||||
|
||||
// ── Start handling ───────────────────────────────────────────────────
|
||||
|
||||
if (els.confirmBox && els.startBtn) {
|
||||
els.confirmBox.addEventListener('change', () => {
|
||||
els.startBtn.disabled = !els.confirmBox.checked;
|
||||
});
|
||||
}
|
||||
|
||||
if (els.startBtn) {
|
||||
els.startBtn.addEventListener('click', () => {
|
||||
if (!confirm(i18n.confirmStart || 'Start migration?')) return;
|
||||
els.startBtn.disabled = true;
|
||||
els.startBtn.textContent = '…';
|
||||
|
||||
const opts = {
|
||||
auto_backup: els.optBackup ? !!els.optBackup.checked : true,
|
||||
verify_strict: els.optStrict ? !!els.optStrict.checked : true,
|
||||
verify_24h: els.opt24h ? !!els.opt24h.checked : false,
|
||||
force_async: els.optAsync ? !!els.optAsync.checked : false,
|
||||
dry_run: els.optDryRun ? !!els.optDryRun.checked : false,
|
||||
};
|
||||
|
||||
fetchJson('/migration/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(opts),
|
||||
}).then((res) => {
|
||||
if (!res.ok) {
|
||||
if (res.body && res.body.reason === 'nothing_to_do') {
|
||||
alert(i18n.nothingToDo);
|
||||
els.startBtn.disabled = false;
|
||||
els.startBtn.textContent = '🚀';
|
||||
return;
|
||||
}
|
||||
alert((res.body && res.body.error) || 'Start failed');
|
||||
els.startBtn.disabled = false;
|
||||
els.startBtn.textContent = '🚀';
|
||||
return;
|
||||
}
|
||||
startedAt = Date.now();
|
||||
show(els.progressPanel);
|
||||
startElapsedTimer();
|
||||
startPolling();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Cancel & Resume ──────────────────────────────────────────────────
|
||||
|
||||
if (els.cancelBtn) {
|
||||
els.cancelBtn.addEventListener('click', () => {
|
||||
if (!confirm(i18n.confirmCancel || 'Cancel?')) return;
|
||||
els.cancelBtn.disabled = true;
|
||||
fetchJson('/migration/cancel', { method: 'POST' }).then(() => {
|
||||
stopPolling();
|
||||
stopElapsedTimer();
|
||||
setTimeout(() => location.reload(), 600);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (els.resumeBtn) {
|
||||
els.resumeBtn.addEventListener('click', () => {
|
||||
els.resumeBtn.disabled = true;
|
||||
fetchJson('/migration/resume', { method: 'POST' }).then((res) => {
|
||||
if (res.ok) {
|
||||
els.resumeBtn.setAttribute('hidden', '');
|
||||
els.cancelBtn.disabled = false;
|
||||
startPolling();
|
||||
} else {
|
||||
alert((res.body && res.body.error) || 'Resume failed');
|
||||
els.resumeBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (els.resetBtn) {
|
||||
els.resetBtn.addEventListener('click', () => location.reload());
|
||||
}
|
||||
|
||||
// ── Polling ──────────────────────────────────────────────────────────
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer) return;
|
||||
const tick = () => {
|
||||
fetchJson('/migration/status').then((res) => {
|
||||
if (!res.ok || !res.body) {
|
||||
pollTimer = setTimeout(tick, POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
renderStatus(res.body);
|
||||
const state = res.body.state;
|
||||
if (state === 'running') {
|
||||
pollTimer = setTimeout(tick, POLL_INTERVAL_MS);
|
||||
} else if (state === 'completed') {
|
||||
stopPolling();
|
||||
stopElapsedTimer();
|
||||
renderDone(res.body);
|
||||
} else if (state === 'failed') {
|
||||
stopPolling();
|
||||
stopElapsedTimer();
|
||||
renderFailed(res.body);
|
||||
} else if (state === 'cancelled' || state === 'idle') {
|
||||
stopPolling();
|
||||
stopElapsedTimer();
|
||||
}
|
||||
});
|
||||
};
|
||||
tick();
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startElapsedTimer() {
|
||||
if (elapsedTimer) return;
|
||||
elapsedTimer = setInterval(() => {
|
||||
if (!els.elapsed) return;
|
||||
const sec = (Date.now() - startedAt) / 1000;
|
||||
els.elapsed.textContent = sec.toFixed(1) + 's';
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function stopElapsedTimer() {
|
||||
if (elapsedTimer) {
|
||||
clearInterval(elapsedTimer);
|
||||
elapsedTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────
|
||||
|
||||
function renderStatus(s) {
|
||||
if (els.progressFill) {
|
||||
els.progressFill.style.width = (s.overall_progress || 0) + '%';
|
||||
}
|
||||
if (els.progressPct) {
|
||||
els.progressPct.textContent = (s.overall_progress || 0) + '%';
|
||||
}
|
||||
if (els.phaseName) {
|
||||
els.phaseName.textContent = s.phase || '';
|
||||
}
|
||||
if (els.liveRatio && s.metrics) {
|
||||
els.liveRatio.textContent =
|
||||
'1:' + (s.metrics.ratio_start || '?') +
|
||||
' → 1:' + (s.metrics.ratio_now || '?');
|
||||
}
|
||||
// Update top metrics live too
|
||||
if (els.ratio && s.metrics && s.metrics.ratio_now != null) {
|
||||
els.ratio.textContent = '1:' + s.metrics.ratio_now;
|
||||
}
|
||||
if (els.residue && s.metrics && s.metrics.eav_rows_now != null) {
|
||||
els.residue.textContent = String(s.metrics.eav_rows_now);
|
||||
}
|
||||
updateLog(s.log || []);
|
||||
}
|
||||
|
||||
let lastLogLength = 0;
|
||||
function updateLog(lines) {
|
||||
if (!els.log) return;
|
||||
if (lines.length === lastLogLength) return;
|
||||
els.log.textContent = lines.join('\n');
|
||||
els.log.scrollTop = els.log.scrollHeight;
|
||||
lastLogLength = lines.length;
|
||||
// Apply a subtle highlight on the last line via CSS animation
|
||||
els.log.classList.remove('wpdo-mw-log-flash');
|
||||
// Force reflow so re-adding the class re-triggers the animation
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
void els.log.offsetWidth;
|
||||
els.log.classList.add('wpdo-mw-log-flash');
|
||||
}
|
||||
|
||||
function renderDone(s) {
|
||||
show(els.donePanel);
|
||||
if (!els.doneSummary) return;
|
||||
const m = s.metrics || {};
|
||||
const elapsed = ((s.completed_at || 0) - (s.started_at || 0));
|
||||
els.doneSummary.innerHTML = '';
|
||||
const lines = [
|
||||
['ratio', '1:' + (m.ratio_start || '?') + ' → 1:' + (m.ratio_now || '?')],
|
||||
['EAV 殘留', (m.eav_rows_start || 0) + ' → ' + (m.eav_rows_now || 0)],
|
||||
['mode', (m.mode_start || '?') + ' → aeav_only'],
|
||||
['耗時', elapsed + ' 秒'],
|
||||
['備份', s.backup_path || '(無)'],
|
||||
];
|
||||
lines.forEach(([label, value]) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'wpdo-mw-done-line';
|
||||
const lab = document.createElement('span'); lab.textContent = label + ':';
|
||||
const val = document.createElement('strong'); val.textContent = value;
|
||||
div.append(lab, val);
|
||||
els.doneSummary.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function renderFailed(s) {
|
||||
els.progressTitle.textContent = '✗ 失敗 — ' + (i18n.failedRetry || '請 Resume 或 Cancel');
|
||||
els.progressTitle.classList.add('wpdo-mw-failed');
|
||||
if (els.resumeBtn) {
|
||||
els.resumeBtn.removeAttribute('hidden');
|
||||
els.resumeBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── On-load: if a job is already running, hop into progress mode ────
|
||||
const initialState = root.getAttribute('data-state');
|
||||
if (initialState === 'running' || initialState === 'paused') {
|
||||
startedAt = Date.now();
|
||||
show(els.progressPanel);
|
||||
startElapsedTimer();
|
||||
startPolling();
|
||||
} else if (initialState === 'failed') {
|
||||
show(els.progressPanel);
|
||||
renderFailed({ state: 'failed' });
|
||||
} else if (initialState === 'completed') {
|
||||
// Already shown via PHP hidden flag — but populate summary if available
|
||||
fetchJson('/migration/status').then((res) => {
|
||||
if (res.ok && res.body && res.body.state === 'completed') {
|
||||
renderDone(res.body);
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* WPDO Post Stress Test — admin tab JS (v2.11.4)
|
||||
*
|
||||
* Mirrors wpdo-stress-test.js (user side) but:
|
||||
* - Talks to /wpdo/v1/post-stress-test/* endpoints
|
||||
* - Sends post_type in start payload (user side has no entity selector)
|
||||
* - DOM IDs prefixed wpdo-pst-* to coexist on the same admin page
|
||||
* - Renders a smaller benchmark report (3 probes per post_type vs 6 for user)
|
||||
*
|
||||
* @since 2.11.4
|
||||
*/
|
||||
( function () {
|
||||
'use strict';
|
||||
|
||||
const cfg = window.wpdoPostStressTest;
|
||||
if ( ! cfg || ! cfg.restUrl ) {
|
||||
return;
|
||||
}
|
||||
if ( ! document.querySelector( '.wpdo-post-stress-test-tab' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $ = ( sel ) => document.querySelector( sel );
|
||||
const restUrl = cfg.restUrl.replace( /\/$/, '' );
|
||||
const headers = { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce };
|
||||
|
||||
let pollTimer = null;
|
||||
|
||||
// ── API helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
async function apiCall( path, method = 'GET', body = null ) {
|
||||
const opts = { method, headers, credentials: 'same-origin' };
|
||||
if ( body ) {
|
||||
opts.body = JSON.stringify( body );
|
||||
}
|
||||
const resp = await fetch( restUrl + path, opts );
|
||||
const text = await resp.text();
|
||||
try {
|
||||
return { ok: resp.ok, status: resp.status, data: text ? JSON.parse( text ) : null };
|
||||
} catch ( e ) {
|
||||
return { ok: false, status: resp.status, data: { error: text } };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────────────
|
||||
|
||||
function fmt( n ) {
|
||||
if ( n === null || n === undefined ) {
|
||||
return '—';
|
||||
}
|
||||
return Number( n ).toLocaleString();
|
||||
}
|
||||
|
||||
function setText( sel, text ) {
|
||||
const el = $( sel );
|
||||
if ( el ) {
|
||||
el.textContent = String( text );
|
||||
}
|
||||
}
|
||||
|
||||
function esc( s ) {
|
||||
if ( s === null || s === undefined ) {
|
||||
return '';
|
||||
}
|
||||
return String( s ).replace( /[&<>"']/g, ( c ) => ( {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[ c ] ) );
|
||||
}
|
||||
|
||||
function renderProgress( state ) {
|
||||
const isRunning = state.status === 'running' || state.status === 'benchmarking';
|
||||
const card = $( '#wpdo-pst-progress-card' );
|
||||
if ( card ) {
|
||||
card.style.display = ( isRunning || state.status === 'completed' || state.status === 'failed' || state.status === 'cancelled' ) ? '' : 'none';
|
||||
}
|
||||
setText( '#wpdo-pst-pg-status', state.status || 'idle' );
|
||||
setText( '#wpdo-pst-pg-post-type', state.post_type || '' );
|
||||
setText( '#wpdo-pst-pg-mode', state.mode || '' );
|
||||
setText( '#wpdo-pst-pg-pct', ( state.pct || 0 ) + '%' );
|
||||
setText( '#wpdo-pst-pg-processed', fmt( state.processed || 0 ) );
|
||||
setText( '#wpdo-pst-pg-target', fmt( state.target || 0 ) );
|
||||
setText( '#wpdo-pst-pg-rate', state.rate_per_sec || 0 );
|
||||
setText( '#wpdo-pst-pg-elapsed', state.elapsed_sec || 0 );
|
||||
setText( '#wpdo-pst-pg-eta', state.eta_sec || 0 );
|
||||
setText( '#wpdo-pst-pg-batches', state.batches_done || 0 );
|
||||
setText( '#wpdo-pst-pg-mem', ( ( state.peak_memory || 0 ) / 1048576 ).toFixed( 1 ) );
|
||||
|
||||
const bar = $( '#wpdo-pst-pg-bar' );
|
||||
if ( bar ) {
|
||||
bar.style.width = ( state.pct || 0 ) + '%';
|
||||
}
|
||||
|
||||
const count = state.test_post_count || 0;
|
||||
setText( '#wpdo-pst-count', fmt( count ) );
|
||||
setText( '#wpdo-pst-count-mirror', fmt( count ) );
|
||||
|
||||
const startBtn = $( '#wpdo-pst-start' );
|
||||
const cancelBtn = $( '#wpdo-pst-cancel' );
|
||||
const cleanupBtn = $( '#wpdo-pst-cleanup' );
|
||||
const benchBtn = $( '#wpdo-pst-rerun-bench' );
|
||||
if ( startBtn ) {
|
||||
startBtn.disabled = isRunning;
|
||||
}
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.disabled = ! isRunning;
|
||||
}
|
||||
if ( cleanupBtn ) {
|
||||
cleanupBtn.disabled = isRunning || count === 0;
|
||||
}
|
||||
if ( benchBtn ) {
|
||||
benchBtn.disabled = isRunning || count === 0;
|
||||
}
|
||||
}
|
||||
|
||||
function renderBenchmark( bench ) {
|
||||
if ( ! bench ) {
|
||||
return;
|
||||
}
|
||||
const card = $( '#wpdo-pst-bench-card' );
|
||||
const content = $( '#wpdo-pst-bench-content' );
|
||||
if ( ! card || ! content ) {
|
||||
return;
|
||||
}
|
||||
card.style.display = '';
|
||||
|
||||
const w = bench.write || {};
|
||||
const dbSizes = bench.db_sizes || [];
|
||||
const q = bench.query || {};
|
||||
|
||||
// Friendly labels for the per-post_type query probes
|
||||
const labels = {
|
||||
point_stock_status: 'Point lookup (_stock_status)',
|
||||
point_status: 'Point lookup (hp_status)',
|
||||
point_verified: 'Point lookup (hp_verified)',
|
||||
point_alt_present: 'Point lookup (alt 文字)',
|
||||
point_type: 'Point lookup (_menu_item_type)',
|
||||
point_thumbnail: 'Point lookup (_thumbnail_id)',
|
||||
range_price_above: 'Range scan (price > 100)',
|
||||
range_budget_above: 'Range scan (budget > 100)',
|
||||
range_rate_above: 'Range scan (hourly_rate > 50)',
|
||||
range_id_above: 'Range scan (post_id 排序)',
|
||||
eav_baseline: 'EAV baseline (wp_postmeta 直查)',
|
||||
};
|
||||
|
||||
let html = '';
|
||||
// Write metrics
|
||||
html += '<h4 style="margin-bottom:6px;">▍ 寫入指標</h4>';
|
||||
html += '<table class="widefat" style="margin-bottom:14px;"><tbody>';
|
||||
html += `<tr><td>模式</td><td><code>${ esc( w.mode ) }</code></td></tr>`;
|
||||
html += `<tr><td>Post Type</td><td><code>${ esc( w.post_type ) }</code></td></tr>`;
|
||||
html += `<tr><td>完成 / 目標</td><td>${ fmt( w.processed ) } / ${ fmt( w.target ) }</td></tr>`;
|
||||
html += `<tr><td>總耗時</td><td>${ fmt( w.elapsed_sec ) } 秒</td></tr>`;
|
||||
html += `<tr><td>平均速率</td><td><strong>${ fmt( w.rate_per_sec ) }</strong> posts/sec</td></tr>`;
|
||||
html += `<tr><td>批次數</td><td>${ fmt( w.batches_done ) }</td></tr>`;
|
||||
html += `<tr><td>批次最快/平均/最慢</td><td>${ fmt( w.batch_min_ms ) } / ${ fmt( w.batch_avg_ms ) } / ${ fmt( w.batch_max_ms ) } ms</td></tr>`;
|
||||
html += `<tr><td>PHP Peak Memory</td><td>${ fmt( w.peak_memory_mb ) } MB</td></tr>`;
|
||||
html += '</tbody></table>';
|
||||
|
||||
// DB sizes (wp_posts + wp_postmeta + flat table for this run)
|
||||
html += '<h4 style="margin-bottom:6px;">▍ DB 容量(post 相關表)</h4>';
|
||||
html += '<table class="widefat striped" style="margin-bottom:14px;"><thead><tr>';
|
||||
html += '<th>Table</th><th>Rows</th><th>Data MB</th><th>Index MB</th><th>Total MB</th><th>Avg bytes/row</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
dbSizes.forEach( ( r ) => {
|
||||
html += `<tr><td><code>${ esc( r.table ) }</code></td><td>${ fmt( r.rows ) }</td>`;
|
||||
html += `<td>${ r.data_mb ?? '—' }</td><td>${ r.index_mb ?? '—' }</td>`;
|
||||
html += `<td><strong>${ r.total_mb ?? '—' }</strong></td><td>${ fmt( r.avg_bytes ) }</td></tr>`;
|
||||
} );
|
||||
html += '</tbody></table>';
|
||||
|
||||
// Query perf — 3 probes per post_type, with EAV baseline last for visual comparison
|
||||
html += '<h4 style="margin-bottom:6px;">▍ 查詢效能</h4>';
|
||||
html += '<table class="widefat striped" style="margin-bottom:8px;"><thead><tr>';
|
||||
html += '<th>測試項目</th><th>耗時 (ms)</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
const baselineMs = q.eav_baseline?.duration_ms ?? null;
|
||||
Object.keys( q ).forEach( ( key ) => {
|
||||
const v = q[ key ];
|
||||
if ( ! v || typeof v.duration_ms !== 'number' ) {
|
||||
return;
|
||||
}
|
||||
const label = labels[ key ] || key;
|
||||
let speedup = '';
|
||||
if ( baselineMs !== null && key !== 'eav_baseline' && v.duration_ms > 0 ) {
|
||||
const ratio = baselineMs / v.duration_ms;
|
||||
if ( ratio >= 1 ) {
|
||||
speedup = ` <span style="color:#28a745;font-weight:600;">(${ ratio.toFixed( 2 ) }× faster)</span>`;
|
||||
}
|
||||
}
|
||||
html += `<tr><td>${ esc( label ) }${ speedup }</td><td><strong>${ v.duration_ms }</strong></td></tr>`;
|
||||
} );
|
||||
html += '</tbody></table>';
|
||||
html += '<p class="description">EAV baseline 走 wp_postmeta,flat probes 走專屬 group 表。倍率即此規模下反 EAV 的查詢加速。</p>';
|
||||
|
||||
content.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Polling ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function poll() {
|
||||
const r = await apiCall( '/post-stress-test/status' );
|
||||
if ( ! r.ok || ! r.data ) {
|
||||
return;
|
||||
}
|
||||
renderProgress( r.data );
|
||||
if ( r.data.benchmark ) {
|
||||
renderBenchmark( r.data.benchmark );
|
||||
}
|
||||
if ( r.data.status !== 'running' && r.data.status !== 'benchmarking' ) {
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
poll();
|
||||
pollTimer = setInterval( poll, 2000 );
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if ( pollTimer ) {
|
||||
clearInterval( pollTimer );
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event handlers ──────────────────────────────────────────────────────
|
||||
|
||||
async function handleStart() {
|
||||
const postType = $( '#wpdo-pst-post-type' ).value;
|
||||
const target = parseInt( $( '#wpdo-pst-target' ).value, 10 );
|
||||
const batch = parseInt( $( '#wpdo-pst-batch' ).value, 10 );
|
||||
const mode = document.querySelector( 'input[name="wpdo-pst-mode"]:checked' ).value;
|
||||
|
||||
if ( ! postType ) {
|
||||
alert( '請選擇 post_type' );
|
||||
return;
|
||||
}
|
||||
if ( ! target || target < 1 ) {
|
||||
alert( '請輸入有效的 post 數量' );
|
||||
return;
|
||||
}
|
||||
// Realistic 模式每 post 100-300ms,batch 太大會撞 nginx 60s timeout 之前的 8s deadline
|
||||
if ( mode === 'realistic' && batch > 30 ) {
|
||||
if ( ! confirm( `Realistic 模式每個 post 約需 100-300 ms,batch_size=${ batch } 可能超過後端 8s deadline 上限。建議使用 batch=10-20。要繼續嗎?` ) ) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Soft warn for combinations that won't demonstrate反 EAV 優化效果
|
||||
const postMode = String( cfg.postMode || 'disabled' );
|
||||
if ( mode === 'fast' && postMode === 'aeav_only' ) {
|
||||
if ( ! confirm( `⚠️ Fast 模式直接 $wpdb->insert 繞過 Hook Bus,即使 post mode=aeav_only 也會寫滿 wp_postmeta(fixture 用途,非優化驗證)。\n\n如果你想驗證反 EAV 優化效果(wp_postmeta 應為 0),請改用 🐢 Realistic 模式。\n\n仍以 Fast 模式繼續嗎?` ) ) {
|
||||
return;
|
||||
}
|
||||
} else if ( mode === 'realistic' && postMode !== 'aeav_only' ) {
|
||||
if ( ! confirm( `⚠️ 目前 post mode = ${ postMode },此模式下 Realistic 寫入仍會雙寫 wp_postmeta(不會展示優化效果,ratio 不變)。\n\n要看 wp_postmeta 完全短路(0 寫入)需要先把 mode 升到 aeav_only(設定 tab)。\n\n仍以 ${ postMode } 模式繼續測試嗎?` ) ) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const big = target >= 5000;
|
||||
const msg = `即將以 ${ mode } 模式建立 ${ target.toLocaleString() } 筆 ${ postType } 測試 post${ big ? '(規模較大,可能耗時數分鐘)' : '' }。\n\n所有 post 的 post_title 會以 WPDO_STRESS_TEST_ 開頭,可一鍵清除。\n\n確定要開始嗎?`;
|
||||
if ( ! confirm( msg ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const r = await apiCall( '/post-stress-test/start', 'POST', {
|
||||
post_type: postType,
|
||||
target,
|
||||
mode,
|
||||
batch_size: batch,
|
||||
} );
|
||||
if ( ! r.ok ) {
|
||||
alert( '啟動失敗:' + ( r.data?.error || r.status ) );
|
||||
return;
|
||||
}
|
||||
// 啟動後立即顯示進度卡片
|
||||
const card = $( '#wpdo-pst-progress-card' );
|
||||
if ( card ) {
|
||||
card.style.display = '';
|
||||
}
|
||||
// Hide the previous benchmark card (a fresh run will replace it)
|
||||
const benchCard = $( '#wpdo-pst-bench-card' );
|
||||
if ( benchCard ) {
|
||||
benchCard.style.display = 'none';
|
||||
}
|
||||
setText( '#wpdo-pst-pg-status', 'running' );
|
||||
setText( '#wpdo-pst-pg-post-type', postType );
|
||||
setText( '#wpdo-pst-pg-mode', mode );
|
||||
setText( '#wpdo-pst-pg-target', target.toLocaleString() );
|
||||
startPolling();
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if ( ! confirm( '確定要取消當前測試?已建立的 post 不會被刪除。' ) ) {
|
||||
return;
|
||||
}
|
||||
const cancelBtn = $( '#wpdo-pst-cancel' );
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.disabled = true;
|
||||
cancelBtn.textContent = '⏹ 取消中…';
|
||||
}
|
||||
setText( '#wpdo-pst-pg-status', 'cancelling' );
|
||||
|
||||
const r = await apiCall( '/post-stress-test/cancel', 'POST' );
|
||||
if ( ! r.ok ) {
|
||||
alert( '取消失敗:' + ( r.data?.error || r.status ) );
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.disabled = false;
|
||||
cancelBtn.textContent = '⏹ 取消';
|
||||
}
|
||||
return;
|
||||
}
|
||||
// realistic 模式 in-flight batch 可能還要 ~1 秒才會真正中止
|
||||
poll();
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.textContent = '⏹ 取消';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCleanup() {
|
||||
if ( ! confirm( '確定要清除所有 stress test posts?\n\n此動作會:\n- DELETE 所有 post_title 前綴 WPDO_STRESS_TEST_ 的 post\n- DELETE 對應 wp_postmeta\n- DELETE 7 張 wp_wpdo_post_* flat tables 中對應 post_id 的列\n\n不可復原!' ) ) {
|
||||
return;
|
||||
}
|
||||
const r = await apiCall( '/post-stress-test/cleanup', 'DELETE' );
|
||||
if ( ! r.ok ) {
|
||||
alert( '清除失敗:' + ( r.data?.error || r.status ) );
|
||||
return;
|
||||
}
|
||||
alert( `已清除 ${ r.data.deleted } 筆測試 post。` );
|
||||
const card = $( '#wpdo-pst-progress-card' );
|
||||
const benchCard = $( '#wpdo-pst-bench-card' );
|
||||
if ( card ) card.style.display = 'none';
|
||||
if ( benchCard ) benchCard.style.display = 'none';
|
||||
poll();
|
||||
}
|
||||
|
||||
async function handleRerunBench() {
|
||||
const btn = $( '#wpdo-pst-rerun-bench' );
|
||||
if ( btn ) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ 執行中...';
|
||||
}
|
||||
const r = await apiCall( '/post-stress-test/benchmark', 'POST' );
|
||||
if ( btn ) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '📊 重跑 Benchmark(不新增資料)';
|
||||
}
|
||||
if ( ! r.ok ) {
|
||||
alert( 'Benchmark 失敗:' + ( r.data?.error || r.status ) );
|
||||
return;
|
||||
}
|
||||
renderBenchmark( r.data.benchmark );
|
||||
}
|
||||
|
||||
// ── Init ────────────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener( 'DOMContentLoaded', () => {
|
||||
const startBtn = $( '#wpdo-pst-start' );
|
||||
const cancelBtn = $( '#wpdo-pst-cancel' );
|
||||
const cleanupBtn = $( '#wpdo-pst-cleanup' );
|
||||
const benchBtn = $( '#wpdo-pst-rerun-bench' );
|
||||
|
||||
if ( startBtn ) startBtn.addEventListener( 'click', handleStart );
|
||||
if ( cancelBtn ) cancelBtn.addEventListener( 'click', handleCancel );
|
||||
if ( cleanupBtn ) cleanupBtn.addEventListener( 'click', handleCleanup );
|
||||
if ( benchBtn ) benchBtn.addEventListener( 'click', handleRerunBench );
|
||||
|
||||
// 初始 poll:若 status==running 自動接手 polling;若 completed 渲染 benchmark
|
||||
poll().then( () => {
|
||||
const status = ( $( '#wpdo-pst-pg-status' )?.textContent || '' ).trim();
|
||||
if ( status === 'running' || status === 'benchmarking' ) {
|
||||
startPolling();
|
||||
}
|
||||
} );
|
||||
} );
|
||||
} )();
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* WP Data Optimizer — REST API JavaScript SDK
|
||||
*
|
||||
* Lightweight client for /wp-json/wpdo/v1/ endpoints.
|
||||
* No dependencies required.
|
||||
*
|
||||
* Usage:
|
||||
* const wpdo = new WpdoClient();
|
||||
*
|
||||
* // Fetch listings (Zone A)
|
||||
* const { items, total } = await wpdo.getListings({ hp_price_min: 100, per_page: 20 });
|
||||
*
|
||||
* // Single listing (Zone A + Zone C)
|
||||
* const listing = await wpdo.getListing(123);
|
||||
*
|
||||
* // View count (Zone B)
|
||||
* const { view_count } = await wpdo.getStats(123);
|
||||
*
|
||||
* // Increment view count (Zone B, requires WP REST nonce)
|
||||
* const { view_count: updated } = await wpdo.incrementView(123);
|
||||
*
|
||||
* // Iterate all pages
|
||||
* for await (const page of wpdo.paginateListings({ hp_featured: 1 })) {
|
||||
* console.log(page.items);
|
||||
* }
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
/* global wpdo_sdk_config */
|
||||
|
||||
( function ( global ) {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* WpdoClient — REST API wrapper for WP Data Optimizer.
|
||||
*
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.baseUrl] REST base URL (default: auto-detected from wpdo_sdk_config or /wp-json)
|
||||
* @param {string} [options.nonce] WP REST nonce for authenticated requests
|
||||
* @param {string} [options.postType] Default post type (default: 'hp_listing')
|
||||
*/
|
||||
function WpdoClient( options ) {
|
||||
options = options || {};
|
||||
|
||||
var config = ( typeof wpdo_sdk_config !== 'undefined' ) ? wpdo_sdk_config : {};
|
||||
|
||||
this._base = options.baseUrl || config.rest_url || '/wp-json/wpdo/v1';
|
||||
this._nonce = options.nonce || config.nonce || '';
|
||||
this._postType = options.postType || config.post_type || 'hp_listing';
|
||||
}
|
||||
|
||||
// ── Core fetch ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Internal GET fetch helper.
|
||||
*
|
||||
* @param {string} path Relative path (e.g. '/listings')
|
||||
* @param {object} params Query parameters
|
||||
* @return {Promise<{data: *, headers: Headers, status: number}>}
|
||||
*/
|
||||
WpdoClient.prototype._fetch = function ( path, params ) {
|
||||
var url = this._base + path;
|
||||
|
||||
if ( params && Object.keys( params ).length ) {
|
||||
var qs = Object.keys( params )
|
||||
.filter(
|
||||
function ( k ) {
|
||||
return params[ k ] !== null && params[ k ] !== undefined && params[ k ] !== ''; }
|
||||
)
|
||||
.map(
|
||||
function ( k ) {
|
||||
return encodeURIComponent( k ) + '=' + encodeURIComponent( params[ k ] ); }
|
||||
)
|
||||
.join( '&' );
|
||||
if ( qs ) {
|
||||
url += '?' + qs;
|
||||
}
|
||||
}
|
||||
|
||||
var headers = { 'Content-Type': 'application/json' };
|
||||
if ( this._nonce ) {
|
||||
headers[ 'X-WP-Nonce' ] = this._nonce;
|
||||
}
|
||||
|
||||
return fetch( url, { headers: headers } ).then(
|
||||
function ( res ) {
|
||||
return res.json().then(
|
||||
function ( data ) {
|
||||
return { data: data, headers: res.headers, status: res.status };
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Internal POST fetch helper.
|
||||
*
|
||||
* @param {string} path Relative path
|
||||
* @param {object} body JSON body (optional)
|
||||
* @return {Promise<{data: *, status: number}>}
|
||||
*/
|
||||
WpdoClient.prototype._post = function ( path, body ) {
|
||||
var headers = { 'Content-Type': 'application/json' };
|
||||
if ( this._nonce ) {
|
||||
headers[ 'X-WP-Nonce' ] = this._nonce;
|
||||
}
|
||||
|
||||
return fetch(
|
||||
this._base + path,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: body ? JSON.stringify( body ) : null,
|
||||
}
|
||||
).then(
|
||||
function ( res ) {
|
||||
return res.json().then(
|
||||
function ( data ) {
|
||||
return { data: data, status: res.status };
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch a page of listings from Zone A.
|
||||
*
|
||||
* @param {object} [params]
|
||||
* @param {string} [params.post_type] Default: configured post type
|
||||
* @param {number} [params.per_page] 1–100, default 20
|
||||
* @param {number} [params.page] Default 1
|
||||
* @param {string} [params.orderby] Column name, default 'post_id'
|
||||
* @param {string} [params.order] 'ASC' | 'DESC', default 'DESC'
|
||||
* @param {number} [params.*_min] Numeric range filter (e.g. hp_price_min)
|
||||
* @param {number} [params.*_max] Numeric range filter (e.g. hp_price_max)
|
||||
* @param {*} [params.*] Exact match filter (e.g. hp_featured: 1)
|
||||
* @return {Promise<{items: Array, total: number, totalPages: number}>}
|
||||
*/
|
||||
WpdoClient.prototype.getListings = function ( params ) {
|
||||
var merged = Object.assign( { post_type: this._postType }, params || {} );
|
||||
return this._fetch( '/listings', merged ).then(
|
||||
function ( res ) {
|
||||
return {
|
||||
items: res.data,
|
||||
total: parseInt( res.headers.get( 'X-WP-Total' ) || '0', 10 ),
|
||||
totalPages: parseInt( res.headers.get( 'X-WP-TotalPages' ) || '0', 10 ),
|
||||
status: res.status,
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch a single listing (Zone A + Zone C merged).
|
||||
*
|
||||
* @param {number} id Post ID
|
||||
* @return {Promise<object>}
|
||||
*/
|
||||
WpdoClient.prototype.getListing = function ( id ) {
|
||||
return this._fetch( '/listings/' + id, null ).then(
|
||||
function ( res ) {
|
||||
return res.data;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch Zone B view count for a post.
|
||||
*
|
||||
* @param {number} id Post ID
|
||||
* @return {Promise<{post_id: number, view_count: number}>}
|
||||
*/
|
||||
WpdoClient.prototype.getStats = function ( id ) {
|
||||
return this._fetch( '/stats/' + id, null ).then(
|
||||
function ( res ) {
|
||||
return res.data;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Increment Zone B view count for a post (requires WP REST nonce).
|
||||
*
|
||||
* The nonce must be passed via the `nonce` constructor option or
|
||||
* via `wpdo_sdk_config.nonce` (wp_create_nonce('wp_rest')).
|
||||
*
|
||||
* @param {number} id Post ID
|
||||
* @return {Promise<{post_id: number, view_count: number}>}
|
||||
*
|
||||
* Example:
|
||||
* // Auto-tracks a view when a listing page loads:
|
||||
* document.addEventListener('DOMContentLoaded', () => {
|
||||
* const postId = parseInt(document.body.dataset.postId);
|
||||
* if (postId) wpdo.incrementView(postId);
|
||||
* });
|
||||
*/
|
||||
WpdoClient.prototype.incrementView = function ( id ) {
|
||||
return this._post( '/listings/' + id + '/view', null ).then(
|
||||
function ( res ) {
|
||||
return res.data;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Async generator — iterates every page of listings.
|
||||
*
|
||||
* @param {object} [params] Same params as getListings (page is managed internally)
|
||||
* @yields {{items: Array, total: number, page: number, totalPages: number}}
|
||||
*
|
||||
* Example:
|
||||
* for await (const page of wpdo.paginateListings({ hp_featured: 1 })) {
|
||||
* page.items.forEach(item => console.log(item));
|
||||
* }
|
||||
*/
|
||||
WpdoClient.prototype.paginateListings = async function * ( params ) {
|
||||
var page = 1;
|
||||
var total = Infinity;
|
||||
var perPage = ( params && params.per_page ) ? params.per_page : 20;
|
||||
|
||||
while ( ( page - 1 ) * perPage < total ) {
|
||||
var merged = Object.assign( {}, params || {}, { page: page } );
|
||||
var result = await this.getListings( merged );
|
||||
total = result.total;
|
||||
yield { items: result.items, total: total, page: page, totalPages: result.totalPages };
|
||||
if ( page >= result.totalPages ) {
|
||||
break;
|
||||
}
|
||||
page++;
|
||||
}
|
||||
};
|
||||
|
||||
// ── Export ────────────────────────────────────────────────────────────────
|
||||
|
||||
global.WpdoClient = WpdoClient;
|
||||
|
||||
// Auto-instantiate as window.wpdo if config is present.
|
||||
if ( typeof wpdo_sdk_config !== 'undefined' ) {
|
||||
global.wpdo = new WpdoClient();
|
||||
}
|
||||
|
||||
}( window ) );
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* WPDO User Stress Test — admin tab JS
|
||||
*
|
||||
* - 2s polling while running/benchmarking
|
||||
* - Start / Cancel / Cleanup / Re-run benchmark
|
||||
* - Renders progress + benchmark report
|
||||
*
|
||||
* @since 2.6.7
|
||||
*/
|
||||
( function () {
|
||||
'use strict';
|
||||
|
||||
const cfg = window.wpdoStressTest;
|
||||
if ( ! cfg || ! cfg.restUrl ) {
|
||||
return;
|
||||
}
|
||||
if ( ! document.querySelector( '.wpdo-stress-test-tab' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $ = ( sel ) => document.querySelector( sel );
|
||||
const restUrl = cfg.restUrl.replace( /\/$/, '' );
|
||||
const headers = { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce };
|
||||
|
||||
let pollTimer = null;
|
||||
|
||||
// ── API helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
async function apiCall( path, method = 'GET', body = null ) {
|
||||
const opts = { method, headers, credentials: 'same-origin' };
|
||||
if ( body ) {
|
||||
opts.body = JSON.stringify( body );
|
||||
}
|
||||
const resp = await fetch( restUrl + path, opts );
|
||||
const text = await resp.text();
|
||||
try {
|
||||
return { ok: resp.ok, status: resp.status, data: text ? JSON.parse( text ) : null };
|
||||
} catch ( e ) {
|
||||
return { ok: false, status: resp.status, data: { error: text } };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────────────
|
||||
|
||||
function fmt( n ) {
|
||||
if ( n === null || n === undefined ) {
|
||||
return '—';
|
||||
}
|
||||
return Number( n ).toLocaleString();
|
||||
}
|
||||
|
||||
function renderProgress( state ) {
|
||||
const isRunning = state.status === 'running' || state.status === 'benchmarking';
|
||||
const card = $( '#wpdo-st-progress-card' );
|
||||
if ( card ) {
|
||||
card.style.display = ( isRunning || state.status === 'completed' || state.status === 'failed' || state.status === 'cancelled' ) ? '' : 'none';
|
||||
}
|
||||
setText( '#wpdo-st-pg-status', state.status || 'idle' );
|
||||
setText( '#wpdo-st-pg-mode', state.mode || '' );
|
||||
setText( '#wpdo-st-pg-pct', ( state.pct || 0 ) + '%' );
|
||||
setText( '#wpdo-st-pg-processed', fmt( state.processed || 0 ) );
|
||||
setText( '#wpdo-st-pg-target', fmt( state.target || 0 ) );
|
||||
setText( '#wpdo-st-pg-rate', state.rate_per_sec || 0 );
|
||||
setText( '#wpdo-st-pg-elapsed', state.elapsed_sec || 0 );
|
||||
setText( '#wpdo-st-pg-eta', state.eta_sec || 0 );
|
||||
setText( '#wpdo-st-pg-batches', state.batches_done || 0 );
|
||||
setText( '#wpdo-st-pg-mem', ( ( state.peak_memory || 0 ) / 1048576 ).toFixed( 1 ) );
|
||||
|
||||
const bar = $( '#wpdo-st-pg-bar' );
|
||||
if ( bar ) {
|
||||
bar.style.width = ( state.pct || 0 ) + '%';
|
||||
}
|
||||
|
||||
setText( '#wpdo-st-count', fmt( state.test_user_count || 0 ) );
|
||||
|
||||
const startBtn = $( '#wpdo-st-start' );
|
||||
const cancelBtn = $( '#wpdo-st-cancel' );
|
||||
const cleanupBtn = $( '#wpdo-st-cleanup' );
|
||||
const benchBtn = $( '#wpdo-st-rerun-bench' );
|
||||
if ( startBtn ) {
|
||||
startBtn.disabled = isRunning;
|
||||
}
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.disabled = ! isRunning;
|
||||
}
|
||||
if ( cleanupBtn ) {
|
||||
cleanupBtn.disabled = isRunning || ( state.test_user_count || 0 ) === 0;
|
||||
}
|
||||
if ( benchBtn ) {
|
||||
benchBtn.disabled = isRunning || ( state.test_user_count || 0 ) === 0;
|
||||
}
|
||||
}
|
||||
|
||||
function renderBenchmark( bench ) {
|
||||
if ( ! bench ) {
|
||||
return;
|
||||
}
|
||||
const card = $( '#wpdo-st-bench-card' );
|
||||
const content = $( '#wpdo-st-bench-content' );
|
||||
if ( ! card || ! content ) {
|
||||
return;
|
||||
}
|
||||
card.style.display = '';
|
||||
|
||||
const w = bench.write || {};
|
||||
const dbSizes = bench.db_sizes || [];
|
||||
const q = bench.query || {};
|
||||
|
||||
const labels = {
|
||||
get_field_membership_level: 'WPDO_API::get_field (membership_level) ×100',
|
||||
get_entity_full: 'WPDO_API::get_entity (整筆) ×100',
|
||||
range_gold_high_points: '索引範圍:gold + points>5000',
|
||||
sort_recent_active_100: '排序:last_active_at DESC LIMIT 100',
|
||||
join_top_gold_active: 'JOIN:top gold + active LIMIT 100',
|
||||
eav_range_baseline: '原生 EAV 等價查詢(baseline)',
|
||||
};
|
||||
|
||||
let html = '';
|
||||
// Write metrics
|
||||
html += '<h4 style="margin-bottom:6px;">▍ 寫入指標</h4>';
|
||||
html += '<table class="widefat" style="margin-bottom:14px;"><tbody>';
|
||||
html += `<tr><td>模式</td><td><code>${ esc( w.mode ) }</code></td></tr>`;
|
||||
html += `<tr><td>完成 / 目標</td><td>${ fmt( w.processed ) } / ${ fmt( w.target ) }</td></tr>`;
|
||||
html += `<tr><td>總耗時</td><td>${ fmt( w.elapsed_sec ) } 秒</td></tr>`;
|
||||
html += `<tr><td>平均速率</td><td><strong>${ fmt( w.rate_per_sec ) }</strong> users/sec</td></tr>`;
|
||||
html += `<tr><td>批次數</td><td>${ fmt( w.batches_done ) }</td></tr>`;
|
||||
html += `<tr><td>批次最快/平均/最慢</td><td>${ fmt( w.batch_min_ms ) } / ${ fmt( w.batch_avg_ms ) } / ${ fmt( w.batch_max_ms ) } ms</td></tr>`;
|
||||
html += `<tr><td>PHP Peak Memory</td><td>${ fmt( w.peak_memory_mb ) } MB</td></tr>`;
|
||||
html += '</tbody></table>';
|
||||
|
||||
// DB sizes
|
||||
html += '<h4 style="margin-bottom:6px;">▍ DB 容量(user 相關表)</h4>';
|
||||
html += '<table class="widefat striped" style="margin-bottom:14px;"><thead><tr>';
|
||||
html += '<th>Table</th><th>Rows</th><th>Data MB</th><th>Index MB</th><th>Total MB</th><th>Avg bytes/row</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
dbSizes.forEach( ( r ) => {
|
||||
html += `<tr><td><code>${ esc( r.table ) }</code></td><td>${ fmt( r.rows ) }</td>`;
|
||||
html += `<td>${ r.data_mb ?? '—' }</td><td>${ r.index_mb ?? '—' }</td>`;
|
||||
html += `<td><strong>${ r.total_mb ?? '—' }</strong></td><td>${ fmt( r.avg_bytes ) }</td></tr>`;
|
||||
} );
|
||||
html += '</tbody></table>';
|
||||
|
||||
// Query perf
|
||||
html += '<h4 style="margin-bottom:6px;">▍ 查詢效能</h4>';
|
||||
html += '<table class="widefat striped" style="margin-bottom:8px;"><thead><tr>';
|
||||
html += '<th>測試項目</th><th>樣本</th><th>總時間 (ms)</th><th>平均 (ms)</th><th>QPS</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
Object.keys( labels ).forEach( ( key ) => {
|
||||
const v = q[ key ];
|
||||
if ( ! v ) {
|
||||
return;
|
||||
}
|
||||
const total = v.total_ms ?? v.duration_ms ?? '—';
|
||||
html += `<tr><td>${ esc( labels[ key ] ) }</td><td>${ v.n || 1 }</td>`;
|
||||
html += `<td><strong>${ total }</strong></td><td>${ v.avg_ms ?? '—' }</td><td>${ v.qps ?? '—' }</td></tr>`;
|
||||
} );
|
||||
html += '</tbody></table>';
|
||||
html += '<p class="description">原生 EAV baseline 與 flat table 範圍查詢的時間差,即代表此規模下反 EAV 帶來的查詢加速倍數。</p>';
|
||||
|
||||
content.innerHTML = html;
|
||||
}
|
||||
|
||||
function setText( sel, text ) {
|
||||
const el = $( sel );
|
||||
if ( el ) {
|
||||
el.textContent = String( text );
|
||||
}
|
||||
}
|
||||
|
||||
function esc( s ) {
|
||||
if ( s === null || s === undefined ) {
|
||||
return '';
|
||||
}
|
||||
return String( s ).replace( /[&<>"']/g, ( c ) => ( {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[ c ] ) );
|
||||
}
|
||||
|
||||
// ── Polling ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function poll() {
|
||||
const r = await apiCall( '/stress-test/status' );
|
||||
if ( ! r.ok || ! r.data ) {
|
||||
return;
|
||||
}
|
||||
renderProgress( r.data );
|
||||
if ( r.data.benchmark ) {
|
||||
renderBenchmark( r.data.benchmark );
|
||||
}
|
||||
if ( r.data.status !== 'running' && r.data.status !== 'benchmarking' ) {
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
poll();
|
||||
pollTimer = setInterval( poll, 2000 );
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if ( pollTimer ) {
|
||||
clearInterval( pollTimer );
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event handlers ──────────────────────────────────────────────────────
|
||||
|
||||
async function handleStart() {
|
||||
const target = parseInt( $( '#wpdo-st-target' ).value, 10 );
|
||||
let batch = parseInt( $( '#wpdo-st-batch' ).value, 10 );
|
||||
const mode = document.querySelector( 'input[name="wpdo-st-mode"]:checked' ).value;
|
||||
|
||||
if ( ! target || target < 1 ) {
|
||||
alert( '請輸入有效的使用者數量' );
|
||||
return;
|
||||
}
|
||||
// Realistic 模式每 user ~3.5 秒,batch_size 太大會撞 nginx 60s timeout
|
||||
// 後端有 25s wall-clock deadline 保護,但前端先給友善提示
|
||||
if ( mode === 'realistic' && batch > 10 ) {
|
||||
if ( ! confirm( `Realistic 模式每個 user 約需 3 秒,batch_size=${ batch } 會超過後端 25s deadline 上限。建議使用 batch=10。要繼續嗎?` ) ) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const big = target >= 10000;
|
||||
const msg = `即將以 ${ mode } 模式建立 ${ target.toLocaleString() } 筆測試使用者${ big ? '(規模較大,可能耗時數分鐘)' : '' }。\n\n密碼一律為 PassWord2026!,user_login 為 test{n}。\n\n確定要開始嗎?`;
|
||||
if ( ! confirm( msg ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const r = await apiCall( '/stress-test/start', 'POST', { target, mode, batch_size: batch } );
|
||||
if ( ! r.ok ) {
|
||||
alert( '啟動失敗:' + ( r.data?.error || r.status ) );
|
||||
return;
|
||||
}
|
||||
// 啟動後立即顯示進度卡片,不必等第一次 poll 才出現
|
||||
const card = $( '#wpdo-st-progress-card' );
|
||||
if ( card ) {
|
||||
card.style.display = '';
|
||||
}
|
||||
setText( '#wpdo-st-pg-status', 'running' );
|
||||
setText( '#wpdo-st-pg-mode', mode );
|
||||
setText( '#wpdo-st-pg-target', target.toLocaleString() );
|
||||
startPolling();
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if ( ! confirm( '確定要取消當前測試?已建立的使用者不會被刪除。' ) ) {
|
||||
return;
|
||||
}
|
||||
// 立刻 UI 反饋:避免使用者再點一次
|
||||
const cancelBtn = $( '#wpdo-st-cancel' );
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.disabled = true;
|
||||
cancelBtn.textContent = '⏹ 取消中…';
|
||||
}
|
||||
setText( '#wpdo-st-pg-status', 'cancelling' );
|
||||
|
||||
const r = await apiCall( '/stress-test/cancel', 'POST' );
|
||||
if ( ! r.ok ) {
|
||||
alert( '取消失敗:' + ( r.data?.error || r.status ) );
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.disabled = false;
|
||||
cancelBtn.textContent = '⏹ 取消';
|
||||
}
|
||||
return;
|
||||
}
|
||||
// realistic 模式 in-flight batch 可能還要 ~3 秒才會真正中止;polling 會看到 cancelled
|
||||
poll();
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.textContent = '⏹ 取消';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCleanup() {
|
||||
if ( ! confirm( '確定要清除所有 test_* 使用者?\n\n此動作會:\n- DELETE 所有 user_login LIKE "test%" 的使用者\n- DELETE 對應 wp_usermeta\n- DELETE 所有 wp_wpdo_user_* flat tables 中對應 user_id 的列\n\n不可復原!' ) ) {
|
||||
return;
|
||||
}
|
||||
const r = await apiCall( '/stress-test/cleanup', 'DELETE' );
|
||||
if ( ! r.ok ) {
|
||||
alert( '清除失敗:' + ( r.data?.error || r.status ) );
|
||||
return;
|
||||
}
|
||||
alert( `已清除 ${ r.data.deleted } 筆測試使用者。` );
|
||||
const card = $( '#wpdo-st-progress-card' );
|
||||
const benchCard = $( '#wpdo-st-bench-card' );
|
||||
if ( card ) card.style.display = 'none';
|
||||
if ( benchCard ) benchCard.style.display = 'none';
|
||||
poll();
|
||||
}
|
||||
|
||||
async function handleRerunBench() {
|
||||
const btn = $( '#wpdo-st-rerun-bench' );
|
||||
if ( btn ) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ 執行中...';
|
||||
}
|
||||
const r = await apiCall( '/stress-test/benchmark', 'POST' );
|
||||
if ( btn ) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '📊 重跑 Benchmark(不新增資料)';
|
||||
}
|
||||
if ( ! r.ok ) {
|
||||
alert( 'Benchmark 失敗:' + ( r.data?.error || r.status ) );
|
||||
return;
|
||||
}
|
||||
renderBenchmark( r.data.benchmark );
|
||||
}
|
||||
|
||||
// ── Init ────────────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener( 'DOMContentLoaded', () => {
|
||||
const startBtn = $( '#wpdo-st-start' );
|
||||
const cancelBtn = $( '#wpdo-st-cancel' );
|
||||
const cleanupBtn = $( '#wpdo-st-cleanup' );
|
||||
const benchBtn = $( '#wpdo-st-rerun-bench' );
|
||||
|
||||
if ( startBtn ) startBtn.addEventListener( 'click', handleStart );
|
||||
if ( cancelBtn ) cancelBtn.addEventListener( 'click', handleCancel );
|
||||
if ( cleanupBtn ) cleanupBtn.addEventListener( 'click', handleCleanup );
|
||||
if ( benchBtn ) benchBtn.addEventListener( 'click', handleRerunBench );
|
||||
|
||||
// 初始 poll,如果正在執行就會自動開始 polling
|
||||
poll().then( () => {
|
||||
const card = $( '#wpdo-st-progress-card' );
|
||||
if ( card && card.style.display !== 'none' ) {
|
||||
const status = ( $( '#wpdo-st-pg-status' )?.textContent || '' ).trim();
|
||||
if ( status === 'running' || status === 'benchmarking' ) {
|
||||
startPolling();
|
||||
}
|
||||
}
|
||||
} );
|
||||
} );
|
||||
} )();
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* WPDO Term Stress Test — admin tab JS (v2.13.0)
|
||||
*
|
||||
* Mirrors wpdo-post-stress-test.js (v2.11.4) but:
|
||||
* - Talks to /wpdo/v1/term-stress-test/* endpoints
|
||||
* - Sends taxonomy in start payload (not post_type)
|
||||
* - DOM IDs prefixed wpdo-tst-* (term stress test)
|
||||
*
|
||||
* @since 2.13.0
|
||||
*/
|
||||
( function () {
|
||||
'use strict';
|
||||
|
||||
const cfg = window.wpdoTermStressTest;
|
||||
if ( ! cfg || ! cfg.restUrl ) {
|
||||
return;
|
||||
}
|
||||
if ( ! document.querySelector( '.wpdo-term-stress-test-tab' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $ = ( sel ) => document.querySelector( sel );
|
||||
const restUrl = cfg.restUrl.replace( /\/$/, '' );
|
||||
const headers = { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce };
|
||||
|
||||
let pollTimer = null;
|
||||
|
||||
// ── API helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
async function apiCall( path, method = 'GET', body = null ) {
|
||||
const opts = { method, headers, credentials: 'same-origin' };
|
||||
if ( body ) {
|
||||
opts.body = JSON.stringify( body );
|
||||
}
|
||||
const resp = await fetch( restUrl + path, opts );
|
||||
const text = await resp.text();
|
||||
try {
|
||||
return { ok: resp.ok, status: resp.status, data: text ? JSON.parse( text ) : null };
|
||||
} catch ( e ) {
|
||||
return { ok: false, status: resp.status, data: { error: text } };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────────────
|
||||
|
||||
function fmt( n ) {
|
||||
if ( n === null || n === undefined ) {
|
||||
return '—';
|
||||
}
|
||||
return Number( n ).toLocaleString();
|
||||
}
|
||||
|
||||
function setText( sel, text ) {
|
||||
const el = $( sel );
|
||||
if ( el ) {
|
||||
el.textContent = String( text );
|
||||
}
|
||||
}
|
||||
|
||||
function esc( s ) {
|
||||
if ( s === null || s === undefined ) {
|
||||
return '';
|
||||
}
|
||||
return String( s ).replace( /[&<>"']/g, ( c ) => ( {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[ c ] ) );
|
||||
}
|
||||
|
||||
function renderProgress( state ) {
|
||||
const isRunning = state.status === 'running' || state.status === 'benchmarking';
|
||||
const card = $( '#wpdo-tst-progress-card' );
|
||||
if ( card ) {
|
||||
card.style.display = ( isRunning || state.status === 'completed' || state.status === 'failed' || state.status === 'cancelled' ) ? '' : 'none';
|
||||
}
|
||||
setText( '#wpdo-tst-pg-status', state.status || 'idle' );
|
||||
setText( '#wpdo-tst-pg-taxonomy', state.taxonomy || '' );
|
||||
setText( '#wpdo-tst-pg-mode', state.mode || '' );
|
||||
setText( '#wpdo-tst-pg-pct', ( state.pct || 0 ) + '%' );
|
||||
setText( '#wpdo-tst-pg-processed', fmt( state.processed || 0 ) );
|
||||
setText( '#wpdo-tst-pg-target', fmt( state.target || 0 ) );
|
||||
setText( '#wpdo-tst-pg-rate', state.rate_per_sec || 0 );
|
||||
setText( '#wpdo-tst-pg-elapsed', state.elapsed_sec || 0 );
|
||||
setText( '#wpdo-tst-pg-eta', state.eta_sec || 0 );
|
||||
setText( '#wpdo-tst-pg-batches', state.batches_done || 0 );
|
||||
setText( '#wpdo-tst-pg-mem', ( ( state.peak_memory || 0 ) / 1048576 ).toFixed( 1 ) );
|
||||
|
||||
const bar = $( '#wpdo-tst-pg-bar' );
|
||||
if ( bar ) {
|
||||
bar.style.width = ( state.pct || 0 ) + '%';
|
||||
}
|
||||
|
||||
const count = state.test_term_count || 0;
|
||||
setText( '#wpdo-tst-count', fmt( count ) );
|
||||
setText( '#wpdo-tst-count-mirror', fmt( count ) );
|
||||
|
||||
const startBtn = $( '#wpdo-tst-start' );
|
||||
const cancelBtn = $( '#wpdo-tst-cancel' );
|
||||
const cleanupBtn = $( '#wpdo-tst-cleanup' );
|
||||
const benchBtn = $( '#wpdo-tst-rerun-bench' );
|
||||
if ( startBtn ) {
|
||||
startBtn.disabled = isRunning;
|
||||
}
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.disabled = ! isRunning;
|
||||
}
|
||||
if ( cleanupBtn ) {
|
||||
cleanupBtn.disabled = isRunning || count === 0;
|
||||
}
|
||||
if ( benchBtn ) {
|
||||
benchBtn.disabled = isRunning || count === 0;
|
||||
}
|
||||
}
|
||||
|
||||
function renderBenchmark( bench ) {
|
||||
if ( ! bench ) {
|
||||
return;
|
||||
}
|
||||
const card = $( '#wpdo-tst-bench-card' );
|
||||
const content = $( '#wpdo-tst-bench-content' );
|
||||
if ( ! card || ! content ) {
|
||||
return;
|
||||
}
|
||||
card.style.display = '';
|
||||
|
||||
const w = bench.write || {};
|
||||
const dbSizes = bench.db_sizes || [];
|
||||
const q = bench.query || {};
|
||||
|
||||
const labels = {
|
||||
point_default: 'Point lookup (hp_default = 1)',
|
||||
range_sort_top: 'Range scan (hp_sort_order > 0 ORDER BY DESC)',
|
||||
eav_baseline: 'EAV baseline (wp_termmeta 直查 hp_default)',
|
||||
};
|
||||
|
||||
let html = '';
|
||||
// Write metrics
|
||||
html += '<h4 style="margin-bottom:6px;">▍ 寫入指標</h4>';
|
||||
html += '<table class="widefat" style="margin-bottom:14px;"><tbody>';
|
||||
html += `<tr><td>模式</td><td><code>${ esc( w.mode ) }</code></td></tr>`;
|
||||
html += `<tr><td>Taxonomy</td><td><code>${ esc( w.taxonomy ) }</code></td></tr>`;
|
||||
html += `<tr><td>完成 / 目標</td><td>${ fmt( w.processed ) } / ${ fmt( w.target ) }</td></tr>`;
|
||||
html += `<tr><td>總耗時</td><td>${ fmt( w.elapsed_sec ) } 秒</td></tr>`;
|
||||
html += `<tr><td>平均速率</td><td><strong>${ fmt( w.rate_per_sec ) }</strong> terms/sec</td></tr>`;
|
||||
html += `<tr><td>批次數</td><td>${ fmt( w.batches_done ) }</td></tr>`;
|
||||
html += `<tr><td>批次最快/平均/最慢</td><td>${ fmt( w.batch_min_ms ) } / ${ fmt( w.batch_avg_ms ) } / ${ fmt( w.batch_max_ms ) } ms</td></tr>`;
|
||||
html += `<tr><td>PHP Peak Memory</td><td>${ fmt( w.peak_memory_mb ) } MB</td></tr>`;
|
||||
html += '</tbody></table>';
|
||||
|
||||
// DB sizes
|
||||
html += '<h4 style="margin-bottom:6px;">▍ DB 容量(term 相關表)</h4>';
|
||||
html += '<table class="widefat striped" style="margin-bottom:14px;"><thead><tr>';
|
||||
html += '<th>Table</th><th>Rows</th><th>Data MB</th><th>Index MB</th><th>Total MB</th><th>Avg bytes/row</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
dbSizes.forEach( ( r ) => {
|
||||
html += `<tr><td><code>${ esc( r.table ) }</code></td><td>${ fmt( r.rows ) }</td>`;
|
||||
html += `<td>${ r.data_mb ?? '—' }</td><td>${ r.index_mb ?? '—' }</td>`;
|
||||
html += `<td><strong>${ r.total_mb ?? '—' }</strong></td><td>${ fmt( r.avg_bytes ) }</td></tr>`;
|
||||
} );
|
||||
html += '</tbody></table>';
|
||||
|
||||
// Query perf — 3 probes with EAV baseline last for visual speedup comparison
|
||||
html += '<h4 style="margin-bottom:6px;">▍ 查詢效能</h4>';
|
||||
html += '<table class="widefat striped" style="margin-bottom:8px;"><thead><tr>';
|
||||
html += '<th>測試項目</th><th>耗時 (ms)</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
const baselineMs = q.eav_baseline?.duration_ms ?? null;
|
||||
Object.keys( q ).forEach( ( key ) => {
|
||||
const v = q[ key ];
|
||||
if ( ! v || typeof v.duration_ms !== 'number' ) {
|
||||
return;
|
||||
}
|
||||
const label = labels[ key ] || key;
|
||||
let speedup = '';
|
||||
if ( baselineMs !== null && key !== 'eav_baseline' && v.duration_ms > 0 ) {
|
||||
const ratio = baselineMs / v.duration_ms;
|
||||
if ( ratio >= 1 ) {
|
||||
speedup = ` <span style="color:#28a745;font-weight:600;">(${ ratio.toFixed( 2 ) }× faster)</span>`;
|
||||
}
|
||||
}
|
||||
html += `<tr><td>${ esc( label ) }${ speedup }</td><td><strong>${ v.duration_ms }</strong></td></tr>`;
|
||||
} );
|
||||
html += '</tbody></table>';
|
||||
html += '<p class="description">EAV baseline 走 wp_termmeta,flat probes 走 wpdo_term_hp_taxonomy。倍率即此規模下反 EAV 的查詢加速。</p>';
|
||||
|
||||
content.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Polling ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function poll() {
|
||||
const r = await apiCall( '/term-stress-test/status' );
|
||||
if ( ! r.ok || ! r.data ) {
|
||||
return;
|
||||
}
|
||||
renderProgress( r.data );
|
||||
if ( r.data.benchmark ) {
|
||||
renderBenchmark( r.data.benchmark );
|
||||
}
|
||||
if ( r.data.status !== 'running' && r.data.status !== 'benchmarking' ) {
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
poll();
|
||||
pollTimer = setInterval( poll, 2000 );
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if ( pollTimer ) {
|
||||
clearInterval( pollTimer );
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event handlers ──────────────────────────────────────────────────────
|
||||
|
||||
async function handleStart() {
|
||||
const taxonomy = $( '#wpdo-tst-taxonomy' ).value;
|
||||
const target = parseInt( $( '#wpdo-tst-target' ).value, 10 );
|
||||
const batch = parseInt( $( '#wpdo-tst-batch' ).value, 10 );
|
||||
const mode = document.querySelector( 'input[name="wpdo-tst-mode"]:checked' ).value;
|
||||
|
||||
if ( ! taxonomy ) {
|
||||
alert( '請選擇 taxonomy' );
|
||||
return;
|
||||
}
|
||||
if ( ! target || target < 1 ) {
|
||||
alert( '請輸入有效的 term 數量' );
|
||||
return;
|
||||
}
|
||||
if ( mode === 'realistic' && batch > 50 ) {
|
||||
if ( ! confirm( `Realistic 模式每個 term 約需 50-150 ms(wp_insert_term + 3 個 update_term_meta),batch_size=${ batch } 可能超過後端 8s deadline。建議 batch=10-30。要繼續嗎?` ) ) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Soft warn for combinations that won't demonstrate反 EAV 優化效果
|
||||
const termMode = String( cfg.termMode || 'disabled' );
|
||||
if ( mode === 'fast' && termMode === 'aeav_only' ) {
|
||||
if ( ! confirm( `⚠️ Fast 模式直接 $wpdb->insert 繞過 Hook Bus,即使 term mode=aeav_only 也會寫滿 wp_termmeta。\n\n要驗證反 EAV 優化效果(wp_termmeta 應為 0),請改用 🐢 Realistic 模式。\n\n仍以 Fast 模式繼續嗎?` ) ) {
|
||||
return;
|
||||
}
|
||||
} else if ( mode === 'realistic' && termMode !== 'aeav_only' ) {
|
||||
if ( ! confirm( `⚠️ 目前 term mode = ${ termMode },Realistic 寫入仍會雙寫 wp_termmeta(不展示優化效果)。\n\n要看 wp_termmeta 完全短路請先升 mode 至 aeav_only(設定 tab)。\n\n仍以 ${ termMode } 模式繼續嗎?` ) ) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const big = target >= 5000;
|
||||
const msg = `即將以 ${ mode } 模式建立 ${ target.toLocaleString() } 筆 ${ taxonomy } 測試 term${ big ? '(規模較大)' : '' }。\n\n所有 term 的 slug 會以 wpdo-stress- 開頭,可一鍵清除。\n\n確定要開始嗎?`;
|
||||
if ( ! confirm( msg ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const r = await apiCall( '/term-stress-test/start', 'POST', {
|
||||
taxonomy,
|
||||
target,
|
||||
mode,
|
||||
batch_size: batch,
|
||||
} );
|
||||
if ( ! r.ok ) {
|
||||
alert( '啟動失敗:' + ( r.data?.error || r.status ) );
|
||||
return;
|
||||
}
|
||||
const card = $( '#wpdo-tst-progress-card' );
|
||||
if ( card ) {
|
||||
card.style.display = '';
|
||||
}
|
||||
const benchCard = $( '#wpdo-tst-bench-card' );
|
||||
if ( benchCard ) {
|
||||
benchCard.style.display = 'none';
|
||||
}
|
||||
setText( '#wpdo-tst-pg-status', 'running' );
|
||||
setText( '#wpdo-tst-pg-taxonomy', taxonomy );
|
||||
setText( '#wpdo-tst-pg-mode', mode );
|
||||
setText( '#wpdo-tst-pg-target', target.toLocaleString() );
|
||||
startPolling();
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if ( ! confirm( '確定要取消當前測試?已建立的 term 不會被刪除。' ) ) {
|
||||
return;
|
||||
}
|
||||
const cancelBtn = $( '#wpdo-tst-cancel' );
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.disabled = true;
|
||||
cancelBtn.textContent = '⏹ 取消中…';
|
||||
}
|
||||
setText( '#wpdo-tst-pg-status', 'cancelling' );
|
||||
|
||||
const r = await apiCall( '/term-stress-test/cancel', 'POST' );
|
||||
if ( ! r.ok ) {
|
||||
alert( '取消失敗:' + ( r.data?.error || r.status ) );
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.disabled = false;
|
||||
cancelBtn.textContent = '⏹ 取消';
|
||||
}
|
||||
return;
|
||||
}
|
||||
poll();
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.textContent = '⏹ 取消';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCleanup() {
|
||||
if ( ! confirm( '確定要清除所有 stress test terms?\n\n此動作會:\n- DELETE 所有 slug 前綴 wpdo-stress- 的 term\n- DELETE 對應 wp_termmeta + wp_term_taxonomy\n- DELETE flat 表中對應 term_id 的列\n\n不可復原!' ) ) {
|
||||
return;
|
||||
}
|
||||
const r = await apiCall( '/term-stress-test/cleanup', 'DELETE' );
|
||||
if ( ! r.ok ) {
|
||||
alert( '清除失敗:' + ( r.data?.error || r.status ) );
|
||||
return;
|
||||
}
|
||||
alert( `已清除 ${ r.data.deleted } 筆測試 term。` );
|
||||
const card = $( '#wpdo-tst-progress-card' );
|
||||
const benchCard = $( '#wpdo-tst-bench-card' );
|
||||
if ( card ) card.style.display = 'none';
|
||||
if ( benchCard ) benchCard.style.display = 'none';
|
||||
poll();
|
||||
}
|
||||
|
||||
async function handleRerunBench() {
|
||||
const btn = $( '#wpdo-tst-rerun-bench' );
|
||||
if ( btn ) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ 執行中...';
|
||||
}
|
||||
const r = await apiCall( '/term-stress-test/benchmark', 'POST' );
|
||||
if ( btn ) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '📊 重跑 Benchmark(不新增資料)';
|
||||
}
|
||||
if ( ! r.ok ) {
|
||||
alert( 'Benchmark 失敗:' + ( r.data?.error || r.status ) );
|
||||
return;
|
||||
}
|
||||
renderBenchmark( r.data.benchmark );
|
||||
}
|
||||
|
||||
// ── Init ────────────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener( 'DOMContentLoaded', () => {
|
||||
const startBtn = $( '#wpdo-tst-start' );
|
||||
const cancelBtn = $( '#wpdo-tst-cancel' );
|
||||
const cleanupBtn = $( '#wpdo-tst-cleanup' );
|
||||
const benchBtn = $( '#wpdo-tst-rerun-bench' );
|
||||
|
||||
if ( startBtn ) startBtn.addEventListener( 'click', handleStart );
|
||||
if ( cancelBtn ) cancelBtn.addEventListener( 'click', handleCancel );
|
||||
if ( cleanupBtn ) cleanupBtn.addEventListener( 'click', handleCleanup );
|
||||
if ( benchBtn ) benchBtn.addEventListener( 'click', handleRerunBench );
|
||||
|
||||
poll().then( () => {
|
||||
const status = ( $( '#wpdo-tst-pg-status' )?.textContent || '' ).trim();
|
||||
if ( status === 'running' || status === 'benchmarking' ) {
|
||||
startPolling();
|
||||
}
|
||||
} );
|
||||
} );
|
||||
} )();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,443 @@
|
||||
<?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 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
|
||||
*/
|
||||
|
||||
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' );
|
||||
$run_health_url = wp_nonce_url( add_query_arg( array( 'wpdo_run_health' => '1' ), $page_url ), 'wpdo_run_health' );
|
||||
$create_snap_url = wp_nonce_url( add_query_arg( array( 'wpdo_create_snapshot' => '1' ), $page_url ), 'wpdo_create_snapshot' );
|
||||
$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 ) :
|
||||
$cleanup_url = wp_nonce_url(
|
||||
add_query_arg( array( 'wpdo_postmeta_cleanup' => '1' ), $page_url ),
|
||||
'wpdo_postmeta_cleanup'
|
||||
);
|
||||
$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'] ) )
|
||||
);
|
||||
?>
|
||||
<a href="<?php echo esc_url( $cleanup_url ); ?>"
|
||||
onclick="return confirm(<?php echo wp_json_encode( $confirm_msg ); ?>);">
|
||||
<strong><?php esc_html_e( '一鍵清理 →', '2meet-data-optimizer' ); ?></strong>
|
||||
</a>
|
||||
</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">
|
||||
<a class="button button-small button-primary" href="<?php echo esc_url( $run_health_url ); ?>">
|
||||
<?php esc_html_e( '跑健康檢查', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
<a class="button button-small" href="<?php echo esc_url( $bridge_url ); ?>">
|
||||
<?php esc_html_e( 'Entity Bridge', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
<a class="button button-small" href="<?php echo esc_url( $create_snap_url ); ?>">
|
||||
<?php esc_html_e( '建立快照', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
<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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Export — Report export endpoints (v2.5.0 M14).
|
||||
*
|
||||
* Three export types:
|
||||
* 1. health — wp_wpdo_audit op='health_check_daily' over last N days
|
||||
* 2. snapshots — wp_wpdo_snapshots metadata (no inline_blob payload)
|
||||
* 3. monthly — wpdo_monthly_summary_history (latest + up to 12 archives)
|
||||
*
|
||||
* Triggered via admin GET:tools.php?page=wp-data-optimizer&wpdo_export=health&format=csv&days=30
|
||||
* Capability: manage_options + nonce.
|
||||
*
|
||||
* Filenames:wpdo-{type}-{site_slug}-{YYYYMMDD}.{csv|json}
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateless static export handler.
|
||||
*/
|
||||
class TMDO_Export {
|
||||
|
||||
public const NONCE = 'wpdo_export';
|
||||
|
||||
/**
|
||||
* Hook into admin_init to handle export requests.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_action( 'admin_init', array( __CLASS__, 'maybe_handle' ), 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect + serve an export request.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function maybe_handle(): void {
|
||||
if ( ! isset( $_GET['wpdo_export'] ) ) {
|
||||
return;
|
||||
}
|
||||
if ( ! TMDO_Capability::current_user_can_admin() ) {
|
||||
return;
|
||||
}
|
||||
if ( ! check_admin_referer( self::NONCE ) ) {
|
||||
return;
|
||||
}
|
||||
$type = sanitize_key( wp_unslash( (string) $_GET['wpdo_export'] ) );
|
||||
$format = sanitize_key( wp_unslash( (string) ( $_GET['format'] ?? 'csv' ) ) );
|
||||
$days = isset( $_GET['days'] ) ? max( 1, min( 365, (int) $_GET['days'] ) ) : 30;
|
||||
if ( ! in_array( $format, array( 'csv', 'json' ), true ) ) {
|
||||
$format = 'csv';
|
||||
}
|
||||
switch ( $type ) {
|
||||
case 'health':
|
||||
self::send_health( $format, $days );
|
||||
break;
|
||||
case 'snapshots':
|
||||
self::send_snapshots( $format );
|
||||
break;
|
||||
case 'monthly':
|
||||
self::send_monthly( $format );
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a download URL with nonce.
|
||||
*
|
||||
* @param string $type health|snapshots|monthly.
|
||||
* @param string $format csv|json.
|
||||
* @param int $days For health only.
|
||||
* @return string
|
||||
*/
|
||||
public static function url( string $type, string $format = 'csv', int $days = 30 ): string {
|
||||
$args = array(
|
||||
'page' => '2meet-data-optimizer',
|
||||
'wpdo_export' => $type,
|
||||
'format' => $format,
|
||||
);
|
||||
if ( 'health' === $type ) {
|
||||
$args['days'] = $days;
|
||||
}
|
||||
return wp_nonce_url( add_query_arg( $args, admin_url( 'tools.php' ) ), self::NONCE );
|
||||
}
|
||||
|
||||
// ─── handlers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send health export.
|
||||
*
|
||||
* @param string $format csv|json.
|
||||
* @param int $days Number of days to include.
|
||||
* @return void
|
||||
*/
|
||||
private static function send_health( string $format, int $days ): void {
|
||||
global $wpdb;
|
||||
$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
|
||||
)
|
||||
);
|
||||
$rows = array();
|
||||
if ( 1 === $exists ) {
|
||||
$raw = (array) $wpdb->get_results(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$audit} is a trusted table name via $wpdb->prefix
|
||||
"SELECT ts, op, value_after FROM `{$audit}` WHERE op = 'health_check_daily' AND ts >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL %d DAY) ORDER BY ts DESC",
|
||||
$days
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
foreach ( $raw as $r ) {
|
||||
$ctx = isset( $r['value_after'] ) ? json_decode( (string) $r['value_after'], true ) : array();
|
||||
if ( ! is_array( $ctx ) ) {
|
||||
$ctx = array();
|
||||
}
|
||||
$rows[] = array(
|
||||
'ts' => (string) $r['ts'],
|
||||
'critical' => (int) ( $ctx['critical'] ?? 0 ),
|
||||
'recommended' => (int) ( $ctx['recommended'] ?? 0 ),
|
||||
'duration_ms' => (int) ( $ctx['duration_ms'] ?? 0 ),
|
||||
'autoload_kb' => (int) ( $ctx['autoload_kb'] ?? 0 ),
|
||||
'conflicts' => (int) ( $ctx['conflicts'] ?? 0 ),
|
||||
'module_suggestions_count' => (int) ( $ctx['module_suggestions_count'] ?? 0 ),
|
||||
);
|
||||
}
|
||||
}
|
||||
$headers = array( 'ts', 'critical', 'recommended', 'duration_ms', 'autoload_kb', 'conflicts', 'module_suggestions_count' );
|
||||
self::respond( $format, 'health', $headers, $rows );
|
||||
}
|
||||
|
||||
/**
|
||||
* Send snapshots export.
|
||||
*
|
||||
* @param string $format csv|json.
|
||||
* @return void
|
||||
*/
|
||||
private static function send_snapshots( string $format ): void {
|
||||
global $wpdb;
|
||||
$snap = $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',
|
||||
$snap
|
||||
)
|
||||
);
|
||||
$rows = array();
|
||||
if ( 1 === $exists ) {
|
||||
$rows = (array) $wpdb->get_results(
|
||||
"SELECT snapshot_id, trigger_type, size_bytes, row_count, storage, file_path, file_sha256, notes, created_at, expires_at FROM `{$snap}` ORDER BY created_at DESC", // phpcs:ignore WordPress.DB
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
$headers = array( 'snapshot_id', 'trigger_type', 'size_bytes', 'row_count', 'storage', 'file_path', 'file_sha256', 'notes', 'created_at', 'expires_at' );
|
||||
self::respond( $format, 'snapshots', $headers, $rows );
|
||||
}
|
||||
|
||||
/**
|
||||
* Send monthly summary export.
|
||||
*
|
||||
* @param string $format csv|json.
|
||||
* @return void
|
||||
*/
|
||||
private static function send_monthly( string $format ): void {
|
||||
$latest = (array) get_option( 'wpdo_monthly_summary_latest', array() );
|
||||
$history = (array) get_option( 'wpdo_monthly_summary_history', array() );
|
||||
$all = array();
|
||||
if ( ! empty( $latest ) ) {
|
||||
$all[] = $latest;
|
||||
}
|
||||
foreach ( $history as $h ) {
|
||||
if ( is_array( $h ) ) {
|
||||
$all[] = $h;
|
||||
}
|
||||
}
|
||||
if ( 'json' === $format ) {
|
||||
self::respond_json( 'monthly', $all );
|
||||
return;
|
||||
}
|
||||
// Flatten for CSV — top-level metric only.
|
||||
$rows = array();
|
||||
foreach ( $all as $row ) {
|
||||
$health = (array) ( $row['health'] ?? array() );
|
||||
$snap = (array) ( $row['snapshots'] ?? array() );
|
||||
$rows[] = array(
|
||||
'period_start' => (string) ( $row['period_start'] ?? '' ),
|
||||
'period_end' => (string) ( $row['period_end'] ?? '' ),
|
||||
'generated_at' => (string) ( $row['generated_at'] ?? '' ),
|
||||
'health_total' => (int) ( $health['total'] ?? 0 ),
|
||||
'health_success' => (int) ( $health['success'] ?? 0 ),
|
||||
'health_critical' => (int) ( $health['critical'] ?? 0 ),
|
||||
'snapshots_total' => (int) ( $snap['total'] ?? 0 ),
|
||||
'snapshots_bytes' => (int) ( $snap['total_bytes'] ?? 0 ),
|
||||
'autoload_size' => (int) ( $row['autoload_size'] ?? 0 ),
|
||||
);
|
||||
}
|
||||
$headers = array( 'period_start', 'period_end', 'generated_at', 'health_total', 'health_success', 'health_critical', 'snapshots_total', 'snapshots_bytes', 'autoload_size' );
|
||||
self::respond_csv( 'monthly', $headers, $rows );
|
||||
}
|
||||
|
||||
// ─── output helpers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Dispatch to csv or json responder.
|
||||
*
|
||||
* @param string $format csv|json.
|
||||
* @param string $type Export type slug.
|
||||
* @param array $headers Column headers.
|
||||
* @param array $rows Data rows.
|
||||
* @return void
|
||||
*/
|
||||
private static function respond( string $format, string $type, array $headers, array $rows ): void {
|
||||
if ( 'json' === $format ) {
|
||||
self::respond_json( $type, $rows );
|
||||
return;
|
||||
}
|
||||
self::respond_csv( $type, $headers, $rows );
|
||||
}
|
||||
|
||||
/**
|
||||
* Send CSV response.
|
||||
*
|
||||
* @param string $type Export type slug.
|
||||
* @param array $headers Column headers.
|
||||
* @param array $rows Data rows.
|
||||
* @return void
|
||||
*/
|
||||
private static function respond_csv( string $type, array $headers, array $rows ): void {
|
||||
$body = TMDO_CSV_Writer::build( $headers, $rows );
|
||||
self::send_attachment( $type, 'csv', 'text/csv; charset=UTF-8', $body );
|
||||
}
|
||||
|
||||
/**
|
||||
* Send JSON response.
|
||||
*
|
||||
* @param string $type Export type slug.
|
||||
* @param array $rows Data rows.
|
||||
* @return void
|
||||
*/
|
||||
private static function respond_json( string $type, array $rows ): void {
|
||||
$body = wp_json_encode( $rows, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
|
||||
self::send_attachment( $type, 'json', 'application/json; charset=UTF-8', (string) $body );
|
||||
}
|
||||
|
||||
/**
|
||||
* Send file attachment response.
|
||||
*
|
||||
* @param string $type Export type slug.
|
||||
* @param string $ext File extension.
|
||||
* @param string $mime MIME type.
|
||||
* @param string $body File body content.
|
||||
* @return void
|
||||
*/
|
||||
private static function send_attachment( string $type, string $ext, string $mime, string $body ): void {
|
||||
$site_slug = sanitize_title( (string) get_option( 'blogname', 'site' ) ) ?: 'site';
|
||||
$filename = sprintf( 'wpdo-%s-%s-%s.%s', $type, $site_slug, gmdate( 'Ymd' ), $ext );
|
||||
nocache_headers();
|
||||
header( 'Content-Type: ' . $mime );
|
||||
header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
|
||||
header( 'Content-Length: ' . strlen( $body ) );
|
||||
echo $body; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Help_Tabs — Contextual help tabs (v2.3.0 M8).
|
||||
*
|
||||
* Registers `add_help_tab()` content on the WPDO admin page. Each tab gets a
|
||||
* dedicated help panel that explains:
|
||||
* - what this tab is for
|
||||
* - what to do here as a first-time user
|
||||
* - links to Entity Bridge, Snapshots, Doctor when relevant
|
||||
*
|
||||
* Hooked on `load-tools_page_wp-data-optimizer` so help tabs only appear on
|
||||
* our admin page (not site-wide).
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Help tab registrar — stateless static API.
|
||||
*/
|
||||
class TMDO_Help_Tabs {
|
||||
|
||||
/**
|
||||
* Hook into admin page load.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_action( 'load-tools_page_wp-data-optimizer', array( __CLASS__, 'add_tabs' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register help tabs based on current `tab` GET param.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function add_tabs(): void {
|
||||
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
|
||||
if ( null === $screen ) {
|
||||
return;
|
||||
}
|
||||
$tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( (string) $_GET['tab'] ) ) : 'dashboard'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
|
||||
// Always-on overview tab.
|
||||
$screen->add_help_tab(
|
||||
array(
|
||||
'id' => 'wpdo-help-overview',
|
||||
'title' => __( '什麼是 WPDO', '2meet-data-optimizer' ),
|
||||
'content' => self::content_overview(),
|
||||
)
|
||||
);
|
||||
|
||||
// Per-tab help (matches whichever tab is active).
|
||||
$tab_help = array(
|
||||
'dashboard' => array( __( '儀表板閱讀指南', '2meet-data-optimizer' ), 'content_dashboard' ),
|
||||
'zones' => array( __( '4 個 Zone 是什麼', '2meet-data-optimizer' ), 'content_zones' ),
|
||||
'classifier' => array( __( 'Classifier 解讀', '2meet-data-optimizer' ), 'content_classifier' ),
|
||||
'snapshots' => array( __( '備份策略', '2meet-data-optimizer' ), 'content_snapshots' ),
|
||||
'conflicts' => array( __( '衝突處理', '2meet-data-optimizer' ), 'content_conflicts' ),
|
||||
'doctor' => array( __( '健康檢查解讀', '2meet-data-optimizer' ), 'content_doctor' ),
|
||||
'logs' => array( __( '日誌使用', '2meet-data-optimizer' ), 'content_logs' ),
|
||||
'rest-api' => array( __( 'REST API', '2meet-data-optimizer' ), 'content_rest_api' ),
|
||||
);
|
||||
if ( isset( $tab_help[ $tab ] ) ) {
|
||||
[ $title, $cb ] = $tab_help[ $tab ];
|
||||
$screen->add_help_tab(
|
||||
array(
|
||||
'id' => 'wpdo-help-' . $tab,
|
||||
'title' => $title,
|
||||
'content' => call_user_func( array( __CLASS__, $cb ) ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Sidebar with persistent links.
|
||||
$screen->set_help_sidebar(
|
||||
'<p><strong>' . esc_html__( '更多資源', '2meet-data-optimizer' ) . '</strong></p>'
|
||||
. '<p><a href="' . esc_url( admin_url( 'tools.php?page=wp-data-optimizer&tab=entity-bridge' ) ) . '">' . esc_html__( 'Entity Bridge — 主維運入口', '2meet-data-optimizer' ) . '</a></p>'
|
||||
. '<p><a href="' . esc_url( admin_url( 'site-health.php' ) ) . '">' . esc_html__( 'WP Site Health', '2meet-data-optimizer' ) . '</a></p>'
|
||||
. '<p><code>wp wpdo doctor</code><br/><code>wp wpdo mode-audit</code><br/><code>wp wpdo snapshot create</code></p>'
|
||||
);
|
||||
}
|
||||
|
||||
// ─── content templates ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Overview help content.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function content_overview(): string {
|
||||
return '<p>' . esc_html__( 'WP Data Optimizer 是反 EAV(meta 爆炸)的解方。', '2meet-data-optimizer' ) . '</p>'
|
||||
. '<p>' . esc_html__( '核心概念:把 wp_postmeta 的高頻欄位(Hot)、TTL 暫存(Warm)、低頻欄位(Cold)、歷史資料(Archive)拆到 4 種專用表,讀寫快很多、autoload 不再爆。', '2meet-data-optimizer' ) . '</p>'
|
||||
. '<p><strong>' . esc_html__( '建議第一步:', '2meet-data-optimizer' ) . '</strong> ' . esc_html__( '逛一遍儀表板了解現況 → 看 Entity Bridge tab 各 entity 健康卡片 → 從 1 個 entity 開始用 Migration Wizard 漸進升級 mode。', '2meet-data-optimizer' ) . '</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard help content.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function content_dashboard(): string {
|
||||
return '<p>' . esc_html__( '儀表板顯示:', '2meet-data-optimizer' ) . '</p>'
|
||||
. '<ul style="list-style: disc; padding-left: 1.5em;">'
|
||||
. '<li>' . esc_html__( 'System Overview — DB 引擎、HivePress、HPCT、Object Cache 是否啟用', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( 'Zone 行數統計 — Hot/Warm/Cold/Archive 各自累積多少資料', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( 'Module 狀態 — 每個 module 在 7-state FSM 哪一格', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( 'Warm zone live view — 哪些 view counts / TTL 進來、24h 快過期數', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( 'Archive 統計 — 壓縮率、依 post_type 拆分', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( 'REST API rate limit — 429 事件 + Top 10 受限 post', '2meet-data-optimizer' ) . '</li>'
|
||||
. '</ul>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Zones help content.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function content_zones(): string {
|
||||
return '<p>' . esc_html__( '4 個 Zone 對應不同存取頻率與保留需求:', '2meet-data-optimizer' ) . '</p>'
|
||||
. '<ul style="list-style: disc; padding-left: 1.5em;">'
|
||||
. '<li><strong>Hot</strong> — ' . esc_html__( '高頻索引欄位,如 listing 的 price / location。獨立 column + index,WP_Query 可 JOIN。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>Warm</strong> — ' . esc_html__( 'TTL 暫存(如 view count、cache stats)。固定表 wp_wpdo_warm 含 expires_at。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>Cold</strong> — ' . esc_html__( '低頻 meta(settings / preferences)。讀寫透過 interceptor 攔截後保持 EAV 形式。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>Archive</strong> — ' . esc_html__( 'Trashed / 90+ 天舊資料。可 gzip 壓縮。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '</ul>'
|
||||
. '<p>' . esc_html__( '不確定要哪種 → 用 Classifier,它會看 access pattern 給建議。', '2meet-data-optimizer' ) . '</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifier help content.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function content_classifier(): string {
|
||||
return '<p>' . esc_html__( 'Classifier 分析 wp_postmeta 給每個 meta_key 一個 zone 建議:', '2meet-data-optimizer' ) . '</p>'
|
||||
. '<ul style="list-style: disc; padding-left: 1.5em;">'
|
||||
. '<li><strong>Confidence</strong> — ' . esc_html__( '0.0~1.0,越高代表分類越確定。≥ 0.8 可放心採納,< 0.5 建議 manual review。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>Reasons</strong> — ' . esc_html__( '說明為什麼建議這個 zone(access frequency / row count / TTL hints)。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>Already-assigned</strong> — ' . esc_html__( '已透過 Schema_Registry 註冊的 meta_key 數量。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '</ul>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshots help content.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function content_snapshots(): string {
|
||||
return '<p>' . esc_html__( '快照保留政策(v2.2.0):', '2meet-data-optimizer' ) . '</p>'
|
||||
. '<ul style="list-style: disc; padding-left: 1.5em;">'
|
||||
. '<li>' . esc_html__( '預設 30 天 TTL,可在 wp wpdo snapshot create 時用 --retention-days 覆蓋。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( 'pre_uninstall / pre_v2_upgrade triggers 受 size-cap 保護(不會被自動 evict)。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( '檔案存於 wp-content/uploads/wpdo-backups/,含 .htaccess deny all + 每個檔 sha256 校驗。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( '小於 5MB 自動 inline 到 wp_wpdo_snapshots.inline_blob,方便 wp db export 時跟著走。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '</ul>'
|
||||
. '<p><strong>' . esc_html__( '災難還原 drill', '2meet-data-optimizer' ) . '</strong>:'
|
||||
. esc_html__( '建議每月做一次 dry-run 還原驗證 — wp wpdo snapshot restore <id>(不加 --apply)即可預覽會還原什麼。', '2meet-data-optimizer' ) . '</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Conflicts help content.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function content_conflicts(): string {
|
||||
return '<p>' . esc_html__( 'Hook 衝突偵測:當多個 plugin 在同一 WordPress 的 metadata filter 上掛 callback 時,可能造成資料寫入順序不確定 / 重複處理。', '2meet-data-optimizer' ) . '</p>'
|
||||
. '<p>' . esc_html__( '常見原因:', '2meet-data-optimizer' ) . '</p>'
|
||||
. '<ul style="list-style: disc; padding-left: 1.5em;">'
|
||||
. '<li>' . esc_html__( 'Hook Bus 啟用(wpdo_hook_bus_enabled = 1)+ legacy interceptors 還沒卸載', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( 'HPCT (HP Custom Tables) plugin 還沒移除 — 與 WPDO 同時攔截', '2meet-data-optimizer' ) . '</li>'
|
||||
. '</ul>'
|
||||
. '<p>' . esc_html__( '解法:先看 conflict-scan 詳情,必要時用 wp wpdo bridge-set off 暫停 Hook Bus 直到清理完。', '2meet-data-optimizer' ) . '</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Doctor help content.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function content_doctor(): string {
|
||||
return '<p>' . esc_html__( '7 項自動健康檢查:', '2meet-data-optimizer' ) . '</p>'
|
||||
. '<ol style="padding-left: 1.5em;">'
|
||||
. '<li><strong>schema_drift</strong> — ' . esc_html__( '所有 v2 表是否存在', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>error_budget</strong> — ' . esc_html__( '7 天內 wp_wpdo_errors 行數', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>hook_conflicts</strong> — ' . esc_html__( '同上 conflicts tab', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>autoload_bloat</strong> — ' . esc_html__( 'wp_options autoload 大小 > 5MB 警告', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>postmeta_explosion</strong> — ' . esc_html__( 'wp_postmeta > 5M 行', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>orphan_zone_rows</strong> — ' . esc_html__( '已 idle 的 module 但 zone 表還有資料', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>missing_snapshot</strong> — ' . esc_html__( '在 cutover/cleanup/complete 但 7 天沒 snapshot', '2meet-data-optimizer' ) . '</li>'
|
||||
. '</ol>'
|
||||
. '<p>' . esc_html__( '結果有 5 分鐘 transient cache,剛操作完想立刻看新值請等下個週期。', '2meet-data-optimizer' ) . '</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs help content.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function content_logs(): string {
|
||||
return '<p>' . esc_html__( '日誌讀取:', '2meet-data-optimizer' ) . '</p>'
|
||||
. '<ul style="list-style: disc; padding-left: 1.5em;">'
|
||||
. '<li>' . esc_html__( '每筆對應 wp_wpdo_errors 一行:module / zone / hook / message / timestamp。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( '預設保留 90 天(wpdo_errors_gc daily cron 自動清)。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( '看 message 開頭 [WARN] 是 warning level(不影響運作但需注意)。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '</ul>';
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API help content.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function content_rest_api(): string {
|
||||
return '<p>' . esc_html__( 'REST API 提供 zone 操作 + diagnostics endpoints。', '2meet-data-optimizer' ) . '</p>'
|
||||
. '<p>' . esc_html__( '所有 endpoint 用 X-WP-Nonce 認證;rate limit 預設 30/min。', '2meet-data-optimizer' ) . '</p>';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
<?php
|
||||
// phpcs:ignore WPDO.AntiEAV -- platform admin UI: postmeta inventory for setup wizard
|
||||
/**
|
||||
* TMDO_Setup_Wizard — First-run onboarding wizard (v2.3.0 M5).
|
||||
*
|
||||
* 5 steps:
|
||||
* 1. Welcome + anti-EAV concept (4-zone diagram, when needed, when not).
|
||||
* 2. Baseline diagnostic — auto-runs Site Health, postmeta count, top
|
||||
* meta_key consumers, predicted optimization upside.
|
||||
* 3. Recommendation — checks Classifier suggestions, presents pre-checked
|
||||
* modules with "OK" / "skip" choice (no auto-enable; explicit consent).
|
||||
* 4. First snapshot + daily health check (already scheduled by Core, just
|
||||
* confirms here).
|
||||
* 5. Done — sets wpdo_setup_wizard_completed=1, wpdo_first_run_at timestamp.
|
||||
*
|
||||
* Trigger: admin loads any wp-admin page AND wpdo_setup_wizard_completed != '1'.
|
||||
* Existing-user shielding: if any module is not idle (= already migrating),
|
||||
* we mark wizard completed immediately to avoid ambushing experienced admins.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup wizard — stateless static API.
|
||||
*/
|
||||
class TMDO_Setup_Wizard {
|
||||
|
||||
public const OPT_COMPLETED = 'wpdo_setup_wizard_completed';
|
||||
public const OPT_FIRST_RUN = 'wpdo_first_run_at';
|
||||
public const QUERY_PARAM = 'wpdo_wizard';
|
||||
public const NONCE_NAME = 'wpdo_wizard_nonce';
|
||||
public const TOTAL_STEPS = 5;
|
||||
|
||||
/**
|
||||
* Bootstrap hooks.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_action( 'admin_init', array( __CLASS__, 'maybe_redirect_to_wizard' ) );
|
||||
add_action( 'admin_menu', array( __CLASS__, 'register_menu_page' ) );
|
||||
add_action( 'admin_post_wpdo_wizard_step', array( __CLASS__, 'handle_step_submit' ) );
|
||||
add_action( 'admin_post_wpdo_wizard_dismiss', array( __CLASS__, 'handle_dismiss' ) );
|
||||
add_action( 'admin_post_wpdo_wizard_reset', array( __CLASS__, 'handle_reset' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* On first activation, decide: run wizard, or shield (already configured).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function maybe_redirect_to_wizard(): void {
|
||||
// Don't redirect on AJAX / cron / auto-saves / wp-cli.
|
||||
if ( wp_doing_ajax() || wp_doing_cron() || ( defined( 'WP_CLI' ) && WP_CLI ) ) {
|
||||
return;
|
||||
}
|
||||
if ( ! TMDO_Capability::current_user_can_admin() ) {
|
||||
return;
|
||||
}
|
||||
if ( '1' === get_option( self::OPT_COMPLETED ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Shield existing users: if any module is not idle, the admin already knows
|
||||
// what they're doing. Mark wizard completed silently.
|
||||
if ( class_exists( 'TMDO_Feature_Flags' ) ) {
|
||||
$flags = TMDO_Feature_Flags::all();
|
||||
foreach ( $flags as $state ) {
|
||||
if ( 'idle' !== $state ) {
|
||||
update_option( self::OPT_COMPLETED, '1', false );
|
||||
update_option( self::OPT_FIRST_RUN, gmdate( 'Y-m-d H:i:s' ), false );
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't redirect if already on the wizard page or another WPDO admin page.
|
||||
$page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( (string) $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
if ( in_array( $page, array( 'wpdo-setup-wizard', '2meet-data-optimizer' ), true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only redirect from the WordPress dashboard top page (avoid plugin install + mu-plugins flow).
|
||||
global $pagenow;
|
||||
if ( 'index.php' !== $pagenow ) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=1' ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the wizard as a visible submenu item under Tools > WP Data Optimizer.
|
||||
*
|
||||
* Appears in the Tools section so admins can re-run the wizard at any time.
|
||||
* The wizard itself is idempotent: re-running it never auto-enables modules.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register_menu_page(): void {
|
||||
add_submenu_page(
|
||||
'tools.php',
|
||||
__( 'WPDO 設定嚮導', '2meet-data-optimizer' ),
|
||||
__( 'WPDO 設定嚮導', '2meet-data-optimizer' ),
|
||||
'manage_options',
|
||||
'wpdo-setup-wizard',
|
||||
array( __CLASS__, 'render_page' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the wizard.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function render_page(): void {
|
||||
if ( ! TMDO_Capability::current_user_can_admin() ) {
|
||||
wp_die( esc_html__( 'Insufficient permissions.', '2meet-data-optimizer' ) );
|
||||
}
|
||||
$step = max( 1, min( self::TOTAL_STEPS, absint( wp_unslash( $_GET['step'] ?? 1 ) ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
$completed = '1' === get_option( self::OPT_COMPLETED );
|
||||
?>
|
||||
<div class="wrap" style="max-width: 800px;">
|
||||
<p>
|
||||
<a href="<?php echo esc_url( admin_url( 'tools.php?page=wp-data-optimizer' ) ); ?>">
|
||||
← <?php esc_html_e( '返回 WP Data Optimizer', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
</p>
|
||||
<h1><?php esc_html_e( 'WP Data Optimizer — 設定嚮導', '2meet-data-optimizer' ); ?></h1>
|
||||
|
||||
<?php if ( $completed ) : ?>
|
||||
<div style="background:#f0f6fc; border-left:4px solid #2271b1; padding:0.75em 1em; margin-bottom:1em; display:flex; align-items:center; gap:1em;">
|
||||
<span>✅ <?php esc_html_e( '嚮導已完成。你可以重新瀏覽任何步驟,或重設為全新執行。', '2meet-data-optimizer' ); ?></span>
|
||||
<a class="button button-secondary"
|
||||
href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=wpdo_wizard_reset' ), self::NONCE_NAME ) ); ?>"
|
||||
style="white-space:nowrap;">
|
||||
<?php esc_html_e( '重新執行精靈', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<p class="description">
|
||||
<?php esc_html_e( '第', '2meet-data-optimizer' ); ?>
|
||||
<strong><?php echo (int) $step; ?></strong> /
|
||||
<strong><?php echo (int) self::TOTAL_STEPS; ?></strong>
|
||||
<?php esc_html_e( '步', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
|
||||
<div style="background: #f0f0f1; height: 8px; border-radius: 4px; overflow: hidden; margin-bottom: 2em;">
|
||||
<div style="background: #2271b1; height: 100%; width: <?php echo (int) ( $step / self::TOTAL_STEPS * 100 ); ?>%; transition: width 0.3s;"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="max-width: none; padding: 2em;">
|
||||
<?php call_user_func( array( __CLASS__, 'render_step_' . $step ) ); ?>
|
||||
</div>
|
||||
|
||||
<p style="margin-top: 2em;">
|
||||
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=wpdo_wizard_dismiss' ), self::NONCE_NAME ) ); ?>"
|
||||
style="color: #888; font-size: 0.9em;">
|
||||
<?php esc_html_e( '我熟悉了,跳過嚮導', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
// ─── steps ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render wizard step 1: Welcome.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function render_step_1(): void {
|
||||
?>
|
||||
<h2><?php esc_html_e( '👋 歡迎使用 WP Data Optimizer', '2meet-data-optimizer' ); ?></h2>
|
||||
<p><?php esc_html_e( 'WPDO 解決的是 wp_postmeta 表「meta 爆炸」問題:當 postmeta 累積到數百萬行時,autoload 變大、JOIN 變慢、整站變慢。', '2meet-data-optimizer' ); ?></p>
|
||||
|
||||
<h3><?php esc_html_e( '4 個 Zone 是什麼?', '2meet-data-optimizer' ); ?></h3>
|
||||
<table class="widefat" style="margin-bottom: 1em;">
|
||||
<thead>
|
||||
<tr><th>Zone</th><th><?php esc_html_e( '用途', '2meet-data-optimizer' ); ?></th><th><?php esc_html_e( '舉例', '2meet-data-optimizer' ); ?></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><strong>Hot</strong></td><td><?php esc_html_e( '高頻 + 索引欄位', '2meet-data-optimizer' ); ?></td><td><?php esc_html_e( 'price, location, vendor_id', '2meet-data-optimizer' ); ?></td></tr>
|
||||
<tr><td><strong>Warm</strong></td><td><?php esc_html_e( 'TTL 暫存(會過期)', '2meet-data-optimizer' ); ?></td><td><?php esc_html_e( 'view_count, last_seen', '2meet-data-optimizer' ); ?></td></tr>
|
||||
<tr><td><strong>Cold</strong></td><td><?php esc_html_e( '低頻設定型欄位', '2meet-data-optimizer' ); ?></td><td><?php esc_html_e( 'preferences, settings', '2meet-data-optimizer' ); ?></td></tr>
|
||||
<tr><td><strong>Archive</strong></td><td><?php esc_html_e( '舊資料 / 已 trashed', '2meet-data-optimizer' ); ?></td><td><?php esc_html_e( '90+ 天前的記錄', '2meet-data-optimizer' ); ?></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3><?php esc_html_e( '何時需要 WPDO?', '2meet-data-optimizer' ); ?></h3>
|
||||
<ul style="list-style: disc; padding-left: 1.5em;">
|
||||
<li><?php esc_html_e( '✅ wp_postmeta 行數 > 100k 開始考慮', '2meet-data-optimizer' ); ?></li>
|
||||
<li><?php esc_html_e( '✅ 行數 > 1M 強烈建議啟用', '2meet-data-optimizer' ); ?></li>
|
||||
<li><?php esc_html_e( '⚠️ 小站台(< 10k 行)可以裝著但別啟用 module', '2meet-data-optimizer' ); ?></li>
|
||||
</ul>
|
||||
|
||||
<form method="get" action="<?php echo esc_url( admin_url( 'tools.php' ) ); ?>" style="margin-top: 2em;">
|
||||
<input type="hidden" name="page" value="wpdo-setup-wizard">
|
||||
<input type="hidden" name="step" value="2">
|
||||
<button class="button button-primary button-hero"><?php esc_html_e( '下一步:跑健診 →', '2meet-data-optimizer' ); ?></button>
|
||||
</form>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Render wizard step 2: Baseline diagnostic.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function render_step_2(): void {
|
||||
global $wpdb;
|
||||
$pm_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->postmeta}`" ); // phpcs:ignore WordPress.DB
|
||||
$autoload_bytes = (int) $wpdb->get_var( "SELECT COALESCE(SUM(LENGTH(option_value)),0) FROM `{$wpdb->options}` WHERE autoload = 'yes'" ); // phpcs:ignore WordPress.DB
|
||||
$top_keys = $wpdb->get_results( "SELECT meta_key, COUNT(*) AS c FROM `{$wpdb->postmeta}` GROUP BY meta_key ORDER BY c DESC LIMIT 10", ARRAY_A ); // phpcs:ignore WordPress.DB
|
||||
?>
|
||||
<h2><?php esc_html_e( '🔬 Baseline 健診', '2meet-data-optimizer' ); ?></h2>
|
||||
<table class="widefat striped" style="max-width: 600px; margin-bottom: 1em;">
|
||||
<tbody>
|
||||
<tr><th><?php esc_html_e( 'wp_postmeta 行數', '2meet-data-optimizer' ); ?></th><td><strong><?php echo esc_html( number_format_i18n( $pm_count ) ); ?></strong></td></tr>
|
||||
<tr><th><?php esc_html_e( 'autoload 大小', '2meet-data-optimizer' ); ?></th><td><strong><?php echo esc_html( size_format( $autoload_bytes, 1 ) ); ?></strong></td></tr>
|
||||
<tr><th><?php esc_html_e( '建議啟用 WPDO?', '2meet-data-optimizer' ); ?></th>
|
||||
<td>
|
||||
<?php if ( $pm_count > 1_000_000 ) : ?>
|
||||
<span style="color: #46b450; font-weight: bold;">✅ <?php esc_html_e( '強烈建議', '2meet-data-optimizer' ); ?></span>
|
||||
<?php elseif ( $pm_count > 100_000 ) : ?>
|
||||
<span style="color: #dba617; font-weight: bold;">🟡 <?php esc_html_e( '可以考慮', '2meet-data-optimizer' ); ?></span>
|
||||
<?php else : ?>
|
||||
<span style="color: #8c8f94;">⏸ <?php esc_html_e( '尚不必', '2meet-data-optimizer' ); ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3><?php esc_html_e( 'Top 10 meta_key(依行數)', '2meet-data-optimizer' ); ?></h3>
|
||||
<table class="widefat striped">
|
||||
<thead><tr><th>meta_key</th><th><?php esc_html_e( '行數', '2meet-data-optimizer' ); ?></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ( (array) $top_keys as $k ) : ?>
|
||||
<tr><td><code><?php echo esc_html( (string) $k['meta_key'] ); ?></code></td><td><?php echo esc_html( number_format_i18n( (int) $k['c'] ) ); ?></td></tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<form method="get" action="<?php echo esc_url( admin_url( 'tools.php' ) ); ?>" style="margin-top: 2em;">
|
||||
<input type="hidden" name="page" value="wpdo-setup-wizard">
|
||||
<input type="hidden" name="step" value="3">
|
||||
<button class="button button-primary button-hero"><?php esc_html_e( '下一步:看建議 →', '2meet-data-optimizer' ); ?></button>
|
||||
</form>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Render wizard step 3: Recommendations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function render_step_3(): void {
|
||||
?>
|
||||
<h2><?php esc_html_e( '🧭 智能推薦', '2meet-data-optimizer' ); ?></h2>
|
||||
<p><?php esc_html_e( '系統依你的環境(plugin / post_type / 行數)自動偵測哪些 module 適合啟用。每筆建議含 confidence + reasons。', '2meet-data-optimizer' ); ?></p>
|
||||
|
||||
<?php
|
||||
$actionable = array();
|
||||
if ( class_exists( 'TMDO_Module_Detector' ) ) {
|
||||
$actionable = TMDO_Module_Detector::get_actionable( 0.5 );
|
||||
}
|
||||
?>
|
||||
|
||||
<?php if ( empty( $actionable ) ) : ?>
|
||||
<div class="notice notice-info inline" style="padding: 1em;">
|
||||
<p><?php esc_html_e( '目前環境暫無高 confidence 的 module 建議。可隨時前往「模組建議」tab 重新檢查。', '2meet-data-optimizer' ); ?></p>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<h3>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %d: count */
|
||||
esc_html__( '✅ 偵測到 %d 個建議啟用的 module', '2meet-data-optimizer' ),
|
||||
count( $actionable )
|
||||
);
|
||||
?>
|
||||
</h3>
|
||||
<table class="widefat striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'Module', '2meet-data-optimizer' ); ?></th>
|
||||
<th><?php esc_html_e( 'Confidence', '2meet-data-optimizer' ); ?></th>
|
||||
<th><?php esc_html_e( '說明', '2meet-data-optimizer' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ( $actionable as $module => $r ) : ?>
|
||||
<tr>
|
||||
<td><strong><code><?php echo esc_html( (string) $module ); ?></code></strong></td>
|
||||
<td><?php echo esc_html( sprintf( '%.2f', (float) $r['confidence'] ) ); ?></td>
|
||||
<td>
|
||||
<em><?php echo esc_html( (string) $r['description'] ); ?></em><br/>
|
||||
<small><?php echo esc_html( implode( ' · ', (array) $r['reasons'] ) ); ?></small>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="description"><?php esc_html_e( '完成 wizard 後,到「模組建議」tab 一鍵啟用(推進到 dual_write,FSM Guard 確保不越級)。', '2meet-data-optimizer' ); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3><?php esc_html_e( '黃金法則', '2meet-data-optimizer' ); ?></h3>
|
||||
<ul style="list-style: disc; padding-left: 1.5em;">
|
||||
<li><?php esc_html_e( '一次只推進 1 個 module,每階段觀察至少 24-48 小時。', '2meet-data-optimizer' ); ?></li>
|
||||
<li><?php esc_html_e( '進 cutover 前一定要有 snapshot(FSM Guard 自動觸發)。', '2meet-data-optimizer' ); ?></li>
|
||||
<li><?php esc_html_e( '出事第一件事:rewind 該 module 到 idle(emergency 流程)。', '2meet-data-optimizer' ); ?></li>
|
||||
</ul>
|
||||
|
||||
<form method="get" action="<?php echo esc_url( admin_url( 'tools.php' ) ); ?>" style="margin-top: 2em;">
|
||||
<input type="hidden" name="page" value="wpdo-setup-wizard">
|
||||
<input type="hidden" name="step" value="4">
|
||||
<button class="button button-primary button-hero"><?php esc_html_e( '下一步:建第一個 snapshot →', '2meet-data-optimizer' ); ?></button>
|
||||
</form>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Render wizard step 4: Snapshot + health check.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function render_step_4(): void {
|
||||
// On entry, take the first snapshot if not already done in this wizard run.
|
||||
$snapshot_taken = isset( $_GET['snap_done'] ) ? '1' === $_GET['snap_done'] : false; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
?>
|
||||
<h2><?php esc_html_e( '📸 第一個 Snapshot + Daily Health Check', '2meet-data-optimizer' ); ?></h2>
|
||||
|
||||
<h3><?php esc_html_e( '📦 Snapshot', '2meet-data-optimizer' ); ?></h3>
|
||||
<?php if ( $snapshot_taken ) : ?>
|
||||
<p style="color: #46b450; font-weight: bold;">✅ <?php esc_html_e( '快照已建立。可隨時於「備份快照」tab 查看與管理。', '2meet-data-optimizer' ); ?></p>
|
||||
<?php else : ?>
|
||||
<p><?php esc_html_e( '我們會建立一個 baseline snapshot,命名為 wizard_baseline,保留 365 天。即使你日後沒做任何 destructive 操作,這也是一個 known-good 還原點。', '2meet-data-optimizer' ); ?></p>
|
||||
<p>
|
||||
<a class="button button-secondary"
|
||||
href="<?php echo esc_url( wp_nonce_url( admin_url( 'tools.php?page=wpdo-setup-wizard&step=4&action=take_snapshot' ), self::NONCE_NAME ) ); ?>">
|
||||
<?php esc_html_e( '建立 baseline snapshot', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
</p>
|
||||
<?php
|
||||
// Take snapshot if action requested.
|
||||
if ( isset( $_GET['action'] ) && 'take_snapshot' === $_GET['action'] && check_admin_referer( self::NONCE_NAME ) && class_exists( 'TMDO_Snapshot_Manager' ) ) {
|
||||
$result = TMDO_Snapshot_Manager::create(
|
||||
'manual',
|
||||
array(),
|
||||
array(
|
||||
'notes' => 'wizard_baseline',
|
||||
'retention_days' => 365,
|
||||
)
|
||||
);
|
||||
if ( ! empty( $result['ok'] ) ) {
|
||||
wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=4&snap_done=1' ) );
|
||||
exit;
|
||||
}
|
||||
echo '<div class="notice notice-error"><p>' . esc_html__( 'Snapshot 建立失敗,請查日誌。', '2meet-data-optimizer' ) . '</p></div>';
|
||||
}
|
||||
?>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3><?php esc_html_e( '🔄 Daily Health Check', '2meet-data-optimizer' ); ?></h3>
|
||||
<p>
|
||||
<?php
|
||||
$next = wp_next_scheduled( 'wpdo_daily_health_check' );
|
||||
if ( $next ) {
|
||||
printf(
|
||||
/* translators: %s: human-readable timestamp */
|
||||
esc_html__( 'Daily cron 已排程,下次執行:%s(UTC)', '2meet-data-optimizer' ),
|
||||
'<code>' . esc_html( gmdate( 'Y-m-d H:i:s', $next ) ) . '</code>'
|
||||
);
|
||||
} else {
|
||||
esc_html_e( '⚠️ Daily cron 未排程;請重新啟用外掛。', '2meet-data-optimizer' );
|
||||
}
|
||||
?>
|
||||
</p>
|
||||
<p class="description"><?php esc_html_e( 'Cron 會跑 7 項 Site Health 檢查;critical 警告寫入 admin notice + audit log。', '2meet-data-optimizer' ); ?></p>
|
||||
|
||||
<form method="get" action="<?php echo esc_url( admin_url( 'tools.php' ) ); ?>" style="margin-top: 2em;">
|
||||
<input type="hidden" name="page" value="wpdo-setup-wizard">
|
||||
<input type="hidden" name="step" value="5">
|
||||
<button class="button button-primary button-hero"><?php esc_html_e( '下一步:完成 →', '2meet-data-optimizer' ); ?></button>
|
||||
</form>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Render wizard step 5: Done.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function render_step_5(): void {
|
||||
// Mark complete on render of step 5.
|
||||
update_option( self::OPT_COMPLETED, '1', false );
|
||||
if ( ! get_option( self::OPT_FIRST_RUN ) ) {
|
||||
update_option( self::OPT_FIRST_RUN, gmdate( 'Y-m-d H:i:s' ), false );
|
||||
}
|
||||
?>
|
||||
<h2><?php esc_html_e( '🎉 完成!', '2meet-data-optimizer' ); ?></h2>
|
||||
<p><?php esc_html_e( 'Setup wizard 已結束。下一步建議:', '2meet-data-optimizer' ); ?></p>
|
||||
|
||||
<ul style="list-style: disc; padding-left: 1.5em; line-height: 1.8;">
|
||||
<li><a href="<?php echo esc_url( admin_url( 'tools.php?page=wp-data-optimizer&tab=entity-bridge' ) ); ?>"><?php esc_html_e( '🌉 看 Entity Bridge 健康卡片', '2meet-data-optimizer' ); ?></a> — <?php esc_html_e( 'user / post / term / comment 4 entity 即時健康+模式狀態', '2meet-data-optimizer' ); ?></li>
|
||||
<li><a href="<?php echo esc_url( admin_url( 'tools.php?page=wp-data-optimizer&tab=migration-wizard' ) ); ?>"><?php esc_html_e( '🪄 開 User / Post 遷移精靈', '2meet-data-optimizer' ); ?></a> — <?php esc_html_e( '一鍵推進 disabled → dual_write → shadow_read → aeav_only', '2meet-data-optimizer' ); ?></li>
|
||||
<li><a href="<?php echo esc_url( admin_url( 'tools.php?page=wp-data-optimizer&tab=classifier' ) ); ?>"><?php esc_html_e( '🔍 跑 Classifier', '2meet-data-optimizer' ); ?></a> — <?php esc_html_e( '看推薦 zone 配置', '2meet-data-optimizer' ); ?></li>
|
||||
<li><a href="<?php echo esc_url( admin_url( 'site-health.php' ) ); ?>"><?php esc_html_e( '🏥 WP Site Health', '2meet-data-optimizer' ); ?></a> — <?php esc_html_e( '7 個 WPDO 健康檢查', '2meet-data-optimizer' ); ?></li>
|
||||
</ul>
|
||||
|
||||
<p style="margin-top: 2em;">
|
||||
<a class="button button-primary button-hero" href="<?php echo esc_url( admin_url( 'tools.php?page=wp-data-optimizer' ) ); ?>">
|
||||
<?php esc_html_e( '前往 WPDO 儀表板', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
// ─── form / dismiss handlers ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handle wizard step form submit.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function handle_step_submit(): void {
|
||||
// v2.13.3: capability check first (matches handle_dismiss / handle_reset
|
||||
// pattern; fixes L-AUTH-1). Reserved for future POST-based steps —
|
||||
// currently steps use GET nav so body is a no-op redirect.
|
||||
if ( ! TMDO_Capability::current_user_can_admin() ) {
|
||||
wp_die( esc_html__( 'Insufficient permissions.', '2meet-data-optimizer' ) );
|
||||
}
|
||||
check_admin_referer( self::NONCE_NAME );
|
||||
wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=1' ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle wizard dismiss action.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function handle_dismiss(): void {
|
||||
check_admin_referer( self::NONCE_NAME );
|
||||
if ( TMDO_Capability::current_user_can_admin() ) {
|
||||
update_option( self::OPT_COMPLETED, '1', false );
|
||||
update_option( self::OPT_FIRST_RUN, gmdate( 'Y-m-d H:i:s' ), false );
|
||||
}
|
||||
wp_safe_redirect( admin_url( 'tools.php?page=wp-data-optimizer' ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle wizard reset — clears OPT_COMPLETED so wizard reruns from step 1.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function handle_reset(): void {
|
||||
check_admin_referer( self::NONCE_NAME );
|
||||
if ( TMDO_Capability::current_user_can_admin() ) {
|
||||
update_option( self::OPT_COMPLETED, '0', false );
|
||||
}
|
||||
wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=1' ) );
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
// phpcs:ignore WPDO.AntiEAV -- platform admin UI: stress test SQL example display
|
||||
/**
|
||||
* Comment Stress Test template (v2.13.1).
|
||||
*
|
||||
* Full async + polling UI mirroring Term / Post / User Stress Test:
|
||||
* 1. 設定並啟動測試(form: post_id / target / mode / batch_size + Start/Cancel)
|
||||
* 2. 即時進度(progress bar + processed/target/rate/ETA/peak memory)
|
||||
* 3. Benchmark 報告(write metrics + DB sizes + query performance)
|
||||
*
|
||||
* Variables in scope from render_comment_stress_test():
|
||||
* $state — TMDO_Comment_Stress_Tester::get_progress(false) output
|
||||
* $test_comment_count — int, comments matching @wpdo-stress.local email
|
||||
* $posts — array<post_id, label> of available posts (top by comment_count)
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$status_str = (string) ( $state['status'] ?? 'idle' );
|
||||
$is_running = ( 'running' === $status_str || 'benchmarking' === $status_str );
|
||||
$current_mode = class_exists( 'TMDO_Mode_Manager' ) ? TMDO_Mode_Manager::get( 'comment' ) : 'disabled';
|
||||
$mode_color = 'aeav_only' === $current_mode ? '#28a745' : ( 'shadow_read' === $current_mode ? '#dba617' : '#dc3545' );
|
||||
$mode_optimal = 'aeav_only' === $current_mode;
|
||||
$settings_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=settings' );
|
||||
|
||||
global $wpdb;
|
||||
$flat_hp_review = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wpdo_comment_hp_review" );
|
||||
$flat_misc = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wpdo_comment_misc" );
|
||||
$total_comments = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comments}" );
|
||||
$total_commentmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->commentmeta}" );
|
||||
?>
|
||||
|
||||
<div class="wpdo-comment-stress-test-tab">
|
||||
<h2><?php esc_html_e( 'Comment Entity 壓力測試 & Benchmark', '2meet-data-optimizer' ); ?></h2>
|
||||
|
||||
<div style="margin:14px 0;padding:12px 14px;background:#f8d7da;color:#721c24;border-left:4px solid #dc3545;border-radius:4px;font-size:13px;line-height:1.6;">
|
||||
⚠️ <strong><?php esc_html_e( '此工具僅供開發 / 測試環境使用。', '2meet-data-optimizer' ); ?></strong>
|
||||
<?php esc_html_e( '會建立大量測試 comment 並 seed 對應 hp_review 群組 keys。請勿在生產環境執行。', '2meet-data-optimizer' ); ?>
|
||||
</div>
|
||||
|
||||
<p class="description">
|
||||
<?php esc_html_e( '透過自動產生大量測試 comment,評估 comment entity 反 EAV 系統在不同規模下的寫入吞吐與查詢效能。所有測試 comment 均以 @wpdo-stress.local 為 email 後綴,可一鍵清除。', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
|
||||
<!-- Diagnose snapshot -->
|
||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);margin-top:20px;">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '當前 Comment Entity 狀態', '2meet-data-optimizer' ); ?></h3>
|
||||
<table class="widefat striped">
|
||||
<tr>
|
||||
<td><?php esc_html_e( 'wp_comments', '2meet-data-optimizer' ); ?></td>
|
||||
<td><strong><?php echo esc_html( number_format_i18n( $total_comments ) ); ?></strong></td>
|
||||
<td><?php esc_html_e( 'wp_commentmeta', '2meet-data-optimizer' ); ?></td>
|
||||
<td><strong><?php echo esc_html( number_format_i18n( $total_commentmeta ) ); ?></strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><?php esc_html_e( 'Mode', '2meet-data-optimizer' ); ?></td>
|
||||
<td><strong style="color:<?php echo esc_attr( $mode_color ); ?>;"><?php echo esc_html( $current_mode ); ?></strong></td>
|
||||
<td><?php esc_html_e( 'Ratio', '2meet-data-optimizer' ); ?></td>
|
||||
<td><strong>1:<?php echo esc_html( (string) ( $total_comments > 0 ? round( $total_commentmeta / $total_comments, 2 ) : 0 ) ); ?></strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>wpdo_comment_hp_review</code></td>
|
||||
<td><strong><?php echo esc_html( number_format_i18n( $flat_hp_review ) ); ?></strong> rows</td>
|
||||
<td><code>wpdo_comment_misc</code></td>
|
||||
<td><strong><?php echo esc_html( number_format_i18n( $flat_misc ) ); ?></strong> rows</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><?php esc_html_e( 'Stress 測試 comment 數', '2meet-data-optimizer' ); ?></td>
|
||||
<td colspan="2"><strong id="wpdo-cst-count" style="color:#dc3545;font-size:18px;"><?php echo esc_html( number_format_i18n( $test_comment_count ) ); ?></strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 驗證反 EAV 優化指南 -->
|
||||
<div class="wpdo-card" style="padding:20px;background:<?php echo $mode_optimal ? '#e8f5e9' : '#fff3cd'; ?>;border-left:4px solid <?php echo esc_attr( $mode_color ); ?>;border-radius:6px;margin-top:16px;font-size:13px;line-height:1.7;">
|
||||
<h3 style="margin-top:0;font-size:14px;">
|
||||
<?php if ( $mode_optimal ) : ?>
|
||||
✅ <?php esc_html_e( 'Comment Mode = aeav_only — 已具備驗證優化的條件', '2meet-data-optimizer' ); ?>
|
||||
<?php else : ?>
|
||||
⚠️
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: current mode */
|
||||
esc_html__( 'Comment Mode = %s — 此模式下壓力測試結果不會展示完整反 EAV 優化效果', '2meet-data-optimizer' ),
|
||||
'<code style="background:#fff;padding:2px 6px;border-radius:3px;">' . esc_html( $current_mode ) . '</code>'
|
||||
);
|
||||
?>
|
||||
<?php endif; ?>
|
||||
</h3>
|
||||
<p style="margin:8px 0 0 0;">
|
||||
<strong><?php esc_html_e( '想看 wp_commentmeta 真實減量?必須兩條件同時滿足:', '2meet-data-optimizer' ); ?></strong>
|
||||
</p>
|
||||
<ol style="margin:6px 0 8px 22px;padding:0;">
|
||||
<li>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: 1: open code, 2: close code */
|
||||
esc_html__( 'Comment mode 設為 %1$saeav_only%2$s(前往設定 tab → Entity Bridge → Comment entity)', '2meet-data-optimizer' ),
|
||||
'<code style="background:#fff;padding:1px 5px;border-radius:3px;">',
|
||||
'</code>'
|
||||
);
|
||||
?>
|
||||
<?php if ( ! $mode_optimal ) : ?>
|
||||
<a href="<?php echo esc_url( $settings_url ); ?>" class="button button-small" style="margin-left:8px;">→ <?php esc_html_e( '前往設定', '2meet-data-optimizer' ); ?></a>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<li>
|
||||
<?php esc_html_e( '寫入模式選 🐢 Realistic(走 wp_insert_comment + Hook Bus,會被攔截短路 wp_commentmeta)', '2meet-data-optimizer' ); ?>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:20px;">
|
||||
|
||||
<!-- Left: Configure & Start -->
|
||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '1. 設定並啟動測試', '2meet-data-optimizer' ); ?></h3>
|
||||
|
||||
<table class="form-table" style="margin-top:0;">
|
||||
<tr>
|
||||
<th style="width:35%;"><label for="wpdo-cst-post-id"><?php esc_html_e( '目標 Post', '2meet-data-optimizer' ); ?></label></th>
|
||||
<td>
|
||||
<select id="wpdo-cst-post-id" class="regular-text">
|
||||
<?php if ( empty( $posts ) ) : ?>
|
||||
<option value=""><?php esc_html_e( '— 沒有可用的 publish post —', '2meet-data-optimizer' ); ?></option>
|
||||
<?php else : ?>
|
||||
<?php foreach ( $posts as $pid => $label ) : ?>
|
||||
<option value="<?php echo esc_attr( (string) $pid ); ?>">
|
||||
<?php echo esc_html( $label ); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</select>
|
||||
<p class="description"><?php esc_html_e( '所有測試 comment 會 attach 到此 post。建議用 hp_listing post — seed 後 hp_rating 走 hp_review 群組。', '2meet-data-optimizer' ); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><label for="wpdo-cst-target"><?php esc_html_e( '要建立的 comment 數', '2meet-data-optimizer' ); ?></label></th>
|
||||
<td>
|
||||
<input type="number" id="wpdo-cst-target" min="1" max="100000" value="500" class="regular-text" />
|
||||
<p class="description"><?php esc_html_e( '常用:100 / 500 / 1,000 / 10,000(上限 100,000)', '2meet-data-optimizer' ); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><label><?php esc_html_e( '寫入模式', '2meet-data-optimizer' ); ?></label></th>
|
||||
<td>
|
||||
<label style="display:block;margin-bottom:6px;">
|
||||
<input type="radio" name="wpdo-cst-mode" value="fast" checked />
|
||||
<strong>Fast</strong> — <?php esc_html_e( '直接 $wpdb->insert,跳過 WP filter chain(最快,但不測 Hook Bus)', '2meet-data-optimizer' ); ?>
|
||||
</label>
|
||||
<label style="display:block;">
|
||||
<input type="radio" name="wpdo-cst-mode" value="realistic" />
|
||||
<strong>Realistic</strong> — <?php esc_html_e( '走 wp_insert_comment + update_comment_meta(較慢,模擬生產路徑 + 觸發 Hook Bus)', '2meet-data-optimizer' ); ?>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><label for="wpdo-cst-batch"><?php esc_html_e( '批次大小', '2meet-data-optimizer' ); ?></label></th>
|
||||
<td>
|
||||
<input type="number" id="wpdo-cst-batch" min="1" max="1000" value="200" class="small-text" />
|
||||
<p class="description"><?php esc_html_e( '每批 8 秒 wall-clock 上限。Fast 建議 200-1000;Realistic 建議 10-30。', '2meet-data-optimizer' ); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>
|
||||
<button type="button" class="button button-primary button-large" id="wpdo-cst-start" <?php disabled( $is_running || empty( $posts ) ); ?>>
|
||||
<?php esc_html_e( '🚀 啟動壓力測試', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
<button type="button" class="button" id="wpdo-cst-cancel" <?php disabled( ! $is_running ); ?>>
|
||||
<?php esc_html_e( '⏹ 取消', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Right: Cleanup + Re-run benchmark -->
|
||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '清除測試資料', '2meet-data-optimizer' ); ?></h3>
|
||||
<p>
|
||||
<?php esc_html_e( '目前 stress test comment 數:', '2meet-data-optimizer' ); ?>
|
||||
<strong id="wpdo-cst-count-mirror" style="font-size:18px;color:#dc3545;">
|
||||
<?php echo esc_html( number_format_i18n( $test_comment_count ) ); ?>
|
||||
</strong>
|
||||
</p>
|
||||
<p class="description">
|
||||
<?php esc_html_e( '一鍵清除所有 email 後綴 @wpdo-stress.local 的 comment,連同 wp_commentmeta + flat 表的對應 row。', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<button type="button" class="button button-secondary" id="wpdo-cst-cleanup" <?php disabled( $is_running || 0 === $test_comment_count ); ?>>
|
||||
<?php esc_html_e( '🗑 清除全部測試 comment', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<hr style="margin:18px 0;" />
|
||||
|
||||
<p>
|
||||
<button type="button" class="button" id="wpdo-cst-rerun-bench" <?php disabled( $is_running || 0 === $test_comment_count ); ?>>
|
||||
<?php esc_html_e( '📊 重跑 Benchmark(不新增資料)', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress card (live) -->
|
||||
<div id="wpdo-cst-progress-card" class="wpdo-card" style="margin-top:20px;padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);<?php echo $is_running ? '' : 'display:none;'; ?>">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:13px;">
|
||||
<span>
|
||||
<strong id="wpdo-cst-pg-status"><?php echo esc_html( $status_str ); ?></strong>
|
||||
· <code id="wpdo-cst-pg-post-id">post #<?php echo esc_html( (string) ( $state['post_id'] ?? '' ) ); ?></code>
|
||||
· <span id="wpdo-cst-pg-mode"><?php echo esc_html( (string) ( $state['mode'] ?? '' ) ); ?></span> mode
|
||||
</span>
|
||||
<span id="wpdo-cst-pg-pct" style="font-weight:600;"><?php echo esc_html( (string) ( $state['pct'] ?? 0 ) ); ?>%</span>
|
||||
</div>
|
||||
<div style="height:14px;background:#e0e0e0;border-radius:7px;overflow:hidden;">
|
||||
<div id="wpdo-cst-pg-bar" style="height:100%;background:linear-gradient(90deg,#28a745,#20c997);width:<?php echo esc_attr( (string) ( $state['pct'] ?? 0 ) ); ?>%;transition:width .4s;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<table style="width:100%;font-size:13px;margin-top:10px;border-collapse:collapse;">
|
||||
<tr>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '已建立', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;font-weight:600;"><span id="wpdo-cst-pg-processed"><?php echo esc_html( (string) ( $state['processed'] ?? 0 ) ); ?></span> / <span id="wpdo-cst-pg-target"><?php echo esc_html( (string) ( $state['target'] ?? 0 ) ); ?></span></td>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '速率', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;font-weight:600;"><span id="wpdo-cst-pg-rate"><?php echo esc_html( (string) ( $state['rate_per_sec'] ?? 0 ) ); ?></span> comments/sec</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '已耗時', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;"><span id="wpdo-cst-pg-elapsed"><?php echo esc_html( (string) ( $state['elapsed_sec'] ?? 0 ) ); ?></span> 秒</td>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '預估剩餘', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;"><span id="wpdo-cst-pg-eta"><?php echo esc_html( (string) ( $state['eta_sec'] ?? 0 ) ); ?></span> 秒</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '完成批次', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;"><span id="wpdo-cst-pg-batches"><?php echo esc_html( (string) ( $state['batches_done'] ?? 0 ) ); ?></span></td>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( 'PHP Peak Mem', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;"><span id="wpdo-cst-pg-mem"><?php echo esc_html( (string) round( ( (int) ( $state['peak_memory'] ?? 0 ) ) / 1048576, 1 ) ); ?></span> MB</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Benchmark report -->
|
||||
<div id="wpdo-cst-bench-card" class="wpdo-card" style="margin-top:20px;padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);<?php echo ! empty( $state['benchmark'] ) ? '' : 'display:none;'; ?>">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
|
||||
<div id="wpdo-cst-bench-content"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
/**
|
||||
* One-click User Entity Migration Wizard.
|
||||
*
|
||||
* Variables in scope (from class-tmdo-admin.php::render_migration_wizard):
|
||||
* $preflight array TMDO_Migration_Orchestrator::preflight() snapshot.
|
||||
* $status array TMDO_Migration_Orchestrator::get_status() snapshot.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.8.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$ratio = $preflight['ratio'] ?? 0;
|
||||
$users_count = $preflight['users'] ?? 0;
|
||||
$usermeta_count = $preflight['usermeta'] ?? 0;
|
||||
$eav_residue = $preflight['eav_rows'] ?? 0;
|
||||
$current_mode = $preflight['mode'] ?? 'unknown';
|
||||
$strategy = $preflight['estimated_strategy'] ?? 'sync';
|
||||
$est_sec = $preflight['estimated_sec'] ?? 0;
|
||||
$groups = $preflight['groups'] ?? array();
|
||||
$state = $status['state'] ?? 'idle';
|
||||
$active = in_array( $state, array( 'running', 'paused' ), true );
|
||||
$completed = 'completed' === $state;
|
||||
$failed = 'failed' === $state;
|
||||
?>
|
||||
<div class="wpdo-migration-wizard" data-state="<?php echo esc_attr( $state ); ?>">
|
||||
|
||||
<header class="wpdo-mw-header">
|
||||
<h2><?php esc_html_e( 'User Entity 遷移精靈', '2meet-data-optimizer' ); ?></h2>
|
||||
<p class="description">
|
||||
<?php esc_html_e( '把 wp_usermeta 中已註冊為 entity field 的 legacy keys 一次遷移到 flat tables。流程包含自動備份、demote、bulk SQL backfill、shadow_read 驗證、aeav_only cutover、以及最終的 EAV 殘留清除。', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Pre-flight diagnostic panel ─────────────────────────────────────── -->
|
||||
<section class="wpdo-mw-panel wpdo-mw-preflight" id="wpdo-mw-preflight">
|
||||
<h3><?php esc_html_e( '當前狀態', '2meet-data-optimizer' ); ?></h3>
|
||||
<div class="wpdo-mw-metrics">
|
||||
<div class="wpdo-mw-metric">
|
||||
<span class="wpdo-mw-metric-label"><?php esc_html_e( 'wp_users', '2meet-data-optimizer' ); ?></span>
|
||||
<span class="wpdo-mw-metric-value" id="wpdo-mw-users"><?php echo esc_html( number_format_i18n( $users_count ) ); ?></span>
|
||||
</div>
|
||||
<div class="wpdo-mw-metric">
|
||||
<span class="wpdo-mw-metric-label"><?php esc_html_e( 'wp_usermeta', '2meet-data-optimizer' ); ?></span>
|
||||
<span class="wpdo-mw-metric-value" id="wpdo-mw-usermeta"><?php echo esc_html( number_format_i18n( $usermeta_count ) ); ?></span>
|
||||
</div>
|
||||
<div class="wpdo-mw-metric wpdo-mw-metric-primary">
|
||||
<span class="wpdo-mw-metric-label"><?php esc_html_e( '當前 ratio', '2meet-data-optimizer' ); ?></span>
|
||||
<span class="wpdo-mw-metric-value" id="wpdo-mw-ratio">1:<?php echo esc_html( (string) $ratio ); ?></span>
|
||||
</div>
|
||||
<div class="wpdo-mw-metric">
|
||||
<span class="wpdo-mw-metric-label"><?php esc_html_e( 'EAV 殘留', '2meet-data-optimizer' ); ?></span>
|
||||
<span class="wpdo-mw-metric-value" id="wpdo-mw-residue"><?php echo esc_html( number_format_i18n( $eav_residue ) ); ?></span>
|
||||
</div>
|
||||
<div class="wpdo-mw-metric">
|
||||
<span class="wpdo-mw-metric-label"><?php esc_html_e( 'user mode', '2meet-data-optimizer' ); ?></span>
|
||||
<span class="wpdo-mw-metric-value wpdo-mw-mode wpdo-mw-mode-<?php echo esc_attr( $current_mode ); ?>"><?php echo esc_html( $current_mode ); ?></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="wpdo-mw-groups">
|
||||
<summary><?php esc_html_e( '各 entity group 詳細狀態', '2meet-data-optimizer' ); ?></summary>
|
||||
<table class="widefat striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'Group', '2meet-data-optimizer' ); ?></th>
|
||||
<th><?php esc_html_e( 'Keys', '2meet-data-optimizer' ); ?></th>
|
||||
<th><?php esc_html_e( 'Flat rows', '2meet-data-optimizer' ); ?></th>
|
||||
<th><?php esc_html_e( 'EAV 殘留', '2meet-data-optimizer' ); ?></th>
|
||||
<th><?php esc_html_e( 'Backfill 策略', '2meet-data-optimizer' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ( $groups as $name => $g ) : ?>
|
||||
<tr>
|
||||
<td><code><?php echo esc_html( $name ); ?></code></td>
|
||||
<td><?php echo esc_html( (string) count( $g['keys'] ) ); ?></td>
|
||||
<td><?php echo esc_html( (string) $g['flat_rows'] ); ?></td>
|
||||
<td>
|
||||
<?php if ( $g['residue'] > 0 ) : ?>
|
||||
<strong><?php echo esc_html( (string) $g['residue'] ); ?></strong>
|
||||
<?php else : ?>
|
||||
<span class="wpdo-mw-zero">0</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><code><?php echo esc_html( $g['strategy'] ); ?></code></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<!-- Run-migration panel (idle state) ──────────────────────────────── -->
|
||||
<section class="wpdo-mw-panel wpdo-mw-run" id="wpdo-mw-run-panel"
|
||||
<?php echo $active || $completed ? 'hidden' : ''; ?>>
|
||||
|
||||
<?php if ( 0 === $eav_residue && 'aeav_only' === $current_mode ) : ?>
|
||||
<div class="wpdo-mw-nothing-todo">
|
||||
<h3>✅ <?php esc_html_e( '無事可做', '2meet-data-optimizer' ); ?></h3>
|
||||
<p><?php esc_html_e( '所有 entity group 已 aeav_only / 0 EAV 殘留。', '2meet-data-optimizer' ); ?></p>
|
||||
<p>
|
||||
<?php
|
||||
echo esc_html(
|
||||
sprintf(
|
||||
/* translators: %s: ratio */
|
||||
__( '當前 ratio:1:%s', '2meet-data-optimizer' ),
|
||||
(string) $ratio
|
||||
)
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<h3><?php esc_html_e( '執行一鍵遷移', '2meet-data-optimizer' ); ?></h3>
|
||||
<div class="wpdo-mw-estimate">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: 1: rows count, 2: strategy, 3: estimated seconds */
|
||||
esc_html__( '將遷移 %1$s 行 EAV 殘留,預估策略:%2$s(約 %3$s 秒)。', '2meet-data-optimizer' ),
|
||||
'<strong>' . esc_html( number_format_i18n( $eav_residue ) ) . '</strong>',
|
||||
'<code>' . esc_html( $strategy ) . '</code>',
|
||||
'<strong>' . esc_html( (string) $est_sec ) . '</strong>'
|
||||
);
|
||||
?>
|
||||
</div>
|
||||
|
||||
<fieldset class="wpdo-mw-options">
|
||||
<legend><?php esc_html_e( '進階選項', '2meet-data-optimizer' ); ?></legend>
|
||||
<label>
|
||||
<input type="checkbox" id="wpdo-mw-opt-backup" checked>
|
||||
<?php esc_html_e( '自動備份 wp_usermeta 至 uploads/wpdo-backups/(推薦)', '2meet-data-optimizer' ); ?>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" id="wpdo-mw-opt-strict" checked>
|
||||
<?php esc_html_e( '嚴格驗證(500 users / 10% 抽樣)', '2meet-data-optimizer' ); ?>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" id="wpdo-mw-opt-24h">
|
||||
<?php esc_html_e( '24 小時 shadow_read 觀察視窗(生產環境用,會暫停 24h 才繼續)', '2meet-data-optimizer' ); ?>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" id="wpdo-mw-opt-async">
|
||||
<?php esc_html_e( '強制 async(cron 推進,不阻塞 HTTP 請求)', '2meet-data-optimizer' ); ?>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" id="wpdo-mw-opt-dryrun">
|
||||
<?php esc_html_e( 'Dry-run(不寫入、不刪除,僅模擬流程)', '2meet-data-optimizer' ); ?>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div class="wpdo-mw-confirm">
|
||||
<label>
|
||||
<input type="checkbox" id="wpdo-mw-confirm-backup" required>
|
||||
<?php esc_html_e( '我已了解:流程將從 wp_usermeta 刪除已註冊欄位的 EAV 行,過程不可逆(自動備份提供救援路徑)。', '2meet-data-optimizer' ); ?>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="button" class="button button-primary button-hero" id="wpdo-mw-start" disabled>
|
||||
🚀 <?php esc_html_e( '開始一鍵遷移', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<!-- Live progress panel (running state) ───────────────────────────── -->
|
||||
<section class="wpdo-mw-panel wpdo-mw-progress" id="wpdo-mw-progress-panel"
|
||||
<?php echo $active || $failed ? '' : 'hidden'; ?>>
|
||||
|
||||
<h3 id="wpdo-mw-progress-title"><?php esc_html_e( '執行中…', '2meet-data-optimizer' ); ?></h3>
|
||||
|
||||
<div class="wpdo-mw-progress-bar">
|
||||
<div class="wpdo-mw-progress-fill" id="wpdo-mw-progress-fill"
|
||||
style="width:<?php echo esc_attr( (string) ( $status['overall_progress'] ?? 0 ) ); ?>%"></div>
|
||||
<span class="wpdo-mw-progress-pct" id="wpdo-mw-progress-pct"><?php echo esc_html( (string) ( $status['overall_progress'] ?? 0 ) ); ?>%</span>
|
||||
</div>
|
||||
|
||||
<div class="wpdo-mw-current">
|
||||
<span class="wpdo-mw-current-phase">
|
||||
<?php esc_html_e( '當前階段:', '2meet-data-optimizer' ); ?>
|
||||
<code id="wpdo-mw-current-phase-name"><?php echo esc_html( $status['phase'] ?? 'idle' ); ?></code>
|
||||
</span>
|
||||
<span class="wpdo-mw-elapsed" id="wpdo-mw-elapsed">0.0s</span>
|
||||
</div>
|
||||
|
||||
<div class="wpdo-mw-live-ratio">
|
||||
<span><?php esc_html_e( 'Live ratio:', '2meet-data-optimizer' ); ?></span>
|
||||
<span id="wpdo-mw-live-ratio-text">
|
||||
1:<?php echo esc_html( (string) ( $status['metrics']['ratio_start'] ?? $ratio ) ); ?>
|
||||
→
|
||||
1:<?php echo esc_html( (string) ( $status['metrics']['ratio_now'] ?? $ratio ) ); ?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="wpdo-mw-log-wrap">
|
||||
<h4><?php esc_html_e( 'Log', '2meet-data-optimizer' ); ?></h4>
|
||||
<pre class="wpdo-mw-log" id="wpdo-mw-log"><?php echo esc_html( implode( "\n", (array) ( $status['log'] ?? array() ) ) ); ?></pre>
|
||||
</div>
|
||||
|
||||
<div class="wpdo-mw-actions">
|
||||
<button type="button" class="button" id="wpdo-mw-cancel"><?php esc_html_e( '取消', '2meet-data-optimizer' ); ?></button>
|
||||
<button type="button" class="button button-primary" id="wpdo-mw-resume" hidden><?php esc_html_e( 'Resume', '2meet-data-optimizer' ); ?></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Completed state ───────────────────────────────────────────────── -->
|
||||
<section class="wpdo-mw-panel wpdo-mw-done" id="wpdo-mw-done-panel"
|
||||
<?php echo $completed ? '' : 'hidden'; ?>>
|
||||
<h3>✅ <?php esc_html_e( '遷移完成', '2meet-data-optimizer' ); ?></h3>
|
||||
<div class="wpdo-mw-done-summary" id="wpdo-mw-done-summary"></div>
|
||||
<button type="button" class="button" id="wpdo-mw-reset"><?php esc_html_e( '回到診斷頁', '2meet-data-optimizer' ); ?></button>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,298 @@
|
||||
<?php
|
||||
/**
|
||||
* Post Migration Wizard template (v2.10.0).
|
||||
*
|
||||
* Variables in scope from render_post_migration_wizard():
|
||||
* $diagnose — TMDO_Post_Migration::diagnose() output
|
||||
* $garbage — TMDO_Postmeta_Cleaner::count_garbage('all') output
|
||||
*
|
||||
* Sync execution model — each action button POSTs back to this page with a
|
||||
* query arg + nonce, the action runs to completion in one request, then
|
||||
* redirects with `wpdo_msg=*` for the success/failure banner.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$page_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=post-migration-wizard' );
|
||||
|
||||
$total_eav = 0;
|
||||
foreach ( $diagnose['groups'] as $g ) {
|
||||
$total_eav += (int) $g['eav_rows'];
|
||||
}
|
||||
$total_flat = 0;
|
||||
foreach ( $diagnose['groups'] as $g ) {
|
||||
$total_flat += (int) $g['flat_rows'];
|
||||
}
|
||||
|
||||
$mode_label = array(
|
||||
'disabled' => __( '🔵 disabled — wp_postmeta 仍是 source-of-truth', '2meet-data-optimizer' ),
|
||||
'dual_write' => __( '🟡 dual_write — 同時寫 wp_postmeta + flat 表', '2meet-data-optimizer' ),
|
||||
'shadow_read' => __( '🟠 shadow_read — 寫雙路徑,讀比對', '2meet-data-optimizer' ),
|
||||
'aeav_only' => __( '🟢 aeav_only — flat 表為 source-of-truth', '2meet-data-optimizer' ),
|
||||
);
|
||||
|
||||
// Action URLs (each carries nonce).
|
||||
$cleanup_garbage_url = wp_nonce_url(
|
||||
add_query_arg( array( 'wpdo_postmeta_cleanup' => '1' ), $page_url ),
|
||||
'wpdo_postmeta_cleanup'
|
||||
);
|
||||
$backfill_all_url = wp_nonce_url(
|
||||
add_query_arg( array( 'wpdo_post_backfill_all' => '1' ), $page_url ),
|
||||
'wpdo_post_backfill_all'
|
||||
);
|
||||
$cutover_legacy_url = wp_nonce_url(
|
||||
add_query_arg( array( 'wpdo_post_cutover_legacy' => '1' ), $page_url ),
|
||||
'wpdo_post_cutover_legacy'
|
||||
);
|
||||
$promote_dual_write_url = wp_nonce_url(
|
||||
add_query_arg( array( 'wpdo_post_promote_dual_write' => '1' ), $page_url ),
|
||||
'wpdo_post_promote_dual_write'
|
||||
);
|
||||
$promote_aeav_url = wp_nonce_url(
|
||||
add_query_arg( array( 'wpdo_post_promote_aeav' => '1' ), $page_url ),
|
||||
'wpdo_post_promote_aeav'
|
||||
);
|
||||
|
||||
// Status banner from prior action redirect.
|
||||
// Read-only display banner — server-set redirect message, no form processing.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
$msg_raw = isset( $_GET['wpdo_msg'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['wpdo_msg'] ) ) : '';
|
||||
|
||||
/**
|
||||
* Map redirect message code → user-facing string.
|
||||
*
|
||||
* @param string $code Message code from wpdo_msg query arg.
|
||||
* @return string
|
||||
*/
|
||||
$wpdo_format_msg = static function ( string $code ): string {
|
||||
$prefix = strtok( $code, '_' );
|
||||
$rest = substr( $code, strlen( (string) $prefix ) + 1 );
|
||||
switch ( $prefix ) {
|
||||
case 'backfill':
|
||||
return sprintf(
|
||||
/* translators: %s: total flat rows */
|
||||
__( 'Backfill 完成:7 個 group 共 %s 個 post 已寫入 flat 表。', '2meet-data-optimizer' ),
|
||||
$rest
|
||||
);
|
||||
case 'cutover':
|
||||
return sprintf(
|
||||
/* translators: %s: rows */
|
||||
__( 'Legacy hot table cutover 完成:%s 行已 copy 至 flat 表。', '2meet-data-optimizer' ),
|
||||
$rest
|
||||
);
|
||||
case 'promote':
|
||||
return sprintf(
|
||||
/* translators: %s: target mode */
|
||||
__( 'Post mode 已升級至 %s。', '2meet-data-optimizer' ),
|
||||
$rest
|
||||
);
|
||||
case 'postmetacleanupdone':
|
||||
return sprintf(
|
||||
/* translators: %s: deleted rows */
|
||||
__( '已從 wp_postmeta 刪除 %s 行垃圾。', '2meet-data-optimizer' ),
|
||||
$rest
|
||||
);
|
||||
case 'err':
|
||||
return sprintf(
|
||||
/* translators: %s: error code */
|
||||
__( '錯誤:%s(請查看 wpdo_errors 日誌)。', '2meet-data-optimizer' ),
|
||||
str_replace( '_', ' ', $rest )
|
||||
);
|
||||
default:
|
||||
return $code;
|
||||
}
|
||||
};
|
||||
?>
|
||||
|
||||
<style>
|
||||
.wpdo-post-wizard h2 { margin-top: 1.4em; }
|
||||
.wpdo-post-wizard .wpdo-card {
|
||||
background: #fff;
|
||||
border: 1px solid #c3c4c7;
|
||||
border-radius: 4px;
|
||||
padding: 1em 1.4em;
|
||||
margin: 0.8em 0;
|
||||
}
|
||||
.wpdo-post-wizard .wpdo-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1em;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.wpdo-post-wizard .wpdo-stat {
|
||||
background: #f6f7f7;
|
||||
border-left: 4px solid #2271b1;
|
||||
padding: 0.8em 1em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.wpdo-post-wizard .wpdo-stat-label { font-size: 0.85em; color: #50575e; }
|
||||
.wpdo-post-wizard .wpdo-stat-value { font-size: 1.6em; font-weight: 600; }
|
||||
.wpdo-post-wizard .wpdo-mode-banner {
|
||||
padding: 0.6em 1em;
|
||||
border-radius: 3px;
|
||||
margin: 0.8em 0;
|
||||
}
|
||||
.wpdo-post-wizard .wpdo-mode-disabled { background: #e5f5fa; border-left: 4px solid #00a0d2; }
|
||||
.wpdo-post-wizard .wpdo-mode-dual_write { background: #fffbe5; border-left: 4px solid #dba617; }
|
||||
.wpdo-post-wizard .wpdo-mode-shadow_read { background: #fff4e5; border-left: 4px solid #d54e21; }
|
||||
.wpdo-post-wizard .wpdo-mode-aeav_only { background: #ecf7ed; border-left: 4px solid #46b450; }
|
||||
.wpdo-post-wizard table.widefat td.right { text-align: right; }
|
||||
.wpdo-post-wizard .actions {
|
||||
margin-top: 1.6em;
|
||||
padding-top: 1em;
|
||||
border-top: 1px solid #dcdcde;
|
||||
}
|
||||
.wpdo-post-wizard .actions .button { margin-right: 0.4em; margin-bottom: 0.4em; }
|
||||
</style>
|
||||
|
||||
<div class="wpdo-post-wizard">
|
||||
|
||||
<?php if ( $msg_raw ) : ?>
|
||||
<?php
|
||||
$msg_class = str_starts_with( $msg_raw, 'err' ) ? 'notice-error' : 'notice-success';
|
||||
$msg_text = $wpdo_format_msg( $msg_raw );
|
||||
?>
|
||||
<div class="notice <?php echo esc_attr( $msg_class ); ?> is-dismissible">
|
||||
<p><?php echo esc_html( $msg_text ); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2><?php esc_html_e( 'Post Entity 遷移精靈', '2meet-data-optimizer' ); ?></h2>
|
||||
|
||||
<p class="description">
|
||||
<?php esc_html_e( '把 wp_postmeta 中的 47 個 managed keys 遷移到 7 張 flat 表(wp_wpdo_post_*)。每個按鈕只執行單一階段;按順序執行可達成完整 EAV → flat 切換。', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
|
||||
<!-- Mode banner -->
|
||||
<div class="wpdo-mode-banner wpdo-mode-<?php echo esc_attr( $diagnose['mode'] ); ?>">
|
||||
<strong><?php esc_html_e( '當前 post mode:', '2meet-data-optimizer' ); ?></strong>
|
||||
<?php echo esc_html( $mode_label[ $diagnose['mode'] ] ?? $diagnose['mode'] ); ?>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="wpdo-stats">
|
||||
<div class="wpdo-stat">
|
||||
<div class="wpdo-stat-label"><?php esc_html_e( 'Posts', '2meet-data-optimizer' ); ?></div>
|
||||
<div class="wpdo-stat-value"><?php echo esc_html( number_format_i18n( $diagnose['posts'] ) ); ?></div>
|
||||
</div>
|
||||
<div class="wpdo-stat">
|
||||
<div class="wpdo-stat-label"><?php esc_html_e( 'Postmeta', '2meet-data-optimizer' ); ?></div>
|
||||
<div class="wpdo-stat-value"><?php echo esc_html( number_format_i18n( $diagnose['postmeta'] ) ); ?></div>
|
||||
</div>
|
||||
<div class="wpdo-stat">
|
||||
<div class="wpdo-stat-label"><?php esc_html_e( 'Ratio', '2meet-data-optimizer' ); ?></div>
|
||||
<div class="wpdo-stat-value">1:<?php echo esc_html( (string) $diagnose['ratio'] ); ?></div>
|
||||
</div>
|
||||
<div class="wpdo-stat">
|
||||
<div class="wpdo-stat-label"><?php esc_html_e( 'Total flat rows', '2meet-data-optimizer' ); ?></div>
|
||||
<div class="wpdo-stat-value"><?php echo esc_html( number_format_i18n( $total_flat ) ); ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group breakdown -->
|
||||
<h3><?php esc_html_e( '7 個 group 詳細狀態', '2meet-data-optimizer' ); ?></h3>
|
||||
<table class="widefat striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'Group', '2meet-data-optimizer' ); ?></th>
|
||||
<th><?php esc_html_e( 'post_type', '2meet-data-optimizer' ); ?></th>
|
||||
<th class="right"><?php esc_html_e( 'Keys', '2meet-data-optimizer' ); ?></th>
|
||||
<th class="right"><?php esc_html_e( 'EAV rows', '2meet-data-optimizer' ); ?></th>
|
||||
<th class="right"><?php esc_html_e( 'Flat rows', '2meet-data-optimizer' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ( $diagnose['groups'] as $name => $g ) : ?>
|
||||
<tr>
|
||||
<td><code><?php echo esc_html( $name ); ?></code></td>
|
||||
<td><?php echo esc_html( $g['post_type'] ?: '(any)' ); ?></td>
|
||||
<td class="right"><?php echo esc_html( (string) count( $g['keys'] ) ); ?></td>
|
||||
<td class="right"><?php echo esc_html( number_format_i18n( (int) $g['eav_rows'] ) ); ?></td>
|
||||
<td class="right"><?php echo esc_html( number_format_i18n( (int) $g['flat_rows'] ) ); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Phase actions -->
|
||||
<div class="wpdo-card">
|
||||
<h3><?php esc_html_e( '步驟 1:清 wp_postmeta 垃圾(v2.9.0 phase 0)', '2meet-data-optimizer' ); ?></h3>
|
||||
<p>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: 1: total garbage rows, 2: transients, 3: wp_old_date, 4: edit_locks */
|
||||
esc_html__( '偵測到 %1$s 行可清理垃圾(transients %2$s + _wp_old_date %3$s + 過期 _edit_lock %4$s)。', '2meet-data-optimizer' ),
|
||||
esc_html( number_format_i18n( (int) $garbage['total'] ) ),
|
||||
esc_html( number_format_i18n( (int) $garbage['transients'] ) ),
|
||||
esc_html( number_format_i18n( (int) $garbage['wp_old_date'] ) ),
|
||||
esc_html( number_format_i18n( (int) $garbage['edit_locks'] ) )
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<div class="actions">
|
||||
<a href="<?php echo esc_url( $cleanup_garbage_url ); ?>"
|
||||
class="button button-primary <?php echo (int) $garbage['total'] > 0 ? '' : 'disabled'; ?>"
|
||||
onclick="return confirm(<?php echo wp_json_encode( __( '確定要刪除 wp_postmeta 中的垃圾資料?此動作不可逆。', '2meet-data-optimizer' ) ); ?>);">
|
||||
<?php esc_html_e( '清理垃圾資料', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wpdo-card">
|
||||
<h3><?php esc_html_e( '步驟 2:把 wp_postmeta 既有資料 backfill 至 flat 表(v2.9.3)', '2meet-data-optimizer' ); ?></h3>
|
||||
<p><?php esc_html_e( 'Idempotent — 重跑安全。每個 group 跑一次 bulk SQL pivot。', '2meet-data-optimizer' ); ?></p>
|
||||
<div class="actions">
|
||||
<a href="<?php echo esc_url( $backfill_all_url ); ?>" class="button button-primary"
|
||||
onclick="return confirm(<?php echo wp_json_encode( __( '確定要對 7 個 group 跑 backfill?此動作 idempotent,可重跑。', '2meet-data-optimizer' ) ); ?>);">
|
||||
<?php esc_html_e( '把 wp_postmeta backfill 到 7 張 flat 表', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wpdo-card">
|
||||
<h3><?php esc_html_e( '步驟 3:把 legacy wpdo_hot_hp_listing 抄到 flat 表(v2.9.5)', '2meet-data-optimizer' ); ?></h3>
|
||||
<p><?php esc_html_e( '非破壞 — legacy hot 表保留作為 v3.0.0 rollback safety net。', '2meet-data-optimizer' ); ?></p>
|
||||
<div class="actions">
|
||||
<a href="<?php echo esc_url( $cutover_legacy_url ); ?>" class="button"
|
||||
onclick="return confirm(<?php echo wp_json_encode( __( '確定要 copy wpdo_hot_hp_listing 到 wp_wpdo_post_hp_listing_core?', '2meet-data-optimizer' ) ); ?>);">
|
||||
<?php esc_html_e( 'Copy legacy hot table', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wpdo-card">
|
||||
<h3><?php esc_html_e( '步驟 4:升級 mode 至 dual_write', '2meet-data-optimizer' ); ?></h3>
|
||||
<p><?php esc_html_e( 'wp_postmeta 與 flat 表同時寫入。讀仍走 wp_postmeta(生產 safe)。建議至少觀察 24h 後再升級下一階。', '2meet-data-optimizer' ); ?></p>
|
||||
<div class="actions">
|
||||
<a href="<?php echo esc_url( $promote_dual_write_url ); ?>" class="button"
|
||||
onclick="return confirm(<?php echo wp_json_encode( __( '確定要升級 post mode 至 dual_write?此後 update_post_meta() 會雙寫。', '2meet-data-optimizer' ) ); ?>);">
|
||||
<?php esc_html_e( '升級 mode → dual_write', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wpdo-card">
|
||||
<h3><?php esc_html_e( '步驟 5:升級 mode 至 aeav_only(最終 cutover)', '2meet-data-optimizer' ); ?></h3>
|
||||
<p><?php esc_html_e( 'flat 表成為 source-of-truth,讀寫都走 flat。完成後可 wp wpdo post-cleanup --confirm 清掉 wp_postmeta 已遷移 keys。', '2meet-data-optimizer' ); ?></p>
|
||||
<div class="actions">
|
||||
<a href="<?php echo esc_url( $promote_aeav_url ); ?>" class="button"
|
||||
onclick="return confirm(<?php echo wp_json_encode( __( '確定要升級 post mode 至 aeav_only?此後讀路徑切換到 flat 表,wp_postmeta 變成 backup。', '2meet-data-optimizer' ) ); ?>);">
|
||||
<?php esc_html_e( '升級 mode → aeav_only', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wpdo-card" style="background: #f6f7f7;">
|
||||
<h3><?php esc_html_e( '回退路徑(rollback)', '2meet-data-optimizer' ); ?></h3>
|
||||
<p>
|
||||
<?php esc_html_e( '若任一步驟出問題,可下降 mode:', '2meet-data-optimizer' ); ?>
|
||||
<code>wp option update wpdo_bridge_modes '{"post":"disabled",...}'</code>
|
||||
<?php esc_html_e( '。Legacy wpdo_hot_* 表完整保留 — 直到 v3.0.0 才考慮 DROP。', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,393 @@
|
||||
<?php
|
||||
/**
|
||||
* Post Stress Test template (v2.11.4).
|
||||
*
|
||||
* Full async + polling UI mirroring User Stress Test:
|
||||
* 1. 設定並啟動測試(form: post_type / target / mode / batch_size + Start/Cancel)
|
||||
* 2. 即時進度(progress bar + processed/target/rate/ETA/peak memory)
|
||||
* 3. Benchmark 報告(write metrics + DB sizes + query performance)
|
||||
*
|
||||
* Backward-compat: legacy GET ?wpdo_post_stress_create / cleanup / bench
|
||||
* handlers in admin still work for bookmarked URLs; the new UI uses REST.
|
||||
*
|
||||
* Variables in scope from render_post_stress_test():
|
||||
* $test_post_count — int, posts matching TMDO_STRESS_TEST_ prefix
|
||||
* $diagnose — TMDO_Post_Migration::diagnose() output
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$page_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=post-stress-test' );
|
||||
|
||||
$cleanup_url = wp_nonce_url(
|
||||
add_query_arg( array( 'wpdo_post_stress_cleanup' => '1' ), $page_url ),
|
||||
'wpdo_post_stress_cleanup'
|
||||
);
|
||||
|
||||
// Read-only display banner — server-set redirect message, no form processing.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
$msg_raw = isset( $_GET['wpdo_msg'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['wpdo_msg'] ) ) : '';
|
||||
|
||||
$msg_text = '';
|
||||
$msg_class = '';
|
||||
if ( '' !== $msg_raw ) {
|
||||
if ( str_starts_with( $msg_raw, 'stress_cleanup_' ) ) {
|
||||
$n = (int) substr( $msg_raw, strlen( 'stress_cleanup_' ) );
|
||||
$msg_text = sprintf(
|
||||
/* translators: %s: count */
|
||||
__( '已清除 %s 個 stress test posts(連同 wp_postmeta + 7 張 flat 表的對應 row)。', '2meet-data-optimizer' ),
|
||||
number_format_i18n( $n )
|
||||
);
|
||||
$msg_class = 'notice-success';
|
||||
} elseif ( str_starts_with( $msg_raw, 'err_' ) ) {
|
||||
$msg_text = __( '錯誤:請查看 wpdo_errors 日誌。', '2meet-data-optimizer' );
|
||||
$msg_class = 'notice-error';
|
||||
}
|
||||
}
|
||||
|
||||
// Live state from the new state machine — drives initial render of cards.
|
||||
$state = class_exists( 'TMDO_Post_Stress_Tester' ) ? TMDO_Post_Stress_Tester::get_progress( false ) : array();
|
||||
$wpdo_pst_stat = (string) ( $state['status'] ?? 'idle' );
|
||||
$is_running = ( 'running' === $wpdo_pst_stat || 'benchmarking' === $wpdo_pst_stat );
|
||||
|
||||
// Map of post_type → seed key count, surfaced inline next to the dropdown.
|
||||
$post_type_options = array(
|
||||
'product' => array( 'product (WC)', 5 ),
|
||||
'hp_listing' => array( 'hp_listing (HivePress)', 7 ),
|
||||
'hp_request' => array( 'hp_request', 5 ),
|
||||
'hp_vendor' => array( 'hp_vendor', 5 ),
|
||||
'attachment' => array( 'attachment', 2 ),
|
||||
'nav_menu_item' => array( 'nav_menu_item', 5 ),
|
||||
'post' => array( 'post', 2 ),
|
||||
);
|
||||
?>
|
||||
|
||||
<div class="wpdo-post-stress-test-tab">
|
||||
<h2><?php esc_html_e( 'Post Entity 壓力測試 & Benchmark', '2meet-data-optimizer' ); ?></h2>
|
||||
|
||||
<div style="margin:14px 0;padding:12px 14px;background:#f8d7da;color:#721c24;border-left:4px solid #dc3545;border-radius:4px;font-size:13px;line-height:1.6;">
|
||||
⚠️ <strong><?php esc_html_e( '此工具僅供開發 / 測試環境使用。', '2meet-data-optimizer' ); ?></strong>
|
||||
<?php esc_html_e( '會建立大量測試 post 並填滿對應 wp_postmeta + flat 表。請勿在生產環境執行。', '2meet-data-optimizer' ); ?>
|
||||
</div>
|
||||
|
||||
<p class="description">
|
||||
<?php esc_html_e( '透過自動產生大量測試 post,評估 post entity 反 EAV 系統在不同規模下的寫入吞吐與查詢效能。所有測試 post 均以 TMDO_STRESS_TEST_ 為 post_title 前綴,可一鍵清除。', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
|
||||
<?php if ( '' !== $msg_text ) : ?>
|
||||
<div class="notice <?php echo esc_attr( $msg_class ); ?> is-dismissible">
|
||||
<p><?php echo esc_html( $msg_text ); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Diagnose snapshot -->
|
||||
<?php
|
||||
$current_post_mode = (string) ( $diagnose['mode'] ?? 'disabled' );
|
||||
$mode_color = 'aeav_only' === $current_post_mode ? '#28a745' : '#dc3545';
|
||||
$mode_optimal = 'aeav_only' === $current_post_mode;
|
||||
$settings_tab_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=settings' );
|
||||
?>
|
||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);margin-top:20px;">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '當前 Post Entity 狀態', '2meet-data-optimizer' ); ?></h3>
|
||||
<table class="widefat striped">
|
||||
<tr>
|
||||
<td><?php esc_html_e( 'Posts', '2meet-data-optimizer' ); ?></td>
|
||||
<td><strong><?php echo esc_html( number_format_i18n( $diagnose['posts'] ) ); ?></strong></td>
|
||||
<td><?php esc_html_e( 'Postmeta', '2meet-data-optimizer' ); ?></td>
|
||||
<td><strong><?php echo esc_html( number_format_i18n( $diagnose['postmeta'] ) ); ?></strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><?php esc_html_e( 'Ratio', '2meet-data-optimizer' ); ?></td>
|
||||
<td><strong>1:<?php echo esc_html( (string) $diagnose['ratio'] ); ?></strong></td>
|
||||
<td><?php esc_html_e( 'Mode', '2meet-data-optimizer' ); ?></td>
|
||||
<td><strong style="color:<?php echo esc_attr( $mode_color ); ?>;"><?php echo esc_html( $current_post_mode ); ?></strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><?php esc_html_e( 'Stress 測試 post 數', '2meet-data-optimizer' ); ?></td>
|
||||
<td colspan="2"><strong id="wpdo-pst-count" style="color:#dc3545;font-size:18px;"><?php echo esc_html( number_format_i18n( $test_post_count ) ); ?></strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 驗證反 EAV 優化指南 -->
|
||||
<div class="wpdo-card" style="padding:20px;background:<?php echo $mode_optimal ? '#e8f5e9' : '#fff3cd'; ?>;border-left:4px solid <?php echo esc_attr( $mode_color ); ?>;border-radius:6px;margin-top:16px;font-size:13px;line-height:1.7;">
|
||||
<h3 style="margin-top:0;font-size:14px;">
|
||||
<?php if ( $mode_optimal ) : ?>
|
||||
✅ <?php esc_html_e( 'Post Mode = aeav_only — 已具備驗證優化的條件', '2meet-data-optimizer' ); ?>
|
||||
<?php else : ?>
|
||||
⚠️
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: current mode */
|
||||
esc_html__( 'Post Mode = %s — 此模式下壓力測試結果不會展示反 EAV 優化效果', '2meet-data-optimizer' ),
|
||||
'<code style="background:#fff;padding:2px 6px;border-radius:3px;">' . esc_html( $current_post_mode ) . '</code>'
|
||||
);
|
||||
?>
|
||||
<?php endif; ?>
|
||||
</h3>
|
||||
|
||||
<p style="margin:8px 0 0 0;">
|
||||
<strong><?php esc_html_e( '想看 wp_postmeta 真實減量?必須同時滿足兩個條件:', '2meet-data-optimizer' ); ?></strong>
|
||||
</p>
|
||||
<ol style="margin:6px 0 8px 22px;padding:0;">
|
||||
<li>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: 1: settings tab anchor open, 2: settings tab anchor close */
|
||||
esc_html__( 'Post mode 設為 %1$saeav_only%2$s(前往設定 tab → Entity Bridge → Post entity)', '2meet-data-optimizer' ),
|
||||
'<code style="background:#fff;padding:1px 5px;border-radius:3px;">',
|
||||
'</code>'
|
||||
);
|
||||
?>
|
||||
<?php if ( ! $mode_optimal ) : ?>
|
||||
<a href="<?php echo esc_url( $settings_tab_url ); ?>" class="button button-small" style="margin-left:8px;">→ <?php esc_html_e( '前往設定', '2meet-data-optimizer' ); ?></a>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<li>
|
||||
<?php esc_html_e( '寫入模式選 🐢 Realistic(走 wp_insert_post + Hook Bus,會被攔截短路 wp_postmeta)', '2meet-data-optimizer' ); ?>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<details style="margin-top:8px;">
|
||||
<summary style="cursor:pointer;color:#0073aa;font-weight:600;"><?php esc_html_e( '📐 模式 × 寫入路徑 → 預期結果矩陣', '2meet-data-optimizer' ); ?></summary>
|
||||
<table class="widefat" style="margin-top:8px;background:#fff;font-size:12px;">
|
||||
<thead>
|
||||
<tr style="background:#f0f0f0;">
|
||||
<th><?php esc_html_e( 'Post Mode', '2meet-data-optimizer' ); ?></th>
|
||||
<th>⚡ Fast</th>
|
||||
<th>🐢 Realistic</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr<?php echo 'disabled' === $current_post_mode ? ' style="background:#fff8e1;"' : ''; ?>>
|
||||
<td><code>disabled</code></td>
|
||||
<td>wp_postmeta 5 rows / flat 0 → <strong>1:5(無優化)</strong></td>
|
||||
<td>wp_postmeta 5 / flat 0 → 1:5(無優化)</td>
|
||||
</tr>
|
||||
<tr<?php echo 'dual_write' === $current_post_mode ? ' style="background:#fff8e1;"' : ''; ?>>
|
||||
<td><code>dual_write</code></td>
|
||||
<td>wp_postmeta 5 / flat 0 → 1:5</td>
|
||||
<td>wp_postmeta 5 + flat 1 → 1:5(有 flat 但 wp_postmeta 不減)</td>
|
||||
</tr>
|
||||
<tr<?php echo 'shadow_read' === $current_post_mode ? ' style="background:#fff8e1;"' : ''; ?>>
|
||||
<td><code>shadow_read</code></td>
|
||||
<td>wp_postmeta 5 / flat 0 → 1:5</td>
|
||||
<td>wp_postmeta 5 + flat 1 → 1:5(讀走 flat,寫仍雙寫)</td>
|
||||
</tr>
|
||||
<tr<?php echo 'aeav_only' === $current_post_mode ? ' style="background:#e8f5e9;font-weight:600;"' : ''; ?>>
|
||||
<td><code>aeav_only</code></td>
|
||||
<td>wp_postmeta 5(直 SQL 繞過 Hook Bus)/ flat 0 → 1:5 ⚠️</td>
|
||||
<td>wp_postmeta <strong>0</strong> / flat 1 → <strong>0:1(完全優化)</strong> ✅</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="description" style="margin-top:6px;">
|
||||
<?php esc_html_e( '範例以 nav_menu_item(5 keys/post)為基準。Fast 模式直接 $wpdb->insert,故意繞過 Hook Bus → 即使 mode=aeav_only 也會寫滿 wp_postmeta(用途:快速灌 fixture 給 Query Router benchmark)。驗證反 EAV 優化效果一律用 Realistic。', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:20px;">
|
||||
|
||||
<!-- Left: Configure & Start -->
|
||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '1. 設定並啟動測試', '2meet-data-optimizer' ); ?></h3>
|
||||
|
||||
<table class="form-table" style="margin-top:0;">
|
||||
<tr>
|
||||
<th style="width:35%;"><label for="wpdo-pst-post-type"><?php esc_html_e( 'Post Type', '2meet-data-optimizer' ); ?></label></th>
|
||||
<td>
|
||||
<select id="wpdo-pst-post-type" class="regular-text">
|
||||
<?php foreach ( $post_type_options as $pt => $info ) : ?>
|
||||
<?php
|
||||
list( $label, $key_count ) = $info;
|
||||
?>
|
||||
<option value="<?php echo esc_attr( $pt ); ?>" data-keys="<?php echo esc_attr( (string) $key_count ); ?>">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: 1: post_type slug, 2: human label, 3: number of seeded keys */
|
||||
esc_html__( '%1$s — %2$s · %3$d keys/post', '2meet-data-optimizer' ),
|
||||
esc_html( $pt ),
|
||||
esc_html( $label ),
|
||||
(int) $key_count
|
||||
);
|
||||
?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="description"><?php esc_html_e( '每筆 post 會 seed 該 post_type 對應 entity group 的標準 meta keys(v2.9.1 entity registry)。', '2meet-data-optimizer' ); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><label for="wpdo-pst-target"><?php esc_html_e( '要建立的 post 數', '2meet-data-optimizer' ); ?></label></th>
|
||||
<td>
|
||||
<input type="number" id="wpdo-pst-target" min="1" max="100000" value="500" class="regular-text" />
|
||||
<p class="description"><?php esc_html_e( '常用:100 / 500 / 1,000 / 10,000(上限 100,000)', '2meet-data-optimizer' ); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><label><?php esc_html_e( '寫入模式', '2meet-data-optimizer' ); ?></label></th>
|
||||
<td>
|
||||
<label style="display:block;margin-bottom:6px;">
|
||||
<input type="radio" name="wpdo-pst-mode" value="fast" checked />
|
||||
<strong>Fast</strong> — <?php esc_html_e( '直接 $wpdb->insert,跳過 WP filter chain(最快,但不測 Hook Bus)', '2meet-data-optimizer' ); ?>
|
||||
</label>
|
||||
<label style="display:block;">
|
||||
<input type="radio" name="wpdo-pst-mode" value="realistic" />
|
||||
<strong>Realistic</strong> — <?php esc_html_e( '走 wp_insert_post + update_post_meta(較慢,模擬生產路徑 + 觸發 Hook Bus → mode=dual_write+ 時 flat 表自動填入)', '2meet-data-optimizer' ); ?>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><label for="wpdo-pst-batch"><?php esc_html_e( '批次大小', '2meet-data-optimizer' ); ?></label></th>
|
||||
<td>
|
||||
<input type="number" id="wpdo-pst-batch" min="1" max="1000" value="200" class="small-text" />
|
||||
<p class="description"><?php esc_html_e( '每批執行有 8 秒 wall-clock 上限(避免 nginx 504)。Fast 建議 200-1000;Realistic 建議 5-20(每 post ~100-300ms)。', '2meet-data-optimizer' ); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>
|
||||
<button type="button" class="button button-primary button-large" id="wpdo-pst-start" <?php disabled( $is_running ); ?>>
|
||||
<?php esc_html_e( '🚀 啟動壓力測試', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
<button type="button" class="button" id="wpdo-pst-cancel" <?php disabled( ! $is_running ); ?>>
|
||||
<?php esc_html_e( '⏹ 取消', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Right: Cleanup + Re-run benchmark -->
|
||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '清除測試資料', '2meet-data-optimizer' ); ?></h3>
|
||||
<p>
|
||||
<?php esc_html_e( '目前 stress test post 數:', '2meet-data-optimizer' ); ?>
|
||||
<strong id="wpdo-pst-count-mirror" style="font-size:18px;color:#dc3545;">
|
||||
<?php echo esc_html( number_format_i18n( $test_post_count ) ); ?>
|
||||
</strong>
|
||||
</p>
|
||||
<p class="description">
|
||||
<?php esc_html_e( '一鍵清除所有 post_title 前綴 TMDO_STRESS_TEST_ 的 post,連同 wp_postmeta + 7 張 flat 表的對應 row。', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<button type="button" class="button button-secondary" id="wpdo-pst-cleanup" <?php disabled( $is_running || 0 === $test_post_count ); ?>>
|
||||
<?php esc_html_e( '🗑 清除全部測試 post', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<hr style="margin:18px 0;" />
|
||||
|
||||
<p>
|
||||
<button type="button" class="button" id="wpdo-pst-rerun-bench" <?php disabled( $is_running || 0 === $test_post_count ); ?>>
|
||||
<?php esc_html_e( '📊 重跑 Benchmark(不新增資料)', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
</p>
|
||||
<p class="description">
|
||||
<?php esc_html_e( '針對最後一次啟動所選的 post_type 重新量測寫入指標(保留)+ DB 容量 + 查詢效能。', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress section (live) -->
|
||||
<div id="wpdo-pst-progress-card" class="wpdo-card" style="margin-top:20px;padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);<?php echo $is_running ? '' : 'display:none;'; ?>">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:13px;">
|
||||
<span>
|
||||
<strong id="wpdo-pst-pg-status"><?php echo esc_html( $wpdo_pst_stat ); ?></strong>
|
||||
· <code id="wpdo-pst-pg-post-type"><?php echo esc_html( (string) ( $state['post_type'] ?? '' ) ); ?></code>
|
||||
· <span id="wpdo-pst-pg-mode"><?php echo esc_html( (string) ( $state['mode'] ?? '' ) ); ?></span> mode
|
||||
</span>
|
||||
<span id="wpdo-pst-pg-pct" style="font-weight:600;"><?php echo esc_html( (string) ( $state['pct'] ?? 0 ) ); ?>%</span>
|
||||
</div>
|
||||
<div style="height:14px;background:#e0e0e0;border-radius:7px;overflow:hidden;">
|
||||
<div id="wpdo-pst-pg-bar" style="height:100%;background:linear-gradient(90deg,#28a745,#20c997);width:<?php echo esc_attr( (string) ( $state['pct'] ?? 0 ) ); ?>%;transition:width .4s;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<table style="width:100%;font-size:13px;margin-top:10px;border-collapse:collapse;">
|
||||
<tr>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '已建立', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;font-weight:600;"><span id="wpdo-pst-pg-processed"><?php echo esc_html( (string) ( $state['processed'] ?? 0 ) ); ?></span> / <span id="wpdo-pst-pg-target"><?php echo esc_html( (string) ( $state['target'] ?? 0 ) ); ?></span></td>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '速率', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;font-weight:600;"><span id="wpdo-pst-pg-rate"><?php echo esc_html( (string) ( $state['rate_per_sec'] ?? 0 ) ); ?></span> posts/sec</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '已耗時', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;"><span id="wpdo-pst-pg-elapsed"><?php echo esc_html( (string) ( $state['elapsed_sec'] ?? 0 ) ); ?></span> 秒</td>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '預估剩餘', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;"><span id="wpdo-pst-pg-eta"><?php echo esc_html( (string) ( $state['eta_sec'] ?? 0 ) ); ?></span> 秒</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '完成批次', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;"><span id="wpdo-pst-pg-batches"><?php echo esc_html( (string) ( $state['batches_done'] ?? 0 ) ); ?></span></td>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( 'PHP Peak Mem', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;"><span id="wpdo-pst-pg-mem"><?php echo esc_html( (string) round( ( (int) ( $state['peak_memory'] ?? 0 ) ) / 1048576, 1 ) ); ?></span> MB</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Benchmark report -->
|
||||
<div id="wpdo-pst-bench-card" class="wpdo-card" style="margin-top:20px;padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);<?php echo ! empty( $state['benchmark'] ) ? '' : 'display:none;'; ?>">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
|
||||
<div id="wpdo-pst-bench-content"></div>
|
||||
</div>
|
||||
|
||||
<!-- 7-group seed map info -->
|
||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);margin-top:20px;">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '4. TMDO_Post_Stress_Tester seed map(7 個 post_type)', '2meet-data-optimizer' ); ?></h3>
|
||||
<p class="description">
|
||||
<?php esc_html_e( '每個 stress post 自動 seed 對應 group 的 canonical meta keys(v2.9.1 entity registry 定義)。', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
<table class="widefat striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'post_type', '2meet-data-optimizer' ); ?></th>
|
||||
<th><?php esc_html_e( 'Seeded keys', '2meet-data-optimizer' ); ?></th>
|
||||
<th><?php esc_html_e( 'Flat 表', '2meet-data-optimizer' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>product</code></td>
|
||||
<td>_price, _regular_price, _stock, _stock_status, _sku</td>
|
||||
<td><code>wp_wpdo_post_wc_product</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>hp_listing</code></td>
|
||||
<td>hp_price, hp_status, hp_featured, hp_verified, hp_vendor, hp_view_count, hp_expired_time</td>
|
||||
<td><code>wp_wpdo_post_hp_listing_core</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>hp_request</code></td>
|
||||
<td>hp_status, hp_user, hp_budget, hp_view_count, hp_expired_time</td>
|
||||
<td><code>wp_wpdo_post_hp_request_core</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>hp_vendor</code></td>
|
||||
<td>hp_user, hp_verified, hp_hourly_rate, hp_rating_count, hp_rating</td>
|
||||
<td><code>wp_wpdo_post_hp_vendor_core</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>attachment</code></td>
|
||||
<td>_wp_attached_file, _wp_attachment_image_alt</td>
|
||||
<td><code>wp_wpdo_post_attachment</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>nav_menu_item</code></td>
|
||||
<td>_menu_item_type, _menu_item_object_id, _menu_item_object, _menu_item_target, _menu_item_url</td>
|
||||
<td><code>wp_wpdo_post_nav_menu_item</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>post</code></td>
|
||||
<td>_thumbnail_id, _edit_last</td>
|
||||
<td><code>wp_wpdo_post_wp_core</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
// phpcs:ignore WPDO.AntiEAV -- platform admin UI: stress test SQL example display
|
||||
/**
|
||||
* Term Stress Test template (v2.13.0).
|
||||
*
|
||||
* Full async + polling UI mirroring Post / User Stress Test:
|
||||
* 1. 設定並啟動測試(form: taxonomy / target / mode / batch_size + Start/Cancel)
|
||||
* 2. 即時進度(progress bar + processed/target/rate/ETA/peak memory)
|
||||
* 3. Benchmark 報告(write metrics + DB sizes + query performance)
|
||||
*
|
||||
* Variables in scope from render_term_stress_test():
|
||||
* $state — TMDO_Term_Stress_Tester::get_progress(false) output
|
||||
* $test_term_count — int, terms matching wpdo-stress- slug prefix
|
||||
* $taxonomies — array<slug,label> of available taxonomies
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$status_str = (string) ( $state['status'] ?? 'idle' );
|
||||
$is_running = ( 'running' === $status_str || 'benchmarking' === $status_str );
|
||||
$current_mode = class_exists( 'TMDO_Mode_Manager' ) ? TMDO_Mode_Manager::get( 'term' ) : 'disabled';
|
||||
$mode_color = 'aeav_only' === $current_mode ? '#28a745' : ( 'shadow_read' === $current_mode ? '#dba617' : '#dc3545' );
|
||||
$mode_optimal = 'aeav_only' === $current_mode;
|
||||
$settings_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=settings' );
|
||||
|
||||
global $wpdb;
|
||||
$flat_hp_taxonomy = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wpdo_term_hp_taxonomy" );
|
||||
$flat_misc = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wpdo_term_misc" );
|
||||
$total_terms = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->terms}" );
|
||||
$total_termmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta}" );
|
||||
?>
|
||||
|
||||
<div class="wpdo-term-stress-test-tab">
|
||||
<h2><?php esc_html_e( 'Term Entity 壓力測試 & Benchmark', '2meet-data-optimizer' ); ?></h2>
|
||||
|
||||
<div style="margin:14px 0;padding:12px 14px;background:#f8d7da;color:#721c24;border-left:4px solid #dc3545;border-radius:4px;font-size:13px;line-height:1.6;">
|
||||
⚠️ <strong><?php esc_html_e( '此工具僅供開發 / 測試環境使用。', '2meet-data-optimizer' ); ?></strong>
|
||||
<?php esc_html_e( '會建立大量測試 term 並 seed 對應 hp_taxonomy 群組 keys。請勿在生產環境執行。', '2meet-data-optimizer' ); ?>
|
||||
</div>
|
||||
|
||||
<p class="description">
|
||||
<?php esc_html_e( '透過自動產生大量測試 term,評估 term entity 反 EAV 系統在不同規模下的寫入吞吐與查詢效能。所有測試 term 均以 wpdo-stress- 為 slug 前綴,可一鍵清除。', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
|
||||
<!-- Diagnose snapshot -->
|
||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);margin-top:20px;">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '當前 Term Entity 狀態', '2meet-data-optimizer' ); ?></h3>
|
||||
<table class="widefat striped">
|
||||
<tr>
|
||||
<td><?php esc_html_e( 'wp_terms', '2meet-data-optimizer' ); ?></td>
|
||||
<td><strong><?php echo esc_html( number_format_i18n( $total_terms ) ); ?></strong></td>
|
||||
<td><?php esc_html_e( 'wp_termmeta', '2meet-data-optimizer' ); ?></td>
|
||||
<td><strong><?php echo esc_html( number_format_i18n( $total_termmeta ) ); ?></strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><?php esc_html_e( 'Mode', '2meet-data-optimizer' ); ?></td>
|
||||
<td><strong style="color:<?php echo esc_attr( $mode_color ); ?>;"><?php echo esc_html( $current_mode ); ?></strong></td>
|
||||
<td><?php esc_html_e( 'Ratio', '2meet-data-optimizer' ); ?></td>
|
||||
<td><strong>1:<?php echo esc_html( (string) ( $total_terms > 0 ? round( $total_termmeta / $total_terms, 2 ) : 0 ) ); ?></strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>wpdo_term_hp_taxonomy</code></td>
|
||||
<td><strong><?php echo esc_html( number_format_i18n( $flat_hp_taxonomy ) ); ?></strong> rows</td>
|
||||
<td><code>wpdo_term_misc</code></td>
|
||||
<td><strong><?php echo esc_html( number_format_i18n( $flat_misc ) ); ?></strong> rows</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><?php esc_html_e( 'Stress 測試 term 數', '2meet-data-optimizer' ); ?></td>
|
||||
<td colspan="2"><strong id="wpdo-tst-count" style="color:#dc3545;font-size:18px;"><?php echo esc_html( number_format_i18n( $test_term_count ) ); ?></strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 驗證反 EAV 優化指南 -->
|
||||
<div class="wpdo-card" style="padding:20px;background:<?php echo $mode_optimal ? '#e8f5e9' : '#fff3cd'; ?>;border-left:4px solid <?php echo esc_attr( $mode_color ); ?>;border-radius:6px;margin-top:16px;font-size:13px;line-height:1.7;">
|
||||
<h3 style="margin-top:0;font-size:14px;">
|
||||
<?php if ( $mode_optimal ) : ?>
|
||||
✅ <?php esc_html_e( 'Term Mode = aeav_only — 已具備驗證優化的條件', '2meet-data-optimizer' ); ?>
|
||||
<?php else : ?>
|
||||
⚠️
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: current mode */
|
||||
esc_html__( 'Term Mode = %s — 此模式下壓力測試結果不會展示完整反 EAV 優化效果', '2meet-data-optimizer' ),
|
||||
'<code style="background:#fff;padding:2px 6px;border-radius:3px;">' . esc_html( $current_mode ) . '</code>'
|
||||
);
|
||||
?>
|
||||
<?php endif; ?>
|
||||
</h3>
|
||||
<p style="margin:8px 0 0 0;">
|
||||
<strong><?php esc_html_e( '想看 wp_termmeta 真實減量?必須兩條件同時滿足:', '2meet-data-optimizer' ); ?></strong>
|
||||
</p>
|
||||
<ol style="margin:6px 0 8px 22px;padding:0;">
|
||||
<li>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: 1: open code, 2: close code */
|
||||
esc_html__( 'Term mode 設為 %1$saeav_only%2$s(前往設定 tab → Entity Bridge → Term entity)', '2meet-data-optimizer' ),
|
||||
'<code style="background:#fff;padding:1px 5px;border-radius:3px;">',
|
||||
'</code>'
|
||||
);
|
||||
?>
|
||||
<?php if ( ! $mode_optimal ) : ?>
|
||||
<a href="<?php echo esc_url( $settings_url ); ?>" class="button button-small" style="margin-left:8px;">→ <?php esc_html_e( '前往設定', '2meet-data-optimizer' ); ?></a>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<li>
|
||||
<?php esc_html_e( '寫入模式選 🐢 Realistic(走 wp_insert_term + Hook Bus,會被攔截短路 wp_termmeta)', '2meet-data-optimizer' ); ?>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:20px;">
|
||||
|
||||
<!-- Left: Configure & Start -->
|
||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '1. 設定並啟動測試', '2meet-data-optimizer' ); ?></h3>
|
||||
|
||||
<table class="form-table" style="margin-top:0;">
|
||||
<tr>
|
||||
<th style="width:35%;"><label for="wpdo-tst-taxonomy"><?php esc_html_e( 'Taxonomy', '2meet-data-optimizer' ); ?></label></th>
|
||||
<td>
|
||||
<select id="wpdo-tst-taxonomy" class="regular-text">
|
||||
<?php foreach ( $taxonomies as $slug => $label ) : ?>
|
||||
<option value="<?php echo esc_attr( $slug ); ?>" <?php selected( $slug, 'listing_category' ); ?>>
|
||||
<?php echo esc_html( $slug . ' — ' . $label ); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="description"><?php esc_html_e( '建議用 HivePress 自訂 taxonomy(listing_category / listing_tag 等)— seed 後 hp_sort_order/hp_default/hp_icon 三 keys 走 hp_taxonomy 群組。', '2meet-data-optimizer' ); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><label for="wpdo-tst-target"><?php esc_html_e( '要建立的 term 數', '2meet-data-optimizer' ); ?></label></th>
|
||||
<td>
|
||||
<input type="number" id="wpdo-tst-target" min="1" max="100000" value="500" class="regular-text" />
|
||||
<p class="description"><?php esc_html_e( '常用:100 / 500 / 1,000 / 10,000(上限 100,000)', '2meet-data-optimizer' ); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><label><?php esc_html_e( '寫入模式', '2meet-data-optimizer' ); ?></label></th>
|
||||
<td>
|
||||
<label style="display:block;margin-bottom:6px;">
|
||||
<input type="radio" name="wpdo-tst-mode" value="fast" checked />
|
||||
<strong>Fast</strong> — <?php esc_html_e( '直接 $wpdb->insert,跳過 WP filter chain(最快,但不測 Hook Bus)', '2meet-data-optimizer' ); ?>
|
||||
</label>
|
||||
<label style="display:block;">
|
||||
<input type="radio" name="wpdo-tst-mode" value="realistic" />
|
||||
<strong>Realistic</strong> — <?php esc_html_e( '走 wp_insert_term + update_term_meta(較慢,模擬生產路徑 + 觸發 Hook Bus)', '2meet-data-optimizer' ); ?>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><label for="wpdo-tst-batch"><?php esc_html_e( '批次大小', '2meet-data-optimizer' ); ?></label></th>
|
||||
<td>
|
||||
<input type="number" id="wpdo-tst-batch" min="1" max="1000" value="200" class="small-text" />
|
||||
<p class="description"><?php esc_html_e( '每批 8 秒 wall-clock 上限。Fast 建議 200-1000;Realistic 建議 10-30。', '2meet-data-optimizer' ); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>
|
||||
<button type="button" class="button button-primary button-large" id="wpdo-tst-start" <?php disabled( $is_running ); ?>>
|
||||
<?php esc_html_e( '🚀 啟動壓力測試', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
<button type="button" class="button" id="wpdo-tst-cancel" <?php disabled( ! $is_running ); ?>>
|
||||
<?php esc_html_e( '⏹ 取消', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Right: Cleanup + Re-run benchmark -->
|
||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '清除測試資料', '2meet-data-optimizer' ); ?></h3>
|
||||
<p>
|
||||
<?php esc_html_e( '目前 stress test term 數:', '2meet-data-optimizer' ); ?>
|
||||
<strong id="wpdo-tst-count-mirror" style="font-size:18px;color:#dc3545;">
|
||||
<?php echo esc_html( number_format_i18n( $test_term_count ) ); ?>
|
||||
</strong>
|
||||
</p>
|
||||
<p class="description">
|
||||
<?php esc_html_e( '一鍵清除所有 slug 前綴 wpdo-stress- 的 term,連同 wp_termmeta + wp_term_taxonomy + flat 表的對應 row。', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<button type="button" class="button button-secondary" id="wpdo-tst-cleanup" <?php disabled( $is_running || 0 === $test_term_count ); ?>>
|
||||
<?php esc_html_e( '🗑 清除全部測試 term', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<hr style="margin:18px 0;" />
|
||||
|
||||
<p>
|
||||
<button type="button" class="button" id="wpdo-tst-rerun-bench" <?php disabled( $is_running || 0 === $test_term_count ); ?>>
|
||||
<?php esc_html_e( '📊 重跑 Benchmark(不新增資料)', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress card (live) -->
|
||||
<div id="wpdo-tst-progress-card" class="wpdo-card" style="margin-top:20px;padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);<?php echo $is_running ? '' : 'display:none;'; ?>">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:13px;">
|
||||
<span>
|
||||
<strong id="wpdo-tst-pg-status"><?php echo esc_html( $status_str ); ?></strong>
|
||||
· <code id="wpdo-tst-pg-taxonomy"><?php echo esc_html( (string) ( $state['taxonomy'] ?? '' ) ); ?></code>
|
||||
· <span id="wpdo-tst-pg-mode"><?php echo esc_html( (string) ( $state['mode'] ?? '' ) ); ?></span> mode
|
||||
</span>
|
||||
<span id="wpdo-tst-pg-pct" style="font-weight:600;"><?php echo esc_html( (string) ( $state['pct'] ?? 0 ) ); ?>%</span>
|
||||
</div>
|
||||
<div style="height:14px;background:#e0e0e0;border-radius:7px;overflow:hidden;">
|
||||
<div id="wpdo-tst-pg-bar" style="height:100%;background:linear-gradient(90deg,#28a745,#20c997);width:<?php echo esc_attr( (string) ( $state['pct'] ?? 0 ) ); ?>%;transition:width .4s;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<table style="width:100%;font-size:13px;margin-top:10px;border-collapse:collapse;">
|
||||
<tr>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '已建立', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;font-weight:600;"><span id="wpdo-tst-pg-processed"><?php echo esc_html( (string) ( $state['processed'] ?? 0 ) ); ?></span> / <span id="wpdo-tst-pg-target"><?php echo esc_html( (string) ( $state['target'] ?? 0 ) ); ?></span></td>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '速率', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;font-weight:600;"><span id="wpdo-tst-pg-rate"><?php echo esc_html( (string) ( $state['rate_per_sec'] ?? 0 ) ); ?></span> terms/sec</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '已耗時', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;"><span id="wpdo-tst-pg-elapsed"><?php echo esc_html( (string) ( $state['elapsed_sec'] ?? 0 ) ); ?></span> 秒</td>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '預估剩餘', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;"><span id="wpdo-tst-pg-eta"><?php echo esc_html( (string) ( $state['eta_sec'] ?? 0 ) ); ?></span> 秒</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '完成批次', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;"><span id="wpdo-tst-pg-batches"><?php echo esc_html( (string) ( $state['batches_done'] ?? 0 ) ); ?></span></td>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( 'PHP Peak Mem', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;"><span id="wpdo-tst-pg-mem"><?php echo esc_html( (string) round( ( (int) ( $state['peak_memory'] ?? 0 ) ) / 1048576, 1 ) ); ?></span> MB</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Benchmark report -->
|
||||
<div id="wpdo-tst-bench-card" class="wpdo-card" style="margin-top:20px;padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);<?php echo ! empty( $state['benchmark'] ) ? '' : 'display:none;'; ?>">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
|
||||
<div id="wpdo-tst-bench-content"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
Reference in New Issue
Block a user