diff --git a/CONTEXT.md b/CONTEXT.md index 8ae0eac..bbab884 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,7 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-02-16 (session 24) +Last updated: 2026-02-16 (session 25) --- @@ -22,18 +22,50 @@ Last updated: 2026-02-16 (session 24) ## Current project state ### felhom-controller (this repo) -- **Version:** v0.7.2 +- **Version:** v0.8.0 - **Phase 1:** ✅ COMPLETE — Stack Manager + Deploy Flow - **Phase 2:** ✅ COMPLETE — Monitoring & Health (scheduler, CPU/temp, healthchecks.io pings) - **Phase 3:** ✅ COMPLETE — Backups (DB dumps, restic integration, manual trigger, **dedicated backup page**) - **Phase 4:** ✅ COMPLETE — Monitoring Page with Metrics Store (SQLite, Chart.js, system + container metrics) - **Phase 5:** ✅ COMPLETE — Authentication, Persistence & Settings Page (settings.json, password change, session management) - **Phase 6:** ✅ COMPLETE — Monitoring Warnings, Dashboard Alerts & Notification System +- **Phase 7:** ✅ COMPLETE — Storage Overview, Per-App Backup Toggles & Limited Restore - **First app deployed:** Paperless-ngx on demo-felhom.eu (2026-02-13) - **Running on:** demo-felhom (N100 mini PC) at 192.168.0.162:8080 - **All Phase 1-5 features working:** deploy, start/stop/restart/update, logs, health-aware states, auth, monitoring, backups, backup detail page, system monitoring page, settings page -### What was just completed (2026-02-16 session 24) +### What was just completed (2026-02-16 session 25) +- **v0.8.0 — Phase 7: Storage Overview, Per-App Backup Toggles & Limited Restore:** + - **Storage overview on backup page** — new "Tárhely áttekintés" section as first section on backup page showing SSD/HDD progress bars + backup repo stats (repo size, dump file count, snapshot count). Reuses existing `system.GetInfo()` and `RepoStats`. + - **Restic password visibility** — new "Titkosítási kulcs" section inside the repository card. Masked password field with show/copy buttons (JS toggle). Password synced to hub via periodic report for disaster recovery (`ResticPassword` field added to `BackupReport`). + - **App data discovery** — new `internal/backup/appdata.go`: + - `StackDataProvider` interface to avoid circular imports between backup and stacks packages + - `AppBackupInfo`, `AppDataPath`, `AppDockerVolume` structs + - `DiscoverAppData()` iterates deployed stacks, discovers HDD bind mounts (via adapter calling `ParseComposeHDDMounts`), Docker named volumes (via `parseComposeNamedVolumes` using YAML parser), and DB dump status + - Stack adapter in `main.go` implements `StackDataProvider` using `stacks.Manager` + - **Per-app backup toggles** — new "Alkalmazás adatok" section on backup page: + - Toggle checkbox per app (only for apps with HDD data) + - Shows HDD paths with sizes, Docker volume info, DB dump notes + - `POST /settings/app-backup` handler saves preferences to `settings.json` + - `AppBackupPrefs` struct + bulk getter/setter in `settings.go` + - `RefreshCache()` populates `AppDataInfo` via `DiscoverAppData()` + - **Dynamic backup paths** — `RunBackup()` now includes enabled app HDD data paths: + - `resolveAppBackupPaths()` reads enabled apps from settings, resolves HDD paths via provider + - Paths logged at INFO level, included in restic snapshot + - `BackupPaths` display on backup page includes app data paths + - **Limited app restore** — new restore section on backup page: + - `RestoreApp()` in `restore.go`: validates enabled, resolves HDD paths, validates snapshot exists, uses running mutex + - `RestoreAppData()` on `ResticManager`: runs `restic restore` with `--include` flags for specific paths + - `POST /backup/restore` web handler with confirmation flow + - `GET /api/backup/snapshots` JSON endpoint for restore dropdown + - UI: app/snapshot dropdowns, warning box, confirmation checkbox, JS-driven form submission + - **Exported `ParseComposeHDDMounts`** from stacks package (was unexported `parseComposeHDDMounts`) + - **Flash messages** on backup page via query params (success/error redirects from handlers) + - **CSS**: New styles for storage overview grid, app backup toggles, encryption key field, restore section, flash messages + - **Files created**: `appdata.go`, `restore.go` + - **Files modified**: `backup.go`, `restic.go`, `handlers.go`, `server.go`, `backups.html`, `style.css`, `settings.go`, `delete.go`, `router.go`, `types.go`, `builder.go`, `main.go` + +### What was previously completed (2026-02-16 session 24) - **v0.7.2 — Fix Notification Preferences Sync (Controller → Hub):** - **Two repos changed** (deploy-felhom-compose + felhom.eu): - **Hub: `POST /api/v1/preferences` endpoint** (`hub/internal/api/handler.go`): @@ -498,18 +530,16 @@ Last updated: 2026-02-16 (session 24) 7. Documentation: restart vs up -d for image updates ### What's next (priorities) -1. **Deploy v0.7.2** — Build + deploy both hub (0.1.5) and controller (0.7.2): - - Hub must be deployed FIRST (controller needs /api/v1/preferences endpoint) - - Then build + deploy controller - - Test: save notification settings → hub logs "Notification preferences updated" → "Teszt email küldése" → email arrives - - Verify: hub customer detail page shows notification email + events + log -2. **Test alert banners** — Configure some missing ping UUIDs or disable backup to verify yellow/red banners appear -3. **Test backup flow** — trigger manual backup via dashboard, verify restic repo + DB dumps -4. Add `app_info` + `optional_config` to more apps (start with Immich, Mealie, Vaultwarden) -5. Deploy a second app (e.g., ActualBudget — simplest, or Immich — tests HDD + secrets) -6. Test on Raspberry Pi (pi-customer-1) -7. Self-update mechanism -8. Hub alerting (webhook to Healthchecks for stale customers) +1. **Deploy v0.8.0** — Build + deploy controller v0.8.0 to demo-felhom.eu +2. **Test per-app backup** — enable backup for Paperless-ngx HDD data, trigger manual backup, verify restic snapshot includes HDD paths +3. **Test restore** — restore app data from snapshot, verify file recovery +4. **Change HDD mount to :rw** — currently `:ro` in controller docker-compose.yml; required for restore to work +5. Add `app_info` + `optional_config` to more apps (start with Immich, Mealie, Vaultwarden) +6. Deploy a second app (e.g., ActualBudget — simplest, or Immich — tests HDD + secrets) +7. Test on Raspberry Pi (pi-customer-1) +8. Self-update mechanism +9. Hub alerting (webhook to Healthchecks for stale customers) +10. Docker volume backup (mount `/var/lib/docker/volumes:ro` into controller) ## Architecture decisions @@ -548,6 +578,10 @@ Last updated: 2026-02-16 (session 24) | Resend HTTP API (no SMTP) | Direct POST to api.resend.com — same pattern as website contact-mailer. Simpler than SMTP setup, good deliverability | | Preferences sync on save + startup | Controller pushes prefs to hub (not pull). Startup sync handles hub DB rebuild. Local save always succeeds even if sync fails | | Chart.js embedded locally | Customer hardware may not have internet — CDN not reliable for offline environments | +| StackDataProvider interface | backup package needs stack data but can't import stacks (circular). Interface in backup, thin adapter in main.go | +| Password sync to hub via report | Restic password in Docker named volume on SSD. Hub sync provides redundancy for disaster recovery | +| App backup via HDD mounts only | Docker volumes at /var/lib/docker/volumes/ not mounted in controller. HDD data is the important user data; DB in volumes covered by nightly dump | +| Restore uses running mutex | Prevents concurrent backup+restore on same restic repo. Reuses existing `m.running` flag | | Metrics downsampling via SQL | Bucket-based AVG in GROUP BY keeps Chart.js responsive with up to 30 days of data | | 60s metrics collection interval | Good balance of resolution vs. storage — ~44K rows/month for system metrics | | /etc/os-release mounted read-only | Container can't read host OS info directly — mount to /host/etc/os-release:ro | diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 240924d..9f623b3 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -109,6 +109,7 @@ func main() { var backupMgr *backup.Manager if cfg.Backup.Enabled { backupMgr = backup.NewManager(cfg, pinger, sett, logger) + backupMgr.SetStackProvider(&stackAdapter{mgr: stackMgr, hddPath: cfg.Paths.HDDPath}) backupMgr.AfterBackup = func() { nextDBDump := scheduler.NextDailyRun(cfg.Backup.DBDumpSchedule) nextBackup := scheduler.NextDailyRun(cfg.Backup.ResticSchedule) @@ -314,3 +315,41 @@ func setupLogger(cfg *config.Config) *log.Logger { return logger } + +// stackAdapter implements backup.StackDataProvider using stacks.Manager. +type stackAdapter struct { + mgr *stacks.Manager + hddPath string +} + +func (a *stackAdapter) GetStackComposePath(name string) (string, bool) { + s, ok := a.mgr.GetStack(name) + if !ok { + return "", false + } + return s.ComposePath, true +} + +func (a *stackAdapter) ListDeployedStacks() []backup.StackSummary { + var result []backup.StackSummary + for _, s := range a.mgr.GetStacks() { + if !s.Deployed { + continue + } + result = append(result, backup.StackSummary{ + Name: s.Name, + DisplayName: s.Meta.DisplayName, + ComposePath: s.ComposePath, + NeedsHDD: s.Meta.Resources.NeedsHDD, + }) + } + return result +} + +func (a *stackAdapter) GetStackHDDMounts(name string) []string { + s, ok := a.mgr.GetStack(name) + if !ok { + return nil + } + return stacks.ParseComposeHDDMounts(s.ComposePath, a.hddPath) +} diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index d97faae..805fb18 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -114,6 +114,10 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { case path == "/backup/run" && req.Method == http.MethodPost: r.triggerBackup(w, req) + // GET /api/backup/snapshots + case path == "/backup/snapshots" && req.Method == http.MethodGet: + r.backupSnapshots(w, req) + // GET /api/metrics/system case path == "/metrics/system" && req.Method == http.MethodGet: r.metricsSystem(w, req) @@ -422,6 +426,23 @@ func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"}) } +func (r *Router) backupSnapshots(w http.ResponseWriter, _ *http.Request) { + if r.backupMgr == nil { + writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: []interface{}{}}) + return + } + + snapshots, err := r.backupMgr.ListSnapshots(50) + if err != nil { + writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()}) + return + } + if snapshots == nil { + snapshots = []backup.SnapshotInfo{} + } + writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: snapshots}) +} + // --- Metrics handlers --- func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) { diff --git a/controller/internal/backup/appdata.go b/controller/internal/backup/appdata.go new file mode 100644 index 0000000..a707698 --- /dev/null +++ b/controller/internal/backup/appdata.go @@ -0,0 +1,181 @@ +package backup + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "gopkg.in/yaml.v3" +) + +// StackDataProvider provides stack data to the backup package without circular imports. +type StackDataProvider interface { + GetStackComposePath(name string) (composePath string, ok bool) + ListDeployedStacks() []StackSummary + GetStackHDDMounts(name string) []string +} + +// StackSummary holds minimal stack info needed for app data discovery. +type StackSummary struct { + Name string + DisplayName string + ComposePath string + NeedsHDD bool +} + +// AppBackupInfo holds backup-relevant data paths for a deployed app. +type AppBackupInfo struct { + StackName string + DisplayName string + NeedsHDD bool + HDDPaths []AppDataPath + HDDTotalSize int64 + HDDSizeHuman string + DockerVolumes []AppDockerVolume + BackupEnabled bool + HasHDDData bool + HasDBDump bool +} + +// AppDataPath represents a single HDD bind mount path. +type AppDataPath struct { + HostPath string + Exists bool + SizeHuman string + SizeBytes int64 +} + +// AppDockerVolume represents a named Docker volume. +type AppDockerVolume struct { + Name string + Contains string +} + +// DiscoverAppData discovers backup-relevant data for all deployed apps. +func DiscoverAppData(provider StackDataProvider, hddPath string, backupPrefs map[string]bool, discoveredDBs []DiscoveredDB) []AppBackupInfo { + if provider == nil { + return nil + } + + var result []AppBackupInfo + + for _, stack := range provider.ListDeployedStacks() { + info := AppBackupInfo{ + StackName: stack.Name, + DisplayName: stack.DisplayName, + NeedsHDD: stack.NeedsHDD, + } + + // Discover HDD bind mounts via adapter + hddMounts := provider.GetStackHDDMounts(stack.Name) + for _, mount := range hddMounts { + path := AppDataPath{HostPath: mount} + if fi, err := os.Stat(mount); err == nil && fi.IsDir() { + path.Exists = true + path.SizeBytes = appDirSizeBytes(mount) + path.SizeHuman = appDirSizeHuman(mount) + } + info.HDDPaths = append(info.HDDPaths, path) + info.HDDTotalSize += path.SizeBytes + } + info.HDDSizeHuman = humanizeBytes(info.HDDTotalSize) + info.HasHDDData = len(info.HDDPaths) > 0 + + // Discover Docker named volumes from compose + info.DockerVolumes = parseComposeNamedVolumes(stack.ComposePath) + + // Check if app has a DB container (already backed up via DB dump) + for _, db := range discoveredDBs { + if db.StackName == stack.Name { + info.HasDBDump = true + break + } + } + + info.BackupEnabled = backupPrefs[stack.Name] + + // Only include apps that have some data to show + if info.HasHDDData || len(info.DockerVolumes) > 0 { + result = append(result, info) + } + } + + return result +} + +// parseComposeNamedVolumes extracts named Docker volumes from a docker-compose.yml. +func parseComposeNamedVolumes(composePath string) []AppDockerVolume { + data, err := os.ReadFile(composePath) + if err != nil { + return nil + } + + var compose struct { + Volumes map[string]interface{} `yaml:"volumes"` + } + if err := yaml.Unmarshal(data, &compose); err != nil { + return nil + } + + var volumes []AppDockerVolume + for name, cfg := range compose.Volumes { + // Skip external volumes + if cfgMap, ok := cfg.(map[string]interface{}); ok { + if ext, ok := cfgMap["external"]; ok && ext == true { + continue + } + } + volumes = append(volumes, AppDockerVolume{Name: name}) + } + return volumes +} + +// appDirSizeHuman returns a human-readable size string for a directory using du. +func appDirSizeHuman(path string) string { + cmd := exec.Command("du", "-sh", path) + output, err := cmd.Output() + if err != nil { + return "?" + } + fields := strings.Fields(string(output)) + if len(fields) > 0 { + return fields[0] + } + return "?" +} + +// appDirSizeBytes returns the total size in bytes for a directory. +func appDirSizeBytes(path string) int64 { + cmd := exec.Command("du", "-sb", path) + output, err := cmd.Output() + if err != nil { + return 0 + } + fields := strings.Fields(string(output)) + if len(fields) > 0 { + var size int64 + fmt.Sscanf(fields[0], "%d", &size) + return size + } + return 0 +} + +// humanizeBytes converts bytes to a human-readable string. +func humanizeBytes(b int64) string { + const ( + KB = 1024 + MB = KB * 1024 + GB = MB * 1024 + ) + switch { + case b >= GB: + return fmt.Sprintf("%.1f GB", float64(b)/float64(GB)) + case b >= MB: + return fmt.Sprintf("%.1f MB", float64(b)/float64(MB)) + case b >= KB: + return fmt.Sprintf("%.1f KB", float64(b)/float64(KB)) + default: + return fmt.Sprintf("%d B", b) + } +} diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go index 76d8380..6020416 100644 --- a/controller/internal/backup/backup.go +++ b/controller/internal/backup/backup.go @@ -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 { diff --git a/controller/internal/backup/restic.go b/controller/internal/backup/restic.go index debe603..54f76a3 100644 --- a/controller/internal/backup/restic.go +++ b/controller/internal/backup/restic.go @@ -303,6 +303,41 @@ func (r *ResticManager) Stats() (*RepoStats, error) { return stats, nil } +// GetPassword reads and returns the restic repository password. +func (r *ResticManager) GetPassword() (string, error) { + data, err := os.ReadFile(r.passwordFile) + if err != nil { + return "", fmt.Errorf("reading restic password: %w", err) + } + return strings.TrimSpace(string(data)), nil +} + +// RestoreAppData restores specific paths from a restic snapshot. +func (r *ResticManager) RestoreAppData(snapshotID string, paths []string) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + + args := []string{ + "restore", snapshotID, + "--target", "/", + } + for _, p := range paths { + args = append(args, "--include", p) + } + + r.logger.Printf("[WARN] RESTORE started: snapshot=%s, paths=%v", snapshotID, paths) + + cmd := r.command(ctx, args...) + output, err := cmd.CombinedOutput() + if err != nil { + r.logger.Printf("[ERROR] Restore failed: %v, output: %s", err, truncate(string(output), 500)) + return fmt.Errorf("restic restore failed: %w", err) + } + + r.logger.Printf("[INFO] RESTORE completed: snapshot=%s, paths=%v", snapshotID, paths) + return nil +} + func (r *ResticManager) command(ctx context.Context, args ...string) *exec.Cmd { cmd := exec.CommandContext(ctx, "restic", args...) cmd.Env = append(os.Environ(), diff --git a/controller/internal/backup/restore.go b/controller/internal/backup/restore.go new file mode 100644 index 0000000..f826e3b --- /dev/null +++ b/controller/internal/backup/restore.go @@ -0,0 +1,62 @@ +package backup + +import "fmt" + +// RestoreApp restores an app's HDD data from a restic snapshot. +func (m *Manager) RestoreApp(stackName, snapshotID string) error { + // Validate app has backup enabled + if !m.settings.IsAppBackupEnabled(stackName) { + return fmt.Errorf("backup not enabled for %s", stackName) + } + + // Resolve HDD paths for this app + if m.stackProvider == nil { + return fmt.Errorf("stack provider not configured") + } + hddMounts := m.stackProvider.GetStackHDDMounts(stackName) + if len(hddMounts) == 0 { + return fmt.Errorf("no HDD data paths found for %s", stackName) + } + + // Validate snapshot exists + snapshots, err := m.restic.ListSnapshots(100) + if err != nil { + return fmt.Errorf("listing snapshots: %w", err) + } + found := false + for _, s := range snapshots { + if s.ID == snapshotID { + found = true + break + } + } + if !found { + return fmt.Errorf("snapshot %s not found", snapshotID) + } + + // Use the running flag to prevent concurrent backup/restore + m.mu.Lock() + if m.running { + m.mu.Unlock() + return fmt.Errorf("backup or restore already in progress") + } + m.running = true + m.mu.Unlock() + + defer func() { + m.mu.Lock() + m.running = false + m.mu.Unlock() + }() + + m.logger.Printf("[WARN] RESTORE starting: stack=%s, snapshot=%s, paths=%v", stackName, snapshotID, hddMounts) + + // Execute restore + if err := m.restic.RestoreAppData(snapshotID, hddMounts); err != nil { + m.logger.Printf("[ERROR] RESTORE failed for %s: %v", stackName, err) + return err + } + + m.logger.Printf("[INFO] RESTORE completed: stack=%s, snapshot=%s", stackName, snapshotID) + return nil +} diff --git a/controller/internal/report/builder.go b/controller/internal/report/builder.go index 367a787..a5e6230 100644 --- a/controller/internal/report/builder.go +++ b/controller/internal/report/builder.go @@ -172,6 +172,11 @@ func buildBackupReport(cfg *config.Config, backupMgr *backup.Manager) BackupRepo } br.IntegrityOK = status.LastCheckOK + // Include restic password for hub-side disaster recovery + if pw, err := backupMgr.GetResticPassword(); err == nil { + br.ResticPassword = pw + } + return br } diff --git a/controller/internal/report/types.go b/controller/internal/report/types.go index c22ad9c..9ac6709 100644 --- a/controller/internal/report/types.go +++ b/controller/internal/report/types.go @@ -69,6 +69,7 @@ type BackupReport struct { RepoSizeMB int64 `json:"repo_size_mb"` LastIntegrityCheck *time.Time `json:"last_integrity_check,omitempty"` IntegrityOK bool `json:"integrity_ok"` + ResticPassword string `json:"restic_password,omitempty"` } // HealthReport holds the aggregated health status. diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index c2a3ba4..d4bd4bb 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -24,6 +24,14 @@ type Settings struct { // Cached state DBValidations map[string]DBValidationCache `json:"db_validations,omitempty"` + + // Per-app backup preferences + AppBackup map[string]AppBackupPrefs `json:"app_backup,omitempty"` +} + +// AppBackupPrefs holds per-app backup toggle state. +type AppBackupPrefs struct { + Enabled bool `json:"enabled"` } // NotificationPrefs holds customer notification preferences. @@ -170,3 +178,49 @@ func (s *Settings) SetNotificationPrefs(prefs *NotificationPrefs) error { s.Notifications = prefs return s.save() } + +// IsAppBackupEnabled returns whether backup is enabled for the given stack. +func (s *Settings) IsAppBackupEnabled(stackName string) bool { + s.mu.RLock() + defer s.mu.RUnlock() + if s.AppBackup == nil { + return false + } + return s.AppBackup[stackName].Enabled +} + +// SetAppBackup enables or disables backup for a stack and saves to disk. +func (s *Settings) SetAppBackup(stackName string, enabled bool) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.AppBackup == nil { + s.AppBackup = make(map[string]AppBackupPrefs) + } + s.AppBackup[stackName] = AppBackupPrefs{Enabled: enabled} + return s.save() +} + +// GetAppBackupMap returns a map of stack_name -> enabled for all app backup prefs. +func (s *Settings) GetAppBackupMap() map[string]bool { + s.mu.RLock() + defer s.mu.RUnlock() + if s.AppBackup == nil { + return nil + } + result := make(map[string]bool, len(s.AppBackup)) + for k, v := range s.AppBackup { + result[k] = v.Enabled + } + return result +} + +// SetAppBackupBulk updates backup prefs for all stacks at once and saves to disk. +func (s *Settings) SetAppBackupBulk(prefs map[string]bool) error { + s.mu.Lock() + defer s.mu.Unlock() + s.AppBackup = make(map[string]AppBackupPrefs, len(prefs)) + for name, enabled := range prefs { + s.AppBackup[name] = AppBackupPrefs{Enabled: enabled} + } + return s.save() +} diff --git a/controller/internal/stacks/delete.go b/controller/internal/stacks/delete.go index 4e3210b..f9ef8d3 100644 --- a/controller/internal/stacks/delete.go +++ b/controller/internal/stacks/delete.go @@ -81,7 +81,7 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse, } // Step 1: Parse compose file for HDD bind mounts - hddMounts := parseComposeHDDMounts(stack.ComposePath, hddPath) + hddMounts := ParseComposeHDDMounts(stack.ComposePath, hddPath) // Step 2: Run docker compose down --rmi local --volumes env := m.stackEnv(stackDir) @@ -164,7 +164,7 @@ func (m *Manager) GetStackHDDData(name string) (*HDDDataResponse, error) { return resp, nil } - mounts := parseComposeHDDMounts(stack.ComposePath, hddPath) + mounts := ParseComposeHDDMounts(stack.ComposePath, hddPath) protected := protectedHDDPaths(hddPath) for _, mount := range mounts { @@ -197,9 +197,9 @@ func (m *Manager) GetStackHDDData(name string) (*HDDDataResponse, error) { return resp, nil } -// parseComposeHDDMounts reads a docker-compose.yml and extracts host paths +// ParseComposeHDDMounts reads a docker-compose.yml and extracts host paths // that reference the HDD path from volume bind mounts. -func parseComposeHDDMounts(composePath, hddPath string) []string { +func ParseComposeHDDMounts(composePath, hddPath string) []string { if hddPath == "" { return nil } diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 016bfd6..773c471 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -237,14 +237,31 @@ func isPingConfigured(uuid string) bool { return uuid != "" && !strings.HasPrefix(uuid, "CHANGEME") } -func (s *Server) backupsHandler(w http.ResponseWriter, _ *http.Request) { +func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) { data := s.baseData("backups", "Biztonsági mentés") + // System info for storage overview bars + data["SystemInfo"] = system.GetInfo(s.cfg.Paths.HDDPath, s.cpuCollector) + if s.backupMgr != nil { nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule) nextBackup := scheduler.NextDailyRun(s.cfg.Backup.ResticSchedule) fullStatus := s.backupMgr.GetFullStatus(nextDBDump, nextBackup) + + // Pass flash messages from query params (set by redirect handlers) + if flash := r.URL.Query().Get("flash"); flash != "" { + fullStatus.FlashSuccess = flash + } + if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" { + fullStatus.FlashError = flashErr + } + data["Backup"] = fullStatus + + // Restic password for display + if pw, err := s.backupMgr.GetResticPassword(); err == nil { + data["ResticPassword"] = pw + } } else { data["Backup"] = nil } @@ -252,6 +269,69 @@ func (s *Server) backupsHandler(w http.ResponseWriter, _ *http.Request) { s.render(w, "backups", data) } +func (s *Server) settingsAppBackupHandler(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + + if s.backupMgr == nil { + http.Redirect(w, r, "/backups", http.StatusFound) + return + } + + // Get current app data info to know which stacks have HDD data + nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule) + nextBackup := scheduler.NextDailyRun(s.cfg.Backup.ResticSchedule) + fullStatus := s.backupMgr.GetFullStatus(nextDBDump, nextBackup) + + prefs := make(map[string]bool) + for _, app := range fullStatus.AppDataInfo { + if app.HasHDDData { + prefs[app.StackName] = r.FormValue("backup_"+app.StackName) == "on" + } + } + + if err := s.settings.SetAppBackupBulk(prefs); err != nil { + s.logger.Printf("[ERROR] Failed to save app backup prefs: %v", err) + http.Redirect(w, r, "/backups?flash_error=Hiba+a+ment%C3%A9skor", http.StatusFound) + return + } + + s.logger.Printf("[INFO] App backup preferences updated: %v", prefs) + + // Trigger cache refresh so the page shows updated data + go s.backupMgr.RefreshCache(nextDBDump, nextBackup) + + http.Redirect(w, r, "/backups?flash=Alkalmaz%C3%A1s+ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1sok+mentve.", http.StatusFound) +} + +func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + + stackName := r.FormValue("stack_name") + snapshotID := r.FormValue("snapshot_id") + + if stackName == "" || snapshotID == "" { + http.Redirect(w, r, "/backups?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound) + return + } + + if s.backupMgr == nil { + http.Redirect(w, r, "/backups?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound) + return + } + + s.logger.Printf("[WARN] Restore requested: stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr) + + if err := s.backupMgr.RestoreApp(stackName, snapshotID); err != nil { + s.logger.Printf("[ERROR] Restore failed: %v", err) + errMsg := url.QueryEscape("Visszaállítás sikertelen: " + err.Error()) + http.Redirect(w, r, "/backups?flash_error="+errMsg, http.StatusFound) + return + } + + msg := url.QueryEscape(stackName + " visszaállítva (" + snapshotID + ").") + http.Redirect(w, r, "/backups?flash="+msg, http.StatusFound) +} + func (s *Server) settingsData() map[string]interface{} { data := s.baseData("settings", "Beállítások") data["CustomerID"] = s.cfg.Customer.ID diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 6dff848..78d5247 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -94,6 +94,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.settingsNotificationsHandler(w, r) case path == "/settings/notifications/test" && r.Method == http.MethodPost: s.settingsNotificationsTestHandler(w, r) + case path == "/settings/app-backup" && r.Method == http.MethodPost: + s.settingsAppBackupHandler(w, r) + case path == "/backup/restore" && r.Method == http.MethodPost: + s.backupRestoreHandler(w, r) case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/logs"): name := strings.TrimPrefix(path, "/stacks/") name = strings.TrimSuffix(name, "/logs") diff --git a/controller/internal/web/templates/backups.html b/controller/internal/web/templates/backups.html index 2e03190..f7b3bfe 100644 --- a/controller/internal/web/templates/backups.html +++ b/controller/internal/web/templates/backups.html @@ -6,6 +6,13 @@ {{.Domain}} +{{if .Backup}}{{if .Backup.FlashSuccess}} +
Az alkalmazások felhasználói adatainak biztonsági mentése.
+ +