feat(setup): show the system disk, and slim the drive cards to one line

Three changes to the Storage step.

The system disk is now a first-class entry — pinned first, ticked, and
locked, since apps fall back to it and it therefore cannot be deselected.
Its Details work like any other drive's, which is the whole point on a
single-disk box: the step now answers "where does my data actually go?"
instead of being skipped and answering nothing. The step is consequently
unconditional; the note changes to explain that no other drives were
found rather than the step vanishing.

The system entry is excluded from the submitted payload — it is already
the primary root, and asking the helper to register it would (correctly)
be refused for nesting.

Cards are one line again. Listing every warning under each drive pushed
them to three lines and made the step tall for no gain: the badge already
carries severity and Details carries the explanation. The note now says
to open Details for the reason rather than claiming it is on the card.

Badge colours were dark-on-light, which against the wizard's mid-blue
glass read as muddy grey — the "needs care" pill in particular. Switched
to light-on-dark, legible without shouting over the drive name.

Verified with lp-shot in both states: system disk alone, and system disk
plus a second drive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-26 02:19:40 +01:00
parent e71fc15f38
commit d8c9f2486b
3 changed files with 116 additions and 55 deletions

View File

@ -1227,15 +1227,18 @@ body.setup-wizard-open {
letter-spacing: 0.02em;
vertical-align: middle;
}
/* The wizard sits on a mid-blue glass panel, so the dark-on-light badge colours
these started as read as muddy grey. Light-on-dark instead: enough contrast
to be legible without shouting over the drive name. */
.setup-storage-badge-warn {
background: rgba(224, 168, 0, 0.16);
color: #b98900;
border: 1px solid rgba(224, 168, 0, 0.35);
background: rgba(255, 190, 60, 0.20);
color: #ffd27a;
border: 1px solid rgba(255, 190, 60, 0.48);
}
.setup-storage-badge-bad {
background: rgba(200, 60, 60, 0.14);
color: #c04040;
border: 1px solid rgba(200, 60, 60, 0.32);
background: rgba(255, 110, 110, 0.18);
color: #ffa3a3;
border: 1px solid rgba(255, 110, 110, 0.45);
}
.setup-storage-card em {
opacity: 0.85;
@ -1306,3 +1309,14 @@ body.setup-wizard-open .eo-modal { z-index: 10000; }
}
.setup-storage-fstab-opt { display: flex; gap: 9px; align-items: center; cursor: pointer; }
.setup-storage-fstab-note { margin-top: 10px !important; font-size: 0.85em !important; opacity: 0.75 !important; }
/* The system drive: ticked and non-interactive, because apps fall back to it
and it therefore cannot be deselected. Kept at full opacity unlike a
refused drive, it is not a lesser option, it is the default one. */
.setup-storage-locked { opacity: 1; cursor: default; }
.setup-storage-locked input[type=checkbox] { cursor: default; }
.setup-storage-badge-ok {
background: rgba(90, 220, 150, 0.18);
color: #86ecb6;
border: 1px solid rgba(90, 220, 150, 0.45);
}

View File

