From d8c9f2486be81142d8fae6d6decd63d25407d44c Mon Sep 17 00:00:00 2001 From: librelad Date: Wed, 26 Aug 2026 02:19:40 +0100 Subject: [PATCH] feat(setup): show the system disk, and slim the drive cards to one line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../frontend/core/setup/css/setup-wizard.css | 26 ++++- .../frontend/core/setup/js/setup-wizard.js | 104 +++++++++--------- .../system/webui_storage_candidates.sh | 41 +++++++ 3 files changed, 116 insertions(+), 55 deletions(-) diff --git a/containers/libreportal/frontend/core/setup/css/setup-wizard.css b/containers/libreportal/frontend/core/setup/css/setup-wizard.css index d8378ed..1a49d08 100755 --- a/containers/libreportal/frontend/core/setup/css/setup-wizard.css +++ b/containers/libreportal/frontend/core/setup/css/setup-wizard.css @@ -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); +} diff --git a/containers/libreportal/frontend/core/setup/js/setup-wizard.js b/containers/libreportal/frontend/core/setup/js/setup-wizard.js index 99de880..a8d938e 100755 --- a/containers/libreportal/frontend/core/setup/js/setup-wizard.js +++ b/containers/libreportal/frontend/core/setup/js/setup-wizard.js @@ -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 + ? 'default' + : (refused ? 'can\u2019t be used' + : (c.verdict === 'warn' ? 'needs care' : '')); + + const title = o.locked ? 'System disk' : c.path; + + return ` + `; + } + 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 - ? 'can\u2019t be used' - : (warned ? 'needs care' : ''); - - // 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 ` - `; - }).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 (libreportal storage).'; } } @@ -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]) => `${esc(k)}${esc(v)}`).join(''); const icon = { refuse: '\u26d4', warn: '\u26a0\ufe0f', info: '\u2705' }; @@ -545,7 +551,7 @@ class SetupWizard { : ``, ]; - if (c.fstab_line) { + if (c.fstab_line && !isSystem) { parts.push(`
Make this drive mount automatically
@@ -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' }], diff --git a/scripts/webui/data/generators/system/webui_storage_candidates.sh b/scripts/webui/data/generators/system/webui_storage_candidates.sh index d2d9bdf..b49b81d 100644 --- a/scripts/webui/data/generators/system/webui_storage_candidates.sh +++ b/scripts/webui/data/generators/system/webui_storage_candidates.sh @@ -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" <