Backup step: drop the timestamp list, and make both answers cards
The pre-password "Taken" list goes. It could only ever be a column of timestamps, and the card directly above it already said how many snapshots there were and how recent the newest was — so it answered a question that had just been answered. The real choice now lives on Contents, where snapshots have names. The times came out of the scan and verify payloads with it; carried but unread is debt. The read result is a card too, matching the folder's. They were a card and a sentence sitting one above the other, looking like two different kinds of thing. Shortened to the host and what it holds — "Change-Me · settings + 2 apps". No "continue to see what will happen": Next is right there and has just become available, which says it better. And a real bug, caught by asserting Next's state after a genuine read rather than a simulated one: readBackup CLEARS the password field the moment it hands the value to the host, so a gate that re-checked the source fields reported a missing password about a repository it had already opened. Next stayed disabled for good after a successful read. Both the button and validateStep now treat an open backup as settling the question — they have to agree, because enabled-but-refused is worse than either alone. The test that caught it could not run at first: BACKUP was declared after the block using it, so the whole eval died in the temporal dead zone and reported as "the browser failed". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
6018250526
commit
601ea03b1a
@ -1630,38 +1630,6 @@ button.setup-found-backup:focus-visible {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* When each snapshot was written. Compact by design: it is here to be
|
||||
recognised at a glance, not read row by row. */
|
||||
.setup-snap-list {
|
||||
margin: 8px 0 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 9px;
|
||||
background: rgba(var(--text-rgb), 0.04);
|
||||
border: 1px solid rgba(var(--text-rgb), 0.10);
|
||||
}
|
||||
.setup-snap-list-title {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.6;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.setup-snap-list ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
gap: 2px 14px;
|
||||
}
|
||||
.setup-snap-list li {
|
||||
font-size: 0.84em;
|
||||
opacity: 0.82;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.setup-snap-list .setup-section-hint { margin: 6px 0 0; }
|
||||
|
||||
/* A Next that is waiting on something above it should look it, rather than
|
||||
accepting the click and then arguing. */
|
||||
.setup-btn-next:disabled,
|
||||
|
||||
@ -1197,34 +1197,6 @@ class SetupWizard {
|
||||
this.renderVerifyResult(Object.assign({ repo: true }, found[0]));
|
||||
}
|
||||
|
||||
// When each snapshot was written, under the count.
|
||||
//
|
||||
// The times are the only thing about a snapshot that is legible without the
|
||||
// key: the filename is an opaque hash, and everything describing what is
|
||||
// inside — which host, which app, what it holds — is in the encrypted
|
||||
// object. So this answers "is this the backup I think it is, and did it run
|
||||
// when I expect", which is what someone wants to know before typing a
|
||||
// password into it.
|
||||
//
|
||||
// It deliberately does not offer a choice. Picking one of these would be
|
||||
// picking a hash: a restic snapshot is ONE app's data or the settings tree,
|
||||
// not a whole machine, and which is which cannot be known until the
|
||||
// repository is open. Choosing a point in time to restore from is a real
|
||||
// thing to want, and it belongs on Contents, after unlocking, where the
|
||||
// snapshots have names.
|
||||
_snapshotList(d) {
|
||||
const times = Array.isArray(d.times) ? d.times : [];
|
||||
if (!times.length) return '';
|
||||
const rows = times.map(ts => `<li>${this._restoreWhen(ts)}</li>`).join('');
|
||||
const more = (d.snapshots || 0) - times.length;
|
||||
return `
|
||||
<div class="setup-snap-list">
|
||||
<div class="setup-snap-list-title">Taken</div>
|
||||
<ul>${rows}</ul>
|
||||
${more > 0 ? `<p class="setup-section-hint">and ${more} older</p>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Check a typed path without unlocking anything.
|
||||
async verifyBackupPath() {
|
||||
const inp = this.container.querySelector('#sw-rs-path');
|
||||
@ -1253,7 +1225,7 @@ class SetupWizard {
|
||||
// No "now enter the password" line: the step will not advance without
|
||||
// one, and saying it as well is telling someone what a locked door is
|
||||
// for while they are standing in front of it.
|
||||
box.innerHTML = this._backupCard(d, 'chosen') + this._snapshotList(d);
|
||||
box.innerHTML = this._backupCard(d, 'chosen');
|
||||
return;
|
||||
}
|
||||
// A suggestion means they pointed at the folder holding the repositories
|
||||
@ -1515,12 +1487,21 @@ class SetupWizard {
|
||||
this._initRestoreChoice();
|
||||
this._syncRestoreNav();
|
||||
if (status) {
|
||||
// The same card shape the folder's answer uses. Both are "here is what
|
||||
// that field found", and they sat one above the other looking like two
|
||||
// different kinds of thing.
|
||||
//
|
||||
// No "continue to see what will happen": Next is right there and has
|
||||
// just become available, which says it better than a sentence.
|
||||
const nApps = (data.apps || []).length;
|
||||
const hasSys = !!(data.system && data.system.present);
|
||||
status.innerHTML = `<p class="setup-rs-ok">Found backups from
|
||||
<strong>${this.escapeHtml(data.host || '')}</strong> \u2014
|
||||
${hasSys ? 'settings and ' : ''}${nApps} app${nApps === 1 ? '' : 's'}.
|
||||
Continue to see what will happen.</p>`;
|
||||
const parts = [];
|
||||
if (data.system && data.system.present) parts.push('settings');
|
||||
parts.push(`${nApps} app${nApps === 1 ? '' : 's'}`);
|
||||
status.innerHTML = `
|
||||
<div class="setup-app-card setup-found-backup setup-found-chosen">
|
||||
<span class="setup-app-name">${this.escapeHtml(data.host || '')}</span>
|
||||
<span class="setup-storage-badge setup-storage-badge-ok">${parts.join(' + ')}</span>
|
||||
</div>`;
|
||||
}
|
||||
await this.renderRestoreContents();
|
||||
} catch (e) {
|
||||
@ -1759,9 +1740,13 @@ class SetupWizard {
|
||||
const next = this.container.querySelector('#sw-next');
|
||||
if (!next) return;
|
||||
const name = this.stepNames[this._visibleSteps()[this.currentStep]];
|
||||
// Once the backup is open, the password question is settled — and
|
||||
// readBackup CLEARS the field the moment it hands the value over, so
|
||||
// asking _restoreSourceProblem() again would report a missing password
|
||||
// about a repository already unlocked, leaving Next disabled for good.
|
||||
const blocked = this.installMode === 'restore'
|
||||
&& name === 'Backup'
|
||||
&& (!!this._restoreSourceProblem() || !this.restoreInfo);
|
||||
&& !this.restoreInfo;
|
||||
next.disabled = blocked;
|
||||
next.classList.toggle('is-waiting', blocked);
|
||||
}
|
||||
@ -2486,6 +2471,11 @@ class SetupWizard {
|
||||
// wrong at the moment the user tries to move on, instead of in advance and
|
||||
// forever.
|
||||
if (name === 'Backup') {
|
||||
// An open backup settles it. Checking the source fields again would
|
||||
// report a missing password about a repository already unlocked, because
|
||||
// readBackup clears that field the moment it hands the value over — and
|
||||
// the button would be enabled while the click was refused.
|
||||
if (this.restoreInfo) return null;
|
||||
const problem = this._restoreSourceProblem();
|
||||
if (problem) return problem;
|
||||
if (!this.restoreInfo) {
|
||||
|
||||
@ -634,6 +634,17 @@ 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.
|
||||
|
||||
The pre-password snapshot list was removed once that chooser existed. It could
|
||||
only ever be a column of timestamps, and the card directly above it already
|
||||
said how many there were and how recent the newest was — so it answered a
|
||||
question that had just been answered, and the real choice now lives where it
|
||||
means something.
|
||||
|
||||
Both fields on the Backup step answer in the same shape: the folder's Check
|
||||
produces a card, and the password's Read produces a card. They had been a card
|
||||
and a sentence, sitting one above the other looking like two different kinds of
|
||||
thing.
|
||||
|
||||
**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
|
||||
|
||||
@ -157,75 +157,8 @@ read -r -d '' DRIVE <<'JS'
|
||||
// order the scan happened to resolve in.
|
||||
$('#sw-rs-path').value = '';
|
||||
w.renderFoundBackups();
|
||||
out.snapshotTimesListed = document.querySelectorAll('#sw-rs-verify-result .setup-snap-list li').length;
|
||||
out.snapshotTimesMatchTheCount =
|
||||
out.snapshotTimesListed === Math.min((w.foundBackups[0] || {}).snapshots || 0, 12);
|
||||
// A field dropped by rebuilding the record by hand is invisible until
|
||||
// something downstream needs it, which is exactly what happened here.
|
||||
out.timesSurviveTheHandoff = Array.isArray((w.foundBackups[0] || {}).times);
|
||||
|
||||
|
||||
// Both the list and the verdict belong to the Folder field, under it, rather
|
||||
// than floating above the whole form: they are answers about that input.
|
||||
const localGroup = $('.setup-subgroup[data-rs-group="local"]');
|
||||
out.foundInsideTheGroup = !!(localGroup && $('#sw-rs-found') && localGroup.contains($('#sw-rs-found')));
|
||||
out.verdictInsideTheGroup = !!(localGroup && $('#sw-rs-verify-result')
|
||||
&& localGroup.contains($('#sw-rs-verify-result')));
|
||||
|
||||
// --- exactly one found: it is the answer, not a choice ---
|
||||
$('#sw-rs-path').value = '';
|
||||
w.foundBackups = [{ path: '/only/one', snapshots: 4, newest: '2026-08-29T05:51:00+01:00' }];
|
||||
w.renderFoundBackups();
|
||||
out.singlePrefillsThePath = ($('#sw-rs-path') || {}).value === '/only/one';
|
||||
out.singleShowsItsVerdict = /4 snapshots/.test(($('#sw-rs-verify-result') || {}).textContent || '');
|
||||
// Rendered as the same CARD the found list uses, not as a sentence. It is
|
||||
// the same fact either way and should not look like two different things
|
||||
// depending on how it was arrived at.
|
||||
const chosen = $('#sw-rs-verify-result .setup-found-backup');
|
||||
out.singleRendersAsACard = !!chosen;
|
||||
out.singleCardCarriesTheBadge = !!(chosen && chosen.querySelector('.setup-storage-badge'));
|
||||
// Not a button: there is nothing left to choose, and a card that looks
|
||||
// clickable but is not is worse than one that does not.
|
||||
out.singleCardIsNotClickable = !!chosen && chosen.tagName !== 'BUTTON';
|
||||
// The card and the verdict would otherwise say the same thing twice.
|
||||
out.singleDrawsNoDuplicateCard = $('#sw-rs-found .setup-found-backup') === null;
|
||||
// Pressing Check must confirm, not reply "give a full path" about the backup
|
||||
// shown directly above it — which is what an empty field did.
|
||||
await w.verifyBackupPath();
|
||||
out.singleCheckDoesNotScold = !/full path/i.test(($('#sw-rs-verify-result') || {}).textContent || '');
|
||||
|
||||
// --- several found: a real choice, so ask ---
|
||||
$('#sw-rs-path').value = '';
|
||||
w.foundBackups = [
|
||||
{ path: '/repo/a', snapshots: 3, newest: '2026-08-01T10:00:00+01:00' },
|
||||
{ path: '/repo/b', snapshots: 9, newest: '2026-08-20T10:00:00+01:00' }
|
||||
];
|
||||
w.renderFoundBackups();
|
||||
out.multipleAreListed = document.querySelectorAll('#sw-rs-found .setup-found-backup').length;
|
||||
out.multipleRenderedAsButtons =
|
||||
Array.from(document.querySelectorAll('#sw-rs-found .setup-found-backup')).every(b => b.tagName === 'BUTTON');
|
||||
// Never guessed when it is genuinely ambiguous.
|
||||
out.multipleLeaveThePathEmpty = ($('#sw-rs-path') || {}).value === '';
|
||||
$('#sw-rs-found .setup-found-backup').click();
|
||||
out.pickingOneFillsThePath = ($('#sw-rs-path') || {}).value === '/repo/a';
|
||||
out.pickingOneReportsWithoutPassword =
|
||||
/snapshot/i.test(($('#sw-rs-verify-result') || {}).textContent || '');
|
||||
|
||||
// --- none found ---
|
||||
$('#sw-rs-path').value = '';
|
||||
w.foundBackups = [];
|
||||
w.renderFoundBackups();
|
||||
// "We looked and there is nothing here" is information; empty space is not.
|
||||
out.noneSaysSo = /nothing found/i.test(($('#sw-rs-found') || {}).textContent || '');
|
||||
out.placeholderIsReal = !/mnt\/usb/.test(($('#sw-rs-path') || {}).placeholder || '');
|
||||
|
||||
// Pointing at the folder that HOLDS the repositories is the common near-miss,
|
||||
// and is worth one click to fix rather than a paragraph explaining it.
|
||||
$('#sw-rs-path').value = '/libreportal-backups';
|
||||
await w.verifyBackupPath();
|
||||
out.parentFolderExplained = /holds backups rather than being one/i.test(
|
||||
($('#sw-rs-verify-result') || {}).textContent || '');
|
||||
out.offersTheRealPath = !!$('#sw-rs-usesuggest');
|
||||
const BACKUP = w.stepNames.indexOf('Backup');
|
||||
|
||||
// Next is disabled, not merely refused. A button that looks available and
|
||||
// then argues is worse than one that plainly is not ready.
|
||||
@ -240,6 +173,15 @@ read -r -d '' DRIVE <<'JS'
|
||||
w.restoreInfo = { host: 'h', hosts: ['h'], system: { present: true, date: '', domains: [] }, apps: [] };
|
||||
w._syncRestoreNav();
|
||||
out.nextEnabledOnceRead = !nextBtn.disabled;
|
||||
// readBackup CLEARS the password once it has handed the value over, so a
|
||||
// gate that re-checks the source fields reports a missing password about a
|
||||
// repository it has already opened — and Next stays disabled for good.
|
||||
$('#sw-rs-pass').value = '';
|
||||
w._syncRestoreNav();
|
||||
out.stillEnabledWithTheFieldCleared = !nextBtn.disabled;
|
||||
// The button and the click must agree; enabled-but-refused is worse than
|
||||
// either on its own.
|
||||
out.validateAgreesWithTheButton = !w.validateStep(BACKUP);
|
||||
$('#sw-rs-path').dispatchEvent(new Event('input', { bubbles: true }));
|
||||
out.nextDisabledAgainAfterEdit = nextBtn.disabled;
|
||||
|
||||
@ -256,7 +198,6 @@ read -r -d '' DRIVE <<'JS'
|
||||
// the backup, so it has to have been opened. Gated rather than instructed —
|
||||
// the old copy told the user to enter a password beneath a field that had
|
||||
// one, and kept telling them after they had.
|
||||
const BACKUP = w.stepNames.indexOf('Backup');
|
||||
out.passwordLabel = ($('label[for="sw-rs-pass"]') || {}).textContent?.trim().split('\n')[0].trim();
|
||||
// The action sits beside the field it acts on, the same way Check does for
|
||||
// the folder. A button floating below the form reads as a step of its own.
|
||||
@ -474,15 +415,12 @@ chk "placeholder is a real path" "$(g .placeholderIsReal)" true
|
||||
chk "the parent-folder mistake is explained" "$(g .parentFolderExplained)" true
|
||||
chk "and the real path is offered" "$(g .offersTheRealPath)" true
|
||||
|
||||
echo "the snapshot list"
|
||||
chk "times are listed" "$(g .snapshotTimesListed)" 4
|
||||
chk "one row per snapshot" "$(g .snapshotTimesMatchTheCount)" true
|
||||
chk "and survive the handoff" "$(g .timesSurviveTheHandoff)" true
|
||||
|
||||
echo "Next is disabled, not merely refused"
|
||||
chk "disabled without a password" "$(g .nextDisabledWithoutPassword)" true
|
||||
chk "still disabled until read" "$(g .nextStillDisabledUntilRead)" true
|
||||
chk "enabled once read" "$(g .nextEnabledOnceRead)" true
|
||||
chk "even with the field cleared" "$(g .stillEnabledWithTheFieldCleared)" true
|
||||
chk "and the click is not refused" "$(g .validateAgreesWithTheButton)" true
|
||||
chk "disabled again after an edit" "$(g .nextDisabledAgainAfterEdit)" true
|
||||
chk "error text is light enough" "$(g '.errorBrightness > 170')" true
|
||||
|
||||
|
||||
@ -42,30 +42,6 @@ _restoreRepoStats()
|
||||
printf '%s\t%s\n' "${n:-0}" "${newest:-}"
|
||||
}
|
||||
|
||||
# WHEN each snapshot was written, newest first, as a JSON array of ISO times.
|
||||
#
|
||||
# The times are the only thing about a snapshot that is legible without the
|
||||
# key: the file name is an opaque hash and everything describing what is inside
|
||||
# — which host, which app, what it holds — is in the encrypted object. So this
|
||||
# answers "is this the backup I think it is, and is it recent", which is what
|
||||
# someone is asking before they type a password. It cannot answer "which one do
|
||||
# I want to restore", because it does not know what any of them are.
|
||||
#
|
||||
# Capped: a repository with a year of daily snapshots would otherwise hand the
|
||||
# browser several hundred rows nobody scrolls.
|
||||
_restoreRepoSnapshotTimes()
|
||||
{
|
||||
local d="${1%/}" cap="${2:-12}"
|
||||
local out='[]' ts iso
|
||||
while IFS= read -r ts; do
|
||||
[[ -z "$ts" ]] && continue
|
||||
iso=$(date -d "@${ts%%.*}" -Iseconds 2>/dev/null) || continue
|
||||
out=$(jq -c --arg t "$iso" '. + [$t]' <<< "$out")
|
||||
done < <(runFileOp find "$d/snapshots" -maxdepth 1 -type f -printf '%T@\n' 2>/dev/null \
|
||||
| sort -rn | head -n "$cap")
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# Check one path. Prints JSON.
|
||||
#
|
||||
# restore verify <path>
|
||||
@ -100,10 +76,14 @@ restoreVerifyPath()
|
||||
local stats n newest
|
||||
stats=$(_restoreRepoStats "$d")
|
||||
IFS=$'\t' read -r n newest <<< "$stats"
|
||||
# The count and the newest, and no more. A list of bare timestamps was
|
||||
# rendered here for a while, but it could only ever be timestamps — a
|
||||
# snapshot's identity is in the encrypted object — and once Contents
|
||||
# offered a real per-app chooser, the pre-password list was answering a
|
||||
# question the card above it had already answered.
|
||||
jq -nc --arg p "$d" --argjson n "${n:-0}" \
|
||||
--arg newest "$([[ -n "$newest" ]] && date -d "@$newest" -Iseconds 2>/dev/null || printf '')" \
|
||||
--argjson times "$(_restoreRepoSnapshotTimes "$d")" \
|
||||
'{repo: true, path: $p, snapshots: $n, newest: $newest, times: $times}'
|
||||
'{repo: true, path: $p, snapshots: $n, newest: $newest}'
|
||||
return 0
|
||||
}
|
||||
|
||||
@ -170,12 +150,8 @@ restoreScanLocal()
|
||||
IFS=$'\t' read -r n newest <<< "$stats"
|
||||
iso=""
|
||||
[[ -n "$newest" ]] && iso=$(date -d "@$newest" -Iseconds 2>/dev/null)
|
||||
# The times come along too: the found card and the Check result show
|
||||
# the same thing, and one of them arriving without them would make the
|
||||
# list appear or vanish depending on how the repository was reached.
|
||||
out=$(jq -c --arg p "$d" --argjson n "${n:-0}" --arg t "$iso" \
|
||||
--argjson times "$(_restoreRepoSnapshotTimes "$d")" \
|
||||
'. + [{path: $p, snapshots: $n, newest: $t, times: $times}]' <<< "$out")
|
||||
'. + [{path: $p, snapshots: $n, newest: $t}]' <<< "$out")
|
||||
done < <(_restoreScanRoots)
|
||||
|
||||
# Most snapshots first: on a machine with more than one, that is nearly
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user