diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a4d436..70b860a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,61 @@ ## Changelog +### v0.154.0 — one restore entry per app, and the intent is a described choice (2026-07-21) + +Closes **R-48**. **No new agent coupling — MinAgent stays 0.90.0.** This is a UI-layer change: +`internal/backup`, `internal/appbackup` and `internal/selfupdate` are untouched, and the release adds +**no mutation endpoint** — every action still posts to the `/backup/offbox/*` handler it always did, +with the same field names and the same gates. + +**The defect.** The „Ellenőrző visszaállítás a távoli tárolóból" list rendered up to five inline +`
` blocks per app row: verify, prepare, the revealed size-gated commit, +the missing-only merge, and the true reconstitution. Two of them sat next to each other as sibling +buttons — + +- „Helyreállítás az élő adatok közé (csak a hiányzó fájlok)" — additive; **cannot** bring deleted + content back, and +- „Teljes visszaállítás (fájlok + adatbázis)" — the real restore + +— and the difference between them is whether the customer's data comes back at all. This is not +theoretical: it caused the round-2 incident. An operator who had *read the source* pressed the +missing-only button, and the controller log shows `/backup/offbox/reconstitute` was never hit +(`felhom.eu/documentation/audits/DIAG-immich-restore-round2-2026-07-19.md`, finding 1). The second +half of the trap was that the decisive „Teljes visszaállítás indítása" appeared **only after** +„…előkészítése" had been pressed, with nothing signposting that a second step existed or that the +first one had done nothing to live data. + +**The rule this establishes,** worth stating once and applying past this page: *two adjacent controls +whose difference is "your data comes back" vs "your data cannot come back" must never be +distinguishable only by layout.* + +**The change.** Each app row on `/backups/restore` now carries exactly **one** control — +„Visszaállítás…" — linking to a per-app wizard at `GET /backups/restore/app?name=`, built on the +`backups_escrow.html` precedent: + +- **Three intent CARDS**, each with its own consequence sentence rather than a label alone: + ellenőrzés külön mappába (live data untouched) · hiányzó fájlok visszahozása (additive, no + database, deleted content does not reappear) · teljes visszaállítás (files + database, danger + styling, the R-43 double-confirm carried over **verbatim** with its pair-honesty facts). +- **A visible phase strip** — Előkészítés · Megerősítés · Végrehajtás · Eredmény — so the sequence is + legible before the first click instead of after it. +- **Server-derived steps.** `deriveWizardStep` is a pure function of (op running, size-gate flash, + scratch ready); the step is never accepted from the request. Precedence is strict: a running op + outranks a stale `?full_prep=` in the URL, so no commit button can reappear mid-restore. +- **Mutation forms are suppressed server-side while any op runs** — the backup manager's + single-flight is process-wide, so a restore for app X now suppresses app Y's controls instead of + offering a button guaranteed to 409. +- **No JavaScript requirement.** Every step is a real form POST and the server renders the next one. + +**Redirect retargeting.** The app-scoped `/backup/offbox/{restore,place,reconstitute}` outcomes now +land back on the wizard the customer acted from rather than on the list. Fixing that surfaced a +latent bug in `offboxRedirectTo`, which hardcoded `"?"` when appending the flash — against a target +that already carries a query (`?name=`) that would have buried the flash inside the `name` +value. The separator is now chosen. + +**Deliberately NOT in scope:** the shares (`_shares`) entry, the local restore panel and the .fab +block are untouched; the R-45 job registry is still its own item — the wizard polls the two existing +status surfaces as-is. + ### v0.153.0 — the database replay no longer races the application, on BOTH restore paths (2026-07-20) Closes **R-47**. **No new agent coupling — MinAgent stays 0.90.0.** Nothing in this release talks to diff --git a/CONTEXT.md b/CONTEXT.md index 4fbaad4..5b7f456 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,38 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-07-20 (v0.153.0 — R-47: the DB replay no longer races the app, both paths) +Last updated: 2026-07-21 (v0.154.0 — R-48: one restore entry per app, intent as a described choice) + +> **2026-07-21 — v0.154.0 (R-48).** Collapses the offsite restore controls to a single +> „Visszaállítás…" entry per app row plus a per-app wizard at `GET /backups/restore/app?name=`. +> The defect it closes is the CAUSE of the round-2 incident: the list rendered up to five inline +> forms per row, two of which — the missing-only merge and the true reconstitution — were sibling +> buttons whose difference is whether the data comes back. The rule it establishes: *two adjacent +> controls whose difference is "your data comes back" vs "your data cannot come back" must never be +> distinguishable only by layout.* +> +> **DECISION: the wizard is server-rendered on the EXISTING endpoints.** No new mutation endpoint, +> no JSON state API, no client router. Every card is a real form POST to +> `/backup/offbox/{restore,place,reconstitute}` with the same field names and gates, and the server +> renders the next step — so it works with JavaScript disabled. `TestRestoreWizard_NoNewMutationEndpoints` +> makes that structural: adding a form that posts somewhere new fails the suite by design. +> +> **DECISION: R-45 stays its own item.** The wizard polls the two existing status surfaces as-is; the +> generalized job registry (and with it a real per-phase progress feed) is not built here. +> +> **DECISION: the step is derived, never requested.** `deriveWizardStep` is pure over (op running, +> size-gate flash, scratch ready). Precedence is load-bearing — a running op outranks a stale +> `?full_prep=` in the URL, or a commit button reappears mid-restore. While ANY op runs every +> mutation form is suppressed server-side rather than offered and then refused with a 409. +> +> Latent bug found and fixed on the way: `offboxRedirectTo` hardcoded `"?"` when appending its flash, +> which would have buried the flash inside `?name=`. **No agent coupling — MinAgent stays +> 0.90.0.** 9 new tests + the Group-B red-proof; full suite green. +> +> **NOT live-validated at commit time by design:** v0.154.0 is published but deliberately NOT +> hand-deployed — the operator's hub floor save (0.153.0 → 0.154.0) pulls it via the self-update +> path, and that swap IS the R-23(a) single-fire validation (STOP-1). + > **2026-07-20 — v0.153.0 (R-47).** Closes the H4 race on **BOTH** restore paths. The replay needs a > running DB container, so both paths started the WHOLE stack first — giving the application a window diff --git a/REUSE.md b/REUSE.md index 519c29b..b9596cc 100644 --- a/REUSE.md +++ b/REUSE.md @@ -41,6 +41,8 @@ | `jsonResponse` / `jsonError` | controller/internal/web/handler_export.go | `(w, v)` / `(w, msg, code)` | Export/import API | Third envelope shape — keep within export surface | | `limitBody` | controller/internal/api/router.go | `(w, req)` | Bound request bodies (1MB) | Apply before decode on any new POST | | `offboxRedirect` | controller/internal/web/offbox_handlers.go | `(w, r, msg string, isErr bool)` | Flash-message redirects | Flash = `?flash=` / `?flash_error=` query params, read by page handlers | +| `offboxRedirectTo` | controller/internal/web/offbox_handlers.go | `(w, r, page, msg string, isErr bool)` | Same, to an EXPLICIT page | **TRAP (fixed v0.154.0): the separator is chosen, not `"?"`.** Targets may already carry a query — the R-48 wizard is `/backups/restore/app?name=` — and a hardcoded `"?"` buries the flash inside the previous parameter's value | +| `restoreWizardPath` / `deriveWizardStep` / `resolveWizardApp` | controller/internal/web/restore_wizard.go | `(app) string` / `(restoreWizardInput) restoreWizardView` / `([]OffboxAppRow, name) *OffboxAppRow` | R-48 offsite restore wizard: URL builder + the PURE step/unlock derivation + the app-resolution refusals | The step is **never** taken from the request. Precedence is load-bearing: op-running outranks a stale `?full_prep=`, else a commit button reappears mid-restore. Truth table + red-proof: `restore_wizard_test.go`. Adding a form here that posts anywhere new breaks `TestRestoreWizard_NoNewMutationEndpoints` **by design** — R-48 adds no mutation surface | | `redirectTier2` | controller/internal/web/tier2_config_handler.go | `(w, r, name, flash, flashErr)` | Tier2 page flash redirects | Same convention | | `validStackName` | controller/internal/web/validate.go | `(name string) bool` | Any stack name from a request | Single-segment, no `/ \ ..` — blocks path traversal into stacks/userdata | | `ValidateSegment` | controller/internal/appexport/validate.go | `(kind, s string) error` | Any attacker-controlled path segment (.fab manifest fields) | CTRL-001 guard; deliberately NOT for dotfile ConfigFiles | diff --git a/controller/README.md b/controller/README.md index 554e709..1e0c8d8 100644 --- a/controller/README.md +++ b/controller/README.md @@ -316,6 +316,16 @@ Each app can define rich metadata in `.felhom.yml`: (they are the undo). The live recovery unit is still never overwritten, which is why the replay source is the scratch. Honesty surfaces (`OffsiteScratchPair`): dump age, an unstamped-pair warning, and the R-44 empty-dump sniff — all warn-level, none of them gates. + - **The restore wizard (v0.154.0, R-48 — `web/restore_wizard.go`).** The offsite restore controls + used to render as up to five inline forms per app row, two of which — the missing-only merge and + the true reconstitution — were sibling buttons whose difference is whether the data comes back. + That mis-selection caused the round-2 incident. Each row now carries ONE entry linking to a + per-app wizard; the three intents are cards with consequence sentences, and the dangerous one + keeps the R-43 double-confirm verbatim. `deriveWizardStep` is pure — (op running, size-gate + flash, scratch ready) → step + which intents unlock — 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 rather than offered and refused. No new endpoint, no job registry + (that stays R-45), and the page works with JavaScript disabled. - **The DB-only replay window (v0.153.0, R-47).** Until v0.153.0 the whole stack was started before the replay, so the application's own schema management raced the dump: measured live on 2026-07-19 (H4), immich-server rebuilt `clip_index` two seconds before the dump's `CREATE INDEX` @@ -464,7 +474,8 @@ height with no magic number to drift as item counts change. | `/backups` | Áttekintés | storage overview, whole-guest Rendszermentés, status stat cards, single-copy warning | | `/backups/remote` | Távoli mentés | Felhom-offsite status card (3 states, display-only), tier-3 status block + quota, participation toggles (+ zero-toggle hint; the persisted zero-toggle run-warning is DISPLAY-replaced by a "kijelölés módosult" note once ≥1 app is toggled — `offboxWarningDisplay`, v0.126.0), manual-target form (`#offbox-section`) | | `/backups/apps` | Alkalmazások | schedule, Adatbázisok table, per-app 1./2./3. tier rows (tier-2 config entry; tier-3 actions deep-link to `/backups/remote#offbox-section`) | -| `/backups/restore` | Visszaállítás | restore panel, offbox restore-to-verify list, .fab download/import loop | +| `/backups/restore` | Visszaállítás | restore panel, offsite restore list (**one „Visszaállítás…" entry per app** since v0.154.0), existing verification copies, .fab download/import loop | +| `/backups/restore/app?name=` | Visszaállítás — | **R-48 per-app offsite restore wizard** (v0.154.0). GET-only; three described intent cards (ellenőrzés / hiányzó fájlok / teljes visszaállítás), a visible phase strip, and a server-derived step. Adds NO mutation endpoint — every card posts to the pre-existing `/backup/offbox/{restore,place,reconstitute}` | Shared data builders: `backupsCommonData` (chrome + full-status + flash) + `backupsOffboxData` (offbox target/toggles) in `handlers.go`; shared partials in `templates/backups_shared.html`. diff --git a/controller/internal/web/app_row_test.go b/controller/internal/web/app_row_test.go index 1065fcd..c59c956 100644 --- a/controller/internal/web/app_row_test.go +++ b/controller/internal/web/app_row_test.go @@ -51,9 +51,16 @@ func TestAppRow_RestoreLists(t *testing.T) { if strings.Contains(html, "storage-path-item") { t.Error("old storage-path-item row structure survives on the restore page") } - // Behavior unchanged: restore-to-verify form + .fab download button. - if !strings.Contains(html, `action="/backup/offbox/restore"`) { - t.Error("restore-to-verify form missing") + // v0.154.0 (R-48): the restore row is now a single entry that LINKS to the per-app wizard — + // the mutation forms moved there. The row itself must still be the one, and only, restore + // control for the app. + if !strings.Contains(html, `href="/backups/restore/app?name=calibre-web"`) { + t.Error("per-app restore wizard entry missing") + } + if strings.Contains(html, `action="/backup/offbox/restore"`) || + strings.Contains(html, `action="/backup/offbox/place"`) || + strings.Contains(html, `action="/backup/offbox/reconstitute"`) { + t.Error("offsite restore mutation forms must not render on the list page (R-48)") } if !strings.Contains(html, `fab-dl-btn`) || !strings.Contains(html, "Letöltés (.fab)") { t.Error(".fab download action missing") diff --git a/controller/internal/web/backups_split_test.go b/controller/internal/web/backups_split_test.go index b26fc15..d0fb907 100644 --- a/controller/internal/web/backups_split_test.go +++ b/controller/internal/web/backups_split_test.go @@ -53,8 +53,8 @@ func TestBackupsSplit_SectionsOnExactlyOnePage(t *testing.T) { "

