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 `
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)}
-
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; }
@@ -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: = json_encode('bash ' . SCRIPTS_DIR . '/Partnership/partnership_onboard.sh --phase1-only') ?>,
+ 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}
+ ${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.