/** * 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 '' + '' + '' + labels[ m ] + '' + ''; } ).join( '' ); } // ─── 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 ); } )();