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,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 );
|
||||
|
||||
} )();
|
||||
Reference in New Issue
Block a user