v0.15.2: Fix snapshot stats and DB validation loss on restart
This commit is contained in:
@@ -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:**
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user