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:
parent
731b787ce5
commit
49c463a996
@ -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]
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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 {
|
||||
<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 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>
|
||||
</div>
|
||||
</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) {
|
||||
// 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_<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,
|
||||
// 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
|
||||
|
||||
36
scripts/dev/lp-storage-default-test
Executable file
36
scripts/dev/lp-storage-default-test
Executable 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
|
||||
@ -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_<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')
|
||||
if [[ "$domains_count" -gt 0 ]]; then
|
||||
local i=0
|
||||
|
||||
@ -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:
|
||||
# <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
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user