v0.154.0 — R-48: one restore entry per app, and the intent is a described choice

The offsite restore list rendered up to five inline forms per app row. Two of them —
„Helyreállítás az élő adatok közé (csak a hiányzó fájlok)" and „Teljes visszaállítás
(fájlok + adatbázis)" — were sibling buttons whose difference is whether the customer's
data comes back at all. That mis-selection CAUSED the round-2 incident: an operator who
had read the source pressed the missing-only button and /backup/offbox/reconstitute was
never hit (DIAG-immich-restore-round2-2026-07-19, finding 1).

The rule this establishes: two adjacent controls whose difference is "your data comes
back" vs "your data cannot come back" must never be distinguishable only by layout.

Each row now carries ONE „Visszaállítás…" entry linking to a per-app wizard at
GET /backups/restore/app?name=<app>: three intent CARDS with consequence sentences, a
visible phase strip, danger styling plus the R-43 double-confirm carried over verbatim on
the destructive one, and the pair-honesty panel bound to real OffsiteScratchPair facts.

deriveWizardStep is pure over (op running, size-gate flash, scratch ready); the step is
never accepted from the request, and a running op outranks a stale ?full_prep= so no
commit button survives into a restore. While ANY op runs every mutation form is suppressed
server-side instead of being offered and refused.