@ -28,6 +28,7 @@ class SetupWizard {
// Set by loadStorage() once the candidate scan comes back.
this.hasStorageCandidates = false;
this.storageCandidates = [];
this.storageSystem = null;
this.selectedStorage = [];
// Paths the user asked us to make permanent in /etc/fstab.
this.fstabWanted = [];
@ -43,7 +44,10 @@ class SetupWizard {
_stepVisible(idx) {
const name = this.stepNames[idx];
if (name === 'Metrics') return this.installLevel === 'advanced';
if (name === 'Storage') return this.hasStorageCandidates;
// Storage always shows now. Even with one disk it answers "where does my
// data actually go?", which is worth a step in a self-hosting product —
// and the system drive's own details are the interesting part there.
if (name === 'Storage') return true;
return true;
}
@ -396,13 +400,12 @@ class SetupWizard {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
this.storageCandidates = Array.isArray(data.candidates) ? data.candidates : [];
this.storageSystem = data.system || null;
} catch (e) {
console.log('[setup] storage scan unavailable, skipping the Storage step:', e.message);
console.log('[setup] storage scan unavailable:', e.message);
this.storageCandidates = [];
this.storageSystem = null;
}
// A candidate that cannot work is still WORTH SHOWING (greyed, with the
// reason) — "why isn't my drive listed?" is a support burden. But if every
// candidate is unusable there is nothing to choose, so skip the step.
this.hasStorageCandidates = this.storageCandidates.some(c => c.verdict !== 'refuse');
this.totalSteps = this._effectiveTotalSteps();
this.renderStorage();
@ -447,60 +450,60 @@ class SetupWizard {
return map[c.id] || c.message;
}
// One card, one line.
//
// 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.
_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>'
: (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;
return `
<label class="setup-app setup-storage-card${refused ? ' setup-storage-disabled' : ''}${o.locked ? ' setup-storage-locked' : ''}">
<input type="checkbox" ${o.locked ? 'checked disabled data-storage-system="1"' : `data-storage-path="${this.escapeHtml(c.path)}"`} ${refused ? 'disabled' : ''}>
<span class="setup-app-body">
<span class="setup-app-name">${this.escapeHtml(title)} ${badge}</span>
<span class="setup-app-desc">${this.escapeHtml(c.size)} &middot; ${this.escapeHtml(c.free)} free &middot; ${this.escapeHtml(c.fstype)}${c.removable ? ' &middot; removable' : ''}</span>
</span>
<button type="button" class="setup-storage-details" data-storage-details="${this.escapeHtml(key)}">Details</button>
</label>`;
}
renderStorage() {
const list = this.container.querySelector('#sw-storage-list');
const note = this.container.querySelector('#sw-storage-note');
if (!list) return;
if (!this.storageCandidates.length) {
list.innerHTML = '';
if (note) note.textContent = '';
return;
let html = '';
if (this.storageSystem) {
html += this._storageCard(this.storageSystem, 'system', { locked: true });
}
list.innerHTML = this.storageCandidates.map((c, i) => {
const refused = c.verdict === 'refuse';
const warned = c.verdict === 'warn';
const id = `sw-storage-${i}`;
const badge = refused
? '<span class="setup-storage-badge setup-storage-badge-bad">can\u2019t be used</span>'
: (warned ? '<span class="setup-storage-badge setup-storage-badge-warn">needs care</span>' : '');
// One line of plain facts, then at most two short flags. Everything else
// is a click away rather than in the user's face.
const flags = (c.checks || [])
.filter(k => k.severity === 'refuse' || k.severity === 'warn')
.map(k => this._storageCheckSummary(k));
const shown = flags.slice(0, 2);
const more = flags.length - shown.length;
return `
<label class="setup-app setup-storage-card${refused ? ' setup-storage-disabled' : ''}">
<input type="checkbox" id="${id}" data-storage-path="${this.escapeHtml(c.path)}" ${refused ? 'disabled' : ''}>
<span class="setup-app-body">
<span class="setup-app-name">
${this.escapeHtml(c.path)} ${badge}
</span>
<span class="setup-app-desc">
${this.escapeHtml(c.size)} drive &middot; ${this.escapeHtml(c.free)} free &middot; ${this.escapeHtml(c.fstype)}${c.removable ? ' &middot; removable' : ''}
${shown.length ? `<br><span class="setup-storage-flags">${shown.map(f => this.escapeHtml(f)).join(' &middot; ')}${more > 0 ? ` &middot; +${more} more` : ''}</span>` : ''}
</span>
</span>
<button type="button" class="setup-storage-details" data-storage-details="${i}">Details</button>
</label>`;
}).join('');
html += this.storageCandidates.map((c, i) => this._storageCard(c, String(i), {})).join('');
list.innerHTML = html;
list.querySelectorAll('[data-storage-details]').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.showStorageDetails(Number(btn.dataset.storageDetails));
this.showStorageDetails(btn.dataset.storageDetails);
});
});
if (note) {
note.innerHTML = 'A drive you tick becomes a storage location during install. '
+ 'Drives that can\u2019t hold app data are shown greyed with the reason.';
note.innerHTML = this.storageCandidates.length
? 'Apps go on the system disk unless you tick another drive. Drives that can\u2019t hold app data are greyed out \u2014 open Details to see why.'
: 'No other drives were found, so everything goes on the system disk. Plug one in and you can add it later from the CLI (<code>libreportal storage</code>).';
}
}
@ -512,13 +515,15 @@ class SetupWizard {
// document.body — a hand-rolled position:fixed backdrop appended INSIDE the
// wizard container resolves against the wizard's containing block (it has a
// backdrop-filter), so it covered part of the screen instead of all of it.
showStorageDetails(idx) {
const c = this.storageCandidates[idx];
showStorageDetails(key) {
const isSystem = key === 'system';
const c = isSystem ? this.storageSystem : this.storageCandidates[Number(key)];
if (!c) return;
const esc = (s) => this.escapeHtml(s);
const spec = [
['Mount point', c.path],
...(isSystem ? [['Apps stored in', c.path], ['Mount point', c.mount || '/']]
: [['Mount point', c.path]]),
['Device', c.device],
['Filesystem', c.fstype],
['Size', c.size],
@ -526,6 +531,7 @@ class SetupWizard {
['UUID', c.uuid || '—'],
['Mount options', c.options || '—'],
['Removable', c.removable ? 'Yes' : 'No'],
...(isSystem && c.apps ? [['Apps here', c.apps]] : []),
].map(([k, v]) => `<tr><th>${esc(k)}</th><td><code>${esc(v)}</code></td></tr>`).join('');
const icon = { refuse: '\u26d4', warn: '\u26a0\ufe0f', info: '\u2705' };
@ -545,7 +551,7 @@ class SetupWizard {
: `<ul class="setup-storage-checks">${checks}</ul>`,
];
if (c.fstab_line) {
if (c.fstab_line && !isSystem) {
parts.push(`
<div class="setup-storage-fstab">
<div class="setup-storage-fstab-title">Make this drive mount automatically</div>
@ -576,7 +582,7 @@ class SetupWizard {
const m = window.openEoModal({
id: 'lp-storage-details',
size: 'md',
title: c.path,
title: isSystem ? 'System disk' : c.path,
desc: `${c.size} drive \u00b7 ${c.free} free \u00b7 ${c.fstype}${c.removable ? ' \u00b7 removable' : ''}`,
body: parts.join(''),
actions: [{ label: 'Close', variant: 'secondary' }],

View File

@ -34,6 +34,46 @@ webuiGenerateStorageCandidates()
done < <(runStorage verify 2>/dev/null)
locations+="]"
# --- the system drive, always present -----------------------------------
# Emitted as a first-class entry, not filtered out as "already in use": it
# is where apps live by default, and a Storage step that shows nothing on a
# single-disk box tells the user nothing about where their data goes.
local system_json="null"
local sys_root; sys_root="$(primaryRoot)"
local sys_mount sys_dev sys_fs sys_size sys_free sys_uuid sys_opts
if command -v findmnt >/dev/null 2>&1; then
sys_mount=$(findmnt -no TARGET --target "$sys_root" 2>/dev/null | tail -1)
sys_dev=$(findmnt -no SOURCE --target "$sys_root" 2>/dev/null | tail -1)
sys_fs=$(findmnt -no FSTYPE --target "$sys_root" 2>/dev/null | tail -1)
sys_size=$(findmnt -no SIZE --target "$sys_root" 2>/dev/null | tail -1)
sys_free=$(findmnt -no AVAIL --target "$sys_root" 2>/dev/null | tail -1)
sys_uuid=$(findmnt -no UUID --target "$sys_root" 2>/dev/null | tail -1)
sys_opts=$(findmnt -no OPTIONS --target "$sys_root" 2>/dev/null | tail -1)
fi
local sys_checks="[" sfirst=1 ssev scheck smsg
while IFS=$'\t' read -r ssev scheck smsg; do
[[ -z "$ssev" || "$scheck" == "fstab-line" ]] && continue
# The system disk is where apps already live, so "same disk as the
# primary location" is a tautology here rather than a finding.
[[ "$scheck" == "same-device" ]] && continue
(( sfirst )) || sys_checks+=","
sfirst=0
sys_checks+="{\"severity\":\"$(_lpJsonEsc "$ssev")\",\"id\":\"$(_lpJsonEsc "$scheck")\",\"message\":\"$(_lpJsonEsc "$smsg")\"}"
done < <(storageCheckPath "$sys_root" 2>/dev/null)
sys_checks+="]"
system_json="{\"path\":\"$(_lpJsonEsc "$sys_root")\""
system_json+=",\"mount\":\"$(_lpJsonEsc "${sys_mount:-/}")\""
system_json+=",\"device\":\"$(_lpJsonEsc "$sys_dev")\""
system_json+=",\"fstype\":\"$(_lpJsonEsc "$sys_fs")\""
system_json+=",\"size\":\"$(_lpJsonEsc "$sys_size")\""
system_json+=",\"free\":\"$(_lpJsonEsc "$sys_free")\""
system_json+=",\"uuid\":\"$(_lpJsonEsc "$sys_uuid")\""
system_json+=",\"options\":\"$(_lpJsonEsc "$sys_opts")\""
system_json+=",\"removable\":false"
system_json+=",\"apps\":\"$(_lpJsonEsc "$(storageAppsOnRoot "$sys_root" 2>/dev/null | paste -sd, -)")\""
system_json+=",\"checks\":$sys_checks}"
# --- unregistered candidates, with a fitness verdict each ---------------
local candidates="[" cfirst=1
local target source fstype size avail uuid rm_flag role
@ -82,6 +122,7 @@ webuiGenerateStorageCandidates()
cat > "$tmp" <<EOF
{
"primary": "$(_lpJsonEsc "$(primaryRoot)")",
"system": $system_json,
"locations": $locations,
"candidates": $candidates,
"updated": "$(date -Iseconds)"