Backup step: snapshot times, a disabled Next, and readable errors
Three things. The snapshot times now sit under the count. They are the only thing about a snapshot legible without the key — the filename is an opaque hash and everything describing what is inside is in the encrypted object — so they answer "is this the backup I think it is, and did it run when I expect", which is the question someone actually has before typing a password into it. Next is disabled until the backup has been opened, rather than accepting the click and then arguing. validateStep still refuses, for anyone who arrives another way, but the disabled state says "something above me is unfinished" before the click instead of after. It re-enables on a successful read and goes back to disabled the moment the path, type or password changes, since that read is then about a different repository. Error text was rgb(220,53,69) on a 10%-opacity danger background — a mid red on a dark blue panel, legible in theory and squinted at in practice. Lighter text, a firmer border, more line-height. The test asserts perceived brightness rather than an exact colour, so a theme change cannot quietly undo it. One real bug on the way: _adoptSingleResult rebuilt the record by hand, naming four fields, so `times` was dropped and the list came out empty even though the data was right there. It passes the whole record through now — a field lost that way is invisible until something downstream needs it. Not done, and worth stating plainly: these times are not selectable. Picking one 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 belongs on Contents, after unlocking, where the snapshots have names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
f736ec6501
commit
94683db240
@ -1616,3 +1616,56 @@ button.setup-found-backup:focus-visible {
|
||||
border-color: rgba(79, 195, 247, 0.42);
|
||||
background: rgba(79, 195, 247, 0.07);
|
||||
}
|
||||
|
||||
/* Error text was the raw danger colour on a 10%-opacity danger background —
|
||||
a mid red on a dark blue panel, which is legible in theory and squinted at
|
||||
in practice. Lighter text, a firmer edge, and room to breathe. */
|
||||
.setup-error {
|
||||
background: rgba(var(--status-danger-rgb), 0.16);
|
||||
border: 1px solid rgba(var(--status-danger-rgb), 0.5);
|
||||
color: #ffc9c2;
|
||||
padding: 11px 14px;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
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,
|
||||
.setup-btn-next.is-waiting {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@ -1157,7 +1157,7 @@ class SetupWizard {
|
||||
if (sel) { sel.value = 'local'; sel.dispatchEvent(new Event('change', { bubbles: true })); }
|
||||
const path = this.container.querySelector('#sw-rs-path');
|
||||
if (path) path.value = f.path;
|
||||
this.renderVerifyResult({ repo: true, path: f.path, snapshots: f.snapshots, newest: f.newest });
|
||||
this.renderVerifyResult(Object.assign({ repo: true }, f));
|
||||
const pass = this.container.querySelector('#sw-rs-pass');
|
||||
if (pass) pass.focus();
|
||||
});
|
||||
@ -1192,8 +1192,35 @@ class SetupWizard {
|
||||
inp.value = found[0].path;
|
||||
// The scan already counted the snapshots, so say so rather than making the
|
||||
// user press Check to be told what is on screen above.
|
||||
this.renderVerifyResult({ repo: true, path: found[0].path,
|
||||
snapshots: found[0].snapshots, newest: found[0].newest });
|
||||
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.
|
||||
@ -1224,7 +1251,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');
|
||||
box.innerHTML = this._backupCard(d, 'chosen') + this._snapshotList(d);
|
||||
return;
|
||||
}
|
||||
// A suggestion means they pointed at the folder holding the repositories
|
||||
@ -1367,13 +1394,14 @@ class SetupWizard {
|
||||
this.restoreInfo = null;
|
||||
const st = this.container.querySelector('#sw-rs-status');
|
||||
if (st) st.innerHTML = '';
|
||||
this._syncRestoreNav();
|
||||
};
|
||||
box.querySelectorAll('input, select').forEach(el => {
|
||||
el.addEventListener('input', stale);
|
||||
el.addEventListener('change', stale);
|
||||
});
|
||||
const pw = this.container.querySelector('#sw-rs-pass');
|
||||
if (pw) pw.addEventListener('input', stale);
|
||||
if (pw) pw.addEventListener('input', () => { stale(); this._syncRestoreNav(); });
|
||||
sync();
|
||||
}
|
||||
|
||||
@ -1482,6 +1510,7 @@ class SetupWizard {
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
this.restoreInfo = data;
|
||||
this._syncRestoreNav();
|
||||
if (status) {
|
||||
const nApps = (data.apps || []).length;
|
||||
const hasSys = !!(data.system && data.system.present);
|
||||
@ -1493,6 +1522,7 @@ class SetupWizard {
|
||||
await this.renderRestoreContents();
|
||||
} catch (e) {
|
||||
this.restoreInfo = null;
|
||||
this._syncRestoreNav();
|
||||
if (status) {
|
||||
status.innerHTML = `<p class="setup-rs-error">${this.escapeHtml(e.message || String(e))}</p>`;
|
||||
}
|
||||
@ -1665,6 +1695,24 @@ class SetupWizard {
|
||||
// Hand the rebuild to the host. Everything it needs was established on the
|
||||
// previous steps: which repository (by the index the read returned) and
|
||||
// whether to keep the domains that do not point at this server.
|
||||
// Next stays disabled until the backup has actually been opened.
|
||||
//
|
||||
// validateStep already refuses to advance, but a button that looks available
|
||||
// and then argues is worse than one that plainly is not yet: the disabled
|
||||
// state says "something above me is unfinished" before the click rather than
|
||||
// after it. The message still exists for anyone who gets there another way.
|
||||
_syncRestoreNav() {
|
||||
if (!this.container) return;
|
||||
const next = this.container.querySelector('#sw-next');
|
||||
if (!next) return;
|
||||
const name = this.stepNames[this._visibleSteps()[this.currentStep]];
|
||||
const blocked = this.installMode === 'restore'
|
||||
&& name === 'Backup'
|
||||
&& (!!this._restoreSourceProblem() || !this.restoreInfo);
|
||||
next.disabled = blocked;
|
||||
next.classList.toggle('is-waiting', blocked);
|
||||
}
|
||||
|
||||
async submitRestore() {
|
||||
const btn = this.container.querySelector('#sw-submit');
|
||||
const setLabel = (s) => {
|
||||
@ -2324,6 +2372,8 @@ class SetupWizard {
|
||||
? ` <span class="setup-tooltip" tabindex="0" data-tip="${this.escapeHtml(this.stepTips[name])}">?</span>`
|
||||
: '');
|
||||
this.container.querySelector('#sw-progress-pct').textContent = `${pct}%`;
|
||||
// Arriving at a step re-evaluates whether Next is available there.
|
||||
this._syncRestoreNav();
|
||||
|
||||
this.container.querySelector('#sw-back').disabled = this.currentStep === 0;
|
||||
const isLast = this.currentStep === this.totalSteps - 1;
|
||||
|
||||
@ -146,6 +146,25 @@ read -r -d '' DRIVE <<'JS'
|
||||
out.scanFoundSomething = (w.foundBackups || []).length > 0;
|
||||
out.foundCarrySnapshotCounts = (w.foundBackups || []).every(f => typeof f.snapshots === 'number');
|
||||
|
||||
// When each snapshot was written, under the count. The times are the only
|
||||
// thing about a snapshot legible without the key — the filename is a hash
|
||||
// and everything describing what is inside is in the encrypted object — so
|
||||
// this answers "is this the backup I think it is", which is what someone
|
||||
// wants before typing a password into it.
|
||||
// Re-rendered deliberately: the validation probes above type into the path,
|
||||
// and the adopt step rightly declines to overwrite something the user has
|
||||
// entered. Clearing it first asserts the behaviour rather than whichever
|
||||
// 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"]');
|
||||
@ -208,6 +227,31 @@ read -r -d '' DRIVE <<'JS'
|
||||
($('#sw-rs-verify-result') || {}).textContent || '');
|
||||
out.offersTheRealPath = !!$('#sw-rs-usesuggest');
|
||||
|
||||
// Next is disabled, not merely refused. A button that looks available and
|
||||
// then argues is worse than one that plainly is not ready.
|
||||
const nextBtn = $('#sw-next');
|
||||
w.restoreInfo = null;
|
||||
$('#sw-rs-pass').value = '';
|
||||
w._syncRestoreNav();
|
||||
out.nextDisabledWithoutPassword = nextBtn.disabled;
|
||||
$('#sw-rs-pass').value = 'x';
|
||||
w._syncRestoreNav();
|
||||
out.nextStillDisabledUntilRead = nextBtn.disabled;
|
||||
w.restoreInfo = { host: 'h', hosts: ['h'], system: { present: true, date: '', domains: [] }, apps: [] };
|
||||
w._syncRestoreNav();
|
||||
out.nextEnabledOnceRead = !nextBtn.disabled;
|
||||
$('#sw-rs-path').dispatchEvent(new Event('input', { bubbles: true }));
|
||||
out.nextDisabledAgainAfterEdit = nextBtn.disabled;
|
||||
|
||||
// The error text was the raw danger colour on a translucent danger
|
||||
// background — a mid red on a dark blue panel.
|
||||
const errEl = $('#sw-error');
|
||||
errEl.style.display = ''; errEl.textContent = 'x';
|
||||
const rgb = (getComputedStyle(errEl).color.match(/\d+/g) || []).map(Number);
|
||||
// Rough perceived brightness; the old colour scored ~96, which is what made
|
||||
// it hard to read on this background.
|
||||
out.errorBrightness = Math.round((rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000);
|
||||
|
||||
// The step will not advance on a promise: the next one renders what is IN
|
||||
// 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
|
||||
@ -384,6 +428,18 @@ 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 "disabled again after an edit" "$(g .nextDisabledAgainAfterEdit)" true
|
||||
chk "error text is light enough" "$(g '.errorBrightness > 170')" true
|
||||
|
||||
echo "the step gates rather than instructs"
|
||||
chk "the field is called Backup Password" "$(g .passwordLabel)" "Backup Password"
|
||||
chk "Read sits beside the password" "$(g .readSitsInThePasswordRow)" true
|
||||
|
||||
@ -42,6 +42,30 @@ _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>
|
||||
@ -76,9 +100,10 @@ restoreVerifyPath()
|
||||
local stats n newest
|
||||
stats=$(_restoreRepoStats "$d")
|
||||
IFS=$'\t' read -r n newest <<< "$stats"
|
||||
printf '{"repo":true,"path":"%s","snapshots":%s,"newest":"%s"}\n' \
|
||||
"$(_lpJsonStr "$d")" "${n:-0}" \
|
||||
"$([[ -n "$newest" ]] && date -d "@$newest" -Iseconds 2>/dev/null || printf '')"
|
||||
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}'
|
||||
return 0
|
||||
}
|
||||
|
||||
@ -145,8 +170,12 @@ 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" \
|
||||
'. + [{path: $p, snapshots: $n, newest: $t}]' <<< "$out")
|
||||
--argjson times "$(_restoreRepoSnapshotTimes "$d")" \
|
||||
'. + [{path: $p, snapshots: $n, newest: $t, times: $times}]' <<< "$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