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. */
|
other dropdowns rather than showing stock white chrome. */
|
||||||
.setup-storage-default select,
|
.setup-storage-default select,
|
||||||
.setup-storage-default .custom-select { flex: 1; min-width: 180px; }
|
.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 = [];
|
this.fstabWanted = [];
|
||||||
// Which drive new apps default to: 'primary' or a ticked drive's path.
|
// Which drive new apps default to: 'primary' or a ticked drive's path.
|
||||||
this.storageDefault = 'primary';
|
this.storageDefault = 'primary';
|
||||||
|
// Where LibrePortal's own tree should live (root-only to change post-install).
|
||||||
|
this.storageSystemChoice = '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
|
||||||
@ -238,8 +240,9 @@ class SetupWizard {
|
|||||||
<div class="setup-section-title">Storage
|
<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>
|
<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-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-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>
|
||||||
@ -476,39 +479,27 @@ class SetupWizard {
|
|||||||
title="${used}% used, ${100 - used}% free"><span style="width:${width}%"></span></span>`;
|
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
|
// 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
|
// stays a single row.
|
||||||
// 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.
|
|
||||||
_storageCard(c, key, opts) {
|
_storageCard(c, key, opts) {
|
||||||
const o = opts || {};
|
const o = opts || {};
|
||||||
const refused = c.verdict === 'refuse';
|
const refused = c.verdict === 'refuse';
|
||||||
const badge = o.locked
|
const badge = o.system
|
||||||
? '<span class="setup-storage-badge setup-storage-badge-ok">default</span>'
|
? '<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>'
|
: (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>' : ''));
|
: (c.verdict === 'warn' ? '<span class="setup-storage-badge setup-storage-badge-warn">needs care</span>' : ''));
|
||||||
|
|
||||||
const title = o.locked ? 'System disk' : c.path;
|
const title = o.system ? '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';
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<${tag} class="setup-app setup-storage-card${refused ? ' setup-storage-disabled' : ''}${o.locked ? ' setup-storage-locked' : ''}">
|
<div class="setup-app setup-storage-card${refused ? ' setup-storage-disabled' : ''} setup-storage-locked">
|
||||||
${control}
|
<span class="setup-storage-spacer" aria-hidden="true"></span>
|
||||||
<span class="setup-app-body">
|
<span class="setup-app-body">
|
||||||
<span class="setup-app-name">${this.escapeHtml(title)} ${badge}</span>
|
<span class="setup-app-name">${this.escapeHtml(title)} ${badge}</span>
|
||||||
<span class="setup-app-desc">
|
<span class="setup-app-desc">
|
||||||
@ -517,25 +508,31 @@ class SetupWizard {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<button type="button" class="setup-storage-details" data-storage-details="${this.escapeHtml(key)}">Details</button>
|
<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() {
|
renderStorage() {
|
||||||
const list = this.container.querySelector('#sw-storage-list');
|
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');
|
const note = this.container.querySelector('#sw-storage-note');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
if (this.storageSystem) {
|
if (this.storageSystem) {
|
||||||
html += this._storageCard(this.storageSystem, 'system', { locked: true });
|
html += this._storageCard(this.storageSystem, 'system', { system: 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.storageCandidates.map((c, i) => this._storageCard(c, String(i), {})).join('');
|
html += this.storageCandidates.map((c, i) => this._storageCard(c, String(i), {})).join('');
|
||||||
list.innerHTML = html;
|
list.innerHTML = html;
|
||||||
|
if (head) head.style.display = this.storageCandidates.length ? '' : 'none';
|
||||||
|
|
||||||
list.querySelectorAll('[data-storage-details]').forEach((btn) => {
|
list.querySelectorAll('[data-storage-details]').forEach((btn) => {
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
@ -545,61 +542,79 @@ class SetupWizard {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ticking a drive changes whether "where do new apps go?" is a real
|
this.renderStorageChoices();
|
||||||
// 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
|
|
||||||
// and their badges already carry it, and a standing explanatory line
|
|
||||||
// under them was noise.
|
|
||||||
note.innerHTML = this.storageCandidates.length
|
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.
|
// The two choices, above the drive list. Rendered only when there is another
|
||||||
// once at least one extra drive is ticked. With nothing ticked there is one
|
// drive — with one disk both answers are forced and a pair of dropdowns
|
||||||
// possible answer and a control would be furniture.
|
// showing the same single option is furniture.
|
||||||
//
|
renderStorageChoices() {
|
||||||
// This sets a DEFAULT, not a per-app placement: apps ship
|
const box = this.container.querySelector('#sw-storage-choices');
|
||||||
// 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;
|
if (!box) return;
|
||||||
|
|
||||||
const ticked = this.collectStorage();
|
const opts = this._storageChoices();
|
||||||
if (!ticked.length) {
|
if (opts.length < 2) {
|
||||||
box.style.display = 'none';
|
|
||||||
box.innerHTML = '';
|
box.innerHTML = '';
|
||||||
this.storageDefault = 'primary';
|
this.storageDefault = 'primary';
|
||||||
|
// Where LibrePortal's own tree should live (root-only to change post-install).
|
||||||
|
this.storageSystemChoice = 'primary';
|
||||||
|
this.storageSystemChoice = 'primary';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep a previous choice if that drive is still ticked.
|
const row = (id, label, tip, value) => `
|
||||||
if (this.storageDefault !== 'primary' && !ticked.includes(this.storageDefault)) {
|
<div class="setup-storage-choice">
|
||||||
this.storageDefault = 'primary';
|
<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' }]
|
box.innerHTML =
|
||||||
.concat(ticked.map(p => ({ value: p, label: p })));
|
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.querySelector('#sw-storage-system').addEventListener('change', (e) => {
|
||||||
box.innerHTML = `
|
this.storageSystemChoice = e.target.value;
|
||||||
<span class="setup-storage-default-label">New apps store their data on</span>
|
this.renderStorageSystemMsg();
|
||||||
<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('')}
|
box.querySelector('#sw-storage-apps').addEventListener('change', (e) => {
|
||||||
</select>`;
|
|
||||||
|
|
||||||
box.querySelector('select').addEventListener('change', (e) => {
|
|
||||||
this.storageDefault = e.target.value;
|
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,
|
// 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() {
|
collectStorage() {
|
||||||
return Array.from(this.container.querySelectorAll('input[data-storage-path]:checked'))
|
const out = [];
|
||||||
.map(cb => cb.dataset.storagePath);
|
[this.storageSystemChoice, this.storageDefault].forEach((v) => {
|
||||||
|
if (v && v !== 'primary' && !out.includes(v)) out.push(v);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
showStep(n) {
|
showStep(n) {
|
||||||
@ -1116,7 +1136,10 @@ class SetupWizard {
|
|||||||
// 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`.
|
// '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
|
// 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
|
1) This disk (default) 911.9G 808.4G free
|
||||||
2) /mnt/bigdisk 3.6T 3.6T 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]:
|
Choose [1]:
|
||||||
|
|
||||||
App data — everything your apps store. This is the one that grows.
|
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
|
local chosen
|
||||||
if [[ -z "${LP_SYSTEM_DIR_EXPLICIT:-}" ]]; then
|
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
|
if [[ -n "$chosen" ]]; then
|
||||||
LP_SYSTEM_DIR="${chosen%/}/libreportal-system"
|
LP_SYSTEM_DIR="${chosen%/}/libreportal-system"
|
||||||
isSuccessful "LibrePortal will live in $LP_SYSTEM_DIR"
|
isSuccessful "LibrePortal will live in $LP_SYSTEM_DIR"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user