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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user