fix(setup): stop "setup complete" from lying about a half-done install
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
<body> 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) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
parent
f4ba7ffe51
commit
e3ec256265
@ -239,7 +239,9 @@ router.post('/save', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const finalizeId = await enqueueTask({
|
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',
|
type: 'setup-finalize',
|
||||||
setupGroup,
|
setupGroup,
|
||||||
setupRole: 'finalize'
|
setupRole: 'finalize'
|
||||||
|
|||||||
@ -936,6 +936,62 @@ body.setup-wizard-open {
|
|||||||
font-size: 13px;
|
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. */
|
/* Top-nav disabled state — applied while setup isn't complete. */
|
||||||
.topbar-nav.setup-needed .nav-item {
|
.topbar-nav.setup-needed .nav-item {
|
||||||
opacity: 0.35;
|
opacity: 0.35;
|
||||||
|
|||||||
@ -94,6 +94,15 @@
|
|||||||
groupTasks.set(t.id, t);
|
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() {
|
function refreshBanner() {
|
||||||
if (!isOnTasksPage()) { removeBanner(); return; }
|
if (!isOnTasksPage()) { removeBanner(); return; }
|
||||||
const handoff = readHandoff();
|
const handoff = readHandoff();
|
||||||
@ -186,7 +195,9 @@
|
|||||||
if (!handoff || !handoff.finalizeTaskId) return;
|
if (!handoff || !handoff.finalizeTaskId) return;
|
||||||
if (detail.taskId !== 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();
|
clearHandoff();
|
||||||
|
|
||||||
// Defer to the next frame so the tasks-manager listener (registered on the
|
// Defer to the next frame so the tasks-manager listener (registered on the
|
||||||
@ -214,7 +225,7 @@
|
|||||||
|| (window.taskEventBus && window.taskEventBus.getTask(handoff.finalizeTaskId));
|
|| (window.taskEventBus && window.taskEventBus.getTask(handoff.finalizeTaskId));
|
||||||
if (!task) return;
|
if (!task) return;
|
||||||
if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') {
|
if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') {
|
||||||
const success = task.status === 'completed';
|
const success = task.status === 'completed' && !groupHadFailure();
|
||||||
clearHandoff();
|
clearHandoff();
|
||||||
removeBanner();
|
removeBanner();
|
||||||
reEnableNav();
|
reEnableNav();
|
||||||
|
|||||||
@ -702,6 +702,23 @@ class SetupWizard {
|
|||||||
|
|
||||||
const apps = Array.from(this.container.querySelectorAll('.setup-app input[type=checkbox][data-app]:checked'))
|
const apps = Array.from(this.container.querySelectorAll('.setup-app input[type=checkbox][data-app]:checked'))
|
||||||
.map(cb => cb.dataset.app);
|
.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
|
// Sub-options live under their parent app and only matter if the parent
|
||||||
// is installed. Shape: { crowdsec: { dashboard: true }, ... }
|
// is installed. Shape: { crowdsec: { dashboard: true }, ... }
|
||||||
const appOptions = {};
|
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 = `
|
||||||
|
<div class="setup-confirm-box" role="dialog" aria-modal="true" aria-labelledby="sw-confirm-title">
|
||||||
|
<div class="setup-confirm-icon" aria-hidden="true">🌑</div>
|
||||||
|
<h3 class="setup-confirm-title" id="sw-confirm-title">Install with no apps?</h3>
|
||||||
|
<p class="setup-confirm-text">
|
||||||
|
You haven't selected any apps, so nothing will be installed. Setup will
|
||||||
|
still finish, but you'll start with an empty install — you can always add
|
||||||
|
apps from the App Center later.
|
||||||
|
</p>
|
||||||
|
<div class="setup-confirm-actions">
|
||||||
|
<button type="button" class="setup-btn-back" data-act="cancel">← Pick apps</button>
|
||||||
|
<button type="button" class="setup-launch setup-confirm-proceed" data-act="proceed">
|
||||||
|
<span>Continue with no apps</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
// Mount on <body>, 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 }) {
|
handoffToTasks({ firstTaskId, finalizeTaskId, setupGroup, installName, taskIds }) {
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem('libreportal_setup_handoff', JSON.stringify({
|
sessionStorage.setItem('libreportal_setup_handoff', JSON.stringify({
|
||||||
|
|||||||
@ -142,6 +142,15 @@ class TopbarComponent {
|
|||||||
// fields they need to manage their install. Idempotent — runs once per
|
// fields they need to manage their install. Idempotent — runs once per
|
||||||
// load; the no-op case (already on, or on release) is silent.
|
// load; the no-op case (already on, or on release) is silent.
|
||||||
async autoEnableDevModeIfNeeded() {
|
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 {
|
try {
|
||||||
const r = await fetch(`/data/config/generated/configs.json?t=${Date.now()}`);
|
const r = await fetch(`/data/config/generated/configs.json?t=${Date.now()}`);
|
||||||
if (!r.ok) return;
|
if (!r.ok) return;
|
||||||
|
|||||||
@ -23,7 +23,9 @@ cliHandleSetupCommands()
|
|||||||
;;
|
;;
|
||||||
|
|
||||||
"finalize")
|
"finalize")
|
||||||
setupApplyFinalize
|
# Optional arg: the setup group id, so finalize can roll up the
|
||||||
|
# per-app install results for this run.
|
||||||
|
setupApplyFinalize "$arg1"
|
||||||
;;
|
;;
|
||||||
|
|
||||||
"status")
|
"status")
|
||||||
|
|||||||
@ -89,6 +89,11 @@ setupApplyConfig()
|
|||||||
|
|
||||||
setupApplyFinalize()
|
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..."
|
isNotice "Initializing backup engine..."
|
||||||
if declare -f installResticHost >/dev/null 2>&1; then
|
if declare -f installResticHost >/dev/null 2>&1; then
|
||||||
installResticHost
|
installResticHost
|
||||||
@ -111,6 +116,45 @@ setupApplyFinalize()
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
setupWizardMarkComplete
|
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."
|
isSuccessful "Setup Wizard complete — your install is configured and ready."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user