setup: surface the per-app storage choice in the App Center

Choosing a drive worked from the CLI but was invisible in the WebUI, for three
separate reasons, each of which hid the next:

  * the config editor only renders fields listed in apps-field-mappings.json,
    and STORAGE was not one — so no amount of correct data made it appear. Added
    there, in General, with its choices built from the locations registered at
    generate time (unlike every other select here, they are not knowable
    statically).

  * app TEMPLATES ship "[default:Primary]", and templates are what the install
    form reads for an app that is not installed yet — precisely the app whose
    form needs to show which drives exist. storageSyncAllAppComments now covers
    templates, and is finally called from a regen path: it was written for one
    and never wired in, so every option list was frozen at install time and
    adding a drive made it selectable nowhere.

  * storageLocationName resolved a name only from an in-scope
    CFG_STORAGE_LOC_<id>_NAME and otherwise fell back to the bare id. That name
    is the value CFG_<APP>_STORAGE takes, so the generated dropdown offered
    "location-1" as both label and value — a choice that does not resolve. Read
    it from the location's own config when the variable is not in scope.

Then the control rendered but sat blank. Config values are the raw right-hand
side of "KEY=value   # comment"; almost all are stored without a comment, but a
field whose comment is regenerated keeps one — CFG_<APP>_STORAGE records the
location it currently resolves to. updateConfigForm assigned that whole string
to the field, which for a <select> matches no option, sets selectedIndex to -1
and renders empty: an app on a second disk read as "nothing configured", or
after a partial fix as "Primary". Normalise once where the config enters the
form, and never assign a select a value none of its options carry.

Verified in the App Center: authelia, installed on disk1, shows
"disk1 (/mnt/lptest1/apps)" selected, with Primary/disk1/disk2 offered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-28 07:46:36 +01:00
parent 56cd6e7fa4
commit 78bc20b8ac
2 changed files with 81 additions and 3 deletions

View File

