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
+15
View File
@@ -252,6 +252,11 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
case path == "/backup/status" && req.Method == http.MethodGet:
r.backupStatus(w, req)
// GET /api/backup/restore-status — async restore progress for the backups-page banner (Part B).
// Distinct from /backup/status (which proxies the agent's PBS whole-guest status).
case path == "/backup/restore-status" && req.Method == http.MethodGet:
r.backupRestoreStatus(w, req)
// GET /api/backup/snapshots?stack=<name> — restorable keep-side backups for the restore panel
case path == "/backup/snapshots" && req.Method == http.MethodGet:
r.backupSnapshots(w, req)
@@ -835,6 +840,16 @@ func (r *Router) backupStatus(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
}
// backupRestoreStatus (Part B) surfaces the async restore-op progress the backups page polls to drive
// its banner (running: <op>/<stack> → terminal last{ok,message}). Display-only; empty when idle.
func (r *Router) backupRestoreStatus(w http.ResponseWriter, _ *http.Request) {
if r.backupMgr == nil {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"running": false}})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.backupMgr.RestoreStatus()})
}
// validStackParam reports whether a stack name from a request is a safe single path segment
// (same semantics as web.validStackName — see internal/web/validate.go; duplicated here because
// api ↔ web would be a circular import). Rejects traversal/escape so the name can never become