v0.142.0: offsite repo continuity — orphaned-repo guard (A) + run-status auto-refresh (C)

- Part A: classify restic cat-config failure (wrong-password=orphaned vs no-repo vs other); ORPHANED state + Hungarian card + offbox_repo_orphaned/reset events (once, not nightly); reset = move-aside (never delete) + init, unclaimed auto / claimed confirm. Red-proofs TestOffbox_OrphanDetection_* + ConfirmedReset.
- Part C: GET /backup/offbox/status + poll on backups_remote → flips Fut→Rendben/Hiba without manual reload.
This commit is contained in:
2026-07-17 10:47:30 +02:00
parent 1452dd2b17
commit 596505ed64
11 changed files with 587 additions and 18 deletions
@@ -2,6 +2,7 @@ package web
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strconv"
@@ -209,6 +210,12 @@ func (s *Server) offboxRunHandler(w http.ResponseWriter, r *http.Request) {
offboxRedirect(w, r, "A távoli mentés a kulcs letétbe helyezésére vár.", true)
return
}
// Offsite-repo continuity (v0.142.0): an ORPHANED repo can't be written — route the customer to the
// orphan card's explanation/reset instead of attempting a doomed write.
if s.backupMgr.OffboxOrphaned() {
offboxRedirect(w, r, "A távoli tároló elárvult — előbb indíts új távoli mentést a kártyán látható módon.", true)
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Hour)
defer cancel()
@@ -219,6 +226,50 @@ func (s *Server) offboxRunHandler(w http.ResponseWriter, r *http.Request) {
offboxRedirect(w, r, "A távoli mentés elindult (a futás után az állapot frissül).", false)
}
// offboxResetHandler is the CLAIMED confirmed orphaned-repo reset (Scenario C): move the old (recovery-
// code-recoverable) history aside — never delete — and init a fresh repo. Refuses unless orphaned AND
// explicitly confirmed (confirm=1, set by the reveal-then-confirm block on the orphan card).
func (s *Server) offboxResetHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
offboxRedirect(w, r, "A távoli mentési cél nincs beállítva.", true)
return
}
if !s.backupMgr.OffboxOrphaned() {
offboxRedirect(w, r, "Az offsite tároló nincs elárvult állapotban.", true)
return
}
if r.FormValue("confirm") != "1" {
offboxRedirect(w, r, "A visszaállításhoz megerősítés szükséges.", true)
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if err := s.backupMgr.ResetOrphanedRepo(ctx); err != nil {
s.logger.Printf("[WARN] [web] offbox orphaned-repo reset failed: %v", err)
}
}()
offboxRedirect(w, r, "Új távoli mentés indítása folyamatban — a régi előzmény félretéve (nem törölve).", false)
}
// offboxStatusHandler (Part C) is the poll source for the remote-backup run status — the page polls it
// after "Távoli mentés most" and flips to the terminal state without a manual reload. Session-auth'd.
func (s *Server) offboxStatusHandler(w http.ResponseWriter, r *http.Request) {
t := s.settings.GetOffboxTarget()
resp := map[string]any{"status": "", "snapshots": 0, "orphaned": false}
if t != nil {
resp["status"] = t.LastStatus
resp["snapshots"] = t.SnapshotCount
resp["last_run"] = t.LastRun
resp["last_duration"] = t.LastDuration
resp["repo_size_human"] = t.RepoSizeHuman
resp["last_error"] = t.LastError
resp["orphaned"] = t.RepoState == "orphaned"
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
// offboxRestoreHandler restores an app's off-box data to an on-data-drive scratch dir (§7, F-A1;
// non-destructive — does NOT overwrite live data). mode=unit (default) restores the recovery unit
// only; mode=full is size-gated and two-step (first POST computes the size + headroom and redirects