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_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]
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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 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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user