diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b873a8..4544da8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ ## Changelog +### v0.47.0 — backups page: whole-guest backup visibility + manual trigger (agent-sourced) (2026-06-12) + +The backups page previously showed only the app-data (DB-dump) tier and had **zero** view of the +agent's whole-guest PBS/vzdump backup. Adds visibility + a manual trigger over the agent's existing +per-guest backup API (no agent change). Cadence/retention CONFIG stays out (hub-served policy, slice 10). + +- **agentapi (2A):** `StatusResponse` gains `Backup *BackupRecord` (the agent's latest recorded + whole-guest backup — target/archive/mode/size/success/started-at); `DueResponse` gains `age_seconds`; + new `RestoreTestStatus()` → `*RestoreTestRecord` (the "verified restorable" signal, nil until one + runs). Non-hollow client tests (`backup_test.go`): parse the documented JSON + assert `StartBackup` + POSTs to `/backup`. +- **Section "Rendszermentés (teljes mentés)" (2B):** new read-only cards above the app-data section — + last whole-guest backup (time + size + **target: PBS vs Helyi (local)**, surfaced from the archive + volid/target-id), next-due (from `/backup/due` age vs cadence), restore-test result, and the running + phase. Agent-unconfigured/unreachable degrades to a note, page still renders. +- **Manual trigger "Mentés most" (2C):** **the controller owns quiescing** (confirmed: the + `quiesce.Loop` stops stacks → `POST /backup` → polls → resumes; the agent's vzdump is crash-consistent + only). The button therefore goes **through the loop**, not a bare agent call. `quiesce.Loop` gains a + mutex + `TriggerNow()` (single-flight via `TryLock` + the existing marker; `ErrBackupInProgress` on + overlap) that runs the same stop→backup→resume cycle async, bypassing the due-check. New + `POST /api/guest-backup/trigger` + `GET /api/guest-backup/status` (distinct prefix from apiRouter's + app-data `/api/backup/{run,status}` to avoid shadowing). The button warns per mode (snapshot ≈ a few + seconds' downtime on lvm-thin; stop = full downtime). +- **App-data section (2D):** the existing per-app DB-dump rows/table are now under a clear + "Alkalmazás-mentések (adatbázis + konfiguráció)" divider, distinct from the whole-guest tier above + (whole-guest = appliance restore; app-backup = granular per-app). No structural change. +- **Config (2E):** OUT OF SCOPE — whole-guest cadence/retention is hub-served policy (slice 10), so it + survives re-provision; no agent config surface added. + ### v0.46.0 — fix: /backups 500 (template referenced disk-tier fields stripped in 8C) (2026-06-12) `GET /backups` returned **HTTP 500**. Root cause (from the live log, not guessed): diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 789ec1a..834e10b 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -178,7 +178,7 @@ func main() { // --- Quiesce loop (slice 8B): app-consistent backup around the agent vzdump --- // Runs only when the local API is configured (a provisioned guest) and quiesce is enabled. // Recover FIRST (restart any stacks left stopped by a crash mid-quiesce), then start the loop. - startQuiesceLoop(ctx, cfg, stackMgr, logger) + quiesceLoop := startQuiesceLoop(ctx, cfg, stackMgr, logger) // --- Start CPU collector --- cpuCollector := system.NewCPUCollector(5 * time.Second) @@ -607,6 +607,9 @@ func main() { webServer.SetEncryptionKey(encKey) webServer.SetAppExporter(appExporter) webServer.SetIntegrationManager(integrationMgr) + if quiesceLoop != nil { + webServer.SetBackupTrigger(quiesceLoop) // "Mentés most" → app-consistent backup via the quiesce loop + } if assetsSyncer != nil { webServer.SetAssetsSyncer(assetsSyncer) } @@ -679,6 +682,9 @@ func main() { mux.Handle("/api/disks/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeDiskAPI)))) // Guided storage provisioning (init/attach/eject orchestration over the agent disk API + registry). mux.Handle("/api/storage/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeStorageAPI)))) + // Whole-guest (appliance) backup visibility + manual trigger. Distinct prefix from apiRouter's + // app-data /api/backup/{run,status} (DB dumps) to avoid shadowing the /api/ catch-all subtree. + mux.Handle("/api/guest-backup/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeBackupAPI)))) // Host metrics API — thin proxy to the host agent (slice 9). Read-only host-wide health + // per-storage capacity for the monitoring view; the de-privileged controller can't read the // host itself. GET only, so no CSRF wrapper needed. @@ -1070,18 +1076,18 @@ func (b quiesceBackend) BackupStatus(ctx context.Context) (string, error) { // startQuiesceLoop wires + starts the slice-8B quiesce loop when the local API is configured and // quiesce is enabled. It Recovers (restarts stacks left stopped by a mid-quiesce crash) before // starting the loop goroutine. Non-fatal: any misconfig disables the loop with a log line. -func startQuiesceLoop(ctx context.Context, cfg *config.Config, stackMgr *stacks.Manager, logger *log.Logger) { +func startQuiesceLoop(ctx context.Context, cfg *config.Config, stackMgr *stacks.Manager, logger *log.Logger) *quiesce.Loop { if cfg.LocalAPI.Endpoint == "" || cfg.LocalAPI.Token == "" { - return // not a provisioned guest — no agent to back up against + return nil // not a provisioned guest — no agent to back up against } if !cfg.Quiesce.QuiesceEnabled() { logger.Printf("[INFO] [quiesce] disabled by config") - return + return nil } client, err := agentapi.New(cfg.LocalAPI.Endpoint, cfg.LocalAPI.Token, cfg.LocalAPI.Fingerprint) if err != nil { logger.Printf("[WARN] [quiesce] disabled (agent client init failed): %v", err) - return + return nil } poll := parseDurationOr(cfg.Quiesce.PollInterval, 5*time.Minute) statusPoll := parseDurationOr(cfg.Quiesce.StatusPoll, 10*time.Second) @@ -1097,6 +1103,7 @@ func startQuiesceLoop(ctx context.Context, cfg *config.Config, stackMgr *stacks. }) loop.Recover() // crash-safety: restart any stacks stranded-down by a mid-quiesce crash go loop.Run(ctx) + return loop } // parseDurationOr parses a duration string, falling back to def on empty/invalid input. diff --git a/controller/internal/agentapi/backup_test.go b/controller/internal/agentapi/backup_test.go new file mode 100644 index 0000000..12573c0 --- /dev/null +++ b/controller/internal/agentapi/backup_test.go @@ -0,0 +1,130 @@ +package agentapi + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// backupStub serves the agent's backup endpoints with the documented payload shapes so the client's +// parse + the trigger POST are asserted against real JSON (non-hollow). +func backupStub(t *testing.T, started chan<- struct{}) (*httptest.Server, string) { + mux := http.NewServeMux() + mux.HandleFunc("GET /backup/status", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"phase":"done","job_id":"backup-9201-1",` + + `"backup":{"target_id":"local","vmid":9201,"archive":"local:backup/vzdump-lxc-9201-x.tar.zst",` + + `"mode":"snapshot","crash_consistent":true,"size_bytes":1399160221,"success":true,` + + `"started_at":"2026-06-12T07:37:41Z","duration_seconds":31.14}}}`)) + }) + mux.HandleFunc("GET /backup/due", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"due":false,"reason":"within cadence window","age_seconds":5234}}`)) + }) + mux.HandleFunc("GET /restore-test/status", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"ok":true,"data":{"restore_test":{"source_archive":"local:backup/x","source_tier":"local",` + + `"pass":true,"verified":"boot+running","tested_at":"2026-06-11T03:00:00Z","duration_seconds":42.0}}}`)) + }) + mux.HandleFunc("POST /backup", func(w http.ResponseWriter, r *http.Request) { + if started != nil { + started <- struct{}{} + } + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"job_id":"backup-9201-2","phase":"running"}}`)) + }) + s := httptest.NewTLSServer(mux) + return s, strings.TrimPrefix(s.URL, "https://") +} + +func TestBackupStatus_ParsesRecord(t *testing.T) { + s, ep := backupStub(t, nil) + defer s.Close() + c := clientFor(t, s, ep) + + st, err := c.BackupStatus(context.Background()) + if err != nil { + t.Fatalf("BackupStatus: %v", err) + } + if st.Phase != "done" || st.JobID != "backup-9201-1" { + t.Fatalf("status fields: %+v", st) + } + if st.Backup == nil { + t.Fatal("expected a backup record") + } + if st.Backup.TargetID != "local" || st.Backup.Mode != "snapshot" || !st.Backup.Success { + t.Fatalf("backup record: %+v", st.Backup) + } + if st.Backup.SizeBytes != 1399160221 || st.Backup.Archive == "" || st.Backup.StartedAt == "" { + t.Fatalf("backup record details: %+v", st.Backup) + } +} + +func TestBackupDue_Parses(t *testing.T) { + s, ep := backupStub(t, nil) + defer s.Close() + c := clientFor(t, s, ep) + + due, err := c.BackupDue(context.Background()) + if err != nil { + t.Fatalf("BackupDue: %v", err) + } + if due.Due || due.Reason != "within cadence window" { + t.Fatalf("due: %+v", due) + } +} + +func TestRestoreTestStatus_Parses(t *testing.T) { + s, ep := backupStub(t, nil) + defer s.Close() + c := clientFor(t, s, ep) + + rt, err := c.RestoreTestStatus(context.Background()) + if err != nil { + t.Fatalf("RestoreTestStatus: %v", err) + } + if rt == nil { + t.Fatal("expected a restore-test record") + } + if !rt.Pass || rt.Verified != "boot+running" || rt.SourceTier != "local" { + t.Fatalf("restore-test: %+v", rt) + } +} + +func TestRestoreTestStatus_Null(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /restore-test/status", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"ok":true,"data":{"restore_test":null}}`)) + }) + s := httptest.NewTLSServer(mux) + defer s.Close() + c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://")) + + rt, err := c.RestoreTestStatus(context.Background()) + if err != nil { + t.Fatalf("RestoreTestStatus(null): %v", err) + } + if rt != nil { + t.Fatalf("expected nil restore-test when none has run, got %+v", rt) + } +} + +// StartBackup must POST to /backup (the manual-trigger contract goes through this). +func TestStartBackup_PostsToBackup(t *testing.T) { + started := make(chan struct{}, 1) + s, ep := backupStub(t, started) + defer s.Close() + c := clientFor(t, s, ep) + + resp, err := c.StartBackup(context.Background()) + if err != nil { + t.Fatalf("StartBackup: %v", err) + } + select { + case <-started: + default: + t.Fatal("StartBackup did not POST to /backup") + } + if resp.JobID != "backup-9201-2" || resp.Phase != "running" { + t.Fatalf("StartBackup response: %+v", resp) + } +} diff --git a/controller/internal/agentapi/client.go b/controller/internal/agentapi/client.go index 555d7bc..fc0e0d5 100644 --- a/controller/internal/agentapi/client.go +++ b/controller/internal/agentapi/client.go @@ -105,11 +105,13 @@ func (c *Client) Storage(ctx context.Context) (StorageResponse, error) { // ---- slice 8B: app-consistent backup (quiesce loop) ------------------------------------- -// DueResponse mirrors the agent's GET /backup/due payload. +// DueResponse mirrors the agent's GET /backup/due payload. AgeSecs is the age of the newest +// successful backup (nil when none has run yet). type DueResponse struct { - VMID int `json:"vmid"` - Due bool `json:"due"` - Reason string `json:"reason"` + VMID int `json:"vmid"` + Due bool `json:"due"` + Reason string `json:"reason"` + AgeSecs *int64 `json:"age_seconds"` } // BackupResponse mirrors the agent's POST /backup payload. @@ -119,12 +121,42 @@ type BackupResponse struct { Phase string `json:"phase"` } -// StatusResponse mirrors the agent's GET /backup/status payload. +// StatusResponse mirrors the agent's GET /backup/status payload. Backup is the latest RECORDED +// whole-guest backup (nil until one has run), surfaced to the controller's backup page for visibility. type StatusResponse struct { - VMID int `json:"vmid"` - Phase string `json:"phase"` // idle | running | done | failed - JobID string `json:"job_id"` - Error string `json:"error"` + VMID int `json:"vmid"` + Phase string `json:"phase"` // idle | running | snapshotted | done | failed + JobID string `json:"job_id"` + Error string `json:"error"` + Backup *BackupRecord `json:"backup,omitempty"` +} + +// BackupRecord mirrors the agent's hub.Backup — one whole-guest vzdump/PBS backup result. The +// controller renders it read-only (it does NOT own whole-guest backup; the agent does). +type BackupRecord struct { + TargetID string `json:"target_id"` // backup storage name (e.g. "local", "felhom-pbs") + VMID int `json:"vmid"` + Archive string `json:"archive"` // produced vzdump volid (e.g. "local:backup/vzdump-lxc-…") + Mode string `json:"mode"` // snapshot | stop + CrashConsistent bool `json:"crash_consistent"` + SizeBytes int64 `json:"size_bytes"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + StartedAt string `json:"started_at"` // RFC3339 + DurationSeconds float64 `json:"duration_seconds"` +} + +// RestoreTestRecord mirrors the agent's hub.RestoreTest — the latest self-restore-test (the "backup +// verified restorable" trust signal). Nil when none has run yet. +type RestoreTestRecord struct { + SourceArchive string `json:"source_archive"` + SourceTier string `json:"source_tier"` // "local" (pbs = Phase B) + Pass bool `json:"pass"` + Verified string `json:"verified"` // "boot+running" this slice + Error string `json:"error,omitempty"` + TestedAt string `json:"tested_at"` // RFC3339 + DurationSeconds float64 `json:"duration_seconds"` + Warnings []string `json:"warnings,omitempty"` } // Backup status phases (mirror the agent's vocabulary). @@ -174,6 +206,22 @@ func (c *Client) BackupStatus(ctx context.Context) (StatusResponse, error) { return out, nil } +// RestoreTestStatus calls GET /restore-test/status and returns the latest self-restore-test result +// (nil when none has run yet — the agent payload is {"restore_test": {...}|null}). +func (c *Client) RestoreTestStatus(ctx context.Context) (*RestoreTestRecord, error) { + body, err := c.get(ctx, "/restore-test/status") + if err != nil { + return nil, err + } + var out struct { + RestoreTest *RestoreTestRecord `json:"restore_test"` + } + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("agentapi: decode /restore-test/status: %w", err) + } + return out.RestoreTest, nil +} + // ---- slice 8C: disk management (execution is the agent's) -------------------------------- // DiskInfo mirrors the agent's GET /disks entry. diff --git a/controller/internal/quiesce/quiesce.go b/controller/internal/quiesce/quiesce.go index c792f2b..6eab642 100644 --- a/controller/internal/quiesce/quiesce.go +++ b/controller/internal/quiesce/quiesce.go @@ -13,13 +13,19 @@ package quiesce import ( "context" "encoding/json" + "errors" "fmt" "log" "os" "path/filepath" + "sync" "time" ) +// ErrBackupInProgress is returned by TriggerNow when a scheduled or manual quiesce cycle is already +// running (single-flight). The caller (the "Mentés most" handler) surfaces it as a benign 409. +var ErrBackupInProgress = errors.New("quiesce: a backup cycle is already in progress") + // Backend is the agent local-API surface the loop needs (satisfied by an adapter over // *agentapi.Client). Kept minimal (bool/string) so the loop is testable with plain fakes. type Backend interface { @@ -74,6 +80,10 @@ type Loop struct { maxQuiesce time.Duration logger *log.Logger now func() time.Time + // mu single-flights the quiesce cycle across the scheduled loop AND the manual trigger, so the + // two can never stop the same stacks concurrently (the persisted marker covers crash-safety across + // restarts; this covers concurrency within the process — which a manual trigger introduces). + mu sync.Mutex } // New builds a Loop with sane defaults for any unset duration. @@ -135,8 +145,15 @@ func (l *Loop) Run(ctx context.Context) { // is guaranteed via the deferred closure: a backup error, a status-poll error, the max-quiesce // bound, or context cancellation all still restart the stacks and clear the marker. func (l *Loop) runOnce(ctx context.Context) error { + // Single-flight: skip the scheduled check if a cycle (scheduled or manual) is already running. + if !l.mu.TryLock() { + l.logger.Printf("[INFO] [quiesce] a backup cycle is already running — skipping this scheduled check") + return nil + } + defer l.mu.Unlock() + // Defensive single-flight: never quiesce on top of an active marker (Recover clears one left - // by a crash; within a process the single loop goroutine already serializes). + // by a crash; the mutex above serializes within the process). if m, ok := l.readMarker(); ok && m.Active { l.logger.Printf("[WARN] [quiesce] a marker is already active — skipping this cycle") return nil @@ -150,6 +167,42 @@ func (l *Loop) runOnce(ctx context.Context) error { return nil } + return l.quiesceAndPoll(ctx) +} + +// TriggerNow forces an app-consistent backup NOW (the manual "Mentés most" action), bypassing the +// /backup/due check. It runs the SAME quiesce flow the scheduled loop uses (stop stacks → POST +// /backup → poll → resume), so it is app-consistent and crash-safe (marker-protected). Single-flight +// via the same mutex: it returns ErrBackupInProgress if a scheduled or manual cycle is already +// running. The cycle runs ASYNCHRONOUSLY (it can take minutes) on a background context bounded by +// maxQuiesce; the caller polls /backup/status for progress. The controller — not the agent — owns +// quiescing (the agent's vzdump is crash-consistent only), so this MUST go through the loop. +func (l *Loop) TriggerNow() error { + if !l.mu.TryLock() { + return ErrBackupInProgress + } + if m, ok := l.readMarker(); ok && m.Active { + l.mu.Unlock() + return ErrBackupInProgress + } + go func() { + defer l.mu.Unlock() + // Detached from any request context; bounded so a hung backup still unquiesces. + ctx, cancel := context.WithTimeout(context.Background(), l.maxQuiesce+5*time.Minute) + defer cancel() + l.logger.Printf("[INFO] [quiesce] manual backup requested — quiescing now") + if err := l.quiesceAndPoll(ctx); err != nil { + l.logger.Printf("[ERROR] [quiesce] manual backup cycle error: %v", err) + } + }() + return nil +} + +// quiesceAndPoll performs the marked, guaranteed-unquiesce cycle: write marker → stop running app +// stacks → POST /backup → poll /backup/status → restart exactly the stacks it stopped. The caller +// MUST hold l.mu. Unquiesce is guaranteed via the deferred closure (backup error, status-poll error, +// the max-quiesce bound, or context cancellation all still restart the stacks and clear the marker). +func (l *Loop) quiesceAndPoll(ctx context.Context) error { running := l.stacks.RunningAppStacks() marker := Marker{Active: true, StartedAt: l.now(), StoppedStacks: running} if err := l.writeMarker(marker); err != nil { diff --git a/controller/internal/web/backup_handlers.go b/controller/internal/web/backup_handlers.go new file mode 100644 index 0000000..2e67b3f --- /dev/null +++ b/controller/internal/web/backup_handlers.go @@ -0,0 +1,163 @@ +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, + }) +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 395fe93..9faee58 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -512,6 +512,9 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) { data["SystemInfo"] = system.GetInfo(s.primaryHDDPath(), s.cpuCollector) data["StorageBars"] = s.buildStorageBars() + // Whole-guest backup view (agent-sourced, read-only) for the "Rendszermentés" section. + data["GuestBackup"] = s.loadGuestBackup(r.Context()) + if s.backupMgr != nil { nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule) fullStatus := s.backupMgr.GetFullStatus(nextDBDump) diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 10de0d9..5540643 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -62,6 +62,11 @@ type Server struct { // App export/import engine (optional) appExporter *appexport.Exporter + // Whole-guest backup trigger (the quiesce loop; optional — nil on an unprovisioned guest or when + // quiesce is disabled). Drives the "Mentés most" button: the CONTROLLER owns quiescing, so the + // manual trigger goes through the loop (stop stacks → backup → resume), never a bare agent call. + backupTrigger BackupTrigger + // Debug mode support logBuffer *LogBuffer debugCallbacks *DebugCallbacks @@ -172,6 +177,19 @@ func (s *Server) SetAppExporter(e *appexport.Exporter) { s.appExporter = e } +// BackupTrigger forces an app-consistent whole-guest backup NOW (the quiesce loop satisfies it). +// Returns quiesce.ErrBackupInProgress when a cycle is already running (single-flight). +type BackupTrigger interface { + TriggerNow() error +} + +// SetBackupTrigger wires the whole-guest backup trigger (the quiesce loop) for the "Mentés most" +// button. Optional — left nil on an unprovisioned guest or when quiesce is disabled (the button then +// renders disabled with an explanatory note). +func (s *Server) SetBackupTrigger(t BackupTrigger) { + s.backupTrigger = t +} + // SetStartTime records the controller start time for uptime calculation. func (s *Server) SetStartTime(t time.Time) { s.startTime = t diff --git a/controller/internal/web/templates/backups.html b/controller/internal/web/templates/backups.html index 55a90a2..0960fd8 100644 --- a/controller/internal/web/templates/backups.html +++ b/controller/internal/web/templates/backups.html @@ -69,6 +69,56 @@ + +{{with .GuestBackup}} +
+

