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:
2026-02-16 07:43:24 +01:00
parent 0985339e6c
commit 37ff296a0d
12 changed files with 1064 additions and 16 deletions
+160 -6
View File
@@ -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 {
+140 -7
View File
@@ -31,11 +31,31 @@ type DiscoveredDB struct {
// DumpResult holds the outcome of a single database dump.
type DumpResult struct {
DB DiscoveredDB
FilePath string
Size int64
Duration time.Duration
Error error
DB DiscoveredDB
FilePath string
Size int64
Duration time.Duration
Error error
Validation DumpValidation
}
// DumpValidation holds the result of a dump file structural check.
type DumpValidation struct {
Valid bool
TableCount int
Error string
FileSize int64
ModTime time.Time
}
// DumpFileInfo holds info about a dump file on disk.
type DumpFileInfo struct {
FileName string
StackName string
DBType DBType
Size int64
ModTime time.Time
Validation DumpValidation
}
// DiscoverDatabases finds running database containers via docker ps.
@@ -200,12 +220,125 @@ func DumpOne(ctx context.Context, db DiscoveredDB, dumpDir string, logger *log.L
result.Size = stat.Size()
result.Duration = time.Since(start)
logger.Printf("[INFO] DB dump: %s → %s (%s, %s)", db.ContainerName, filename,
formatBytes(stat.Size()), result.Duration.Round(time.Millisecond))
// Run validation on the dump file
result.Validation = ValidateDump(finalPath, db.DBType)
logger.Printf("[INFO] DB dump: %s → %s (%s, %s, %d tables)", db.ContainerName, filename,
formatBytes(stat.Size()), result.Duration.Round(time.Millisecond), result.Validation.TableCount)
return result
}
// ValidateDump checks a SQL dump file for basic structural integrity.
func ValidateDump(filePath string, dbType DBType) DumpValidation {
stat, err := os.Stat(filePath)
if err != nil {
return DumpValidation{Error: fmt.Sprintf("stat failed: %v", err)}
}
v := DumpValidation{
FileSize: stat.Size(),
ModTime: stat.ModTime(),
}
if stat.Size() < 100 {
v.Error = "dump file too small (< 100 bytes)"
return v
}
data, err := os.ReadFile(filePath)
if err != nil {
v.Error = fmt.Sprintf("read failed: %v", err)
return v
}
content := string(data)
// Count CREATE TABLE statements
tableCount := 0
for _, line := range strings.Split(content, "\n") {
upper := strings.ToUpper(strings.TrimSpace(line))
if strings.HasPrefix(upper, "CREATE TABLE") {
tableCount++
}
}
v.TableCount = tableCount
// Basic header check
switch dbType {
case DBTypePostgres:
if !strings.HasPrefix(content, "--") {
v.Error = "PostgreSQL dump missing comment header"
return v
}
case DBTypeMariaDB:
if !strings.HasPrefix(content, "-- ") {
v.Error = "MariaDB dump missing comment header"
return v
}
}
if tableCount == 0 {
v.Error = "no CREATE TABLE statements found"
return v
}
v.Valid = true
return v
}
// ListDumpFiles returns info about SQL dump files on disk.
func ListDumpFiles(dumpDir string) ([]DumpFileInfo, error) {
entries, err := os.ReadDir(dumpDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("reading dump dir: %w", err)
}
var files []DumpFileInfo
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
continue
}
if strings.HasSuffix(e.Name(), ".tmp") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
f := DumpFileInfo{
FileName: e.Name(),
Size: info.Size(),
ModTime: info.ModTime(),
}
// Parse stack name and DB type from filename: "paperless-ngx-postgres.sql"
base := strings.TrimSuffix(e.Name(), ".sql")
if strings.HasSuffix(base, "-postgres") {
f.StackName = strings.TrimSuffix(base, "-postgres")
f.DBType = DBTypePostgres
} else if strings.HasSuffix(base, "-mariadb") {
f.StackName = strings.TrimSuffix(base, "-mariadb")
f.DBType = DBTypeMariaDB
} else {
f.StackName = base
}
// Run validation on the file
fullPath := filepath.Join(dumpDir, e.Name())
f.Validation = ValidateDump(fullPath, f.DBType)
files = append(files, f)
}
return files, nil
}
func populateDBEnv(ctx context.Context, db *DiscoveredDB) error {
cmd := exec.CommandContext(ctx, "docker", "inspect", db.ContainerID,
"--format", "{{range .Config.Env}}{{println .}}{{end}}")
+28
View File
@@ -216,6 +216,34 @@ func (r *ResticManager) Check() error {
return nil
}
// ListSnapshots returns all snapshots, newest first, limited to N entries.
func (r *ResticManager) ListSnapshots(limit int) ([]SnapshotInfo, error) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
cmd := r.command(ctx, "snapshots", "--json")
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("restic snapshots failed: %v", err)
}
var snapshots []SnapshotInfo
if err := json.Unmarshal(out, &snapshots); err != nil {
return nil, fmt.Errorf("parsing snapshot JSON: %v", err)
}
// Reverse for newest first
for i, j := 0, len(snapshots)-1; i < j; i, j = i+1, j-1 {
snapshots[i], snapshots[j] = snapshots[j], snapshots[i]
}
if limit > 0 && len(snapshots) > limit {
snapshots = snapshots[:limit]
}
return snapshots, nil
}
// LatestSnapshot returns the most recent snapshot info.
func (r *ResticManager) LatestSnapshot() (*SnapshotInfo, error) {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)