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" <