From e3ec256265a58eecd35983aa81ce3f8b8600fbdf Mon Sep 17 00:00:00 2001 From: librelad Date: Mon, 6 Jul 2026 18:31:15 +0100 Subject: [PATCH] fix(setup): stop "setup complete" from lying about a half-done install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three coupled defects in the first-run wizard flow, all surfacing as "it said complete but nothing was set up": 1. Zero-app installs sailed through. With no apps ticked, setup was just config+finalize, finished in seconds having installed nothing, and fast-forwarded to an empty App Center. Add a self-contained in-wizard confirm ("Install with no apps?") before submitting. Can't reuse the shared confirmation-dialog component — it isn't loaded this early in boot — so the dialog is rendered by the wizard itself and mounted on to escape the aurora surface's FX-stacking rule. 2. finalize declared success unconditionally. It never inspected the per-app install tasks, so a failed app still yielded "your install is ready" + a redirect. Pass the setup group id to `setup finalize`; it now rolls up the group's app-install task results and logs a clear partial/failed verdict. The WebUI completion watcher gates the welcome toast + App Center hand-off on the whole group succeeding, not just on finalize completing — a failed app now keeps the user on the tasks page with an error toast instead of a false all-clear. 3. Setup task count was confusing (banner "of 2" while the page listed 3). On dev/git installs the topbar's dev-mode auto-enable raced the wizard's own CFG_DEV_MODE write and spawned a second, un-grouped config-update task mid-setup. Skip that auto-enable while a setup handoff is active (the wizard already persists CFG_DEV_MODE); it runs on the next load if still needed. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: librelad --- .../backend/routes/setup-routes.js | 4 +- .../frontend/core/setup/css/setup-wizard.css | 56 +++++++++++++++ .../core/setup/js/setup-completion-watcher.js | 15 +++- .../frontend/core/setup/js/setup-wizard.js | 71 +++++++++++++++++++ .../frontend/core/topbar/js/topbar.js | 9 +++ .../cli/commands/setup/cli_setup_commands.sh | 4 +- scripts/setup/setup_apply.sh | 44 ++++++++++++ 7 files changed, 199 insertions(+), 4 deletions(-) diff --git a/containers/libreportal/backend/routes/setup-routes.js b/containers/libreportal/backend/routes/setup-routes.js index 717875a..90ea102 100644 --- a/containers/libreportal/backend/routes/setup-routes.js +++ b/containers/libreportal/backend/routes/setup-routes.js @@ -239,7 +239,9 @@ router.post('/save', requireAuth, async (req, res) => { } const finalizeId = await enqueueTask({ - command: `libreportal setup finalize`, + // Pass the group id so finalize can inspect this run's app-install tasks + // and report whether every selected app actually installed. + command: `libreportal setup finalize ${setupGroup}`, type: 'setup-finalize', setupGroup, setupRole: 'finalize' diff --git a/containers/libreportal/frontend/core/setup/css/setup-wizard.css b/containers/libreportal/frontend/core/setup/css/setup-wizard.css index e7f3277..148c8ca 100755 --- a/containers/libreportal/frontend/core/setup/css/setup-wizard.css +++ b/containers/libreportal/frontend/core/setup/css/setup-wizard.css @@ -936,6 +936,62 @@ body.setup-wizard-open { font-size: 13px; } +/* "No apps selected" confirm — a self-contained dialog layered over the + wizard card (the shared confirmation-dialog component isn't loaded this + early in boot). Backdrop dims the wizard; the box mirrors .setup-card. */ +.setup-confirm { + /* Fixed + explicit offsets (not `inset`/`absolute`) so it reliably covers + the whole wizard regardless of the flex flow it's appended into. */ + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(var(--bg-rgb), 0.55); + backdrop-filter: blur(4px); + opacity: 0; + transition: opacity 0.2s ease; +} +.setup-confirm.active { opacity: 1; } + +.setup-confirm-box { + width: min(440px, 100%); + background: rgba(var(--bg-rgb), 0.92); + border: 1px solid rgba(var(--accent-rgb), 0.28); + border-radius: 16px; + padding: 26px 24px 22px; + text-align: center; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45); + transform: translateY(8px) scale(0.98); + transition: transform 0.2s ease; +} +.setup-confirm.active .setup-confirm-box { transform: translateY(0) scale(1); } + +.setup-confirm-icon { font-size: 34px; line-height: 1; margin-bottom: 10px; } + +.setup-confirm-title { + margin: 0 0 8px; + font-size: 18px; + font-weight: 700; + color: var(--text-primary); +} + +.setup-confirm-text { + margin: 0 0 20px; + font-size: 13.5px; + line-height: 1.5; + color: var(--text-secondary); +} + +.setup-confirm-actions { display: flex; gap: 10px; } +.setup-confirm-actions .setup-btn-back { flex: 1; } +.setup-confirm-proceed { flex: 1.4; } + /* Top-nav disabled state — applied while setup isn't complete. */ .topbar-nav.setup-needed .nav-item { opacity: 0.35; diff --git a/containers/libreportal/frontend/core/setup/js/setup-completion-watcher.js b/containers/libreportal/frontend/core/setup/js/setup-completion-watcher.js index d6377e0..0e5fe5d 100644 --- a/containers/libreportal/frontend/core/setup/js/setup-completion-watcher.js +++ b/containers/libreportal/frontend/core/setup/js/setup-completion-watcher.js @@ -94,6 +94,15 @@ groupTasks.set(t.id, t); } + // True if any task in the setup group ended in a non-success terminal state. + // The finalize task can complete cleanly while an earlier app-install failed — + // in that case setup is "done" but NOT "ready", so we must not show the + // welcome toast or fast-forward to the App Center as if all went well. + function groupHadFailure() { + return Array.from(groupTasks.values()) + .some(t => t && (t.status === 'failed' || t.status === 'cancelled')); + } + function refreshBanner() { if (!isOnTasksPage()) { removeBanner(); return; } const handoff = readHandoff(); @@ -186,7 +195,9 @@ if (!handoff || !handoff.finalizeTaskId) return; if (detail.taskId !== handoff.finalizeTaskId) return; - const success = detail.status === 'completed'; + // Success requires BOTH that finalize completed AND that no app in the + // group failed — otherwise we'd announce "ready" over a half-installed set. + const success = detail.status === 'completed' && !groupHadFailure(); clearHandoff(); // Defer to the next frame so the tasks-manager listener (registered on the @@ -214,7 +225,7 @@ || (window.taskEventBus && window.taskEventBus.getTask(handoff.finalizeTaskId)); if (!task) return; if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') { - const success = task.status === 'completed'; + const success = task.status === 'completed' && !groupHadFailure(); clearHandoff(); removeBanner(); reEnableNav(); diff --git a/containers/libreportal/frontend/core/setup/js/setup-wizard.js b/containers/libreportal/frontend/core/setup/js/setup-wizard.js index 6e1e75d..54b7e92 100755 --- a/containers/libreportal/frontend/core/setup/js/setup-wizard.js +++ b/containers/libreportal/frontend/core/setup/js/setup-wizard.js @@ -702,6 +702,23 @@ class SetupWizard { const apps = Array.from(this.container.querySelectorAll('.setup-app input[type=checkbox][data-app]:checked')) .map(cb => cb.dataset.app); + + // Zero apps is valid but almost always a mistake — the whole point of the + // wizard is to stand up a *working* install. Without a guard, setup would + // finish in seconds having installed nothing and fast-forward the user into + // an empty App Center wondering why "setup complete" left them with nothing. + // Confirm first, with a self-contained in-wizard dialog (the shared + // confirmation-dialog component isn't loaded this early in boot, so we + // can't rely on window.showConfirmation here). _zeroAppsConfirmed lets the + // confirm callback re-enter submit() and skip this branch the second time. + if (apps.length === 0 && !this._zeroAppsConfirmed) { + this._submitting = false; + this.showZeroAppConfirm( + () => { this._zeroAppsConfirmed = true; this.submit(); }, // Continue + () => { this.showStep(3); } // Go back to apps + ); + return; + } // Sub-options live under their parent app and only matter if the parent // is installed. Shape: { crowdsec: { dashboard: true }, ... } const appOptions = {}; @@ -767,6 +784,60 @@ class SetupWizard { } } + // Self-contained "no apps selected" confirm. Rendered inside the wizard's + // own full-screen shell (so it inherits the aurora aesthetic and doesn't + // depend on the shared confirmation-dialog component, which hasn't loaded + // this early in boot). Calls onProceed / onCancel exactly once. + showZeroAppConfirm(onProceed, onCancel) { + // Never stack two. + document.querySelector('.setup-confirm')?.remove(); + + const overlay = document.createElement('div'); + overlay.className = 'setup-confirm'; + overlay.innerHTML = ` + + `; + // Mount on , not the wizard: the wizard is an `.aurora-bg` surface + // whose `.aurora-bg > :not(.aurora-stars){position:relative}` FX-stacking + // rule would otherwise force this fixed overlay back to `relative` and + // collapse it into the flex flow. A modal belongs above everything anyway. + document.body.appendChild(overlay); + // Entrance on next frame so the CSS transition plays. + requestAnimationFrame(() => overlay.classList.add('active')); + + let done = false; + const close = (cb) => { + if (done) return; + done = true; + overlay.classList.remove('active'); + setTimeout(() => overlay.remove(), 250); + if (typeof cb === 'function') cb(); + }; + overlay.querySelector('[data-act="proceed"]').addEventListener('click', () => close(onProceed)); + overlay.querySelector('[data-act="cancel"]').addEventListener('click', () => close(onCancel)); + // Backdrop click / Escape cancels (same as "Pick apps"). + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(onCancel); }); + const onKey = (e) => { + if (e.key === 'Escape') { document.removeEventListener('keydown', onKey); close(onCancel); } + }; + document.addEventListener('keydown', onKey); + setTimeout(() => overlay.querySelector('[data-act="proceed"]')?.focus(), 260); + } + handoffToTasks({ firstTaskId, finalizeTaskId, setupGroup, installName, taskIds }) { try { sessionStorage.setItem('libreportal_setup_handoff', JSON.stringify({ diff --git a/containers/libreportal/frontend/core/topbar/js/topbar.js b/containers/libreportal/frontend/core/topbar/js/topbar.js index 93ef808..9ec5db7 100755 --- a/containers/libreportal/frontend/core/topbar/js/topbar.js +++ b/containers/libreportal/frontend/core/topbar/js/topbar.js @@ -142,6 +142,15 @@ class TopbarComponent { // fields they need to manage their install. Idempotent — runs once per // load; the no-op case (already on, or on release) is silent. async autoEnableDevModeIfNeeded() { + // Don't fire a config-update task while the Setup Wizard is still handing + // off. The wizard already persists CFG_DEV_MODE via its own setup-config + // task; a second loose task here (dev/git installs race the not-yet- + // regenerated configs.json and auto-enable) shows up as an extra, + // un-grouped row and throws off the setup progress count. If dev mode is + // genuinely still needed, this runs on the next normal load. + try { + if (sessionStorage.getItem('libreportal_setup_handoff')) return; + } catch { /* sessionStorage unavailable — fall through to normal behaviour */ } try { const r = await fetch(`/data/config/generated/configs.json?t=${Date.now()}`); if (!r.ok) return; diff --git a/scripts/cli/commands/setup/cli_setup_commands.sh b/scripts/cli/commands/setup/cli_setup_commands.sh index 90c7a84..3d43017 100644 --- a/scripts/cli/commands/setup/cli_setup_commands.sh +++ b/scripts/cli/commands/setup/cli_setup_commands.sh @@ -23,7 +23,9 @@ cliHandleSetupCommands() ;; "finalize") - setupApplyFinalize + # Optional arg: the setup group id, so finalize can roll up the + # per-app install results for this run. + setupApplyFinalize "$arg1" ;; "status") diff --git a/scripts/setup/setup_apply.sh b/scripts/setup/setup_apply.sh index 87b15ac..a8e318f 100644 --- a/scripts/setup/setup_apply.sh +++ b/scripts/setup/setup_apply.sh @@ -89,6 +89,11 @@ setupApplyConfig() setupApplyFinalize() { + # setup_group is passed by the WebUI finalize task so we can read back the + # sibling app-install tasks and tell "install ready" apart from "install + # ran but an app failed". Empty when finalize is invoked standalone. + local setup_group="$1" + isNotice "Initializing backup engine..." if declare -f installResticHost >/dev/null 2>&1; then installResticHost @@ -111,6 +116,45 @@ setupApplyFinalize() fi setupWizardMarkComplete + + # Roll up the per-app install results for this setup group. Each ticked app + # ran as its own `app install` task; the host daemon writes their terminal + # status back into the task JSON before it ever reaches this finalize task + # (FIFO, one at a time), so by now every sibling is settled. We only LOG the + # verdict here — finalize itself still succeeds (it did finalize) and the + # failed app's own task row is already red; the WebUI watcher is what gates + # the "your install is ready" hand-off on this same group-level result. + if [[ -n "$setup_group" ]]; then + local tasks_dir="${containers_dir}libreportal/frontend/data/tasks" + local total=0 failed=0 failed_names="" f + if [[ -d "$tasks_dir" ]]; then + for f in "$tasks_dir"/task_*.json; do + [[ -f "$f" ]] || continue + local grp role + grp=$(jq -r '.setupGroup // empty' "$f" 2>/dev/null) + role=$(jq -r '.setupRole // empty' "$f" 2>/dev/null) + [[ "$grp" == "$setup_group" && "$role" == "app" ]] || continue + total=$((total + 1)) + local st ec app + st=$(jq -r '.status // empty' "$f" 2>/dev/null) + ec=$(jq -r '.exit_code // .exitCode // empty' "$f" 2>/dev/null) + app=$(jq -r '.app // "app"' "$f" 2>/dev/null) + if [[ "$st" == "failed" || "$st" == "cancelled" \ + || ( -n "$ec" && "$ec" != "0" && "$ec" != "null" ) ]]; then + failed=$((failed + 1)) + failed_names+="${failed_names:+, }$app" + fi + done + fi + if [[ "$total" -eq 0 ]]; then + isNotice "No apps were selected — add apps any time from the App Center." + elif [[ "$failed" -eq 0 ]]; then + isSuccessful "All $total selected app(s) installed successfully." + else + isError "$failed of $total app(s) failed to install: ${failed_names}. Setup is marked complete — retry the failed app(s) from the App Center." + fi + fi + isSuccessful "Setup Wizard complete — your install is configured and ready." }