chore: initial snapshot of 2meet-data-optimizer v0.1.0
Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
This commit is contained in:
@@ -0,0 +1,515 @@
|
||||
/**
|
||||
* WP Data Optimizer — Admin Styles (Morandi Design System)
|
||||
*
|
||||
* Morandi tokens are provided via the 'morandi-design-system' CSS dependency
|
||||
* registered in WPDO_Admin::enqueue_assets(). No @import needed.
|
||||
*/
|
||||
|
||||
/* ── KPI Hero Strip (v2.16.0) ────────────────────────────────────────── */
|
||||
|
||||
.wpdo-kpi-hero {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: var(--space-4);
|
||||
margin: var(--space-5) 0;
|
||||
}
|
||||
|
||||
.wpdo-kpi-card {
|
||||
background: var(--color-bg-secondary, #FAF9F6);
|
||||
border: 1px solid var(--color-bg-divider, #E8E3D9);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
box-shadow: var(--shadow-sm, 0 1px 3px rgba(58,53,48,0.08));
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: transform var(--duration-normal, 200ms) var(--ease-default, ease-out),
|
||||
box-shadow var(--duration-normal, 200ms) var(--ease-default, ease-out);
|
||||
}
|
||||
|
||||
.wpdo-kpi-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg,
|
||||
var(--color-accent-warm, #c4a986),
|
||||
var(--color-accent-warm, #c4a986));
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.wpdo-kpi-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md, 0 4px 12px rgba(58,53,48,0.10));
|
||||
}
|
||||
|
||||
.wpdo-kpi-label {
|
||||
font-family: var(--font-family-body);
|
||||
font-size: var(--text-sm, 13px);
|
||||
color: var(--color-text-secondary, #6b6256);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.wpdo-kpi-value {
|
||||
font-family: var(--font-family-display);
|
||||
font-size: 2.25rem;
|
||||
line-height: 1.1;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary, #3a3530);
|
||||
margin-bottom: var(--space-2);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.wpdo-kpi-unit {
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text-secondary, #6b6256);
|
||||
font-weight: 500;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.wpdo-kpi-empty {
|
||||
color: var(--color-text-tertiary, #9a8e7e);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.wpdo-kpi-sub {
|
||||
font-family: var(--font-family-body);
|
||||
font-size: var(--text-xs, 12px);
|
||||
color: var(--color-text-secondary, #6b6256);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Per-KPI accent stripe color */
|
||||
.wpdo-kpi--ratio::before { background: linear-gradient(90deg, #c4a986, #d4b89a); }
|
||||
.wpdo-kpi--speedup::before { background: linear-gradient(90deg, #5a9a85, #7eb39e); }
|
||||
.wpdo-kpi--coverage::before { background: linear-gradient(90deg, #8b7eb3, #a397c9); }
|
||||
|
||||
.wpdo-kpi--health-excellent::before { background: linear-gradient(90deg, #5a9a85, #7eb39e); }
|
||||
.wpdo-kpi--health-good::before { background: linear-gradient(90deg, #c4a986, #d4b89a); }
|
||||
.wpdo-kpi--health-warn::before { background: linear-gradient(90deg, #d4a574, #e0b888); }
|
||||
.wpdo-kpi--health-crit::before { background: linear-gradient(90deg, #c47a7a, #d49090); }
|
||||
|
||||
.wpdo-kpi--health-excellent .wpdo-kpi-value { color: #4a8270; }
|
||||
.wpdo-kpi--health-good .wpdo-kpi-value { color: #a8906f; }
|
||||
.wpdo-kpi--health-warn .wpdo-kpi-value { color: #b88955; }
|
||||
.wpdo-kpi--health-crit .wpdo-kpi-value { color: #a86060; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.wpdo-kpi-hero {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.wpdo-kpi-value {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Grouped Tab Navigation (v2.16.0) ────────────────────────────────── */
|
||||
|
||||
.wpdo-tab-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3) var(--space-4);
|
||||
align-items: flex-end;
|
||||
padding-bottom: 0;
|
||||
border-bottom: 1px solid var(--color-bg-divider, #E8E3D9);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.wpdo-tab-group {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-right: var(--space-2);
|
||||
padding-right: var(--space-3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wpdo-tab-group:not(:last-child)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 24px;
|
||||
bottom: 4px;
|
||||
width: 1px;
|
||||
background: var(--color-bg-divider, #E8E3D9);
|
||||
}
|
||||
|
||||
.wpdo-tab-group-label {
|
||||
font-family: var(--font-family-body);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-tertiary, #9a8e7e);
|
||||
font-weight: 500;
|
||||
padding-left: var(--space-2);
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wpdo-tab-group .nav-tab {
|
||||
margin: 0 2px -1px 0;
|
||||
transition: background-color var(--duration-fast, 120ms) ease,
|
||||
color var(--duration-fast, 120ms) ease;
|
||||
}
|
||||
|
||||
.wpdo-tab-group .nav-tab:hover {
|
||||
background-color: var(--color-bg-tertiary, #F2EEE8);
|
||||
color: var(--color-text-primary, #3a3530);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.wpdo-tab-nav {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.wpdo-tab-group {
|
||||
margin-right: 0;
|
||||
padding-right: var(--space-2);
|
||||
}
|
||||
.wpdo-tab-group-label {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Layout ──────────────────────────────────────────────────────────── */
|
||||
.wpdo-wrap .wpdo-tab-content {
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
|
||||
.wpdo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: var(--space-5);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.wpdo-card {
|
||||
background: var(--color-bg-secondary, #FAF9F6);
|
||||
border: 1px solid var(--color-bg-divider, #E8E3D9);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
box-shadow: var(--shadow-sm, 0 1px 3px rgba(58,53,48,0.08));
|
||||
overflow-x: auto; /* WCAG 1.4.10 Reflow: tables scroll within card, not page */
|
||||
}
|
||||
|
||||
.wpdo-card h2 {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-bottom: 1px solid var(--color-bg-divider, #E8E3D9);
|
||||
padding-bottom: var(--space-3);
|
||||
font-family: var(--font-family-display);
|
||||
}
|
||||
|
||||
.wpdo-card h3,
|
||||
.wpdo-card h4 {
|
||||
margin-top: var(--space-3);
|
||||
font-family: var(--font-family-display);
|
||||
}
|
||||
|
||||
.wpdo-card-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* Utility spacers replacing inline style="margin-top:N" */
|
||||
.wpdo-mt-1 { margin-top: var(--space-2); }
|
||||
.wpdo-mt-2 { margin-top: var(--space-3); }
|
||||
.wpdo-mt-3 { margin-top: var(--space-5); }
|
||||
.wpdo-mb-2 { margin-bottom: var(--space-3); }
|
||||
.wpdo-table--narrow { max-width: 480px; }
|
||||
.wpdo-table--medium { max-width: 700px; }
|
||||
.wpdo-block--wide { max-width: 900px; }
|
||||
.wpdo-col-param { width: 180px; }
|
||||
|
||||
/* Inline form row */
|
||||
.wpdo-form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
margin: var(--space-3) 0;
|
||||
}
|
||||
|
||||
/* ── Tables ──────────────────────────────────────────────────────────── */
|
||||
.wpdo-table {
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-family-body);
|
||||
}
|
||||
|
||||
.wpdo-table code,
|
||||
.wpdo-wrap code {
|
||||
font-size: var(--text-xs);
|
||||
background: var(--color-bg-tertiary, #F2EEE8);
|
||||
padding: 2px var(--space-1);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
|
||||
.wpdo-log-msg {
|
||||
max-width: 400px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ── Touch targets (CLAUDE.md: 44px minimum) ─────────────────────────── */
|
||||
.wpdo-wrap .button,
|
||||
.wpdo-wrap .button-secondary,
|
||||
.wpdo-wrap .button-primary,
|
||||
.wpdo-wrap input[type="number"],
|
||||
.wpdo-wrap input[type="text"],
|
||||
.wpdo-wrap input[type="search"],
|
||||
.wpdo-wrap select {
|
||||
min-height: 44px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.wpdo-wrap .button-small {
|
||||
min-height: 40px; /* WCAG 2.2 AA: 24px min; 40px exceeds 2.5.8 while remaining compact */
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
|
||||
.wpdo-wrap input[type="number"] {
|
||||
padding: 6px var(--space-2);
|
||||
}
|
||||
|
||||
.wpdo-wrap input[type="number"].wpdo-input--xs {
|
||||
width: 72px; /* was inline 60px — bumped to fit 44px height without clipping digits */
|
||||
}
|
||||
|
||||
.wpdo-wrap .button:focus-visible,
|
||||
.wpdo-wrap input:focus-visible,
|
||||
.wpdo-wrap select:focus-visible {
|
||||
outline: 2px solid var(--color-terra-primary, #C4897A);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Method pill for REST API tab (was inline #0073aa) ───────────────── */
|
||||
.wpdo-method-pill {
|
||||
background: var(--color-blue-primary, #9EB3C2);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
|
||||
/* Code block for curl examples (was inline #EFEFEF) */
|
||||
.wpdo-wrap .wpdo-code-block {
|
||||
background: var(--color-bg-tertiary, #F2EEE8);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-family-mono);
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
border: 1px solid var(--color-bg-divider, #E8E3D9);
|
||||
}
|
||||
|
||||
/* ── Zone badges (icon + text, not colour alone — WCAG 1.4.1) ────────── */
|
||||
.wpdo-zone {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.wpdo-zone::before {
|
||||
font-family: var(--font-family-mono);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.wpdo-zone-hot {
|
||||
background: var(--color-terra-primary, #C4897A);
|
||||
}
|
||||
.wpdo-zone-hot::before { content: "🔥"; }
|
||||
|
||||
.wpdo-zone-warm {
|
||||
background: var(--color-apricot-medium, #C8B090);
|
||||
}
|
||||
.wpdo-zone-warm::before { content: "◐"; }
|
||||
|
||||
.wpdo-zone-cold {
|
||||
background: var(--color-blue-medium, #8AA0B2);
|
||||
}
|
||||
.wpdo-zone-cold::before { content: "❄"; }
|
||||
|
||||
.wpdo-zone-archive {
|
||||
background: var(--color-gray-medium, #AFA8A0);
|
||||
}
|
||||
.wpdo-zone-archive::before { content: "▦"; }
|
||||
|
||||
/* ── State badges (shape-prefixed for colour-blind legibility) ───────── */
|
||||
.wpdo-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.wpdo-state::before {
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
|
||||
.wpdo-state-idle {
|
||||
background: var(--color-bg-tertiary, #F2EEE8);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
}
|
||||
.wpdo-state-idle::before { content: "○"; }
|
||||
|
||||
.wpdo-state-dual_write {
|
||||
background: var(--color-apricot-lightest, #EDD9C0);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
}
|
||||
.wpdo-state-dual_write::before { content: "⇄"; }
|
||||
|
||||
.wpdo-state-backfill {
|
||||
background: var(--color-blue-lightest, #C8D6DE);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
}
|
||||
.wpdo-state-backfill::before { content: "⟲"; }
|
||||
|
||||
.wpdo-state-verify {
|
||||
background: var(--color-apricot-lightest, #EDD9C0);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
}
|
||||
.wpdo-state-verify::before { content: "⚠"; }
|
||||
|
||||
.wpdo-state-cutover {
|
||||
background: var(--color-green-lightest, #C8D5C4);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
}
|
||||
.wpdo-state-cutover::before { content: "✓"; }
|
||||
|
||||
.wpdo-state-cleanup {
|
||||
background: var(--color-blue-lightest, #C8D6DE);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
}
|
||||
.wpdo-state-cleanup::before { content: "✂"; }
|
||||
|
||||
.wpdo-state-complete {
|
||||
background: var(--color-green-primary, #A8B9A4);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
}
|
||||
.wpdo-state-complete::before { content: "✔"; }
|
||||
|
||||
/* ── Generic badges ──────────────────────────────────────────────────── */
|
||||
.wpdo-badge {
|
||||
display: inline-block;
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
background: var(--color-bg-tertiary, #F2EEE8);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
}
|
||||
|
||||
.wpdo-badge-ok {
|
||||
background: var(--color-green-lightest, #C8D5C4);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
}
|
||||
|
||||
.wpdo-badge-warn {
|
||||
background: var(--color-apricot-lightest, #EDD9C0);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
}
|
||||
|
||||
/* ── Progress bars (transform-based — no layout-thrashing) ───────────── */
|
||||
.wpdo-progress {
|
||||
display: inline-block;
|
||||
width: 120px;
|
||||
height: 14px;
|
||||
background: var(--color-bg-tertiary, #F2EEE8);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
margin-right: var(--space-2);
|
||||
}
|
||||
|
||||
.wpdo-progress-bar {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--color-blue-primary, #9EB3C2);
|
||||
border-radius: var(--radius-full);
|
||||
transform-origin: left center;
|
||||
transform: scaleX(var(--wpdo-progress, 0));
|
||||
transition: transform var(--duration-slow, 0.4s) var(--ease-default, ease);
|
||||
}
|
||||
|
||||
.wpdo-progress-text {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-primary, #3A3530);
|
||||
}
|
||||
|
||||
/* ── Confidence bar ──────────────────────────────────────────────────── */
|
||||
.wpdo-confidence {
|
||||
display: inline-block;
|
||||
width: 60px;
|
||||
height: 10px;
|
||||
background: var(--color-bg-tertiary, #F2EEE8);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
margin-right: var(--space-1);
|
||||
}
|
||||
|
||||
.wpdo-confidence-bar {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--color-green-primary, #A8B9A4);
|
||||
border-radius: var(--radius-full);
|
||||
transform-origin: left center;
|
||||
transform: scaleX(var(--wpdo-confidence, 0));
|
||||
}
|
||||
|
||||
/* ── Screen reader only ──────────────────────────────────────────────── */
|
||||
.wpdo-sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ── Tab red-dot badge (v2.8.1) ──────────────────────────────────────── */
|
||||
.nav-tab .wpdo-tab-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #dc3232;
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
box-shadow: 0 0 6px rgba(220, 50, 50, 0.6);
|
||||
animation: wpdo-tab-dot-pulse 2s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes wpdo-tab-dot-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(220, 50, 50, 0.7); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(220, 50, 50, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(220, 50, 50, 0); }
|
||||
}
|
||||
|
||||
/* ── Reduced motion ──────────────────────────────────────────────────── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wpdo-progress-bar {
|
||||
transition-duration: 0.01ms;
|
||||
}
|
||||
.nav-tab .wpdo-tab-dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* WP Data Optimizer — Admin JS (native fetch, no jQuery)
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
( function () {
|
||||
'use strict';
|
||||
|
||||
var WPDO = window.wpdoAdmin || {};
|
||||
var i18n = WPDO.i18n || {};
|
||||
|
||||
document.addEventListener(
|
||||
'DOMContentLoaded',
|
||||
function () {
|
||||
bindFlushCacheButtons();
|
||||
bindStatusRefresh();
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Announce a message via the shared aria-live region.
|
||||
* Falls back to creating the region on first call.
|
||||
*/
|
||||
function announce( message ) {
|
||||
var region = document.getElementById( 'wpdo-aria-live' );
|
||||
if ( ! region ) {
|
||||
region = document.createElement( 'div' );
|
||||
region.id = 'wpdo-aria-live';
|
||||
region.className = 'wpdo-sr-only';
|
||||
region.setAttribute( 'role', 'status' );
|
||||
region.setAttribute( 'aria-live', 'polite' );
|
||||
region.setAttribute( 'aria-atomic', 'true' );
|
||||
document.body.appendChild( region );
|
||||
}
|
||||
region.textContent = '';
|
||||
setTimeout(
|
||||
function () {
|
||||
region.textContent = message; },
|
||||
50
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an inline admin notice at the top of .wpdo-wrap.
|
||||
* Dismissable by user. Replaces legacy alert() for errors.
|
||||
*/
|
||||
function showNotice( message, type ) {
|
||||
type = type || 'error';
|
||||
var wrap = document.querySelector( '.wpdo-wrap' );
|
||||
if ( ! wrap ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var notice = document.createElement( 'div' );
|
||||
notice.className = 'notice notice-' + type + ' is-dismissible';
|
||||
notice.setAttribute( 'role', type === 'error' ? 'alert' : 'status' );
|
||||
|
||||
var p = document.createElement( 'p' );
|
||||
p.textContent = message;
|
||||
notice.appendChild( p );
|
||||
|
||||
var dismiss = document.createElement( 'button' );
|
||||
dismiss.type = 'button';
|
||||
dismiss.className = 'notice-dismiss';
|
||||
dismiss.setAttribute( 'aria-label', i18n.dismiss || 'Dismiss' );
|
||||
dismiss.addEventListener(
|
||||
'click',
|
||||
function () {
|
||||
notice.remove();
|
||||
}
|
||||
);
|
||||
notice.appendChild( dismiss );
|
||||
|
||||
// Insert after the H1 so it appears at the top of the content area.
|
||||
var h1 = wrap.querySelector( 'h1' );
|
||||
if ( h1 && h1.nextSibling ) {
|
||||
wrap.insertBefore( notice, h1.nextSibling );
|
||||
} else {
|
||||
wrap.prepend( notice );
|
||||
}
|
||||
|
||||
announce( message );
|
||||
}
|
||||
|
||||
/**
|
||||
* POST to admin-ajax.php using native fetch + FormData.
|
||||
* Returns a Promise resolving to the parsed JSON response.
|
||||
*/
|
||||
function wpdoAjax( actionType, data ) {
|
||||
var body = new FormData();
|
||||
body.append( 'action', 'wpdo_admin_action' );
|
||||
body.append( 'nonce', WPDO.nonce || '' );
|
||||
body.append( 'action_type', actionType );
|
||||
Object.keys( data || {} ).forEach(
|
||||
function ( key ) {
|
||||
body.append( key, data[ key ] );
|
||||
}
|
||||
);
|
||||
|
||||
return fetch(
|
||||
WPDO.ajaxUrl,
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
body: body,
|
||||
}
|
||||
)
|
||||
.then(
|
||||
function ( res ) {
|
||||
if ( ! res.ok ) {
|
||||
throw new Error( 'HTTP ' + res.status );
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush cache button handler — delegated click listener.
|
||||
*/
|
||||
function bindFlushCacheButtons() {
|
||||
document.addEventListener(
|
||||
'click',
|
||||
function ( e ) {
|
||||
var btn = e.target.closest( '.wpdo-flush-cache' );
|
||||
if ( ! btn ) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
|
||||
var postType = btn.getAttribute( 'data-post-type' );
|
||||
if ( ! postType ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var originalLabel = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = i18n.flushing || 'Flushing…';
|
||||
announce( btn.textContent );
|
||||
|
||||
wpdoAjax( 'flush_cache', { post_type: postType } )
|
||||
.then(
|
||||
function ( response ) {
|
||||
if ( response && response.success ) {
|
||||
btn.textContent = i18n.flushed || 'Flushed!';
|
||||
announce( btn.textContent );
|
||||
setTimeout(
|
||||
function () {
|
||||
btn.disabled = false;
|
||||
btn.textContent = i18n.flushLabel || originalLabel;
|
||||
},
|
||||
2000
|
||||
);
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.textContent = i18n.flushLabel || originalLabel;
|
||||
var msg = ( response && response.data ) ? String( response.data ) : ( i18n.error || 'Unknown error' );
|
||||
showNotice( msg, 'error' );
|
||||
}
|
||||
}
|
||||
)
|
||||
.catch(
|
||||
function () {
|
||||
btn.disabled = false;
|
||||
btn.textContent = i18n.flushLabel || originalLabel;
|
||||
showNotice( i18n.error || 'Request failed.', 'error' );
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-refresh module status on the Dashboard tab (every 30s).
|
||||
*/
|
||||
function bindStatusRefresh() {
|
||||
var isDashboard = document.querySelector( '.wpdo-wrap' )
|
||||
&& window.location.search.indexOf( 'tab=dashboard' ) !== -1;
|
||||
if ( ! isDashboard ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var timer = setInterval(
|
||||
function () {
|
||||
if ( ! document.hidden ) {
|
||||
refreshStatus();
|
||||
}
|
||||
},
|
||||
30000
|
||||
);
|
||||
|
||||
window.addEventListener(
|
||||
'beforeunload',
|
||||
function () {
|
||||
clearInterval( timer );
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function refreshStatus() {
|
||||
wpdoAjax( 'module_status', {} )
|
||||
.then(
|
||||
function ( response ) {
|
||||
if ( ! response || ! response.success || ! response.data ) {
|
||||
return;
|
||||
}
|
||||
var data = response.data;
|
||||
var changed = 0;
|
||||
var changedSummaries = [];
|
||||
|
||||
document.querySelectorAll( '.wpdo-state' ).forEach(
|
||||
function ( el ) {
|
||||
var row = el.closest( 'tr' );
|
||||
var codeEl = row ? row.querySelector( 'code' ) : null;
|
||||
if ( ! codeEl ) {
|
||||
return;
|
||||
}
|
||||
var module = codeEl.textContent;
|
||||
if ( ! module || ! ( module in data ) ) {
|
||||
return;
|
||||
}
|
||||
var newState = data[ module ];
|
||||
if ( el.textContent === newState ) {
|
||||
return;
|
||||
}
|
||||
el.textContent = newState;
|
||||
el.className = 'wpdo-state wpdo-state-' + newState;
|
||||
changed++;
|
||||
changedSummaries.push( module + ' → ' + newState );
|
||||
}
|
||||
);
|
||||
|
||||
if ( changed > 0 ) {
|
||||
announce( changedSummaries.join( '、' ) );
|
||||
}
|
||||
}
|
||||
)
|
||||
.catch(
|
||||
function () {
|
||||
// Fail silently on polling — user will see the stale state; not worth a notice.
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
} )();
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* WPDO Comment Stress Test — admin tab JS (v2.13.1)
|
||||
*
|
||||
* Mirrors wpdo-term-stress-test.js (v2.13.0) but:
|
||||
* - Talks to /wpdo/v1/comment-stress-test/* endpoints
|
||||
* - Sends post_id in start payload (not taxonomy)
|
||||
* - DOM IDs prefixed wpdo-cst-* (comment stress test)
|
||||
*
|
||||
* @since 2.13.1
|
||||
*/
|
||||
( function () {
|
||||
'use strict';
|
||||
|
||||
const cfg = window.wpdoCommentStressTest;
|
||||
if ( ! cfg || ! cfg.restUrl ) {
|
||||
return;
|
||||
}
|
||||
if ( ! document.querySelector( '.wpdo-comment-stress-test-tab' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $ = ( sel ) => document.querySelector( sel );
|
||||
const restUrl = cfg.restUrl.replace( /\/$/, '' );
|
||||
const headers = { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce };
|
||||
|
||||
let pollTimer = null;
|
||||
|
||||
// ── API helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
async function apiCall( path, method = 'GET', body = null ) {
|
||||
const opts = { method, headers, credentials: 'same-origin' };
|
||||
if ( body ) {
|
||||
opts.body = JSON.stringify( body );
|
||||
}
|
||||
const resp = await fetch( restUrl + path, opts );
|
||||
const text = await resp.text();
|
||||
try {
|
||||
return { ok: resp.ok, status: resp.status, data: text ? JSON.parse( text ) : null };
|
||||
} catch ( e ) {
|
||||
return { ok: false, status: resp.status, data: { error: text } };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────────────
|
||||
|
||||
function fmt( n ) {
|
||||
if ( n === null || n === undefined ) {
|
||||
return '—';
|
||||
}
|
||||
return Number( n ).toLocaleString();
|
||||
}
|
||||
|
||||
function setText( sel, text ) {
|
||||
const el = $( sel );
|
||||
if ( el ) {
|
||||
el.textContent = String( text );
|
||||
}
|
||||
}
|
||||
|
||||
function esc( s ) {
|
||||
if ( s === null || s === undefined ) {
|
||||
return '';
|
||||
}
|
||||
return String( s ).replace( /[&<>"']/g, ( c ) => ( {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[ c ] ) );
|
||||
}
|
||||
|
||||
function renderProgress( state ) {
|
||||
const isRunning = state.status === 'running' || state.status === 'benchmarking';
|
||||
const card = $( '#wpdo-cst-progress-card' );
|
||||
if ( card ) {
|
||||
card.style.display = ( isRunning || state.status === 'completed' || state.status === 'failed' || state.status === 'cancelled' ) ? '' : 'none';
|
||||
}
|
||||
setText( '#wpdo-cst-pg-status', state.status || 'idle' );
|
||||
setText( '#wpdo-cst-pg-post-id', state.post_id ? ( 'post #' + state.post_id ) : '' );
|
||||
setText( '#wpdo-cst-pg-mode', state.mode || '' );
|
||||
setText( '#wpdo-cst-pg-pct', ( state.pct || 0 ) + '%' );
|
||||
setText( '#wpdo-cst-pg-processed', fmt( state.processed || 0 ) );
|
||||
setText( '#wpdo-cst-pg-target', fmt( state.target || 0 ) );
|
||||
setText( '#wpdo-cst-pg-rate', state.rate_per_sec || 0 );
|
||||
setText( '#wpdo-cst-pg-elapsed', state.elapsed_sec || 0 );
|
||||
setText( '#wpdo-cst-pg-eta', state.eta_sec || 0 );
|
||||
setText( '#wpdo-cst-pg-batches', state.batches_done || 0 );
|
||||
setText( '#wpdo-cst-pg-mem', ( ( state.peak_memory || 0 ) / 1048576 ).toFixed( 1 ) );
|
||||
|
||||
const bar = $( '#wpdo-cst-pg-bar' );
|
||||
if ( bar ) {
|
||||
bar.style.width = ( state.pct || 0 ) + '%';
|
||||
}
|
||||
|
||||
const count = state.test_comment_count || 0;
|
||||
setText( '#wpdo-cst-count', fmt( count ) );
|
||||
setText( '#wpdo-cst-count-mirror', fmt( count ) );
|
||||
|
||||
const startBtn = $( '#wpdo-cst-start' );
|
||||
const cancelBtn = $( '#wpdo-cst-cancel' );
|
||||
const cleanupBtn = $( '#wpdo-cst-cleanup' );
|
||||
const benchBtn = $( '#wpdo-cst-rerun-bench' );
|
||||
if ( startBtn ) {
|
||||
startBtn.disabled = isRunning;
|
||||
}
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.disabled = ! isRunning;
|
||||
}
|
||||
if ( cleanupBtn ) {
|
||||
cleanupBtn.disabled = isRunning || count === 0;
|
||||
}
|
||||
if ( benchBtn ) {
|
||||
benchBtn.disabled = isRunning || count === 0;
|
||||
}
|
||||
}
|
||||
|
||||
function renderBenchmark( bench ) {
|
||||
if ( ! bench ) {
|
||||
return;
|
||||
}
|
||||
const card = $( '#wpdo-cst-bench-card' );
|
||||
const content = $( '#wpdo-cst-bench-content' );
|
||||
if ( ! card || ! content ) {
|
||||
return;
|
||||
}
|
||||
card.style.display = '';
|
||||
|
||||
const w = bench.write || {};
|
||||
const dbSizes = bench.db_sizes || [];
|
||||
const q = bench.query || {};
|
||||
|
||||
const labels = {
|
||||
point_rating_5: 'Point lookup (hp_rating = 5)',
|
||||
range_rating_top: 'Range scan (hp_rating >= 4 ORDER BY DESC)',
|
||||
eav_baseline: 'EAV baseline (wp_commentmeta 直查 hp_rating)',
|
||||
};
|
||||
|
||||
let html = '';
|
||||
// Write metrics
|
||||
html += '<h4 style="margin-bottom:6px;">▍ 寫入指標</h4>';
|
||||
html += '<table class="widefat" style="margin-bottom:14px;"><tbody>';
|
||||
html += `<tr><td>模式</td><td><code>${ esc( w.mode ) }</code></td></tr>`;
|
||||
html += `<tr><td>Post ID</td><td><code>#${ esc( w.post_id ) }</code></td></tr>`;
|
||||
html += `<tr><td>完成 / 目標</td><td>${ fmt( w.processed ) } / ${ fmt( w.target ) }</td></tr>`;
|
||||
html += `<tr><td>總耗時</td><td>${ fmt( w.elapsed_sec ) } 秒</td></tr>`;
|
||||
html += `<tr><td>平均速率</td><td><strong>${ fmt( w.rate_per_sec ) }</strong> comments/sec</td></tr>`;
|
||||
html += `<tr><td>批次數</td><td>${ fmt( w.batches_done ) }</td></tr>`;
|
||||
html += `<tr><td>批次最快/平均/最慢</td><td>${ fmt( w.batch_min_ms ) } / ${ fmt( w.batch_avg_ms ) } / ${ fmt( w.batch_max_ms ) } ms</td></tr>`;
|
||||
html += `<tr><td>PHP Peak Memory</td><td>${ fmt( w.peak_memory_mb ) } MB</td></tr>`;
|
||||
html += '</tbody></table>';
|
||||
|
||||
// DB sizes
|
||||
html += '<h4 style="margin-bottom:6px;">▍ DB 容量(comment 相關表)</h4>';
|
||||
html += '<table class="widefat striped" style="margin-bottom:14px;"><thead><tr>';
|
||||
html += '<th>Table</th><th>Rows</th><th>Data MB</th><th>Index MB</th><th>Total MB</th><th>Avg bytes/row</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
dbSizes.forEach( ( r ) => {
|
||||
html += `<tr><td><code>${ esc( r.table ) }</code></td><td>${ fmt( r.rows ) }</td>`;
|
||||
html += `<td>${ r.data_mb ?? '—' }</td><td>${ r.index_mb ?? '—' }</td>`;
|
||||
html += `<td><strong>${ r.total_mb ?? '—' }</strong></td><td>${ fmt( r.avg_bytes ) }</td></tr>`;
|
||||
} );
|
||||
html += '</tbody></table>';
|
||||
|
||||
// Query perf — 3 probes with EAV baseline last for visual speedup comparison
|
||||
html += '<h4 style="margin-bottom:6px;">▍ 查詢效能</h4>';
|
||||
html += '<table class="widefat striped" style="margin-bottom:8px;"><thead><tr>';
|
||||
html += '<th>測試項目</th><th>耗時 (ms)</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
const baselineMs = q.eav_baseline?.duration_ms ?? null;
|
||||
Object.keys( q ).forEach( ( key ) => {
|
||||
const v = q[ key ];
|
||||
if ( ! v || typeof v.duration_ms !== 'number' ) {
|
||||
return;
|
||||
}
|
||||
const label = labels[ key ] || key;
|
||||
let speedup = '';
|
||||
if ( baselineMs !== null && key !== 'eav_baseline' && v.duration_ms > 0 ) {
|
||||
const ratio = baselineMs / v.duration_ms;
|
||||
if ( ratio >= 1 ) {
|
||||
speedup = ` <span style="color:#28a745;font-weight:600;">(${ ratio.toFixed( 2 ) }× faster)</span>`;
|
||||
}
|
||||
}
|
||||
html += `<tr><td>${ esc( label ) }${ speedup }</td><td><strong>${ v.duration_ms }</strong></td></tr>`;
|
||||
} );
|
||||
html += '</tbody></table>';
|
||||
html += '<p class="description">EAV baseline 走 wp_commentmeta,flat probes 走 wpdo_comment_hp_review。倍率即此規模下反 EAV 的查詢加速。</p>';
|
||||
|
||||
content.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Polling ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function poll() {
|
||||
const r = await apiCall( '/comment-stress-test/status' );
|
||||
if ( ! r.ok || ! r.data ) {
|
||||
return;
|
||||
}
|
||||
renderProgress( r.data );
|
||||
if ( r.data.benchmark ) {
|
||||
renderBenchmark( r.data.benchmark );
|
||||
}
|
||||
if ( r.data.status !== 'running' && r.data.status !== 'benchmarking' ) {
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
poll();
|
||||
pollTimer = setInterval( poll, 2000 );
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if ( pollTimer ) {
|
||||
clearInterval( pollTimer );
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event handlers ──────────────────────────────────────────────────────
|
||||
|
||||
async function handleStart() {
|
||||
const post_id = parseInt( $( '#wpdo-cst-post-id' ).value, 10 );
|
||||
const target = parseInt( $( '#wpdo-cst-target' ).value, 10 );
|
||||
const batch = parseInt( $( '#wpdo-cst-batch' ).value, 10 );
|
||||
const mode = document.querySelector( 'input[name="wpdo-cst-mode"]:checked' ).value;
|
||||
|
||||
if ( ! post_id || post_id < 1 ) {
|
||||
alert( '請選擇目標 post' );
|
||||
return;
|
||||
}
|
||||
if ( ! target || target < 1 ) {
|
||||
alert( '請輸入有效的 comment 數量' );
|
||||
return;
|
||||
}
|
||||
if ( mode === 'realistic' && batch > 50 ) {
|
||||
if ( ! confirm( `Realistic 模式每個 comment 約需 30-100 ms(wp_insert_comment + update_comment_meta),batch_size=${ batch } 可能超過後端 8s deadline。建議 batch=10-30。要繼續嗎?` ) ) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Soft warn for combinations that won't demonstrate反 EAV 優化效果
|
||||
const commentMode = String( cfg.commentMode || 'disabled' );
|
||||
if ( mode === 'fast' && commentMode === 'aeav_only' ) {
|
||||
if ( ! confirm( `⚠️ Fast 模式直接 $wpdb->insert 繞過 Hook Bus,即使 comment mode=aeav_only 也會寫滿 wp_commentmeta。\n\n要驗證反 EAV 優化效果(wp_commentmeta 應為 0),請改用 🐢 Realistic 模式。\n\n仍以 Fast 模式繼續嗎?` ) ) {
|
||||
return;
|
||||
}
|
||||
} else if ( mode === 'realistic' && commentMode !== 'aeav_only' ) {
|
||||
if ( ! confirm( `⚠️ 目前 comment mode = ${ commentMode },Realistic 寫入仍會雙寫 wp_commentmeta(不展示優化效果)。\n\n要看 wp_commentmeta 完全短路請先升 mode 至 aeav_only(設定 tab)。\n\n仍以 ${ commentMode } 模式繼續嗎?` ) ) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const big = target >= 5000;
|
||||
const msg = `即將以 ${ mode } 模式建立 ${ target.toLocaleString() } 筆 comment(attached to post #${ post_id })${ big ? '(規模較大)' : '' }。\n\n所有 comment 的 author email 會以 @wpdo-stress.local 結尾,可一鍵清除。\n\n確定要開始嗎?`;
|
||||
if ( ! confirm( msg ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const r = await apiCall( '/comment-stress-test/start', 'POST', {
|
||||
post_id,
|
||||
target,
|
||||
mode,
|
||||
batch_size: batch,
|
||||
} );
|
||||
if ( ! r.ok ) {
|
||||
alert( '啟動失敗:' + ( r.data?.error || r.status ) );
|
||||
return;
|
||||
}
|
||||
const card = $( '#wpdo-cst-progress-card' );
|
||||
if ( card ) {
|
||||
card.style.display = '';
|
||||
}
|
||||
const benchCard = $( '#wpdo-cst-bench-card' );
|
||||
if ( benchCard ) {
|
||||
benchCard.style.display = 'none';
|
||||
}
|
||||
setText( '#wpdo-cst-pg-status', 'running' );
|
||||
setText( '#wpdo-cst-pg-post-id', 'post #' + post_id );
|
||||
setText( '#wpdo-cst-pg-mode', mode );
|
||||
setText( '#wpdo-cst-pg-target', target.toLocaleString() );
|
||||
startPolling();
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if ( ! confirm( '確定要取消當前測試?已建立的 comment 不會被刪除。' ) ) {
|
||||
return;
|
||||
}
|
||||
const cancelBtn = $( '#wpdo-cst-cancel' );
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.disabled = true;
|
||||
cancelBtn.textContent = '⏹ 取消中…';
|
||||
}
|
||||
setText( '#wpdo-cst-pg-status', 'cancelling' );
|
||||
|
||||
const r = await apiCall( '/comment-stress-test/cancel', 'POST' );
|
||||
if ( ! r.ok ) {
|
||||
alert( '取消失敗:' + ( r.data?.error || r.status ) );
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.disabled = false;
|
||||
cancelBtn.textContent = '⏹ 取消';
|
||||
}
|
||||
return;
|
||||
}
|
||||
poll();
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.textContent = '⏹ 取消';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCleanup() {
|
||||
if ( ! confirm( '確定要清除所有 stress test comments?\n\n此動作會:\n- DELETE 所有 email 後綴 @wpdo-stress.local 的 comment\n- DELETE 對應 wp_commentmeta\n- DELETE flat 表中對應 comment_id 的列\n\n不可復原!' ) ) {
|
||||
return;
|
||||
}
|
||||
const r = await apiCall( '/comment-stress-test/cleanup', 'DELETE' );
|
||||
if ( ! r.ok ) {
|
||||
alert( '清除失敗:' + ( r.data?.error || r.status ) );
|
||||
return;
|
||||
}
|
||||
alert( `已清除 ${ r.data.deleted } 筆測試 comment。` );
|
||||
const card = $( '#wpdo-cst-progress-card' );
|
||||
const benchCard = $( '#wpdo-cst-bench-card' );
|
||||
if ( card ) card.style.display = 'none';
|
||||
if ( benchCard ) benchCard.style.display = 'none';
|
||||
poll();
|
||||
}
|
||||
|
||||
async function handleRerunBench() {
|
||||
const btn = $( '#wpdo-cst-rerun-bench' );
|
||||
if ( btn ) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ 執行中...';
|
||||
}
|
||||
const r = await apiCall( '/comment-stress-test/benchmark', 'POST' );
|
||||
if ( btn ) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '📊 重跑 Benchmark(不新增資料)';
|
||||
}
|
||||
if ( ! r.ok ) {
|
||||
alert( 'Benchmark 失敗:' + ( r.data?.error || r.status ) );
|
||||
return;
|
||||
}
|
||||
renderBenchmark( r.data.benchmark );
|
||||
}
|
||||
|
||||
// ── Init ────────────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener( 'DOMContentLoaded', () => {
|
||||
const startBtn = $( '#wpdo-cst-start' );
|
||||
const cancelBtn = $( '#wpdo-cst-cancel' );
|
||||
const cleanupBtn = $( '#wpdo-cst-cleanup' );
|
||||
const benchBtn = $( '#wpdo-cst-rerun-bench' );
|
||||
|
||||
if ( startBtn ) startBtn.addEventListener( 'click', handleStart );
|
||||
if ( cancelBtn ) cancelBtn.addEventListener( 'click', handleCancel );
|
||||
if ( cleanupBtn ) cleanupBtn.addEventListener( 'click', handleCleanup );
|
||||
if ( benchBtn ) benchBtn.addEventListener( 'click', handleRerunBench );
|
||||
|
||||
poll().then( () => {
|
||||
const status = ( $( '#wpdo-cst-pg-status' )?.textContent || '' ).trim();
|
||||
if ( status === 'running' || status === 'benchmarking' ) {
|
||||
startPolling();
|
||||
}
|
||||
} );
|
||||
} );
|
||||
} )();
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* WPDO Entity Bridge — Admin UI controller
|
||||
*
|
||||
* Features:
|
||||
* - Polls /wp-json/wpdo/v1/entity-bridge/health every 5 s during active backfill
|
||||
* - Updates coverage progress bars and migration badges in real time
|
||||
* - Handles Backfill / Promote / Demote button clicks with confirmation
|
||||
*
|
||||
* Depends on: wpdoEntityBridge (wp_localize_script data)
|
||||
*/
|
||||
|
||||
/* global wpdoEntityBridge */
|
||||
( function () {
|
||||
'use strict';
|
||||
|
||||
var cfg = window.wpdoEntityBridge || {};
|
||||
var restUrl = cfg.restUrl || '';
|
||||
var nonce = cfg.nonce || '';
|
||||
var pollInterval = 5000; // ms between polls
|
||||
var timer = null;
|
||||
var activeBackfills = {}; // {entity_type: {group_name: true}} — track in-progress
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function apiFetch( method, path, body ) {
|
||||
var url = restUrl + path;
|
||||
var opts = {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': nonce,
|
||||
},
|
||||
};
|
||||
if ( body ) {
|
||||
opts.body = JSON.stringify( body );
|
||||
}
|
||||
return fetch( url, opts ).then( function ( res ) {
|
||||
if ( ! res.ok ) {
|
||||
return res.json().then( function ( e ) { throw new Error( e.message || e.error || res.status ); } );
|
||||
}
|
||||
return res.json();
|
||||
} );
|
||||
}
|
||||
|
||||
function modeBadgeClass( mode ) {
|
||||
return {
|
||||
disabled: 'wpdo-mode-disabled',
|
||||
dual_write: 'wpdo-mode-dual-write',
|
||||
shadow_read: 'wpdo-mode-shadow-read',
|
||||
aeav_only: 'wpdo-mode-aeav-only',
|
||||
}[ mode ] || 'wpdo-mode-disabled';
|
||||
}
|
||||
|
||||
function modeLabel( mode ) {
|
||||
return {
|
||||
disabled: 'disabled',
|
||||
dual_write: 'dual_write',
|
||||
shadow_read: 'shadow_read',
|
||||
aeav_only: 'aeav_only',
|
||||
}[ mode ] || mode;
|
||||
}
|
||||
|
||||
function pct( n ) {
|
||||
return Math.min( 100, Math.max( 0, parseFloat( n ) || 0 ) ).toFixed( 1 );
|
||||
}
|
||||
|
||||
// ─── Update UI from health data ──────────────────────────────────────────
|
||||
|
||||
function updateAll( data ) {
|
||||
var hasActive = false;
|
||||
|
||||
Object.keys( data ).forEach( function ( type ) {
|
||||
var card = document.querySelector( '[data-entity-type="' + type + '"]' );
|
||||
if ( ! card ) return;
|
||||
|
||||
var info = data[ type ];
|
||||
|
||||
// Mode badge
|
||||
var badge = card.querySelector( '.wpdo-mode-badge' );
|
||||
if ( badge ) {
|
||||
badge.textContent = modeLabel( info.mode );
|
||||
badge.className = 'wpdo-mode-badge ' + modeBadgeClass( info.mode );
|
||||
}
|
||||
|
||||
// Pipeline dots
|
||||
var pipeline = card.querySelector( '.wpdo-pipeline' );
|
||||
if ( pipeline ) {
|
||||
pipeline.innerHTML = buildPipeline( info.mode );
|
||||
}
|
||||
|
||||
// Mode days
|
||||
var daysEl = card.querySelector( '.wpdo-mode-days' );
|
||||
if ( daysEl ) {
|
||||
daysEl.textContent = info.mode_days + ' 天';
|
||||
}
|
||||
|
||||
// Groups
|
||||
( info.groups || [] ).forEach( function ( g ) {
|
||||
var groupEl = card.querySelector( '[data-group="' + g.name + '"]' );
|
||||
if ( ! groupEl ) return;
|
||||
|
||||
// Progress bar
|
||||
var bar = groupEl.querySelector( '.wpdo-cov-bar-fill' );
|
||||
if ( bar ) bar.style.width = pct( g.coverage_pct ) + '%';
|
||||
|
||||
var pctEl = groupEl.querySelector( '.wpdo-cov-pct' );
|
||||
if ( pctEl ) pctEl.textContent = pct( g.coverage_pct ) + '%';
|
||||
|
||||
var rowsEl = groupEl.querySelector( '.wpdo-cov-rows' );
|
||||
if ( rowsEl ) rowsEl.textContent = g.flat_rows + ' / ' + g.eav_rows;
|
||||
|
||||
// Migration badge
|
||||
var migBadge = groupEl.querySelector( '.wpdo-mig-status' );
|
||||
if ( migBadge ) {
|
||||
migBadge.textContent = g.migration_status;
|
||||
migBadge.className = 'wpdo-mig-status wpdo-mig-' + g.migration_status.replace( /_/g, '-' );
|
||||
}
|
||||
|
||||
// Is this group's backfill active?
|
||||
if ( g.migration_status === 'running' ) {
|
||||
hasActive = true;
|
||||
}
|
||||
} );
|
||||
|
||||
// Shadow diffs
|
||||
var diffsEl = card.querySelector( '.wpdo-shadow-diffs' );
|
||||
if ( diffsEl ) diffsEl.textContent = info.shadow_diffs;
|
||||
|
||||
// Recommendation
|
||||
var recEl = card.querySelector( '.wpdo-recommendation' );
|
||||
if ( recEl ) recEl.textContent = info.recommendation;
|
||||
|
||||
// Auto-promote eligible badge
|
||||
var apEl = card.querySelector( '.wpdo-auto-promote-eligible' );
|
||||
if ( apEl ) {
|
||||
if ( info.auto_promote && info.auto_promote.eligible ) {
|
||||
apEl.textContent = '✓ 可升級';
|
||||
apEl.style.color = '#155724';
|
||||
} else {
|
||||
apEl.textContent = '— ' + ( ( info.auto_promote || {} ).reason || '' );
|
||||
apEl.style.color = '#856404';
|
||||
}
|
||||
}
|
||||
|
||||
// Button states — update promote/demote targets
|
||||
var promoteBtn = card.querySelector( '.wpdo-btn-promote' );
|
||||
if ( promoteBtn ) {
|
||||
promoteBtn.disabled = ! info.next_mode;
|
||||
promoteBtn.dataset.nextMode = info.next_mode || '';
|
||||
promoteBtn.title = info.next_mode ? '升級到 ' + info.next_mode : '已在最高模式';
|
||||
}
|
||||
|
||||
var demoteBtn = card.querySelector( '.wpdo-btn-demote' );
|
||||
if ( demoteBtn ) {
|
||||
demoteBtn.disabled = ! info.prev_mode;
|
||||
demoteBtn.dataset.prevMode = info.prev_mode || '';
|
||||
demoteBtn.title = info.prev_mode ? '降級到 ' + info.prev_mode : '已在最低模式';
|
||||
}
|
||||
|
||||
// Backfill active indicator
|
||||
if ( info.backfill_active ) hasActive = true;
|
||||
} );
|
||||
|
||||
// Manage poll timer
|
||||
if ( hasActive ) {
|
||||
startPolling();
|
||||
}
|
||||
}
|
||||
|
||||
function buildPipeline( currentMode ) {
|
||||
var modes = [ 'disabled', 'dual_write', 'shadow_read', 'aeav_only' ];
|
||||
var labels = { disabled: 'disabled', dual_write: 'dual_write', shadow_read: 'shadow_read', aeav_only: 'aeav_only' };
|
||||
return modes.map( function ( m ) {
|
||||
var active = m === currentMode ? ' wpdo-pipeline-active' : '';
|
||||
return '<span class="wpdo-pipeline-dot' + active + '" title="' + labels[ m ] + '">' +
|
||||
'<span class="wpdo-dot"></span>' +
|
||||
'<span class="wpdo-dot-label">' + labels[ m ] + '</span>' +
|
||||
'</span>';
|
||||
} ).join( '<span class="wpdo-pipeline-arrow">→</span>' );
|
||||
}
|
||||
|
||||
// ─── Polling ─────────────────────────────────────────────────────────────
|
||||
|
||||
function poll() {
|
||||
apiFetch( 'GET', '/entity-bridge/health' )
|
||||
.then( updateAll )
|
||||
.catch( function ( e ) { console.warn( '[WPDO] Health poll error:', e ); } );
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if ( timer ) return;
|
||||
timer = setInterval( poll, pollInterval );
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if ( timer ) { clearInterval( timer ); timer = null; }
|
||||
}
|
||||
|
||||
// ─── Button handlers ─────────────────────────────────────────────────────
|
||||
|
||||
function handleBackfill( btn ) {
|
||||
var entityType = btn.dataset.entityType;
|
||||
var groupName = btn.dataset.groupName;
|
||||
if ( ! confirm( '確定要啟動 ' + entityType + '/' + groupName + ' 的 Backfill 遷移嗎?這將清除現有進度並重新開始。' ) ) return;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '排程中…';
|
||||
|
||||
apiFetch( 'POST', '/entity-bridge/backfill', { entity_type: entityType, group_name: groupName } )
|
||||
.then( function () {
|
||||
btn.textContent = '已排程 ✓';
|
||||
startPolling();
|
||||
// Refresh once immediately
|
||||
setTimeout( poll, 1000 );
|
||||
} )
|
||||
.catch( function ( e ) {
|
||||
alert( '啟動 Backfill 失敗:' + e.message );
|
||||
btn.disabled = false;
|
||||
btn.textContent = '啟動 Backfill';
|
||||
} );
|
||||
}
|
||||
|
||||
function handlePromote( btn ) {
|
||||
var entityType = btn.dataset.entityType;
|
||||
var nextMode = btn.dataset.nextMode;
|
||||
if ( ! nextMode ) return;
|
||||
|
||||
if ( ! confirm( '確定要將 ' + entityType + ' 升級到 ' + nextMode + ' 嗎?' ) ) return;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '更新中…';
|
||||
|
||||
apiFetch( 'POST', '/entity-bridge/promote', { entity_type: entityType } )
|
||||
.then( function () {
|
||||
poll(); // immediate refresh
|
||||
} )
|
||||
.catch( function ( e ) {
|
||||
alert( '升級失敗:' + e.message );
|
||||
btn.disabled = false;
|
||||
btn.textContent = '升級模式';
|
||||
} );
|
||||
}
|
||||
|
||||
function handleDemote( btn ) {
|
||||
var entityType = btn.dataset.entityType;
|
||||
var prevMode = btn.dataset.prevMode;
|
||||
if ( ! prevMode ) return;
|
||||
|
||||
if ( ! confirm( '確定要將 ' + entityType + ' 降級到 ' + prevMode + ' 嗎?降級後讀取會切回 EAV。' ) ) return;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '更新中…';
|
||||
|
||||
apiFetch( 'POST', '/entity-bridge/demote', { entity_type: entityType } )
|
||||
.then( function () {
|
||||
poll();
|
||||
} )
|
||||
.catch( function ( e ) {
|
||||
alert( '降級失敗:' + e.message );
|
||||
btn.disabled = false;
|
||||
btn.textContent = '降級模式';
|
||||
} );
|
||||
}
|
||||
|
||||
// ─── Event delegation ────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener( 'click', function ( e ) {
|
||||
var btn = e.target.closest( 'button[data-wpdo-action]' );
|
||||
if ( ! btn ) return;
|
||||
|
||||
var action = btn.dataset.wpdoAction;
|
||||
|
||||
if ( action === 'backfill' ) {
|
||||
handleBackfill( btn );
|
||||
} else if ( action === 'promote' ) {
|
||||
handlePromote( btn );
|
||||
} else if ( action === 'demote' ) {
|
||||
handleDemote( btn );
|
||||
}
|
||||
} );
|
||||
|
||||
// ─── Init ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Initial poll on page load if we're on the entity-bridge tab.
|
||||
if ( document.querySelector( '.wpdo-entity-bridge-tab' ) ) {
|
||||
poll();
|
||||
|
||||
// Auto-start polling if any backfill is already running (check server state).
|
||||
// A subsequent poll() response will call startPolling() if needed.
|
||||
}
|
||||
|
||||
// Stop polling when navigating away.
|
||||
window.addEventListener( 'beforeunload', stopPolling );
|
||||
|
||||
} )();
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* WP Data Optimizer — Migration Wizard styling.
|
||||
*
|
||||
* Consumes the Morandi design system tokens (var(--color-*), var(--space-*),
|
||||
* var(--radius-*), var(--shadow-*), var(--ease-default), var(--duration-normal),
|
||||
* var(--font-family-display), var(--font-family-body)).
|
||||
*
|
||||
* @since 2.8.0
|
||||
*/
|
||||
|
||||
.wpdo-migration-wizard {
|
||||
max-width: 1100px;
|
||||
font-family: var(--font-family-body, system-ui);
|
||||
color: var(--color-ink, #2c2618);
|
||||
}
|
||||
|
||||
.wpdo-mw-header h2 {
|
||||
font-family: var(--font-family-display, serif);
|
||||
font-size: 1.6rem;
|
||||
margin-bottom: var(--space-2, 8px);
|
||||
}
|
||||
|
||||
.wpdo-mw-panel {
|
||||
background: var(--color-surface-card, #fbf7f0);
|
||||
border: 1px solid var(--color-border-soft, #e5dccd);
|
||||
border-radius: var(--radius-md, 12px);
|
||||
padding: var(--space-6, 24px);
|
||||
margin-bottom: var(--space-5, 20px);
|
||||
box-shadow: var(--shadow-sm, 0 1px 3px rgba(60, 40, 20, 0.06));
|
||||
}
|
||||
|
||||
.wpdo-mw-panel h3 {
|
||||
margin-top: 0;
|
||||
font-family: var(--font-family-display, serif);
|
||||
}
|
||||
|
||||
/* ── Pre-flight metrics grid ─────────────────────────────────── */
|
||||
.wpdo-mw-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: var(--space-3, 12px);
|
||||
margin-bottom: var(--space-4, 16px);
|
||||
}
|
||||
|
||||
.wpdo-mw-metric {
|
||||
background: var(--color-surface, #fff);
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
padding: var(--space-3, 12px) var(--space-4, 16px);
|
||||
border: 1px solid var(--color-border-soft, #e5dccd);
|
||||
}
|
||||
|
||||
.wpdo-mw-metric-primary {
|
||||
background: var(--color-accent-soft, #f4e9d4);
|
||||
border-color: var(--color-accent, #c9a55a);
|
||||
}
|
||||
|
||||
.wpdo-mw-metric-label {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-ink-soft, #6b5d44);
|
||||
margin-bottom: var(--space-1, 4px);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.wpdo-mw-metric-value {
|
||||
display: block;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-family-display, serif);
|
||||
}
|
||||
|
||||
.wpdo-mw-mode {
|
||||
font-size: 0.95rem;
|
||||
font-family: var(--font-family-body, monospace);
|
||||
padding: var(--space-1, 4px) var(--space-2, 8px);
|
||||
border-radius: var(--radius-pill, 999px);
|
||||
}
|
||||
|
||||
.wpdo-mw-mode-aeav_only { background: #d8efd8; color: #2c5a2c; }
|
||||
.wpdo-mw-mode-shadow_read { background: #fff3cd; color: #7c5e1e; }
|
||||
.wpdo-mw-mode-dual_write { background: #d8e4f0; color: #2c4a6a; }
|
||||
.wpdo-mw-mode-disabled { background: #eaeaea; color: #555; }
|
||||
|
||||
.wpdo-mw-zero { color: var(--color-ink-soft, #aaa); }
|
||||
|
||||
.wpdo-mw-groups summary {
|
||||
cursor: pointer;
|
||||
margin-bottom: var(--space-2, 8px);
|
||||
color: var(--color-ink-soft, #6b5d44);
|
||||
}
|
||||
|
||||
.wpdo-mw-groups table {
|
||||
margin-top: var(--space-2, 8px);
|
||||
}
|
||||
|
||||
/* ── Run panel ───────────────────────────────────────────────── */
|
||||
.wpdo-mw-nothing-todo {
|
||||
text-align: center;
|
||||
padding: var(--space-8, 32px);
|
||||
color: var(--color-ink-soft, #6b5d44);
|
||||
}
|
||||
|
||||
.wpdo-mw-nothing-todo h3 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: var(--space-3, 12px);
|
||||
}
|
||||
|
||||
.wpdo-mw-estimate {
|
||||
background: var(--color-surface, #fff);
|
||||
border-left: 3px solid var(--color-accent, #c9a55a);
|
||||
padding: var(--space-3, 12px) var(--space-4, 16px);
|
||||
margin: var(--space-4, 16px) 0;
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
}
|
||||
|
||||
.wpdo-mw-options {
|
||||
border: 1px solid var(--color-border-soft, #e5dccd);
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
padding: var(--space-4, 16px);
|
||||
margin: var(--space-4, 16px) 0;
|
||||
}
|
||||
|
||||
.wpdo-mw-options legend {
|
||||
padding: 0 var(--space-2, 8px);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wpdo-mw-options label {
|
||||
display: block;
|
||||
margin: var(--space-2, 8px) 0;
|
||||
cursor: pointer;
|
||||
transition: color var(--duration-normal, 200ms) var(--ease-default, ease);
|
||||
}
|
||||
|
||||
.wpdo-mw-options label:hover {
|
||||
color: var(--color-accent, #c9a55a);
|
||||
}
|
||||
|
||||
.wpdo-mw-confirm {
|
||||
background: var(--color-warn-soft, #fff3cd);
|
||||
border: 1px solid var(--color-warn, #d4a04f);
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
padding: var(--space-3, 12px) var(--space-4, 16px);
|
||||
margin: var(--space-4, 16px) 0;
|
||||
}
|
||||
|
||||
.wpdo-mw-confirm label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#wpdo-mw-start {
|
||||
min-height: 44px;
|
||||
font-size: 1.05rem;
|
||||
padding: var(--space-3, 12px) var(--space-6, 24px);
|
||||
border-radius: var(--radius-md, 12px);
|
||||
transition: transform var(--duration-normal, 200ms) var(--ease-default, ease);
|
||||
}
|
||||
|
||||
#wpdo-mw-start:not([disabled]):hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ── Progress panel ──────────────────────────────────────────── */
|
||||
.wpdo-mw-progress-bar {
|
||||
position: relative;
|
||||
background: var(--color-surface, #fff);
|
||||
border: 1px solid var(--color-border-soft, #e5dccd);
|
||||
border-radius: var(--radius-pill, 999px);
|
||||
height: 28px;
|
||||
overflow: hidden;
|
||||
margin: var(--space-4, 16px) 0;
|
||||
}
|
||||
|
||||
.wpdo-mw-progress-fill {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-accent, #c9a55a),
|
||||
var(--color-accent-strong, #b08c3a)
|
||||
);
|
||||
height: 100%;
|
||||
transition: width var(--duration-normal, 300ms) var(--ease-default, ease);
|
||||
}
|
||||
|
||||
.wpdo-mw-progress-pct {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-family-display, serif);
|
||||
mix-blend-mode: difference;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wpdo-mw-current {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: var(--space-3, 12px) 0;
|
||||
}
|
||||
|
||||
.wpdo-mw-current code {
|
||||
background: var(--color-surface, #fff);
|
||||
padding: var(--space-1, 4px) var(--space-2, 8px);
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
font-family: var(--font-family-body, monospace);
|
||||
}
|
||||
|
||||
.wpdo-mw-elapsed {
|
||||
font-family: var(--font-family-display, serif);
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-ink-soft, #6b5d44);
|
||||
min-width: 70px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.wpdo-mw-live-ratio {
|
||||
background: var(--color-accent-soft, #f4e9d4);
|
||||
padding: var(--space-2, 8px) var(--space-4, 16px);
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
margin: var(--space-3, 12px) 0;
|
||||
font-family: var(--font-family-display, serif);
|
||||
}
|
||||
|
||||
.wpdo-mw-log-wrap h4 {
|
||||
margin: var(--space-4, 16px) 0 var(--space-2, 8px);
|
||||
font-family: var(--font-family-display, serif);
|
||||
}
|
||||
|
||||
.wpdo-mw-log {
|
||||
background: var(--color-ink, #2c2618);
|
||||
color: var(--color-paper, #f4ecdb);
|
||||
padding: var(--space-4, 16px);
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
font-family: var(--font-family-body, monospace);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wpdo-mw-log-flash {
|
||||
animation: wpdo-mw-log-pulse var(--duration-normal, 350ms) var(--ease-default, ease);
|
||||
}
|
||||
|
||||
@keyframes wpdo-mw-log-pulse {
|
||||
0% { box-shadow: inset 0 0 0 0 var(--color-accent, #c9a55a); }
|
||||
30% { box-shadow: inset 0 0 30px -8px var(--color-accent, #c9a55a); }
|
||||
100% { box-shadow: inset 0 0 0 0 transparent; }
|
||||
}
|
||||
|
||||
.wpdo-mw-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2, 8px);
|
||||
margin-top: var(--space-4, 16px);
|
||||
}
|
||||
|
||||
.wpdo-mw-failed {
|
||||
color: var(--color-error, #b03d3d);
|
||||
}
|
||||
|
||||
/* ── Done panel ──────────────────────────────────────────────── */
|
||||
.wpdo-mw-done {
|
||||
background: var(--color-success-soft, #d8efd8);
|
||||
border-color: var(--color-success, #5a8c5a);
|
||||
}
|
||||
|
||||
.wpdo-mw-done-summary {
|
||||
margin: var(--space-4, 16px) 0;
|
||||
}
|
||||
|
||||
.wpdo-mw-done-line {
|
||||
padding: var(--space-2, 8px) 0;
|
||||
border-bottom: 1px dashed var(--color-border-soft, #e5dccd);
|
||||
}
|
||||
|
||||
.wpdo-mw-done-line:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.wpdo-mw-done-line span {
|
||||
display: inline-block;
|
||||
min-width: 100px;
|
||||
color: var(--color-ink-soft, #6b5d44);
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* WP Data Optimizer — One-click User Migration Wizard frontend.
|
||||
*
|
||||
* - Confirms options + checkbox before starting.
|
||||
* - Calls REST endpoints under /wp-json/wpdo/v1/migration/.
|
||||
* - Polls /status every 500ms while job is active.
|
||||
* - Streams log lines with fade-in; live-updates ratio + progress bar.
|
||||
*
|
||||
* @since 2.8.0
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const POLL_INTERVAL_MS = 500;
|
||||
const config = window.wpdoMigrationWizard || {};
|
||||
const i18n = config.i18n || {};
|
||||
const restUrl = (config.restUrl || '').replace(/\/$/, '');
|
||||
const nonce = config.nonce || '';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const root = document.querySelector('.wpdo-migration-wizard');
|
||||
if (!root) return;
|
||||
|
||||
const els = {
|
||||
runPanel: $('wpdo-mw-run-panel'),
|
||||
progressPanel: $('wpdo-mw-progress-panel'),
|
||||
donePanel: $('wpdo-mw-done-panel'),
|
||||
startBtn: $('wpdo-mw-start'),
|
||||
cancelBtn: $('wpdo-mw-cancel'),
|
||||
resumeBtn: $('wpdo-mw-resume'),
|
||||
resetBtn: $('wpdo-mw-reset'),
|
||||
confirmBox: $('wpdo-mw-confirm-backup'),
|
||||
optBackup: $('wpdo-mw-opt-backup'),
|
||||
optStrict: $('wpdo-mw-opt-strict'),
|
||||
opt24h: $('wpdo-mw-opt-24h'),
|
||||
optAsync: $('wpdo-mw-opt-async'),
|
||||
optDryRun: $('wpdo-mw-opt-dryrun'),
|
||||
progressFill: $('wpdo-mw-progress-fill'),
|
||||
progressPct: $('wpdo-mw-progress-pct'),
|
||||
progressTitle: $('wpdo-mw-progress-title'),
|
||||
phaseName: $('wpdo-mw-current-phase-name'),
|
||||
elapsed: $('wpdo-mw-elapsed'),
|
||||
liveRatio: $('wpdo-mw-live-ratio-text'),
|
||||
log: $('wpdo-mw-log'),
|
||||
ratio: $('wpdo-mw-ratio'),
|
||||
residue: $('wpdo-mw-residue'),
|
||||
usermeta: $('wpdo-mw-usermeta'),
|
||||
doneSummary: $('wpdo-mw-done-summary'),
|
||||
};
|
||||
|
||||
let pollTimer = null;
|
||||
let startedAt = 0;
|
||||
let elapsedTimer = null;
|
||||
|
||||
function show(panel) {
|
||||
[els.runPanel, els.progressPanel, els.donePanel].forEach((p) => {
|
||||
if (!p) return;
|
||||
if (p === panel) {
|
||||
p.removeAttribute('hidden');
|
||||
} else {
|
||||
p.setAttribute('hidden', '');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fetchJson(path, options = {}) {
|
||||
const url = restUrl + path;
|
||||
const opts = Object.assign(
|
||||
{
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-WP-Nonce': nonce, 'Content-Type': 'application/json' },
|
||||
},
|
||||
options
|
||||
);
|
||||
return fetch(url, opts).then(async (r) => {
|
||||
let body;
|
||||
try {
|
||||
body = await r.json();
|
||||
} catch (e) {
|
||||
body = null;
|
||||
}
|
||||
return { ok: r.ok, status: r.status, body };
|
||||
});
|
||||
}
|
||||
|
||||
// ── Start handling ───────────────────────────────────────────────────
|
||||
|
||||
if (els.confirmBox && els.startBtn) {
|
||||
els.confirmBox.addEventListener('change', () => {
|
||||
els.startBtn.disabled = !els.confirmBox.checked;
|
||||
});
|
||||
}
|
||||
|
||||
if (els.startBtn) {
|
||||
els.startBtn.addEventListener('click', () => {
|
||||
if (!confirm(i18n.confirmStart || 'Start migration?')) return;
|
||||
els.startBtn.disabled = true;
|
||||
els.startBtn.textContent = '…';
|
||||
|
||||
const opts = {
|
||||
auto_backup: els.optBackup ? !!els.optBackup.checked : true,
|
||||
verify_strict: els.optStrict ? !!els.optStrict.checked : true,
|
||||
verify_24h: els.opt24h ? !!els.opt24h.checked : false,
|
||||
force_async: els.optAsync ? !!els.optAsync.checked : false,
|
||||
dry_run: els.optDryRun ? !!els.optDryRun.checked : false,
|
||||
};
|
||||
|
||||
fetchJson('/migration/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(opts),
|
||||
}).then((res) => {
|
||||
if (!res.ok) {
|
||||
if (res.body && res.body.reason === 'nothing_to_do') {
|
||||
alert(i18n.nothingToDo);
|
||||
els.startBtn.disabled = false;
|
||||
els.startBtn.textContent = '🚀';
|
||||
return;
|
||||
}
|
||||
alert((res.body && res.body.error) || 'Start failed');
|
||||
els.startBtn.disabled = false;
|
||||
els.startBtn.textContent = '🚀';
|
||||
return;
|
||||
}
|
||||
startedAt = Date.now();
|
||||
show(els.progressPanel);
|
||||
startElapsedTimer();
|
||||
startPolling();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Cancel & Resume ──────────────────────────────────────────────────
|
||||
|
||||
if (els.cancelBtn) {
|
||||
els.cancelBtn.addEventListener('click', () => {
|
||||
if (!confirm(i18n.confirmCancel || 'Cancel?')) return;
|
||||
els.cancelBtn.disabled = true;
|
||||
fetchJson('/migration/cancel', { method: 'POST' }).then(() => {
|
||||
stopPolling();
|
||||
stopElapsedTimer();
|
||||
setTimeout(() => location.reload(), 600);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (els.resumeBtn) {
|
||||
els.resumeBtn.addEventListener('click', () => {
|
||||
els.resumeBtn.disabled = true;
|
||||
fetchJson('/migration/resume', { method: 'POST' }).then((res) => {
|
||||
if (res.ok) {
|
||||
els.resumeBtn.setAttribute('hidden', '');
|
||||
els.cancelBtn.disabled = false;
|
||||
startPolling();
|
||||
} else {
|
||||
alert((res.body && res.body.error) || 'Resume failed');
|
||||
els.resumeBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (els.resetBtn) {
|
||||
els.resetBtn.addEventListener('click', () => location.reload());
|
||||
}
|
||||
|
||||
// ── Polling ──────────────────────────────────────────────────────────
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer) return;
|
||||
const tick = () => {
|
||||
fetchJson('/migration/status').then((res) => {
|
||||
if (!res.ok || !res.body) {
|
||||
pollTimer = setTimeout(tick, POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
renderStatus(res.body);
|
||||
const state = res.body.state;
|
||||
if (state === 'running') {
|
||||
pollTimer = setTimeout(tick, POLL_INTERVAL_MS);
|
||||
} else if (state === 'completed') {
|
||||
stopPolling();
|
||||
stopElapsedTimer();
|
||||
renderDone(res.body);
|
||||
} else if (state === 'failed') {
|
||||
stopPolling();
|
||||
stopElapsedTimer();
|
||||
renderFailed(res.body);
|
||||
} else if (state === 'cancelled' || state === 'idle') {
|
||||
stopPolling();
|
||||
stopElapsedTimer();
|
||||
}
|
||||
});
|
||||
};
|
||||
tick();
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startElapsedTimer() {
|
||||
if (elapsedTimer) return;
|
||||
elapsedTimer = setInterval(() => {
|
||||
if (!els.elapsed) return;
|
||||
const sec = (Date.now() - startedAt) / 1000;
|
||||
els.elapsed.textContent = sec.toFixed(1) + 's';
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function stopElapsedTimer() {
|
||||
if (elapsedTimer) {
|
||||
clearInterval(elapsedTimer);
|
||||
elapsedTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────
|
||||
|
||||
function renderStatus(s) {
|
||||
if (els.progressFill) {
|
||||
els.progressFill.style.width = (s.overall_progress || 0) + '%';
|
||||
}
|
||||
if (els.progressPct) {
|
||||
els.progressPct.textContent = (s.overall_progress || 0) + '%';
|
||||
}
|
||||
if (els.phaseName) {
|
||||
els.phaseName.textContent = s.phase || '';
|
||||
}
|
||||
if (els.liveRatio && s.metrics) {
|
||||
els.liveRatio.textContent =
|
||||
'1:' + (s.metrics.ratio_start || '?') +
|
||||
' → 1:' + (s.metrics.ratio_now || '?');
|
||||
}
|
||||
// Update top metrics live too
|
||||
if (els.ratio && s.metrics && s.metrics.ratio_now != null) {
|
||||
els.ratio.textContent = '1:' + s.metrics.ratio_now;
|
||||
}
|
||||
if (els.residue && s.metrics && s.metrics.eav_rows_now != null) {
|
||||
els.residue.textContent = String(s.metrics.eav_rows_now);
|
||||
}
|
||||
updateLog(s.log || []);
|
||||
}
|
||||
|
||||
let lastLogLength = 0;
|
||||
function updateLog(lines) {
|
||||
if (!els.log) return;
|
||||
if (lines.length === lastLogLength) return;
|
||||
els.log.textContent = lines.join('\n');
|
||||
els.log.scrollTop = els.log.scrollHeight;
|
||||
lastLogLength = lines.length;
|
||||
// Apply a subtle highlight on the last line via CSS animation
|
||||
els.log.classList.remove('wpdo-mw-log-flash');
|
||||
// Force reflow so re-adding the class re-triggers the animation
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
void els.log.offsetWidth;
|
||||
els.log.classList.add('wpdo-mw-log-flash');
|
||||
}
|
||||
|
||||
function renderDone(s) {
|
||||
show(els.donePanel);
|
||||
if (!els.doneSummary) return;
|
||||
const m = s.metrics || {};
|
||||
const elapsed = ((s.completed_at || 0) - (s.started_at || 0));
|
||||
els.doneSummary.innerHTML = '';
|
||||
const lines = [
|
||||
['ratio', '1:' + (m.ratio_start || '?') + ' → 1:' + (m.ratio_now || '?')],
|
||||
['EAV 殘留', (m.eav_rows_start || 0) + ' → ' + (m.eav_rows_now || 0)],
|
||||
['mode', (m.mode_start || '?') + ' → aeav_only'],
|
||||
['耗時', elapsed + ' 秒'],
|
||||
['備份', s.backup_path || '(無)'],
|
||||
];
|
||||
lines.forEach(([label, value]) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'wpdo-mw-done-line';
|
||||
const lab = document.createElement('span'); lab.textContent = label + ':';
|
||||
const val = document.createElement('strong'); val.textContent = value;
|
||||
div.append(lab, val);
|
||||
els.doneSummary.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function renderFailed(s) {
|
||||
els.progressTitle.textContent = '✗ 失敗 — ' + (i18n.failedRetry || '請 Resume 或 Cancel');
|
||||
els.progressTitle.classList.add('wpdo-mw-failed');
|
||||
if (els.resumeBtn) {
|
||||
els.resumeBtn.removeAttribute('hidden');
|
||||
els.resumeBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── On-load: if a job is already running, hop into progress mode ────
|
||||
const initialState = root.getAttribute('data-state');
|
||||
if (initialState === 'running' || initialState === 'paused') {
|
||||
startedAt = Date.now();
|
||||
show(els.progressPanel);
|
||||
startElapsedTimer();
|
||||
startPolling();
|
||||
} else if (initialState === 'failed') {
|
||||
show(els.progressPanel);
|
||||
renderFailed({ state: 'failed' });
|
||||
} else if (initialState === 'completed') {
|
||||
// Already shown via PHP hidden flag — but populate summary if available
|
||||
fetchJson('/migration/status').then((res) => {
|
||||
if (res.ok && res.body && res.body.state === 'completed') {
|
||||
renderDone(res.body);
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* WPDO Post Stress Test — admin tab JS (v2.11.4)
|
||||
*
|
||||
* Mirrors wpdo-stress-test.js (user side) but:
|
||||
* - Talks to /wpdo/v1/post-stress-test/* endpoints
|
||||
* - Sends post_type in start payload (user side has no entity selector)
|
||||
* - DOM IDs prefixed wpdo-pst-* to coexist on the same admin page
|
||||
* - Renders a smaller benchmark report (3 probes per post_type vs 6 for user)
|
||||
*
|
||||
* @since 2.11.4
|
||||
*/
|
||||
( function () {
|
||||
'use strict';
|
||||
|
||||
const cfg = window.wpdoPostStressTest;
|
||||
if ( ! cfg || ! cfg.restUrl ) {
|
||||
return;
|
||||
}
|
||||
if ( ! document.querySelector( '.wpdo-post-stress-test-tab' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $ = ( sel ) => document.querySelector( sel );
|
||||
const restUrl = cfg.restUrl.replace( /\/$/, '' );
|
||||
const headers = { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce };
|
||||
|
||||
let pollTimer = null;
|
||||
|
||||
// ── API helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
async function apiCall( path, method = 'GET', body = null ) {
|
||||
const opts = { method, headers, credentials: 'same-origin' };
|
||||
if ( body ) {
|
||||
opts.body = JSON.stringify( body );
|
||||
}
|
||||
const resp = await fetch( restUrl + path, opts );
|
||||
const text = await resp.text();
|
||||
try {
|
||||
return { ok: resp.ok, status: resp.status, data: text ? JSON.parse( text ) : null };
|
||||
} catch ( e ) {
|
||||
return { ok: false, status: resp.status, data: { error: text } };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────────────
|
||||
|
||||
function fmt( n ) {
|
||||
if ( n === null || n === undefined ) {
|
||||
return '—';
|
||||
}
|
||||
return Number( n ).toLocaleString();
|
||||
}
|
||||
|
||||
function setText( sel, text ) {
|
||||
const el = $( sel );
|
||||
if ( el ) {
|
||||
el.textContent = String( text );
|
||||
}
|
||||
}
|
||||
|
||||
function esc( s ) {
|
||||
if ( s === null || s === undefined ) {
|
||||
return '';
|
||||
}
|
||||
return String( s ).replace( /[&<>"']/g, ( c ) => ( {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[ c ] ) );
|
||||
}
|
||||
|
||||
function renderProgress( state ) {
|
||||
const isRunning = state.status === 'running' || state.status === 'benchmarking';
|
||||
const card = $( '#wpdo-pst-progress-card' );
|
||||
if ( card ) {
|
||||
card.style.display = ( isRunning || state.status === 'completed' || state.status === 'failed' || state.status === 'cancelled' ) ? '' : 'none';
|
||||
}
|
||||
setText( '#wpdo-pst-pg-status', state.status || 'idle' );
|
||||
setText( '#wpdo-pst-pg-post-type', state.post_type || '' );
|
||||
setText( '#wpdo-pst-pg-mode', state.mode || '' );
|
||||
setText( '#wpdo-pst-pg-pct', ( state.pct || 0 ) + '%' );
|
||||
setText( '#wpdo-pst-pg-processed', fmt( state.processed || 0 ) );
|
||||
setText( '#wpdo-pst-pg-target', fmt( state.target || 0 ) );
|
||||
setText( '#wpdo-pst-pg-rate', state.rate_per_sec || 0 );
|
||||
setText( '#wpdo-pst-pg-elapsed', state.elapsed_sec || 0 );
|
||||
setText( '#wpdo-pst-pg-eta', state.eta_sec || 0 );
|
||||
setText( '#wpdo-pst-pg-batches', state.batches_done || 0 );
|
||||
setText( '#wpdo-pst-pg-mem', ( ( state.peak_memory || 0 ) / 1048576 ).toFixed( 1 ) );
|
||||
|
||||
const bar = $( '#wpdo-pst-pg-bar' );
|
||||
if ( bar ) {
|
||||
bar.style.width = ( state.pct || 0 ) + '%';
|
||||
}
|
||||
|
||||
const count = state.test_post_count || 0;
|
||||
setText( '#wpdo-pst-count', fmt( count ) );
|
||||
setText( '#wpdo-pst-count-mirror', fmt( count ) );
|
||||
|
||||
const startBtn = $( '#wpdo-pst-start' );
|
||||
const cancelBtn = $( '#wpdo-pst-cancel' );
|
||||
const cleanupBtn = $( '#wpdo-pst-cleanup' );
|
||||
const benchBtn = $( '#wpdo-pst-rerun-bench' );
|
||||
if ( startBtn ) {
|
||||
startBtn.disabled = isRunning;
|
||||
}
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.disabled = ! isRunning;
|
||||
}
|
||||
if ( cleanupBtn ) {
|
||||
cleanupBtn.disabled = isRunning || count === 0;
|
||||
}
|
||||
if ( benchBtn ) {
|
||||
benchBtn.disabled = isRunning || count === 0;
|
||||
}
|
||||
}
|
||||
|
||||
function renderBenchmark( bench ) {
|
||||
if ( ! bench ) {
|
||||
return;
|
||||
}
|
||||
const card = $( '#wpdo-pst-bench-card' );
|
||||
const content = $( '#wpdo-pst-bench-content' );
|
||||
if ( ! card || ! content ) {
|
||||
return;
|
||||
}
|
||||
card.style.display = '';
|
||||
|
||||
const w = bench.write || {};
|
||||
const dbSizes = bench.db_sizes || [];
|
||||
const q = bench.query || {};
|
||||
|
||||
// Friendly labels for the per-post_type query probes
|
||||
const labels = {
|
||||
point_stock_status: 'Point lookup (_stock_status)',
|
||||
point_status: 'Point lookup (hp_status)',
|
||||
point_verified: 'Point lookup (hp_verified)',
|
||||
point_alt_present: 'Point lookup (alt 文字)',
|
||||
point_type: 'Point lookup (_menu_item_type)',
|
||||
point_thumbnail: 'Point lookup (_thumbnail_id)',
|
||||
range_price_above: 'Range scan (price > 100)',
|
||||
range_budget_above: 'Range scan (budget > 100)',
|
||||
range_rate_above: 'Range scan (hourly_rate > 50)',
|
||||
range_id_above: 'Range scan (post_id 排序)',
|
||||
eav_baseline: 'EAV baseline (wp_postmeta 直查)',
|
||||
};
|
||||
|
||||
let html = '';
|
||||
// Write metrics
|
||||
html += '<h4 style="margin-bottom:6px;">▍ 寫入指標</h4>';
|
||||
html += '<table class="widefat" style="margin-bottom:14px;"><tbody>';
|
||||
html += `<tr><td>模式</td><td><code>${ esc( w.mode ) }</code></td></tr>`;
|
||||
html += `<tr><td>Post Type</td><td><code>${ esc( w.post_type ) }</code></td></tr>`;
|
||||
html += `<tr><td>完成 / 目標</td><td>${ fmt( w.processed ) } / ${ fmt( w.target ) }</td></tr>`;
|
||||
html += `<tr><td>總耗時</td><td>${ fmt( w.elapsed_sec ) } 秒</td></tr>`;
|
||||
html += `<tr><td>平均速率</td><td><strong>${ fmt( w.rate_per_sec ) }</strong> posts/sec</td></tr>`;
|
||||
html += `<tr><td>批次數</td><td>${ fmt( w.batches_done ) }</td></tr>`;
|
||||
html += `<tr><td>批次最快/平均/最慢</td><td>${ fmt( w.batch_min_ms ) } / ${ fmt( w.batch_avg_ms ) } / ${ fmt( w.batch_max_ms ) } ms</td></tr>`;
|
||||
html += `<tr><td>PHP Peak Memory</td><td>${ fmt( w.peak_memory_mb ) } MB</td></tr>`;
|
||||
html += '</tbody></table>';
|
||||
|
||||
// DB sizes (wp_posts + wp_postmeta + flat table for this run)
|
||||
html += '<h4 style="margin-bottom:6px;">▍ DB 容量(post 相關表)</h4>';
|
||||
html += '<table class="widefat striped" style="margin-bottom:14px;"><thead><tr>';
|
||||
html += '<th>Table</th><th>Rows</th><th>Data MB</th><th>Index MB</th><th>Total MB</th><th>Avg bytes/row</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
dbSizes.forEach( ( r ) => {
|
||||
html += `<tr><td><code>${ esc( r.table ) }</code></td><td>${ fmt( r.rows ) }</td>`;
|
||||
html += `<td>${ r.data_mb ?? '—' }</td><td>${ r.index_mb ?? '—' }</td>`;
|
||||
html += `<td><strong>${ r.total_mb ?? '—' }</strong></td><td>${ fmt( r.avg_bytes ) }</td></tr>`;
|
||||
} );
|
||||
html += '</tbody></table>';
|
||||
|
||||
// Query perf — 3 probes per post_type, with EAV baseline last for visual comparison
|
||||
html += '<h4 style="margin-bottom:6px;">▍ 查詢效能</h4>';
|
||||
html += '<table class="widefat striped" style="margin-bottom:8px;"><thead><tr>';
|
||||
html += '<th>測試項目</th><th>耗時 (ms)</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
const baselineMs = q.eav_baseline?.duration_ms ?? null;
|
||||
Object.keys( q ).forEach( ( key ) => {
|
||||
const v = q[ key ];
|
||||
if ( ! v || typeof v.duration_ms !== 'number' ) {
|
||||
return;
|
||||
}
|
||||
const label = labels[ key ] || key;
|
||||
let speedup = '';
|
||||
if ( baselineMs !== null && key !== 'eav_baseline' && v.duration_ms > 0 ) {
|
||||
const ratio = baselineMs / v.duration_ms;
|
||||
if ( ratio >= 1 ) {
|
||||
speedup = ` <span style="color:#28a745;font-weight:600;">(${ ratio.toFixed( 2 ) }× faster)</span>`;
|
||||
}
|
||||
}
|
||||
html += `<tr><td>${ esc( label ) }${ speedup }</td><td><strong>${ v.duration_ms }</strong></td></tr>`;
|
||||
} );
|
||||
html += '</tbody></table>';
|
||||
html += '<p class="description">EAV baseline 走 wp_postmeta,flat probes 走專屬 group 表。倍率即此規模下反 EAV 的查詢加速。</p>';
|
||||
|
||||
content.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Polling ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function poll() {
|
||||
const r = await apiCall( '/post-stress-test/status' );
|
||||
if ( ! r.ok || ! r.data ) {
|
||||
return;
|
||||
}
|
||||
renderProgress( r.data );
|
||||
if ( r.data.benchmark ) {
|
||||
renderBenchmark( r.data.benchmark );
|
||||
}
|
||||
if ( r.data.status !== 'running' && r.data.status !== 'benchmarking' ) {
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
poll();
|
||||
pollTimer = setInterval( poll, 2000 );
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if ( pollTimer ) {
|
||||
clearInterval( pollTimer );
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event handlers ──────────────────────────────────────────────────────
|
||||
|
||||
async function handleStart() {
|
||||
const postType = $( '#wpdo-pst-post-type' ).value;
|
||||
const target = parseInt( $( '#wpdo-pst-target' ).value, 10 );
|
||||
const batch = parseInt( $( '#wpdo-pst-batch' ).value, 10 );
|
||||
const mode = document.querySelector( 'input[name="wpdo-pst-mode"]:checked' ).value;
|
||||
|
||||
if ( ! postType ) {
|
||||
alert( '請選擇 post_type' );
|
||||
return;
|
||||
}
|
||||
if ( ! target || target < 1 ) {
|
||||
alert( '請輸入有效的 post 數量' );
|
||||
return;
|
||||
}
|
||||
// Realistic 模式每 post 100-300ms,batch 太大會撞 nginx 60s timeout 之前的 8s deadline
|
||||
if ( mode === 'realistic' && batch > 30 ) {
|
||||
if ( ! confirm( `Realistic 模式每個 post 約需 100-300 ms,batch_size=${ batch } 可能超過後端 8s deadline 上限。建議使用 batch=10-20。要繼續嗎?` ) ) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Soft warn for combinations that won't demonstrate反 EAV 優化效果
|
||||
const postMode = String( cfg.postMode || 'disabled' );
|
||||
if ( mode === 'fast' && postMode === 'aeav_only' ) {
|
||||
if ( ! confirm( `⚠️ Fast 模式直接 $wpdb->insert 繞過 Hook Bus,即使 post mode=aeav_only 也會寫滿 wp_postmeta(fixture 用途,非優化驗證)。\n\n如果你想驗證反 EAV 優化效果(wp_postmeta 應為 0),請改用 🐢 Realistic 模式。\n\n仍以 Fast 模式繼續嗎?` ) ) {
|
||||
return;
|
||||
}
|
||||
} else if ( mode === 'realistic' && postMode !== 'aeav_only' ) {
|
||||
if ( ! confirm( `⚠️ 目前 post mode = ${ postMode },此模式下 Realistic 寫入仍會雙寫 wp_postmeta(不會展示優化效果,ratio 不變)。\n\n要看 wp_postmeta 完全短路(0 寫入)需要先把 mode 升到 aeav_only(設定 tab)。\n\n仍以 ${ postMode } 模式繼續測試嗎?` ) ) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const big = target >= 5000;
|
||||
const msg = `即將以 ${ mode } 模式建立 ${ target.toLocaleString() } 筆 ${ postType } 測試 post${ big ? '(規模較大,可能耗時數分鐘)' : '' }。\n\n所有 post 的 post_title 會以 WPDO_STRESS_TEST_ 開頭,可一鍵清除。\n\n確定要開始嗎?`;
|
||||
if ( ! confirm( msg ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const r = await apiCall( '/post-stress-test/start', 'POST', {
|
||||
post_type: postType,
|
||||
target,
|
||||
mode,
|
||||
batch_size: batch,
|
||||
} );
|
||||
if ( ! r.ok ) {
|
||||
alert( '啟動失敗:' + ( r.data?.error || r.status ) );
|
||||
return;
|
||||
}
|
||||
// 啟動後立即顯示進度卡片
|
||||
const card = $( '#wpdo-pst-progress-card' );
|
||||
if ( card ) {
|
||||
card.style.display = '';
|
||||
}
|
||||
// Hide the previous benchmark card (a fresh run will replace it)
|
||||
const benchCard = $( '#wpdo-pst-bench-card' );
|
||||
if ( benchCard ) {
|
||||
benchCard.style.display = 'none';
|
||||
}
|
||||
setText( '#wpdo-pst-pg-status', 'running' );
|
||||
setText( '#wpdo-pst-pg-post-type', postType );
|
||||
setText( '#wpdo-pst-pg-mode', mode );
|
||||
setText( '#wpdo-pst-pg-target', target.toLocaleString() );
|
||||
startPolling();
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if ( ! confirm( '確定要取消當前測試?已建立的 post 不會被刪除。' ) ) {
|
||||
return;
|
||||
}
|
||||
const cancelBtn = $( '#wpdo-pst-cancel' );
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.disabled = true;
|
||||
cancelBtn.textContent = '⏹ 取消中…';
|
||||
}
|
||||
setText( '#wpdo-pst-pg-status', 'cancelling' );
|
||||
|
||||
const r = await apiCall( '/post-stress-test/cancel', 'POST' );
|
||||
if ( ! r.ok ) {
|
||||
alert( '取消失敗:' + ( r.data?.error || r.status ) );
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.disabled = false;
|
||||
cancelBtn.textContent = '⏹ 取消';
|
||||
}
|
||||
return;
|
||||
}
|
||||
// realistic 模式 in-flight batch 可能還要 ~1 秒才會真正中止
|
||||
poll();
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.textContent = '⏹ 取消';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCleanup() {
|
||||
if ( ! confirm( '確定要清除所有 stress test posts?\n\n此動作會:\n- DELETE 所有 post_title 前綴 WPDO_STRESS_TEST_ 的 post\n- DELETE 對應 wp_postmeta\n- DELETE 7 張 wp_wpdo_post_* flat tables 中對應 post_id 的列\n\n不可復原!' ) ) {
|
||||
return;
|
||||
}
|
||||
const r = await apiCall( '/post-stress-test/cleanup', 'DELETE' );
|
||||
if ( ! r.ok ) {
|
||||
alert( '清除失敗:' + ( r.data?.error || r.status ) );
|
||||
return;
|
||||
}
|
||||
alert( `已清除 ${ r.data.deleted } 筆測試 post。` );
|
||||
const card = $( '#wpdo-pst-progress-card' );
|
||||
const benchCard = $( '#wpdo-pst-bench-card' );
|
||||
if ( card ) card.style.display = 'none';
|
||||
if ( benchCard ) benchCard.style.display = 'none';
|
||||
poll();
|
||||
}
|
||||
|
||||
async function handleRerunBench() {
|
||||
const btn = $( '#wpdo-pst-rerun-bench' );
|
||||
if ( btn ) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ 執行中...';
|
||||
}
|
||||
const r = await apiCall( '/post-stress-test/benchmark', 'POST' );
|
||||
if ( btn ) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '📊 重跑 Benchmark(不新增資料)';
|
||||
}
|
||||
if ( ! r.ok ) {
|
||||
alert( 'Benchmark 失敗:' + ( r.data?.error || r.status ) );
|
||||
return;
|
||||
}
|
||||
renderBenchmark( r.data.benchmark );
|
||||
}
|
||||
|
||||
// ── Init ────────────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener( 'DOMContentLoaded', () => {
|
||||
const startBtn = $( '#wpdo-pst-start' );
|
||||
const cancelBtn = $( '#wpdo-pst-cancel' );
|
||||
const cleanupBtn = $( '#wpdo-pst-cleanup' );
|
||||
const benchBtn = $( '#wpdo-pst-rerun-bench' );
|
||||
|
||||
if ( startBtn ) startBtn.addEventListener( 'click', handleStart );
|
||||
if ( cancelBtn ) cancelBtn.addEventListener( 'click', handleCancel );
|
||||
if ( cleanupBtn ) cleanupBtn.addEventListener( 'click', handleCleanup );
|
||||
if ( benchBtn ) benchBtn.addEventListener( 'click', handleRerunBench );
|
||||
|
||||
// 初始 poll:若 status==running 自動接手 polling;若 completed 渲染 benchmark
|
||||
poll().then( () => {
|
||||
const status = ( $( '#wpdo-pst-pg-status' )?.textContent || '' ).trim();
|
||||
if ( status === 'running' || status === 'benchmarking' ) {
|
||||
startPolling();
|
||||
}
|
||||
} );
|
||||
} );
|
||||
} )();
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* WP Data Optimizer — REST API JavaScript SDK
|
||||
*
|
||||
* Lightweight client for /wp-json/wpdo/v1/ endpoints.
|
||||
* No dependencies required.
|
||||
*
|
||||
* Usage:
|
||||
* const wpdo = new WpdoClient();
|
||||
*
|
||||
* // Fetch listings (Zone A)
|
||||
* const { items, total } = await wpdo.getListings({ hp_price_min: 100, per_page: 20 });
|
||||
*
|
||||
* // Single listing (Zone A + Zone C)
|
||||
* const listing = await wpdo.getListing(123);
|
||||
*
|
||||
* // View count (Zone B)
|
||||
* const { view_count } = await wpdo.getStats(123);
|
||||
*
|
||||
* // Increment view count (Zone B, requires WP REST nonce)
|
||||
* const { view_count: updated } = await wpdo.incrementView(123);
|
||||
*
|
||||
* // Iterate all pages
|
||||
* for await (const page of wpdo.paginateListings({ hp_featured: 1 })) {
|
||||
* console.log(page.items);
|
||||
* }
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
/* global wpdo_sdk_config */
|
||||
|
||||
( function ( global ) {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* WpdoClient — REST API wrapper for WP Data Optimizer.
|
||||
*
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.baseUrl] REST base URL (default: auto-detected from wpdo_sdk_config or /wp-json)
|
||||
* @param {string} [options.nonce] WP REST nonce for authenticated requests
|
||||
* @param {string} [options.postType] Default post type (default: 'hp_listing')
|
||||
*/
|
||||
function WpdoClient( options ) {
|
||||
options = options || {};
|
||||
|
||||
var config = ( typeof wpdo_sdk_config !== 'undefined' ) ? wpdo_sdk_config : {};
|
||||
|
||||
this._base = options.baseUrl || config.rest_url || '/wp-json/wpdo/v1';
|
||||
this._nonce = options.nonce || config.nonce || '';
|
||||
this._postType = options.postType || config.post_type || 'hp_listing';
|
||||
}
|
||||
|
||||
// ── Core fetch ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Internal GET fetch helper.
|
||||
*
|
||||
* @param {string} path Relative path (e.g. '/listings')
|
||||
* @param {object} params Query parameters
|
||||
* @return {Promise<{data: *, headers: Headers, status: number}>}
|
||||
*/
|
||||
WpdoClient.prototype._fetch = function ( path, params ) {
|
||||
var url = this._base + path;
|
||||
|
||||
if ( params && Object.keys( params ).length ) {
|
||||
var qs = Object.keys( params )
|
||||
.filter(
|
||||
function ( k ) {
|
||||
return params[ k ] !== null && params[ k ] !== undefined && params[ k ] !== ''; }
|
||||
)
|
||||
.map(
|
||||
function ( k ) {
|
||||
return encodeURIComponent( k ) + '=' + encodeURIComponent( params[ k ] ); }
|
||||
)
|
||||
.join( '&' );
|
||||
if ( qs ) {
|
||||
url += '?' + qs;
|
||||
}
|
||||
}
|
||||
|
||||
var headers = { 'Content-Type': 'application/json' };
|
||||
if ( this._nonce ) {
|
||||
headers[ 'X-WP-Nonce' ] = this._nonce;
|
||||
}
|
||||
|
||||
return fetch( url, { headers: headers } ).then(
|
||||
function ( res ) {
|
||||
return res.json().then(
|
||||
function ( data ) {
|
||||
return { data: data, headers: res.headers, status: res.status };
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Internal POST fetch helper.
|
||||
*
|
||||
* @param {string} path Relative path
|
||||
* @param {object} body JSON body (optional)
|
||||
* @return {Promise<{data: *, status: number}>}
|
||||
*/
|
||||
WpdoClient.prototype._post = function ( path, body ) {
|
||||
var headers = { 'Content-Type': 'application/json' };
|
||||
if ( this._nonce ) {
|
||||
headers[ 'X-WP-Nonce' ] = this._nonce;
|
||||
}
|
||||
|
||||
return fetch(
|
||||
this._base + path,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: body ? JSON.stringify( body ) : null,
|
||||
}
|
||||
).then(
|
||||
function ( res ) {
|
||||
return res.json().then(
|
||||
function ( data ) {
|
||||
return { data: data, status: res.status };
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch a page of listings from Zone A.
|
||||
*
|
||||
* @param {object} [params]
|
||||
* @param {string} [params.post_type] Default: configured post type
|
||||
* @param {number} [params.per_page] 1–100, default 20
|
||||
* @param {number} [params.page] Default 1
|
||||
* @param {string} [params.orderby] Column name, default 'post_id'
|
||||
* @param {string} [params.order] 'ASC' | 'DESC', default 'DESC'
|
||||
* @param {number} [params.*_min] Numeric range filter (e.g. hp_price_min)
|
||||
* @param {number} [params.*_max] Numeric range filter (e.g. hp_price_max)
|
||||
* @param {*} [params.*] Exact match filter (e.g. hp_featured: 1)
|
||||
* @return {Promise<{items: Array, total: number, totalPages: number}>}
|
||||
*/
|
||||
WpdoClient.prototype.getListings = function ( params ) {
|
||||
var merged = Object.assign( { post_type: this._postType }, params || {} );
|
||||
return this._fetch( '/listings', merged ).then(
|
||||
function ( res ) {
|
||||
return {
|
||||
items: res.data,
|
||||
total: parseInt( res.headers.get( 'X-WP-Total' ) || '0', 10 ),
|
||||
totalPages: parseInt( res.headers.get( 'X-WP-TotalPages' ) || '0', 10 ),
|
||||
status: res.status,
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch a single listing (Zone A + Zone C merged).
|
||||
*
|
||||
* @param {number} id Post ID
|
||||
* @return {Promise<object>}
|
||||
*/
|
||||
WpdoClient.prototype.getListing = function ( id ) {
|
||||
return this._fetch( '/listings/' + id, null ).then(
|
||||
function ( res ) {
|
||||
return res.data;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch Zone B view count for a post.
|
||||
*
|
||||
* @param {number} id Post ID
|
||||
* @return {Promise<{post_id: number, view_count: number}>}
|
||||
*/
|
||||
WpdoClient.prototype.getStats = function ( id ) {
|
||||
return this._fetch( '/stats/' + id, null ).then(
|
||||
function ( res ) {
|
||||
return res.data;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Increment Zone B view count for a post (requires WP REST nonce).
|
||||
*
|
||||
* The nonce must be passed via the `nonce` constructor option or
|
||||
* via `wpdo_sdk_config.nonce` (wp_create_nonce('wp_rest')).
|
||||
*
|
||||
* @param {number} id Post ID
|
||||
* @return {Promise<{post_id: number, view_count: number}>}
|
||||
*
|
||||
* Example:
|
||||
* // Auto-tracks a view when a listing page loads:
|
||||
* document.addEventListener('DOMContentLoaded', () => {
|
||||
* const postId = parseInt(document.body.dataset.postId);
|
||||
* if (postId) wpdo.incrementView(postId);
|
||||
* });
|
||||
*/
|
||||
WpdoClient.prototype.incrementView = function ( id ) {
|
||||
return this._post( '/listings/' + id + '/view', null ).then(
|
||||
function ( res ) {
|
||||
return res.data;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Async generator — iterates every page of listings.
|
||||
*
|
||||
* @param {object} [params] Same params as getListings (page is managed internally)
|
||||
* @yields {{items: Array, total: number, page: number, totalPages: number}}
|
||||
*
|
||||
* Example:
|
||||
* for await (const page of wpdo.paginateListings({ hp_featured: 1 })) {
|
||||
* page.items.forEach(item => console.log(item));
|
||||
* }
|
||||
*/
|
||||
WpdoClient.prototype.paginateListings = async function * ( params ) {
|
||||
var page = 1;
|
||||
var total = Infinity;
|
||||
var perPage = ( params && params.per_page ) ? params.per_page : 20;
|
||||
|
||||
while ( ( page - 1 ) * perPage < total ) {
|
||||
var merged = Object.assign( {}, params || {}, { page: page } );
|
||||
var result = await this.getListings( merged );
|
||||
total = result.total;
|
||||
yield { items: result.items, total: total, page: page, totalPages: result.totalPages };
|
||||
if ( page >= result.totalPages ) {
|
||||
break;
|
||||
}
|
||||
page++;
|
||||
}
|
||||
};
|
||||
|
||||
// ── Export ────────────────────────────────────────────────────────────────
|
||||
|
||||
global.WpdoClient = WpdoClient;
|
||||
|
||||
// Auto-instantiate as window.wpdo if config is present.
|
||||
if ( typeof wpdo_sdk_config !== 'undefined' ) {
|
||||
global.wpdo = new WpdoClient();
|
||||
}
|
||||
|
||||
}( window ) );
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* WPDO User Stress Test — admin tab JS
|
||||
*
|
||||
* - 2s polling while running/benchmarking
|
||||
* - Start / Cancel / Cleanup / Re-run benchmark
|
||||
* - Renders progress + benchmark report
|
||||
*
|
||||
* @since 2.6.7
|
||||
*/
|
||||
( function () {
|
||||
'use strict';
|
||||
|
||||
const cfg = window.wpdoStressTest;
|
||||
if ( ! cfg || ! cfg.restUrl ) {
|
||||
return;
|
||||
}
|
||||
if ( ! document.querySelector( '.wpdo-stress-test-tab' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $ = ( sel ) => document.querySelector( sel );
|
||||
const restUrl = cfg.restUrl.replace( /\/$/, '' );
|
||||
const headers = { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce };
|
||||
|
||||
let pollTimer = null;
|
||||
|
||||
// ── API helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
async function apiCall( path, method = 'GET', body = null ) {
|
||||
const opts = { method, headers, credentials: 'same-origin' };
|
||||
if ( body ) {
|
||||
opts.body = JSON.stringify( body );
|
||||
}
|
||||
const resp = await fetch( restUrl + path, opts );
|
||||
const text = await resp.text();
|
||||
try {
|
||||
return { ok: resp.ok, status: resp.status, data: text ? JSON.parse( text ) : null };
|
||||
} catch ( e ) {
|
||||
return { ok: false, status: resp.status, data: { error: text } };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────────────
|
||||
|
||||
function fmt( n ) {
|
||||
if ( n === null || n === undefined ) {
|
||||
return '—';
|
||||
}
|
||||
return Number( n ).toLocaleString();
|
||||
}
|
||||
|
||||
function renderProgress( state ) {
|
||||
const isRunning = state.status === 'running' || state.status === 'benchmarking';
|
||||
const card = $( '#wpdo-st-progress-card' );
|
||||
if ( card ) {
|
||||
card.style.display = ( isRunning || state.status === 'completed' || state.status === 'failed' || state.status === 'cancelled' ) ? '' : 'none';
|
||||
}
|
||||
setText( '#wpdo-st-pg-status', state.status || 'idle' );
|
||||
setText( '#wpdo-st-pg-mode', state.mode || '' );
|
||||
setText( '#wpdo-st-pg-pct', ( state.pct || 0 ) + '%' );
|
||||
setText( '#wpdo-st-pg-processed', fmt( state.processed || 0 ) );
|
||||
setText( '#wpdo-st-pg-target', fmt( state.target || 0 ) );
|
||||
setText( '#wpdo-st-pg-rate', state.rate_per_sec || 0 );
|
||||
setText( '#wpdo-st-pg-elapsed', state.elapsed_sec || 0 );
|
||||
setText( '#wpdo-st-pg-eta', state.eta_sec || 0 );
|
||||
setText( '#wpdo-st-pg-batches', state.batches_done || 0 );
|
||||
setText( '#wpdo-st-pg-mem', ( ( state.peak_memory || 0 ) / 1048576 ).toFixed( 1 ) );
|
||||
|
||||
const bar = $( '#wpdo-st-pg-bar' );
|
||||
if ( bar ) {
|
||||
bar.style.width = ( state.pct || 0 ) + '%';
|
||||
}
|
||||
|
||||
setText( '#wpdo-st-count', fmt( state.test_user_count || 0 ) );
|
||||
|
||||
const startBtn = $( '#wpdo-st-start' );
|
||||
const cancelBtn = $( '#wpdo-st-cancel' );
|
||||
const cleanupBtn = $( '#wpdo-st-cleanup' );
|
||||
const benchBtn = $( '#wpdo-st-rerun-bench' );
|
||||
if ( startBtn ) {
|
||||
startBtn.disabled = isRunning;
|
||||
}
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.disabled = ! isRunning;
|
||||
}
|
||||
if ( cleanupBtn ) {
|
||||
cleanupBtn.disabled = isRunning || ( state.test_user_count || 0 ) === 0;
|
||||
}
|
||||
if ( benchBtn ) {
|
||||
benchBtn.disabled = isRunning || ( state.test_user_count || 0 ) === 0;
|
||||
}
|
||||
}
|
||||
|
||||
function renderBenchmark( bench ) {
|
||||
if ( ! bench ) {
|
||||
return;
|
||||
}
|
||||
const card = $( '#wpdo-st-bench-card' );
|
||||
const content = $( '#wpdo-st-bench-content' );
|
||||
if ( ! card || ! content ) {
|
||||
return;
|
||||
}
|
||||
card.style.display = '';
|
||||
|
||||
const w = bench.write || {};
|
||||
const dbSizes = bench.db_sizes || [];
|
||||
const q = bench.query || {};
|
||||
|
||||
const labels = {
|
||||
get_field_membership_level: 'WPDO_API::get_field (membership_level) ×100',
|
||||
get_entity_full: 'WPDO_API::get_entity (整筆) ×100',
|
||||
range_gold_high_points: '索引範圍:gold + points>5000',
|
||||
sort_recent_active_100: '排序:last_active_at DESC LIMIT 100',
|
||||
join_top_gold_active: 'JOIN:top gold + active LIMIT 100',
|
||||
eav_range_baseline: '原生 EAV 等價查詢(baseline)',
|
||||
};
|
||||
|
||||
let html = '';
|
||||
// Write metrics
|
||||
html += '<h4 style="margin-bottom:6px;">▍ 寫入指標</h4>';
|
||||
html += '<table class="widefat" style="margin-bottom:14px;"><tbody>';
|
||||
html += `<tr><td>模式</td><td><code>${ esc( w.mode ) }</code></td></tr>`;
|
||||
html += `<tr><td>完成 / 目標</td><td>${ fmt( w.processed ) } / ${ fmt( w.target ) }</td></tr>`;
|
||||
html += `<tr><td>總耗時</td><td>${ fmt( w.elapsed_sec ) } 秒</td></tr>`;
|
||||
html += `<tr><td>平均速率</td><td><strong>${ fmt( w.rate_per_sec ) }</strong> users/sec</td></tr>`;
|
||||
html += `<tr><td>批次數</td><td>${ fmt( w.batches_done ) }</td></tr>`;
|
||||
html += `<tr><td>批次最快/平均/最慢</td><td>${ fmt( w.batch_min_ms ) } / ${ fmt( w.batch_avg_ms ) } / ${ fmt( w.batch_max_ms ) } ms</td></tr>`;
|
||||
html += `<tr><td>PHP Peak Memory</td><td>${ fmt( w.peak_memory_mb ) } MB</td></tr>`;
|
||||
html += '</tbody></table>';
|
||||
|
||||
// DB sizes
|
||||
html += '<h4 style="margin-bottom:6px;">▍ DB 容量(user 相關表)</h4>';
|
||||
html += '<table class="widefat striped" style="margin-bottom:14px;"><thead><tr>';
|
||||
html += '<th>Table</th><th>Rows</th><th>Data MB</th><th>Index MB</th><th>Total MB</th><th>Avg bytes/row</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
dbSizes.forEach( ( r ) => {
|
||||
html += `<tr><td><code>${ esc( r.table ) }</code></td><td>${ fmt( r.rows ) }</td>`;
|
||||
html += `<td>${ r.data_mb ?? '—' }</td><td>${ r.index_mb ?? '—' }</td>`;
|
||||
html += `<td><strong>${ r.total_mb ?? '—' }</strong></td><td>${ fmt( r.avg_bytes ) }</td></tr>`;
|
||||
} );
|
||||
html += '</tbody></table>';
|
||||
|
||||
// Query perf
|
||||
html += '<h4 style="margin-bottom:6px;">▍ 查詢效能</h4>';
|
||||
html += '<table class="widefat striped" style="margin-bottom:8px;"><thead><tr>';
|
||||
html += '<th>測試項目</th><th>樣本</th><th>總時間 (ms)</th><th>平均 (ms)</th><th>QPS</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
Object.keys( labels ).forEach( ( key ) => {
|
||||
const v = q[ key ];
|
||||
if ( ! v ) {
|
||||
return;
|
||||
}
|
||||
const total = v.total_ms ?? v.duration_ms ?? '—';
|
||||
html += `<tr><td>${ esc( labels[ key ] ) }</td><td>${ v.n || 1 }</td>`;
|
||||
html += `<td><strong>${ total }</strong></td><td>${ v.avg_ms ?? '—' }</td><td>${ v.qps ?? '—' }</td></tr>`;
|
||||
} );
|
||||
html += '</tbody></table>';
|
||||
html += '<p class="description">原生 EAV baseline 與 flat table 範圍查詢的時間差,即代表此規模下反 EAV 帶來的查詢加速倍數。</p>';
|
||||
|
||||
content.innerHTML = html;
|
||||
}
|
||||
|
||||
function setText( sel, text ) {
|
||||
const el = $( sel );
|
||||
if ( el ) {
|
||||
el.textContent = String( text );
|
||||
}
|
||||
}
|
||||
|
||||
function esc( s ) {
|
||||
if ( s === null || s === undefined ) {
|
||||
return '';
|
||||
}
|
||||
return String( s ).replace( /[&<>"']/g, ( c ) => ( {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[ c ] ) );
|
||||
}
|
||||
|
||||
// ── Polling ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function poll() {
|
||||
const r = await apiCall( '/stress-test/status' );
|
||||
if ( ! r.ok || ! r.data ) {
|
||||
return;
|
||||
}
|
||||
renderProgress( r.data );
|
||||
if ( r.data.benchmark ) {
|
||||
renderBenchmark( r.data.benchmark );
|
||||
}
|
||||
if ( r.data.status !== 'running' && r.data.status !== 'benchmarking' ) {
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
poll();
|
||||
pollTimer = setInterval( poll, 2000 );
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if ( pollTimer ) {
|
||||
clearInterval( pollTimer );
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event handlers ──────────────────────────────────────────────────────
|
||||
|
||||
async function handleStart() {
|
||||
const target = parseInt( $( '#wpdo-st-target' ).value, 10 );
|
||||
let batch = parseInt( $( '#wpdo-st-batch' ).value, 10 );
|
||||
const mode = document.querySelector( 'input[name="wpdo-st-mode"]:checked' ).value;
|
||||
|
||||
if ( ! target || target < 1 ) {
|
||||
alert( '請輸入有效的使用者數量' );
|
||||
return;
|
||||
}
|
||||
// Realistic 模式每 user ~3.5 秒,batch_size 太大會撞 nginx 60s timeout
|
||||
// 後端有 25s wall-clock deadline 保護,但前端先給友善提示
|
||||
if ( mode === 'realistic' && batch > 10 ) {
|
||||
if ( ! confirm( `Realistic 模式每個 user 約需 3 秒,batch_size=${ batch } 會超過後端 25s deadline 上限。建議使用 batch=10。要繼續嗎?` ) ) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const big = target >= 10000;
|
||||
const msg = `即將以 ${ mode } 模式建立 ${ target.toLocaleString() } 筆測試使用者${ big ? '(規模較大,可能耗時數分鐘)' : '' }。\n\n密碼一律為 PassWord2026!,user_login 為 test{n}。\n\n確定要開始嗎?`;
|
||||
if ( ! confirm( msg ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const r = await apiCall( '/stress-test/start', 'POST', { target, mode, batch_size: batch } );
|
||||
if ( ! r.ok ) {
|
||||
alert( '啟動失敗:' + ( r.data?.error || r.status ) );
|
||||
return;
|
||||
}
|
||||
// 啟動後立即顯示進度卡片,不必等第一次 poll 才出現
|
||||
const card = $( '#wpdo-st-progress-card' );
|
||||
if ( card ) {
|
||||
card.style.display = '';
|
||||
}
|
||||
setText( '#wpdo-st-pg-status', 'running' );
|
||||
setText( '#wpdo-st-pg-mode', mode );
|
||||
setText( '#wpdo-st-pg-target', target.toLocaleString() );
|
||||
startPolling();
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if ( ! confirm( '確定要取消當前測試?已建立的使用者不會被刪除。' ) ) {
|
||||
return;
|
||||
}
|
||||
// 立刻 UI 反饋:避免使用者再點一次
|
||||
const cancelBtn = $( '#wpdo-st-cancel' );
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.disabled = true;
|
||||
cancelBtn.textContent = '⏹ 取消中…';
|
||||
}
|
||||
setText( '#wpdo-st-pg-status', 'cancelling' );
|
||||
|
||||
const r = await apiCall( '/stress-test/cancel', 'POST' );
|
||||
if ( ! r.ok ) {
|
||||
alert( '取消失敗:' + ( r.data?.error || r.status ) );
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.disabled = false;
|
||||
cancelBtn.textContent = '⏹ 取消';
|
||||
}
|
||||
return;
|
||||
}
|
||||
// realistic 模式 in-flight batch 可能還要 ~3 秒才會真正中止;polling 會看到 cancelled
|
||||
poll();
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.textContent = '⏹ 取消';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCleanup() {
|
||||
if ( ! confirm( '確定要清除所有 test_* 使用者?\n\n此動作會:\n- DELETE 所有 user_login LIKE "test%" 的使用者\n- DELETE 對應 wp_usermeta\n- DELETE 所有 wp_wpdo_user_* flat tables 中對應 user_id 的列\n\n不可復原!' ) ) {
|
||||
return;
|
||||
}
|
||||
const r = await apiCall( '/stress-test/cleanup', 'DELETE' );
|
||||
if ( ! r.ok ) {
|
||||
alert( '清除失敗:' + ( r.data?.error || r.status ) );
|
||||
return;
|
||||
}
|
||||
alert( `已清除 ${ r.data.deleted } 筆測試使用者。` );
|
||||
const card = $( '#wpdo-st-progress-card' );
|
||||
const benchCard = $( '#wpdo-st-bench-card' );
|
||||
if ( card ) card.style.display = 'none';
|
||||
if ( benchCard ) benchCard.style.display = 'none';
|
||||
poll();
|
||||
}
|
||||
|
||||
async function handleRerunBench() {
|
||||
const btn = $( '#wpdo-st-rerun-bench' );
|
||||
if ( btn ) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ 執行中...';
|
||||
}
|
||||
const r = await apiCall( '/stress-test/benchmark', 'POST' );
|
||||
if ( btn ) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '📊 重跑 Benchmark(不新增資料)';
|
||||
}
|
||||
if ( ! r.ok ) {
|
||||
alert( 'Benchmark 失敗:' + ( r.data?.error || r.status ) );
|
||||
return;
|
||||
}
|
||||
renderBenchmark( r.data.benchmark );
|
||||
}
|
||||
|
||||
// ── Init ────────────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener( 'DOMContentLoaded', () => {
|
||||
const startBtn = $( '#wpdo-st-start' );
|
||||
const cancelBtn = $( '#wpdo-st-cancel' );
|
||||
const cleanupBtn = $( '#wpdo-st-cleanup' );
|
||||
const benchBtn = $( '#wpdo-st-rerun-bench' );
|
||||
|
||||
if ( startBtn ) startBtn.addEventListener( 'click', handleStart );
|
||||
if ( cancelBtn ) cancelBtn.addEventListener( 'click', handleCancel );
|
||||
if ( cleanupBtn ) cleanupBtn.addEventListener( 'click', handleCleanup );
|
||||
if ( benchBtn ) benchBtn.addEventListener( 'click', handleRerunBench );
|
||||
|
||||
// 初始 poll,如果正在執行就會自動開始 polling
|
||||
poll().then( () => {
|
||||
const card = $( '#wpdo-st-progress-card' );
|
||||
if ( card && card.style.display !== 'none' ) {
|
||||
const status = ( $( '#wpdo-st-pg-status' )?.textContent || '' ).trim();
|
||||
if ( status === 'running' || status === 'benchmarking' ) {
|
||||
startPolling();
|
||||
}
|
||||
}
|
||||
} );
|
||||
} );
|
||||
} )();
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* WPDO Term Stress Test — admin tab JS (v2.13.0)
|
||||
*
|
||||
* Mirrors wpdo-post-stress-test.js (v2.11.4) but:
|
||||
* - Talks to /wpdo/v1/term-stress-test/* endpoints
|
||||
* - Sends taxonomy in start payload (not post_type)
|
||||
* - DOM IDs prefixed wpdo-tst-* (term stress test)
|
||||
*
|
||||
* @since 2.13.0
|
||||
*/
|
||||
( function () {
|
||||
'use strict';
|
||||
|
||||
const cfg = window.wpdoTermStressTest;
|
||||
if ( ! cfg || ! cfg.restUrl ) {
|
||||
return;
|
||||
}
|
||||
if ( ! document.querySelector( '.wpdo-term-stress-test-tab' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $ = ( sel ) => document.querySelector( sel );
|
||||
const restUrl = cfg.restUrl.replace( /\/$/, '' );
|
||||
const headers = { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce };
|
||||
|
||||
let pollTimer = null;
|
||||
|
||||
// ── API helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
async function apiCall( path, method = 'GET', body = null ) {
|
||||
const opts = { method, headers, credentials: 'same-origin' };
|
||||
if ( body ) {
|
||||
opts.body = JSON.stringify( body );
|
||||
}
|
||||
const resp = await fetch( restUrl + path, opts );
|
||||
const text = await resp.text();
|
||||
try {
|
||||
return { ok: resp.ok, status: resp.status, data: text ? JSON.parse( text ) : null };
|
||||
} catch ( e ) {
|
||||
return { ok: false, status: resp.status, data: { error: text } };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────────────
|
||||
|
||||
function fmt( n ) {
|
||||
if ( n === null || n === undefined ) {
|
||||
return '—';
|
||||
}
|
||||
return Number( n ).toLocaleString();
|
||||
}
|
||||
|
||||
function setText( sel, text ) {
|
||||
const el = $( sel );
|
||||
if ( el ) {
|
||||
el.textContent = String( text );
|
||||
}
|
||||
}
|
||||
|
||||
function esc( s ) {
|
||||
if ( s === null || s === undefined ) {
|
||||
return '';
|
||||
}
|
||||
return String( s ).replace( /[&<>"']/g, ( c ) => ( {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[ c ] ) );
|
||||
}
|
||||
|
||||
function renderProgress( state ) {
|
||||
const isRunning = state.status === 'running' || state.status === 'benchmarking';
|
||||
const card = $( '#wpdo-tst-progress-card' );
|
||||
if ( card ) {
|
||||
card.style.display = ( isRunning || state.status === 'completed' || state.status === 'failed' || state.status === 'cancelled' ) ? '' : 'none';
|
||||
}
|
||||
setText( '#wpdo-tst-pg-status', state.status || 'idle' );
|
||||
setText( '#wpdo-tst-pg-taxonomy', state.taxonomy || '' );
|
||||
setText( '#wpdo-tst-pg-mode', state.mode || '' );
|
||||
setText( '#wpdo-tst-pg-pct', ( state.pct || 0 ) + '%' );
|
||||
setText( '#wpdo-tst-pg-processed', fmt( state.processed || 0 ) );
|
||||
setText( '#wpdo-tst-pg-target', fmt( state.target || 0 ) );
|
||||
setText( '#wpdo-tst-pg-rate', state.rate_per_sec || 0 );
|
||||
setText( '#wpdo-tst-pg-elapsed', state.elapsed_sec || 0 );
|
||||
setText( '#wpdo-tst-pg-eta', state.eta_sec || 0 );
|
||||
setText( '#wpdo-tst-pg-batches', state.batches_done || 0 );
|
||||
setText( '#wpdo-tst-pg-mem', ( ( state.peak_memory || 0 ) / 1048576 ).toFixed( 1 ) );
|
||||
|
||||
const bar = $( '#wpdo-tst-pg-bar' );
|
||||
if ( bar ) {
|
||||
bar.style.width = ( state.pct || 0 ) + '%';
|
||||
}
|
||||
|
||||
const count = state.test_term_count || 0;
|
||||
setText( '#wpdo-tst-count', fmt( count ) );
|
||||
setText( '#wpdo-tst-count-mirror', fmt( count ) );
|
||||
|
||||
const startBtn = $( '#wpdo-tst-start' );
|
||||
const cancelBtn = $( '#wpdo-tst-cancel' );
|
||||
const cleanupBtn = $( '#wpdo-tst-cleanup' );
|
||||
const benchBtn = $( '#wpdo-tst-rerun-bench' );
|
||||
if ( startBtn ) {
|
||||
startBtn.disabled = isRunning;
|
||||
}
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.disabled = ! isRunning;
|
||||
}
|
||||
if ( cleanupBtn ) {
|
||||
cleanupBtn.disabled = isRunning || count === 0;
|
||||
}
|
||||
if ( benchBtn ) {
|
||||
benchBtn.disabled = isRunning || count === 0;
|
||||
}
|
||||
}
|
||||
|
||||
function renderBenchmark( bench ) {
|
||||
if ( ! bench ) {
|
||||
return;
|
||||
}
|
||||
const card = $( '#wpdo-tst-bench-card' );
|
||||
const content = $( '#wpdo-tst-bench-content' );
|
||||
if ( ! card || ! content ) {
|
||||
return;
|
||||
}
|
||||
card.style.display = '';
|
||||
|
||||
const w = bench.write || {};
|
||||
const dbSizes = bench.db_sizes || [];
|
||||
const q = bench.query || {};
|
||||
|
||||
const labels = {
|
||||
point_default: 'Point lookup (hp_default = 1)',
|
||||
range_sort_top: 'Range scan (hp_sort_order > 0 ORDER BY DESC)',
|
||||
eav_baseline: 'EAV baseline (wp_termmeta 直查 hp_default)',
|
||||
};
|
||||
|
||||
let html = '';
|
||||
// Write metrics
|
||||
html += '<h4 style="margin-bottom:6px;">▍ 寫入指標</h4>';
|
||||
html += '<table class="widefat" style="margin-bottom:14px;"><tbody>';
|
||||
html += `<tr><td>模式</td><td><code>${ esc( w.mode ) }</code></td></tr>`;
|
||||
html += `<tr><td>Taxonomy</td><td><code>${ esc( w.taxonomy ) }</code></td></tr>`;
|
||||
html += `<tr><td>完成 / 目標</td><td>${ fmt( w.processed ) } / ${ fmt( w.target ) }</td></tr>`;
|
||||
html += `<tr><td>總耗時</td><td>${ fmt( w.elapsed_sec ) } 秒</td></tr>`;
|
||||
html += `<tr><td>平均速率</td><td><strong>${ fmt( w.rate_per_sec ) }</strong> terms/sec</td></tr>`;
|
||||
html += `<tr><td>批次數</td><td>${ fmt( w.batches_done ) }</td></tr>`;
|
||||
html += `<tr><td>批次最快/平均/最慢</td><td>${ fmt( w.batch_min_ms ) } / ${ fmt( w.batch_avg_ms ) } / ${ fmt( w.batch_max_ms ) } ms</td></tr>`;
|
||||
html += `<tr><td>PHP Peak Memory</td><td>${ fmt( w.peak_memory_mb ) } MB</td></tr>`;
|
||||
html += '</tbody></table>';
|
||||
|
||||
// DB sizes
|
||||
html += '<h4 style="margin-bottom:6px;">▍ DB 容量(term 相關表)</h4>';
|
||||
html += '<table class="widefat striped" style="margin-bottom:14px;"><thead><tr>';
|
||||
html += '<th>Table</th><th>Rows</th><th>Data MB</th><th>Index MB</th><th>Total MB</th><th>Avg bytes/row</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
dbSizes.forEach( ( r ) => {
|
||||
html += `<tr><td><code>${ esc( r.table ) }</code></td><td>${ fmt( r.rows ) }</td>`;
|
||||
html += `<td>${ r.data_mb ?? '—' }</td><td>${ r.index_mb ?? '—' }</td>`;
|
||||
html += `<td><strong>${ r.total_mb ?? '—' }</strong></td><td>${ fmt( r.avg_bytes ) }</td></tr>`;
|
||||
} );
|
||||
html += '</tbody></table>';
|
||||
|
||||
// Query perf — 3 probes with EAV baseline last for visual speedup comparison
|
||||
html += '<h4 style="margin-bottom:6px;">▍ 查詢效能</h4>';
|
||||
html += '<table class="widefat striped" style="margin-bottom:8px;"><thead><tr>';
|
||||
html += '<th>測試項目</th><th>耗時 (ms)</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
const baselineMs = q.eav_baseline?.duration_ms ?? null;
|
||||
Object.keys( q ).forEach( ( key ) => {
|
||||
const v = q[ key ];
|
||||
if ( ! v || typeof v.duration_ms !== 'number' ) {
|
||||
return;
|
||||
}
|
||||
const label = labels[ key ] || key;
|
||||
let speedup = '';
|
||||
if ( baselineMs !== null && key !== 'eav_baseline' && v.duration_ms > 0 ) {
|
||||
const ratio = baselineMs / v.duration_ms;
|
||||
if ( ratio >= 1 ) {
|
||||
speedup = ` <span style="color:#28a745;font-weight:600;">(${ ratio.toFixed( 2 ) }× faster)</span>`;
|
||||
}
|
||||
}
|
||||
html += `<tr><td>${ esc( label ) }${ speedup }</td><td><strong>${ v.duration_ms }</strong></td></tr>`;
|
||||
} );
|
||||
html += '</tbody></table>';
|
||||
html += '<p class="description">EAV baseline 走 wp_termmeta,flat probes 走 wpdo_term_hp_taxonomy。倍率即此規模下反 EAV 的查詢加速。</p>';
|
||||
|
||||
content.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Polling ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function poll() {
|
||||
const r = await apiCall( '/term-stress-test/status' );
|
||||
if ( ! r.ok || ! r.data ) {
|
||||
return;
|
||||
}
|
||||
renderProgress( r.data );
|
||||
if ( r.data.benchmark ) {
|
||||
renderBenchmark( r.data.benchmark );
|
||||
}
|
||||
if ( r.data.status !== 'running' && r.data.status !== 'benchmarking' ) {
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
poll();
|
||||
pollTimer = setInterval( poll, 2000 );
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if ( pollTimer ) {
|
||||
clearInterval( pollTimer );
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event handlers ──────────────────────────────────────────────────────
|
||||
|
||||
async function handleStart() {
|
||||
const taxonomy = $( '#wpdo-tst-taxonomy' ).value;
|
||||
const target = parseInt( $( '#wpdo-tst-target' ).value, 10 );
|
||||
const batch = parseInt( $( '#wpdo-tst-batch' ).value, 10 );
|
||||
const mode = document.querySelector( 'input[name="wpdo-tst-mode"]:checked' ).value;
|
||||
|
||||
if ( ! taxonomy ) {
|
||||
alert( '請選擇 taxonomy' );
|
||||
return;
|
||||
}
|
||||
if ( ! target || target < 1 ) {
|
||||
alert( '請輸入有效的 term 數量' );
|
||||
return;
|
||||
}
|
||||
if ( mode === 'realistic' && batch > 50 ) {
|
||||
if ( ! confirm( `Realistic 模式每個 term 約需 50-150 ms(wp_insert_term + 3 個 update_term_meta),batch_size=${ batch } 可能超過後端 8s deadline。建議 batch=10-30。要繼續嗎?` ) ) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Soft warn for combinations that won't demonstrate反 EAV 優化效果
|
||||
const termMode = String( cfg.termMode || 'disabled' );
|
||||
if ( mode === 'fast' && termMode === 'aeav_only' ) {
|
||||
if ( ! confirm( `⚠️ Fast 模式直接 $wpdb->insert 繞過 Hook Bus,即使 term mode=aeav_only 也會寫滿 wp_termmeta。\n\n要驗證反 EAV 優化效果(wp_termmeta 應為 0),請改用 🐢 Realistic 模式。\n\n仍以 Fast 模式繼續嗎?` ) ) {
|
||||
return;
|
||||
}
|
||||
} else if ( mode === 'realistic' && termMode !== 'aeav_only' ) {
|
||||
if ( ! confirm( `⚠️ 目前 term mode = ${ termMode },Realistic 寫入仍會雙寫 wp_termmeta(不展示優化效果)。\n\n要看 wp_termmeta 完全短路請先升 mode 至 aeav_only(設定 tab)。\n\n仍以 ${ termMode } 模式繼續嗎?` ) ) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const big = target >= 5000;
|
||||
const msg = `即將以 ${ mode } 模式建立 ${ target.toLocaleString() } 筆 ${ taxonomy } 測試 term${ big ? '(規模較大)' : '' }。\n\n所有 term 的 slug 會以 wpdo-stress- 開頭,可一鍵清除。\n\n確定要開始嗎?`;
|
||||
if ( ! confirm( msg ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const r = await apiCall( '/term-stress-test/start', 'POST', {
|
||||
taxonomy,
|
||||
target,
|
||||
mode,
|
||||
batch_size: batch,
|
||||
} );
|
||||
if ( ! r.ok ) {
|
||||
alert( '啟動失敗:' + ( r.data?.error || r.status ) );
|
||||
return;
|
||||
}
|
||||
const card = $( '#wpdo-tst-progress-card' );
|
||||
if ( card ) {
|
||||
card.style.display = '';
|
||||
}
|
||||
const benchCard = $( '#wpdo-tst-bench-card' );
|
||||
if ( benchCard ) {
|
||||
benchCard.style.display = 'none';
|
||||
}
|
||||
setText( '#wpdo-tst-pg-status', 'running' );
|
||||
setText( '#wpdo-tst-pg-taxonomy', taxonomy );
|
||||
setText( '#wpdo-tst-pg-mode', mode );
|
||||
setText( '#wpdo-tst-pg-target', target.toLocaleString() );
|
||||
startPolling();
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if ( ! confirm( '確定要取消當前測試?已建立的 term 不會被刪除。' ) ) {
|
||||
return;
|
||||
}
|
||||
const cancelBtn = $( '#wpdo-tst-cancel' );
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.disabled = true;
|
||||
cancelBtn.textContent = '⏹ 取消中…';
|
||||
}
|
||||
setText( '#wpdo-tst-pg-status', 'cancelling' );
|
||||
|
||||
const r = await apiCall( '/term-stress-test/cancel', 'POST' );
|
||||
if ( ! r.ok ) {
|
||||
alert( '取消失敗:' + ( r.data?.error || r.status ) );
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.disabled = false;
|
||||
cancelBtn.textContent = '⏹ 取消';
|
||||
}
|
||||
return;
|
||||
}
|
||||
poll();
|
||||
if ( cancelBtn ) {
|
||||
cancelBtn.textContent = '⏹ 取消';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCleanup() {
|
||||
if ( ! confirm( '確定要清除所有 stress test terms?\n\n此動作會:\n- DELETE 所有 slug 前綴 wpdo-stress- 的 term\n- DELETE 對應 wp_termmeta + wp_term_taxonomy\n- DELETE flat 表中對應 term_id 的列\n\n不可復原!' ) ) {
|
||||
return;
|
||||
}
|
||||
const r = await apiCall( '/term-stress-test/cleanup', 'DELETE' );
|
||||
if ( ! r.ok ) {
|
||||
alert( '清除失敗:' + ( r.data?.error || r.status ) );
|
||||
return;
|
||||
}
|
||||
alert( `已清除 ${ r.data.deleted } 筆測試 term。` );
|
||||
const card = $( '#wpdo-tst-progress-card' );
|
||||
const benchCard = $( '#wpdo-tst-bench-card' );
|
||||
if ( card ) card.style.display = 'none';
|
||||
if ( benchCard ) benchCard.style.display = 'none';
|
||||
poll();
|
||||
}
|
||||
|
||||
async function handleRerunBench() {
|
||||
const btn = $( '#wpdo-tst-rerun-bench' );
|
||||
if ( btn ) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ 執行中...';
|
||||
}
|
||||
const r = await apiCall( '/term-stress-test/benchmark', 'POST' );
|
||||
if ( btn ) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '📊 重跑 Benchmark(不新增資料)';
|
||||
}
|
||||
if ( ! r.ok ) {
|
||||
alert( 'Benchmark 失敗:' + ( r.data?.error || r.status ) );
|
||||
return;
|
||||
}
|
||||
renderBenchmark( r.data.benchmark );
|
||||
}
|
||||
|
||||
// ── Init ────────────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener( 'DOMContentLoaded', () => {
|
||||
const startBtn = $( '#wpdo-tst-start' );
|
||||
const cancelBtn = $( '#wpdo-tst-cancel' );
|
||||
const cleanupBtn = $( '#wpdo-tst-cleanup' );
|
||||
const benchBtn = $( '#wpdo-tst-rerun-bench' );
|
||||
|
||||
if ( startBtn ) startBtn.addEventListener( 'click', handleStart );
|
||||
if ( cancelBtn ) cancelBtn.addEventListener( 'click', handleCancel );
|
||||
if ( cleanupBtn ) cleanupBtn.addEventListener( 'click', handleCleanup );
|
||||
if ( benchBtn ) benchBtn.addEventListener( 'click', handleRerunBench );
|
||||
|
||||
poll().then( () => {
|
||||
const status = ( $( '#wpdo-tst-pg-status' )?.textContent || '' ).trim();
|
||||
if ( status === 'running' || status === 'benchmarking' ) {
|
||||
startPolling();
|
||||
}
|
||||
} );
|
||||
} );
|
||||
} )();
|
||||
Reference in New Issue
Block a user