feat(storage): a default location, so one choice places every future app

Per-app placement worked but had no default: a box with a big second disk
meant setting CFG_<APP>_STORAGE on every app individually. CFG_STORAGE_DEFAULT
fixes that, and the wizard asks for it in one line.

CFG_<APP>_STORAGE now has three states rather than two, and the third is
the point:

  <name>    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 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-26 03:31:13 +01:00
parent 731b787ce5
commit 49c463a996
6 changed files with 148 additions and 5 deletions

View File

@ -5,3 +5,4 @@
CFG_INSTALL_NAME=Change-Me # Installation Name - The name for your LibrePortal instance 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_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_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]

View File

@ -295,7 +295,11 @@ body.setup-wizard-open {
.setup-field input[type=text], .setup-field input[type=text],
.setup-field input[type=email], .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%; width: 100%;
background: rgba(var(--text-rgb), 0.06); background: rgba(var(--text-rgb), 0.06);
border: 1px solid rgba(var(--text-rgb), 0.12); 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=text]:focus,
.setup-field input[type=email]:focus, .setup-field input[type=email]:focus,
.setup-field select:focus { .setup-field select:focus,
.setup-storage-default select:focus {
outline: none; outline: none;
background: rgba(var(--text-rgb), 0.10); background: rgba(var(--text-rgb), 0.10);
border-color: rgba(var(--accent-rgb), 0.55); border-color: rgba(var(--accent-rgb), 0.55);
@ -1374,3 +1379,15 @@ body.setup-wizard-open .eo-modal { z-index: 10000; }
height: 1px; height: 1px;
background: rgba(255, 255, 255, 0.16); 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; }

View File

@ -32,6 +32,8 @@ class SetupWizard {
this.selectedStorage = []; this.selectedStorage = [];
// Paths the user asked us to make permanent in /etc/fstab. // Paths the user asked us to make permanent in /etc/fstab.
this.fstabWanted = []; this.fstabWanted = [];
// Which drive new apps default to: 'primary' or a ticked drive's path.
this.storageDefault = 'primary';
this.installLevel = 'beginner'; this.installLevel = 'beginner';
this.totalSteps = this._effectiveTotalSteps(); this.totalSteps = this._effectiveTotalSteps();
this.domainCount = 0; // tracked dynamically as the user adds rows this.domainCount = 0; // tracked dynamically as the user adds rows
@ -237,6 +239,7 @@ class SetupWizard {
<span class="setup-tooltip" tabindex="0" data-tip="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.">?</span> <span class="setup-tooltip" tabindex="0" data-tip="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.">?</span>
</div> </div>
<div id="sw-storage-list"></div> <div id="sw-storage-list"></div>
<div id="sw-storage-default" class="setup-storage-default" style="display:none;"></div>
<p class="setup-section-hint" id="sw-storage-note" style="margin-top:10px;"></p> <p class="setup-section-hint" id="sw-storage-note" style="margin-top:10px;"></p>
</div> </div>
</section> </section>
@ -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) { if (note) {
// Only the single-disk case says anything: with drives listed, the cards // Only the single-disk case says anything: with drives listed, the cards
// and their badges already carry it, and a standing explanatory line // 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_<APP>_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 = `
<span class="setup-storage-default-label">New apps store their data on</span>
<select id="sw-storage-default-select">
${opts.map(o => `<option value="${this.escapeHtml(o.value)}"${o.value === this.storageDefault ? ' selected' : ''}>${this.escapeHtml(o.label)}</option>`).join('')}
</select>`;
box.querySelector('select').addEventListener('change', (e) => {
this.storageDefault = e.target.value;
});
}
// Details modal — the technical spec, every check with its full explanation, // 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. // 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. // the CLI and the root helper, which validate independently.
storage: this.collectStorage(), storage: this.collectStorage(),
// Only for drives that are actually being registered. // 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 // Apply the experience choice to the WebUI immediately so the next

View File

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

View File

@ -26,6 +26,7 @@ setupApplyConfig()
local domains_json=$(echo "$payload" | jq -c '.domains // []') local domains_json=$(echo "$payload" | jq -c '.domains // []')
local storage_json=$(echo "$payload" | jq -c '.storage // []') local storage_json=$(echo "$payload" | jq -c '.storage // []')
local storage_fstab_json=$(echo "$payload" | jq -c '.storage_fstab // []') 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 if [[ -n "$install_name" ]]; then
updateConfigOption "CFG_INSTALL_NAME" "$install_name" updateConfigOption "CFG_INSTALL_NAME" "$install_name"
@ -88,6 +89,24 @@ setupApplyConfig()
done done
fi fi
# Where newly installed apps keep their data. Stored as a location NAME, not
# a path: app configs ship CFG_<APP>_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') local domains_count=$(echo "$domains_json" | jq -r 'length')
if [[ "$domains_count" -gt 0 ]]; then if [[ "$domains_count" -gt 0 ]]; then
local i=0 local i=0

View File

@ -203,7 +203,10 @@ pathIsContainerData()
storageLocationPath() storageLocationPath()
{ {
local want="$1" 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 local _id _path _rest name_var
if [[ -r "$lp_storage_registry" ]]; then if [[ -r "$lp_storage_registry" ]]; then
@ -355,11 +358,26 @@ _appDirIntended()
local slug="$1" local slug="$1"
local key="CFG_${slug^^}_STORAGE" local key="CFG_${slug^^}_STORAGE"
local want="${!key:-}" local want="${!key:-}"
# Three states, and the distinction matters:
# <name> 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 local path
if [[ -n "$want" ]] && path=$(storageLocationPath "$want"); then if path=$(storageLocationPath "$want"); then
printf '%s' "$path" printf '%s' "$path"
return 0 return 0
fi 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 primaryRoot
} }