From 49c463a9963189cf0a68c24c11a0e3f8c0e99c3a Mon Sep 17 00:00:00 2001 From: librelad Date: Wed, 26 Aug 2026 03:31:13 +0100 Subject: [PATCH] feat(storage): a default location, so one choice places every future app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-app placement worked but had no default: a box with a big second disk meant setting CFG__STORAGE on every app individually. CFG_STORAGE_DEFAULT fixes that, and the wizard asks for it in one line. CFG__STORAGE now has three states rather than two, and the third is the point: this app goes there, whatever the default says primary this app goes on the install-time root, explicitly default no opinion — follow CFG_STORAGE_DEFAULT Templates ship "default", so the setting reaches every app without touching 37 configs, while an app that was deliberately placed keeps its placement. "primary" is new, and needed: without it there was no way to say "keep this one on the system disk" once the global default moved. A default naming a location that has since been removed falls back to the primary root rather than refusing — a disk that got unregistered must not make apps un-installable. The wizard asks only once a second drive is ticked; with nothing ticked there is one possible answer and a control would be furniture. It sets a default, not a placement, and the value stored is the location NAME, so it survives the disk being remounted elsewhere. scripts/dev/lp-storage-default-test covers all three states plus the removed-location fallback. Co-Authored-By: Claude Opus 5 --- configs/general/general_basic | 1 + .../frontend/core/setup/css/setup-wizard.css | 21 +++++++- .../frontend/core/setup/js/setup-wizard.js | 54 ++++++++++++++++++- scripts/dev/lp-storage-default-test | 36 +++++++++++++ scripts/setup/setup_apply.sh | 19 +++++++ scripts/source/paths.sh | 22 +++++++- 6 files changed, 148 insertions(+), 5 deletions(-) create mode 100755 scripts/dev/lp-storage-default-test diff --git a/configs/general/general_basic b/configs/general/general_basic index 57ce1c6..d329ab8 100755 --- a/configs/general/general_basic +++ b/configs/general/general_basic @@ -5,3 +5,4 @@ CFG_INSTALL_NAME=Change-Me # Installation Name - The name for your LibrePortal instance CFG_TIMEZONE=Etc/UTC # Container Timezone - Timezone handed to app containers; scheduled tasks follow the host clock CFG_INSTALL_LEVEL=beginner # Experience Level - How much technical detail the WebUI shows [beginner:Beginner — simple|advanced:Advanced — show everything] +CFG_STORAGE_DEFAULT=primary # Default App Storage - Where a newly installed app keeps its data, unless that app says otherwise. Change an individual app from its own config, or with `libreportal app move` [primary:System disk] diff --git a/containers/libreportal/frontend/core/setup/css/setup-wizard.css b/containers/libreportal/frontend/core/setup/css/setup-wizard.css index 716ea45..8604f06 100755 --- a/containers/libreportal/frontend/core/setup/css/setup-wizard.css +++ b/containers/libreportal/frontend/core/setup/css/setup-wizard.css @@ -295,7 +295,11 @@ body.setup-wizard-open { .setup-field input[type=text], .setup-field input[type=email], -.setup-field select { +/* The storage default's select is outside .setup-field (it is a row, not a + labelled field), so it would otherwise render as a raw native dropdown — + white box, wrong font — against the glass panel. Share the styling. */ +.setup-field select, +.setup-storage-default select { width: 100%; background: rgba(var(--text-rgb), 0.06); border: 1px solid rgba(var(--text-rgb), 0.12); @@ -312,7 +316,8 @@ body.setup-wizard-open { .setup-field input[type=text]:focus, .setup-field input[type=email]:focus, -.setup-field select:focus { +.setup-field select:focus, +.setup-storage-default select:focus { outline: none; background: rgba(var(--text-rgb), 0.10); border-color: rgba(var(--accent-rgb), 0.55); @@ -1374,3 +1379,15 @@ body.setup-wizard-open .eo-modal { z-index: 10000; } height: 1px; background: rgba(255, 255, 255, 0.16); } + +/* "New apps store their data on [ … ]" — one row, and only rendered once a + second drive is ticked, so it never appears as furniture. */ +.setup-storage-default { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-top: 12px; +} +.setup-storage-default-label { font-size: 0.92em; opacity: 0.9; } +.setup-storage-default select { flex: 1; min-width: 180px; } diff --git a/containers/libreportal/frontend/core/setup/js/setup-wizard.js b/containers/libreportal/frontend/core/setup/js/setup-wizard.js index 8ec11b6..3e6dafe 100755 --- a/containers/libreportal/frontend/core/setup/js/setup-wizard.js +++ b/containers/libreportal/frontend/core/setup/js/setup-wizard.js @@ -32,6 +32,8 @@ class SetupWizard { this.selectedStorage = []; // Paths the user asked us to make permanent in /etc/fstab. this.fstabWanted = []; + // Which drive new apps default to: 'primary' or a ticked drive's path. + this.storageDefault = 'primary'; this.installLevel = 'beginner'; this.totalSteps = this._effectiveTotalSteps(); this.domainCount = 0; // tracked dynamically as the user adds rows @@ -237,6 +239,7 @@ class SetupWizard { ?
+

