v0.11.8 — Per-App Cross-Drive Backup (3-2-1 rule)
New feature: backup app data to a secondary storage drive to satisfy
the "different media" requirement of the 3-2-1 backup rule.
- settings.go: CrossDriveBackup struct, AppBackupPrefs.CrossDrive field,
getter/setter methods, GetOrCreateCrossDrivePassword, preserves
cross-drive config when toggling nightly backup
- crossdrive.go (new): CrossDriveRunner with rsync and restic backends.
Validates destination (mount point, writable), prevents source/dest
overlap, per-app concurrency lock, persists last_run/status/size.
- main.go: wire CrossDriveRunner, register cross-drive-daily (03:30)
and cross-drive-weekly (04:30 Sundays) scheduler jobs
- router.go: 4 new API endpoints — save config, trigger run, get status,
run-all. Router now accepts Settings and CrossDriveRunner.
- server.go: Server struct accepts CrossDriveRunner, new web route
POST /settings/cross-backup/{name}
- handlers.go: deployHandler populates CrossDriveConfig, BackupDestPaths,
BackupDestWarning, AppBackupEnabled. settingsCrossBackupHandler saves
config. backupsHandler builds CrossDriveSummary, UnconfiguredApps,
CrossDriveWarnings for backup page.
- deploy.html: "Biztonsági mentés" card with destination/method/schedule
dropdowns, last-run status, manual trigger button, flash messages.
- backups.html: "Másolatok másik meghajtóra" section with per-app
status rows, unconfigured app warnings, "Összes futtatása most" button.
- style.css: margin-bottom fix for .deploy-stale-data, new cross-drive
card and list styles.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
|
||||
// DeployStoragePath extends StoragePath with free space data for the deploy dropdown.
|
||||
type DeployStoragePath struct {
|
||||
settings.StoragePath
|
||||
@@ -204,6 +206,45 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
|
||||
if len(staleData) > 0 {
|
||||
data["StaleData"] = staleData
|
||||
}
|
||||
|
||||
// Cross-drive backup config for this app
|
||||
crossCfg := s.settings.GetCrossDriveConfig(name)
|
||||
data["CrossDriveConfig"] = crossCfg
|
||||
|
||||
// Other storage paths for destination dropdown (exclude the app's current storage path)
|
||||
currentPath := ""
|
||||
if storageInfo != nil {
|
||||
currentPath = storageInfo.Path
|
||||
}
|
||||
var destPaths []DeployStoragePath
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
if sp.Path == currentPath {
|
||||
continue // skip the app's current storage — must be a DIFFERENT physical device
|
||||
}
|
||||
dp := DeployStoragePath{StoragePath: sp}
|
||||
if di := system.GetDiskUsage(sp.Path); di != nil {
|
||||
dp.FreeHuman = formatFreeSpace(di.AvailGB)
|
||||
if di.TotalGB > 0 {
|
||||
dp.FreePercent = di.AvailGB / di.TotalGB * 100
|
||||
}
|
||||
}
|
||||
destPaths = append(destPaths, dp)
|
||||
}
|
||||
data["BackupDestPaths"] = destPaths
|
||||
|
||||
// Destination health warning
|
||||
if crossCfg != nil && crossCfg.Enabled && crossCfg.DestinationPath != "" {
|
||||
if !system.IsMountPoint(crossCfg.DestinationPath) || !system.IsWritable(crossCfg.DestinationPath) {
|
||||
data["BackupDestWarning"] = fmt.Sprintf(
|
||||
"A cél tárhely (%s) nem elérhető! Ellenőrizd a meghajtó csatlakozását.",
|
||||
crossCfg.DestinationPath,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Nightly backup toggle state
|
||||
appBackupEnabled := s.settings.IsAppBackupEnabled(name)
|
||||
data["AppBackupEnabled"] = appBackupEnabled
|
||||
}
|
||||
|
||||
// Memory info for deploy page (only for non-deployed apps)
|
||||
@@ -243,6 +284,14 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
|
||||
data["MemoryInfo"] = memInfo
|
||||
}
|
||||
|
||||
// Flash messages from cross-drive backup save redirect
|
||||
if flash := r.URL.Query().Get("flash"); flash != "" {
|
||||
data["FlashSuccess"] = flash
|
||||
}
|
||||
if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" {
|
||||
data["FlashError"] = flashErr
|
||||
}
|
||||
|
||||
s.render(w, "deploy", data)
|
||||
}
|
||||
|
||||
@@ -352,6 +401,71 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Build cross-drive summary
|
||||
crossConfigs := s.settings.GetAllCrossDriveConfigs()
|
||||
|
||||
// Build label lookup for dest paths
|
||||
destLabels := make(map[string]string)
|
||||
for _, sp := range storagePaths {
|
||||
destLabels[sp.Path] = sp.Label
|
||||
}
|
||||
|
||||
for _, app := range fullStatus.AppDataInfo {
|
||||
if !app.HasHDDData {
|
||||
continue
|
||||
}
|
||||
cfg, hasCfg := crossConfigs[app.StackName]
|
||||
if !hasCfg || cfg == nil {
|
||||
fullStatus.UnconfiguredApps = append(fullStatus.UnconfiguredApps, backup.CrossDriveSummaryItem{
|
||||
StackName: app.StackName,
|
||||
DisplayName: app.DisplayName,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
item := backup.CrossDriveSummaryItem{
|
||||
StackName: app.StackName,
|
||||
DisplayName: app.DisplayName,
|
||||
Method: cfg.Method,
|
||||
DestPath: cfg.DestinationPath,
|
||||
DestLabel: destLabels[cfg.DestinationPath],
|
||||
Schedule: cfg.Schedule,
|
||||
LastStatus: cfg.LastStatus,
|
||||
SizeHuman: cfg.LastSizeHuman,
|
||||
}
|
||||
switch cfg.Method {
|
||||
case "rsync":
|
||||
item.MethodLabel = "rsync"
|
||||
case "restic":
|
||||
item.MethodLabel = "restic"
|
||||
default:
|
||||
item.MethodLabel = cfg.Method
|
||||
}
|
||||
switch cfg.Schedule {
|
||||
case "daily":
|
||||
item.ScheduleLabel = "Naponta"
|
||||
case "weekly":
|
||||
item.ScheduleLabel = "Hetente"
|
||||
default:
|
||||
item.ScheduleLabel = "Kézi"
|
||||
}
|
||||
if cfg.LastRun != "" {
|
||||
if t, err := time.Parse(time.RFC3339, cfg.LastRun); err == nil {
|
||||
loc, _ := time.LoadLocation("Europe/Budapest")
|
||||
item.LastRunShort = t.In(loc).Format("01-02 15:04")
|
||||
}
|
||||
}
|
||||
fullStatus.CrossDriveSummary = append(fullStatus.CrossDriveSummary, item)
|
||||
|
||||
// Destination health warning
|
||||
if cfg.Enabled && cfg.DestinationPath != "" {
|
||||
if !system.IsMountPoint(cfg.DestinationPath) || !system.IsWritable(cfg.DestinationPath) {
|
||||
fullStatus.CrossDriveWarnings = append(fullStatus.CrossDriveWarnings,
|
||||
fmt.Sprintf("⚠️ %s mentési célja (%s) nem elérhető!", app.DisplayName, cfg.DestinationPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data["Backup"] = fullStatus
|
||||
|
||||
// Restic password for display
|
||||
@@ -399,6 +513,56 @@ func (s *Server) settingsAppBackupHandler(w http.ResponseWriter, r *http.Request
|
||||
http.Redirect(w, r, "/backups?flash=Alkalmaz%C3%A1s+ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1sok+mentve.", http.StatusFound)
|
||||
}
|
||||
|
||||
// settingsCrossBackupHandler handles POST /settings/cross-backup/{name}
|
||||
// Saves or updates the cross-drive backup configuration for an app.
|
||||
func (s *Server) settingsCrossBackupHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
_ = r.ParseForm()
|
||||
|
||||
enabled := r.FormValue("cross_drive_enabled") == "on"
|
||||
method := r.FormValue("cross_drive_method")
|
||||
destPath := r.FormValue("cross_drive_dest")
|
||||
schedule := r.FormValue("cross_drive_schedule")
|
||||
|
||||
// Validate method and schedule
|
||||
if method != "rsync" && method != "restic" {
|
||||
method = "rsync"
|
||||
}
|
||||
if schedule != "daily" && schedule != "weekly" && schedule != "manual" {
|
||||
schedule = "daily"
|
||||
}
|
||||
|
||||
// Preserve existing runtime status fields
|
||||
existing := s.settings.GetCrossDriveConfig(name)
|
||||
|
||||
var cfg *settings.CrossDriveBackup
|
||||
if destPath != "" {
|
||||
cfg = &settings.CrossDriveBackup{
|
||||
Enabled: enabled,
|
||||
Method: method,
|
||||
DestinationPath: destPath,
|
||||
Schedule: schedule,
|
||||
}
|
||||
if existing != nil {
|
||||
cfg.LastRun = existing.LastRun
|
||||
cfg.LastStatus = existing.LastStatus
|
||||
cfg.LastError = existing.LastError
|
||||
cfg.LastDuration = existing.LastDuration
|
||||
cfg.LastSizeHuman = existing.LastSizeHuman
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.settings.SetCrossDriveConfig(name, cfg); err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to save cross-drive config for %s: %v", name, err)
|
||||
http.Redirect(w, r, "/stacks/"+name+"/deploy?flash_error=Hiba+a+ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1s+ment%C3%A9sakor", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Cross-drive backup config saved for %s: method=%s dest=%s schedule=%s enabled=%v",
|
||||
name, method, destPath, schedule, enabled)
|
||||
|
||||
http.Redirect(w, r, "/stacks/"+name+"/deploy?flash=Ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1s+mentve.", http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user