Files
deploy-felhom-compose/TASK.md
T

33 KiB
Raw Blame History

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.goGetFullStatus()

Fix: Return a deep copy of the cached status. Specifically, CrossDriveSummary, UnconfiguredApps, CrossDriveWarnings, and AppDataInfo slices must not share backing arrays with the cache.

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.godeployHandler() (line ~13818), backupsHandler() (line ~14036)

Current logic:

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:

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:

// 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

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:

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:

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.goGetFullStatus(): return deep copy with nil cross-drive/unconfigured slices
  • internal/web/handlers.gobackupsHandler(): 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.godeployHandler(): replace IsMountPoint || !IsWritable with CheckBackupDestination()
  • internal/web/handlers.gobackupsHandler(): 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:

// 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):

<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:

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.goRunAllScheduled() 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):

// 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:

<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.gobackupsHandler(): include destination health in row data

Startup check (main.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):

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.godeployHandler(): 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:
{{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.