diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dbc1f3..657cdbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ ## Changelog +### What was just completed (2026-02-18 session 45) +- **v0.12.8 — Complete Cross-Drive Backup + Per-Tier UI:** + + **Fix 1: Cross-drive backup now includes DB dumps + app config (`crossdrive.go`, `main.go`)** + - `CrossDriveRunner` gets `dbDumpDir` field + `SetDBDumpDir(dir string)` setter + - `copyStackDBDumps()` helper copies `_*.sql` files to `_db/` subfolder in rsync dest + - `runRsyncBackup()`: after HDD mount rsync loop, copies DB dumps to `_db/` and rsyncs config dir to `_config/` — both non-fatal on error + - `runResticBackup()`: appends config dir and full DB dump dir to restic paths (restic deduplicates) + - rsync destination layout: `backups/rsync//_db/` (dumps) + `_config/` (compose+yaml) + user data + - `main.go`: `crossDriveRunner.SetDBDumpDir(cfg.Paths.DBDumpDir)` wired after runner init + + **Fix 2: UI restructured from per-layer to per-tier (`handlers.go`, `backups.html`, `style.css`)** + - `AppBackupRow` struct rebuilt: dropped old `DBLastRun/Status`, `VolumeLastRun/Status`, `HasUserData`, `UserDataConfigured/Method/Dest/Schedule/LastRun/LastStatus/LastError/StatusBadge` fields + - New fields: `BackupContents` (e.g., "DB + Konfig + Adatok"), `Tier1LastRun/LastStatus/DBStatus`, `Tier2Configured/Method/MethodLabel/Dest/Schedule/LastRun/LastStatus/LastError/StatusBadge/SizeHuman/Browsable` + - `buildAppBackupRows()` rewritten: destination health now via `s.crossDriveRunner.ValidateDestination()` instead of `system.CheckBackupDestination()` + - `backups.html`: two tier rows (1. mentés / 2. mentés) replace the old three layer rows (DB / Konfig / Userdata) + - `style.css`: added `.tier-label`, `.tier-location`, `.tier-contents`, `.tier-size`, `.tier-browsable` classes + + **Fix 3: Cleanup (`router.go`)** + - `filterSnapshotsByPaths()` and `pathCovers()` deleted (were unused since v0.12.7a) + + **Files modified (6):** `internal/backup/crossdrive.go`, `cmd/controller/main.go`, `internal/web/handlers.go`, `internal/web/templates/backups.html`, `internal/web/templates/style.css`, `internal/api/router.go` + ### What was just completed (2026-02-18 session 44) - **v0.12.7a — Post-deploy fixes:** diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index f362aca..a252e6a 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -138,6 +138,7 @@ func main() { if backupMgr != nil { crossDriveRunner.SetDBDumper(backupMgr) } + crossDriveRunner.SetDBDumpDir(cfg.Paths.DBDumpDir) // --- Initialize alert manager --- alertMgr := web.NewAlertManager(logger) diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index 64b0b0a..1edd6d3 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -469,33 +469,6 @@ func (r *Router) backupSnapshots(w http.ResponseWriter, req *http.Request) { writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: snapshots}) } -// filterSnapshotsByPaths returns only snapshots whose Paths overlap with requiredPaths. -// A snapshot matches if any of its paths is a prefix of (or prefixed by) any required path. -// M10: Uses separator-aware prefix check to prevent /mnt/hdd_1 matching /mnt/hdd_10/data. -func filterSnapshotsByPaths(snapshots []backup.SnapshotInfo, requiredPaths []string) []backup.SnapshotInfo { - var filtered []backup.SnapshotInfo -outer: - for _, snap := range snapshots { - for _, required := range requiredPaths { - for _, sp := range snap.Paths { - if pathCovers(required, sp) || pathCovers(sp, required) { - filtered = append(filtered, snap) - continue outer - } - } - } - } - return filtered -} - -// pathCovers returns true if base is equal to or a directory-prefix of target. -func pathCovers(base, target string) bool { - if base == target { - return true - } - return strings.HasPrefix(target, strings.TrimRight(base, "/")+"/") -} - // --- Metrics handlers --- func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) { diff --git a/controller/internal/backup/crossdrive.go b/controller/internal/backup/crossdrive.go index cf07702..223e88d 100644 --- a/controller/internal/backup/crossdrive.go +++ b/controller/internal/backup/crossdrive.go @@ -25,6 +25,7 @@ type CrossDriveRunner struct { sett *settings.Settings stackProvider StackDataProvider dbDumper DBDumper + dbDumpDir string // path to DB dump directory (e.g., /srv/backups/db-dumps) logger *log.Logger mu sync.Mutex running map[string]bool // per-app running state @@ -46,6 +47,11 @@ func (r *CrossDriveRunner) SetDBDumper(d DBDumper) { r.dbDumper = d } +// SetDBDumpDir sets the path to the DB dump directory for cross-drive backups. +func (r *CrossDriveRunner) SetDBDumpDir(dir string) { + r.dbDumpDir = dir +} + // RunAppBackup runs cross-drive backup for a single app. func (r *CrossDriveRunner) RunAppBackup(ctx context.Context, stackName string) error { cfg := r.sett.GetCrossDriveConfig(stackName) @@ -258,6 +264,34 @@ func (r *CrossDriveRunner) runRsyncBackup(ctx context.Context, stackName, destBa return fmt.Errorf("rsync failed for %s: %v (%s)", srcMount, err, strings.TrimSpace(string(out))) } } + + // --- Copy DB dumps for this stack --- + dbDestDir := filepath.Join(destDir, "_db") + if err := os.MkdirAll(dbDestDir, 0755); err != nil { + return fmt.Errorf("creating DB dump dest dir: %w", err) + } + if err := r.copyStackDBDumps(stackName, dbDestDir); err != nil { + r.logger.Printf("[WARN] Cross-drive DB dump copy failed for %s: %v", stackName, err) + // Non-fatal: user data is the primary concern + } + + // --- Rsync app config (compose dir) --- + if composePath, ok := r.stackProvider.GetStackComposePath(stackName); ok { + configSrcDir := filepath.Dir(composePath) + configDestDir := filepath.Join(destDir, "_config") + if err := os.MkdirAll(configDestDir, 0755); err != nil { + return fmt.Errorf("creating config dest dir: %w", err) + } + src := strings.TrimRight(configSrcDir, "/") + "/" + dst := strings.TrimRight(configDestDir, "/") + "/" + cmd := exec.CommandContext(ctx, "rsync", "-a", "--delete", src, dst) + r.logger.Printf("[DEBUG] rsync config: %s → %s", src, dst) + if out, err := cmd.CombinedOutput(); err != nil { + r.logger.Printf("[WARN] Cross-drive config rsync failed for %s: %v (%s)", stackName, err, strings.TrimSpace(string(out))) + // Non-fatal + } + } + return nil } @@ -298,7 +332,18 @@ func (r *CrossDriveRunner) runResticBackup(ctx context.Context, stackName, destB "--tag", stackName, "--tag", "cross-drive", } + // Include user data (HDD mounts) args = append(args, mounts...) + // Include app config dir (compose + app.yaml + .felhom.yml) + if composePath, ok := r.stackProvider.GetStackComposePath(stackName); ok { + args = append(args, filepath.Dir(composePath)) + } + // Include DB dump dir (all stacks' dumps — restic deduplicates) + if r.dbDumpDir != "" { + if _, err := os.Stat(r.dbDumpDir); err == nil { + args = append(args, r.dbDumpDir) + } + } cmd := exec.CommandContext(ctx, "restic", args...) r.logger.Printf("[DEBUG] restic backup: %v", args) @@ -346,6 +391,43 @@ func (r *CrossDriveRunner) ensureResticRepo(ctx context.Context, repoPath, pwFil return nil } +// copyStackDBDumps copies DB dump files for the given stack to destDir. +// DB dump files are named _.sql (e.g., immich_postgres.sql). +// Small files — uses plain file copy, not rsync. +func (r *CrossDriveRunner) copyStackDBDumps(stackName, destDir string) error { + if r.dbDumpDir == "" { + return nil + } + entries, err := os.ReadDir(r.dbDumpDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("reading DB dump dir: %w", err) + } + prefix := stackName + "_" + copied := 0 + for _, e := range entries { + if e.IsDir() || !strings.HasPrefix(e.Name(), prefix) { + continue + } + src := filepath.Join(r.dbDumpDir, e.Name()) + dst := filepath.Join(destDir, e.Name()) + data, err := os.ReadFile(src) + if err != nil { + return fmt.Errorf("reading %s: %w", e.Name(), err) + } + if err := os.WriteFile(dst, data, 0644); err != nil { + return fmt.Errorf("writing %s: %w", e.Name(), err) + } + copied++ + } + if copied > 0 { + r.logger.Printf("[DEBUG] Copied %d DB dump file(s) to %s", copied, destDir) + } + return nil +} + // --- helpers --- func (r *CrossDriveRunner) updateStatus(stackName, status, errMsg string, duration time.Duration, sizeHuman string) { diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 4a123a7..9eb4dd4 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -497,42 +497,46 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) { s.render(w, "backups", data) } -// AppBackupRow holds all backup information for one app, used by the backup page template. +// AppBackupRow holds per-tier backup information for one app on the backup page. type AppBackupRow struct { StackName string DisplayName string Status string // "green", "yellow", "red", "auto" StatusText string // short Hungarian tooltip - // Storage info (HDD apps only) + // App characteristics HasHDDData bool + HasDB bool StorageLabel string HDDSizeHuman string - // Layer details (nil = layer not applicable) - HasDB bool - DBLastRun string // formatted time - DBLastStatus string // "ok", "error", "" + // What this app's backup contains (for display) + // e.g., "DB + Konfiguráció + Adatok", "DB + Konfiguráció", "Konfiguráció" + BackupContents string - VolumeLastRun string - VolumeLastStatus string + // Tier 1: Nightly backup (always exists) + Tier1LastRun string // formatted time of last restic snapshot + Tier1LastStatus string // "ok", "error", "" + Tier1DBStatus string // "ok", "error", "" — separate DB dump status for warning - // Cross-drive / user data - HasUserData bool - UserDataConfigured bool - UserDataMethod string // "rsync", "restic" - UserDataDest string // destination label - UserDataSchedule string // "Naponta", "Hetente" - UserDataLastRun string - UserDataLastStatus string // "ok", "error", "running", "" - UserDataLastError string - UserDataStatusBadge string // "Sikeres", "Hiba", "Fut...", "—" + // Tier 2: Cross-drive backup (only for apps with HDD data) + Tier2Configured bool + Tier2Method string // "rsync", "restic" + Tier2MethodLabel string // "rsync", "restic" + Tier2Dest string // destination label + Tier2Schedule string // "Naponta", "Hetente" + Tier2LastRun string + Tier2LastStatus string // "ok", "error", "running", "" + Tier2LastError string + Tier2StatusBadge string // "Sikeres", "Hiba", "Fut...", "—" + Tier2SizeHuman string + Tier2Browsable bool // true for rsync (plain files), false for restic // Warnings accumulated for this app Warnings []string } -// buildAppBackupRows constructs one AppBackupRow per deployed app for the unified backup page. +// buildAppBackupRows constructs one AppBackupRow per deployed app for the backup page. func (s *Server) buildAppBackupRows( status *backup.FullBackupStatus, crossConfigs map[string]*settings.CrossDriveBackup, @@ -540,137 +544,139 @@ func (s *Server) buildAppBackupRows( ) []AppBackupRow { loc := getTimezone() - // Build a quick lookup: which stacks have a DB dump? + // Build DB stack lookup dbStacks := make(map[string]bool) for _, db := range status.DiscoveredDBs { dbStacks[db.StackName] = true } - // Also check dump files if no live discovered DBs for _, f := range status.DumpFiles { dbStacks[f.StackName] = true } - // Determine last restic run time for volume backup display - volumeLastRun := "" - volumeLastStatus := "" + // Tier 1 timestamps (shared across all apps — single nightly job) + tier1LastRun := "" + tier1LastStatus := "" if status.LastBackup != nil { - volumeLastRun = status.LastBackup.LastRun.In(loc).Format("01-02 15:04") + tier1LastRun = status.LastBackup.LastRun.In(loc).Format("01-02 15:04") if status.LastBackup.Success { - volumeLastStatus = "ok" + tier1LastStatus = "ok" } else { - volumeLastStatus = "error" + tier1LastStatus = "error" } } - - // DB dump last run - dbLastRun := "" - dbLastStatus := "" + tier1DBStatus := "" if status.LastDBDump != nil { - dbLastRun = status.LastDBDump.LastRun.In(loc).Format("01-02 15:04") if status.LastDBDump.Success { - dbLastStatus = "ok" + tier1DBStatus = "ok" } else { - dbLastStatus = "error" + tier1DBStatus = "error" } } var rows []AppBackupRow for _, app := range status.AppDataInfo { + hasDB := dbStacks[app.StackName] || app.HasDBDump + + // Build backup contents label + var parts []string + if hasDB { + parts = append(parts, "DB") + } + parts = append(parts, "Konfig") + if app.HasHDDData { + parts = append(parts, "Adatok") + } + contents := strings.Join(parts, " + ") + row := AppBackupRow{ - StackName: app.StackName, - DisplayName: app.DisplayName, - HasHDDData: app.HasHDDData, - StorageLabel: app.StorageLabel, - HDDSizeHuman: app.HDDSizeHuman, - HasDB: dbStacks[app.StackName] || app.HasDBDump, - DBLastRun: dbLastRun, - DBLastStatus: dbLastStatus, - VolumeLastRun: volumeLastRun, - VolumeLastStatus: volumeLastStatus, + StackName: app.StackName, + DisplayName: app.DisplayName, + HasHDDData: app.HasHDDData, + HasDB: hasDB, + StorageLabel: app.StorageLabel, + HDDSizeHuman: app.HDDSizeHuman, + BackupContents: contents, + + Tier1LastRun: tier1LastRun, + Tier1LastStatus: tier1LastStatus, + Tier1DBStatus: tier1DBStatus, } - // Default status = green/auto + // Default status = auto (no user data, just config) row.Status = "auto" row.StatusText = "Automatikus mentés" if app.HasHDDData { - row.HasUserData = true cfg, hasCfg := crossConfigs[app.StackName] if !hasCfg || cfg == nil || !cfg.Enabled { // HDD data backed up via nightly restic (mandatory), but no second copy - row.UserDataConfigured = false + row.Tier2Configured = false row.Status = "yellow" row.StatusText = "Nincs második másolat (csak helyi mentés)" } else { - row.UserDataConfigured = true - row.UserDataMethod = cfg.Method - row.UserDataDest = destLabels[cfg.DestinationPath] - if row.UserDataDest == "" { - row.UserDataDest = cfg.DestinationPath + row.Tier2Configured = true + row.Tier2Method = cfg.Method + row.Tier2MethodLabel = cfg.Method // "rsync" or "restic" + row.Tier2Browsable = cfg.Method == "rsync" + row.Tier2Dest = destLabels[cfg.DestinationPath] + if row.Tier2Dest == "" { + row.Tier2Dest = cfg.DestinationPath } switch cfg.Schedule { case "daily": - row.UserDataSchedule = "Naponta" + row.Tier2Schedule = "Naponta" case "weekly": - row.UserDataSchedule = "Hetente (vasárnap)" + row.Tier2Schedule = "Hetente" default: - row.UserDataSchedule = cfg.Schedule + row.Tier2Schedule = cfg.Schedule } if cfg.LastRun != "" { if t, err := time.Parse(time.RFC3339, cfg.LastRun); err == nil { - row.UserDataLastRun = t.In(loc).Format("01-02 15:04") + row.Tier2LastRun = t.In(loc).Format("01-02 15:04") } } - row.UserDataLastStatus = cfg.LastStatus - row.UserDataLastError = cfg.LastError + row.Tier2LastStatus = cfg.LastStatus + row.Tier2LastError = cfg.LastError + row.Tier2SizeHuman = cfg.LastSizeHuman switch cfg.LastStatus { case "ok": - row.UserDataStatusBadge = "Sikeres" + row.Tier2StatusBadge = "Sikeres" case "error": - row.UserDataStatusBadge = "Hiba" - case "running": - row.UserDataStatusBadge = "Fut..." - default: - row.UserDataStatusBadge = "—" - } - - // Check destination health for status determination - health := system.CheckBackupDestination(cfg.DestinationPath) - if health.Blocked { - row.Status = "red" - row.StatusText = "Mentési cél nem elérhető" - row.Warnings = append(row.Warnings, health.Warning) - } else if health.Warning != "" { - row.Status = "yellow" - row.StatusText = "Figyelmeztetés" - row.Warnings = append(row.Warnings, health.Warning) - } else if cfg.LastStatus == "error" { + row.Tier2StatusBadge = "Hiba" row.Status = "yellow" row.StatusText = "Utolsó mentés sikertelen" - } else { - row.Status = "green" - row.StatusText = "Mentés rendben" + case "running": + row.Tier2StatusBadge = "Fut..." + default: + row.Tier2StatusBadge = "—" + } + + // Destination health check + if cfg.Enabled && cfg.DestinationPath != "" { + if err := s.crossDriveRunner.ValidateDestination(cfg.DestinationPath); err != nil { + if strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "not writable") { + row.Status = "red" + row.StatusText = "Mentési cél nem elérhető" + } else { + row.Status = "yellow" + row.StatusText = "Figyelmeztetés" + } + row.Warnings = append(row.Warnings, err.Error()) + } else if row.Status != "yellow" { + row.Status = "green" + row.StatusText = "Mentés rendben" + } } - } - } else { - // No HDD data — everything backed up automatically via restic - if volumeLastStatus == "ok" { - row.Status = "green" - row.StatusText = "Mentés rendben" - } else if volumeLastStatus == "error" { - row.Status = "yellow" - row.StatusText = "Kötetek mentése sikertelen" - } else { - row.Status = "auto" - row.StatusText = "Automatikus mentés" } } - // If DB dump failed for this app, degrade to yellow (if not already red) - if row.HasDB && dbLastStatus == "error" && row.Status != "red" { - row.Status = "yellow" - row.StatusText = "Adatbázis mentés sikertelen" + // DB dump failure warning (affects Tier 1 quality) + if hasDB && tier1DBStatus == "error" { + if row.Status != "red" { + row.Status = "yellow" + row.StatusText = "Adatbázis mentés sikertelen" + } } rows = append(rows, row) diff --git a/controller/internal/web/templates/backups.html b/controller/internal/web/templates/backups.html index 967414d..4037a70 100644 --- a/controller/internal/web/templates/backups.html +++ b/controller/internal/web/templates/backups.html @@ -264,62 +264,53 @@