diff --git a/containers/libreportal/backend/routes/setup-routes.js b/containers/libreportal/backend/routes/setup-routes.js index 1d5e334..53dc764 100644 --- a/containers/libreportal/backend/routes/setup-routes.js +++ b/containers/libreportal/backend/routes/setup-routes.js @@ -350,9 +350,30 @@ router.post('/restore/apply', requireAuth, async (req, res) => { } const drop = (req.body && req.body.drop_domains) ? 'yes' : 'no'; + // Which snapshot of each thing. base64 JSON, because a per-app map cannot + // survive the CLI wrapper's nine positional slots. Validated first: these + // are restic short ids and app names, and both reach a command line. + let choice = ''; + const c = req.body && req.body.choice; + if (c && typeof c === 'object') { + const clean = { system: null, apps: {}, times: {} }; + if (typeof c.system === 'string' && /^[0-9a-f]{6,64}$/.test(c.system)) clean.system = c.system; + for (const [app, snap] of Object.entries(c.apps || {})) { + if (!/^[a-z0-9_-]+$/i.test(app)) continue; + if (typeof snap !== 'string' || !/^[0-9a-f]{6,64}$/.test(snap)) continue; + clean.apps[app] = snap; + } + for (const [app, when] of Object.entries(c.times || {})) { + if (!/^[a-z0-9_-]+$/i.test(app)) continue; + if (typeof when !== 'string' || when.length > 40) continue; + clean.times[app] = when; + } + choice = Buffer.from(JSON.stringify(clean), 'utf8').toString('base64'); + } + try { const id = await enqueueTask({ - command: `libreportal restore rebuild ${idx} ${host || "''"} ${drop}`, + command: `libreportal restore rebuild ${idx} ${host || "''"} ${drop}${choice ? ' ' + choice : ''}`, type: 'restore', app: 'libreportal', setupRole: 'config' diff --git a/containers/libreportal/frontend/core/setup/css/setup-wizard.css b/containers/libreportal/frontend/core/setup/css/setup-wizard.css index b816b7f..f787f36 100755 --- a/containers/libreportal/frontend/core/setup/css/setup-wizard.css +++ b/containers/libreportal/frontend/core/setup/css/setup-wizard.css @@ -1669,3 +1669,42 @@ button.setup-found-backup:focus-visible { opacity: 0.45; cursor: not-allowed; } + +/* Which snapshot of a thing to restore. Sits at the end of its row, so the + row still reads name-then-detail rather than turning into a form. */ +.setup-snap-pick { + /* appearance:none, like every other select in the wizard. Left on `auto` the + browser paints its own control and ignores the background and colour + entirely — which is why this rendered as a light native dropdown on a dark + panel while the computed styles looked correct. */ + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23bcd7ea' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + margin-left: auto; + padding: 4px 26px 4px 8px; + border-radius: 7px; + border: 1px solid rgba(var(--text-rgb), 0.18); + /* background-COLOR, not the shorthand: `background:` resets + background-image and would wipe the arrow set above it. */ + background-color: rgba(var(--text-rgb), 0.06); + color: inherit; + font: inherit; + font-size: 0.84em; + font-variant-numeric: tabular-nums; + cursor: pointer; + max-width: 100%; +} +.setup-snap-pick:hover { border-color: rgba(79, 195, 247, 0.5); } +.setup-snap-pick:focus-visible { + outline: 2px solid rgba(79, 195, 247, 0.75); + outline-offset: 1px; +} +.setup-app-card:has(.setup-snap-pick) { display: flex; align-items: center; gap: 10px; } +/* The popup list is drawn by the platform, which does not inherit the panel's + palette — without this the open dropdown is white text on white. */ +.setup-snap-pick option { + background: #14283e; + color: #e8f2fb; +} diff --git a/containers/libreportal/frontend/core/setup/js/setup-wizard.js b/containers/libreportal/frontend/core/setup/js/setup-wizard.js index 6deafb7..2a58526 100755 --- a/containers/libreportal/frontend/core/setup/js/setup-wizard.js +++ b/containers/libreportal/frontend/core/setup/js/setup-wizard.js @@ -49,6 +49,8 @@ class SetupWizard { // What `restore read` found: host, hosts, apps, domains, location_idx. this.restoreInfo = null; this.restoreDomains = []; + // Which snapshot of each thing to restore. Newest of each by default. + this.restoreChoice = null; // Repositories found on this machine, from `restore scan`. this.foundBackups = []; this.storageBackupRoot = ''; @@ -1510,6 +1512,7 @@ class SetupWizard { if (data.error) throw new Error(data.error); this.restoreInfo = data; + this._initRestoreChoice(); this._syncRestoreNav(); if (status) { const nApps = (data.apps || []).length; @@ -1572,8 +1575,8 @@ class SetupWizard { ${system.present ? `
Your logins, your domains, and every backup repository you had \u2014 @@ -1597,15 +1600,65 @@ class SetupWizard { apps.map(a => `
No app backups in this repository.
'}`; + this._wireSnapshotPickers(); + if (!domains.length) return; await this._checkRestoreDomains(domains); } + // Which snapshot of a thing to restore. + // + // Only rendered where there is more than one: a dropdown with a single entry + // is a control that cannot be operated, and it makes a repository with one + // backup look like it is hiding something. + // + // This is the point at which choosing is meaningful. Before the repository is + // open, a snapshot is a hash — a restic snapshot holds ONE app's data or the + // settings tree, and which is which lives in the encrypted object. Here they + // have names, so "linkding, as it was on Tuesday" is a sentence. + _snapshotPicker(kind, key, snaps, chosenId) { + if (!Array.isArray(snaps) || snaps.length <= 1) { + const only = (snaps || [])[0]; + return only ? `${this._restoreWhen(only.time)}` : ''; + } + const id = `sw-rs-snap-${kind}${key ? '-' + key : ''}`; + return ` + `; + } + + // Remember every pick, and default to the newest of each. + _initRestoreChoice() { + const d = this.restoreInfo || {}; + const choice = { system: null, apps: {} }; + const sys = ((d.system || {}).snapshots || [])[0]; + if (sys) choice.system = sys.id; + (d.apps || []).forEach((a) => { + const newest = (a.snapshots || [])[0]; + if (newest) choice.apps[a.name] = newest.id; + }); + this.restoreChoice = choice; + } + + _wireSnapshotPickers() { + this.container.querySelectorAll('.setup-snap-pick').forEach((sel) => { + sel.addEventListener('change', () => { + if (!this.restoreChoice) this._initRestoreChoice(); + if (sel.dataset.snapKind === 'system') this.restoreChoice.system = sel.value; + else this.restoreChoice.apps[sel.dataset.snapKey] = sel.value; + }); + }); + } + // "28 Aug 2026, 13:10" — a snapshot's age is the thing people actually judge // a backup by, and an ISO timestamp is not something anyone reads at a // glance. @@ -1713,6 +1766,20 @@ class SetupWizard { next.classList.toggle('is-waiting', blocked); } + // The picks, with the human time beside each so the restore's own output can + // name a date instead of a hash. + _restoreChoicePayload() { + if (!this.restoreChoice) this._initRestoreChoice(); + const d = this.restoreInfo || {}; + const times = {}; + (d.apps || []).forEach((a) => { + const id = (this.restoreChoice.apps || {})[a.name]; + const hit = (a.snapshots || []).find(s => s.id === id); + if (hit) times[a.name] = this._restoreWhen(hit.time); + }); + return { system: this.restoreChoice.system, apps: this.restoreChoice.apps, times }; + } + async submitRestore() { const btn = this.container.querySelector('#sw-submit'); const setLabel = (s) => { @@ -1736,7 +1803,10 @@ class SetupWizard { body: JSON.stringify({ location_idx: String(this.restoreInfo.location_idx), host: this.restoreInfo.host || '', - drop_domains: !!(dropBox && dropBox.checked) + drop_domains: !!(dropBox && dropBox.checked), + // Which snapshot of each thing, plus the times so the restore log can + // say "restoring the snapshot from 28 Aug" rather than a hash. + choice: this._restoreChoicePayload() }) }); const data = await res.json().catch(() => ({})); diff --git a/docs/roadmap/first-run-restore.md b/docs/roadmap/first-run-restore.md index 6417b40..e68763d 100644 --- a/docs/roadmap/first-run-restore.md +++ b/docs/roadmap/first-run-restore.md @@ -589,6 +589,51 @@ The first version filled in only the *placeholder*, which left the field empty so pressing **Check** replied "give a full path, starting with /" about the very backup displayed directly above it. +### Choosing which snapshot — and what a snapshot actually is + +Worth stating plainly, because the shape of the repository is not obvious and +it decides what a chooser can mean. + +**A snapshot is one app's data, or the settings tree. Never a machine.** A +four-snapshot repository looks like this: + +``` +28 Aug 13:10 56e95ba1 app=linkding /mnt/lptest1/apps/linkding +28 Aug 13:10 6f8eed7d app=ipinfo /mnt/lptest2/apps/ipinfo +29 Aug 04:09 28bedbb0 system=config /libreportal-system/configs +29 Aug 05:50 cc5b6bcf system=config /libreportal-system/configs +``` + +That is two apps plus two versions of the settings — not four backups to pick +between. They are separate because they are used separately: the settings tree +is small and changes rarely, app data is large and has to be restorable, +movable and ageable on its own. + +So the choice belongs on **Contents**, after unlocking, where each snapshot has +a name and a date. Every row that has more than one gets a picker, defaulting +to the newest; a row with one shows its date as text, because a dropdown +holding a single entry is a control that cannot be operated. + +**The chain already supported this.** `restorePickSnapshot` passes any value +that is not the string `"latest"` straight through as an id — it has done +since it was written, and nothing ever offered the choice. What was missing: + +- `restoreInspect` now returns every snapshot per app and for the settings, + not only the newest. +- `restoreFirstRunBulk` reads an optional `RESTORE_SNAPSHOT_CHOICE` map instead + of hardcoding `"latest"`. An associative array rather than an argument, + because the CLI wrapper pads argv to nine slots and a per-app map cannot + survive that; the map itself reaches the host as base64 JSON. +- `backupRestoreSystemConfig` takes a snapshot **and a host**. The host was the + real bug: it defaulted to *this* machine's name, which is right for + "recover my own settings" and wrong for a rebuild, where the snapshots carry + the dead machine's name. It surfaced the moment a restore adopted a config + with a different install name and the next lookup found nothing. + +Verified by restoring both settings snapshots and diffing them: `28bedbb0` +brings back a config carrying `example.com`, `cc5b6bcf` one with no domains. +The pick changes what lands. + **Not done: listing individual snapshots before the password.** The count is a directory listing, but the *identity* of each snapshot — its host, its tags, what it holds, when its contents are from — lives in the encrypted object. All diff --git a/scripts/backup/engine/restic_restore.sh b/scripts/backup/engine/restic_restore.sh index 1c1c661..e0b654d 100644 --- a/scripts/backup/engine/restic_restore.sh +++ b/scripts/backup/engine/restic_restore.sh @@ -125,14 +125,22 @@ resticRestoreSystemLatest() local idx="$1" local target_dir="$2" local host="${3:-$CFG_INSTALL_NAME}" + # An explicit snapshot, when the caller has one. The wizard offers a choice + # between system-config snapshots, and a picker whose answer is ignored is + # worse than no picker. + local want="${4:-}" - resticEnvExport "$idx" || return 1 local snapshot_id - snapshot_id=$(runBackupOp restic snapshots \ - --tag "system=config" --host "$host" \ - --latest 1 --json --no-lock 2>/dev/null | \ - grep -o '"short_id":"[^"]*"' | head -1 | cut -d'"' -f4) - resticEnvUnset + if [[ -n "$want" && "$want" != "latest" ]]; then + snapshot_id="$want" + else + resticEnvExport "$idx" || return 1 + snapshot_id=$(runBackupOp restic snapshots \ + --tag "system=config" --host "$host" \ + --latest 1 --json --no-lock 2>/dev/null | \ + grep -o '"short_id":"[^"]*"' | head -1 | cut -d'"' -f4) + resticEnvUnset + fi if [[ -z "$snapshot_id" ]]; then isError "No system-config snapshot found in $(resticLocationName "$idx") for host=$host" diff --git a/scripts/backup/system/backup_system.sh b/scripts/backup/system/backup_system.sh index cda7274..8ac6bf3 100644 --- a/scripts/backup/system/backup_system.sh +++ b/scripts/backup/system/backup_system.sh @@ -62,6 +62,15 @@ backupSystemConfig() backupRestoreSystemConfig() { local idx="${1:-}" + # Optional: which system-config snapshot. Empty or "latest" keeps the old + # behaviour, which is what every existing caller wants. + local want_snapshot="${2:-}" + # Optional: WHOSE snapshot. Defaults to this machine's name, which is right + # for "recover my own settings" and wrong for a rebuild — the snapshots + # were tagged with the DEAD machine's name, and the moment a restore adopts + # a config carrying a different install name, a later lookup under the new + # name finds nothing at all. + local want_host="${3:-}" [[ -z "$idx" ]] && idx=$(resticEnabledLocations | head -1) if [[ -z "$idx" ]]; then isError "No enabled backup location to restore the system config from" @@ -81,7 +90,7 @@ backupRestoreSystemConfig() fi isHeader "Restoring system config (to staging — live config is untouched)" - if ! engineRestoreSystemLatest "$idx" "$staging"; then + if ! engineRestoreSystemLatest "$idx" "$staging" "$want_host" "$want_snapshot"; then isError "System config restore failed" return 1 fi diff --git a/scripts/cli/commands/restore/cli_restore_commands.sh b/scripts/cli/commands/restore/cli_restore_commands.sh index be64258..e49fad3 100755 --- a/scripts/cli/commands/restore/cli_restore_commands.sh +++ b/scripts/cli/commands/restore/cli_restore_commands.sh @@ -39,12 +39,13 @@ cliHandleRestoreCommands() system) # Restore the latest system-config snapshot (settings + creds) into a # staging dir; never overwrites live config. Optional location idx. - backupRestoreSystemConfig "$action" + # restore system [loc_idx] [snapshot] [host] + backupRestoreSystemConfig "$action" "$name" "$extra" ;; rebuild) # The WebUI's restore: adopt settings, reconcile domains, restore - # apps. restore rebuild