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:
2026-06-12 11:15:25 +02:00
parent cd76afeca1
commit bbed5af662
10 changed files with 558 additions and 15 deletions
+163
View File
@@ -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,
})
}
+3
View File
@@ -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)
+18
View File
@@ -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}}&#10003;{{else}}&#10007;{{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}}&#10003;{{else}}&#10007;{{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 {