package web import ( "context" "errors" "net/http" "regexp" "strings" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" "gitea.dooplex.hu/admin/felhom-controller/internal/backupwindow" "gitea.dooplex.hu/admin/felhom-controller/internal/quiesce" "gitea.dooplex.hu/admin/felhom-controller/internal/scheduler" ) // effectiveBackupWindow resolves the active backup-window start (settings > controller.yaml > // "02:30") for this server. Every nightly-leg display and the DB-dump next-run derive from it. func (s *Server) effectiveBackupWindow() string { return backupwindow.EffectiveWindow(s.settings.GetBackupWindowStart(), s.cfg.Backup.DBDumpSchedule) } // backupWindowData injects the customer-configurable-window view onto the Áttekintés page: the // effective start, the three derived leg times (DB / helyi másolat / távoli mentés), and the // whole-guest gate span [W+2h, W+6h). The offsets are DERIVED here, never stored. func (s *Server) backupWindowData(data map[string]interface{}) { win := s.effectiveBackupWindow() db, tier2, offbox := backupwindow.LegTimes(win) from, to := backupwindow.GateWindow(win) data["BackupWindow"] = win data["BackupLegDB"] = db data["BackupLegTier2"] = tier2 data["BackupLegOffbox"] = offbox data["BackupGateFrom"] = from data["BackupGateTo"] = to } // backupWindowSaveHandler persists a new backup-window start and fans it out to the three daily legs // live (no restart) via UpdateDaily. POST /backups/window (behind RequireAuth + CsrfProtect). On an // invalid time nothing is stored and the jobs are untouched. func (s *Server) backupWindowSaveHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() start := strings.TrimSpace(r.FormValue("window_start")) if backupwindow.Valid(start) != nil { s.backupWindowRedirect(w, r, "", "flash.backup.window_invalid_time") return } if err := s.settings.SetBackupWindowStart(start); err != nil { s.logger.Printf("[ERROR] [web] backup window save failed: %v", err) s.backupWindowRedirect(w, r, "", "flash.backup.window_save_failed") return } // Fan out to the three daily legs at their fixed offsets — takes effect at the next scheduling // pass (no restart). The scheduler wakes each job via its reschedule signal. db, tier2, offbox := backupwindow.LegTimes(start) if s.scheduler != nil { s.scheduler.UpdateDaily("db-dump", db) s.scheduler.UpdateDaily("tier2-backup", tier2) s.scheduler.UpdateDaily("offbox-backup", offbox) } // Refresh the cached "next DB dump" so the display updates immediately, not at the next 5m tick. if s.backupMgr != nil { s.backupMgr.RefreshCache(scheduler.NextDailyRun(db)) } s.logger.Printf("[INFO] [web] backup window set to %s (legs %s/%s/%s)", start, db, tier2, offbox) s.backupWindowRedirect(w, r, "flash.backup.window_updated", "") } // backupWindowRedirect PRG-redirects back to the Áttekintés page with a success or error flash. func (s *Server) backupWindowRedirect(w http.ResponseWriter, r *http.Request, flash, flashErr string) { dest := "/backups" if flashErr != "" { dest += "?" + flashQuery("flash_error", flashErr) } else if flash != "" { dest += "?" + flashQuery("flash", flash) } http.Redirect(w, r, dest, http.StatusSeeOther) } // 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 – külön hardver (PBS)" / "Helyi tároló (local)" Offsite bool // the whole-guest backup landed on the PBS offsite tier (separate hardware) 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 // RestoreTier (R-727, v0.283.0) names the tier the last restore test read from, so a ✗ says WHERE — // measured 2026-09-30: a bare ✗ above the local tier's heading read as „the local tier failed" while // the PBS tier had. RestoreTier string CanTrigger bool // a backup trigger (quiesce loop) is wired // SizeUnknown (R-517): the shown success was read back from storage after an agent restart, so // its size is not known. The tile prints „–", never „0 B" (measured live on 9201, 2026-09-15). SizeUnknown bool // Tiers (R-517, agent >= v0.131.0) is the per-tier truth. Empty on an older agent — the tile then // renders the single latest record as before. Tiers []guestTierView } // guestTierView is one whole-guest tier as the customer sees it. A failed attempt is shown UNDER the // newest success, never instead of it ("presence is not success"). type guestTierView struct { Target string Label string // Hungarian tier name IsPBS bool NotSetUp bool // the tier's storage does not exist on the host → „nincs beállítva" HasSuccess bool SuccessAt time.Time SizeKnown bool // false when the success was read back from storage after a restart SizeBytes int64 Current bool // newest success is inside the tier's window FailedAfter bool // the last attempt failed and is newer than the newest success FailedAt time.Time // NoSpaceLine (v0.272.0, R-685 page half) is the plain sentence under a failed tier when the agent // SKIPPED the backup because it cannot fit (agent v0.134.0+), in the reader's language. NoSpaceLine string } // agentNoSpacePrefix is the agent's `backup.BackupSkipNoSpacePrefix` (felhom-agent v0.134.0, // internal/backup/runner.go) — the stable start of a tier attempt's Error when the space preflight refused. // A wire contract between two repos: change it in both or in neither. const agentNoSpacePrefix = "skipped: not enough space: " // agentNoSpaceRe reads the agent's sentence: " has X GiB free; … needs about Z GiB (…)". var agentNoSpaceRe = regexp.MustCompile(`has ([0-9.]+ GiB) free;.*needs about ([0-9.]+ GiB)`) // noSpaceLine renders the household's sentence for a space skip, "" when the attempt is not one. func noSpaceLine(errText string, msg func(key string, a ...interface{}) string) string { if !strings.HasPrefix(errText, agentNoSpacePrefix) { return "" } if m := agentNoSpaceRe.FindStringSubmatch(errText); m != nil { return msg("backup.tier.no_space", m[2], m[1]) } return msg("backup.tier.no_space_unknown") } // tierWindow is how old a tier's newest success may be and still count as current: its cadence plus // half again (a scheduled run lands inside the nightly window, not on the exact second). A tier with // no advertised cadence uses a day. func tierWindow(cadenceSecs int64) time.Duration { if cadenceSecs <= 0 { cadenceSecs = 24 * 3600 } return time.Duration(cadenceSecs) * time.Second * 3 / 2 } // buildTierViews turns the agent's per-tier state into page rows and derives the three legacy tile // fields from SUCCESSES only: HasBackup/Success/Size/Target/StartedAt from the primary tier's newest // success; Due when any set-up tier has no current success; Offsite only on a current PBS success. func buildTierViews(v *guestBackupView, tiers []agentapi.TierBackupState, cadence map[string]int64, now time.Time, msg func(key string, a ...interface{}) string) { v.Tiers = nil v.Due, v.Offsite, v.HasBackup = false, false, false var newestOK time.Time for _, t := range tiers { tv := guestTierView{Target: t.Target, IsPBS: strings.Contains(strings.ToLower(t.Target), "pbs")} if tv.IsPBS { tv.Label = msg("backup.tier.pbs") } else { tv.Label = msg("backup.tier.local", t.Target) } tv.NotSetUp = t.Storage == "absent" if t.LastSuccess != nil { if ts, err := time.Parse(time.RFC3339, t.LastSuccess.StartedAt); err == nil { tv.HasSuccess, tv.SuccessAt = true, ts tv.SizeKnown = t.LastSuccessSource != "storage" tv.SizeBytes = t.LastSuccess.SizeBytes tv.Current = now.Sub(ts) <= tierWindow(cadence[t.Target]) } } if a := t.LastAttempt; a != nil && !a.Success { if ts, err := time.Parse(time.RFC3339, a.StartedAt); err == nil && (!tv.HasSuccess || ts.After(tv.SuccessAt)) { tv.FailedAfter, tv.FailedAt = true, ts tv.NoSpaceLine = noSpaceLine(a.Error, msg) } } if !tv.NotSetUp && !tv.Current { v.Due = true } if tv.IsPBS && tv.Current && !tv.NotSetUp { v.Offsite = true } if tv.HasSuccess && (t.Primary || !v.HasBackup) && (t.Primary || tv.SuccessAt.After(newestOK)) { v.HasBackup, v.Success = true, true v.StartedAt, v.SizeBytes, v.Target = tv.SuccessAt, tv.SizeBytes, tv.Label v.SizeUnknown = !tv.SizeKnown newestOK = tv.SuccessAt } v.Tiers = append(v.Tiers, tv) } if v.Due { v.DueReason = "tier_not_current" } if v.HasBackup { v.AgeHours = int64(now.Sub(v.StartedAt).Hours()) } } // 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, lang string) *guestBackupView { msg := func(key string, a ...interface{}) string { return s.msgLang(lang, key, a...) } v := &guestBackupView{CanTrigger: s.backupTrigger != nil} client, err := s.agentClient() if err != nil { v.Note = msg("backup.guest.agent_unconfigured") return v } st, err := client.BackupStatus(ctx) if err != nil { v.Note = msg("backup.guest.agent_unreachable") 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, msg) v.Offsite = backupIsPBS(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 } } // R-517: an agent that speaks per tier replaces the single-record fields with per-tier truth. // Done AFTER the legacy fill so an older agent keeps exactly the old rendering. if len(st.Tiers) > 0 { cadence := map[string]int64{} if tr, terr := client.BackupTiers(ctx); terr == nil { for _, t := range tr.Tiers { cadence[t.Target] = t.CadenceSeconds } } buildTierViews(v, st.Tiers, cadence, time.Now(), msg) } // 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 switch strings.ToLower(rt.SourceTier) { case "": case "pbs", "felhom-pbs": v.RestoreTier = msg("backup.tier.pbs") default: v.RestoreTier = msg("backup.tier.local", rt.SourceTier) } if t, perr := time.Parse(time.RFC3339, rt.TestedAt); perr == nil { v.RestoreTestedAt = t } } return v } // backupIsPBS reports whether a whole-guest backup landed on the PBS offsite tier (separate // hardware), inferred from the target id / archive volid ("felhom-pbs"/"pbs:" ⇒ PBS). func backupIsPBS(b *agentapi.BackupRecord) bool { id := strings.ToLower(b.TargetID) arc := strings.ToLower(b.Archive) return strings.Contains(id, "pbs") || strings.HasPrefix(arc, "felhom-pbs") || strings.Contains(arc, "pbs:") } // backupTargetLabel maps the agent's backup target to a customer-facing Hungarian label. The PBS // case calls out that the backup is on SEPARATE HARDWARE (real disaster recovery — survives a host // disk/hardware failure), which is the whole point of re-pointing the backup offsite. func backupTargetLabel(b *agentapi.BackupRecord, msg func(key string, a ...interface{}) string) string { if backupIsPBS(b) { return msg("backup.tier.pbs") } if b.TargetID != "" { return msg("backup.tier.local", b.TargetID) } return msg("backup.tier.local_plain") } // 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, s.msg(r, "backup.guest.err.unavailable"), nil) return } if err := s.backupTrigger.TriggerNow(); err != nil { if errors.Is(err, quiesce.ErrBackupInProgress) { writeDiskJSON(w, http.StatusConflict, false, s.msg(r, "backup.guest.err.in_progress"), nil) return } s.logger.Printf("[ERROR] [web] backup trigger failed: %v", err) writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), 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, s.errText(r, err), nil) return } st, err := client.BackupStatus(r.Context()) if err != nil { writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil) return } writeDiskJSON(w, http.StatusOK, true, "", map[string]any{ "phase": st.Phase, "job_id": st.JobID, "error": st.Error, "backup": st.Backup, }) }