Files
Varaverk/Plugin/unraid/pages/setup.php
T

757 lines
40 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Setup tab. First-run wizard — walks a fresh install through host identity, credentials,
// API key creation, storage mode, and the readiness checklist.
//
// DESIGN PRINCIPLES
// The checklist is derived, never stored. api/checklist.php re-evaluates real state on each
// load, so a step cannot be marked complete while the thing it checks is actually missing.
//
// Setup state is pushed to partners. Once a node knows who it is, that identity is shared
// through vv_push_setup_state() rather than typed twice.
//
// Detection over interrogation. Where a value can be read from a running service, setup
// reads it instead of asking — the same principle conf_populate.sh follows.
//
// OPERATIONAL SAFEGUARDS
// Existing values are not overwritten by detection. A field already filled in stays as it
// is; the wizard fills gaps rather than resetting a working install.
//
// Ambiguous detection is refused rather than guessed — a container prefix matching two
// containers is reported for manual resolution, never picked arbitrarily.
//
// Credentials entered here are written to host*.conf, which is gitignored. They are never
// committed and never leave the node except through the explicit partner push.
//
// RENDERS
// Step-by-step wizard, readiness checklist, credential entry, API key creation
//
// DEPENDS ON
// api/setup.php wizard state and writes
// api/checklist.php live readiness evaluation
// api/create_api_key.php unraid-api key provisioning
// api/storage.php storage-mode selection
// First-run setup wizard — uniform flow for all hosts.
// Step 1: auto-detect environment + server identity form.
// Step 2: auto-populate + guide + checklist.
// master.conf pull (for partner servers) lives in the checklist, not here.
$detectedHostname = vv_get_hostname();
// ── Identity already on disk? ────────────────────────────────────────────────────────────────
// A partner that has been sent the owner's master.conf already knows everything this step asks
// for: the conf names the primary, and it names this machine in one of the HOSTn slots. Asking
// for it again invites a typo in the one field the form itself warns is case-sensitive, and the
// answer is sitting in a file two lines away.
//
// Only a *populated* conf counts. The installer seeds master.conf from the template, where every
// HOSTn is empty — that is a fresh node with no identity, not a detected one.
//
// 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' => '', '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);
$vvSlots = [];
foreach ($vvHm as $m) {
$val = trim($m[2]);
if ($val !== '') $vvSlots[strtolower($m[1])] = $val;
}
foreach ($vvSlots as $slot => $name) {
if (strcasecmp($name, $detectedHostname) === 0) {
$vvDetected['slot'] = $slot;
$vvDetected['role'] = ($slot === 'host1') ? 'primary' : 'partner';
break;
}
}
// The primary is whatever HOST1 says, and it is only useful to a host that is not HOST1.
if ($vvDetected['role'] === 'partner') $vvDetected['primary'] = $vvSlots['host1'] ?? '';
}
// ── How far this host's own onboarding has got ───────────────────────────────────────────────
// Lets the inline join panel 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);
}
// ── Custom docker networks, adopted from the owner ───────────────────────────────────────────
// master.conf names the hosts; it does not name the networks, which live in each host's own
// host*.conf. The owner's copy is available here anyway: onboard Phase 1 caches it into
// VV_CONF_RAM_CACHE_DIR as soon as SSH works, before this node has finished setup.
//
// This is the value a fresh mirror cannot know and cannot be expected to type. host.conf.template
// ships NETWORK_CONNECT_NETWORKS with its only entry commented out, so a mirror comes up with an
// empty list while the owner deploys containers onto a network named in the owner's templates —
// which is exactly how twelve containers ended up created against a network that did not exist.
//
// 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.
if ($vvDetected['role'] === 'partner' && $vvDetected['slot'] !== '') {
$vvOwnerConf = VV_CONF_RAM_CACHE_DIR . '/host1.conf';
if (is_readable($vvOwnerConf)) {
$vvOwnerRaw = (string)@file_get_contents($vvOwnerConf);
$vvNets = vv_parse_conf_list($vvOwnerRaw, 'HOST1_NETWORK_CONNECT_NETWORKS');
// br* is ipvlan/macvlan tied to the owner's own hardware — its parent interface does not
// transfer, and adopting the name would attach this host's containers to the wrong thing.
$vvNets = array_values(array_filter($vvNets, fn($n) =>
$n !== '' && !preg_match('/^(bridge|host|none|br\d)/i', $n)));
if ($vvNets) {
$vvDetected['networks'] = $vvNets;
$vvDetected['networks_src'] = basename($vvOwnerConf);
}
}
}
?>
<link rel="stylesheet" href="/plugins/varaverk/css/varaverk.css">
<style>
#vv-setup {
max-width: 580px; margin: 40px auto 0;
background: #141414; border: 1px solid #2a2a2a;
border-radius: 6px; padding: 36px 40px 40px;
font-family: monospace; color: #ccc;
}
#vv-setup h1 { margin: 0 0 4px; font-size: 17px; color: #e0e0e0; font-weight: normal; letter-spacing: .04em; }
.vv-sub { font-size: 12px; color: #555; margin-bottom: 28px; }
.vv-field { margin-bottom: 18px; }
.vv-field label { display: block; font-size: 11px; color: #888; margin-bottom: 5px; text-transform: uppercase; letter-spacing: .06em; }
.vv-field input[type=text],
.vv-field select {
width: 100%; box-sizing: border-box; background: #0d0d0d;
border: 1px solid #333; color: #ddd; padding: 7px 10px;
border-radius: 3px; font-family: monospace; font-size: 13px;
}
.vv-field input:focus, .vv-field select:focus { outline: none; border-color: #555; }
.vv-hint { font-size: 11px; color: #555; margin-top: 4px; }
.vv-role-row { display: flex; gap: 10px; margin-bottom: 22px; }
.vv-role-btn { flex: 1; padding: 9px 0; background: #1a1a1a; border: 1px solid #333;
border-radius: 3px; color: #777; font-family: monospace; font-size: 12px;
cursor: pointer; text-align: center; transition: border-color .15s, color .15s; }
.vv-role-btn.active { border-color: #555; color: #ccc; background: #1e1e1e; }
.vv-cond { display: none; }
.vv-cond.show { display: block; }
hr.vv-hr { border: none; border-top: 1px solid #1e1e1e; margin: 22px 0; }
.vv-btn { width: 100%; padding: 10px; background: #1e1e1e; border: 1px solid #444;
color: #ccc; font-family: monospace; font-size: 13px; border-radius: 3px;
cursor: pointer; letter-spacing: .03em; }
.vv-btn:hover { border-color: #666; color: #eee; }
.vv-btn:disabled { opacity: .4; cursor: default; }
#vv-status { margin-top: 10px; font-size: 12px; color: #666; text-align: center; min-height: 16px; }
#vv-status.ok { color: #4a8; }
#vv-status.err { color: #a44; }
/* Detection banner */
#vv-detect-banner {
background: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 3px;
padding: 11px 14px; margin-bottom: 22px; font-size: 12px; line-height: 1.8; color: #666;
}
#vv-detect-banner .vv-det-row { display: flex; gap: 8px; }
#vv-detect-banner .vv-det-lbl { color: #555; min-width: 100px; }
#vv-detect-banner .vv-det-val { color: #999; }
#vv-detect-banner .loading { color: #444; font-style: italic; }
/* Step 2 */
#vv-step2 { display: none; }
.vv-guide {
background: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 3px;
padding: 13px 16px; margin-bottom: 20px; font-size: 12px; color: #666; line-height: 1.9;
}
.vv-guide ol { margin: 8px 0 0 16px; padding: 0; }
.vv-guide li { margin-bottom: 3px; }
.vv-cl-title { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: .06em; margin-bottom: 10px; }
.vv-cl-item { display: flex; align-items: flex-start; gap: 10px; padding: 7px 0;
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
.vv-cl-item:last-child { border-bottom: none; }
.vv-cl-icon { font-size: 13px; min-width: 16px; margin-top: 1px; }
.vv-cl-body { flex: 1; }
.vv-cl-label { color: #bbb; }
.vv-cl-detail{ color: #555; font-size: 11px; margin-top: 2px; }
.vv-cl-act { margin-top: 5px; }
.vv-cl-act button { padding: 4px 10px; background: #1a1a1a; border: 1px solid #333; color: #888;
font-family: monospace; font-size: 11px; border-radius: 2px; cursor: pointer; }
.vv-cl-act button:hover { border-color: #555; color: #bbb; }
.vv-cl-err { font-size: 11px; color: #a44; margin-top: 4px; }
</style>
<div id="vv-setup">
<h1>⬡ Varaverk — First Run</h1>
<div class="vv-sub">Set up this server before the plugin can start.</div>
<!-- ── Step 1: Detection + identity ──────────────────────────────────────── -->
<div id="vv-step1">
<div id="vv-detect-banner"><div class="loading">Detecting environment…</div></div>
<div class="vv-field">
<label>Storage mode</label>
<div class="vv-role-row" style="margin-bottom:4px">
<div class="vv-role-btn" id="vv-store-flash" onclick="vvSetStorage('flash')">
Appdata<br><span style="color:#555;font-size:10px;">USB boot · requires array</span>
</div>
<div class="vv-role-btn" id="vv-store-internal" onclick="vvSetStorage('internal')">
Internal Boot<br><span style="color:#555;font-size:10px;">NVMe/SSD · no array dep</span>
</div>
</div>
<div id="vv-store-hint" class="vv-hint"></div>
</div>
<div class="vv-field">
<label>This server's hostname</label>
<input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">
<div class="vv-hint">Must match Unraid Settings → Identification exactly (case-sensitive)</div>
</div>
<hr class="vv-hr">
<label style="display:block;font-size:11px;color:#888;text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px;">Server role</label>
<div class="vv-role-row">
<div class="vv-role-btn active" id="vv-role-primary" onclick="vvSetRole('primary')">
Primary<br><span style="color:#555;font-size:10px;">HOST1 · first server</span>
</div>
<div class="vv-role-btn" id="vv-role-partner" onclick="vvSetRole('partner')">
Partner<br><span style="color:#555;font-size:10px;">HOST2+ · joining primary</span>
</div>
</div>
<div class="vv-cond" id="vv-cond-primary">
<div class="vv-field">
<label>Partner's hostname <span style="color:#444;font-size:10px;">(optional — can fill in later)</span></label>
<input type="text" id="vv-partner-hostname" value="" placeholder="unRAID-PartnerServer" autocomplete="off" spellcheck="false">
</div>
</div>
<div class="vv-cond" id="vv-cond-partner">
<div class="vv-field">
<label>Primary server's hostname <span style="color:#a44;font-size:10px;">required</span></label>
<input type="text" id="vv-primary-hostname" value="" placeholder="unRAID-PrimaryServer" autocomplete="off" spellcheck="false">
</div>
<div class="vv-field">
<label>Your slot</label>
<select id="vv-partner-slot">
<option value="host2">HOST2</option>
<option value="host3">HOST3</option>
<option value="host4">HOST4</option>
</select>
</div>
<div style="font-size:11px;color:#555;margin-bottom:4px;">
SSH key and master.conf pull are handled automatically after save.
</div>
</div>
<button class="vv-btn" id="vv-main-btn" onclick="vvDoSave()">Save and continue →</button>
<div id="vv-status"></div>
</div>
<!-- ── Step 2: Populate + guide + checklist ───────────────────────────────── -->
<div id="vv-step2">
<hr class="vv-hr">
<div style="font-size:10px;color:#555;text-transform:uppercase;letter-spacing:.06em;margin-bottom:14px;">Step 2 of 2</div>
<div id="vv-populate-status" style="font-size:12px;color:#555;margin-bottom:14px;">⟳ Running auto-populate…</div>
<div class="vv-guide">
<strong style="color:#888;">Quick start</strong>
<ol>
<li>Create your Unraid API key below — needed for live monitor stats</li>
<li>Open <strong>Scheduler → Edit host.conf</strong> — a few things need manual entry:<br>
<span style="color:#444;">
<code>HOST1_EMBY_API_KEY</code> — Emby Dashboard → API Keys → + New Key<br>
<code>HOST1_JELLYFIN_API_KEY</code> — Jellyfin Dashboard → Administration → API Keys (if using Jellyfin)<br>
<code>HOST1_DISCORD_WEBHOOK</code> — for notifications (optional)<br>
<code>HOST1_DAILY_SYNC_SHARES</code> — media paths to rsync nightly<br>
Everything else was auto-populated or has working defaults
</span></li>
<li>If partnering: the checklist below will guide you through pulling HOST1's config and running onboard</li>
</ol>
</div>
<div style="display:flex;gap:10px;align-items:center;margin-bottom:14px;">
<button id="vv-key-btn" onclick="vvCreateKey(this)" class="vv-btn" style="flex:1;background:#1a3a1a;border-color:#2e6b2e;color:#6fcf97;">
Create API Key
</button>
<a href="#" onclick="vvGoNext(event)" style="font-size:11px;color:#444;text-decoration:none;white-space:nowrap;">Skip →</a>
</div>
<div id="vv-key-status" style="font-size:12px;min-height:14px;margin-bottom:18px;"></div>
<hr class="vv-hr">
<div class="vv-cl-title">Setup checklist</div>
<div id="vv-checklist"><div style="font-size:12px;color:#444;">Loading…</div></div>
<div id="vv-onboard-panel"></div>
<div id="vv-done-banner"></div>
<div style="margin-top:18px;text-align:right;">
<a href="#" id="vv-exit-link" onclick="vvGoNext(event)" style="font-size:12px;color:#444;text-decoration:none;">Continue →</a>
</div>
</div>
</div>
<script>
let _vvRedirect = '?tab=scheduler';
let _vvStorageMode = 'flash';
let _vvCurrentDir = '';
function vvSetStorage(mode) {
_vvStorageMode = mode;
document.getElementById('vv-store-flash')?.classList.toggle('active', mode === 'flash');
document.getElementById('vv-store-internal')?.classList.toggle('active', mode === 'internal');
const hint = document.getElementById('vv-store-hint');
if (hint) hint.textContent = mode === 'flash'
? 'Scripts live in appdata — requires array to be started. Recommended for USB flash boot.'
: 'Scripts live on /boot — available before array mounts. Requires NVMe/SSD boot.';
}
// ── Detection banner ──────────────────────────────────────────────────────────
(function() {
const _ac = new AbortController();
setTimeout(() => _ac.abort(), 6000);
fetch('/plugins/varaverk/api/setup.php?action=detect&_=' + Date.now(), {signal: _ac.signal})
.then(r => r.json()).then(d => {
const b = document.getElementById('vv-detect-banner');
if (!d.ok) { b.innerHTML = '<span style="color:#555">Detection unavailable</span>'; return; }
_vvCurrentDir = d.scripts_dir || '';
b.innerHTML =
'<div class="vv-det-row"><span class="vv-det-lbl">OS</span><span class="vv-det-val">Unraid ' + (d.unraid_ver||'') + '</span></div>' +
'<div class="vv-det-row"><span class="vv-det-lbl">Boot device</span><span class="vv-det-val">' + d.boot_device + ' (' + d.transport + ')</span></div>' +
'<div class="vv-det-row"><span class="vv-det-lbl">Scripts dir</span><span class="vv-det-val" style="color:#666">' + d.scripts_dir + '</span></div>';
vvSetStorage(d.mode);
const hf = document.getElementById('vv-hostname');
if (hf && !hf.value.trim()) hf.value = d.hostname;
// After the banner is rebuilt, never before — this appends to it, and the line above
// replaces its innerHTML wholesale.
vvApplyDetectedIdentity();
}).catch(() => {
document.getElementById('vv-detect-banner').innerHTML = '<span style="color:#444">Detection unavailable</span>';
// Identity comes off the local conf, not from the probe, so it still applies when the
// environment probe is the thing that failed.
vvApplyDetectedIdentity();
});
})();
// ── Role toggle ───────────────────────────────────────────────────────────────
let vvRole = 'primary';
function vvSetRole(role) {
vvRole = role;
document.getElementById('vv-role-primary')?.classList.toggle('active', role === 'primary');
document.getElementById('vv-role-partner')?.classList.toggle('active', role === 'partner');
document.getElementById('vv-cond-primary')?.classList.toggle('show', role === 'primary');
document.getElementById('vv-cond-partner')?.classList.toggle('show', role === 'partner');
}
// ── Identity detected from master.conf ────────────────────────────────────────
// Applied, not enforced. Every field stays editable: the conf is evidence of what the owner
// intended, and if it is wrong the person standing at the machine is the one who can say so.
// What this removes is the need to retype three values that are already known — including the
// hostname field whose own hint warns it is case-sensitive.
const vvDetectedIdentity = <?= json_encode($vvDetected) ?>;
function vvApplyDetectedIdentity() {
const d = vvDetectedIdentity;
if (!d || !d.slot) return;
vvSetRole(d.role);
if (d.role === 'partner') {
const pn = document.getElementById('vv-primary-hostname');
if (pn && d.primary) pn.value = d.primary;
const sel = document.getElementById('vv-partner-slot');
if (sel && [...sel.options].some(o => o.value === d.slot)) sel.value = d.slot;
}
const banner = document.getElementById('vv-detect-banner');
if (banner) {
const note = document.createElement('div');
note.style.cssText = 'margin-top:8px;padding:7px 10px;border-radius:3px;background:#0d1f0d;'
+ 'border:1px solid #1a3a1a;color:#4caf50;font-size:11px;line-height:1.5;';
note.textContent = d.role === 'partner'
? `Identity read from master.conf — this server is ${d.slot.toUpperCase()}, joining `
+ `${d.primary}. Change anything below if that is wrong.`
: 'Identity read from master.conf — this server is HOST1, the primary.';
banner.appendChild(note);
// Named separately from identity: this one is adopted from the owner's cached host conf,
// not from master.conf, and it is the value the operator would otherwise have to know.
if (Array.isArray(d.networks) && d.networks.length) {
const nets = document.createElement('div');
nets.style.cssText = 'margin-top:6px;padding:7px 10px;border-radius:3px;background:#0d1a28;'
+ 'border:1px solid #1a3a5a;color:#7ab;font-size:11px;line-height:1.5;';
nets.textContent = `Custom network${d.networks.length > 1 ? 's' : ''} adopted from `
+ `${d.primary || 'the primary'}: ${d.networks.join(', ')} — added to this `
+ `server's conf on save, so the containers it deploys here have somewhere to land.`;
banner.appendChild(nets);
}
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function vvSetStatus(msg, cls) {
const s = document.getElementById('vv-status');
s.textContent = msg; s.className = cls || '';
}
function vvSetBtn(text, disabled) {
const b = document.getElementById('vv-main-btn');
if (b) { b.textContent = text; b.disabled = disabled; }
}
// 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';
document.getElementById('vv-step1').style.display = 'none';
document.getElementById('vv-step2').style.display = 'block';
if (apiKey && apiKey.ok) {
const btn = document.getElementById('vv-key-btn');
const status = document.getElementById('vv-key-status');
if (btn) { btn.textContent = 'Created ✓'; btn.disabled = true; btn.style.opacity = '.6'; }
if (status) { status.textContent = '✓ API key created automatically'; status.style.color = '#4a8'; }
}
vvRunPopulate();
vvLoadChecklist();
}
// ── Populate ──────────────────────────────────────────────────────────────────
function vvRunPopulate() {
const el = document.getElementById('vv-populate-status');
fetch('/plugins/varaverk/api/setup.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({action: 'populate'})
}).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'
: (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';
el.style.color = '#555';
}
vvLoadChecklist();
}).catch(() => {
el.textContent = 'Auto-populate unavailable — run manually from Scheduler';
el.style.color = '#555';
});
}
// ── Checklist ─────────────────────────────────────────────────────────────────
const vvActionLabels = {
create_key: 'Create API key',
ssh_setup: 'Generate SSH key',
run_populate: 'Run now',
pull_master: 'Pull from HOST1',
onboard: 'Partnership tab →',
};
// ssh_setup used to be a link to the Partnership tab — a guide, on the one row where the reader
// has no key and therefore cannot do the thing the guide describes. It generates the key here
// now, through api/setup.php?action=ssh_generate, which already existed and had never been
// called from anywhere. The partner path needs this: 'Pull from HOST1' authenticates with the
// key this row creates, so sending someone away to read about it left the next row unusable.
const vvActionHref = {
onboard: '?tab=partnership',
};
// URLSearchParams, not FormData — a FormData fetch POST hangs on this platform with no status
// and no server-side trace. The fetch shim in Varaverk.page adds the CSRF header either way.
function vvDeferItem(btn, id, defer) {
btn.disabled = true;
btn.textContent = defer ? 'Deferring…' : 'Restoring…';
fetch('/plugins/varaverk/api/setup.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({action: defer ? 'defer' : 'undefer', item: id})
}).then(r => r.text()).then(text => {
if (!text.trim()) throw new Error('Empty response — request rejected before it reached setup.php');
const d = JSON.parse(text);
if (!d.ok) throw new Error(d.error || 'Unknown error');
vvLoadChecklist();
}).catch(e => {
btn.disabled = false;
btn.textContent = defer ? 'Not now' : 'Undo';
vvAlert('Could not update: ' + e.message);
});
}
// 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; }
vvScriptsDir(<?= json_encode(SCRIPTS_DIR) ?>).then(dir => {
el.innerHTML = '<hr class="vv-hr">'
+ '<div class="vv-cl-title">Join the partnership</div>'
+ vvRenderMirrorOnboard({
ownerName: vvDetectedIdentity.primary,
// This host's own web terminal — the command runs here, not on the owner.
termBase: `https://${window.location.hostname}/webterminal/ttyd/`,
termCmd: `bash ${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 = `<div style="margin-top:16px;padding:10px 12px;border-radius:3px;background:#0d1f0d;
border:1px solid #1a3a1a;color:#4caf50;font-size:12px;line-height:1.6;">
✓ Setup complete — every required item is done.<br>
<span style="color:#3a7a3a;">${next}</span></div>`;
return;
}
const blocking = (d.items || []).filter(i => !i.ok && i.blocking);
if (!blocking.length) { el.innerHTML = ''; return; }
el.innerHTML = `<div style="margin-top:16px;padding:10px 12px;border-radius:3px;background:#1a1400;
border:1px solid #3a2e00;color:#a80;font-size:12px;line-height:1.6;">
${blocking.length} required item${blocking.length > 1 ? 's' : ''} left:
<span style="color:#886;">${blocking.map(i => vvEscHtml(i.label)).join(', ')}</span><br>
<span style="color:#665;">Anything marked optional can be dismissed with “Not now”.</span></div>`;
}
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 = '<span style="color:#555">Unable to load checklist</span>'; 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.
const icon = item.deferred ? '' : (item.ok === null ? '○' : (item.ok ? '✓' : '✗'));
const iclr = item.deferred ? '#a80' : (item.ok === null ? '#444' : (item.ok ? '#4a8' : '#a66'));
let act = '';
if (item.action === 'undefer') {
act = `<div class="vv-cl-act"><button onclick="vvDeferItem(this,'${item.id}',false)">Undo</button></div>`;
} else if (item.action) {
const lbl = vvActionLabels[item.action] || item.action;
const href = vvActionHref[item.action];
if (href) {
act = `<div class="vv-cl-act"><a href="${href}" class="localURL" style="font-size:11px;color:#556;">${lbl}</a></div>`;
} else if (item.action === 'create_key') {
act = `<div class="vv-cl-act"><button onclick="vvCreateKey(this)">${lbl}</button></div>`;
} else if (item.action === 'run_populate') {
act = `<div class="vv-cl-act"><button onclick="vvRunPopulateBtn(this)">${lbl}</button></div>`;
} else if (item.action === 'pull_master') {
act = `<div class="vv-cl-act"><button onclick="vvPullMaster(this)">${lbl}</button><div id="vv-pull-err" class="vv-cl-err"></div></div>`;
} else if (item.action === 'ssh_setup') {
act = `<div class="vv-cl-act"><button onclick="vvSshGenerate(this)">${lbl}</button>`
+ `<div id="vv-ssh-out" class="vv-cl-err"></div></div>`;
}
}
// Offered alongside the real action, never instead of it — "not now" is a second choice
// on a row that still tells you how to do the thing.
if (item.can_defer) {
act += `<div class="vv-cl-act"><button onclick="vvDeferItem(this,'${item.id}',true)"
style="background:#1a1400;border-color:#3a2e00;color:#a80;">Not now</button></div>`;
}
return `<div class="vv-cl-item">
<div class="vv-cl-icon" style="color:${iclr}">${icon}</div>
<div class="vv-cl-body">
<div class="vv-cl-label">${item.label}</div>
<div class="vv-cl-detail">${item.detail || ''}</div>
${act}
</div>
</div>`;
}).join('');
}).catch(() => {});
}
// ── SSH key ───────────────────────────────────────────────────────────────────
// Generates this node's keypair and shows the public half. Generating it is the easy part; the
// key is useless until the other host trusts it, and that is a step no button here can take —
// so the public key is put on screen, in something selectable, rather than described.
//
// A readonly textarea rather than a copy button: the clipboard API needs a secure context and
// Unraid is routinely reached over plain http on the LAN, where it is simply undefined.
function vvSshGenerate(btn) {
btn.disabled = true; btn.textContent = '⟳ Generating…';
const out = document.getElementById('vv-ssh-out');
if (out) { out.textContent = ''; out.style.color = ''; }
fetch('/plugins/varaverk/api/setup.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({action: 'ssh_generate'})
}).then(r => r.json()).then(d => {
if (d.ok && d.pubkey) {
btn.textContent = '✓ Created';
if (out) {
out.style.color = '#8a8a8a';
// Built as nodes, not innerHTML — the key and its comment come off the filesystem and
// land in the page either way, so there is no reason to send them through a parser.
const p = document.createElement('div');
p.textContent = 'Key created. It is not trusted until this public key is on the other '
+ 'host — append it to /root/.ssh/authorized_keys there:';
p.style.cssText = 'margin-bottom:6px;line-height:1.5;';
const ta = document.createElement('textarea');
ta.readOnly = true; ta.value = d.pubkey; ta.rows = 3;
ta.style.cssText = 'width:100%;font-family:monospace;font-size:10px;background:#111;'
+ 'color:#8a8a8a;border:1px solid #2a2a2a;border-radius:3px;padding:6px;';
ta.onclick = () => ta.select();
out.appendChild(p); out.appendChild(ta);
}
setTimeout(vvLoadChecklist, 800);
} else {
if (out) { out.style.color = '#ef5350'; out.textContent = d.error || 'Key generation failed'; }
btn.disabled = false; btn.textContent = 'Retry';
}
}).catch(() => { btn.disabled = false; btn.textContent = 'Retry'; });
}
function vvRunPopulateBtn(btn) {
btn.disabled = true; btn.textContent = '…';
fetch('/plugins/varaverk/api/setup.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({action: 'populate'})
}).then(() => { btn.textContent = 'Done'; vvLoadChecklist(); })
.catch(() => { btn.disabled = false; btn.textContent = 'Retry'; });
}
function vvPullMaster(btn) {
btn.disabled = true; btn.textContent = '⟳ Pulling…';
const errEl = document.getElementById('vv-pull-err');
if (errEl) errEl.textContent = '';
fetch('/plugins/varaverk/api/setup.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({action: 'pull'})
}).then(r => r.json()).then(d => {
if (d.ok) {
btn.textContent = '✓ Done';
setTimeout(vvLoadChecklist, 600);
} else {
if (errEl) errEl.textContent = d.error || 'Pull failed';
btn.disabled = false; btn.textContent = 'Retry';
}
}).catch(() => { btn.disabled = false; btn.textContent = 'Retry'; });
}
// ── API key ───────────────────────────────────────────────────────────────────
function vvCreateKey(btn) {
const status = document.getElementById('vv-key-status');
btn.disabled = true; btn.textContent = '⟳ Creating…';
fetch('/plugins/varaverk/api/create_api_key.php', { method: 'POST' })
.then(r => r.json()).then(d => {
if (d.ok) {
status.textContent = '✓ Key created — ' + d.key_preview;
status.style.color = '#4a8';
btn.textContent = 'Created ✓'; btn.style.opacity = '.6';
vvLoadChecklist();
} else {
status.textContent = '✗ ' + (d.error || 'Failed');
status.style.color = '#a44';
btn.disabled = false; btn.textContent = 'Retry';
}
}).catch(e => {
status.textContent = '✗ ' + e; status.style.color = '#a44';
btn.disabled = false; btn.textContent = 'Retry';
});
}
// ── Save ──────────────────────────────────────────────────────────────────────
function vvDoSave() {
const hostname = document.getElementById('vv-hostname')?.value.trim();
if (!hostname) { vvSetStatus('✗ Hostname is required', 'err'); return; }
let host1 = '', host2 = '', mySlot = 'host1';
if (vvRole === 'primary') {
host1 = hostname;
host2 = document.getElementById('vv-partner-hostname')?.value.trim() || '';
mySlot = 'host1';
} else {
const primary = document.getElementById('vv-primary-hostname')?.value.trim();
if (!primary) { vvSetStatus('✗ Primary hostname required', 'err'); return; }
mySlot = document.getElementById('vv-partner-slot')?.value || 'host2';
host1 = primary;
if (mySlot === 'host2') host2 = hostname;
}
vvSetBtn('Saving…', true);
// Networks come from the owner's cached conf, not from any field — there is nothing for the
// operator to type and nothing to get wrong. Empty on the primary, and on a partner whose
// owner conf has not been cached yet; the save handler treats absent as "nothing to adopt".
const networks = (vvDetectedIdentity?.networks ?? []).join(',');
fetch('/plugins/varaverk/api/setup.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({action:'save', host1, host2, my_slot:mySlot, my_hostname:hostname, storage_mode:_vvStorageMode, networks})
}).then(r => r.json()).then(d => {
if (d.ok) {
if (d.needs_migration) {
const dest = d.migrate_to === 'flash' ? 'appdata' : '/boot';
vvSetStatus('⟳ Migrating scripts to ' + dest + '…', '');
vvDoMigration(d.migrate_to, d.redirect || '?tab=scheduler', d.api_key);
} else {
vvShowStep2(d.redirect || '?tab=scheduler', d.api_key);
}
} else { vvSetBtn('Save and continue →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }
}).catch(() => { vvSetBtn('Save and continue →', false); vvSetStatus('✗ Request failed', 'err'); });
}
function vvDoMigration(to, redirect, apiKey) {
fetch('/plugins/varaverk/api/storage.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({action: 'migrate', to})
}).then(r => r.json()).then(d => {
if (d.ok) {
vvShowStep2(redirect, apiKey);
} else {
vvSetBtn('Save and continue →', false);
vvSetStatus('✗ Migration failed — ' + (d.error || 'check install.log'), 'err');
}
}).catch(() => {
vvSetBtn('Save and continue →', false);
vvSetStatus('✗ Migration request failed', 'err');
});
}
</script>