From 069815790b9f13b360f3e765dd2b91ca51ac9423 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Mon, 17 Aug 2026 06:16:36 -0400 Subject: [PATCH] Put the join inside the wizard, send a partner to Partnership when it finishes, and say what is left --- Plugin/unraid/Varaverk.page | 154 ++++++++++++++++++++++++++ Plugin/unraid/api/setup.php | 16 ++- Plugin/unraid/pages/partnership.php | 161 ++-------------------------- Plugin/unraid/pages/setup.php | 105 +++++++++++++++++- 4 files changed, 278 insertions(+), 158 deletions(-) diff --git a/Plugin/unraid/Varaverk.page b/Plugin/unraid/Varaverk.page index 12f7578..34923bf 100644 --- a/Plugin/unraid/Varaverk.page +++ b/Plugin/unraid/Varaverk.page @@ -192,6 +192,160 @@ function vvPrompt(text, def, opts) { }, function (val) { resolve(val === false ? null : String(val)); }); }); } + +// ── Mirror onboarding, shared by the Partnership tab and the first-run wizard ───────────────── +// +// Here for the reason stated above: only one pages/*.php is included per request, and both the +// wizard and the Partnership tab need to render the same panel and run the same job. A second +// copy in setup.php would drift from the one in partnership.php, and the panel encodes a detail +// that is easy to get wrong on a copy — the terminal command must carry the SERVING host's +// SCRIPTS_DIR, because it is pasted into a terminal on that machine. +// +// Not in js/varaverk.js: that loads below the tab include, and the wizard returns before it. + +// api/run.php answers when a job is LAUNCHED, not finished. An empty body is never success — +// Unraid's CSRF guard exits with one, and so does a PHP fatal. +function _vvPtRun(id, extraArgs) { + const params = {id, manual: '1'}; + if (extraArgs) params.extra_args = extraArgs; + return fetch('/plugins/varaverk/api/run.php', {method: 'POST', body: new URLSearchParams(params)}) + .then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); }) + .then(text => { + if (!text.trim()) throw new Error('Empty response — request rejected before it reached run.php'); + return JSON.parse(text); + }); +} + +function _vvJobProgressEl(btn) { + let el = btn.parentElement.querySelector('.vv-jobprog'); + if (!el) { + el = document.createElement('div'); + el.className = 'vv-jobprog'; + el.style.cssText = 'font-size:10px;margin-top:5px;white-space:nowrap;'; + btn.parentElement.appendChild(el); + } + el.innerHTML = '⟳ starting…'; + return el; +} + +// Polls the job's stat for liveness and its log for the step banner, so a launched job shows +// where it is instead of appearing to do nothing for minutes. +const _vvJobPoll = {}; +function vvPtWatchJob(id, mountEl) { + if (_vvJobPoll[id]) clearInterval(_vvJobPoll[id]); + const enc = encodeURIComponent(id); + const tick = () => { + fetch(`/plugins/varaverk/api/status.php?id=${enc}&_=${Date.now()}`) + .then(r => r.json()) + .then(s => { + if (!s.ok) return; + if (s.status === 'running') { + return fetch(`/plugins/varaverk/api/log.php?id=${enc}&_=${Date.now()}`) + .then(r => r.json()) + .then(l => { + const lines = (l.content || '').split('\n'); + let step = ''; + for (let i = lines.length - 1; i >= 0; i--) { + const m = lines[i].match(/━━━\s*(?:[^\s]+\s+)?(Step [^━]+?)\s*━━━/); + if (m) { step = m[1].trim(); break; } + } + mountEl.innerHTML = `⟳ running` + + (step ? ` · ${vvEscHtml(step)}` : ''); + }); + } + clearInterval(_vvJobPoll[id]); delete _vvJobPoll[id]; + const col = s.status === 'ok' ? '#4caf50' : (s.status === 'warn' ? '#ff9800' : '#f44336'); + const lbl = s.status === 'ok' ? 'complete ✅' + : (s.status === 'never_run' ? 'did not start ⚠' : `${s.status} (exit ${s.exit ?? '?'})`); + mountEl.innerHTML = `${lbl}` + + ` log`; + // Only the Partnership tab defines a reload; the wizard has nothing to refresh. + if (typeof _vvPtReload === 'function') _vvPtReload(); + }) + .catch(() => {}); + }; + tick(); + _vvJobPoll[id] = setInterval(tick, 4000); +} + +async function vvPtOnboard(btn) { + if (!await vvConfirm('Run full partnership_onboard.sh?\n\nRun on the MIRROR first, then on the OWNER.\n\nUse Phase 1 + Phase 2 buttons for step-by-step control.')) return; + btn.disabled = true; + btn.textContent = '⟳ Starting…'; + // Stays disabled while it runs. A timed re-enable invited the second click whose lock refusal + // overwrote the live run's job record. + const prog = _vvJobProgressEl(btn); + _vvPtRun('Partnership/partnership_onboard.sh') + .then(d => { + if (!d.ok) throw new Error(d.error ?? 'Unknown error'); + btn.textContent = '⟳ Running…'; + vvPtWatchJob('Partnership/partnership_onboard.sh', prog); + }) + .catch(e => { + prog.innerHTML = `failed to start — ${vvEscHtml(String(e.message || e))}`; + btn.disabled = false; + btn.textContent = '▶ Onboard'; + }); +} + +// The mirror's two-step join. Step 1 is a terminal step on purpose: ssh_setup.sh runs ssh-copy-id, +// which prompts for the owner's root password on a first install, and a WebGUI button cannot +// answer a password prompt. Offering only a button here was offering the one route that cannot +// work — it failed on ssh-copy-id every time. +// opts: {ownerName, termBase, termCmd, phase, hasPartner} +function vvRenderMirrorOnboard(opts) { + const owner = vvEscHtml(opts.ownerName || 'the owner'); + const dis = opts.hasPartner === false ? 'disabled style="opacity:.35;cursor:default;"' : ''; + + if ((opts.phase ?? 0) >= 1) { + return `
+
+
+
Join partnership
+
SSH key ready · notifies ${owner} to run Phase 2
+
+ +
+
`; + } + + return `
+
+
+
+ Step 1 + Install SSH key on ${owner} +
+
+ Open Terminal + ${opts.termCmd} +
+
Enter ${owner} root password when prompted · a button cannot answer that prompt
+
+
+
+
+
+ Step 2 + Join partnership +
+
Notifies ${owner} to run Phase 2 · if key already installed
+
+ +
+
+
+
`; +} true, 'host_id' => $hostId, 'conf_file' => $confFile, 'api_key' => $apiKeyResult, - 'redirect' => '?tab=scheduler&vv_setup=' . $confFile]); + 'redirect' => vv_setup_redirect($hostId, $confFile)]); exit; } @@ -495,5 +507,5 @@ echo json_encode([ 'api_key' => $apiKeyResult, 'needs_migration'=> $needsMigration, 'migrate_to' => $needsMigration ? ($storageInternal === 'true' ? 'internal' : 'flash') : null, - 'redirect' => '?tab=scheduler&vv_setup=master.conf', + 'redirect' => vv_setup_redirect($mySlot), ]); diff --git a/Plugin/unraid/pages/partnership.php b/Plugin/unraid/pages/partnership.php index 73c9cfc..0a9bd04 100644 --- a/Plugin/unraid/pages/partnership.php +++ b/Plugin/unraid/pages/partnership.php @@ -172,26 +172,6 @@ const _vvDeleteKeys = {}; // hostId → true when delete-keys panel is expande let _vvPtReload = null; // set by IIFE so top-level fns can trigger a refresh // ── Public action functions — top-level so onclick= attributes can reach them ── -function _vvPtRun(id, extraArgs) { - const params = {id, manual: '1'}; - if (extraArgs) params.extra_args = extraArgs; - return fetch('/plugins/varaverk/api/run.php', { - method: 'POST', - body: new URLSearchParams(params) - }).then(r => { - if (!r.ok) throw new Error('HTTP ' + r.status); - return r.text(); - }).then(text => { - // An empty body is never success. api/run.php echoes JSON on every path it can reach, so - // nothing arriving means the request died before it: Unraid's CSRF guard exits with an empty - // body on a token mismatch, nginx returns an empty 200 for a bodyless POST, and a PHP fatal - // produces the same. Returning {ok:true} for that made a killed request and a launched job - // indistinguishable — press Onboard, get no error, and nothing has run. vvApiKey below has - // always thrown on this; the action path is what disagreed. - if (!text.trim()) throw new Error('Empty response — request rejected before it reached run.php'); - return JSON.parse(text); - }); -} // ── Live progress for a launched job ────────────────────────────────────────── // api/run.php returns {ok:true} the instant the job is *launched*, not when it finishes, and @@ -201,46 +181,7 @@ function _vvPtRun(id, extraArgs) { // // Polls the job's stat for liveness and its log for the step banner run_job.sh is capturing, and // renders the last "━━━ Step … ━━━" line as the current activity. -const _vvJobPoll = {}; -function vvPtWatchJob(id, mountEl) { - if (_vvJobPoll[id]) clearInterval(_vvJobPoll[id]); - const enc = encodeURIComponent(id); - - const tick = () => { - fetch(`/plugins/varaverk/api/status.php?id=${enc}&_=${Date.now()}`) - .then(r => r.json()) - .then(s => { - if (!s.ok) return; - if (s.status === 'running') { - return fetch(`/plugins/varaverk/api/log.php?id=${enc}&_=${Date.now()}`) - .then(r => r.json()) - .then(l => { - const lines = (l.content || '').split('\n'); - // Last step banner wins — that is where the run currently is. - let step = ''; - for (let i = lines.length - 1; i >= 0; i--) { - const m = lines[i].match(/━━━\s*(?:[^\s]+\s+)?(Step [^━]+?)\s*━━━/); - if (m) { step = m[1].trim(); break; } - } - mountEl.innerHTML = `⟳ running` - + (step ? ` · ${vvEscHtml(step)}` : ''); - }); - } - clearInterval(_vvJobPoll[id]); - delete _vvJobPoll[id]; - const col = s.status === 'ok' ? '#4caf50' : (s.status === 'warn' ? '#ff9800' : '#f44336'); - const lbl = s.status === 'ok' ? 'complete ✅' - : (s.status === 'never_run' ? 'did not start ⚠' : `${s.status} (exit ${s.exit ?? '?'})`); - mountEl.innerHTML = `${lbl}` - + ` log`; - if (_vvPtReload) _vvPtReload(); - }) - .catch(() => {}); - }; - tick(); - _vvJobPoll[id] = setInterval(tick, 4000); -} function vvPtStartOnboard(hostId) { _vvOnboarding[hostId] = true; @@ -298,38 +239,8 @@ async function vvPtPhase2(btn, hostId) { .finally(() => setTimeout(() => { btn.disabled = false; btn.textContent = '▶ Run Phase 2 Manually'; }, 3000)); } -async function vvPtOnboard(btn) { - if (!await vvConfirm('Run full partnership_onboard.sh?\n\nRun on the MIRROR first, then on the OWNER.\n\nUse Phase 1 + Phase 2 buttons for step-by-step control.')) return; - btn.disabled = true; - btn.textContent = '⟳ Starting…'; - // Stays disabled while the job runs. Re-enabling after a fixed timeout invited the second - // click that overwrites the live run's job record with its own lock refusal. - const prog = _vvJobProgressEl(btn); - _vvPtRun('Partnership/partnership_onboard.sh') - .then(d => { - if (!d.ok) throw new Error(d.error ?? 'Unknown error'); - btn.textContent = '⟳ Running…'; - vvPtWatchJob('Partnership/partnership_onboard.sh', prog); - }) - .catch(e => { - prog.innerHTML = `failed to start — ${vvEscHtml(String(e.message || e))}`; - btn.disabled = false; - btn.textContent = '▶ Onboard'; - }); -} // One status line per button, inserted after it and reused across polls. -function _vvJobProgressEl(btn) { - let el = btn.parentElement.querySelector('.vv-jobprog'); - if (!el) { - el = document.createElement('div'); - el.className = 'vv-jobprog'; - el.style.cssText = 'font-size:10px;margin-top:5px;white-space:nowrap;'; - btn.parentElement.appendChild(el); - } - el.innerHTML = '⟳ starting…'; - return el; -} async function vvPtPushConf(btn, hostId) { if (!await vvConfirm(`Push conf to ${hostId}?\n\nAssumes SSH key is already installed on ${hostId}.\nSkips key generation/install, goes straight to conf push + local setup.`)) return; @@ -1247,70 +1158,18 @@ function _renderActions(nodes, cfg) { } // ── Mirror onboard (non-owner) ───────────────────────────────────────────── - // - // The mirror's half of phase 1 is ssh_setup.sh doing ssh-copy-id *to the owner*, which - // prompts for the owner's root password on a first install. A WebGUI button cannot answer a - // password prompt, so until the key exists the only honest control here is the same - // terminal + copy-command pair the owner's phase 0 panel has always had. The bare button - // was the sole affordance on this side, which meant the mirror was offered the one route - // that structurally cannot work — it failed on ssh-copy-id every time. - // - // Once the key is in, the button is exactly right, and it becomes the whole panel. + // Rendered by vvRenderMirrorOnboard() in Varaverk.page so the wizard shows the identical + // panel. Only one pages/*.php loads per request, so a copy here could not be reached from + // setup.php — and two copies of a panel this fiddly would drift. if (!isOwner) { const ownerNode = remotes.find(n => n.is_owner) || remotes[0]; - const ownerName = ownerNode?.hostname || 'the owner'; - const myPhase = selfNode?.onboard_phase ?? 0; - - if (myPhase >= 1) { - html += `
-
-
-
Join partnership
-
SSH key ready · notifies ${vvEscHtml(ownerName)} to run Phase 2
-
- -
-
`; - } else { - html += `
-
-
-
- Step 1 - Install SSH key on ${vvEscHtml(ownerName)} -
-
- Open Terminal - ${termCmd} -
-
Enter ${vvEscHtml(ownerName)} root password when prompted · a button cannot answer that prompt
-
-
-
-
-
- Step 2 - Join partnership -
-
Notifies ${vvEscHtml(ownerName)} to run Phase 2 · if key already installed
-
- -
-
-
-
`; - } + html += vvRenderMirrorOnboard({ + ownerName: ownerNode?.hostname, + termBase: termBase, + termCmd: termCmd, + phase: selfNode?.onboard_phase ?? 0, + hasPartner: hasPartner, + }); } // ── Offboard + Transfer ──────────────────────────────────────────────────── diff --git a/Plugin/unraid/pages/setup.php b/Plugin/unraid/pages/setup.php index c452151..fc36e21 100644 --- a/Plugin/unraid/pages/setup.php +++ b/Plugin/unraid/pages/setup.php @@ -51,7 +51,8 @@ $detectedHostname = vv_get_hostname(); // Matching is exact and case-insensitive, never a prefix: two hosts called Tower and Tower2 must // not resolve to each other, and there is no ambiguity to tolerate when the value was written by // the very host it names. -$vvDetected = ['slot' => '', 'primary' => '', 'role' => '', 'networks' => [], 'networks_src' => '']; +$vvDetected = ['slot' => '', 'primary' => '', 'role' => '', 'phase' => 0, + 'networks' => [], 'networks_src' => '']; $vvMasterRaw = vv_read_conf_raw('master.conf'); if ($vvMasterRaw !== '' && $detectedHostname !== '') { preg_match_all('/^\s*(HOST\d+)\s*=\s*"([^"]*)"/m', $vvMasterRaw, $vvHm, PREG_SET_ORDER); @@ -83,6 +84,17 @@ if ($vvMasterRaw !== '' && $detectedHostname !== '') { // // Adopting the owner's list also means docker_network_connect.sh will recreate the network here // after an Unraid update wipes it, which is the whole reason that script exists. +// How far this host's own onboarding has got, so the inline join panel can show the SSH step or +// the join button rather than both. Same flags api/checklist.php reads, and the same two +// spellings, because the state file has been written by both bash and PHP over its life. +if ($vvDetected['slot'] !== '') { + $vvSt = vv_setup_state_read(); + $vvSlUp = strtoupper($vvDetected['slot']); + $vvDetected['phase'] = + (!empty($vvSt[$vvSlUp . '_PHASE2_DONE']) || !empty($vvSt[$vvDetected['slot'] . '_phase2_done'])) ? 2 + : ((!empty($vvSt[$vvSlUp . '_PHASE1_DONE']) || !empty($vvSt[$vvDetected['slot'] . '_phase1_done'])) ? 1 : 0); +} + if ($vvDetected['role'] === 'partner' && $vvDetected['slot'] !== '') { $vvOwnerConf = VV_CONF_RAM_CACHE_DIR . '/host1.conf'; if (is_readable($vvOwnerConf)) { @@ -265,7 +277,7 @@ hr.vv-hr { border: none; border-top: 1px solid #1e1e1e; margin: 22px 0; } - Skip → + Skip →
@@ -273,8 +285,11 @@ hr.vv-hr { border: none; border-top: 1px solid #1e1e1e; margin: 22px 0; }
Setup checklist
Loading…
+
+
+
- Go to Scheduler → + Continue →
@@ -385,11 +400,22 @@ function vvSetBtn(text, disabled) { const b = document.getElementById('vv-main-btn'); if (b) { b.textContent = text; b.disabled = disabled; } } -function vvGoScheduler(e) { +// Named for what it does rather than where it goes — the destination is now the server's +// decision (vv_setup_redirect), because a partner and a primary have different next steps. +function vvGoNext(e) { if (e) e.preventDefault(); window.location.href = _vvRedirect || '?tab=scheduler'; } +// Label the exit after the place it actually leads, so the link is not a surprise. Derived from +// the redirect the save returned, not from the role guessed again on this side. +function vvLabelExit() { + const el = document.getElementById('vv-exit-link'); + if (!el) return; + const target = /tab=partnership/.test(_vvRedirect || '') ? 'Partnership' : 'Scheduler'; + el.textContent = `Go to ${target} →`; +} + // ── Step 2 ──────────────────────────────────────────────────────────────────── function vvShowStep2(redirect, apiKey) { _vvRedirect = redirect || '?tab=scheduler'; @@ -414,9 +440,16 @@ function vvRunPopulate() { }).then(r => r.json()).then(d => { if (d.ok) { const found = (d.lines || []).filter(l => /✅|found|detected/i.test(l)); + // Finding nothing is the expected result on a partner, not a failure: this runs during + // first-run setup, which on a fresh mirror is before the owner has deployed anything to + // find. Onboard Step 11 re-runs discovery once the stacks exist. Saying so beats a line + // that reads like something went wrong on the one screen with no context to judge it. el.textContent = found.length ? '✓ Auto-populate: ' + found.length + ' field' + (found.length > 1 ? 's' : '') + ' detected' - : '✓ Auto-populate ran — arr keys will fill once services are running'; + : (vvDetectedIdentity?.role === 'partner' + ? '✓ Auto-populate ran — nothing to find yet, which is normal here. It runs again ' + + 'automatically once ' + (vvDetectedIdentity.primary || 'the primary') + ' deploys your stack' + : '✓ Auto-populate ran — arr keys will fill once services are running'); el.style.color = '#4a8'; } else { el.textContent = 'Auto-populate skipped — run Deployment/conf_populate.sh once your arr containers are up'; @@ -466,11 +499,73 @@ function vvDeferItem(btn, id, defer) { }); } +// The join, inline, for a partner — the same panel the Partnership tab renders, from the same +// function in Varaverk.page. The wizard used to end by pointing at another tab for the one task +// a fresh mirror still had to do; the SSH step in particular is the only genuinely blocking part +// of onboarding and it was the part you had to go looking for. +// +// Only while it is still outstanding: once the mirror has joined, the partnership row in the +// checklist above says so and a second copy of the panel is noise. +function vvRenderOnboardPanel(d) { + const el = document.getElementById('vv-onboard-panel'); + if (!el) return; + const partner = vvDetectedIdentity?.role === 'partner'; + const pt = (d.items || []).find(i => i.id === 'partnership'); + if (!partner || !pt || pt.ok) { el.innerHTML = ''; return; } + + // Same derivation the Partnership tab uses: the command runs on THIS machine, so it must carry + // this machine's SCRIPTS_DIR, and the terminal link must be this machine's web terminal. + el.innerHTML = '
' + + '
Join the partnership
' + + vvRenderMirrorOnboard({ + ownerName: vvDetectedIdentity.primary, + termBase: `https://${window.location.hostname}/webterminal/ttyd/`, + termCmd: , + phase: vvDetectedIdentity.phase ?? 0, + hasPartner: true, + }); +} + +// Setup finishing used to change nothing on screen. The wizard simply stopped appearing on the +// next load, because host*.conf now existed — so the last thing the operator saw was the same +// list of rows they had been staring at, with no signal that the job was done or what came next. +// +// Says what is left when it is not complete, rather than only celebrating when it is: the count +// of blocking rows is the actual answer to "can I stop now". +function vvRenderDone(d) { + const el = document.getElementById('vv-done-banner'); + if (!el) return; + const partner = vvDetectedIdentity?.role === 'partner'; + + if (d.complete) { + const next = partner + ? `Next: install this server's SSH key on ${vvEscHtml(vvDetectedIdentity.primary || 'the primary')} ` + + `and join, both on the Partnership tab.` + : 'Next: review the schedule, then onboard a partner when you have one.'; + el.innerHTML = `
+ ✓ Setup complete — every required item is done.
+ ${next}
`; + return; + } + + const blocking = (d.items || []).filter(i => !i.ok && i.blocking); + if (!blocking.length) { el.innerHTML = ''; return; } + el.innerHTML = `
+ ${blocking.length} required item${blocking.length > 1 ? 's' : ''} left: + ${blocking.map(i => vvEscHtml(i.label)).join(', ')}
+ Anything marked optional can be dismissed with “Not now”.
`; +} + function vvLoadChecklist() { fetch('/plugins/varaverk/api/checklist.php?_=' + Date.now()) .then(r => r.json()).then(d => { const el = document.getElementById('vv-checklist'); if (!d.ok || !d.items) { el.innerHTML = 'Unable to load checklist'; return; } + vvLabelExit(); + vvRenderOnboardPanel(d); + vvRenderDone(d); el.innerHTML = d.items.map(item => { // Deferred reads as its own state, not as pass and not as fail: the thing is still not // done, the operator has said not now, and the row has to keep saying both.