feat(setup): selections above, drives below, and both roots choosable

Reworks the Storage step: two labelled choices with tooltips at the top —
LibrePortal and New apps — and the drive list underneath as reference.

The drive cards lose their checkboxes. Choosing a drive in a dropdown IS
the request to register it, so a separate tick was a second way to say the
same thing, and the way you end up with a drive ticked that nobody
selected. Cards are now informational plus Details.

Both dropdowns only render when there is a second drive; with one disk
both answers are forced and a pair of selects showing one option each is
furniture.

Moving LibrePortal's own tree cannot be a WebUI action. It re-bakes the
six root-owned helpers, the systemd unit and the WebUI's own bind-mounts
— real root, not the scoped sudo the manager holds. A helper that re-baked
the other helpers from a manager-supplied path would hand the manager
exactly the trust boundary those helpers exist to defend. So picking a
different disk for LibrePortal surfaces the root command to run rather
than pretending the wizard can do it; the payload carries the choice so
the finish screen can repeat it. libreportal-relocate follows.

Also drops "itself" from the installer's wording.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-26 06:39:25 +01:00
parent 3034eaf4c7
commit 6fd6587714
4 changed files with 125 additions and 73 deletions

View File

@ -1391,3 +1391,32 @@ body.setup-wizard-open .eo-modal { z-index: 10000; }
other dropdowns rather than showing stock white chrome. */
.setup-storage-default select,
.setup-storage-default .custom-select { flex: 1; min-width: 180px; }
/* Two choices above the drive list. The list below is reference, not input. */
.setup-storage-choice {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.setup-storage-choice-label {
flex: 0 0 120px;
font-size: 0.92em;
opacity: 0.92;
}
.setup-storage-choice select,
.setup-storage-choice .custom-select { flex: 1; min-width: 180px; }
.setup-storage-choice-msg {
margin: 2px 0 4px;
padding: 9px 11px;
border-radius: 8px;
border: 1px solid rgba(255, 190, 60, 0.32);
background: rgba(255, 190, 60, 0.09);
font-size: 0.86em;
line-height: 1.5;
}
.setup-storage-choice-msg code {
display: inline-block;
margin-top: 4px;
word-break: break-all;
}

View File

@ -34,6 +34,8 @@ class SetupWizard {
this.fstabWanted = [];
// Which drive new apps default to: 'primary' or a ticked drive's path.
this.storageDefault = 'primary';
// Where LibrePortal's own tree should live (root-only to change post-install).
this.storageSystemChoice = 'primary';
this.installLevel = 'beginner';
this.totalSteps = this._effectiveTotalSteps();
this.domainCount = 0; // tracked dynamically as the user adds rows
@ -238,8 +240,9 @@ class SetupWizard {
<div class="setup-section-title">Storage
<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-choices"></div>
<div class="setup-storage-divider" id="sw-storage-drives-head" style="display:none;"><span>Drives</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>
@ -476,39 +479,27 @@ class SetupWizard {
title="${used}% used, ${100 - used}% free"><span style="width:${width}%"></span></span>`;
}
// One card, one line.
// One card, one line — informational.
//
// No checkbox: the dropdowns above ARE the selection, and a drive picked
// there is registered automatically. Two ways to say the same thing (tick it
// AND choose it) is how you end up with a ticked drive nobody selected.
//
// The badge carries severity and Details carries the explanation, so the card
// itself stays a single row — listing every warning here pushed each drive to
// three lines and made the step tall for no gain.
//
// `locked` renders it ticked and non-interactive: the system drive is what
// apps fall back to, so it cannot be deselected. Details still works, which
// is the point of showing it at all on a single-disk box.
// stays a single row.
_storageCard(c, key, opts) {
const o = opts || {};
const refused = c.verdict === 'refuse';
const badge = o.locked
? '<span class="setup-storage-badge setup-storage-badge-ok">default</span>'
const badge = o.system
? '<span class="setup-storage-badge setup-storage-badge-ok">system</span>'
: (refused ? '<span class="setup-storage-badge setup-storage-badge-bad">can\u2019t be used</span>'
: (c.verdict === 'warn' ? '<span class="setup-storage-badge setup-storage-badge-warn">needs care</span>' : ''));
const title = o.locked ? 'System disk' : c.path;
// The system disk carries NO checkbox — not even a disabled one. Apps fall
// back to it whatever happens, so it is not a choice, and a control that
// can never change state reads as "locked option" rather than "this is
// simply where things go". It is a <div> for the same reason: a <label>
// with nothing to label is wrong. A spacer keeps the text aligned with the
// real checkboxes below it.
const control = o.locked
? '<span class="setup-storage-spacer" aria-hidden="true"></span>'
: `<input type="checkbox" data-storage-path="${this.escapeHtml(c.path)}" ${refused ? 'disabled' : ''}>`;
const tag = o.locked ? 'div' : 'label';
const title = o.system ? 'System disk' : c.path;
return `
<${tag} class="setup-app setup-storage-card${refused ? ' setup-storage-disabled' : ''}${o.locked ? ' setup-storage-locked' : ''}">
${control}
<div class="setup-app setup-storage-card${refused ? ' setup-storage-disabled' : ''} setup-storage-locked">
<span class="setup-storage-spacer" aria-hidden="true"></span>
<span class="setup-app-body">
<span class="setup-app-name">${this.escapeHtml(title)} ${badge}</span>
<span class="setup-app-desc">
@ -517,25 +508,31 @@ class SetupWizard {
</span>
</span>
<button type="button" class="setup-storage-details" data-storage-details="${this.escapeHtml(key)}">Details</button>
</${tag}>`;
</div>`;
}
// Every place a root could go: the system disk, plus each usable candidate.
_storageChoices() {
const opts = [{ value: 'primary', label: 'System disk' }];
this.storageCandidates
.filter(c => c.verdict !== 'refuse')
.forEach(c => opts.push({ value: c.path, label: c.path }));
return opts;
}
renderStorage() {
const list = this.container.querySelector('#sw-storage-list');
const head = this.container.querySelector('#sw-storage-drives-head');
const note = this.container.querySelector('#sw-storage-note');
if (!list) return;
let html = '';
if (this.storageSystem) {
html += this._storageCard(this.storageSystem, 'system', { locked: true });
}
// Separate the fixed fact from the actual choice: above the divider is
// where apps go regardless, below it is what you can opt into.
if (this.storageCandidates.length) {
html += '<div class="setup-storage-divider"><span>Additional drives</span></div>';
html += this._storageCard(this.storageSystem, 'system', { system: true });
}
html += this.storageCandidates.map((c, i) => this._storageCard(c, String(i), {})).join('');
list.innerHTML = html;
if (head) head.style.display = this.storageCandidates.length ? '' : 'none';
list.querySelectorAll('[data-storage-details]').forEach((btn) => {
btn.addEventListener('click', (e) => {
@ -545,61 +542,79 @@ 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();
this.renderStorageChoices();
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
// under them was noise.
note.innerHTML = this.storageCandidates.length
? ''
: 'No other drives found. You can add one later with <code>libreportal storage</code>.';
: 'Only one drive found, so everything goes here. Add another later with <code>libreportal storage</code>.';
}
}
// "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');
// The two choices, above the drive list. Rendered only when there is another
// drive — with one disk both answers are forced and a pair of dropdowns
// showing the same single option is furniture.
renderStorageChoices() {
const box = this.container.querySelector('#sw-storage-choices');
if (!box) return;
const ticked = this.collectStorage();
if (!ticked.length) {
box.style.display = 'none';
const opts = this._storageChoices();
if (opts.length < 2) {
box.innerHTML = '';
this.storageDefault = 'primary';
// Where LibrePortal's own tree should live (root-only to change post-install).
this.storageSystemChoice = 'primary';
this.storageSystemChoice = 'primary';
return;
}
// Keep a previous choice if that drive is still ticked.
if (this.storageDefault !== 'primary' && !ticked.includes(this.storageDefault)) {
this.storageDefault = 'primary';
}
const row = (id, label, tip, value) => `
<div class="setup-storage-choice">
<span class="setup-storage-choice-label">${label}
<span class="setup-tooltip" tabindex="0" data-tip="${this.escapeHtml(tip)}">?</span>
</span>
<select id="${id}" class="form-control">
${opts.map(o => `<option value="${this.escapeHtml(o.value)}"${o.value === value ? ' selected' : ''}>${this.escapeHtml(o.label)}</option>`).join('')}
</select>
</div>`;
const opts = [{ value: 'primary', label: 'System disk' }]
.concat(ticked.map(p => ({ value: p, label: p })));
box.innerHTML =
row('sw-storage-system', 'LibrePortal',
'Settings, database and logs. Around 20 MB, and it stays small. Moving this after install needs a root command — the wizard will tell you which.',
this.storageSystemChoice) +
row('sw-storage-apps', 'New apps',
'Where an app keeps its data unless you place that app somewhere else. You can move any app later with `libreportal app move`.',
this.storageDefault) +
'<div class="setup-storage-choice-msg" id="sw-storage-system-msg" style="display:none;"></div>';
box.style.display = '';
box.innerHTML = `
<span class="setup-storage-default-label">New apps store their data on</span>
<select id="sw-storage-default-select" class="form-control">
${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) => {
box.querySelector('#sw-storage-system').addEventListener('change', (e) => {
this.storageSystemChoice = e.target.value;
this.renderStorageSystemMsg();
});
box.querySelector('#sw-storage-apps').addEventListener('change', (e) => {
this.storageDefault = e.target.value;
});
this.renderStorageSystemMsg();
}
// Moving LibrePortal's own tree re-bakes the root-owned helpers, the systemd
// unit and the WebUI's own bind-mounts. That is real root, not the scoped
// sudo the manager holds — a helper that re-baked the other helpers from a
// manager-supplied path would hand the manager the trust boundary those
// helpers exist to defend. So the wizard hands over the command instead of
// pretending it can do it.
renderStorageSystemMsg() {
const msg = this.container.querySelector('#sw-storage-system-msg');
if (!msg) return;
if (!this.storageSystemChoice || this.storageSystemChoice === 'primary') {
msg.style.display = 'none';
msg.innerHTML = '';
return;
}
msg.style.display = '';
msg.innerHTML = `Moving LibrePortal itself needs root, so it happens outside the WebUI.
Finish setup, then run:<br>
<code>sudo libreportal-relocate --system-dir=${this.escapeHtml(this.storageSystemChoice)}/libreportal-system</code>`;
}
// Details modal — the technical spec, every check with its full explanation,
@ -706,9 +721,14 @@ class SetupWizard {
));
}
// Drives to register: whatever either dropdown points at. Selecting a drive
// IS the request to register it — there is no separate tick to forget.
collectStorage() {
return Array.from(this.container.querySelectorAll('input[data-storage-path]:checked'))
.map(cb => cb.dataset.storagePath);
const out = [];
[this.storageSystemChoice, this.storageDefault].forEach((v) => {
if (v && v !== 'primary' && !out.includes(v)) out.push(v);
});
return out;
}
showStep(n) {
@ -1116,7 +1136,10 @@ class SetupWizard {
// Only for drives that are actually being registered.
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'
storage_default: (this.storageDefault && this.storageDefault !== 'primary') ? this.storageDefault : 'primary',
// Recorded so the finished-setup screen can repeat the relocate command.
// Nothing acts on it: moving LibrePortal's own tree needs real root.
storage_system: (this.storageSystemChoice && this.storageSystemChoice !== 'primary') ? this.storageSystemChoice : 'primary'
};
// Apply the experience choice to the WebUI immediately so the next

View File

@ -35,7 +35,7 @@ Where should LibrePortal keep things?
1) This disk (default) 911.9G 808.4G free
2) /mnt/bigdisk 3.6T 3.6T free
LibrePortal itself — settings, database, logs. Around 20 MB, and it stays small.
LibrePortal — settings, database, logs. Around 20 MB, and it stays small.
Choose [1]:
App data — everything your apps store. This is the one that grows.

View File

@ -286,7 +286,7 @@ initPickRoots()
local chosen
if [[ -z "${LP_SYSTEM_DIR_EXPLICIT:-}" ]]; then
chosen=$(_initAskDisk "LibrePortal itself — settings, database, logs. Around 20 MB, and it stays small.")
chosen=$(_initAskDisk "LibrePortal — settings, database, logs. Around 20 MB, and it stays small.")
if [[ -n "$chosen" ]]; then
LP_SYSTEM_DIR="${chosen%/}/libreportal-system"
isSuccessful "LibrePortal will live in $LP_SYSTEM_DIR"