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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user