diff --git a/CHANGELOG.md b/CHANGELOG.md index 45032e8..6b62730 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ ## Changelog +### What was just completed (2026-02-19 session 52) +- **v0.15.2 — Fix data loss on container restart (2 bugs):** + + **Bug 1:** Snapshot history delta stats (HOZZÁADOTT, ÚJ FÁJL, VÁLTOZOTT) showed 0 after container restart because restic doesn't store these stats — they were only in memory. Fixed by persisting the snapshot history ring buffer to `data/snapshot-history.json`. On startup, persisted stats are merged with restic repo snapshots. Added `saveSnapshotHistory()` (atomic write via tmp+rename), `loadSnapshotHistoryFromFile()`, updated `appendSnapshotRecord()` to save after each backup, and updated `LoadSnapshotHistory()` to merge persisted + restic data. + + **Bug 2:** DB validation (ÉRVÉNYESÍTÉS column) showed "–" after restart because the synthesized `LastDBDump.Results` didn't copy `Validation` from `DumpFileInfo`. One-line fix: added `Validation: f.Validation` to the synthesized `DumpResult` in `GetFullStatus()`. + + **Files modified:** `internal/backup/backup.go` + ### What was just completed (2026-02-19 session 51) - **v0.15.1 — Backup Page "Részletek" Overhaul:** diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go index 64601dc..10cff81 100644 --- a/controller/internal/backup/backup.go +++ b/controller/internal/backup/backup.go @@ -2,6 +2,7 @@ package backup import ( "context" + "encoding/json" "fmt" "log" "os" @@ -26,13 +27,14 @@ type Manager struct { stackProvider StackDataProvider systemDataPath string // fallback drive for SSD-only apps - mu sync.Mutex - lastDBDump *DBDumpStatus - lastBackup *BackupStatus - running bool - snapshotHistory []SnapshotRecord // ring buffer, last 20 entries - lastCheckTime time.Time - lastCheckOK bool + mu sync.Mutex + lastDBDump *DBDumpStatus + lastBackup *BackupStatus + running bool + snapshotHistory []SnapshotRecord // ring buffer, last 20 entries + snapshotHistoryFile string // path to persist snapshot history JSON + lastCheckTime time.Time + lastCheckOK bool // Cached status for page rendering (refreshed periodically) cachedStatus *FullBackupStatus @@ -145,13 +147,18 @@ func NewManager(cfg *config.Config, pinger *monitor.Pinger, sett *settings.Setti if cfg.Paths.SystemDataPath == "" { logger.Printf("[WARN] SystemDataPath is empty in config — SSD-only apps will not have correct backup paths") } + dataDir := cfg.Paths.DataDir + if dataDir == "" { + dataDir = "/opt/docker/felhom-controller/data" + } return &Manager{ - cfg: cfg, - restic: NewResticManager(cfg, logger), - logger: logger, - pinger: pinger, - settings: sett, - systemDataPath: cfg.Paths.SystemDataPath, + cfg: cfg, + restic: NewResticManager(cfg, logger), + logger: logger, + pinger: pinger, + settings: sett, + systemDataPath: cfg.Paths.SystemDataPath, + snapshotHistoryFile: filepath.Join(dataDir, "snapshot-history.json"), } } @@ -793,10 +800,65 @@ func (m *Manager) appendSnapshotRecord(rec SnapshotRecord) { if len(m.snapshotHistory) > 20 { m.snapshotHistory = m.snapshotHistory[len(m.snapshotHistory)-20:] } + m.saveSnapshotHistory() } -// LoadSnapshotHistory populates the snapshot history from all primary restic repos on startup. +// saveSnapshotHistory persists the current snapshotHistory to disk as JSON. +// Caller must hold m.mu. Writes atomically (tmp file + rename). +func (m *Manager) saveSnapshotHistory() { + if m.snapshotHistoryFile == "" { + return + } + data, err := json.Marshal(m.snapshotHistory) + if err != nil { + m.logger.Printf("[WARN] Could not marshal snapshot history: %v", err) + return + } + tmp := m.snapshotHistoryFile + ".tmp" + if err := os.WriteFile(tmp, data, 0644); err != nil { + m.logger.Printf("[WARN] Could not write snapshot history tmp file: %v", err) + return + } + if err := os.Rename(tmp, m.snapshotHistoryFile); err != nil { + m.logger.Printf("[WARN] Could not rename snapshot history file: %v", err) + } +} + +// loadSnapshotHistoryFromFile reads the persisted snapshot history from disk. +// Returns nil if the file does not exist or cannot be read. +func (m *Manager) loadSnapshotHistoryFromFile() []SnapshotRecord { + if m.snapshotHistoryFile == "" { + return nil + } + data, err := os.ReadFile(m.snapshotHistoryFile) + if err != nil { + if !os.IsNotExist(err) { + m.logger.Printf("[WARN] Could not read snapshot history file: %v", err) + } + return nil + } + var records []SnapshotRecord + if err := json.Unmarshal(data, &records); err != nil { + m.logger.Printf("[WARN] Could not parse snapshot history file: %v", err) + return nil + } + return records +} + +// LoadSnapshotHistory populates the snapshot history on startup. +// First tries to load persisted history (with delta stats) from disk. +// Merges with restic repo snapshots to pick up any entries not in the persisted file. func (m *Manager) LoadSnapshotHistory() { + // Try loading persisted records (contains delta stats from actual backup runs) + persisted := m.loadSnapshotHistoryFromFile() + + // Build a lookup map of persisted records by SnapshotID + persistedByID := make(map[string]SnapshotRecord, len(persisted)) + for _, r := range persisted { + persistedByID[r.SnapshotID] = r + } + + // Query restic repos for any snapshots not in the persisted file drives := m.activeDrives() var allSnapshots []SnapshotInfo @@ -821,18 +883,40 @@ func (m *Manager) LoadSnapshotHistory() { m.mu.Lock() defer m.mu.Unlock() - for _, s := range allSnapshots { - m.snapshotHistory = append(m.snapshotHistory, SnapshotRecord{ - SnapshotID: s.ID, - Time: s.Time, - HasStats: false, // historical — no delta stats available - Success: true, + if len(persisted) > 0 { + // Start from persisted records, add any restic snapshots not already there + m.snapshotHistory = persisted + for _, s := range allSnapshots { + if _, found := persistedByID[s.ID]; !found { + m.snapshotHistory = append(m.snapshotHistory, SnapshotRecord{ + SnapshotID: s.ID, + Time: s.Time, + HasStats: false, + Success: true, + }) + } + } + // Re-sort by time after merge (oldest first for ring buffer) + sort.Slice(m.snapshotHistory, func(i, j int) bool { + return m.snapshotHistory[i].Time.Before(m.snapshotHistory[j].Time) }) + m.logger.Printf("[INFO] Loaded %d snapshots from persisted history (merged with %d restic entries)", len(persisted), len(allSnapshots)) + } else { + // No persisted file — fall back to restic-only loading (first run) + for _, s := range allSnapshots { + m.snapshotHistory = append(m.snapshotHistory, SnapshotRecord{ + SnapshotID: s.ID, + Time: s.Time, + HasStats: false, + Success: true, + }) + } + m.logger.Printf("[INFO] Loaded %d historical snapshots from %d restic repos (no persisted history)", len(m.snapshotHistory), len(drives)) } + if len(m.snapshotHistory) > 20 { m.snapshotHistory = m.snapshotHistory[len(m.snapshotHistory)-20:] } - m.logger.Printf("[INFO] Loaded %d historical snapshots from %d repos", len(m.snapshotHistory), len(drives)) } // RefreshCache updates the cached full status. Called by scheduler every 5 minutes @@ -976,9 +1060,10 @@ func (m *Manager) GetFullStatus(nextDBDump, nextBackup time.Time) *FullBackupSta var latestTime time.Time for _, f := range status.DumpFiles { results = append(results, DumpResult{ - DB: DiscoveredDB{StackName: f.StackName, DBType: f.DBType, ContainerName: f.StackName}, - FilePath: f.FileName, - Size: f.Size, + DB: DiscoveredDB{StackName: f.StackName, DBType: f.DBType, ContainerName: f.StackName}, + FilePath: f.FileName, + Size: f.Size, + Validation: f.Validation, }) if f.ModTime.After(latestTime) { latestTime = f.ModTime