feat: infra backup retention + version picker

Hub: GFS retention (7d/4w/3m, ~14 versions) in new infra_backup_versions
table. Recovery endpoint supports ?version=ID. New /versions API endpoint.
Dashboard shows backup history.

Controller: local drive backups rotated into history/ (last 5 versions).
Setup wizard shows version picker for Hub restores when multiple versions
exist. Scan results enriched with app names, disk count, history badge.
Local restore supports historical versions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 14:47:40 +01:00
parent 8f49bcc4cc
commit c0cdd95e56
9 changed files with 540 additions and 80 deletions
+92 -29
View File
@@ -115,6 +115,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/setup/scan", s.handleScan)
mux.HandleFunc("/setup/scan/status", s.handleScanStatus)
mux.HandleFunc("/setup/hub-restore", s.handleHubRestore)
mux.HandleFunc("/setup/hub-restore/select", s.handleHubVersionSelect)
mux.HandleFunc("/setup/restore", s.handleRestore)
mux.HandleFunc("/setup/restore/status", s.handleRestoreStatus)
mux.HandleFunc("/setup/fresh", s.handleFreshHub)
@@ -233,7 +234,8 @@ func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
switch source {
case "local":
drivePath := r.FormValue("drive_path")
go s.executeLocalRestore(drivePath)
historyFile := r.FormValue("history_file")
go s.executeLocalRestore(drivePath, historyFile)
case "hub":
go s.executeHubRestore()
default:
@@ -372,10 +374,31 @@ func (s *Server) autoProcessHubRestore(w http.ResponseWriter, r *http.Request, c
}
if s.isDebug() {
s.logger.Printf("[DEBUG] Setup: hub recovery received — hasInfra=%v, configLen=%d", recovery.HasInfraBackup, len(recovery.ConfigYAML))
s.logger.Printf("[DEBUG] Setup: hub recovery received — hasInfra=%v, configLen=%d, versions=%d", recovery.HasInfraBackup, len(recovery.ConfigYAML), len(recovery.BackupVersions))
}
// Store recovery data in state for restore execution
// If multiple versions available, show picker instead of auto-restoring
if len(recovery.BackupVersions) > 1 && recovery.HasInfraBackup {
s.logger.Printf("[INFO] Setup: %d backup versions available — showing version picker", len(recovery.BackupVersions))
// Store config for later use after version selection
s.state.SetFormField("hub_config_yaml", recovery.ConfigYAML)
s.state.Save()
csrf := ensureCSRFToken(w, r)
data := map[string]interface{}{
"CSRF": csrf,
"Versions": recovery.BackupVersions,
}
s.render(w, "setup_hub_versions", data)
return
}
// Single version or no versions — proceed directly
s.storeRecoveryAndRestore(w, r, recovery, customerID)
}
// storeRecoveryAndRestore stores recovery data in state and starts the restore goroutine.
func (s *Server) storeRecoveryAndRestore(w http.ResponseWriter, r *http.Request, recovery *report.RecoveryResponse, customerID string) {
s.state.SelectedBackup = &SelectedBackup{
Source: "hub",
CustomerID: customerID,
@@ -389,9 +412,8 @@ func (s *Server) autoProcessHubRestore(w http.ResponseWriter, r *http.Request, c
s.state.SetStep("restore-exec")
s.state.Save()
s.logger.Printf("[INFO] Setup: hub recovery received (hasInfra=%v) — starting restore", recovery.HasInfraBackup)
s.logger.Printf("[INFO] Setup: hub recovery stored (hasInfra=%v) — starting restore", recovery.HasInfraBackup)
// Start the restore goroutine, then render the progress page
go s.executeHubRestore()
csrf := ensureCSRFToken(w, r)
@@ -476,34 +498,69 @@ func (s *Server) processHubRestore(w http.ResponseWriter, r *http.Request) {
}
if s.isDebug() {
s.logger.Printf("[DEBUG] Setup: hub recovery received — hasInfra=%v, configLen=%d", recovery.HasInfraBackup, len(recovery.ConfigYAML))
s.logger.Printf("[DEBUG] Setup: hub recovery received — hasInfra=%v, configLen=%d, versions=%d", recovery.HasInfraBackup, len(recovery.ConfigYAML), len(recovery.BackupVersions))
}
// Store recovery data in state for restore execution
s.state.SelectedBackup = &SelectedBackup{
Source: "hub",
CustomerID: customerID,
}
s.state.SetFormField("retrieval_password", password)
s.state.SetFormField("hub_config_yaml", recovery.ConfigYAML)
if recovery.HasInfraBackup && recovery.InfraBackup != nil {
ibJSON, _ := json.Marshal(recovery.InfraBackup)
s.state.SetFormField("hub_infra_backup", string(ibJSON))
s.state.SelectedBackup.Timestamp = recovery.InfraBackup.Timestamp
// If multiple versions available, show picker
if len(recovery.BackupVersions) > 1 && recovery.HasInfraBackup {
s.logger.Printf("[INFO] Setup: %d backup versions available — showing version picker", len(recovery.BackupVersions))
s.state.SetFormField("hub_config_yaml", recovery.ConfigYAML)
s.state.Save()
csrf := ensureCSRFToken(w, r)
data := map[string]interface{}{
"CSRF": csrf,
"Versions": recovery.BackupVersions,
}
s.render(w, "setup_hub_versions", data)
return
}
s.state.SetStep("restore-exec")
s.state.Save()
s.logger.Printf("[INFO] Setup: hub recovery received (hasInfra=%v) — starting restore", recovery.HasInfraBackup)
s.storeRecoveryAndRestore(w, r, recovery, customerID)
}
// Start the restore goroutine, then render the progress page
go s.executeHubRestore()
csrf := ensureCSRFToken(w, r)
data := map[string]interface{}{
"CSRF": csrf,
// handleHubVersionSelect processes the user's version selection from the Hub version picker.
func (s *Server) handleHubVersionSelect(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/setup/hub-restore", http.StatusFound)
return
}
s.render(w, "setup_restore_exec", data)
if !validateCSRF(r) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
versionStr := r.FormValue("version_id")
customerID := s.state.GetFormField("customer_id")
password := s.state.GetFormField("retrieval_password")
hubURL := DefaultHubURL
if customerID == "" || password == "" {
http.Redirect(w, r, "/setup/hub-restore", http.StatusFound)
return
}
var versionID int64
fmt.Sscanf(versionStr, "%d", &versionID)
s.logger.Printf("[INFO] Setup: user selected backup version %d for %s", versionID, customerID)
// Fetch the specific version
recovery, err := report.PullRecoveryVersion(hubURL, customerID, password, versionID)
if err != nil {
s.logger.Printf("[ERROR] Setup: failed to fetch version %d: %v", versionID, err)
csrf := ensureCSRFToken(w, r)
data := map[string]interface{}{
"CSRF": csrf,
"Error": fmt.Sprintf("Hiba a verzió letöltésekor: %v", err),
}
s.render(w, "setup_hub_versions", data)
return
}
s.storeRecoveryAndRestore(w, r, recovery, customerID)
}
func (s *Server) processFreshHub(w http.ResponseWriter, r *http.Request) {
@@ -618,7 +675,7 @@ func (s *Server) processManual(w http.ResponseWriter, r *http.Request) {
// --- Restore Execution ---
func (s *Server) executeLocalRestore(drivePath string) {
func (s *Server) executeLocalRestore(drivePath, historyFile string) {
s.restoreMu.Lock()
s.restoreRunning = true
s.restoreDone = false
@@ -630,8 +687,14 @@ func (s *Server) executeLocalRestore(drivePath string) {
}
s.restoreMu.Unlock()
// Step 1: Read backup
backupData, _, err := backup.ReadLocalInfraBackup(drivePath)
// Step 1: Read backup (current or historical version)
var backupData []byte
var err error
if historyFile != "" {
backupData, _, err = backup.ReadLocalInfraBackupFromHistory(drivePath, historyFile)
} else {
backupData, _, err = backup.ReadLocalInfraBackup(drivePath)
}
if err != nil {
s.setRestoreError(0, fmt.Sprintf("Mentés olvasási hiba: %v", err))
return