No new mutation endpoint: every card posts to the pre-existing /backup/offbox/* handler
with the same field names and gates, and the page works with JavaScript disabled.
internal/{backup,appbackup,selfupdate} untouched. R-45's job registry stays its own item.

Fixes a latent bug found on the way: offboxRedirectTo hardcoded "?" when appending its
flash, which against the wizard's ?name=<app> target would have buried the flash inside
the name value.

No new agent coupling — MinAgent stays 0.90.0.
This commit is contained in:
2026-07-21 08:30:42 +02:00
parent b30e2e5a28
commit 3a9d744360
13 changed files with 846 additions and 85 deletions
@@ -0,0 +1,160 @@
{{define "backups_restore_wizard"}}
{{template "layout_start" .}}
<!-- R-48: the offsite restore wizard. One entry per app on /backups/restore leads here, and the
three intents are separately-described CARDS with a consequence sentence each — never sibling
buttons whose difference is whether the data comes back. Server-rendered throughout: every step
is a real form POST to the EXISTING endpoint, so the page works with JavaScript disabled. -->
<div class="page-header">
<div style="display:flex;align-items:center;gap:.5rem">
<a href="/backups/restore" class="btn btn-sm btn-outline">← Vissza</a>
<h2>Visszaállítás — {{.AppDisplayName}}</h2>
</div>
<span class="domain-badge">{{.Domain}}</span>
</div>
{{template "backups_flash" .}}
{{template "restore_banner" .}}
<!-- Phase strip: the customer can see there IS a sequence, and where they are in it. The round-2
incident's second half was that the decisive step appeared only after the first was pressed,
with nothing signposting that a second step existed at all. -->
<div class="restore-wizard-phases">
<span class="restore-wizard-phase{{if eq (printf "%s" .Wizard.Step) "intent"}} is-current{{end}}">Előkészítés</span>
<span class="restore-wizard-phase{{if eq (printf "%s" .Wizard.Step) "prepare-confirm"}} is-current{{end}}">Megerősítés</span>
<span class="restore-wizard-phase{{if eq (printf "%s" .Wizard.Step) "execution"}} is-current{{end}}">Végrehajtás</span>
<span class="restore-wizard-phase">Eredmény</span>
</div>
{{if eq (printf "%s" .Wizard.Step) "execution"}}
<!-- EXECUTION — every mutation form is suppressed server-side. The manager's single-flight would
refuse them anyway; offering a control guaranteed to fail is the same dishonesty class R-48
addresses. The live progress comes from the existing restore-status poll (the banner above). -->
<div class="settings-card">
<h3>Végrehajtás</h3>
<p>Jelenleg egy mentési vagy visszaállítási művelet fut{{with .RunningStack}} ({{.}}){{end}}. Amíg ez tart, új visszaállítás nem indítható.</p>
<p class="form-hint">Az állapot fent automatikusan frissül. A művelet befejezése után frissítsd az oldalt.</p>
<div class="form-actions">
<a href="/backups/restore/app?name={{.App}}" class="btn btn-sm btn-outline">Állapot frissítése</a>
</div>
</div>
{{else if eq (printf "%s" .Wizard.Step) "prepare-confirm"}}
<!-- MEGERŐSÍTÉS — the size gate. The preparation has measured what needs downloading; the customer
confirms with the size in front of them, before any transfer starts. -->
<div class="settings-card">
<h3>Megerősítés — teljes visszaállítás előkészítése</h3>
<p>A teljes visszaállításhoz a mentés teljes tartalmát le kell tölteni a távoli tárolóból{{with .FullPrepSize}} — a becsült méret: <strong>{{.}}</strong>{{end}}. A letöltés a meghajtón egy külön előkészítő mappába kerül; az élő adataid ebben a lépésben még nem változnak.</p>
<p class="form-hint">A letöltés a mérettől és a kapcsolat sebességétől függően hosszabb ideig is tarthat. Ha elkészült, ezen az oldalon választhatod ki, hogy csak a hiányzó fájlokat hozod vissza, vagy teljes visszaállítást kérsz.</p>
<div class="form-actions">
<form method="POST" action="/backup/offbox/restore">{{.CSRFField}}
<input type="hidden" name="app" value="{{.App}}">
<input type="hidden" name="mode" value="full">
<input type="hidden" name="confirm" value="1">
<button type="submit" class="btn btn-primary">Előkészítés indítása{{with .FullPrepSize}} (~{{.}}){{end}}</button>
</form>
<a href="/backups/restore/app?name={{.App}}" class="btn btn-outline">Mégsem</a>
</div>
</div>
{{else}}
<!-- INTENT — three cards, each with its own consequence sentence. Card order is deliberate:
harmless first, irreversible-looking last. -->
<div class="settings-card">
<h3>1. Ellenőrzés külön mappába</h3>
<p>A mentés tartalma egy külön ellenőrző mappába kerül — az élő adataid nem változnak.</p>
<div class="form-actions">
<form method="POST" action="/backup/offbox/restore">{{.CSRFField}}
<input type="hidden" name="app" value="{{.App}}">
<input type="hidden" name="mode" value="unit">
<button type="submit" class="btn btn-outline"{{if not .Wizard.VerifyEnabled}} disabled{{end}}>Ellenőrzés indítása</button>
</form>
</div>
</div>
<div class="settings-card">
<h3>2. Hiányzó fájlok visszahozása</h3>
<p>Csak a hiányzó fájlokat másolja vissza a meglévők közé. A meglévő fájlokat nem írja felül, adatbázist nem állít vissza — törölt tartalom ettől nem jelenik meg újra.</p>
{{if .Wizard.PlaceEnabled}}
<div class="form-actions">
<form method="POST" action="/backup/offbox/place">{{.CSRFField}}
<input type="hidden" name="app" value="{{.App}}">
<button type="submit" class="btn btn-outline">Hiányzó fájlok visszahozása</button>
</form>
</div>
{{else}}
<p class="form-hint">Ehhez előbb elő kell készíteni a teljes mentést — lásd a 3. pontot.</p>
{{end}}
</div>
<div class="settings-card restore-danger-card">
<h3>3. Teljes visszaállítás (fájlok + adatbázis)</h3>
<p>A fájlokat a mentés szerinti változatra állítja vissza és az adatbázist is visszatölti. Semmit nem töröl: a mentés óta létrejött fájlok megmaradnak. A jelenlegi adatbázisról előtte biztonsági mentés készül.</p>
{{if .Wizard.RestoreEnabled}}
<!-- Pair honesty (R-43): what the two halves of this restore actually ARE. A restore is the one
operation whose result cannot be inspected before committing to it. -->
<div class="alert alert-info" style="margin-bottom:.75rem">
<strong>Az előkészített mentés:</strong>
{{if not .Pair.DumpsAt.IsZero}}adatbázis-mentés ideje: {{fmtTime .Pair.DumpsAt}}.{{else}}az adatbázis-mentés ideje nem állapítható meg.{{end}}
</div>
{{if .Pair.Skewed}}
<div class="alert alert-warning" style="margin-bottom:.75rem">Az adatbázis-mentés régebbi{{if not .Pair.DumpsAt.IsZero}} ({{fmtTime .Pair.DumpsAt}}){{end}} — a fájlok és az adatbázis eltérő időpontból származnak.</div>
{{end}}
{{if .Pair.LooksEmpty}}
<div class="alert alert-warning" style="margin-bottom:.75rem">A mentett adatbázis üresnek tűnik (nincs benne felhasználói fiók) — elképzelhető, hogy a mentés korábbi, mint az adataid.</div>
{{end}}
<div class="form-actions">
<form method="POST" action="/backup/offbox/reconstitute">{{.CSRFField}}
<input type="hidden" name="app" value="{{.App}}">
<input type="hidden" name="confirm" value="1">
<button type="button" class="btn btn-danger"
data-restore-app="{{.App}}"
data-restore-when="{{if not .Pair.DumpsAt.IsZero}}{{fmtTime .Pair.DumpsAt}}{{end}}"
data-restore-skewed="{{if .Pair.Skewed}}1{{end}}"
data-restore-empty="{{if .Pair.LooksEmpty}}1{{end}}"
onclick="confirmFullRestore(this)">Teljes visszaállítás indítása</button>
</form>
</div>
{{else}}
<p class="form-hint">A teljes visszaállításhoz először le kell tölteni a mentés teljes tartalmát. Az előkészítés megmutatja a méretet, mielőtt bármi elindulna — az élő adataid az előkészítés alatt nem változnak.</p>
<div class="form-actions">
<form method="POST" action="/backup/offbox/restore">{{.CSRFField}}
<input type="hidden" name="app" value="{{.App}}">
<input type="hidden" name="mode" value="full">
<button type="submit" class="btn btn-outline"{{if not .Wizard.PrepareEnabled}} disabled{{end}}>Teljes visszaállítás előkészítése</button>
</form>
</div>
{{end}}
</div>
{{end}}
<script>
{{template "restore_banner_js"}}
/* Carried VERBATIM from backups_restore.html (R-43). The double-confirm is the good part of the old
surface — it states the DB half's age and any warning before the customer commits — so it moves
with the action rather than being rewritten. felhomConfirm is the house inline idiom, never the
OS-modal confirm() (F-11). */
function confirmFullRestore(btn){
var app = btn.getAttribute('data-restore-app') || '';
var when = btn.getAttribute('data-restore-when') || '';
var skewed = btn.getAttribute('data-restore-skewed') === '1';
var empty = btn.getAttribute('data-restore-empty') === '1';
var q = 'Teljes visszaállítás: ' + app + (when ? ' — a mentés ideje: ' + when : '') + '.';
if (skewed) { q += ' FIGYELEM: a fájlok és az adatbázis eltérő időpontból származnak.'; }
if (empty) { q += ' FIGYELEM: a mentett adatbázis üresnek tűnik.'; }
q += ' A fájlok a mentés szerinti változatra állnak vissza, semmi nem törlődik.';
felhomConfirm(btn, q, function(){
felhomConfirm(btn, 'UTOLSÓ MEGERŐSÍTÉS: az alkalmazás leáll, az adatbázis visszatöltődik, majd újraindul. A jelenlegi adatbázisról biztonsági mentés készül.', function(){
var f = btn.closest('form');
if (f) { if (f.requestSubmit) f.requestSubmit(); else f.submit(); }
});
});
}
</script>
{{template "layout_end" .}}
{{end}}