controller v0.47.0: backups page — whole-guest backup visibility + manual trigger
Part 2 of the USB/backup spec. agentapi: StatusResponse.Backup record, DueResponse
age_seconds, RestoreTestStatus(). New "Rendszermentés (teljes mentés)" section
(read-only: last backup/target PBS-vs-local/next-due/restore-test) + "Mentés most"
manual trigger that goes through the quiesce loop (controller owns quiescing):
quiesce.Loop gains mutex + TriggerNow() (single-flight, async). New
/api/guest-backup/{trigger,status} (distinct from apiRouter's /api/backup/*).
App-data rows relabeled under an "Alkalmazás-mentések" divider. Config → slice 10.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -69,6 +69,56 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section: Whole-guest (appliance) backup — agent-sourced, read-only + manual trigger -->
|
||||
{{with .GuestBackup}}
|
||||
<div class="backup-section-card">
|
||||
<h3>Rendszermentés (teljes mentés)</h3>
|
||||
<p class="form-hint" style="margin-bottom:1rem">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.</p>
|
||||
{{if not .Available}}
|
||||
<div class="alert alert-info">{{.Note}}</div>
|
||||
{{else}}
|
||||
<div class="stats-grid backup-page-cards">
|
||||
<div class="stat-card {{if .HasBackup}}{{if .Success}}stat-running{{else}}stat-stopped{{end}}{{end}}">
|
||||
<div class="stat-value">{{if .HasBackup}}{{if .Success}}✓{{else}}✗{{end}}{{else}}–{{end}}</div>
|
||||
<div class="stat-label">Utolsó teljes mentés
|
||||
{{if .HasBackup}}<br><span class="relative-time">{{fmtTime .StartedAt}} ({{timeAgo .StartedAt}})</span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-total">
|
||||
<div class="stat-value">{{if .HasBackup}}{{fmtBytes .SizeBytes}}{{else}}–{{end}}</div>
|
||||
<div class="stat-label">{{if .HasBackup}}{{.Target}}{{else}}Méret / cél{{end}}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="font-size:1.15rem">{{if .Due}}Esedékes{{else}}Naprakész{{end}}</div>
|
||||
<div class="stat-label">Következő mentés
|
||||
{{if not .Due}}<br><span class="relative-time">{{.AgeHours}} órája — a mentési ablakon belül</span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{if .HasRestoreTest}}{{if .RestorePass}}✓{{else}}✗{{end}}{{else}}–{{end}}</div>
|
||||
<div class="stat-label">Visszaállítás ellenőrizve
|
||||
{{if .HasRestoreTest}}<br><span class="relative-time">{{fmtTime .RestoreTestedAt}}</span>{{else}}<br><span class="relative-time">Még nem futott</span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{if .Running}}
|
||||
<div class="alert alert-info" id="wg-running">Mentés folyamatban… (fázis: <span id="wg-phase">{{.Phase}}</span>)</div>
|
||||
{{end}}
|
||||
{{if .CanTrigger}}
|
||||
<div class="schedule-actions" style="margin-top:1rem">
|
||||
<button class="btn btn-sm btn-primary" id="wg-backup-btn" onclick="triggerGuestBackup()" {{if .Running}}disabled{{end}}>Mentés most</button>
|
||||
{{if .StopMode}}<span class="form-hint" style="margin-left:.5rem">⚠ A mentés idejére az alkalmazások rövid időre leállnak.</span>
|
||||
{{else}}<span class="form-hint" style="margin-left:.5rem">Pillanatkép-mód: az alkalmazások csak néhány másodpercre állnak le.</span>{{end}}
|
||||
<div id="wg-backup-result" style="margin-top:.6rem"></div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<h3 class="backup-tier-divider">Alkalmazás-mentések (adatbázis + konfiguráció)</h3>
|
||||
<p class="form-hint" style="margin:-0.25rem 0 1rem">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ó.</p>
|
||||
|
||||
<!-- Section 1: Status overview cards -->
|
||||
<div class="stats-grid backup-page-cards">
|
||||
{{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 = '<span class="form-hint">Mentés indítása…</span>';
|
||||
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 = '<div class="alert alert-error">' + (data.error || 'Hiba') + '</div>'; btn.disabled = false; return; }
|
||||
out.innerHTML = '<span class="form-hint">Mentés folyamatban…</span>';
|
||||
pollGuestBackup(out, btn);
|
||||
})
|
||||
.catch(e => { out.innerHTML = '<div class="alert alert-error">Hiba: ' + e + '</div>'; 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 = '<span class="form-hint">Állapot: ' + ph + '</span>';
|
||||
if (ph === 'done') { clearInterval(iv); out.innerHTML = '<div class="flash flash-success">✅ A rendszermentés elkészült.</div>'; setTimeout(() => window.location.reload(), 1500); }
|
||||
else if (ph === 'failed') { clearInterval(iv); out.innerHTML = '<div class="alert alert-error">A mentés sikertelen.</div>'; 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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user