Files
deploy-felhom-compose/TASK.md
T

724 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
<div class="backup-section-card">
<h3>Alkalmazások mentési állapota</h3>
{{if .NoUserDataBackupWarning}}
<div class="alert alert-error" style="margin-bottom:1.5rem">
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.
<a href="/settings">Meghajtó beállítása</a>
</div>
{{end}}
{{range .AppBackupRows}}
<div class="app-backup-row" data-status="{{.Status}}">
<div class="app-backup-row-header" onclick="toggleBackupDetail(this)">
<span class="status-dot status-{{.Status}}" title="{{.StatusText}}"></span>
<span class="app-backup-row-name">{{.DisplayName}}</span>
<div class="app-backup-row-meta">
{{if .HasHDDData}}
{{if .StorageLabel}}<span class="meta-badge meta-badge-storage">{{.StorageLabel}}</span>{{end}}
<span class="mono app-backup-size">{{.HDDSizeHuman}}</span>
{{else}}
<span class="meta-badge meta-badge-auto">Auto</span>
{{end}}
</div>
<span class="expand-icon"></span>
</div>
<div class="app-backup-row-detail" style="display:none">
<!-- DB layer -->
<div class="backup-layer-row">
<span class="layer-label">Adatbázis mentés</span>
{{if .DBBackup}}
<span class="layer-badge">Auto</span>
{{if .DBBackup.LastRun}}<span class="layer-last">{{.DBBackup.LastRun}}</span>{{end}}
{{else}}
<span class="layer-na">— (nincs adatbázis)</span>
{{end}}
</div>
<!-- Volume layer -->
<div class="backup-layer-row">
<span class="layer-label">Docker kötetek</span>
<span class="layer-badge">Auto</span>
{{if .VolumeBackup.LastRun}}<span class="layer-last">{{.VolumeBackup.LastRun}}</span>{{end}}
</div>
<!-- User data layer -->
<div class="backup-layer-row">
<span class="layer-label">Felhasználói adatok</span>
{{if .UserDataBackup}}
{{if .UserDataBackup.Configured}}
<span class="layer-method">{{.UserDataBackup.Method}}</span>
<span class="layer-dest">→ {{.UserDataBackup.Destination}}</span>
<span class="layer-schedule">{{.UserDataBackup.Schedule}}</span>
{{if .UserDataBackup.LastRun}}
<span class="layer-last">{{.UserDataBackup.LastRun}}
{{.UserDataBackup.StatusBadge}}</span>
{{end}}
<div class="layer-actions">
<a href="/stacks/{{$.StackName}}/deploy" class="btn btn-xs">Beallitas</a>
<button class="btn btn-xs btn-outline"
onclick="triggerCrossDriveBackup('{{$.StackName}}', this)">
Futtatás most</button>
</div>
{{else}}
<span class="layer-unconfigured">Nincs beállítva</span>
<a href="/stacks/{{$.StackName}}/deploy">Beállítás →</a>
{{end}}
{{else}}
<span class="layer-na">— (nincs HDD adat)</span>
{{end}}
</div>
<!-- Warnings -->
{{range .Warnings}}
<div class="backup-layer-warning">{{.}}</div>
{{end}}
</div>
</div>
{{end}}
</div>
```
**JS for expand/collapse:**
```javascript
function toggleBackupDetail(header) {
const detail = header.nextElementSibling;
const icon = header.querySelector('.expand-icon');
if (detail.style.display === 'none') {
detail.style.display = 'block';
icon.textContent = '▼';
} else {
detail.style.display = 'none';
icon.textContent = '▶';
}
}
```
**Test:**
- All deployed apps appear in list
- Apps with HDD data show storage label + size
- Apps without HDD data show "Auto" badge
- Expanding shows correct layer info
- Red dot for unconfigured user data
- Green dot for fully covered apps
### Step 5: Sequential backup chaining (Architecture item 3)
**Files:**
- `cmd/controller/main.go` — modify nightly scheduler to chain cross-drive after restic
- `internal/backup/crossdrive.go``RunAllScheduled()` already exists, verify it handles "daily" and "weekly" correctly
- `internal/web/templates/deploy.html` — update schedule dropdown (remove "Csak kézi indítás", add weekly consistency note)
**Current scheduler code (conceptual location in main.go):**
```go
// In the nightly backup goroutine
go func() {
// ... wait for scheduled time ...
// Phase 1: DB dumps
backupMgr.RunDBDump(ctx)
// Phase 2: Restic snapshot
backupMgr.RunBackup(ctx)
// Phase 3: Cross-drive (NEW — chained)
if crossDriveRunner != nil {
crossDriveRunner.RunAllScheduled(ctx, "daily")
if isWeeklyTriggerDay() { // e.g., Sunday
crossDriveRunner.RunAllScheduled(ctx, "weekly")
}
}
}()
```
**Deploy page schedule dropdown update:**
```html
<select name="cross_drive_schedule" ...>
<option value="daily">Naponta (az éjszakai mentés után)</option>
<option value="weekly">Hetente, vasárnap (az éjszakai mentés után)</option>
</select>
<!-- Show note for weekly -->
<div class="form-hint weekly-note" style="display:none">
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).
</div>
```
**Test:**
- Set app to daily → cross-drive runs after nightly backup every night
- Set app to weekly → cross-drive runs only on Sunday night
- Manual trigger still works regardless of schedule
### Step 6: Drive disconnection detection & notification (Architecture item 5)
**Files:**
- `cmd/controller/main.go` — startup drive check
- `internal/backup/crossdrive.go` — pre-run destination check, notification on failure
- `internal/web/handlers.go``backupsHandler()`: include destination health in row data
**Startup check (main.go):**
```go
// After settings are loaded, check all configured cross-drive destinations
crossConfigs := sett.GetAllCrossDriveConfigs()
for appName, cfg := range crossConfigs {
if cfg == nil || !cfg.Enabled || cfg.DestinationPath == "" {
continue
}
health := system.CheckBackupDestination(cfg.DestinationPath)
if health.Blocked {
logger.Printf("[WARN] Backup destination for %s unreachable: %s (%s)",
appName, cfg.DestinationPath, health.Warning)
}
}
```
**Pre-run check (crossdrive.go RunAppBackup):**
```go
func (r *CrossDriveRunner) RunAppBackup(ctx context.Context, stackName string) error {
cfg := r.sett.GetCrossDriveConfig(stackName)
// ... existing checks ...
// NEW: Tiered destination validation
health := system.CheckBackupDestination(cfg.DestinationPath)
if health.Blocked {
r.sett.UpdateCrossDriveStatus(stackName, "error", health.Warning, 0, "")
// Send notification
if r.notifier != nil {
r.notifier.NotifyBackupFailed(
"Mentési meghajtó nem elérhető",
fmt.Sprintf("%s mentési célja (%s): %s", stackName, cfg.DestinationPath, health.Warning),
)
}
return fmt.Errorf("destination blocked: %s", health.Warning)
}
// ... proceed with backup ...
}
```
**Hub report inclusion:**
Add destination health summary to the 15-minute hub status report payload so the central dashboard can show drive problems.
**Test:**
- Disconnect external USB drive → backup page shows red status, notification sent
- Reconnect → status recovers on next page load
- Controller restart with disconnected drive → warning in logs
### Step 7: Deploy page updates
**Files:**
- `internal/web/templates/deploy.html` — update cross-drive section
- `internal/web/handlers.go``deployHandler()`: use `CheckBackupDestination()` for warnings
**Changes:**
- Remove "Csak kézi indítás" schedule option
- Add weekly consistency note (shown/hidden via JS based on dropdown)
- Replace flat error message with tiered warning/critical display
- Add warning on app deploy page if app has HDD data but no cross-drive configured:
```html
{{if and .StorageInfo (not .CrossDriveConfig)}}
<div class="alert alert-warning">
Az alkalmazás felhasználói adatairól nincs biztonsági mentés.
Állítsd be alább, vagy csatlakoztass egy
<a href="/settings">külső meghajtót</a>.
</div>
{{end}}
```
### Step 8: Version bump & testing
- Update CHANGELOG.md, CONTEXT.md
- Bump version (suggest 0.12.0 — this is a significant feature change)
- Build + deploy to demo-felhom.eu
- Full test cycle:
1. Backup page loads without duplicates ✓
2. All deployed apps appear in unified list ✓
3. Expandable rows show correct layer info ✓
4. Apps without HDD data show "Auto" ✓
5. Immich (HDD data, cross-drive configured) → green ✓
6. Paperless/RomM (HDD data, no cross-drive) → red ✓
7. Configure cross-drive to hdd_placeholder → yellow warning ✓
8. Configure cross-drive to hdd_1 → green ✓
9. Cross-drive runs after nightly backup (check logs) ✓
10. Disconnect external drive → red on backup page, notification sent ✓
11. Manual trigger still works ✓
---
## Files Summary
### New files:
- None (all changes in existing files)
### Modified files:
| File | Changes |
|------|---------|
| `internal/backup/backup.go` | Deep copy in `GetFullStatus()`, remove cross-drive/unconfigured population from cache |
| `internal/backup/crossdrive.go` | Pre-run destination validation, notification on blocked destination |
| `internal/system/mounts_linux.go` | `CheckBackupDestination()`, `isSameBlockDevice()` |
| `internal/system/mounts_other.go` | Stubs for new functions |
| `internal/web/handlers.go` | `buildAppBackupRows()`, remove `settingsAppBackupHandler()`, updated `deployHandler()` validation |
| `internal/web/server.go` | Remove `POST /settings/app-backup` route |
| `internal/web/templates/backups.html` | Complete rewrite of app backup sections → unified expandable rows |
| `internal/web/templates/deploy.html` | Updated schedule dropdown, weekly note, tiered warnings, unconfigured warning |
| `internal/web/templates/style.css` | New styles for expandable rows, status dots, layer detail grid |
| `internal/settings/settings.go` | Deprecate `SetAppBackupBulk()`, `IsAppBackupEnabled()` |
| `cmd/controller/main.go` | Sequential chaining in scheduler, startup drive check |
---
## Design Decisions
### Why merge the two backup sections?
Separation between "Alkalmazás adatok" and "Másolatok másik meghajtóra" forced users to mentally correlate information across two lists. A single per-app row with expandable detail gives complete backup coverage at a glance without cross-referencing.
### Why remove the BackupEnabled toggle?
It wrote to settings.json but nothing consumed the flag. No Backrest deployment exists; the nightly restic script doesn't check settings.json. Showing a toggle that doesn't affect anything is worse than showing nothing — it gives false confidence.
### Why sequential chaining instead of independent schedules?
DB ↔ file consistency. If cross-drive runs at a different time than the DB dump, a restore could produce an inconsistent state (DB references files that don't exist, or vice versa). Chaining ensures DB dump → restic → cross-drive all happen in one window, and a "restore to date X" operation can grab all three from the same night.
### Why allow system drive as backup target with warning?
For small data (configs, documents), having a second copy on the system SSD is better than no copy at all. For large data (Immich photos), the system drive may not have enough space and a drive failure would lose both copies. The warning lets users make an informed choice rather than blocking a useful configuration for small-data apps.
### Why "Hetente" restores also roll back the DB?
A weekly user data snapshot from Sunday combined with a daily DB from Wednesday would create inconsistency — the DB would reference files from Mon-Wed that don't exist in Sunday's snapshot. Rolling both back to the same point (Sunday night) guarantees consistency at the cost of losing up to 7 days of DB changes. This is the correct trade-off for data integrity.