v0.15.2: Fix snapshot stats and DB validation loss on restart
This commit is contained in:
@@ -1,5 +1,14 @@
|
|||||||
## Changelog
|
## 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)
|
### What was just completed (2026-02-19 session 51)
|
||||||
- **v0.15.1 — Backup Page "Részletek" Overhaul:**
|
- **v0.15.1 — Backup Page "Részletek" Overhaul:**
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package backup
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@@ -26,13 +27,14 @@ type Manager struct {
|
|||||||
stackProvider StackDataProvider
|
stackProvider StackDataProvider
|
||||||
systemDataPath string // fallback drive for SSD-only apps
|
systemDataPath string // fallback drive for SSD-only apps
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
lastDBDump *DBDumpStatus
|
lastDBDump *DBDumpStatus
|
||||||
lastBackup *BackupStatus
|
lastBackup *BackupStatus
|
||||||
running bool
|
running bool
|
||||||
snapshotHistory []SnapshotRecord // ring buffer, last 20 entries
|
snapshotHistory []SnapshotRecord // ring buffer, last 20 entries
|
||||||
lastCheckTime time.Time
|
snapshotHistoryFile string // path to persist snapshot history JSON
|
||||||
lastCheckOK bool
|
lastCheckTime time.Time
|
||||||
|
lastCheckOK bool
|
||||||
|
|
||||||
// Cached status for page rendering (refreshed periodically)
|
// Cached status for page rendering (refreshed periodically)
|
||||||
cachedStatus *FullBackupStatus
|
cachedStatus *FullBackupStatus
|
||||||
@@ -145,13 +147,18 @@ func NewManager(cfg *config.Config, pinger *monitor.Pinger, sett *settings.Setti
|
|||||||
if cfg.Paths.SystemDataPath == "" {
|
if cfg.Paths.SystemDataPath == "" {
|
||||||
logger.Printf("[WARN] SystemDataPath is empty in config — SSD-only apps will not have correct backup paths")
|
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{
|
return &Manager{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
restic: NewResticManager(cfg, logger),
|
restic: NewResticManager(cfg, logger),
|
||||||
logger: logger,
|
logger: logger,
|
||||||
pinger: pinger,
|
pinger: pinger,
|
||||||
settings: sett,
|
settings: sett,
|
||||||
systemDataPath: cfg.Paths.SystemDataPath,
|
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 {
|
if len(m.snapshotHistory) > 20 {
|
||||||
m.snapshotHistory = m.snapshotHistory[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() {
|
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()
|
drives := m.activeDrives()
|
||||||
var allSnapshots []SnapshotInfo
|
var allSnapshots []SnapshotInfo
|
||||||
|
|
||||||
@@ -821,18 +883,40 @@ func (m *Manager) LoadSnapshotHistory() {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
for _, s := range allSnapshots {
|
if len(persisted) > 0 {
|
||||||
m.snapshotHistory = append(m.snapshotHistory, SnapshotRecord{
|
// Start from persisted records, add any restic snapshots not already there
|
||||||
SnapshotID: s.ID,
|
m.snapshotHistory = persisted
|
||||||
Time: s.Time,
|
for _, s := range allSnapshots {
|
||||||
HasStats: false, // historical — no delta stats available
|
if _, found := persistedByID[s.ID]; !found {
|
||||||
Success: true,
|
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 {
|
if len(m.snapshotHistory) > 20 {
|
||||||
m.snapshotHistory = m.snapshotHistory[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
|
// 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
|
var latestTime time.Time
|
||||||
for _, f := range status.DumpFiles {
|
for _, f := range status.DumpFiles {
|
||||||
results = append(results, DumpResult{
|
results = append(results, DumpResult{
|
||||||
DB: DiscoveredDB{StackName: f.StackName, DBType: f.DBType, ContainerName: f.StackName},
|
DB: DiscoveredDB{StackName: f.StackName, DBType: f.DBType, ContainerName: f.StackName},
|
||||||
FilePath: f.FileName,
|
FilePath: f.FileName,
|
||||||
Size: f.Size,
|
Size: f.Size,
|
||||||
|
Validation: f.Validation,
|
||||||
})
|
})
|
||||||
if f.ModTime.After(latestTime) {
|
if f.ModTime.After(latestTime) {
|
||||||
latestTime = f.ModTime
|
latestTime = f.ModTime
|
||||||
|
|||||||
Reference in New Issue
Block a user