Adatbázisok

": "backups_apps", // databases "Alkalmazások mentési állapota": "backups_apps", // per-app rows `id="restore-app"`: "backups_restore", // restore panel - "Ellenőrző visszaállítás a távoli tárolóból": "backups_restore", // moved restore-to-verify - `action="/backup/offbox/restore"`: "backups_restore", // the MOVED form itself + "Visszaállítás a távoli tárolóból": "backups_restore", // the offsite restore list + `href="/backups/restore/app?name=`: "backups_restore", // R-48: the ONE entry per app } for marker, home := range markers { for page, html := range pages { diff --git a/controller/internal/web/offbox_handlers.go b/controller/internal/web/offbox_handlers.go index cf47cf7..3375b7d 100644 --- a/controller/internal/web/offbox_handlers.go +++ b/controller/internal/web/offbox_handlers.go @@ -30,7 +30,14 @@ func offboxRedirectTo(w http.ResponseWriter, r *http.Request, page, msg string, if isErr { q = "flash_error" } - http.Redirect(w, r, page+"?"+q+"="+url.QueryEscape(msg), http.StatusFound) + // R-48: `page` may already carry a query (the wizard is /backups/restore/app?name=), so the + // separator has to be chosen, not hardcoded — appending a second "?" produces a URL whose flash + // silently lands inside the `name` value instead of as its own parameter. + sep := "?" + if strings.Contains(page, "?") { + sep = "&" + } + http.Redirect(w, r, page+sep+q+"="+url.QueryEscape(msg), http.StatusFound) } // offboxConfigHandler saves the off-box target + (out-of-band) SSH key + known_hosts. @@ -304,16 +311,18 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) { defer cancel() sizeHuman, err := s.backupMgr.OffboxRestorePrepareFull(pctx, app) if err != nil { - offboxRedirectTo(w, r, "/backups/restore", err.Error(), true) + offboxRedirectTo(w, r, restoreWizardPath(app), err.Error(), true) return } - http.Redirect(w, r, "/backups/restore?full_prep="+url.QueryEscape(app)+"&full_size="+url.QueryEscape(sizeHuman), http.StatusFound) + // R-48: the size-gate reveal now lands on the app's wizard (prepare-confirm step) rather than + // on the list page. Same params, same meaning — only the surface that renders them changed. + http.Redirect(w, r, restoreWizardPath(app)+"&full_prep="+url.QueryEscape(app)+"&full_size="+url.QueryEscape(sizeHuman), http.StatusFound) return } // Fast-path refuse a concurrent op, then run async on a BACKGROUND context (a proxy read-timeout on // r.Context() would CANCEL the SFTP restore mid-flight — the F4 lesson). if s.backupMgr.IsRunning() { - offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet már fut.", true) + offboxRedirectTo(w, r, restoreWizardPath(app), "Egy mentési/visszaállítási művelet már fut.", true) return } full := mode == "full" @@ -338,7 +347,7 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) { } s.backupMgr.EndRestoreOp(true, msg) }() - offboxRedirectTo(w, r, "/backups/restore", "A távoli visszaállítás elindult — az állapot itt frissül.", false) + offboxRedirectTo(w, r, restoreWizardPath(app), "A távoli visszaállítás elindult — az állapot itt frissül.", false) } // offboxReconstituteHandler is the TRUE offsite restore (R-43, v0.148.0): files overwritten to the @@ -363,11 +372,11 @@ func (s *Server) offboxReconstituteHandler(w http.ResponseWriter, r *http.Reques } // This one overwrites live files and replays a database — it must never happen on a stray click. if r.FormValue("confirm") != "1" { - offboxRedirectTo(w, r, "/backups/restore", "A teljes visszaállítás megerősítés nélkül nem hajtható végre.", true) + offboxRedirectTo(w, r, restoreWizardPath(app), "A teljes visszaállítás megerősítés nélkül nem hajtható végre.", true) return } if s.backupMgr.IsRunning() { - offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet már fut.", true) + offboxRedirectTo(w, r, restoreWizardPath(app), "Egy mentési/visszaállítási művelet már fut.", true) return } s.backupMgr.BeginRestoreOp("offbox-reconstitute", app) @@ -384,7 +393,7 @@ func (s *Server) offboxReconstituteHandler(w http.ResponseWriter, r *http.Reques app, res.FilesPlaced, res.DBsReplayed, res.SnapshotID) s.backupMgr.EndRestoreOp(true, reconstituteOutcomeMsg(app, res)) }() - offboxRedirectTo(w, r, "/backups/restore", "A teljes visszaállítás elindult — az állapot itt frissül.", false) + offboxRedirectTo(w, r, restoreWizardPath(app), "A teljes visszaállítás elindult — az állapot itt frissül.", false) } // reconstituteOutcomeMsg builds the OUTCOME flash for a completed reconstitution. Pure, so the @@ -455,7 +464,7 @@ func (s *Server) offboxPlaceHandler(w http.ResponseWriter, r *http.Request) { return } if s.backupMgr.IsRunning() { - offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet már fut.", true) + offboxRedirectTo(w, r, restoreWizardPath(app), "Egy mentési/visszaállítási művelet már fut.", true) return } s.backupMgr.BeginRestoreOp("offbox-place", app) @@ -470,7 +479,7 @@ func (s *Server) offboxPlaceHandler(w http.ResponseWriter, r *http.Request) { s.logger.Printf("[INFO] [web] off-box place %s completed (async)", app) s.backupMgr.EndRestoreOp(true, "A(z) "+app+" hiányzó fájljai helyreállítva az élő adatok közé.") }() - offboxRedirectTo(w, r, "/backups/restore", "A helyreállítás elindult — az állapot itt frissül.", false) + offboxRedirectTo(w, r, restoreWizardPath(app), "A helyreállítás elindult — az állapot itt frissül.", false) } // --- R-7b: „Megosztások" restore ------------------------------------------------------------------ diff --git a/controller/internal/web/restore_wizard.go b/controller/internal/web/restore_wizard.go new file mode 100644 index 0000000..bfb3a10 --- /dev/null +++ b/controller/internal/web/restore_wizard.go @@ -0,0 +1,190 @@ +package web + +import ( + "net/http" + "net/url" + "strings" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" +) + +// R-48 — the offsite restore wizard. +// +// WHY THIS EXISTS. Until v0.153.0 the „Ellenőrző visszaállítás a távoli tárolóból" list rendered up +// to five inline `` blocks 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)" — sat next to each other as sibling buttons, and the +// difference between them is whether the customer's data comes back at all. The missing-only merge +// cannot resurrect deleted content; the reconstitution can. An operator who had read the source +// still pressed the wrong one (audits/DIAG-immich-restore-round2-2026-07-19.md, finding 1): the +// controller log shows /backup/offbox/reconstitute was never hit. +// +// The rule R-48 establishes, worth stating where it is implemented: TWO ADJACENT CONTROLS WHOSE +// DIFFERENCE IS "YOUR DATA COMES BACK" VS "YOUR DATA CANNOT COME BACK" MUST NOT BE DISTINGUISHABLE +// ONLY BY LAYOUT. So the list page now carries ONE entry per app — „Visszaállítás…" — and this +// wizard makes the intent an explicit, separately-described choice. +// +// It is deliberately a THIN surface: no new mutation endpoint, no job registry (that is R-45), no +// JSON state API. Every step posts to the endpoint the old buttons posted to, with the same field +// names, and the server re-renders the next step. The only client-side JS is the card reveal the +// escrow wizard already established plus the EXISTING confirm helper and status poll. + +// restoreWizardStep is which phase of the wizard the server decided to render. It is derived, never +// stored and never accepted from the request — a customer cannot navigate to a step whose +// preconditions do not hold. +type restoreWizardStep string + +const ( + // wizStepIntent is the choice: verify / bring back missing files / full restore. + wizStepIntent restoreWizardStep = "intent" + // wizStepPrepareConfirm is the size-gate reveal — the full-restore preparation ran, reported a + // size, and the customer confirms before the download starts. + wizStepPrepareConfirm restoreWizardStep = "prepare-confirm" + // wizStepExecution is "something is running" — every mutation form is suppressed SERVER-SIDE, + // because the backup manager's single-flight would refuse them anyway and offering a control + // that is guaranteed to fail is exactly the class of dishonesty R-48 is about. + wizStepExecution restoreWizardStep = "execution" +) + +// restoreWizardInput is the complete set of facts the step derivation is allowed to see. Keeping it +// a plain struct (rather than reading off the Server) is what makes deriveWizardStep a pure, +// table-testable function — the Scenario-B table in restore_wizard_test.go is this struct's +// truth table. +type restoreWizardInput struct { + // App is the app this wizard page is for. + App string + // OpRunning is true when ANY backup/restore op is in flight — not just this app's. The + // single-flight is process-wide, so a restore running for app X must suppress app Y's controls. + OpRunning bool + // ScratchReady is true when a completed full-restore scratch exists for App. Both the + // missing-only merge and the true reconstitution require one. + ScratchReady bool + // FullPrepApp is the app the size-gate flash binds to (from ?full_prep=). It binds to ONE app: + // a prepare for X must not reveal a confirm on Y's page. + FullPrepApp string +} + +// restoreWizardView is what the template renders. The enabled-flags are part of the derivation (not +// separate template conditionals) so that the whole "what may the customer do right now" decision is +// one pure function with one test table. +type restoreWizardView struct { + Step restoreWizardStep + // VerifyEnabled — intent 1: restore into a separate verification folder (mode=unit). Live data + // is untouched, so this is the only intent available without a prepared scratch. + VerifyEnabled bool + // PrepareEnabled — intent 3, first leg: no scratch yet, so the full restore must first be + // prepared (mode=full, size-gated). + PrepareEnabled bool + // PlaceEnabled — intent 2: missing-only merge (/backup/offbox/place). Needs a prepared scratch. + PlaceEnabled bool + // RestoreEnabled — intent 3, second leg: the TRUE restore (/backup/offbox/reconstitute). Needs a + // prepared scratch. + RestoreEnabled bool + // CommitPrepareEnabled — the revealed „Teljes visszaállítás indítása (~méret)" confirm + // (mode=full&confirm=1). Only on the prepare-confirm step. + CommitPrepareEnabled bool +} + +// deriveWizardStep is the Scenario-B truth table: step and available intents are a PURE function of +// the state, in strict precedence order. +// +// 1. An op is running (any app) → execution; nothing is offered. +// 2. The size-gate flash is for THIS app → prepare-confirm; only the commit is offered. +// 3. Otherwise → intent. Verification is always available; the two data-touching intents unlock +// only with a prepared scratch, and without one the full-restore card offers preparation +// instead. +// +// Precedence matters: execution outranks the flash, because a stale ?full_prep= in the URL must +// never resurrect a commit button while a restore is mid-flight. +func deriveWizardStep(in restoreWizardInput) restoreWizardView { + if in.OpRunning { + return restoreWizardView{Step: wizStepExecution} + } + if in.FullPrepApp != "" && in.FullPrepApp == in.App { + return restoreWizardView{Step: wizStepPrepareConfirm, CommitPrepareEnabled: true} + } + return restoreWizardView{ + Step: wizStepIntent, + VerifyEnabled: true, + PrepareEnabled: !in.ScratchReady, + PlaceEnabled: in.ScratchReady, + RestoreEnabled: in.ScratchReady, + } +} + +// resolveWizardApp finds the wizard's app in the offsite-toggled set — the same gating the list page +// applies. Pure, so the two refusal rows (unknown app, app present but NOT toggled for offsite) are +// table-testable without a live backup manager. +// +// An app that is not toggled has no offsite snapshot to restore FROM, so its wizard would be a page +// of controls that cannot work. Both refusals return nil and the caller redirects — a customer-visible +// URL that survives a bookmark, an app rename or a toggle being switched off must never 500. +func resolveWizardApp(rows []OffboxAppRow, name string) *OffboxAppRow { + for _, a := range rows { + if a.Name == name && a.Enabled { + cp := a + return &cp + } + } + return nil +} + +// backupsRestoreWizardHandler renders GET /backups/restore/app?name= — the single entry the +// list page now offers per app. +// +// Unknown app, an app that is not toggled for offsite backup, or an unconfigured offsite target all +// redirect back to the list with a Hungarian flash. They must never 500: the URL is customer-visible +// and survives a bookmark, an app rename and a toggle being switched off. +func (s *Server) backupsRestoreWizardHandler(w http.ResponseWriter, r *http.Request) { + if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() { + offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true) + return + } + app := strings.TrimSpace(r.URL.Query().Get("name")) + if app == "" { + offboxRedirectTo(w, r, "/backups/restore", "Hiányzó alkalmazás.", true) + return + } + + row := resolveWizardApp(s.buildOffboxApps(), app) + if row == nil { + offboxRedirectTo(w, r, "/backups/restore", "Ez az alkalmazás nincs távoli mentésre kijelölve.", true) + return + } + + data := s.backupsCommonData("backups-restore", "Visszaállítás — "+row.DisplayName, r) + + in := restoreWizardInput{ + App: app, + OpRunning: s.backupMgr.IsRunning(), + ScratchReady: s.backupMgr.OffboxFullScratchReady(app), + FullPrepApp: strings.TrimSpace(r.URL.Query().Get("full_prep")), + } + view := deriveWizardStep(in) + + data["App"] = app + data["AppDisplayName"] = row.DisplayName + data["AppSlug"] = row.Slug + data["Wizard"] = view + data["FullPrepSize"] = strings.TrimSpace(r.URL.Query().Get("full_size")) + // The pair-honesty panel (R-43): how old the database half is, whether the two halves come from + // the same run, and whether the dump looks customer-empty. Only meaningful once a scratch exists. + if in.ScratchReady { + pair := s.backupMgr.OffsiteScratchPair(app) + data["Pair"] = pair + } else { + data["Pair"] = backup.OffsitePairInfo{} + } + // The running op's identity, so the execution card can say WHAT is running rather than a bare + // "please wait" — including the case where it belongs to a different app. + st := s.backupMgr.RestoreStatus() + data["RunningStack"] = st.Stack + + s.executeTemplate(w, r, "backups_restore_wizard", data) +} + +// restoreWizardPath builds the wizard URL for an app. Handlers redirect here after an app-scoped +// mutation so the customer lands back on the surface they acted from, not on the list. +func restoreWizardPath(app string) string { + return "/backups/restore/app?name=" + url.QueryEscape(app) +} diff --git a/controller/internal/web/restore_wizard_test.go b/controller/internal/web/restore_wizard_test.go new file mode 100644 index 0000000..3d4c587 --- /dev/null +++ b/controller/internal/web/restore_wizard_test.go @@ -0,0 +1,324 @@ +package web + +import ( + "net/http/httptest" + "regexp" + "sort" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" +) + +// R-48 — the offsite restore wizard. +// +// What these tests actually guard is the finding's RULE, not the page: two adjacent controls whose +// difference is "your data comes back" vs "your data cannot come back" must not be distinguishable +// only by layout. Scenario A is therefore asserted STRUCTURALLY (the mutation forms are absent from +// the list page and present only in the wizard), not by eyeballing copy. + +// wizardData builds the wizard template's data map. Mirrors what backupsRestoreWizardHandler puts +// there — the handler's own construction is exercised separately by the redirect tests. +func wizardData(app string, view restoreWizardView, pair backup.OffsitePairInfo) map[string]interface{} { + return map[string]interface{}{ + "Page": "backups-restore", "Title": "Visszaállítás", + "Backup": &backup.FullBackupStatus{}, + "App": app, + "AppDisplayName": strings.ToUpper(app[:1]) + app[1:], + "AppSlug": app, + "Wizard": view, + "Pair": pair, + "FullPrepSize": "1,2 GB", + "RunningStack": "", + } +} + +func renderWizard(t *testing.T, data map[string]interface{}) string { + t.Helper() + return renderBackupPage(t, "backups_restore_wizard", data) +} + +// --- Group B (Scenario B): the step derivation is server truth, and PURE --------------------------- +// +// COMPANION RED-PROOF (run + recorded in REPORT.md): replace the body of deriveWizardStep with the +// trivial `return restoreWizardView{Step: wizStepIntent, VerifyEnabled: true}` — the op-running, +// prepare-confirm and scratch-ready rows all FAIL. Restore → green. +func TestDeriveWizardStep_Table(t *testing.T) { + cases := []struct { + name string + in restoreWizardInput + want restoreWizardView + }{ + { + name: "no scratch, no op → intent; only verification is offered, full restore must be prepared first", + in: restoreWizardInput{App: "immich"}, + want: restoreWizardView{Step: wizStepIntent, VerifyEnabled: true, PrepareEnabled: true}, + }, + { + name: "scratch ready → intent, and BOTH data-touching intents unlock; preparation is done", + in: restoreWizardInput{App: "immich", ScratchReady: true}, + want: restoreWizardView{Step: wizStepIntent, VerifyEnabled: true, PlaceEnabled: true, RestoreEnabled: true}, + }, + { + name: "full_prep flash for THIS app → prepare-confirm; only the commit is offered", + in: restoreWizardInput{App: "immich", FullPrepApp: "immich"}, + want: restoreWizardView{Step: wizStepPrepareConfirm, CommitPrepareEnabled: true}, + }, + { + name: "full_prep flash for ANOTHER app → this app keeps its own intent step", + in: restoreWizardInput{App: "immich", FullPrepApp: "bookstack"}, + want: restoreWizardView{Step: wizStepIntent, VerifyEnabled: true, PrepareEnabled: true}, + }, + { + name: "op running (this app) → execution; nothing offered", + in: restoreWizardInput{App: "immich", OpRunning: true}, + want: restoreWizardView{Step: wizStepExecution}, + }, + { + name: "op running for ANOTHER app still suppresses THIS app (the single-flight is process-wide)", + in: restoreWizardInput{App: "immich", OpRunning: true, ScratchReady: true}, + want: restoreWizardView{Step: wizStepExecution}, + }, + { + name: "op running OUTRANKS a stale full_prep flash — no commit button mid-restore", + in: restoreWizardInput{App: "immich", OpRunning: true, FullPrepApp: "immich"}, + want: restoreWizardView{Step: wizStepExecution}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := deriveWizardStep(tc.in) + if got != tc.want { + t.Errorf("deriveWizardStep(%+v)\n got %+v\n want %+v", tc.in, got, tc.want) + } + }) + } +} + +// --- Group C (Scenario B error rows): refusals resolve, never 500 ---------------------------------- + +func TestResolveWizardApp_Refusals(t *testing.T) { + rows := []OffboxAppRow{ + {Name: "immich", DisplayName: "Immich", Enabled: true}, + {Name: "radarr", DisplayName: "Radarr", Enabled: false}, + } + if got := resolveWizardApp(rows, "immich"); got == nil || got.DisplayName != "Immich" { + t.Fatalf("toggled app must resolve, got %+v", got) + } + if got := resolveWizardApp(rows, "radarr"); got != nil { + t.Errorf("an app that is NOT toggled for offsite has no snapshot to restore from — want nil, got %+v", got) + } + if got := resolveWizardApp(rows, "does-not-exist"); got != nil { + t.Errorf("unknown app must not resolve, got %+v", got) + } + if got := resolveWizardApp(nil, "immich"); got != nil { + t.Errorf("empty set must not resolve, got %+v", got) + } +} + +// The customer-visible URL must redirect, not 500, when the offsite target is not configured at all. +func TestRestoreWizardHandler_UnconfiguredRedirects(t *testing.T) { + s := testServer(t) + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/backups/restore/app?name=immich", nil) + s.backupsRestoreWizardHandler(rec, req) + if rec.Code != 302 { + t.Fatalf("want 302 redirect, got %d", rec.Code) + } + loc := rec.Header().Get("Location") + if !strings.HasPrefix(loc, "/backups/restore?flash_error=") { + t.Errorf("must redirect to the list with an error flash, got %q", loc) + } +} + +// --- Group A + C (Scenarios A and C): one entry on the list, three described cards in the wizard ---- + +// Scenario A — the list page carries EXACTLY ONE restore control per app and ZERO offsite mutation +// forms. This is the finding itself: the five inline forms are gone from the row. +func TestRestoreList_SingleEntryPerApp(t *testing.T) { + data := splitTestData() + data["OffboxScratchReady"] = map[string]bool{"calibre-web": true} + data["OffboxPairInfo"] = map[string]backup.OffsitePairInfo{ + "calibre-web": {Ready: true, HasDump: true, DumpsAt: time.Now().Add(-2 * time.Hour)}, + } + html := renderBackupPage(t, "backups_restore", data) + + // The scratch-ready fixture is precisely the state in which the OLD page rendered place and + // reconstitute as adjacent siblings. None of them may appear here now. + for _, banned := range []string{ + `action="/backup/offbox/restore"`, + `action="/backup/offbox/place"`, + `action="/backup/offbox/reconstitute"`, + "Helyreállítás az élő adatok közé", + "Teljes visszaállítás (fájlok + adatbázis)", + "Teljes visszaállítás előkészítése", + } { + if strings.Contains(html, banned) { + t.Errorf("R-48 violated: the list page still renders %q", banned) + } + } + if n := strings.Count(html, `href="/backups/restore/app?name=calibre-web"`); n != 1 { + t.Errorf("want exactly ONE wizard entry for the app, got %d", n) + } +} + +// Scenario C — the three intents are CARDS with their own consequence sentence, the dangerous one is +// styled as such, and the honesty panel is bound to the real pair info. +func TestRestoreWizard_ThreeIntentCards(t *testing.T) { + pair := backup.OffsitePairInfo{ + Ready: true, HasDump: true, + DumpsAt: time.Date(2026, 7, 19, 3, 15, 0, 0, time.UTC), + Skewed: true, LooksEmpty: true, + } + view := deriveWizardStep(restoreWizardInput{App: "immich", ScratchReady: true}) + html := renderWizard(t, wizardData("immich", view, pair)) + + // Each intent must state its CONSEQUENCE, not just its name. + for _, want := range []string{ + "Ellenőrzés külön mappába", + "az élő adataid nem változnak", + "Hiányzó fájlok visszahozása", + "törölt tartalom ettől nem jelenik meg újra", + "Teljes visszaállítás (fájlok + adatbázis)", + "az adatbázist is visszatölti", + } { + if !strings.Contains(html, want) { + t.Errorf("intent card copy missing: %q", want) + } + } + // The dangerous intent is marked structurally, not only by wording/position. + if !strings.Contains(html, "restore-danger-card") { + t.Error("the full-restore card must carry the danger styling hook") + } + // Pair honesty is BOUND to the fixture, not hardcoded prose. + if !strings.Contains(html, "eltérő időpontból származnak") { + t.Error("Skewed pair must surface the skew warning") + } + if !strings.Contains(html, "üresnek tűnik") { + t.Error("LooksEmpty pair must surface the empty-dump warning") + } + if !strings.Contains(html, `data-restore-skewed="1"`) || !strings.Contains(html, `data-restore-empty="1"`) { + t.Error("the double-confirm must receive the pair facts it repeats back") + } + // The confirm copy moved VERBATIM — it is the good part of the old surface. + if !strings.Contains(html, "UTOLSÓ MEGERŐSÍTÉS") || !strings.Contains(html, "confirmFullRestore") { + t.Error("the double-confirm did not move with the action") + } + // A pair WITHOUT the warnings must not inherit them (guards a hardcoded-prose regression). + // NOTE: the confirm helper's JS repeats both sentences as string literals, so the assertion has + // to be on the RENDERED markup — the warning banners and the data-attributes that drive them — + // not on a bare substring, which is always present via the script block. + clean := renderWizard(t, wizardData("immich", view, backup.OffsitePairInfo{Ready: true, HasDump: true})) + // The two banners are identified by their opening markup, not by a bare substring: the layout's + // shared confirm helper also contains alert-warning literals. + const skewBanner = `alert alert-warning" style="margin-bottom:.75rem">Az adatbázis-mentés régebbi` + const emptyBanner = `alert alert-warning" style="margin-bottom:.75rem">A mentett adatbázis üresnek tűnik` + if !strings.Contains(html, skewBanner) || !strings.Contains(html, emptyBanner) { + t.Error("a skewed + empty-looking pair must render BOTH warning banners") + } + if strings.Contains(clean, skewBanner) || strings.Contains(clean, emptyBanner) { + t.Error("a clean pair must render neither warning banner") + } + if !strings.Contains(clean, `data-restore-skewed=""`) || !strings.Contains(clean, `data-restore-empty=""`) { + t.Error("a clean pair must pass empty skew/empty flags to the confirm helper") + } +} + +// Without a prepared scratch the two data-touching intents are NOT offered — the card explains what +// has to happen first instead of showing a button that would fail. +func TestRestoreWizard_NoScratchLocksDataIntents(t *testing.T) { + view := deriveWizardStep(restoreWizardInput{App: "immich"}) + html := renderWizard(t, wizardData("immich", view, backup.OffsitePairInfo{})) + if strings.Contains(html, `action="/backup/offbox/place"`) { + t.Error("missing-only merge must not be offered without a prepared scratch") + } + if strings.Contains(html, `action="/backup/offbox/reconstitute"`) { + t.Error("full restore must not be offered without a prepared scratch") + } + if !strings.Contains(html, "Teljes visszaállítás előkészítése") { + t.Error("the full-restore card must offer preparation instead") + } +} + +// --- Group E (Scenario B, execution row): every mutation form suppressed while an op runs ---------- + +func TestRestoreWizard_OpRunningSuppressesAllMutations(t *testing.T) { + view := deriveWizardStep(restoreWizardInput{App: "immich", OpRunning: true, ScratchReady: true, FullPrepApp: "immich"}) + data := wizardData("immich", view, backup.OffsitePairInfo{Ready: true, HasDump: true}) + data["RunningStack"] = "bookstack" + html := renderWizard(t, data) + + if strings.Contains(html, "`, + ``, + ``, + } { + if !strings.Contains(html, want) { + t.Errorf("field contract broken, missing: %s", want) + } + } + // prepare-confirm step: mode=full AND confirm=1 together (the size-gated commit) + confirmHTML := renderWizard(t, wizardData("immich", + deriveWizardStep(restoreWizardInput{App: "immich", FullPrepApp: "immich"}), + backup.OffsitePairInfo{})) + if !strings.Contains(confirmHTML, `name="mode" value="full"`) || !strings.Contains(confirmHTML, `name="confirm" value="1"`) { + t.Error("the size-gated commit must post mode=full together with confirm=1") + } + if !strings.Contains(confirmHTML, "1,2 GB") { + t.Error("the confirm step must show the measured size before the customer commits") + } +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 92eb38a..4e2a193 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -352,6 +352,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.backupsAppsHandler(w, r) case path == "/backups/restore": s.backupsRestoreHandler(w, r) + // R-48: the per-app offsite restore wizard. A GET-only page — every action inside it posts to the + // pre-existing /backup/offbox/* endpoints, so this adds no mutation surface. + case path == "/backups/restore/app" && r.Method == http.MethodGet: + s.backupsRestoreWizardHandler(w, r) case path == "/monitoring": s.monitoringHandler(w, r) case path == "/settings": diff --git a/controller/internal/web/templates/backups_restore.html b/controller/internal/web/templates/backups_restore.html index 7c6549c..921141c 100644 --- a/controller/internal/web/templates/backups_restore.html +++ b/controller/internal/web/templates/backups_restore.html @@ -58,61 +58,24 @@ {{end}} - + {{if .OffboxConfigured}}
-

