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:
@@ -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
|
||||
`<form style="display: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=<app>`, 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=<app>`) 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
|
||||
|
||||
+32
-1
@@ -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=<app>`.
|
||||
> 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=<app>`. **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
|
||||
|
||||
@@ -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=<app>` — 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 |
|
||||
|
||||
+12
-1
@@ -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=<app>` | Visszaállítás — <app> | **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`.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -53,8 +53,8 @@ func TestBackupsSplit_SectionsOnExactlyOnePage(t *testing.T) {
|
||||
"<h3>Adatbázisok</h3>": "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 {
|
||||
|
||||
@@ -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=<app>), 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 ------------------------------------------------------------------
|
||||
|
||||
@@ -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 `<form style="display: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=<app> — 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)
|
||||
}
|
||||
@@ -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, "<form") {
|
||||
t.Error("the execution step must render NO mutation form — the single-flight would refuse it")
|
||||
}
|
||||
if !strings.Contains(html, "bookstack") {
|
||||
t.Error("the execution card must name what is actually running")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Group D (Scenario D): real flows only — no new mutation surface ------------------------------
|
||||
|
||||
var formActionRe = regexp.MustCompile(`action="([^"]+)"`)
|
||||
|
||||
// Every form in every wizard state posts to a PRE-EXISTING endpoint. If this test has to be updated
|
||||
// to add a path, a new mutation endpoint was introduced — which R-48 explicitly does not do.
|
||||
func TestRestoreWizard_NoNewMutationEndpoints(t *testing.T) {
|
||||
preExisting := map[string]bool{
|
||||
"/backup/offbox/restore": true,
|
||||
"/backup/offbox/place": true,
|
||||
"/backup/offbox/reconstitute": true,
|
||||
}
|
||||
states := []restoreWizardInput{
|
||||
{App: "immich"},
|
||||
{App: "immich", ScratchReady: true},
|
||||
{App: "immich", FullPrepApp: "immich"},
|
||||
{App: "immich", OpRunning: true},
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, in := range states {
|
||||
html := renderWizard(t, wizardData(in.App, deriveWizardStep(in), backup.OffsitePairInfo{Ready: true, HasDump: true}))
|
||||
for _, m := range formActionRe.FindAllStringSubmatch(html, -1) {
|
||||
seen[m[1]] = true
|
||||
if !preExisting[m[1]] {
|
||||
t.Errorf("wizard posts to a NON-pre-existing endpoint %q — R-48 adds no mutation surface", m[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
var got []string
|
||||
for p := range seen {
|
||||
got = append(got, p)
|
||||
}
|
||||
sort.Strings(got)
|
||||
if len(got) != 3 {
|
||||
t.Errorf("expected all three existing endpoints to be reachable across the states, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The field NAMES the wizard sends must match what the handlers read — a renamed field would make
|
||||
// every action a silent no-op that still redirects with a success-shaped flash.
|
||||
func TestRestoreWizard_FieldContract(t *testing.T) {
|
||||
// intent step, scratch ready: unit-verify + place + reconstitute(confirm=1)
|
||||
html := renderWizard(t, wizardData("immich",
|
||||
deriveWizardStep(restoreWizardInput{App: "immich", ScratchReady: true}),
|
||||
backup.OffsitePairInfo{Ready: true, HasDump: true}))
|
||||
for _, want := range []string{
|
||||
`<input type="hidden" name="app" value="immich">`,
|
||||
`<input type="hidden" name="mode" value="unit">`,
|
||||
`<input type="hidden" name="confirm" value="1">`,
|
||||
} {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -58,61 +58,24 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Ellenőrző visszaállítás a távoli (3. mentés) tárolóból — the per-app restore-to-verify
|
||||
forms moved here from the Távoli mentés toggle list (v0.124.0 IA split). Display list =
|
||||
the offbox-toggled apps; the action itself is unchanged. -->
|
||||
<!-- Visszaállítás a távoli (3. mentés) tárolóból. Display list = the offbox-toggled apps.
|
||||
v0.124.0 moved these rows here from the Távoli mentés toggle list; v0.154.0 (R-48) reduced each
|
||||
row to a SINGLE „Visszaállítás…" entry — the actions themselves are unchanged, they moved into
|
||||
the wizard at /backups/restore/app. -->
|
||||
{{if .OffboxConfigured}}
|
||||
<div class="backup-section-card">
|
||||
<h3>Ellenőrző visszaállítás a távoli tárolóból</h3>
|
||||
<p class="form-hint" style="margin:-0.25rem 0 1rem">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.</p>
|
||||
<h3>Visszaállítás a távoli tárolóból</h3>
|
||||
<p class="form-hint" style="margin:-0.25rem 0 1rem">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.</p>
|
||||
{{if .OffboxToggledCount}}
|
||||
<div class="app-row-list">
|
||||
{{range .OffboxApps}}
|
||||
{{if .Enabled}}
|
||||
{{template "app_list_row" dict "Slug" .Slug "Name" .DisplayName}}
|
||||
<form method="POST" action="/backup/offbox/restore" style="display:inline">{{$.CSRFField}}
|
||||
<input type="hidden" name="app" value="{{.Name}}">
|
||||
<input type="hidden" name="mode" value="unit">
|
||||
<button type="submit" class="btn btn-xs btn-outline">Visszaállítás ellenőrzéshez (konfiguráció + adatbázis)</button>
|
||||
</form>
|
||||
<form method="POST" action="/backup/offbox/restore" style="display:inline">{{$.CSRFField}}
|
||||
<input type="hidden" name="app" value="{{.Name}}">
|
||||
<input type="hidden" name="mode" value="full">
|
||||
<button type="submit" class="btn btn-xs btn-outline">Teljes visszaállítás előkészítése</button>
|
||||
</form>
|
||||
{{if $.FullPrepApp}}{{if eq $.FullPrepApp .Name}}
|
||||
<form method="POST" action="/backup/offbox/restore" style="display:inline">{{$.CSRFField}}
|
||||
<input type="hidden" name="app" value="{{.Name}}">
|
||||
<input type="hidden" name="mode" value="full">
|
||||
<input type="hidden" name="confirm" value="1">
|
||||
<button type="submit" class="btn btn-xs btn-primary">Teljes visszaállítás indítása (~{{$.FullPrepSize}})</button>
|
||||
</form>
|
||||
{{end}}{{end}}
|
||||
{{if $.OffboxScratchReady}}{{if index $.OffboxScratchReady .Name}}
|
||||
<form method="POST" action="/backup/offbox/place" style="display:inline">{{$.CSRFField}}
|
||||
<input type="hidden" name="app" value="{{.Name}}">
|
||||
<button type="submit" class="btn btn-xs btn-outline">Helyreállítás az élő adatok közé (csak a hiányzó fájlok)</button>
|
||||
</form>
|
||||
<span class="form-hint" style="display:block;margin-top:.25rem">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.</span>
|
||||
{{$pair := index $.OffboxPairInfo .Name}}
|
||||
<form method="POST" action="/backup/offbox/reconstitute" style="display:inline">{{$.CSRFField}}
|
||||
<input type="hidden" name="app" value="{{.Name}}">
|
||||
<input type="hidden" name="confirm" value="1">
|
||||
<button type="button" class="btn btn-xs btn-primary"
|
||||
data-restore-app="{{.Name}}"
|
||||
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 (fájlok + adatbázis)</button>
|
||||
</form>
|
||||
<span class="form-hint" style="display:block;margin-top:.25rem">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.</span>
|
||||
{{if $pair.Skewed}}
|
||||
<span class="form-hint" style="display:block;margin-top:.25rem;color:var(--warn)">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.</span>
|
||||
{{end}}
|
||||
{{if $pair.LooksEmpty}}
|
||||
<span class="form-hint" style="display:block;margin-top:.25rem;color:var(--warn)">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.</span>
|
||||
{{end}}
|
||||
{{end}}{{end}}
|
||||
<!-- R-48: ONE entry per app. The five inline forms that used to live here — verify,
|
||||
prepare, the revealed commit, the missing-only merge and the true reconstitution —
|
||||
were separable only by layout, and two of them differed by whether the customer's
|
||||
data comes back at all. They are now described intents inside the wizard. -->
|
||||
<a href="/backups/restore/app?name={{.Name}}" class="btn btn-xs btn-outline">Visszaállítás…</a>
|
||||
{{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(); })
|
||||
|
||||
@@ -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}}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user