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:
2026-07-31 05:06:36 +08:00
commit d36bb954d1
206 changed files with 66538 additions and 0 deletions
+515
View File
@@ -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;
}
}
+246
View File
@@ -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.
}
);
}
} )();
+368
View File
@@ -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 ) => ( {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[ 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_commentmetaflat 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 mswp_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() } 筆 commentattached 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();
}
} );
} );
} )();
+295
View File
@@ -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 );
} )();
+288
View File
@@ -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);
}
+314
View File
@@ -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);
}
});
}
})();
+383
View File
@@ -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 ) => ( {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[ 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_postmetaflat 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-300msbatch 太大會撞 nginx 60s timeout 之前的 8s deadline
if ( mode === 'realistic' && batch > 30 ) {
if ( ! confirm( `Realistic 模式每個 post 約需 100-300 msbatch_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_postmetafixture 用途,非優化驗證)。\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();
}
} );
} );
} )();
+247
View File
@@ -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] 1100, 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 ) );
+339
View File
@@ -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: 'JOINtop 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 ) => ( {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[ 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();
}
}
} );
} );
} )();
+368
View File
@@ -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 ) => ( {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[ 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_termmetaflat 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 mswp_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();
}
} );
} );
} )();