@ -46,6 +46,18 @@ Object.assign(AppsManager.prototype, {
});
}
},
// Config values arrive as the raw right-hand side of "KEY=value # comment".
// Most fields are written back without their comment, so this rarely
// mattered — but any field whose comment is REGENERATED keeps one
// (CFG_<APP>_STORAGE carries the location it currently resolves to), and then
// a <select> gets a value matching none of its options and renders blank, as
// if nothing were set. Strip it once here rather than per field.
stripInlineComment(value) {
if (typeof value !== 'string') return value;
const cut = value.replace(/\s+#.*$/, '').trim();
return cut.replace(/^"(.*)"$/, '$1');
},
updateConfigForm(appName, appConfig) {
const form = document.getElementById(`app-form-${appName}`);
if (!form) return;
@ -55,12 +67,20 @@ Object.assign(AppsManager.prototype, {
Object.entries(appConfig).forEach(([key, value]) => {
const field = form.querySelector(`[name="${key}"]`);
if (!field) return;
let nextValue = value;
let nextValue = this.stripInlineComment(value);
if (key.endsWith('_NETWORK')) {
nextValue = this.applyContextualDefault('NETWORK', value, appData);
nextValue = this.applyContextualDefault('NETWORK', nextValue, appData);
}
if (field.type === 'checkbox') {
field.checked = nextValue === 'true' || nextValue === 'yes';
} else if (field.tagName === 'SELECT') {
// Assigning a value no option carries sets selectedIndex to -1, and the
// control then renders blank — which reads as "nothing is configured"
// rather than "the stored value is unrecognised". The renderer has
// already fallen back to the field's default, so keep that instead.
if ([...field.options].some(o => o.value === String(nextValue))) {
field.value = nextValue;
}
} else {
field.value = nextValue;
}
@ -74,6 +94,20 @@ Object.assign(AppsManager.prototype, {
return;
}
// Normalise once, here, instead of at each of the several places a value is
// read. Config values are the raw right-hand side of "KEY=value # comment",
// and most are stored without a comment — but any field whose comment is
// regenerated keeps one (CFG_<APP>_STORAGE records the location it currently
// resolves to). A <select> then matched no option and fell back to showing
// the field's default, so an app living on a second disk read as "Primary".
if (appData && appData.config) {
appData = Object.assign({}, appData, {
config: Object.fromEntries(
Object.entries(appData.config).map(([k, v]) => [k, this.stripInlineComment(v)])
)
});
}
const cleanAppName = appData.command.split(' ').pop();
const requiresKey = Object.keys(appData.config || {}).find(k => k.endsWith('_REQUIRES_SERVICE'));
@ -361,6 +395,7 @@ Object.assign(AppsManager.prototype, {
// Get current value or use default
let fieldValue = cfgKey && appConfig.hasOwnProperty(cfgKey) ? appConfig[cfgKey] : (fieldConfig.default || '');
fieldValue = this.stripInlineComment(fieldValue);
fieldValue = this.applyContextualDefault(fieldKey, fieldValue, appData);
const fieldHTML = await this.generateField(fieldKey, cfgKey, fieldValue, fieldConfig);
if (fieldConfig.hideByDefault) {

View File

@ -19,6 +19,28 @@ webuiCreateAppFieldMappings() {
local temp_file="$(mktemp)"
local final_file="${output_dir}/apps-field-mappings.json"
# Storage Location's choices are whatever is registered right now, so unlike
# every other select here they cannot be written into the static block
# below. Built once and substituted for the placeholder.
#
# This generator runs on the same regen that refreshes the storage view, so
# adding a drive makes it selectable without a further step — which is what
# was missing: the config file's own dropdown listed the locations, but the
# WebUI's editor only renders fields named in THIS file, so the field was
# invisible in the UI no matter what the config said.
local storage_options='[{"value": "default", "label": "Primary"}]'
if declare -f storageRoots >/dev/null 2>&1; then
local _opts='[{"value": "default", "label": "Primary"}]' _root _name
while IFS= read -r _root; do
[[ -z "$_root" ]] && continue
[[ "${_root%/}" == "$(primaryRoot 2>/dev/null)" ]] && continue
_name=$(storageLocationName "$_root" 2>/dev/null) || continue
[[ -z "$_name" || "$_name" == "default" ]] && continue
_opts="${_opts%]}, {\"value\": \"${_name}\", \"label\": \"${_name} (${_root})\"}]"
done < <(storageRoots 2>/dev/null)
storage_options="$_opts"
fi
# Generate the complete JSON in one go
cat > "$temp_file" << 'JSONEOF'
{
@ -88,6 +110,7 @@ webuiCreateAppFieldMappings() {
},
JSONEOF
# Add PORT_1 through PORT_20 dynamically
for i in {1..20}; do
cat >> "$temp_file" << PORTEOF
@ -170,6 +193,14 @@ PORTEOF
],
"default": "auto"
},
"STORAGE": {
"category": "general",
"label": "Storage Location",
"type": "select",
"tooltip": "Which drive this app's data lives on. Apps can each use a different one.",
"options": __STORAGE_OPTIONS__,
"default": "default"
},
"UPDATE_TYPE": {
"category": "general",
"label": "Updates",
@ -999,12 +1030,24 @@ RESTEOF
# Substitute live-runtime placeholders the heredoc can't compute
# (single-quoted heredoc → no $var expansion). Currently:
# __INSTALL_NAME__ → CFG_INSTALL_NAME
# __INSTALL_NAME__ → CFG_INSTALL_NAME
# __STORAGE_OPTIONS__ → the storage locations registered right now
if [ $? -eq 0 ]; then
local install_name_safe
install_name_safe=$(printf '%s' "${CFG_INSTALL_NAME:-LibrePortal}" \
| sed -e 's/[\/&]/\\&/g' -e 's/"/\\"/g')
sed -i "s|__INSTALL_NAME__|${install_name_safe}|g" "$temp_file"
# awk, not sed: the replacement is JSON and contains characters sed
# would treat as syntax. Never leave the placeholder behind — that is
# invalid JSON and the whole config editor fails to load.
# The default is assigned separately, not inline as ${x:-...}: that form
# ends at the first unescaped } and the replacement is JSON, so the rest
# of it leaked out as literal text and produced a stray "]}".
local _opts_json="$storage_options"
[[ -n "$_opts_json" ]] || _opts_json='[{"value": "default", "label": "Primary"}]'
awk -v opts="$_opts_json" \
'{ gsub(/__STORAGE_OPTIONS__/, opts); print }' "$temp_file" > "${temp_file}.s" \
&& mv -f "${temp_file}.s" "$temp_file"
runFileWrite "$final_file" < "$temp_file"; rm -f "$temp_file"
else
rm -f "$temp_file" 2>/dev/null