package web import ( "context" "errors" "net/http" "strings" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" "gitea.dooplex.hu/admin/felhom-controller/internal/quiesce" ) // Whole-guest backup visibility + manual trigger (spec Part 2). The agent owns whole-guest // vzdump/PBS backup; the controller is a read-only window onto it (GET /backup/{status,due}, // /restore-test/status) plus a "Mentés most" trigger that goes through the quiesce loop (the // CONTROLLER owns quiescing — stop stacks → POST /backup → resume — so the captured state is // app-consistent, not the agent's crash-consistent default). Cadence/retention CONFIG is NOT here // (hub-served policy, slice 10). // guestBackupView is the template payload for the "Rendszermentés" section. Times are time.Time so // the existing fmtTime/timeAgo funcmap helpers format them; size is int64 for fmtBytes. type guestBackupView struct { Available bool // agent reachable + a status read succeeded Note string // shown when not Available (unprovisioned / unreachable) Phase string // idle | running | snapshotted | done | failed Running bool // a backup job is in progress now HasBackup bool Success bool StartedAt time.Time SizeBytes int64 Target string // human label: "Biztonsági szerver (PBS)" / "Helyi tároló (local)" Archive string Mode string // snapshot | stop StopMode bool // mode == stop → full app downtime during the backup (warn) Due bool DueReason string AgeHours int64 // age of the newest successful backup, hours (for "X órája") HasRestoreTest bool RestorePass bool RestoreVerified string RestoreTestedAt time.Time CanTrigger bool // a backup trigger (quiesce loop) is wired } // loadGuestBackup fetches the agent's whole-guest backup view (best-effort). Returns a view with // Available=false (+ a note) when the agent isn't configured/reachable — the page still renders. func (s *Server) loadGuestBackup(ctx context.Context) *guestBackupView { v := &guestBackupView{CanTrigger: s.backupTrigger != nil} client, err := s.agentClient() if err != nil { v.Note = "A host-ügynök nincs konfigurálva ezen a gépen." return v } st, err := client.BackupStatus(ctx) if err != nil { v.Note = "A host-ügynök jelenleg nem elérhető." return v } v.Available = true v.Phase = st.Phase v.Running = st.Phase == agentapi.PhaseRunning || st.Phase == "snapshotted" if st.Backup != nil { v.HasBackup = true v.Success = st.Backup.Success v.SizeBytes = st.Backup.SizeBytes v.Archive = st.Backup.Archive v.Mode = st.Backup.Mode v.StopMode = st.Backup.Mode == "stop" v.Target = backupTargetLabel(st.Backup) if t, perr := time.Parse(time.RFC3339, st.Backup.StartedAt); perr == nil { v.StartedAt = t } } // Due window (best-effort; a failure just leaves the due fields zero). if due, derr := client.BackupDue(ctx); derr == nil { v.Due = due.Due v.DueReason = due.Reason if due.AgeSecs != nil { v.AgeHours = *due.AgeSecs / 3600 } } // Restore-test (the "verified restorable" trust signal; nil until one runs). if rt, rerr := client.RestoreTestStatus(ctx); rerr == nil && rt != nil { v.HasRestoreTest = true v.RestorePass = rt.Pass v.RestoreVerified = rt.Verified if t, perr := time.Parse(time.RFC3339, rt.TestedAt); perr == nil { v.RestoreTestedAt = t } } return v } // backupTargetLabel maps the agent's backup target to a customer-facing Hungarian label, surfacing // whether the backup landed on the PBS offsite tier or local host storage (from the archive volid / // target id — "felhom-pbs"/"pbs:" ⇒ PBS, else local host storage). func backupTargetLabel(b *agentapi.BackupRecord) string { id := strings.ToLower(b.TargetID) if strings.Contains(id, "pbs") || strings.HasPrefix(strings.ToLower(b.Archive), "felhom-pbs") || strings.Contains(strings.ToLower(b.Archive), "pbs:") { return "Biztonsági szerver (PBS)" } if b.TargetID != "" { return "Helyi tároló (" + b.TargetID + ")" } return "Helyi tároló" } // ServeBackupAPI dispatches /api/guest-backup/* (whole-guest manual trigger + status poll). A // distinct prefix from apiRouter's app-data /api/backup/{run,status}. Wired behind RequireAuth + // CsrfProtect in main.go. func (s *Server) ServeBackupAPI(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/api/guest-backup/trigger" && r.Method == http.MethodPost: s.handleBackupTriggerAPI(w, r) case r.URL.Path == "/api/guest-backup/status" && r.Method == http.MethodGet: s.handleBackupStatusAPI(w, r) default: http.NotFound(w, r) } } // handleBackupTriggerAPI starts an app-consistent whole-guest backup NOW via the quiesce loop. It // returns immediately (the backup runs async, minutes); the page polls /api/backup/status. func (s *Server) handleBackupTriggerAPI(w http.ResponseWriter, r *http.Request) { if s.backupTrigger == nil { writeDiskJSON(w, http.StatusServiceUnavailable, false, "a rendszermentés nem érhető el ezen a gépen", nil) return } if err := s.backupTrigger.TriggerNow(); err != nil { if errors.Is(err, quiesce.ErrBackupInProgress) { writeDiskJSON(w, http.StatusConflict, false, "mentés már folyamatban van", nil) return } s.logger.Printf("[ERROR] [web] backup trigger failed: %v", err) writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil) return } s.logger.Printf("[INFO] [web] manual whole-guest backup triggered (quiesce loop)") writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"started": true}) } // handleBackupStatusAPI proxies the agent's GET /backup/status for the page's progress poll. func (s *Server) handleBackupStatusAPI(w http.ResponseWriter, r *http.Request) { client, err := s.agentClient() if err != nil { writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil) return } st, err := client.BackupStatus(r.Context()) if err != nil { writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil) return } writeDiskJSON(w, http.StatusOK, true, "", map[string]any{ "phase": st.Phase, "job_id": st.JobID, "error": st.Error, "backup": st.Backup, }) }