k0880k commited on
Commit
71fa6eb
·
1 Parent(s): d7a25e9

fix(migration): keep preflight controls on canonical first-load pipeline

Browse files
Files changed (1) hide show
  1. core/migration_ui_stability.py +71 -84
core/migration_ui_stability.py CHANGED
@@ -2,19 +2,11 @@
2
 
3
  Older migration UI layers still exist as implementation machinery, but they may
4
  not infer roles, auto-select a Joomla runtime, or issue their own plans when a
5
- selector changes. The guided route controller is now the sole browser owner of
6
- SOURCE -> TARGET -> desired Joomla -> automatic preflight.
7
-
8
- One narrow legacy behavior remains intentional: when an operator explicitly
9
- chooses a registered site from the historical source/target selectors, refresh
10
- that site's database proof immediately. This is *not* a migration preflight and
11
- never chooses a role from option population; it only opens a fresh DB connection
12
- using the already-registered node configuration so an idle MySQL session cannot
13
- be mistaken for missing credentials.
14
-
15
- This final script pass also keeps fleet population placeholder-first and narrows
16
- connectivity observers so rendering status cannot recursively trigger probes.
17
- Server-side live-write gates are untouched.
18
  """
19
  from __future__ import annotations
20
 
@@ -32,19 +24,16 @@ _REGISTERED_SITE_RECONNECT = r"""
32
  (() => {
33
  if (window.__signalmeshRegisteredSiteReconnect) return;
34
  window.__signalmeshRegisteredSiteReconnect = true;
35
-
36
  const bind = () => {
37
  const src = document.querySelector('#mg-src');
38
  const dst = document.querySelector('#mg-dst');
39
  if (!src || !dst) return false;
40
-
41
  const apiBase = (typeof API_BASE !== 'undefined') ? API_BASE : location.pathname.replace(/\/[^/]*$/, '');
42
  const carried = () => (typeof sessionHeader === 'function' ? sessionHeader() : '');
43
  const phase = document.querySelector('#mg-phase');
44
  const msg = document.querySelector('#mg-msg');
45
  const adminNote = document.querySelector('#mg-admin-creds .muted');
46
  if (adminNote) adminNote.textContent = 'Joomla administrator only — not database credentials. Used only to install missing software on the target.';
47
-
48
  const show = (text, bad=false) => {
49
  if (phase) phase.textContent = text;
50
  if (bad && msg) {
@@ -52,44 +41,33 @@ _REGISTERED_SITE_RECONNECT = r"""
52
  else msg.textContent = text;
53
  }
54
  };
55
-
56
  async function reconnect(role, sel) {
57
  const site = String(sel.value || '').trim();
58
  if (!site) return;
59
  const seq = Number(sel.dataset.smReconnectSeq || 0) + 1;
60
  sel.dataset.smReconnectSeq = String(seq);
61
  show(`Reconnecting ${site}…`);
62
-
63
- const q = new URLSearchParams();
64
- q.set(role, site);
65
- const headers = {};
66
- if (carried()) headers['X-Kyklos-Session'] = carried();
67
-
68
  try {
69
- const r = await fetch(apiBase + '/api/migration-suite/state?' + q.toString(), {
70
- credentials: 'same-origin', headers, cache: 'no-store'
71
- });
72
  const body = await r.json().catch(() => ({}));
73
  if (Number(sel.dataset.smReconnectSeq) !== seq) return;
74
  if (!r.ok) throw new Error(body?.detail || body?.message || `Reconnect failed (${r.status})`);
75
-
76
  const p = body?.[role] || null;
77
  const ok = role === 'source'
78
  ? !!(p?.read_proven && p?.inventory_proven && p?.joomla)
79
  : !!(p?.read_proven && p?.inventory_proven && p?.write_proven && p?.joomla);
80
  if (!ok) {
81
- const detail = (p?.checks || []).filter(x => !x.ok).map(x => x.detail).filter(Boolean).join(' · ')
82
- || p?.error || 'Joomla database proof did not pass.';
83
  throw new Error(detail);
84
  }
85
-
86
  show(`${site} reconnected · Joomla ${p.joomla || '?'} · ${p.database || 'database ready'}`);
87
  } catch (e) {
88
  if (Number(sel.dataset.smReconnectSeq) !== seq) return;
89
  show(`Registered site ${site} could not reconnect: ${e.message || String(e)}`, true);
90
  }
91
  }
92
-
93
  for (const [role, sel] of [['source', src], ['target', dst]]) {
94
  if (sel.dataset.smReconnectBound === '1') continue;
95
  sel.dataset.smReconnectBound = '1';
@@ -97,7 +75,6 @@ _REGISTERED_SITE_RECONNECT = r"""
97
  }
98
  return true;
99
  };
100
-
101
  if (!bind()) {
102
  const obs = new MutationObserver(() => { if (bind()) obs.disconnect(); });
103
  obs.observe(document.documentElement, {childList:true, subtree:true});
@@ -110,69 +87,78 @@ _SAFE_MGFILL = r"""
110
  window.mgFill = function(sites) {
111
  const rows = Array.isArray(sites) ? sites : [];
112
  for (const [selector, role] of [['#mg-src','source'], ['#mg-dst','target']]) {
113
- const sel = document.querySelector(selector);
114
- if (!sel) continue;
115
  sel.innerHTML = '';
116
- const ph = document.createElement('option');
117
- ph.value = '';
118
- ph.textContent = `— connect/select ${role} —`;
119
- sel.append(ph);
120
  for (const row of rows) {
121
  const value = typeof row === 'string' ? row : (row?.site || row?.id || '');
122
  if (!value) continue;
123
- const opt = document.createElement('option');
124
- opt.value = value;
125
- opt.textContent = value;
126
- sel.append(opt);
127
  }
128
  sel.value = '';
129
  }
130
- const box = document.querySelector('#migratebox');
131
- if (box) box.classList.add('sm-pipeline-superseded');
132
  };
133
  })();
134
  """
135
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
 
137
  def _remove_legacy_triggers() -> None:
138
- """Historical controls may render, but cannot choose or preflight a route."""
139
- suite._SCRIPT = suite._SCRIPT.replace(
140
- "src?.addEventListener('change',()=>{version='';load().catch(showError)});dst?.addEventListener('change',()=>{version='';load().catch(showError)});",
141
- "// Guided migration owns selector decisions and preflight.",
142
- 1,
143
- )
144
- suite._SCRIPT = suite._SCRIPT.replace(
145
- "if(!version)version=body.desired_target_joomla||'';",
146
- "// Desired Joomla is explicit in the guided pipeline; never auto-adopt it here.",
147
- 1,
148
- )
149
- suite._SCRIPT = re.sub(
150
- r"\n\s*const obs=new MutationObserver\(\(\)=>\{if\(src\?\.options\.length&&dst\?\.options\.length\)load\(\)\.catch\(\(\)=>\{\}\);\}\);if\(src&&dst\)\{obs\.observe\(src,\{childList:true\}\);obs\.observe\(dst,\{childList:true\}\);\}\n",
151
- "\n",
152
- suite._SCRIPT,
153
- count=1,
154
- )
155
-
156
- # Strip the two anonymous explicit-selector listeners independently so this
157
- # remains correct whether the source script indents them or a test fixture
158
- # does not. The guided pipeline owns migration planning, but a separate
159
- # reconnect-only listener is appended below for the historical selectors.
160
- unified._UNIFIED_JS = unified._UNIFIED_JS.replace(
161
- "src.addEventListener('change',()=>fetchPlan().catch(e=>setMsg(e.message,true)));",
162
- "// Guided migration owns SOURCE changes.",
163
- 1,
164
- )
165
- unified._UNIFIED_JS = unified._UNIFIED_JS.replace(
166
- "dst.addEventListener('change',()=>fetchPlan().catch(e=>setMsg(e.message,true)));",
167
- "// Guided migration owns TARGET changes.",
168
- 1,
169
- )
170
- unified._UNIFIED_JS = re.sub(
171
- r"\n\s*// loadFleet/mgFill populate the selects asynchronously\.[\s\S]*?if\(src\.options\.length>1&&dst\.options\.length>1\)fetchPlan\(\)\.catch\(\(\)=>\{\}\);\n",
172
- "\n",
173
- unified._UNIFIED_JS,
174
- count=1,
175
- )
176
  if "__signalmeshRegisteredSiteReconnect" not in unified._UNIFIED_JS:
177
  unified._UNIFIED_JS += "\n" + _REGISTERED_SITE_RECONNECT
178
 
@@ -184,10 +170,11 @@ def _install_safe_fleet_fill() -> None:
184
 
185
  def install() -> None:
186
  global _INSTALLED
187
- if _INSTALLED:
188
- return
189
  _remove_legacy_triggers()
190
  _install_safe_fleet_fill()
191
  if _OLD_CONNECTIVITY in suite._SCRIPT:
192
  suite._SCRIPT = suite._SCRIPT.replace(_OLD_CONNECTIVITY, _NEW_CONNECTIVITY, 1)
 
 
193
  _INSTALLED = True
 
2
 
3
  Older migration UI layers still exist as implementation machinery, but they may
4
  not infer roles, auto-select a Joomla runtime, or issue their own plans when a
5
+ selector changes. The guided route controller is the sole browser owner.
6
+
7
+ This final pass also exposes a persistent preflight control on the canonical
8
+ pipeline. Preflight is diagnostic/read-only and must never be hidden behind a
9
+ failed live migration.
 
 
 
 
 
 
 
 
10
  """
11
  from __future__ import annotations
12
 
 
24
  (() => {
25
  if (window.__signalmeshRegisteredSiteReconnect) return;
26
  window.__signalmeshRegisteredSiteReconnect = true;
 
27
  const bind = () => {
28
  const src = document.querySelector('#mg-src');
29
  const dst = document.querySelector('#mg-dst');
30
  if (!src || !dst) return false;
 
31
  const apiBase = (typeof API_BASE !== 'undefined') ? API_BASE : location.pathname.replace(/\/[^/]*$/, '');
32
  const carried = () => (typeof sessionHeader === 'function' ? sessionHeader() : '');
33
  const phase = document.querySelector('#mg-phase');
34
  const msg = document.querySelector('#mg-msg');
35
  const adminNote = document.querySelector('#mg-admin-creds .muted');
36
  if (adminNote) adminNote.textContent = 'Joomla administrator only — not database credentials. Used only to install missing software on the target.';
 
37
  const show = (text, bad=false) => {
38
  if (phase) phase.textContent = text;
39
  if (bad && msg) {
 
41
  else msg.textContent = text;
42
  }
43
  };
 
44
  async function reconnect(role, sel) {
45
  const site = String(sel.value || '').trim();
46
  if (!site) return;
47
  const seq = Number(sel.dataset.smReconnectSeq || 0) + 1;
48
  sel.dataset.smReconnectSeq = String(seq);
49
  show(`Reconnecting ${site}…`);
50
+ const q = new URLSearchParams(); q.set(role, site);
51
+ const headers = {}; if (carried()) headers['X-Kyklos-Session'] = carried();
 
 
 
 
52
  try {
53
+ const r = await fetch(apiBase + '/api/migration-suite/state?' + q.toString(), {credentials:'same-origin', headers, cache:'no-store'});
 
 
54
  const body = await r.json().catch(() => ({}));
55
  if (Number(sel.dataset.smReconnectSeq) !== seq) return;
56
  if (!r.ok) throw new Error(body?.detail || body?.message || `Reconnect failed (${r.status})`);
 
57
  const p = body?.[role] || null;
58
  const ok = role === 'source'
59
  ? !!(p?.read_proven && p?.inventory_proven && p?.joomla)
60
  : !!(p?.read_proven && p?.inventory_proven && p?.write_proven && p?.joomla);
61
  if (!ok) {
62
+ const detail = (p?.checks || []).filter(x => !x.ok).map(x => x.detail).filter(Boolean).join(' · ') || p?.error || 'Joomla database proof did not pass.';
 
63
  throw new Error(detail);
64
  }
 
65
  show(`${site} reconnected · Joomla ${p.joomla || '?'} · ${p.database || 'database ready'}`);
66
  } catch (e) {
67
  if (Number(sel.dataset.smReconnectSeq) !== seq) return;
68
  show(`Registered site ${site} could not reconnect: ${e.message || String(e)}`, true);
69
  }
70
  }
 
71
  for (const [role, sel] of [['source', src], ['target', dst]]) {
72
  if (sel.dataset.smReconnectBound === '1') continue;
73
  sel.dataset.smReconnectBound = '1';
 
75
  }
76
  return true;
77
  };
 
78
  if (!bind()) {
79
  const obs = new MutationObserver(() => { if (bind()) obs.disconnect(); });
80
  obs.observe(document.documentElement, {childList:true, subtree:true});
 
87
  window.mgFill = function(sites) {
88
  const rows = Array.isArray(sites) ? sites : [];
89
  for (const [selector, role] of [['#mg-src','source'], ['#mg-dst','target']]) {
90
+ const sel = document.querySelector(selector); if (!sel) continue;
 
91
  sel.innerHTML = '';
92
+ const ph = document.createElement('option'); ph.value = ''; ph.textContent = `— connect/select ${role} —`; sel.append(ph);
 
 
 
93
  for (const row of rows) {
94
  const value = typeof row === 'string' ? row : (row?.site || row?.id || '');
95
  if (!value) continue;
96
+ const opt = document.createElement('option'); opt.value = value; opt.textContent = value; sel.append(opt);
 
 
 
97
  }
98
  sel.value = '';
99
  }
100
+ const box = document.querySelector('#migratebox'); if (box) box.classList.add('sm-pipeline-superseded');
 
101
  };
102
  })();
103
  """
104
 
105
+ _PERSISTENT_PREFLIGHT = r"""
106
+ (() => {
107
+ if (window.__signalmeshPersistentPreflight) return;
108
+ window.__signalmeshPersistentPreflight = true;
109
+ const apiBase = () => (typeof API_BASE !== 'undefined') ? API_BASE : location.pathname.replace(/\/[^/]*$/, '');
110
+ const carried = () => (typeof sessionHeader === 'function' ? sessionHeader() : '');
111
+ const selected = role => document.querySelector(`[data-role-select="${role}"]`)?.value || document.querySelector(`[data-step="${role}"] .sm-pipe-choice b`)?.textContent?.trim() || '';
112
+ const version = () => document.querySelector('#sm-target-version')?.value || document.querySelector('[data-step="version"] .sm-pipe-choice b')?.textContent?.replace(/^Joomla\s+/,'').trim() || '';
113
+
114
+ function ensure() {
115
+ const root = document.querySelector('#sm-guided-pipeline');
116
+ if (!root) return;
117
+ let box = document.querySelector('#sm-persistent-preflight');
118
+ if (!box) {
119
+ box = document.createElement('section');
120
+ box.id = 'sm-persistent-preflight';
121
+ box.className = 'sm-pipe-step active';
122
+ box.innerHTML = `<div class="sm-pipe-num">P</div><div class="sm-pipe-body"><div class="sm-pipe-head"><h3>Preflight</h3><span class="sm-pipe-status wait" data-pf-status>AVAILABLE</span></div><div class="sm-sub">Read-only proof. Never migrates data. Select source + target; desired Joomla runtime is optional until runtime-contract validation.</div><div class="sm-actions"><button class="primary" data-pf-run>Run preflight</button></div><div data-pf-out class="sm-pipe-evidence">Waiting for source and target selection.</div></div>`;
123
+ root.append(box);
124
+ }
125
+ const btn = box.querySelector('[data-pf-run]');
126
+ if (btn && btn.dataset.bound !== '1') {
127
+ btn.dataset.bound = '1';
128
+ btn.onclick = async () => {
129
+ const source = selected('source'), target = selected('target'), target_version = version();
130
+ const out = box.querySelector('[data-pf-out]'), stat = box.querySelector('[data-pf-status]');
131
+ if (!source || !target) { out.textContent = 'Choose and prove both Source and Target first.'; return; }
132
+ btn.disabled = true; stat.textContent = 'RUNNING'; out.textContent = 'Proving connectivity, inventory, target write authority, component data mapping, extension requirements, compatibility, and runtime contract…';
133
+ try {
134
+ const q = new URLSearchParams({source,target}); if (target_version) q.set('target_version',target_version);
135
+ const headers = {}; if (carried()) headers['X-Kyklos-Session'] = carried();
136
+ const r = await fetch(apiBase() + '/api/migration-suite/state?' + q.toString(), {credentials:'same-origin',headers,cache:'no-store'});
137
+ const body = await r.json().catch(()=>({}));
138
+ if (!r.ok) throw new Error(body?.detail || body?.message || `Preflight failed (${r.status})`);
139
+ const blockers = body?.migration?.blockers || [];
140
+ const req = body?.requirements || {};
141
+ const totals = body?.migration?.database?.totals || {};
142
+ stat.textContent = blockers.length ? 'BLOCKED' : 'PROVEN';
143
+ out.innerHTML = `<div><b>${blockers.length ? 'Preflight found blockers' : 'Preflight passed'}</b></div><div>${Number(totals.rows_to_move||0)} client rows planned · ${Number(body?.migration?.extensions?.required||0)} extension roots</div><div>source ${req.source?'✓':'✗'} · target ${req.target?'✓':'✗'} · target write ${req.target_write?'✓':'✗'} · runtime ${target_version?(req.target_version?'✓':'✗'):'not selected yet'}</div>${blockers.map(b=>`<div class="bad">✗ ${String(b.message||b.kind||'blocker')}</div>`).join('')}`;
144
+ } catch(e) { stat.textContent='FAILED'; out.textContent=e.message||String(e); }
145
+ finally { btn.disabled=false; }
146
+ };
147
+ }
148
+ }
149
+ ensure();
150
+ const obs = new MutationObserver(ensure); obs.observe(document.documentElement,{childList:true,subtree:true});
151
+ })();
152
+ """
153
+
154
 
155
  def _remove_legacy_triggers() -> None:
156
+ suite._SCRIPT = suite._SCRIPT.replace("src?.addEventListener('change',()=>{version='';load().catch(showError)});dst?.addEventListener('change',()=>{version='';load().catch(showError)});", "// Guided migration owns selector decisions and preflight.", 1)
157
+ suite._SCRIPT = suite._SCRIPT.replace("if(!version)version=body.desired_target_joomla||'';", "// Desired Joomla is explicit in the guided pipeline; never auto-adopt it here.", 1)
158
+ suite._SCRIPT = re.sub(r"\n\s*const obs=new MutationObserver\(\(\)=>\{if\(src\?\.options\.length&&dst\?\.options\.length\)load\(\)\.catch\(\(\)=>\{\}\);\}\);if\(src&&dst\)\{obs\.observe\(src,\{childList:true\}\);obs\.observe\(dst,\{childList:true\}\);\}\n", "\n", suite._SCRIPT, count=1)
159
+ unified._UNIFIED_JS = unified._UNIFIED_JS.replace("src.addEventListener('change',()=>fetchPlan().catch(e=>setMsg(e.message,true)));", "// Guided migration owns SOURCE changes.", 1)
160
+ unified._UNIFIED_JS = unified._UNIFIED_JS.replace("dst.addEventListener('change',()=>fetchPlan().catch(e=>setMsg(e.message,true)));", "// Guided migration owns TARGET changes.", 1)
161
+ unified._UNIFIED_JS = re.sub(r"\n\s*// loadFleet/mgFill populate the selects asynchronously\.[\s\S]*?if\(src\.options\.length>1&&dst\.options\.length>1\)fetchPlan\(\)\.catch\(\(\)=>\{\}\);\n", "\n", unified._UNIFIED_JS, count=1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  if "__signalmeshRegisteredSiteReconnect" not in unified._UNIFIED_JS:
163
  unified._UNIFIED_JS += "\n" + _REGISTERED_SITE_RECONNECT
164
 
 
170
 
171
  def install() -> None:
172
  global _INSTALLED
173
+ if _INSTALLED: return
 
174
  _remove_legacy_triggers()
175
  _install_safe_fleet_fill()
176
  if _OLD_CONNECTIVITY in suite._SCRIPT:
177
  suite._SCRIPT = suite._SCRIPT.replace(_OLD_CONNECTIVITY, _NEW_CONNECTIVITY, 1)
178
+ if "__signalmeshPersistentPreflight" not in suite._SCRIPT:
179
+ suite._SCRIPT += "\n" + _PERSISTENT_PREFLIGHT
180
  _INSTALLED = True