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:
parent
3034eaf4c7
commit
6fd6587714
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
2
init.sh
2
init.sh
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user