@@ -542,6 +545,13 @@ class SetupWizard { }); }); + // Ticking a drive changes whether "where do new apps go?" is a real + // question, so re-evaluate it on every change. + list.querySelectorAll('[data-storage-path]').forEach((cb) => { + cb.addEventListener('change', () => this.renderStorageDefault()); + }); + this.renderStorageDefault(); + if (note) { // Only the single-disk case says anything: with drives listed, the cards // and their badges already carry it, and a standing explanatory line @@ -552,6 +562,46 @@ class SetupWizard { } } + // "Where do new apps go?" — asked only when it is actually a question, i.e. + // once at least one extra drive is ticked. With nothing ticked there is one + // possible answer and a control would be furniture. + // + // This sets a DEFAULT, not a per-app placement: apps ship + // CFG__STORAGE=default, which means "follow the global setting", so one + // choice here places every app installed afterwards. Individual apps can + // still be pinned, and moved later with `libreportal app move`. + renderStorageDefault() { + const box = this.container.querySelector('#sw-storage-default'); + if (!box) return; + + const ticked = this.collectStorage(); + if (!ticked.length) { + box.style.display = 'none'; + box.innerHTML = ''; + this.storageDefault = 'primary'; + return; + } + + // Keep a previous choice if that drive is still ticked. + if (this.storageDefault !== 'primary' && !ticked.includes(this.storageDefault)) { + this.storageDefault = 'primary'; + } + + const opts = [{ value: 'primary', label: 'System disk' }] + .concat(ticked.map(p => ({ value: p, label: p }))); + + box.style.display = ''; + box.innerHTML = ` + New apps store their data on + `; + + box.querySelector('select').addEventListener('change', (e) => { + this.storageDefault = e.target.value; + }); + } + // Details modal — the technical spec, every check with its full explanation, // and (when the drive isn't in fstab) the offer to make it permanent. // @@ -1064,7 +1114,9 @@ class SetupWizard { // the CLI and the root helper, which validate independently. storage: this.collectStorage(), // Only for drives that are actually being registered. - storage_fstab: this.fstabWanted.filter(p => this.collectStorage().includes(p)) + storage_fstab: this.fstabWanted.filter(p => this.collectStorage().includes(p)), + // 'primary', or the path of a drive also present in `storage`. + storage_default: (this.storageDefault !== 'primary' && this.collectStorage().includes(this.storageDefault)) ? this.storageDefault : 'primary' }; // Apply the experience choice to the WebUI immediately so the next diff --git a/scripts/dev/lp-storage-default-test b/scripts/dev/lp-storage-default-test new file mode 100755 index 0000000..8088996 --- /dev/null +++ b/scripts/dev/lp-storage-default-test @@ -0,0 +1,36 @@ +#!/bin/bash +# Covers CFG_STORAGE_DEFAULT's three states: a named location wins, "primary" +# pins to the install-time root, and "default" (what every app template ships) +# inherits the global setting — which is what lets one choice at setup place +# every future app without editing 37 configs. +R="$(cd "$(dirname "$0")/../.." && pwd)" +B=$(mktemp -d); trap 'rm -rf "$B"' EXIT +export LP_SYSTEM_DIR="$B/sys" LP_CONTAINERS_DIR="$B/primary" LP_BACKUPS_DIR="$B/bk" +export LP_STORAGE_REGISTRY="$B/storage.roots" +mkdir -p "$B/primary" "$B/big" "$B/sys/configs" +printf '2\t%s\t0:0\tuuid2\n' "$B/big" > "$B/storage.roots" +: > "$B/big/.libreportal-storage" +source "$R/scripts/source/paths.sh" +CFG_STORAGE_LOC_2_NAME=bigdisk +fail=0 +chk(){ [[ "$2" == "$3" ]] && echo " ok $1" || { echo " FAIL $1: got '$2' want '$3'"; fail=1; }; } + +echo "--- global default = primary (out of the box) ---" +CFG_STORAGE_DEFAULT=primary; storageCacheReset +chk "app with no opinion" "$(appDir newapp)" "$B/primary/newapp" + +echo "--- global default = bigdisk ---" +CFG_STORAGE_DEFAULT=bigdisk; storageCacheReset +chk "app inherits the default" "$(appDir newapp)" "$B/big/newapp" +CFG_NEWAPP_STORAGE=default; storageCacheReset +chk "explicit 'default' inherits too" "$(appDir newapp)" "$B/big/newapp" +CFG_NEWAPP_STORAGE=primary; storageCacheReset +chk "explicit 'primary' overrides" "$(appDir newapp)" "$B/primary/newapp" +CFG_NEWAPP_STORAGE=bigdisk; storageCacheReset +chk "explicit name wins" "$(appDir newapp)" "$B/big/newapp" + +echo "--- a default naming a location that was removed ---" +CFG_STORAGE_DEFAULT=ghostdisk; unset CFG_NEWAPP_STORAGE; storageCacheReset +chk "falls back, never refuses" "$(appDir newapp)" "$B/primary/newapp" + +echo; [[ $fail -eq 0 ]] && echo "ALL PASS" || echo "FAILURES"; exit $fail diff --git a/scripts/setup/setup_apply.sh b/scripts/setup/setup_apply.sh index d36a8f6..d3f6c55 100644 --- a/scripts/setup/setup_apply.sh +++ b/scripts/setup/setup_apply.sh @@ -26,6 +26,7 @@ setupApplyConfig() local domains_json=$(echo "$payload" | jq -c '.domains // []') local storage_json=$(echo "$payload" | jq -c '.storage // []') local storage_fstab_json=$(echo "$payload" | jq -c '.storage_fstab // []') + local storage_default=$(echo "$payload" | jq -r '.storage_default // "primary"') if [[ -n "$install_name" ]]; then updateConfigOption "CFG_INSTALL_NAME" "$install_name" @@ -88,6 +89,24 @@ setupApplyConfig() done fi + # Where newly installed apps keep their data. Stored as a location NAME, not + # a path: app configs ship CFG__STORAGE=default meaning "follow this", + # so one choice at setup places every app installed afterwards without + # touching 37 configs. Resolved AFTER registration, since the name only + # exists once the location has been accepted. + if [[ -n "$storage_default" && "$storage_default" != "primary" && "$storage_default" != "null" ]]; then + local default_name="" + if declare -f storageLocationName >/dev/null 2>&1; then + default_name=$(storageLocationName "$storage_default" 2>/dev/null) || default_name="" + fi + if [[ -n "$default_name" && "$default_name" != "default" ]]; then + updateConfigOption "CFG_STORAGE_DEFAULT" "$default_name" + isSuccessful "New apps will store their data on '$default_name'" + else + isNotice "Could not resolve '$storage_default' to a storage location — new apps will use the system disk." + fi + fi + local domains_count=$(echo "$domains_json" | jq -r 'length') if [[ "$domains_count" -gt 0 ]]; then local i=0 diff --git a/scripts/source/paths.sh b/scripts/source/paths.sh index 51ff4cd..433f01c 100644 --- a/scripts/source/paths.sh +++ b/scripts/source/paths.sh @@ -203,7 +203,10 @@ pathIsContainerData() storageLocationPath() { local want="$1" - [[ -z "$want" || "$want" == "default" ]] && { primaryRoot; return 0; } + # "primary" names the install-time root explicitly. "default" is accepted + # here for callers that resolve a concrete value, but at APP level it means + # "inherit the global default" — see _appDirIntended. + [[ -z "$want" || "$want" == "default" || "$want" == "primary" ]] && { primaryRoot; return 0; } local _id _path _rest name_var if [[ -r "$lp_storage_registry" ]]; then @@ -355,11 +358,26 @@ _appDirIntended() local slug="$1" local key="CFG_${slug^^}_STORAGE" local want="${!key:-}" + + # Three states, and the distinction matters: + # this app goes there, whatever the global default says + # primary this app goes on the install-time root, explicitly + # default no opinion — follow CFG_STORAGE_DEFAULT + # + # Templates ship "default", so a box configured with a big second disk + # picks it up for every new app without editing 37 configs; an app that + # was deliberately placed keeps its placement. + if [[ -z "$want" || "$want" == "default" ]]; then + want="${CFG_STORAGE_DEFAULT:-primary}" + fi + local path - if [[ -n "$want" ]] && path=$(storageLocationPath "$want"); then + if path=$(storageLocationPath "$want"); then printf '%s' "$path" return 0 fi + # Named a location that no longer exists — fall back rather than refuse, so + # a removed disk can't make an app un-installable. primaryRoot }