Rendszermentés (teljes mentés)

+

A teljes szerver — alkalmazások, beállítások és adatbázisok együtt — időszakos mentése, amelyből az egész készülék visszaállítható. Ezt a host-ügynök készíti és kezeli.

+ {{if not .Available}} +
{{.Note}}
+ {{else}} +
+
+
{{if .HasBackup}}{{if .Success}}✓{{else}}✗{{end}}{{else}}–{{end}}
+
Utolsó teljes mentés + {{if .HasBackup}}
{{fmtTime .StartedAt}} ({{timeAgo .StartedAt}}){{end}} +
+
+
+
{{if .HasBackup}}{{fmtBytes .SizeBytes}}{{else}}–{{end}}
+
{{if .HasBackup}}{{.Target}}{{else}}Méret / cél{{end}}
+
+
+
{{if .Due}}Esedékes{{else}}Naprakész{{end}}
+
Következő mentés + {{if not .Due}}
{{.AgeHours}} órája — a mentési ablakon belül{{end}} +
+
+
+
{{if .HasRestoreTest}}{{if .RestorePass}}✓{{else}}✗{{end}}{{else}}–{{end}}
+
Visszaállítás ellenőrizve + {{if .HasRestoreTest}}
{{fmtTime .RestoreTestedAt}}{{else}}
Még nem futott{{end}} +
+
+
+ {{if .Running}} +
Mentés folyamatban… (fázis: {{.Phase}})
+ {{end}} + {{if .CanTrigger}} +
+ + {{if .StopMode}}⚠ A mentés idejére az alkalmazások rövid időre leállnak. + {{else}}Pillanatkép-mód: az alkalmazások csak néhány másodpercre állnak le.{{end}} +
+
+ {{end}} + {{end}} +
+{{end}} + +

