+ 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. +
+ + +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. --> -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} โ ${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" <