# TASK: Backup Page Overhaul — Unified App Backup Status & Bug Fixes ## Summary Overhaul the "Biztonsági mentés" page to show a single unified per-app backup overview with expandable detail rows, fix critical bugs (duplicate apps, misleading errors, dead toggles), and implement sequential backup chaining so cross-drive backups run after the nightly backup completes. --- ## Bug Fixes (Critical) ### Bug 1: Duplicate unconfigured apps on every page load **Root cause:** `GetFullStatus()` returns a pointer to `m.cachedStatus`. The `backupsHandler()` then appends to `UnconfiguredApps` and `CrossDriveSummary` on that cached object. Every page load appends again → 3 apps × 3 loads = 9 entries. **File:** `internal/backup/backup.go` — `GetFullStatus()` **Fix:** Return a **deep copy** of the cached status. Specifically, `CrossDriveSummary`, `UnconfiguredApps`, `CrossDriveWarnings`, and `AppDataInfo` slices must not share backing arrays with the cache. ```go func (m *Manager) GetFullStatus(nextDBDump, nextBackup time.Time) *FullBackupStatus { m.mu.Lock() defer m.mu.Unlock() if m.cachedStatus != nil { // Deep copy to prevent callers from mutating cached slices status := *m.cachedStatus status.AppDataInfo = make([]AppBackupInfo, len(m.cachedStatus.AppDataInfo)) copy(status.AppDataInfo, m.cachedStatus.AppDataInfo) status.CrossDriveSummary = nil // rebuilt by handler status.UnconfiguredApps = nil // rebuilt by handler status.CrossDriveWarnings = nil // rebuilt by handler // ... (keep existing dynamic field updates on &status) return &status } // ... (rest unchanged) } ``` **Alternative (simpler):** Don't populate `CrossDriveSummary`/`UnconfiguredApps` in the returned status at all — move that logic into a separate method or let `backupsHandler` build them from `AppDataInfo` + settings. This is cleaner architecturally since the cache shouldn't hold UI-assembly data. **Recommendation:** Alternative approach. The handler already builds this data; the cache should only hold the expensive-to-compute parts (app discovery, restic stats, snapshot history). ### Bug 2: Misleading error message for non-mount-point destinations **Root cause:** When cross-drive destination is configured to `/mnt/hdd_placeholder`, `IsMountPoint()` returns `false` (it's a directory on the system SSD, not a separate mount point). The code then shows: *"A cél tárhely (/mnt/hdd_placeholder) nem elérhető! Ellenőrizd a meghajtó csatlakozását."* — suggesting a disconnected drive, which is incorrect. **File:** `internal/web/handlers.go` — `deployHandler()` (line ~13818), `backupsHandler()` (line ~14036) **Current logic:** ```go if !system.IsMountPoint(path) || !system.IsWritable(path) { // "nem elérhető! Ellenőrizd a meghajtó csatlakozását." } ``` **Fix:** Replace the single check with tiered validation: ```go func validateBackupDestination(path string) (warning string, blocked bool) { // 1. Path doesn't exist at all if _, err := os.Stat(path); os.IsNotExist(err) { return "A cél tárhely (" + path + ") nem létezik!", true } // 2. Path exists but not writable if !system.IsWritable(path) { return "A cél tárhely (" + path + ") nem írható! Ellenőrizd a jogosultságokat.", true } // 3. Path is on the system drive (not a separate mount point) if !system.IsMountPoint(path) { return "A cél tárhely (" + path + ") a rendszermeghajtón van. " + "Meghajtóhiba esetén az eredeti adat és a mentés is elveszhet. " + "Külső meghajtó használata javasolt.", false // warning, not blocked } // 4. All good return "", false } ``` - `blocked = true` → red alert, backup should not run - `blocked = false, warning != ""` → yellow alert, backup can run but user is informed - No warning → green ### Bug 3: Dead `BackupEnabled` toggle **Root cause:** The `BackupEnabled` flag per app (toggled via `settingsAppBackupHandler`) saves to `settings.json` but nothing reads it to actually include/exclude apps from any backup process. There is no Backrest deployment; the nightly restic backup is a cron-driven script that doesn't consult settings.json. **Fix:** Remove the `settingsAppBackupHandler` and the bulk toggle form from the backup page. This toggle is replaced by the new unified per-app status rows (see Architecture section below). The concept of "include this app in nightly backup" becomes implicit: if the app has a DB, it gets dumped; if it has Docker volumes, they're in the restic paths; if it has HDD data, cross-drive must be explicitly configured. **Files to modify:** - `internal/web/handlers.go` — remove `settingsAppBackupHandler()` - `internal/web/server.go` — remove route registration for `POST /settings/app-backup` - `internal/web/templates/backups.html` — remove the bulk toggle form - `internal/settings/settings.go` — deprecate `SetAppBackupBulk()`, `IsAppBackupEnabled()` (keep getters for migration, remove after one version) --- ## Architecture Changes ### 1. Backup Coverage Model Each deployed app has up to 3 backup layers: | Layer | What | How | Needs user config? | |-------|------|-----|-------------------| | **DB dump** | PostgreSQL/MariaDB databases | `backup-db-dump.sh` via systemd timer | No (auto-discovered) | | **Docker volumes** | App configs, state, SQLite DBs | Nightly restic snapshot of named volumes | No (automatic for all deployed apps) | | **User data (HDD)** | Photos, documents, media files | Cross-drive rsync/restic to second drive | **Yes** — requires second drive + config | **Status colors per app:** | Color | Meaning | |-------|---------| | **Green** | All applicable backup layers are configured and last run was successful | | **Yellow** | Warning: last backup run failed/stale, OR destination drive has low space, OR backup is to system drive | | **Red** | Critical: app has HDD user data but no cross-drive backup configured, OR backup destination unreachable/disconnected | | **Gray/Auto** | App has no user-configurable backup needs (DB + volumes only, fully automatic) | **Logic for computing status per app:** ``` if app.HasHDDData: if no cross-drive configured: → RED ("Felhasználói adatokról nincs mentés") elif destination unreachable/disconnected: → RED ("Mentési cél nem elérhető") elif last cross-drive run failed: → YELLOW ("Utolsó mentés sikertelen") elif destination is system drive: → YELLOW ("Mentés a rendszermeghajtóra — külső meghajtó javasolt") elif destination drive >85% full: → YELLOW ("Mentési meghajtó majdnem megtelt") else: → GREEN else: → GREEN/AUTO (no user action needed) # Additionally, cross-cutting checks: if app.HasDB and last DB dump for this app failed: → YELLOW (even if user data is fine) ``` ### 2. Unified Per-App Backup Rows (UI) **Replace** the current two sections ("Alkalmazás adatok" + "Másolatok másik meghajtóra") with a **single section** containing one expandable row per deployed app. #### Collapsed row (default): ``` ┌──────────────────────────────────────────────────────────────────────┐ │ ● Immich Külső tárhely (hdd_1) 92 MB ▶ Részletek │ │ ● Paperless-ngx Külső tárhely (hdd_1) 12 MB ▶ Részletek │ │ ● Gokapi Auto ▶ Részletek │ │ ● Mealie Auto ▶ Részletek │ │ ● RomM Külső tárhely (hdd_1) 340 MB ▶ Részletek │ └──────────────────────────────────────────────────────────────────────┘ ``` - `●` = color indicator (green/yellow/red) - Storage label + size only shown for apps with HDD data - "Auto" badge for apps with only automatic backups - `▶ Részletek` = expand button (triangle/chevron) #### Expanded row (after clicking): ``` ┌──────────────────────────────────────────────────────────────────────┐ │ ● Immich Külső tárhely (hdd_1) 92 MB ▼ Részletek │ │ ┌────────────────────────────────────────────────────────────────┐ │ │ │ Adatbázis mentés Auto Utolsó: 02-17 02:31 │ │ │ │ Docker kötetek Auto Utolsó: 02-17 03:02 │ │ │ │ Felhasználói adatok rsync → Külső HDD (hdd_1) │ │ │ │ Ütemezés: Naponta │ │ │ │ Utolsó: 02-17 03:35 Sikeres │ │ │ │ [Beállítás] [Futtatás most] │ │ │ └────────────────────────────────────────────────────────────────┘ │ ├──────────────────────────────────────────────────────────────────────┤ │ ● Gokapi Auto ▼ Részletek │ │ ┌────────────────────────────────────────────────────────────────┐ │ │ │ Adatbázis mentés — (nincs adatbázis) │ │ │ │ Docker kötetek Auto Utolsó: 02-17 03:02 │ │ │ │ Felhasználói adatok — (nincs HDD adat) │ │ │ └────────────────────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────────────┘ ``` **For apps with HDD data but NO cross-drive configured (RED status):** ``` │ Felhasználói adatok ⚠ Nincs beállítva │ │ Mentés beállítása az alkalmazás │ │ oldalán: [Immich beállítások →] │ ``` **For apps with HDD data and destination is system drive (YELLOW):** ``` │ Felhasználói adatok rsync → Rendszer SSD (hdd_placeholder) │ │ ⚠ Rendszermeghajtóra mentés — │ │ meghajtóhiba esetén mindkét másolat │ │ elveszhet. Külső meghajtó javasolt. │ ``` #### No second drive at all — top-level warning If zero apps have cross-drive configured AND at least one app has HDD data, show a prominent card above the app list: ``` ┌──────────────────────────────────────────────────────────────────────┐ │ ⚠ Felhasználói adatokról nincs biztonsági mentés │ │ │ │ A szerveren tárolt fotók, dokumentumok és egyéb fájlok jelenleg │ │ csak egy példányban léteznek. Külső meghajtó csatlakoztatásával │ │ biztonsági másolat készíthető a 3-2-1 szabály szerint. │ │ │ │ [Meghajtó beállítása →] │ └──────────────────────────────────────────────────────────────────────┘ ``` ### 3. Sequential Backup Chaining **Current flow:** ``` 02:30 systemd timer → backup-db-dump.sh (DB dumps) 03:00 cron/scheduler → restic backup (Docker volumes + DB dumps) 03:30 independent scheduler → cross-drive jobs (own schedule) ``` **New flow:** ``` 02:30 systemd timer → backup-db-dump.sh (DB dumps) 03:00 scheduler → restic backup (Docker volumes + DB dumps) ↓ on completion (success or fail) scheduler → cross-drive jobs (for apps with daily schedule) (weekly jobs: only triggered on configured day, e.g., Sunday) ``` **Implementation:** The controller's main scheduler loop already triggers DB dump and restic backup in sequence. After the restic backup completes, add: ```go // After restic backup completes if crossDriveRunner != nil { schedule := "daily" if time.Now().Weekday() == time.Sunday { // Also run weekly jobs on Sunday crossDriveRunner.RunAllScheduled(ctx, "weekly") } crossDriveRunner.RunAllScheduled(ctx, schedule) } ``` **User-facing schedule options on deploy page (cross-drive config):** | Option | Meaning | |--------|---------| | Naponta | Runs every night after nightly backup completes | | Hetente (vasárnap) | Runs only on Sunday night after nightly backup | **Remove** the "Csak kézi indítás" (manual only) schedule option. If a user doesn't want automated cross-drive, they can disable the toggle. Manual trigger ("Futtatás most") remains available regardless. **Weekly + daily DB consistency:** When the user selects "Hetente" for cross-drive backup, show an informational note: ``` ℹ Heti mentés esetén visszaállításkor az adatbázis is a mentés napjára áll vissza a konzisztencia érdekében. A mentés napja és a visszaállítás között keletkezett adatbázis-változások elvesznek (max. 7 nap). ``` **Restore implication:** When restoring user data from a weekly cross-drive snapshot, the restore process should also restore the DB dump from the same date (matching by timestamp). This ensures DB ↔ file consistency. ### 4. Cross-Drive Destination Validation **Replace** the current binary `IsMountPoint || !IsWritable` check with tiered validation. **New function:** `internal/system/mounts_linux.go` ```go type DestinationHealth struct { Exists bool Writable bool MountPoint bool // different device from parent SystemDrive bool // on same device as / UsedPercent float64 // disk usage percentage FreeGB float64 Warning string // human-readable warning (Hungarian) Blocked bool // if true, backup should not run Severity string // "ok", "warning", "critical" } func CheckBackupDestination(path string) DestinationHealth { ... } ``` **Validation tiers:** | Condition | Severity | Blocked? | Message | |-----------|----------|----------|---------| | Path doesn't exist | critical | yes | "A cél tárhely nem létezik" | | Path not writable | critical | yes | "A cél tárhely nem írható" | | Same block device as source | critical | yes | "A forrás és a cél azonos meghajtón van" | | Path is on system drive (/) | warning | no | "Mentés a rendszermeghajtóra — meghajtóhiba esetén mindkét másolat elveszhet" | | Destination >90% full | warning | no | "A mentési meghajtó majdnem megtelt (X% használt)" | | Destination >95% full | critical | yes | "A mentési meghajtó megtelt (X%)" | | Mount point, writable, healthy | ok | no | "" | **Same block device detection:** ```go func isSameBlockDevice(pathA, pathB string) bool { var statA, statB syscall.Stat_t if err := syscall.Stat(pathA, &statA); err != nil { return false } if err := syscall.Stat(pathB, &statB); err != nil { return false } return statA.Dev == statB.Dev } ``` This replaces the current `IsMountPoint()` check in the destination dropdown filtering and the health warning logic. ### 5. Drive Disconnection Notifications **Trigger:** During the nightly backup chain, before running cross-drive jobs, check all configured destinations. If any are unreachable: ```go for _, app := range appsWithCrossDrive { health := system.CheckBackupDestination(app.DestinationPath) if health.Blocked { notifier.NotifyBackupFailed( "Mentési meghajtó nem elérhető", fmt.Sprintf("%s mentési célja (%s) nem elérhető: %s", app.DisplayName, app.DestinationPath, health.Warning), ) } } ``` **Also check on controller startup** (in `main.go` boot sequence) — if a configured destination is unreachable, log a warning and set the status. The backup page will show it immediately. **Also check in the 15-minute hub report** — include destination health in the status payload so the hub dashboard shows drive problems centrally. --- ## Implementation Steps ### Step 1: Fix duplicate apps bug (Bug 1) **Files:** - `internal/backup/backup.go` — `GetFullStatus()`: return deep copy with nil cross-drive/unconfigured slices - `internal/web/handlers.go` — `backupsHandler()`: verify no mutation of cached data **Test:** Load backup page 5 times → unconfigured apps count stays consistent. ### Step 2: Fix destination validation (Bug 2 + Architecture item 4) **Files:** - `internal/system/mounts_linux.go` — add `CheckBackupDestination()`, `isSameBlockDevice()` - `internal/system/mounts_other.go` — add stubs - `internal/web/handlers.go` — `deployHandler()`: replace `IsMountPoint || !IsWritable` with `CheckBackupDestination()` - `internal/web/handlers.go` — `backupsHandler()`: same replacement for cross-drive warnings - `internal/web/templates/deploy.html` — update warning display to handle `warning` vs `critical` severity **Test:** - Configure cross-drive to `hdd_placeholder` → shows yellow warning about system drive, backup still allowed - Configure cross-drive to `hdd_1` (external) → green, no warning - Disconnect external drive → shows red "nem elérhető" - Configure source and dest on same drive → blocked ### Step 3: Remove dead BackupEnabled toggle (Bug 3) **Files:** - `internal/web/handlers.go` — remove `settingsAppBackupHandler()` - `internal/web/server.go` — remove `POST /settings/app-backup` route - `internal/web/templates/backups.html` — remove bulk toggle form, remove "Aktív/Inaktív" badges from old section **Test:** Backup page loads without toggle form. No 500 errors. ### Step 4: Unified per-app backup rows (Architecture item 2) **New struct:** ```go // AppBackupRow holds all backup information for one app, used by the template. type AppBackupRow struct { StackName string DisplayName string Status string // "green", "yellow", "red", "auto" StatusText string // short Hungarian text for the indicator tooltip // Storage info (HDD apps only) HasHDDData bool StorageLabel string HDDSizeHuman string // Backup layers DBBackup *BackupLayerInfo // nil if app has no DB VolumeBackup *BackupLayerInfo // always present for deployed apps UserDataBackup *BackupLayerInfo // nil if app has no HDD data // Warnings (shown in expanded view) Warnings []string } type BackupLayerInfo struct { Type string // "db", "volume", "userdata" Label string // "Adatbázis mentés", "Docker kötetek", "Felhasználói adatok" Auto bool // true if no user config needed Configured bool // true if backup is set up for this layer Method string // "restic", "rsync", "" (for auto) Destination string // label of destination (for cross-drive) Schedule string // "Naponta", "Hetente", "" (for auto) LastRun string // formatted timestamp LastStatus string // "ok", "error", "running", "" LastError string // error message if failed StatusBadge string // "Sikeres", "Hiba", "Fut...", "Auto", "—" ConfigURL string // link to configure (deploy page) } ``` **Files:** - `internal/web/handlers.go` — new `buildAppBackupRows()` function, called from `backupsHandler()` - `internal/web/templates/backups.html` — replace both sections with unified expandable rows - `internal/web/templates/style.css` — new styles for expandable rows, status indicators, layer detail grid **Template structure (backups.html):** ```html