From cea653f67ba3efdb447077b2aa4812d8678f0a1a Mon Sep 17 00:00:00 2001 From: librelad Date: Tue, 25 Aug 2026 22:45:10 +0100 Subject: [PATCH] feat(setup): Storage step in the first-run wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of docs/roadmap/storage-locations.md. The step appears only when the candidate scan finds a filesystem LibrePortal isn't already using, so the single-disk case โ€” which is most boxes โ€” is completely unchanged. It sits before Recommended because a location has to exist before an app can be placed on it. Supporting two conditional steps meant the wizard could no longer treat 'position in the DOM' and 'step index' as the same number: Metrics was advanced-only and got away with 'length minus one', but a step hidden in the MIDDLE leaves a gap. Navigation, progress, validation and submit now all run off _visibleSteps(), and section matching is by data-step rather than DOM position. Unusable candidates render greyed WITH the reason rather than being filtered out โ€” 'why isn't my drive listed?' is a support burden, and 'exFAT can't store file ownership' is actionable. The step is skipped only when nothing usable was found at all. What the wizard sends is a request, not an instruction: setupApplyConfig feeds each path through storageAdd, so the fitness checks and the root helper's admission rules both re-run regardless of what arrived in the payload. Co-Authored-By: Claude Opus 5 --- .../frontend/core/setup/css/setup-wizard.css | 37 ++++ .../frontend/core/setup/js/setup-wizard.js | 172 ++++++++++++++++-- scripts/setup/setup_apply.sh | 25 +++ scripts/source/files/arrays/files_webui.sh | 1 + .../source/files/arrays/function_manifest.sh | 3 + .../system/webui_storage_candidates.sh | 94 ++++++++++ 6 files changed, 315 insertions(+), 17 deletions(-) create mode 100644 scripts/webui/data/generators/system/webui_storage_candidates.sh diff --git a/containers/libreportal/frontend/core/setup/css/setup-wizard.css b/containers/libreportal/frontend/core/setup/css/setup-wizard.css index e7f3277..93f8d4d 100755 --- a/containers/libreportal/frontend/core/setup/css/setup-wizard.css +++ b/containers/libreportal/frontend/core/setup/css/setup-wizard.css @@ -1211,3 +1211,40 @@ body.setup-wizard-open { .setup-dev-strip .setup-dev-strip-icon, .setup-dev-strip .setup-dev-strip-text { animation-duration: .01ms; } } + +/* ---- Storage step ------------------------------------------------------- + Candidate drives reuse the .setup-app card so the step matches Recommended + visually. The only additions are the verdict badge and the greyed state for + a drive that cannot hold app data โ€” shown WITH its reason rather than hidden, + because "why isn't my drive listed?" is a support burden we don't need. */ +.setup-storage-badge { + display: inline-block; + margin-left: 8px; + padding: 1px 7px; + border-radius: 10px; + font-size: 0.72em; + font-weight: 600; + letter-spacing: 0.02em; + vertical-align: middle; +} +.setup-storage-badge-warn { + background: rgba(224, 168, 0, 0.16); + color: #b98900; + border: 1px solid rgba(224, 168, 0, 0.35); +} +.setup-storage-badge-bad { + background: rgba(200, 60, 60, 0.14); + color: #c04040; + border: 1px solid rgba(200, 60, 60, 0.32); +} +.setup-storage-card em { + opacity: 0.85; + font-style: normal; +} +.setup-storage-disabled { + opacity: 0.55; + cursor: not-allowed; +} +.setup-storage-disabled input { + cursor: not-allowed; +} diff --git a/containers/libreportal/frontend/core/setup/js/setup-wizard.js b/containers/libreportal/frontend/core/setup/js/setup-wizard.js index a49b063..1c1b649 100755 --- a/containers/libreportal/frontend/core/setup/js/setup-wizard.js +++ b/containers/libreportal/frontend/core/setup/js/setup-wizard.js @@ -18,17 +18,42 @@ class SetupWizard { // visible step count is dynamic (4 for beginner, 5 for advanced). // installLevel defaults to 'beginner' so a user who races through // step 0 without touching anything gets the safe-default UX. - this.stepNames = ['Experience', 'Identity', 'Domains', 'Recommended', 'Metrics']; - this.stepIcons = ['๐ŸŒฑ', '๐Ÿช', '๐Ÿ›ฐ๏ธ', '๐Ÿ›ก๏ธ', '๐Ÿ“Š']; + // Storage sits BEFORE Recommended on purpose: a location has to exist + // before an app can be placed on it, and the Recommended step can then + // offer the big apps a home other than the system disk. + this.stepNames = ['Experience', 'Identity', 'Domains', 'Storage', 'Recommended', 'Metrics']; + this.stepIcons = ['๐ŸŒฑ', '๐Ÿช', '๐Ÿ›ฐ๏ธ', '๐Ÿ’พ', '๐Ÿ›ก๏ธ', '๐Ÿ“Š']; + // Storage is skipped entirely when this box has nowhere else to put things + // โ€” one disk means one answer, and a step with nothing in it is noise. + // Set by loadStorage() once the candidate scan comes back. + this.hasStorageCandidates = false; + this.storageCandidates = []; + this.selectedStorage = []; this.installLevel = 'beginner'; this.totalSteps = this._effectiveTotalSteps(); this.domainCount = 0; // tracked dynamically as the user adds rows this.devMode = false; // unlocked by the Advanced-card 10-tap easter egg } + // Two conditional steps now, so the count is derived from a visibility test + // rather than "length minus one". Metrics is advanced-only; Storage appears + // only when a usable second filesystem was actually found. + _stepVisible(idx) { + const name = this.stepNames[idx]; + if (name === 'Metrics') return this.installLevel === 'advanced'; + if (name === 'Storage') return this.hasStorageCandidates; + return true; + } + + // Indices of the steps actually shown, in order. Everything else (progress, + // next/prev, validation, submit) works off this rather than raw indices, so + // hiding a step never leaves a gap in the numbering. + _visibleSteps() { + return this.stepNames.map((_, i) => i).filter((i) => this._stepVisible(i)); + } + _effectiveTotalSteps() { - // Metrics (last step) is advanced-only; beginner skips it entirely. - return this.installLevel === 'advanced' ? this.stepNames.length : this.stepNames.length - 1; + return this._visibleSteps().length; } initialize(setupDetector, onComplete = null) { @@ -38,6 +63,9 @@ class SetupWizard { this.suggestName(); this.renderAppTiles(); this.preselectTimezone(); + // Async: the wizard is usable immediately and the Storage step appears if + // and when the scan says there is something to choose. + this.loadStorage(); this.showStep(0); } @@ -194,8 +222,23 @@ class SetupWizard { - +
+
+
Storage
+

+ Apps normally live on the system disk. If you have another drive, you can + register it here and choose per app where its data goes. +

+
+

+
+
+ + +
Recommended Apps

Pre-selected to give you a working install out of the box.

@@ -219,7 +262,7 @@ class SetupWizard { default โ€” they're only useful if the user wants the MONITORING toggle on apps to do anything. Advanced-only: this whole step is skipped when the user chose Beginner on step 1. --> -
+
Metrics Apps

Optional. Install these to enable per-app "Export metrics to Grafana" later.

@@ -332,16 +375,104 @@ class SetupWizard { }); } - showStep(n) { - this.currentStep = Math.max(0, Math.min(this.totalSteps - 1, n)); + // The actual step index (position in stepNames) for the current visible slot. + // currentStep is a VISIBLE position, not a raw index โ€” with a hidden step in + // the middle the two diverge, and conflating them was what made the old + // index-based paging impossible to extend. + _actualStep() { + const vis = this._visibleSteps(); + return vis[Math.max(0, Math.min(vis.length - 1, this.currentStep))]; + } - this.container.querySelectorAll('.setup-step').forEach((el, idx) => { - el.classList.toggle('active', idx === this.currentStep); + // Fetch the candidate scan and decide whether the Storage step exists at all. + // Deliberately fail-open-to-hidden: if the file is missing (a fresh install + // where the generator has not run yet) the step simply does not appear, which + // is the correct answer for the overwhelmingly common single-disk box. + async loadStorage() { + try { + const res = await fetch('/data/system/storage.json', { cache: 'no-store' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + this.storageCandidates = Array.isArray(data.candidates) ? data.candidates : []; + } catch (e) { + console.log('[setup] storage scan unavailable, skipping the Storage step:', e.message); + this.storageCandidates = []; + } + // A candidate that cannot work is still WORTH SHOWING (greyed, with the + // reason) โ€” "why isn't my drive listed?" is a support burden. But if every + // candidate is unusable there is nothing to choose, so skip the step. + this.hasStorageCandidates = this.storageCandidates.some(c => c.verdict !== 'refuse'); + this.totalSteps = this._effectiveTotalSteps(); + this.renderStorage(); + this.showStep(this.currentStep); + } + + renderStorage() { + const list = this.container.querySelector('#sw-storage-list'); + const note = this.container.querySelector('#sw-storage-note'); + if (!list) return; + + if (!this.storageCandidates.length) { + list.innerHTML = ''; + if (note) note.textContent = ''; + return; + } + + list.innerHTML = this.storageCandidates.map((c, i) => { + const refused = c.verdict === 'refuse'; + const warned = c.verdict === 'warn'; + const id = `sw-storage-${i}`; + const detail = refused ? c.refusals : (warned ? c.warnings : ''); + const badge = refused + ? 'unusable' + : (warned ? 'needs care' : ''); + return ` + `; + }).join(''); + + if (note) { + note.innerHTML = 'A drive you tick is registered as a storage location during install. ' + + 'You can add or remove locations later from the CLI (libreportal storage). ' + + 'Unusable drives are shown greyed with the reason rather than hidden.'; + } + } + + escapeHtml(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, (ch) => ( + { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch] + )); + } + + collectStorage() { + return Array.from(this.container.querySelectorAll('input[data-storage-path]:checked')) + .map(cb => cb.dataset.storagePath); + } + + showStep(n) { + this.totalSteps = this._effectiveTotalSteps(); + this.currentStep = Math.max(0, Math.min(this.totalSteps - 1, n)); + const actual = this._actualStep(); + + // Match on data-step, not on DOM position: a hidden step is still in the + // DOM, so position and step index are not the same thing. + this.container.querySelectorAll('.setup-step').forEach((el) => { + el.classList.toggle('active', Number(el.dataset.step) === actual); }); const pct = Math.round(((this.currentStep + 1) / this.totalSteps) * 100); - const name = this.stepNames[this.currentStep]; - const icon = this.stepIcons[this.currentStep]; + const name = this.stepNames[actual]; + const icon = this.stepIcons[actual]; this.container.querySelector('#sw-progress-fill').style.width = `${pct}%`; this.container.querySelector('#sw-progress-step').innerHTML = `Step ${this.currentStep + 1} of ${this.totalSteps} โ€” ${icon} ${name}`; @@ -376,7 +507,8 @@ class SetupWizard { } } } - if (idx === 3) { + // 4 = Recommended (Storage was inserted at 3, shifting this along). + if (idx === 4) { const traefikBox = this.container.querySelector('input[data-app="traefik"]'); if (traefikBox && traefikBox.checked) { const tEmail = $('#sw-traefik-email').value.trim(); @@ -389,7 +521,7 @@ class SetupWizard { } next() { - const err = this.validateStep(this.currentStep); + const err = this.validateStep(this._actualStep()); if (err) { this.showError(err); return; } this.clearError(); this.showStep(this.currentStep + 1); @@ -690,11 +822,13 @@ class SetupWizard { const $ = (id) => this.container.querySelector(id); this.clearError(); - for (let i = 0; i < this.totalSteps; i++) { + const visible = this._visibleSteps(); + for (let pos = 0; pos < visible.length; pos++) { + const i = visible[pos]; const err = this.validateStep(i); if (err) { console.log('[setup] step', i, 'validation failed:', err); - this.showStep(i); + this.showStep(pos); this.showError(err); this._submitting = false; return; @@ -722,7 +856,11 @@ class SetupWizard { domains, apps, appOptions, - traefik_email: apps.includes('traefik') ? $('#sw-traefik-email').value.trim() : '' + traefik_email: apps.includes('traefik') ? $('#sw-traefik-email').value.trim() : '', + // Paths ticked on the Storage step. Registering one is a privileged + // action, so this is only a request โ€” the host applier runs it through + // the CLI and the root helper, which validate independently. + storage: this.collectStorage() }; // Apply the experience choice to the WebUI immediately so the next diff --git a/scripts/setup/setup_apply.sh b/scripts/setup/setup_apply.sh index a561e9e..6d84fc6 100644 --- a/scripts/setup/setup_apply.sh +++ b/scripts/setup/setup_apply.sh @@ -24,6 +24,7 @@ setupApplyConfig() local dev_mode=$(echo "$payload" | jq -r '.dev_mode // empty') local traefik_email=$(echo "$payload" | jq -r '.traefik_email // empty') local domains_json=$(echo "$payload" | jq -c '.domains // []') + local storage_json=$(echo "$payload" | jq -c '.storage // []') if [[ -n "$install_name" ]]; then updateConfigOption "CFG_INSTALL_NAME" "$install_name" @@ -51,6 +52,30 @@ setupApplyConfig() isSuccessful "Developer mode enabled" fi + # Storage locations ticked on the wizard's Storage step. This is a REQUEST, + # not an instruction: storageAdd re-runs the fitness checks and the root + # helper re-runs admission, so a path that should not be accepted is not, + # no matter what arrived in the payload. + local storage_count=$(echo "$storage_json" | jq -r 'length') + if [[ "$storage_count" -gt 0 ]]; then + local s i=0 + while [[ $i -lt $storage_count ]]; do + s=$(echo "$storage_json" | jq -r ".[$i]") + if [[ -n "$s" && "$s" != "null" ]]; then + # Name it after the mount point's basename โ€” short, recognisable, + # and the user can rename it later from the location config. + local sname="${s##*/}" + [[ -z "$sname" ]] && sname="disk$((i+1))" + if storageAdd "$s" "$sname" >/dev/null; then + isSuccessful "Storage location '$sname' registered at $s" + else + isNotice "Could not register '$s' as a storage location โ€” continuing without it." + fi + fi + i=$((i+1)) + done + fi + local domains_count=$(echo "$domains_json" | jq -r 'length') if [[ "$domains_count" -gt 0 ]]; then local i=0 diff --git a/scripts/source/files/arrays/files_webui.sh b/scripts/source/files/arrays/files_webui.sh index 8464c7d..1d3f0bd 100755 --- a/scripts/source/files/arrays/files_webui.sh +++ b/scripts/source/files/arrays/files_webui.sh @@ -29,6 +29,7 @@ webui_scripts=( "webui/data/generators/config/webui_update_config.sh" "webui/data/generators/peers/webui_peers.sh" "webui/data/generators/system/webui_ssh_access.sh" + "webui/data/generators/system/webui_storage_candidates.sh" "webui/data/generators/system/webui_system_disk.sh" "webui/data/generators/system/webui_system_health.sh" "webui/data/generators/system/webui_system_info.sh" diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index 7df9cae..2018055 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -1191,6 +1191,7 @@ declare -gA LP_FN_MAP=( [webuiGenerateLibrePortalConfig]="webui/data/generators/apps/webui_config.sh" [webuiGeneratePeers]="webui/data/generators/peers/webui_peers.sh" [webuiGenerateSshAccess]="webui/data/generators/system/webui_ssh_access.sh" + [webuiGenerateStorageCandidates]="webui/data/generators/system/webui_storage_candidates.sh" [webuiGenerateSystemConfigs]="webui/data/generators/config/webui_generate_configs.sh" [webuiLibrePortalUpdate]="webui/webui_updater.sh" [webuiPatchAppConfigJson]="webui/data/generators/apps/webui_config_patch.sh" @@ -2413,6 +2414,7 @@ declare -gA LP_FN_ROOT=( [webuiGenerateLibrePortalConfig]="scripts" [webuiGeneratePeers]="scripts" [webuiGenerateSshAccess]="scripts" + [webuiGenerateStorageCandidates]="scripts" [webuiGenerateSystemConfigs]="scripts" [webuiLibrePortalUpdate]="scripts" [webuiPatchAppConfigJson]="scripts" @@ -3672,6 +3674,7 @@ webuiGenerateBackupSnapshots() { unset -f webuiGenerateBackupSnapshots; __lpAuto webuiGenerateLibrePortalConfig() { unset -f webuiGenerateLibrePortalConfig; __lpAutoload "${install_scripts_dir}webui/data/generators/apps/webui_config.sh"; webuiGenerateLibrePortalConfig "$@"; } webuiGeneratePeers() { unset -f webuiGeneratePeers; __lpAutoload "${install_scripts_dir}webui/data/generators/peers/webui_peers.sh"; webuiGeneratePeers "$@"; } webuiGenerateSshAccess() { unset -f webuiGenerateSshAccess; __lpAutoload "${install_scripts_dir}webui/data/generators/system/webui_ssh_access.sh"; webuiGenerateSshAccess "$@"; } +webuiGenerateStorageCandidates() { unset -f webuiGenerateStorageCandidates; __lpAutoload "${install_scripts_dir}webui/data/generators/system/webui_storage_candidates.sh"; webuiGenerateStorageCandidates "$@"; } webuiGenerateSystemConfigs() { unset -f webuiGenerateSystemConfigs; __lpAutoload "${install_scripts_dir}webui/data/generators/config/webui_generate_configs.sh"; webuiGenerateSystemConfigs "$@"; } webuiLibrePortalUpdate() { unset -f webuiLibrePortalUpdate; __lpAutoload "${install_scripts_dir}webui/webui_updater.sh"; webuiLibrePortalUpdate "$@"; } webuiPatchAppConfigJson() { unset -f webuiPatchAppConfigJson; __lpAutoload "${install_scripts_dir}webui/data/generators/apps/webui_config_patch.sh"; webuiPatchAppConfigJson "$@"; } diff --git a/scripts/webui/data/generators/system/webui_storage_candidates.sh b/scripts/webui/data/generators/system/webui_storage_candidates.sh new file mode 100644 index 0000000..7074393 --- /dev/null +++ b/scripts/webui/data/generators/system/webui_storage_candidates.sh @@ -0,0 +1,94 @@ +#!/bin/bash + +# Storage data for the WebUI: registered locations + unregistered candidates. +# +# Written as a plain JSON file under frontend/data like every other generator, +# so the setup wizard and the Disks view read it with a fetch and no new backend +# route. Read-only by construction โ€” registering a location is a mutating action +# and goes through the task system โ†’ CLI โ†’ root helper, never through here. +# +# The wizard uses `candidates` to decide whether its Storage step appears at all: +# an empty list means this box has nowhere else to put things, and the step is +# skipped rather than shown with nothing in it. + +webuiGenerateStorageCandidates() +{ + local out_dir="$(webuiDir)/frontend/data/system" + local out_file="$out_dir/storage.json" + createFolders "quiet" "$sudo_user_name" "$out_dir" + + local tmp; tmp=$(mktemp) || return 1 + + # --- registered locations (including any whose drive is absent) ---------- + local locations="[]" first=1 + locations="[" + local id state path name_var name apps + while IFS=$'\t' read -r id state path; do + [[ -z "$id" ]] && continue + name_var="CFG_STORAGE_LOC_${id}_NAME" + name="${!name_var:-location-$id}" + apps="$(storageAppsOnRoot "$path" 2>/dev/null | paste -sd, -)" + (( first )) || locations+="," + first=0 + locations+="{\"id\":\"$(_lpJsonEsc "$id")\",\"name\":\"$(_lpJsonEsc "$name")\",\"path\":\"$(_lpJsonEsc "$path")\",\"state\":\"$(_lpJsonEsc "$state")\",\"apps\":\"$(_lpJsonEsc "$apps")\"}" + done < <(runStorage verify 2>/dev/null) + locations+="]" + + # --- unregistered candidates, with a fitness verdict each --------------- + local candidates="[" cfirst=1 + local target source fstype size avail uuid rm_flag role + while IFS=$'\t' read -r target source fstype size avail uuid rm_flag role; do + [[ -z "$target" ]] && continue + # Only offer filesystems LibrePortal isn't already using. + [[ "$role" == "free" ]] || continue + + local sev check msg refusals="" warnings="" + while IFS=$'\t' read -r sev check msg; do + case "$sev" in + refuse) refusals+="${refusals:+; }$msg" ;; + warn) warnings+="${warnings:+; }$msg" ;; + esac + done < <(storageCheckPath "$target" 2>/dev/null) + + local verdict="ok" + [[ -n "$warnings" ]] && verdict="warn" + [[ -n "$refusals" ]] && verdict="refuse" + + (( cfirst )) || candidates+="," + cfirst=0 + candidates+="{\"path\":\"$(_lpJsonEsc "$target")\"" + candidates+=",\"device\":\"$(_lpJsonEsc "$source")\"" + candidates+=",\"fstype\":\"$(_lpJsonEsc "$fstype")\"" + candidates+=",\"size\":\"$(_lpJsonEsc "$size")\"" + candidates+=",\"free\":\"$(_lpJsonEsc "$avail")\"" + candidates+=",\"removable\":$([[ "$rm_flag" == "1" ]] && echo true || echo false)" + candidates+=",\"verdict\":\"$verdict\"" + candidates+=",\"refusals\":\"$(_lpJsonEsc "$refusals")\"" + candidates+=",\"warnings\":\"$(_lpJsonEsc "$warnings")\"}" + done < <(storageScanCandidates 2>/dev/null) + candidates+="]" + + cat > "$tmp" <