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 ? `
System settings - 1 snapshot - ${this._restoreWhen(system.date)} + ${(system.snapshots || []).length} version${(system.snapshots || []).length === 1 ? '' : 's'} + ${this._snapshotPicker('system', '', system.snapshots, (this.restoreChoice || {}).system)}

Your logins, your domains, and every backup repository you had \u2014 @@ -1597,15 +1600,65 @@ class SetupWizard { apps.map(a => `

${this.escapeHtml(a.name)} - ${this.escapeHtml(a.size || '')}${ - a.size && a.date ? ' \u00b7 ' : ''}${this._restoreWhen(a.date)} + ${this.escapeHtml(a.size || '')} + ${this._snapshotPicker('app', a.name, a.snapshots, ((this.restoreChoice || {}).apps || {})[a.name])}
`).join('') : '

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 [drop-domains] - restoreWebuiRebuild "$action" "$name" "$extra" + # apps. restore rebuild [drop-domains] [choice-b64] + restoreWebuiRebuild "$action" "$name" "$extra" "$extra2" ;; connect) # Connect a repository from a base64 JSON payload and report what diff --git a/scripts/dev/lp-restore-adopt-test b/scripts/dev/lp-restore-adopt-test index 5fd6eba..949608e 100755 --- a/scripts/dev/lp-restore-adopt-test +++ b/scripts/dev/lp-restore-adopt-test @@ -168,13 +168,22 @@ fi echo "adoption does not re-permission directories it passes through" # config-adopt clamped every parent to manager:manager 0750, including ones # that already existed. That closed configs/backup to the container user and -# broke the credential read, and closed configs/webui, which is what actually -# took the WebUI down. +# broke the credential read, and closed configs/webui, which is what took the +# WebUI down. +# +# Asserted as "can the container user get in", not as a mode. The first version +# demanded 0755 and failed on configs/webui, which libreportal-ownership sets +# to 0751:container ON PURPOSE — tighter, and perfectly traversable. A test +# that pins an incidental number reports a regression every time someone makes +# the permissions better. +CU=$(stat -c '%G' "$CONFIGS/backup/locations" 2>/dev/null || echo dockerinstall) for d in general network security webui backup; do [[ -d "$CONFIGS/$d" ]] || continue - m=$(stat -c '%a' "$CONFIGS/$d") - if [[ "$m" == "755" ]]; then echo " ok configs/$d is traversable ($m)" - else echo " FAIL configs/$d is $m — the container user cannot traverse it"; fail=1; fi + if sudo -u "$CU" test -x "$CONFIGS/$d" 2>/dev/null; then + echo " ok $CU can traverse configs/$d ($(stat -c '%a' "$CONFIGS/$d"))" + else + echo " FAIL $CU cannot traverse configs/$d ($(stat -c '%a %U:%G' "$CONFIGS/$d"))"; fail=1 + fi done echo "every adopted config stays readable by the backup" diff --git a/scripts/dev/lp-restore-wizard-test b/scripts/dev/lp-restore-wizard-test index 4a0b7c1..20314ec 100755 --- a/scripts/dev/lp-restore-wizard-test +++ b/scripts/dev/lp-restore-wizard-test @@ -342,6 +342,52 @@ read -r -d '' DRIVE <<'JS' // Domains belong under Settings, so with none there is no stray heading. out.noDomainsHeadingWhenEmpty = !/Domains it will bring across/.test(contents); + // Choosing WHICH snapshot. This is the point at which it is meaningful: + // before the repository is open a snapshot is a hash, because 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 and dates. + // + // restorePickSnapshot has always passed any id that is not the string + // "latest" straight through, so the chain supported this long before + // anything offered it. + w.restoreInfo = { + host: 'oldbox', hosts: ['oldbox'], location_idx: '1', + system: { present: true, date: '2026-08-29T05:50:00+01:00', domains: [], + snapshots: [{ id: 'cc5b6bcf', time: '2026-08-29T05:50:00+01:00' }, + { id: '28bedbb0', time: '2026-08-29T04:09:00+01:00' }] }, + apps: [ + { name: 'linkding', size: '1M', + snapshots: [{ id: '56e95ba1', time: '2026-08-28T13:10:00+01:00' }, + { id: 'aaaa1111', time: '2026-08-20T02:00:00+01:00' }] }, + { name: 'ipinfo', size: '5K', + snapshots: [{ id: '6f8eed7d', time: '2026-08-28T13:10:00+01:00' }] } + ] + }; + w._initRestoreChoice(); + await w.renderRestoreContents(); + + out.systemHasAPicker = !!$('#sw-rs-snap-system'); + out.appWithTwoHasAPicker = !!$('#sw-rs-snap-app-linkding'); + // A dropdown holding one entry is a control that cannot be operated, and it + // makes a repository with one backup look like it is hiding something. + out.appWithOneHasNoPicker = !$('#sw-rs-snap-app-ipinfo'); + out.defaultsToNewest = JSON.stringify(w.restoreChoice) === + '{"system":"cc5b6bcf","apps":{"linkding":"56e95ba1","ipinfo":"6f8eed7d"}}'; + + const sysSel = $('#sw-rs-snap-system'); + sysSel.value = '28bedbb0'; fire(sysSel, 'change'); + const appSel = $('#sw-rs-snap-app-linkding'); + appSel.value = 'aaaa1111'; fire(appSel, 'change'); + out.pickingIsRemembered = w.restoreChoice.system === '28bedbb0' + && w.restoreChoice.apps.linkding === 'aaaa1111'; + // The payload carries the ids AND the human times, so the restore's own + // output can name a date rather than a hash. + const payload = w._restoreChoicePayload(); + out.payloadCarriesThePicks = payload.system === '28bedbb0' && payload.apps.linkding === 'aaaa1111'; + out.payloadCarriesReadableTimes = /20 Aug 2026/.test(payload.times.linkding || ''); + // Apps the user did not touch still travel, at their newest. + out.untouchedAppsStillSent = payload.apps.ipinfo === '6f8eed7d'; + // A repository with app data but no settings snapshot must say so: the // user's repositories and logins will NOT come back, and finding that out // afterwards is the worst possible time. @@ -465,6 +511,16 @@ chk "shows when each was taken" "$(g .showsSnapshotDate)" true chk "no domain heading when there are none" "$(g .noDomainsHeadingWhenEmpty)" true chk "warns when there is no settings snapshot" "$(g .warnsWhenNoSystemSnapshot)" true +echo "choosing which snapshot" +chk "the settings offer a choice" "$(g .systemHasAPicker)" true +chk "so does an app with two" "$(g .appWithTwoHasAPicker)" true +chk "an app with one does not" "$(g .appWithOneHasNoPicker)" true +chk "everything defaults to newest" "$(g .defaultsToNewest)" true +chk "picking is remembered" "$(g .pickingIsRemembered)" true +chk "and reaches the payload" "$(g .payloadCarriesThePicks)" true +chk "with readable times beside it" "$(g .payloadCarriesReadableTimes)" true +chk "untouched apps still travel" "$(g .untouchedAppsStillSent)" true + echo "submit" chk "routes to the restore path" "$(g .submitRoutedToRestore)" true diff --git a/scripts/restore/restore_first_run.sh b/scripts/restore/restore_first_run.sh index 4d6a3b6..768b56b 100644 --- a/scripts/restore/restore_first_run.sh +++ b/scripts/restore/restore_first_run.sh @@ -73,7 +73,20 @@ restoreFirstRunBulk() local -a failed=() noisy=() for app in "${apps_to_restore[@]}"; do before=$(_restoreErrLines) - if restoreAppStart "$app" "latest" "$idx" "$source_host"; then + # Which snapshot of this app. "latest" unless the caller named one: + # restorePickSnapshot passes anything else straight through as an id, + # so the chain has always supported a point-in-time restore — until now + # nothing offered the choice. + # + # Read from an associative array the caller may set rather than an + # argument, because the CLI wrapper pads argv to nine slots and a + # per-app map cannot survive that. + local _snap="latest" + if declare -p RESTORE_SNAPSHOT_CHOICE >/dev/null 2>&1; then + [[ -n "${RESTORE_SNAPSHOT_CHOICE[$app]:-}" ]] && _snap="${RESTORE_SNAPSHOT_CHOICE[$app]}" + fi + [[ "$_snap" != "latest" ]] && isNotice " $app: restoring the snapshot from ${RESTORE_SNAPSHOT_TIME[$app]:-$_snap}" + if restoreAppStart "$app" "$_snap" "$idx" "$source_host"; then ok=$(( ok + 1 )) after=$(_restoreErrLines) (( after > before )) && noisy+=("$app") @@ -111,17 +124,43 @@ restoreFirstRunBulk() # Called from a task, so its output is the progress the user watches. restoreWebuiRebuild() { - local idx="${1:-}" host="${2:-}" drop="${3:-no}" + local idx="${1:-}" host="${2:-}" drop="${3:-no}" choice_b64="${4:-}" if [[ -z "$idx" ]]; then isError "restoreWebuiRebuild requires a backup location" return 1 fi + # Which snapshot of each thing the user picked on the Contents step. + # Base64 JSON, because a per-app map cannot survive the CLI wrapper's + # nine positional slots. + declare -gA RESTORE_SNAPSHOT_CHOICE=() + declare -gA RESTORE_SNAPSHOT_TIME=() + local _sys_snap="" + if [[ -n "$choice_b64" && "$choice_b64" != "empty" ]]; then + local _json _k _v + _json=$(printf '%s' "$choice_b64" | base64 -d 2>/dev/null) + if [[ -n "$_json" ]] && jq -e . >/dev/null 2>&1 <<< "$_json"; then + _sys_snap=$(jq -r '.system // ""' <<< "$_json") + while IFS=$'\t' read -r _k _v; do + [[ -z "$_k" ]] && continue + RESTORE_SNAPSHOT_CHOICE["$_k"]="$_v" + done < <(jq -r '(.apps // {}) | to_entries[] | "\(.key)\t\(.value)"' <<< "$_json") + while IFS=$'\t' read -r _k _v; do + [[ -z "$_k" ]] && continue + RESTORE_SNAPSHOT_TIME["$_k"]="$_v" + done < <(jq -r '(.times // {}) | to_entries[] | "\(.key)\t\(.value)"' <<< "$_json") + else + isNotice "Could not read which snapshots were chosen — restoring the newest of each." + fi + fi + isHeader "Rebuilding from backup" # --- settings first ------------------------------------------------------ isNotice "Restoring settings and credentials…" - if backupRestoreSystemConfig "$idx" >/dev/null 2>&1; then + # "$host", not this machine's name: the snapshots carry the name of the + # machine being rebuilt FROM. + if backupRestoreSystemConfig "$idx" "$_sys_snap" "$host" >/dev/null 2>&1; then # --force: the WebUI is only reachable at all because this machine has # a working install on it, so restoreAdoptIsFirstRun will say no. The # user asked for this explicitly on the Rebuild step, which is the diff --git a/scripts/restore/restore_inspect.sh b/scripts/restore/restore_inspect.sh index 2d4f266..98c591d 100644 --- a/scripts/restore/restore_inspect.sh +++ b/scripts/restore/restore_inspect.sh @@ -123,20 +123,32 @@ restoreInspect() system_json=$(jq -c --arg h "$host" ' [ .[] | select(.hostname == $h) | select(any(.tags[]?; . == "system=config")) ] - | sort_by(.time) - | if length > 0 then {present: true, date: (.[-1].time)} else {present: false, date: ""} end + | sort_by(.time) | reverse + | if length > 0 + then {present: true, date: .[0].time, + snapshots: map({id: .short_id, time: .time})} + else {present: false, date: "", snapshots: []} end ' <<< "$snaps" 2>/dev/null) - [[ -n "$system_json" ]] || system_json='{"present":false,"date":""}' + [[ -n "$system_json" ]] || system_json='{"present":false,"date":"","snapshots":[]}' - # One entry per app, newest snapshot of each. + # One entry per app, newest first, WITH every snapshot it has. + # + # The per-snapshot list is what makes "restore this app as it was on + # Tuesday" possible: restorePickSnapshot already passes any id that is not + # the string "latest" straight through, so the chain has always supported + # it — nothing ever offered the choice. local apps_json apps_json=$(jq -c --arg h "$host" ' [ .[] | select(.hostname == $h) | . as $s | (.tags[]? | select(startswith("app=")) | ltrimstr("app=")) as $name - | {name: $name, time: $s.time} ] + | {name: $name, time: $s.time, id: $s.short_id} ] | group_by(.name) - | map({name: .[0].name, date: (sort_by(.time) | .[-1].time)}) + | map({ + name: .[0].name, + date: (sort_by(.time) | .[-1].time), + snapshots: (sort_by(.time) | reverse | map({id: .id, time: .time})) + }) | sort_by(.name) ' <<< "$snaps" 2>/dev/null) [[ -n "$apps_json" ]] || apps_json='[]'