feat(backup): async restore family — no proxy-timeout error page on a succeeding restore (v0.102.0)

Re-adjudicates F4: /backup/restore, /backup/tier2/restore, /backup/offbox/restore
blocked the HTTP request until completion, so through cloudflared's 100s cap a
customer got an error page while the restore succeeded (offbox worse — bounded
on r.Context(), canceling the SFTP restore mid-flight). Convert all three to the
offboxRun async shape: fast-path IsRunning refuse, background goroutine
(offbox ctx off r.Context() -> Background+30m), instant redirect. Add mutex-
guarded op-status (opstatus.go) + GET /api/backup/restore-status + a 3s-polling
backups.html banner (neutral running, red on failure). Restore single-flight
unchanged. Tests + red-proof (sync handler blocks indefinitely vs <500ms async).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-06 20:23:49 +02:00
parent 9d5a588ca3
commit c529a455af
9 changed files with 429 additions and 41 deletions
+19 -7
View File
@@ -134,13 +134,25 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
offboxRedirect(w, r, "Hiányzó alkalmazás.", true)
return
}
dest := filepath.Join(s.cfg.Paths.DataDir, "offbox-restore", app)
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
defer cancel()
if err := s.backupMgr.RestoreOffbox(ctx, app, dest); err != nil {
s.logger.Printf("[ERROR] [web] off-box restore %s: %v", app, err)
offboxRedirect(w, r, "A visszaállítás sikertelen: "+err.Error(), true)
// Part B: fast-path refuse a concurrent op, then run async on a BACKGROUND context. The old code
// bounded on r.Context()+30m — a proxy read-timeout then CANCELED the SFTP restore mid-flight
// (worse than F4: not just an error page, an aborted restore). Background ctx fixes that.
if s.backupMgr.IsRunning() {
offboxRedirect(w, r, "Egy mentési/visszaállítási művelet már fut.", true)
return
}
offboxRedirect(w, r, "A(z) "+app+" visszaállítva ide (ellenőrzésre): "+dest, false)
dest := filepath.Join(s.cfg.Paths.DataDir, "offbox-restore", app)
s.backupMgr.BeginRestoreOp("offbox-restore", app)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
if err := s.backupMgr.RestoreOffbox(ctx, app, dest); err != nil {
s.logger.Printf("[ERROR] [web] off-box restore %s (async): %v", app, err)
s.backupMgr.EndRestoreOp(false, "A visszaállítás sikertelen: "+err.Error())
return
}
s.logger.Printf("[INFO] [web] off-box restore %s completed (async) → %s", app, dest)
s.backupMgr.EndRestoreOp(true, "A(z) "+app+" visszaállítva ide (ellenőrzésre): "+dest)
}()
offboxRedirect(w, r, "A NAS-visszaállítás elindult — az állapot itt frissül.", false)
}