Choose which snapshot to restore, per app and for the settings

A snapshot is one app's data, or the settings tree — never a machine. A
four-snapshot repository is typically two apps plus two versions of the
settings, not four backups to pick between. So the choice belongs on Contents,
after unlocking, where each snapshot has a name and a date rather than being a
hash.

Every row with more than one snapshot gets a picker, defaulting to the newest.
A row with one shows its date as text: a dropdown holding a single entry is a
control that cannot be operated, and it makes a repository with one backup look
like it is hiding something.

The chain already supported this. restorePickSnapshot has always passed any
value that is not the string "latest" straight through as an id; nothing ever
offered the choice. What was missing:

  - restoreInspect returns every snapshot per app and for the settings, not
    just 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 it; the map reaches the host as base64 JSON, validated at the route
    against restic short ids and app names since both hit a command line.
  - backupRestoreSystemConfig takes a snapshot AND a host.

That host was a real bug. It defaulted to this machine's install name, which is
right for "recover my own settings" and wrong for a rebuild — the snapshots
carry the name of the machine being rebuilt FROM. It surfaced the moment a
restore adopted a config with a different install name and the next lookup
found nothing at all.

Verified by restoring both settings snapshots and diffing: 28bedbb0 brings back
a config carrying example.com, cc5b6bcf one with no domains.

Two CSS traps on the picker: appearance stayed `auto`, so the browser painted
its own control and ignored the colours entirely while the computed styles
looked right; and a `background:` shorthand later in the rule silently reset the
background-image, wiping the arrow set three lines above it.

lp-restore-adopt-test asserted configs/* were mode 0755 and started failing on
configs/webui, which libreportal-ownership sets to 0751:container on purpose —
tighter, and perfectly traversable. It asserts "the container user can traverse
it" now. A test that pins an incidental number reports a regression every time
someone improves the thing it is watching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-29 13:44:21 +01:00
parent 94683db240
commit 6018250526
11 changed files with 339 additions and 30 deletions

View File

@ -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'

View File

@ -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;
}

View File

@ -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
? `<div class="setup-app-card">
<span class="setup-app-name">System settings</span>
<span class="setup-storage-badge setup-storage-badge-ok">1 snapshot</span>
<span class="setup-app-desc">${this._restoreWhen(system.date)}</span>
<span class="setup-storage-badge setup-storage-badge-ok">${(system.snapshots || []).length} version${(system.snapshots || []).length === 1 ? '' : 's'}</span>
${this._snapshotPicker('system', '', system.snapshots, (this.restoreChoice || {}).system)}
</div>
<p class="setup-section-hint">
Your logins, your domains, and every backup repository you had \u2014
@ -1597,15 +1600,65 @@ class SetupWizard {
apps.map(a => `
<div class="setup-app-card">
<span class="setup-app-name">${this.escapeHtml(a.name)}</span>
<span class="setup-app-desc">${this.escapeHtml(a.size || '')}${
a.size && a.date ? ' \u00b7 ' : ''}${this._restoreWhen(a.date)}</span>
<span class="setup-app-desc">${this.escapeHtml(a.size || '')}</span>
${this._snapshotPicker('app', a.name, a.snapshots, ((this.restoreChoice || {}).apps || {})[a.name])}
</div>`).join('')
: '<p class="setup-section-hint">No app backups in this repository.</p>'}`;
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 ? `<span class="setup-app-desc">${this._restoreWhen(only.time)}</span>` : '';
}
const id = `sw-rs-snap-${kind}${key ? '-' + key : ''}`;
return `
<select class="setup-snap-pick" id="${id}" data-snap-kind="${kind}" data-snap-key="${this.escapeHtml(key || '')}">
${snaps.map((s, i) => `
<option value="${this.escapeHtml(s.id)}"${(chosenId ? s.id === chosenId : i === 0) ? ' selected' : ''}>
${this._restoreWhen(s.time)}${i === 0 ? ' (newest)' : ''}
</option>`).join('')}
</select>`;
}
// 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(() => ({}));

View File

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

View File

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

View File

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

View File

@ -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 <location-idx> <host> [drop-domains]
restoreWebuiRebuild "$action" "$name" "$extra"
# apps. restore rebuild <idx> <host> [drop-domains] [choice-b64]
restoreWebuiRebuild "$action" "$name" "$extra" "$extra2"
;;
connect)
# Connect a repository from a base64 JSON payload and report what

View File

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

View File

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

View File

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

View File

@ -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='[]'