Phase 3 complete: per-app backup toggles, restore, storage overview

- Storage overview on backup page (SSD/HDD bars, repo stats)
- Restic password visibility + hub sync for disaster recovery
- App data discovery (HDD bind mounts, Docker volumes)
- Per-app backup toggle checkboxes with settings persistence
- Dynamic backup paths: enabled app HDD data included in restic snapshots
- Limited app restore from snapshots (self-service recovery)
- Snapshots API endpoint for restore dropdown
- Version bump to 0.8.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 21:29:56 +01:00
parent a3af7c6a2d
commit 7d801d1094
15 changed files with 1088 additions and 29 deletions
+81 -1
View File
@@ -237,14 +237,31 @@ func isPingConfigured(uuid string) bool {
return uuid != "" && !strings.HasPrefix(uuid, "CHANGEME")
}
func (s *Server) backupsHandler(w http.ResponseWriter, _ *http.Request) {
func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
data := s.baseData("backups", "Biztonsági mentés")
// System info for storage overview bars
data["SystemInfo"] = system.GetInfo(s.cfg.Paths.HDDPath, s.cpuCollector)
if s.backupMgr != nil {
nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
nextBackup := scheduler.NextDailyRun(s.cfg.Backup.ResticSchedule)
fullStatus := s.backupMgr.GetFullStatus(nextDBDump, nextBackup)
// Pass flash messages from query params (set by redirect handlers)
if flash := r.URL.Query().Get("flash"); flash != "" {
fullStatus.FlashSuccess = flash
}
if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" {
fullStatus.FlashError = flashErr
}
data["Backup"] = fullStatus
// Restic password for display
if pw, err := s.backupMgr.GetResticPassword(); err == nil {
data["ResticPassword"] = pw
}
} else {
data["Backup"] = nil
}
@@ -252,6 +269,69 @@ func (s *Server) backupsHandler(w http.ResponseWriter, _ *http.Request) {
s.render(w, "backups", data)
}
func (s *Server) settingsAppBackupHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
if s.backupMgr == nil {
http.Redirect(w, r, "/backups", http.StatusFound)
return
}
// Get current app data info to know which stacks have HDD data
nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
nextBackup := scheduler.NextDailyRun(s.cfg.Backup.ResticSchedule)
fullStatus := s.backupMgr.GetFullStatus(nextDBDump, nextBackup)
prefs := make(map[string]bool)
for _, app := range fullStatus.AppDataInfo {
if app.HasHDDData {
prefs[app.StackName] = r.FormValue("backup_"+app.StackName) == "on"
}
}
if err := s.settings.SetAppBackupBulk(prefs); err != nil {
s.logger.Printf("[ERROR] Failed to save app backup prefs: %v", err)
http.Redirect(w, r, "/backups?flash_error=Hiba+a+ment%C3%A9skor", http.StatusFound)
return
}
s.logger.Printf("[INFO] App backup preferences updated: %v", prefs)
// Trigger cache refresh so the page shows updated data
go s.backupMgr.RefreshCache(nextDBDump, nextBackup)
http.Redirect(w, r, "/backups?flash=Alkalmaz%C3%A1s+ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1sok+mentve.", http.StatusFound)
}
func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
stackName := r.FormValue("stack_name")
snapshotID := r.FormValue("snapshot_id")
if stackName == "" || snapshotID == "" {
http.Redirect(w, r, "/backups?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound)
return
}
if s.backupMgr == nil {
http.Redirect(w, r, "/backups?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound)
return
}
s.logger.Printf("[WARN] Restore requested: stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
if err := s.backupMgr.RestoreApp(stackName, snapshotID); err != nil {
s.logger.Printf("[ERROR] Restore failed: %v", err)
errMsg := url.QueryEscape("Visszaállítás sikertelen: " + err.Error())
http.Redirect(w, r, "/backups?flash_error="+errMsg, http.StatusFound)
return
}
msg := url.QueryEscape(stackName + " visszaállítva (" + snapshotID + ").")
http.Redirect(w, r, "/backups?flash="+msg, http.StatusFound)
}
func (s *Server) settingsData() map[string]interface{} {
data := s.baseData("settings", "Beállítások")
data["CustomerID"] = s.cfg.Customer.ID