Alkalmazás-mentések (adatbázis + konfiguráció)

+

Az egyes alkalmazások részletes, granulált mentése — adatbázis-kiírások, beállítások és alkalmazás-fájlok. A fenti teljes mentéstől függetlenül, alkalmazásonként visszaállítható.

+
{{if .Backup.LastDBDump}} @@ -507,6 +557,43 @@ function startBackupPolling() { }, 3000); } +// Whole-guest (appliance) backup — manual trigger via the quiesce loop (controller-owned quiesce: +// stop stacks → agent vzdump → resume). Returns immediately; we poll /api/guest-backup/status. +function triggerGuestBackup() { + if (!confirm('Elindítja a teljes rendszermentést most? A mentés ideje alatt az alkalmazások rövid időre leállhatnak.')) return; + var btn = document.getElementById('wg-backup-btn'); + var out = document.getElementById('wg-backup-result'); + btn.disabled = true; + out.innerHTML = 'Mentés indítása…'; + fetch('/api/guest-backup/trigger', { method: 'POST', headers: Object.assign({'Content-Type':'application/json'}, csrfHeaders()) }) + .then(r => r.json()) + .then(data => { + if (!data.ok) { out.innerHTML = '
' + (data.error || 'Hiba') + '
'; btn.disabled = false; return; } + out.innerHTML = 'Mentés folyamatban…'; + pollGuestBackup(out, btn); + }) + .catch(e => { out.innerHTML = '
Hiba: ' + e + '
'; btn.disabled = false; }); +} + +function pollGuestBackup(out, btn) { + var tries = 0; + var iv = setInterval(function () { + tries++; + fetch('/api/guest-backup/status') + .then(r => r.json()) + .then(data => { + if (data.ok && data.data) { + var ph = data.data.phase; + out.innerHTML = 'Állapot: ' + ph + ''; + if (ph === 'done') { clearInterval(iv); out.innerHTML = '
✅ A rendszermentés elkészült.
'; setTimeout(() => window.location.reload(), 1500); } + else if (ph === 'failed') { clearInterval(iv); out.innerHTML = '
A mentés sikertelen.
'; btn.disabled = false; } + } + }) + .catch(() => {}); + if (tries > 180) { clearInterval(iv); btn.disabled = false; } // ~15 min cap at 5s polls + }, 5000); +} + // Restic password toggle/copy function toggleResticPw() { var el = document.getElementById('restic-pw'); diff --git a/controller/internal/web/templates/style.css b/controller/internal/web/templates/style.css index d938b8d..422858d 100644 --- a/controller/internal/web/templates/style.css +++ b/controller/internal/web/templates/style.css @@ -3125,6 +3125,11 @@ a.stat-card:hover { .badge-lock { background: rgba(210, 153, 34, 0.18); color: var(--yellow); } .badge-muted { background: rgba(110, 118, 129, 0.18); color: var(--text-muted); } .badge-info { background: rgba(0, 136, 204, 0.18); color: var(--accent-light); } +/* Backup-page tier divider between the whole-guest section and the per-app section. */ +.backup-tier-divider { + margin: 2rem 0 0.25rem; padding-top: 1.25rem; + border-top: 1px solid var(--border-color); font-size: 1.05rem; +} /* Per-card storage purpose description + the tiering one-liner above the drive list. */ .drive-purpose { font-size: .8rem; color: var(--text-secondary); line-height: 1.4; } .drive-tiering-note {