Phase 3 complete: per-app backup toggles, restore, storage overview

- Storage overview on backup page (SSD/HDD bars, repo stats)
- Restic password visibility + hub sync for disaster recovery
- App data discovery (HDD bind mounts, Docker volumes)
- Per-app backup toggle checkboxes with settings persistence
- Dynamic backup paths: enabled app HDD data included in restic snapshots
- Limited app restore from snapshots (self-service recovery)
- Snapshots API endpoint for restore dropdown
- Version bump to 0.8.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 21:29:56 +01:00
parent a3af7c6a2d
commit 7d801d1094
15 changed files with 1088 additions and 29 deletions
+80 -7
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
@@ -16,11 +17,12 @@ import (
// Manager orchestrates database dumps and restic backups.
type Manager struct {
cfg *config.Config
restic *ResticManager
logger *log.Logger
pinger *monitor.Pinger
settings *settings.Settings
cfg *config.Config
restic *ResticManager
logger *log.Logger
pinger *monitor.Pinger
settings *settings.Settings
stackProvider StackDataProvider
mu sync.Mutex
lastDBDump *DBDumpStatus
@@ -82,6 +84,13 @@ type FullBackupStatus struct {
// Remote (placeholder)
RemoteEnabled bool
// App data backup
AppDataInfo []AppBackupInfo
// Flash messages (set by handlers, passed through redirect)
FlashSuccess string
FlashError string
}
// DBDumpStatus holds the last DB dump result.
@@ -209,12 +218,17 @@ func (m *Manager) RunBackup(ctx context.Context) error {
return err
}
// Backup paths
// Backup paths: base + dynamic app data
paths := []string{
m.cfg.Paths.StacksDir,
m.cfg.Paths.DBDumpDir,
"/opt/docker/felhom-controller/controller.yaml",
}
appPaths := m.resolveAppBackupPaths()
if len(appPaths) > 0 {
paths = append(paths, appPaths...)
m.logger.Printf("[INFO] Backup paths (%d total, %d app data): %v", len(paths), len(appPaths), paths)
}
tags := []string{"felhom", m.cfg.Customer.ID}
result, err := m.restic.Snapshot(paths, tags)
@@ -358,13 +372,60 @@ func (m *Manager) GetRepoStats() (*RepoStats, error) {
return m.restic.Stats()
}
// IsRunning returns whether a backup is currently in progress.
// IsRunning returns whether a backup or restore is currently in progress.
func (m *Manager) IsRunning() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.running
}
// GetResticPassword returns the restic repository encryption password.
func (m *Manager) GetResticPassword() (string, error) {
return m.restic.GetPassword()
}
// ListSnapshots returns snapshots from the restic repository.
func (m *Manager) ListSnapshots(limit int) ([]SnapshotInfo, error) {
return m.restic.ListSnapshots(limit)
}
// SetStackProvider sets the stack data provider for app data discovery.
func (m *Manager) SetStackProvider(provider StackDataProvider) {
m.stackProvider = provider
}
// resolveAppBackupPaths returns HDD paths for all enabled app backups.
func (m *Manager) resolveAppBackupPaths() []string {
if m.stackProvider == nil || m.settings == nil {
return nil
}
appBackupMap := m.settings.GetAppBackupMap()
if len(appBackupMap) == 0 {
return nil
}
var paths []string
seen := make(map[string]bool)
for stackName, enabled := range appBackupMap {
if !enabled {
continue
}
hddMounts := m.stackProvider.GetStackHDDMounts(stackName)
for _, mount := range hddMounts {
if seen[mount] {
continue
}
if _, err := os.Stat(mount); err == nil {
paths = append(paths, mount)
seen[mount] = true
m.logger.Printf("[DEBUG] Including app data: %s (from %s)", mount, stackName)
}
}
}
return paths
}
func shouldPrune(schedule string) bool {
loc, err := time.LoadLocation("Europe/Budapest")
if err != nil {
@@ -450,6 +511,18 @@ func (m *Manager) RefreshCache(nextDBDump, nextBackup time.Time) {
status.DiscoveredDBs = dbs
}
// Discover app data (for per-app backup toggles)
if m.stackProvider != nil {
backupPrefs := m.settings.GetAppBackupMap()
status.AppDataInfo = DiscoverAppData(m.stackProvider, m.cfg.Paths.HDDPath, backupPrefs, status.DiscoveredDBs)
// Include enabled app backup paths in the displayed BackupPaths
appPaths := m.resolveAppBackupPaths()
if len(appPaths) > 0 {
status.BackupPaths = append(status.BackupPaths, appPaths...)
}
}
// Cross-check: if LastDBDump results have empty validation but files exist,
// re-validate from disk. This handles controller restarts and race conditions.
if m.lastDBDump != nil && filesErr == nil {