Ellenőrző visszaállítás a távoli tárolóból

-

A távoli mentésre kijelölt alkalmazások legutóbbi pillanatképe egy külön ellenőrző mappába állítható vissza — a meglévő adatok nem változnak.

+

Visszaállítás a távoli tárolóból

+

Válaszd ki az alkalmazást, és a következő oldalon döntsd el, mit szeretnél: ellenőrzést külön mappába, csak a hiányzó fájlok visszahozását, vagy teljes visszaállítást. Egyik sem indul el kérdés nélkül.

{{if .OffboxToggledCount}}
{{range .OffboxApps}} {{if .Enabled}} {{template "app_list_row" dict "Slug" .Slug "Name" .DisplayName}} - {{$.CSRFField}} - - - - -
{{$.CSRFField}} - - - -
- {{if $.FullPrepApp}}{{if eq $.FullPrepApp .Name}} -
{{$.CSRFField}} - - - - -
- {{end}}{{end}} - {{if $.OffboxScratchReady}}{{if index $.OffboxScratchReady .Name}} -
{{$.CSRFField}} - - -
- 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. - {{$pair := index $.OffboxPairInfo .Name}} -
{{$.CSRFField}} - - - -
- 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. - {{if $pair.Skewed}} - 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. - {{end}} - {{if $pair.LooksEmpty}} - 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. - {{end}} - {{end}}{{end}} + + Visszaállítás… {{template "app_list_row_end"}} {{end}} {{end}} @@ -235,26 +198,6 @@ function confirmDeleteVerifyCopy(btn){ }); }); } -/* R-43: the true offsite restore overwrites live files and replays a database, so it double-confirms - and — unlike the old missing-only merge — states the DB half's age and any warning BEFORE the - customer commits. The honesty lines are already rendered under the button; repeating the decisive - ones here means the person clicking "Igen" has read them. */ -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(); } - }); - }); -} function fabStart(stack, next){ fetch('/api/export/download/start', {method:'POST', headers:Object.assign({'Content-Type':'application/json'}, csrfHeaders()), body: JSON.stringify({stack_name: stack, password: fabPassword()})}) .then(function(r){ return r.json(); }) diff --git a/controller/internal/web/templates/backups_restore_wizard.html b/controller/internal/web/templates/backups_restore_wizard.html new file mode 100644 index 0000000..5f9deaa --- /dev/null +++ b/controller/internal/web/templates/backups_restore_wizard.html @@ -0,0 +1,160 @@ +{{define "backups_restore_wizard"}} +{{template "layout_start" .}} + + + + + +{{template "backups_flash" .}} +{{template "restore_banner" .}} + + +
+ Előkészítés + Megerősítés + Végrehajtás + Eredmény +
+ +{{if eq (printf "%s" .Wizard.Step) "execution"}} + +
+

Végrehajtás

+

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ó.

+

Az állapot fent automatikusan frissül. A művelet befejezése után frissítsd az oldalt.

+ +
+ +{{else if eq (printf "%s" .Wizard.Step) "prepare-confirm"}} + +
+

Megerősítés — teljes visszaállítás előkészítése

+

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: {{.}}{{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.

+

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.

+
+
{{.CSRFField}} + + + + +
+ Mégsem +
+
+ +{{else}} + + +
+

1. Ellenőrzés külön mappába

+

A mentés tartalma egy külön ellenőrző mappába kerül — az élő adataid nem változnak.

+
+
{{.CSRFField}} + + + +
+
+
+ +
+

2. Hiányzó fájlok visszahozása

+

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.

+ {{if .Wizard.PlaceEnabled}} +
+
{{.CSRFField}} + + +
+
+ {{else}} +

Ehhez előbb elő kell készíteni a teljes mentést — lásd a 3. pontot.

+ {{end}} +
+ +
+

3. Teljes visszaállítás (fájlok + adatbázis)

+

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.

+ + {{if .Wizard.RestoreEnabled}} + +
+ Az előkészített mentés: + {{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}} +
+ {{if .Pair.Skewed}} +
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.
+ {{end}} + {{if .Pair.LooksEmpty}} +
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.
+ {{end}} +
+
{{.CSRFField}} + + + +
+
+ {{else}} +

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.

+
+
{{.CSRFField}} + + + +
+
+ {{end}} +
+{{end}} + + + +{{template "layout_end" .}} +{{end}} diff --git a/controller/internal/web/templates/style.css b/controller/internal/web/templates/style.css index 29386ec..9f9b468 100644 --- a/controller/internal/web/templates/style.css +++ b/controller/internal/web/templates/style.css @@ -3421,3 +3421,27 @@ html::-webkit-scrollbar-track { background: var(--bg-0); } /* Inline two-step confirm (drill F-11) — the in-place replacement for native confirm() */ .inline-confirm { display: inline-flex; align-items: center; gap: .5rem; flex-wrap: wrap; } .inline-confirm-q { font-size: .8rem; color: var(--warn); } + +/* R-48 offsite restore wizard — scoped to the wizard page only. + The destructive intent card is marked by a critical-tone left rule, not a shouty background: + the exception-color principle applies to cards too, and the copy carries the warning. */ +.restore-wizard-phases { + display: flex; + flex-wrap: wrap; + gap: .35rem .75rem; + margin-bottom: 1.5rem; + font-size: .8rem; + color: var(--text-3); +} +.restore-wizard-phase + .restore-wizard-phase::before { + content: "·"; + margin-right: .75rem; + color: var(--line); +} +.restore-wizard-phase.is-current { + color: var(--blue-bright); + font-weight: 600; +} +.restore-danger-card { + border-left: 2px solid var(--crit); +}