v0.4.5: Add dedicated Backup page (Biztonsági mentés)
New /backups page with full backup system visibility: - Status overview cards (local/remote backup, DB count, repo size) - Schedule section with next-run times and retention policy - Database table with type, size, validation (table count), status - Snapshot history table with per-snapshot stats - Repository info card with paths, integrity status, remote placeholder - "Mentés most" button with auto-refresh polling - Empty state when backup not configured Backend: SnapshotRecord history (ring buffer), DumpValidation, ListDumpFiles, ListSnapshots, GetFullStatus, restic check tracking. Server accepts scheduler for next-run time calculation. Sidebar nav updated with 3rd item, dashboard backup card title clickable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,10 +19,58 @@ type Manager struct {
|
||||
logger *log.Logger
|
||||
pinger *monitor.Pinger
|
||||
|
||||
mu sync.Mutex
|
||||
lastDBDump *DBDumpStatus
|
||||
lastBackup *BackupStatus
|
||||
running bool
|
||||
mu sync.Mutex
|
||||
lastDBDump *DBDumpStatus
|
||||
lastBackup *BackupStatus
|
||||
running bool
|
||||
snapshotHistory []SnapshotRecord // ring buffer, last 20 entries
|
||||
lastCheckTime time.Time
|
||||
lastCheckOK bool
|
||||
}
|
||||
|
||||
// SnapshotRecord combines restic snapshot metadata with our run stats.
|
||||
type SnapshotRecord struct {
|
||||
SnapshotID string `json:"snapshot_id"`
|
||||
Time time.Time `json:"time"`
|
||||
FilesNew int `json:"files_new"`
|
||||
FilesChanged int `json:"files_changed"`
|
||||
DataAdded string `json:"data_added"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
Success bool `json:"success"`
|
||||
HasStats bool `json:"has_stats"` // false for historical entries loaded from restic
|
||||
}
|
||||
|
||||
// FullBackupStatus contains everything the backup page needs.
|
||||
type FullBackupStatus struct {
|
||||
Enabled bool
|
||||
Running bool
|
||||
|
||||
// DB Dumps
|
||||
LastDBDump *DBDumpStatus
|
||||
DumpFiles []DumpFileInfo
|
||||
DiscoveredDBs []DiscoveredDB
|
||||
|
||||
// Restic
|
||||
LastBackup *BackupStatus
|
||||
SnapshotHistory []SnapshotRecord
|
||||
RepoStats *RepoStats
|
||||
|
||||
// Schedule
|
||||
DBDumpSchedule string
|
||||
ResticSchedule string
|
||||
PruneSchedule string
|
||||
NextDBDump time.Time
|
||||
NextBackup time.Time
|
||||
Retention config.RetentionConfig
|
||||
|
||||
// Repository health
|
||||
RepoPath string
|
||||
BackupPaths []string
|
||||
LastCheckTime time.Time
|
||||
LastCheckOK bool
|
||||
|
||||
// Remote (placeholder)
|
||||
RemoteEnabled bool
|
||||
}
|
||||
|
||||
// DBDumpStatus holds the last DB dump result.
|
||||
@@ -162,9 +210,14 @@ func (m *Manager) RunBackup(ctx context.Context) error {
|
||||
if err := m.restic.Prune(m.cfg.Backup.Retention); err != nil {
|
||||
m.logger.Printf("[WARN] Restic prune failed: %v", err)
|
||||
}
|
||||
if err := m.restic.Check(); err != nil {
|
||||
m.logger.Printf("[WARN] Restic check failed: %v", err)
|
||||
checkErr := m.restic.Check()
|
||||
if checkErr != nil {
|
||||
m.logger.Printf("[WARN] Restic check failed: %v", checkErr)
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.lastCheckTime = time.Now()
|
||||
m.lastCheckOK = checkErr == nil
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// Get stats
|
||||
@@ -179,6 +232,17 @@ func (m *Manager) RunBackup(ctx context.Context) error {
|
||||
Duration: duration,
|
||||
RepoStats: stats,
|
||||
}
|
||||
// Append to snapshot history
|
||||
m.appendSnapshotRecord(SnapshotRecord{
|
||||
SnapshotID: result.SnapshotID,
|
||||
Time: time.Now(),
|
||||
FilesNew: result.FilesNew,
|
||||
FilesChanged: result.FilesChanged,
|
||||
DataAdded: result.DataAdded,
|
||||
Duration: duration,
|
||||
Success: true,
|
||||
HasStats: true,
|
||||
})
|
||||
m.mu.Unlock()
|
||||
|
||||
body := fmt.Sprintf("Backup OK\nSnapshot: %s\nNew files: %d, Changed: %d\nData added: %s\nDuration: %s",
|
||||
@@ -254,6 +318,96 @@ func shouldPrune(schedule string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// appendSnapshotRecord adds a record to the ring buffer (max 20). Caller must hold m.mu.
|
||||
func (m *Manager) appendSnapshotRecord(rec SnapshotRecord) {
|
||||
m.snapshotHistory = append(m.snapshotHistory, rec)
|
||||
if len(m.snapshotHistory) > 20 {
|
||||
m.snapshotHistory = m.snapshotHistory[len(m.snapshotHistory)-20:]
|
||||
}
|
||||
}
|
||||
|
||||
// LoadSnapshotHistory populates the snapshot history from restic on startup.
|
||||
func (m *Manager) LoadSnapshotHistory() {
|
||||
snapshots, err := m.restic.ListSnapshots(20)
|
||||
if err != nil {
|
||||
m.logger.Printf("[WARN] Could not load snapshot history: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for _, s := range snapshots {
|
||||
m.snapshotHistory = append(m.snapshotHistory, SnapshotRecord{
|
||||
SnapshotID: s.ID,
|
||||
Time: s.Time,
|
||||
HasStats: false, // historical — no delta stats available
|
||||
Success: true,
|
||||
})
|
||||
}
|
||||
if len(m.snapshotHistory) > 20 {
|
||||
m.snapshotHistory = m.snapshotHistory[len(m.snapshotHistory)-20:]
|
||||
}
|
||||
m.logger.Printf("[INFO] Loaded %d historical snapshots", len(m.snapshotHistory))
|
||||
}
|
||||
|
||||
// GetFullStatus returns everything the backup page needs.
|
||||
func (m *Manager) GetFullStatus(nextDBDump, nextBackup time.Time) *FullBackupStatus {
|
||||
m.mu.Lock()
|
||||
status := &FullBackupStatus{
|
||||
Enabled: m.cfg.Backup.Enabled,
|
||||
Running: m.running,
|
||||
LastDBDump: m.lastDBDump,
|
||||
LastBackup: m.lastBackup,
|
||||
|
||||
DBDumpSchedule: m.cfg.Backup.DBDumpSchedule,
|
||||
ResticSchedule: m.cfg.Backup.ResticSchedule,
|
||||
PruneSchedule: m.cfg.Backup.PruneSchedule,
|
||||
NextDBDump: nextDBDump,
|
||||
NextBackup: nextBackup,
|
||||
Retention: m.cfg.Backup.Retention,
|
||||
|
||||
RepoPath: m.cfg.Backup.ResticRepo,
|
||||
LastCheckTime: m.lastCheckTime,
|
||||
LastCheckOK: m.lastCheckOK,
|
||||
}
|
||||
// Copy snapshot history
|
||||
status.SnapshotHistory = make([]SnapshotRecord, len(m.snapshotHistory))
|
||||
copy(status.SnapshotHistory, m.snapshotHistory)
|
||||
m.mu.Unlock()
|
||||
|
||||
// Reverse so newest first
|
||||
for i, j := 0, len(status.SnapshotHistory)-1; i < j; i, j = i+1, j-1 {
|
||||
status.SnapshotHistory[i], status.SnapshotHistory[j] = status.SnapshotHistory[j], status.SnapshotHistory[i]
|
||||
}
|
||||
|
||||
// Backup paths
|
||||
status.BackupPaths = []string{
|
||||
m.cfg.Paths.StacksDir,
|
||||
m.cfg.Paths.DBDumpDir,
|
||||
"/opt/docker/felhom-controller/controller.yaml",
|
||||
}
|
||||
|
||||
// Get repo stats (non-locked)
|
||||
if stats, err := m.restic.Stats(); err == nil {
|
||||
status.RepoStats = stats
|
||||
}
|
||||
|
||||
// List dump files from disk
|
||||
if files, err := ListDumpFiles(m.cfg.Paths.DBDumpDir); err == nil {
|
||||
status.DumpFiles = files
|
||||
}
|
||||
|
||||
// Discover running DBs
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if dbs, err := DiscoverDatabases(ctx, m.logger); err == nil {
|
||||
status.DiscoveredDBs = dbs
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
func dbNames(dbs []DiscoveredDB) string {
|
||||
var names []string
|
||||
for _, db := range dbs {
|
||||
|
||||
Reference in New Issue
Block a user