From cce1e25c2ba1a4be5dbba0e5bc898c1b48545f03 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Sun, 16 Aug 2026 21:56:00 -0400 Subject: [PATCH] Let a non-blocking checklist item be dismissed with a recorded decision, and run discovery on the mirror once there is something to discover --- Partnership/partnership_onboard.sh | 37 +++++++++++++++++++++++++++++ Plugin/unraid/api/checklist.php | 38 +++++++++++++++++++++++++++++- Plugin/unraid/api/setup.php | 33 ++++++++++++++++++++++++++ Plugin/unraid/include/config.php | 8 +++++-- Plugin/unraid/pages/setup.php | 36 +++++++++++++++++++++++++--- 5 files changed, 146 insertions(+), 6 deletions(-) diff --git a/Partnership/partnership_onboard.sh b/Partnership/partnership_onboard.sh index 2de804c..1551c56 100755 --- a/Partnership/partnership_onboard.sh +++ b/Partnership/partnership_onboard.sh @@ -40,6 +40,8 @@ # prevents arrs treating every file as missing after bootstrap # Step 9e: Webhook listener — start listener on mirror (runs continuously, no reboot needed) # Step 10: Conf push — push master.conf + setup state to all listed hosts +# Step 11: Service discovery — conf_populate.sh on the mirror, last, once the stacks it +# would discover are actually deployed there # # ============================================================================================== # DESIGN PRINCIPLES @@ -1008,6 +1010,40 @@ else fi fi +# ── Step 11: Service discovery on the mirror ────────────────────────────────────────────────── +# Deliberately last. conf_populate.sh fills host*.conf from what it can actually find running — +# arr API keys, container names, URLs — and until Step 3 and Step 5 deployed the auth and arr +# stacks there was nothing on the mirror to find. The wizard runs it during first-run setup, +# which on a fresh mirror is precisely the moment the machine is still empty, so everything it +# could have discovered was discovered as absent. +# +# No --overwrite: it only fills blanks, so anything the operator set by hand survives. --no-push +# because Step 10 above has just pushed conf; letting discovery push again would race it. +echo "" +echo "━━━ $ICON_GEAR Step 11 — Service Discovery ($MIRROR) ━━━" + +POPULATE_OK=false +if [[ "$MIRROR_REACHABLE" != true ]]; then + warn "$MIRROR unreachable — skipping discovery, run Deployment/conf_populate.sh there later" + POPULATE_OK=skipped +elif [[ "$DRY_RUN" == true ]]; then + warn "DRY RUN — would run conf_populate.sh --no-push on $MIRROR" + POPULATE_OK=true +else + _mirror_sd=$(resolve_remote_scripts_dir "$MIRROR_IP" "$MIRROR_SSH_KEY" "no") + _pop_script="${_mirror_sd}/Deployment/conf_populate.sh" + if timeout 180 ssh -i "$MIRROR_SSH_KEY" \ + -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \ + root@"$MIRROR_IP" \ + "[ -f '$_pop_script' ] || { echo missing; exit 127; }; bash '$_pop_script' --no-push" 2>/dev/null; then + echo "Discovery complete on $MIRROR ✅" + POPULATE_OK=true + else + warn "Discovery failed on $MIRROR — run $_pop_script there by hand" + fi + unset _mirror_sd _pop_script +fi + # ── Write Phase 2 completion state ──────────────────────────────────────────────────────────── [[ "$ONBOARD_OK" == true && "$DRY_RUN" == false ]] && write_onboard_phase "$MIRROR_ID" 2 @@ -1040,6 +1076,7 @@ echo " Step 9d — Media seed: $( [[ "$SKIP_MEDIA_SEED" == true ]] && ech || echo "${MEDIA_SEED_COUNT}/${#DAILY_SYNC_SHARES[@]} shares $(_ok "$MEDIA_SEED_OK")"; } )" echo " Step 9e — Webhook listener: $(_skip "$SKIP_WEBHOOK_LISTENER" "$WEBHOOK_LISTENER_OK")" echo " Step 10 — Conf push: $( [[ "$ONBOARD_OK" == false ]] && echo "skipped" || echo "$(_ok "$MASTER_PUSH_OK")" )" +echo " Step 11 — Discovery: $( [[ "$POPULATE_OK" == skipped ]] && echo "skipped (unreachable)" || _ok "$POPULATE_OK" )" echo "" if [[ "$ONBOARD_OK" == true ]]; then diff --git a/Plugin/unraid/api/checklist.php b/Plugin/unraid/api/checklist.php index 0939587..1a7f731 100644 --- a/Plugin/unraid/api/checklist.php +++ b/Plugin/unraid/api/checklist.php @@ -232,6 +232,42 @@ if (!empty($host2)) { ]; } -$allOk = !in_array(false, array_column($items, 'ok'), true); +// ── Blocking vs deferrable ──────────────────────────────────────────────────── +// Setup used to be complete only when every item was green, which made a media-server API key a +// gate on finishing onboarding. A mirror that runs no Emby, or runs one the operator has not got +// round to configuring, could never reach "complete" — and the checklist is what tells them +// whether they are done. +// +// Blocking items are the ones the mesh genuinely cannot work without: who this host is, its conf, +// its SSH key, the owner's master.conf, and the partnership itself. Everything else improves the +// install without being load-bearing, and can be dismissed with a recorded decision. +// +// Deferring is per item and reversible, stored in the setup state so it survives a reload. The +// item still shows — amber, "deferred" — rather than disappearing, because a dismissed item is a +// decision to revisit, not a thing that stopped being true. +$deferrable = ['api_key' => true, 'populated' => true, 'emby_key' => true, 'jellyfin_key' => true]; +$state = vv_setup_state_read(); + +foreach ($items as &$item) { + $canDefer = !empty($deferrable[$item['id']]); + $item['blocking'] = !$canDefer; + $item['deferred'] = $canDefer && !$item['ok'] + && !empty($state['DEFER_' . strtoupper($item['id'])]); + if ($item['deferred']) { + $item['detail'] = ($item['detail'] ?? '') . ' — deferred'; + $item['action'] = 'undefer'; + } elseif ($canDefer && !$item['ok']) { + // Keep the item's real action as the primary; the UI offers defer alongside it. + $item['can_defer'] = true; + } +} +unset($item); + +// Complete when every blocking item is green and every deferrable one is green or dismissed. +$allOk = true; +foreach ($items as $i) { + if ($i['ok']) continue; + if ($i['blocking'] || empty($i['deferred'])) { $allOk = false; break; } +} echo json_encode(['ok' => true, 'complete' => $allOk, 'host_id' => $hostId, 'items' => $items]); diff --git a/Plugin/unraid/api/setup.php b/Plugin/unraid/api/setup.php index 2d73ee3..d990e01 100644 --- a/Plugin/unraid/api/setup.php +++ b/Plugin/unraid/api/setup.php @@ -195,6 +195,39 @@ if ($action === 'populate') { exit; } +// ── POST: defer / undefer a non-blocking checklist item ─────────────────────────────────────── +// Records the operator's "not now" so the checklist can reach complete without the item being +// green. Only the ids api/checklist.php marks deferrable are accepted — a blocking item cannot be +// dismissed, because dismissing it would report a mesh as ready when it cannot function. +if ($action === 'defer' || $action === 'undefer') { + // POST only, and not merely by convention: $action is taken from GET too, and Unraid's CSRF + // guard checks POST alone. A GET-reachable mutation here would let any page the operator + // visits silently dismiss a checklist item. + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + echo json_encode(['ok' => false, 'error' => 'Method not allowed']); + exit; + } + $allowed = ['api_key', 'populated', 'emby_key', 'jellyfin_key']; + $item = trim($_POST['item'] ?? ''); + if (!in_array($item, $allowed, true)) { + echo json_encode(['ok' => false, 'error' => 'Not a deferrable item: ' . $item]); + exit; + } + $state = vv_setup_state_read(); + $key = 'DEFER_' . strtoupper($item); + if ($action === 'defer') { + $state[$key] = (string)time(); // when, not just whether — a stale decision shows its age + } else { + unset($state[$key]); + } + if (!vv_setup_state_write($state)) { + echo json_encode(['ok' => false, 'error' => 'Failed to write setup state']); + exit; + } + echo json_encode(['ok' => true, 'item' => $item, 'deferred' => $action === 'defer']); + exit; +} + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['ok' => false, 'error' => 'Method not allowed']); exit; diff --git a/Plugin/unraid/include/config.php b/Plugin/unraid/include/config.php index 4fd0be9..23141a1 100644 --- a/Plugin/unraid/include/config.php +++ b/Plugin/unraid/include/config.php @@ -226,10 +226,14 @@ function vv_setup_state_read(): array { } // Write the setup state file (creates or overwrites). -function vv_setup_state_write(array $data): void { +// Returns whether the write landed. Was void, so a caller had no way to tell a full flash or a +// read-only mount from success — and `if (!vv_setup_state_write(...))` on a void function is +// always true, which turns "it worked" into a reported failure. Existing void callers are +// unaffected by the added return. +function vv_setup_state_write(array $data): bool { $content = ''; foreach ($data as $k => $v) $content .= "$k=$v\n"; - file_put_contents(VV_SETUP_STATE_FILE, $content); + return file_put_contents(VV_SETUP_STATE_FILE, $content) !== false; } // Push the setup state file to all remote hosts via scp. diff --git a/Plugin/unraid/pages/setup.php b/Plugin/unraid/pages/setup.php index dfb6afb..c452151 100644 --- a/Plugin/unraid/pages/setup.php +++ b/Plugin/unraid/pages/setup.php @@ -446,16 +446,40 @@ 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); + }); +} + 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; } el.innerHTML = d.items.map(item => { - const icon = item.ok === null ? '○' : (item.ok ? '✓' : '✗'); - const iclr = item.ok === null ? '#444' : (item.ok ? '#4a8' : '#a66'); + // 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) { + if (item.action === 'undefer') { + act = `
`; + } else if (item.action) { const lbl = vvActionLabels[item.action] || item.action; const href = vvActionHref[item.action]; if (href) { @@ -471,6 +495,12 @@ function vvLoadChecklist() { + `
`; } } + // 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 += `
`; + } return `
${icon}