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" <