diff --git a/TASK.md b/TASK.md
index 174e3e3..c36bd63 100644
--- a/TASK.md
+++ b/TASK.md
@@ -1,621 +1,624 @@
-# 0.11.7 — Stale Data Cleanup + FileBrowser Sync + UI Title Fix
+# Per-App Cross-Drive Backup — Design & Task Document
-## Summary
+## Overview
-Three changes in this release:
-1. **Stale data cleanup** — After migration, option to delete data from the previous storage location
-2. **FileBrowser sync after migration** — Trigger `syncFileBrowserMounts()` after successful migration
-3. **UI title fix** — Deploy page shows "Beállítások" instead of "Telepítés" for already-deployed apps
+Extend the controller with per-app user data backup to a **secondary storage drive**. This is distinct from the existing nightly restic snapshot (which backs up to the same drive). The cross-drive backup provides the "second copy on different media" part of the 3-2-1 backup rule.
+
+Two mechanisms available (user chooses per app):
+- **rsync** — Simple file mirror. Easy to browse via FileBrowser. No versioning.
+- **restic** — Versioned, encrypted, deduplicated snapshots on the secondary drive.
+
+### Current State (what already exists)
+
+| Feature | Status | Location |
+|---------|--------|----------|
+| Per-app backup toggle | ✅ Exists | Backup page, `settings.json` `app_backup` map |
+| `resolveAppBackupPaths()` | ✅ Exists | `backup.go` — includes enabled app HDD paths in nightly restic |
+| `AppBackupInfo` discovery | ✅ Exists | `appdata.go` — discovers HDD mounts, Docker volumes per app |
+| Storage paths registry | ✅ Exists | `settings.json` — multiple paths with labels, health, default |
+| Mount health checks | ✅ Exists | `mounts_linux.go` — `IsMountPoint()`, `GetDiskUsage()` |
+| Scheduler | ✅ Exists | `scheduler.go` — daily cron-style jobs |
+| Stale data cleanup | ✅ v0.11.7 | Deploy page — delete old data from non-active paths |
+
+### What's New
+
+The existing backup toggle (`Enabled bool`) includes app data in the **same-drive** restic snapshot. The new feature adds a completely separate **cross-drive** backup job with its own method, destination, and schedule.
---
-## 1. Stale Data Cleanup
+## Data Model
-### Concept
-
-After migrating an app (e.g. Immich from `hdd_placeholder` → `hdd_1`), the old data remains as a safety backup. We need to:
-- Detect stale data on non-active storage paths
-- Show it on the deploy (settings) page
-- Allow deletion with proper warnings
-- Also offer deletion right after migration completes
-
-### Files Modified
-
-| File | Change |
-|------|--------|
-| `internal/web/handlers.go` | Add `findStaleStorageData()`, `staleDataCleanupHandler()` |
-| `internal/web/server.go` | Register new route |
-| `internal/web/templates/deploy.html` | Show stale data card with delete button |
-| `internal/web/templates/migrate.html` | Add delete button to migration-done card |
-| `internal/api/router.go` | Add `DELETE /api/stacks/{name}/stale-data` route |
-
----
-
-## 2. FileBrowser Sync After Migration
-
-### Files Modified
-
-| File | Change |
-|------|--------|
-| `internal/web/handlers.go` | Add `syncFileBrowserMounts()` call after successful migration |
-
----
-
-## 3. UI Title Fix
-
-### Files Modified
-
-| File | Change |
-|------|--------|
-| `internal/web/handlers.go` | Dynamic page title based on `alreadyDeployed` |
-| `internal/web/templates/deploy.html` | Dynamic `
` title |
-
----
-
-## Detailed Changes
-
-### `internal/web/handlers.go`
-
-#### Change 1: Dynamic page title (Task 3)
+### Extended `AppBackupPrefs` in `settings.json`
```go
-// BEFORE (line ~13105):
-data := s.baseData("deploy", meta.DisplayName+" — Telepítés")
+// AppBackupPrefs holds per-app backup configuration.
+type AppBackupPrefs struct {
+ // Existing: includes app data in nightly restic (same drive)
+ Enabled bool `json:"enabled"`
-// AFTER:
-pageTitle := meta.DisplayName + " — Telepítés"
+ // NEW: Cross-drive backup to secondary storage
+ CrossDrive *CrossDriveBackup `json:"cross_drive,omitempty"`
+}
+
+// CrossDriveBackup configures per-app backup to a secondary drive.
+type CrossDriveBackup struct {
+ Enabled bool `json:"enabled"`
+ Method string `json:"method"` // "rsync" or "restic"
+ DestinationPath string `json:"destination_path"` // e.g., "/mnt/hdd_1"
+ Schedule string `json:"schedule"` // "daily", "weekly", "manual"
+
+ // Runtime state (updated by backup runner, persisted for display)
+ LastRun string `json:"last_run,omitempty"` // RFC3339
+ LastStatus string `json:"last_status,omitempty"` // "ok", "error", "running"
+ LastError string `json:"last_error,omitempty"`
+ LastDuration string `json:"last_duration,omitempty"` // "2m34s"
+ LastSizeHuman string `json:"last_size_human,omitempty"` // "1.2 GB"
+}
+```
+
+Example `settings.json`:
+```json
+{
+ "app_backup": {
+ "immich": {
+ "enabled": true,
+ "cross_drive": {
+ "enabled": true,
+ "method": "rsync",
+ "destination_path": "/mnt/hdd_1",
+ "schedule": "daily",
+ "last_run": "2026-02-17T03:15:00Z",
+ "last_status": "ok",
+ "last_duration": "45s",
+ "last_size_human": "48 MB"
+ }
+ },
+ "paperless-ngx": {
+ "enabled": true,
+ "cross_drive": {
+ "enabled": true,
+ "method": "restic",
+ "destination_path": "/mnt/hdd_1",
+ "schedule": "weekly"
+ }
+ }
+ }
+}
+```
+
+### Cross-drive backup directory layout
+
+On the destination drive:
+
+```
+/mnt/hdd_1/
+├── storage/ # App user data (active apps store data here)
+│ ├── immich/
+│ └── paperless-ngx/
+├── media/ # User media files
+├── Dokumentumok/ # User documents
+└── backups/ # NEW: Cross-drive backups
+ ├── rsync/ # Mirror copies
+ │ ├── immich/ # rsync of /mnt/hdd_placeholder/storage/immich/
+ │ └── ...
+ └── restic/ # Restic repository for versioned backups
+ ├── config
+ ├── data/
+ ├── index/
+ ├── keys/
+ └── snapshots/
+```
+
+Key decisions:
+- All cross-drive backups go under `{destination}/backups/` to keep them separate from active app data
+- rsync method: one directory per app under `backups/rsync/{stackname}/`
+- restic method: single shared restic repo at `backups/restic/` (dedup benefits from shared repo)
+- Restic repo on secondary drive uses a **separate password** stored in `settings.json` (not the same as the main backup repo)
+
+---
+
+## Architecture
+
+### New package: `internal/backup/crossdrive.go`
+
+```go
+// CrossDriveRunner handles per-app backup to secondary storage.
+type CrossDriveRunner struct {
+ settings *settings.Settings
+ stackProvider StackDataProvider
+ logger *log.Logger
+ mu sync.Mutex
+ running map[string]bool // per-app running state
+}
+
+// RunAppBackup runs cross-drive backup for a single app.
+func (r *CrossDriveRunner) RunAppBackup(ctx context.Context, stackName string) error
+
+// RunAllScheduled runs cross-drive backup for all apps matching the schedule.
+func (r *CrossDriveRunner) RunAllScheduled(ctx context.Context, schedule string) error
+
+// GetAppStatus returns the current cross-drive backup status for an app.
+func (r *CrossDriveRunner) GetAppStatus(stackName string) *CrossDriveStatus
+```
+
+### rsync backup flow
+
+```
+1. Validate: destination path mounted & writable
+2. Resolve app HDD mounts (e.g., /mnt/hdd_placeholder/storage/immich/)
+3. Create destination: {dest}/backups/rsync/{stackname}/
+4. For each HDD mount:
+ rsync -a --delete --info=progress2 \
+ /mnt/hdd_placeholder/storage/immich/ \
+ /mnt/hdd_1/backups/rsync/immich/storage/immich/
+5. Update settings: last_run, last_status, last_size_human
+```
+
+Note: `--delete` mirrors exactly — old files on destination get removed. This is a mirror, not versioned.
+
+### restic backup flow
+
+```
+1. Validate: destination path mounted & writable
+2. Ensure shared restic repo initialized at {dest}/backups/restic/
+3. Resolve app HDD mounts
+4. restic backup --repo {dest}/backups/restic/ \
+ --password-file {settings-based} \
+ --tag {stackname} \
+ /mnt/hdd_placeholder/storage/immich/
+5. Update settings: last_run, last_status, last_size_human
+```
+
+Restic benefits: dedup across apps, versioned snapshots, can restore specific point-in-time.
+
+---
+
+## UI Design
+
+### 1. Deploy/Settings Page — Per-App Section
+
+On the deploy page (when `AlreadyDeployed`), after the "Adattárolás" card and before/after the "Korábbi adatok" card, add a new **"Biztonsági mentés"** card:
+
+```
+┌────────────────────────────────────────────────────────┐
+│ 🔒 Biztonsági mentés │
+│ │
+│ ☑ Napi mentésbe foglalás (restic, helyi) │
+│ Az alkalmazás adatai bekerülnek az éjszakai │
+│ biztonsági mentésbe. │
+│ │
+│ ───────────────────────────────────────────── │
+│ │
+│ Másolat másik meghajtóra: │
+│ │
+│ Cél tárhely: [▼ Külső HDD 1TB (/mnt/hdd_1) ★] │
+│ Módszer: [▼ Egyszerű másolat (rsync) ] │
+│ Ütemezés: [▼ Naponta ] │
+│ │
+│ Utolsó futás: 2026-02-17 03:15 — ✅ Sikeres (45s) │
+│ Méret: 48 MB │
+│ │
+│ [Mentés most] [Beállítások mentése] │
+│ │
+│ ⚠️ A cél meghajtó legyen más fizikai eszköz, mint │
+│ az alkalmazás adattárolója. │
+└────────────────────────────────────────────────────────┘
+```
+
+**States:**
+- **No other storage path available:** Card visible but form disabled with message: "Másik adattároló szükséges a másolat készítéséhez. Csatlakoztass egy külső meghajtót a Beállítások oldalon."
+- **Other path available but not configured:** Dropdowns shown, save button active
+- **Configured and healthy:** Shows last run status, manual trigger available
+- **Configured but destination unreachable:** Red warning: "⚠️ A cél tárhely ({path}) nem elérhető! Ellenőrizd a meghajtó csatlakozását."
+
+### 2. Backup Page — Summary Card
+
+On the central "Biztonsági mentés" page, add a new section after "Alkalmazás adatok":
+
+```
+┌────────────────────────────────────────────────────────┐
+│ Másolatok másik meghajtóra │
+│ │
+│ ┌──────────────────────────────────────────────────┐ │
+│ │ Immich rsync → hdd_1 ✅ 03:15 48MB │ │
+│ │ Paperless-ngx restic → hdd_1 ⏰ Heti (V) │ │
+│ └──────────────────────────────────────────────────┘ │
+│ │
+│ ⚠️ 1 alkalmazáshoz nincs beállítva: │
+│ RoMM — Beállítás → │
+│ │
+│ [Összes futtatása most] │
+└────────────────────────────────────────────────────────┘
+```
+
+Each row links to the app's deploy/settings page. Shows warnings for:
+- Apps with HDD data but no cross-drive backup configured
+- Destinations that are unreachable/unmounted
+- Last run failures
+
+---
+
+## Routes
+
+### New API endpoints
+
+| Method | Path | Auth | Description |
+|--------|------|------|-------------|
+| POST | `/api/stacks/{name}/cross-backup` | Yes | Save cross-drive backup config for app |
+| POST | `/api/stacks/{name}/cross-backup/run` | Yes | Trigger manual run for single app |
+| GET | `/api/stacks/{name}/cross-backup/status` | Yes | Get current status (for polling) |
+| POST | `/api/backup/cross-drive/run-all` | Yes | Trigger all scheduled cross-drive backups |
+
+### New web handler
+
+| Method | Path | Description |
+|--------|------|-------------|
+| POST | `/settings/cross-backup/{name}` | Form POST from deploy page (redirect back) |
+
+---
+
+## Implementation Steps
+
+### Step 0: CSS fix (immediate)
+
+Add `margin-bottom: 1.5rem` to `.deploy-stale-data` in `style.css`.
+
+### Step 1: Extend data model
+
+**Files:** `settings.go`
+
+- Add `CrossDriveBackup` struct
+- Extend `AppBackupPrefs` with `CrossDrive` field
+- Add getter/setter methods:
+ - `GetCrossDriveConfig(stackName string) *CrossDriveBackup`
+ - `SetCrossDriveConfig(stackName string, cfg CrossDriveBackup) error`
+ - `GetAllCrossDriveConfigs() map[string]*CrossDriveBackup`
+- Add `CrossDriveResticPassword` field to Settings for the secondary restic repo
+- Auto-generate password on first restic cross-drive config save
+
+### Step 2: Cross-drive backup runner
+
+**New file:** `internal/backup/crossdrive.go`
+
+- `CrossDriveRunner` struct with mutex, settings, stack provider, logger
+- `RunAppBackup(ctx, stackName)`:
+ 1. Load config from settings
+ 2. Validate destination: `IsMountPoint()` + `IsWritable()`
+ 3. Resolve HDD mounts via `StackDataProvider`
+ 4. Branch on method:
+ - rsync: `runRsyncBackup()` — runs rsync per mount with `--delete --info=progress2`
+ - restic: `runResticBackup()` — ensures repo init, runs `restic backup` with app tag
+ 5. Update settings with result (last_run, last_status, etc.)
+ 6. Log result
+- `RunAllScheduled(ctx, schedule)`:
+ 1. Iterate all apps with cross_drive enabled
+ 2. Filter by schedule match (daily → every day, weekly → Sunday)
+ 3. Run sequentially (not parallel — disk I/O bound)
+- `GetAppStatus(stackName)` — returns latest status from settings
+- `ValidateDestination(path)` — checks mount + writable + free space
+
+**rsync specifics:**
+```go
+func (r *CrossDriveRunner) runRsyncBackup(stackName, destBase string, mounts []string) error {
+ destDir := filepath.Join(destBase, "backups", "rsync", stackName)
+ os.MkdirAll(destDir, 0755)
+
+ for _, srcMount := range mounts {
+ // Preserve directory structure relative to storage root
+ // e.g., /mnt/hdd_placeholder/storage/immich/ → {dest}/backups/rsync/immich/storage/immich/
+ relPath := strings.TrimPrefix(srcMount, filepath.Dir(filepath.Dir(srcMount)))
+ dstPath := filepath.Join(destDir, relPath)
+ os.MkdirAll(filepath.Dir(dstPath), 0755)
+
+ cmd := exec.Command("rsync", "-a", "--delete",
+ srcMount+"/", dstPath+"/")
+ if out, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("rsync failed for %s: %v (%s)", srcMount, err, string(out))
+ }
+ }
+ return nil
+}
+```
+
+**restic specifics:**
+```go
+func (r *CrossDriveRunner) runResticBackup(stackName, destBase string, mounts []string) error {
+ repoPath := filepath.Join(destBase, "backups", "restic")
+ passwordFile := r.getResticPasswordFile() // from settings or dedicated file
+
+ // Ensure initialized
+ if !r.isRepoInitialized(repoPath) {
+ cmd := exec.Command("restic", "init", "--repo", repoPath, "--password-file", passwordFile)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("restic init failed: %v (%s)", err, string(out))
+ }
+ }
+
+ // Build args
+ args := []string{"backup", "--repo", repoPath, "--password-file", passwordFile,
+ "--tag", stackName, "--tag", "cross-drive"}
+ args = append(args, mounts...)
+
+ cmd := exec.Command("restic", args...)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("restic backup failed: %v (%s)", err, string(out))
+ }
+ return nil
+}
+```
+
+### Step 3: Scheduler integration
+
+**File:** `main.go` (scheduler registration)
+
+Add a new daily job that runs after the existing backup:
+
+```go
+// Cross-drive backup job — runs at 03:30 (after main backup at 03:00)
+sched.RegisterDaily("cross_drive_backup", "03:30", func(ctx context.Context) error {
+ return crossDriveRunner.RunAllScheduled(ctx, "daily")
+})
+
+// Weekly cross-drive job — runs Sundays at 04:00
+sched.RegisterWeekly("cross_drive_weekly", time.Sunday, "04:00", func(ctx context.Context) error {
+ return crossDriveRunner.RunAllScheduled(ctx, "weekly")
+})
+```
+
+Note: If `RegisterWeekly` doesn't exist in the scheduler, we can check the day inside `RunAllScheduled` (like the existing `shouldPrune` pattern).
+
+### Step 4: API endpoints
+
+**File:** `internal/api/router.go`
+
+Add to the switch:
+```go
+// POST /api/stacks/{name}/cross-backup — save config
+case hasSuffix(path, "/cross-backup") && req.Method == http.MethodPost:
+ r.saveCrossBackupConfig(w, req, extractName(path, "/cross-backup"))
+
+// POST /api/stacks/{name}/cross-backup/run — trigger manual run
+case hasSuffix(path, "/cross-backup/run") && req.Method == http.MethodPost:
+ r.triggerCrossBackup(w, req, extractNameFromPath(path, "/cross-backup/run"))
+
+// GET /api/stacks/{name}/cross-backup/status — poll status
+case hasSuffix(path, "/cross-backup/status") && req.Method == http.MethodGet:
+ r.getCrossBackupStatus(w, req, extractNameFromPath(path, "/cross-backup/status"))
+
+// POST /api/backup/cross-drive/run-all — trigger all
+case path == "/backup/cross-drive/run-all" && req.Method == http.MethodPost:
+ r.triggerAllCrossBackups(w, req)
+```
+
+### Step 5: Deploy page UI
+
+**File:** `internal/web/templates/deploy.html`
+
+After the StorageInfo section, add the backup card for deployed apps with HDD data. The card is rendered server-side with current config values.
+
+**File:** `internal/web/handlers.go`
+
+In `deployHandler`, populate new template data:
+
+```go
if alreadyDeployed {
- pageTitle = meta.DisplayName + " — Beállítások"
-}
-data := s.baseData("deploy", pageTitle)
-```
+ // ... existing storageInfo ...
-#### Change 2: Add stale data to deploy page context (Task 1)
+ // Cross-drive backup config for this app
+ crossCfg := s.settings.GetCrossDriveConfig(name)
+ data["CrossDriveConfig"] = crossCfg
-In `deployHandler`, after the existing `storageInfo` block (after line ~13135), add:
-
-```go
- // Stale data from previous migrations (only for deployed apps with HDD data)
- if alreadyDeployed {
- staleData := s.findStaleStorageData(name)
- if len(staleData) > 0 {
- data["StaleData"] = staleData
- }
- }
-```
-
-#### Change 3: Add `findStaleStorageData()` function (Task 1)
-
-Add after `storageInfoForStack()`:
-
-```go
-// StaleStorageData describes leftover data on a non-active storage path.
-type StaleStorageData struct {
- Path string // e.g., "/mnt/hdd_placeholder"
- Label string // e.g., "Külső tárhely (hdd_placeholder)"
- Mounts []string // host-side paths with data
- SizeHuman string // e.g., "48 MB"
- SizeBytes int64
-}
-
-// findStaleStorageData detects leftover app data on non-active storage paths.
-// This happens after migration: the old data stays on the previous storage path.
-func (s *Server) findStaleStorageData(stackName string) []StaleStorageData {
- appCfg := s.stackMgr.LoadAppConfigByName(stackName)
- if appCfg == nil {
- return nil
- }
- currentHDDPath := appCfg.Env["HDD_PATH"]
- if currentHDDPath == "" {
- return nil
- }
-
- stack, ok := s.stackMgr.GetStack(stackName)
- if !ok {
- return nil
- }
-
- var result []StaleStorageData
-
- // Check all registered storage paths except the current one
+ // Other storage paths for destination dropdown (exclude current app path)
+ var destPaths []DeployStoragePath
for _, sp := range s.settings.GetStoragePaths() {
- if sp.Path == currentHDDPath {
- continue
+ if storageInfo != nil && sp.Path == storageInfo.Path {
+ continue // skip the app's current storage
}
-
- // Use ParseComposeHDDMounts to find what dirs WOULD exist on this path
- mounts := stacks.ParseComposeHDDMounts(stack.ComposePath, sp.Path)
- if len(mounts) == 0 {
- continue
+ dp := DeployStoragePath{StoragePath: sp}
+ if di := system.GetDiskUsage(sp.Path); di != nil {
+ dp.FreeHuman = formatFreeSpace(di.AvailGB)
+ dp.FreePercent = di.AvailGB / di.TotalGB * 100
}
+ destPaths = append(destPaths, dp)
+ }
+ data["BackupDestPaths"] = destPaths
- // Check which mounts actually have data
- var existingMounts []string
- var totalSize int64
- for _, m := range mounts {
- info, err := os.Stat(m)
- if err != nil || !info.IsDir() {
- continue
- }
- size := dirSizeInt64(m)
- if size > 0 {
- existingMounts = append(existingMounts, m)
- totalSize += size
- }
+ // Destination health warning
+ if crossCfg != nil && crossCfg.Enabled && crossCfg.DestinationPath != "" {
+ if !system.IsMountPoint(crossCfg.DestinationPath) || !system.IsWritable(crossCfg.DestinationPath) {
+ data["BackupDestWarning"] = fmt.Sprintf(
+ "A cél tárhely (%s) nem elérhető! Ellenőrizd a meghajtó csatlakozását.",
+ crossCfg.DestinationPath,
+ )
}
-
- if len(existingMounts) == 0 {
- continue
- }
-
- label := sp.Label
- if label == "" {
- label = settings.InferStorageLabel(sp.Path)
- }
-
- result = append(result, StaleStorageData{
- Path: sp.Path,
- Label: label,
- Mounts: existingMounts,
- SizeHuman: dirSizeBytesHuman(totalSize),
- SizeBytes: totalSize,
- })
}
- return result
+ // Existing nightly backup toggle state
+ appBackupEnabled := false
+ if prefs, ok := s.settings.GetAppBackupPrefs(name); ok {
+ appBackupEnabled = prefs.Enabled
+ }
+ data["AppBackupEnabled"] = appBackupEnabled
}
```
-#### Change 4: Add stale data cleanup API handler (Task 1)
+### Step 6: Backup page summary
-Add to storage API handler switch in `storageAPIHandler()`:
+**File:** `internal/web/templates/backups.html`
-```go
- case path == "/api/storage/stale-cleanup" && r.Method == http.MethodPost:
- s.staleDataCleanupHandler(w, r)
-```
-
-Add the handler function:
-
-```go
-// staleDataCleanupHandler handles POST /api/storage/stale-cleanup.
-// Deletes leftover app data from a previous storage path after migration.
-func (s *Server) staleDataCleanupHandler(w http.ResponseWriter, r *http.Request) {
- var req struct {
- StackName string `json:"stack_name"`
- StalePath string `json:"stale_path"` // the old storage root, e.g., "/mnt/hdd_placeholder"
- }
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
- return
- }
-
- if req.StackName == "" || req.StalePath == "" {
- jsonError(w, "Hiányos paraméterek", http.StatusBadRequest)
- return
- }
-
- // Verify the app exists and is deployed
- stack, ok := s.stackMgr.GetStack(req.StackName)
- if !ok {
- jsonError(w, "Alkalmazás nem található: "+req.StackName, http.StatusNotFound)
- return
- }
-
- appCfg := s.stackMgr.LoadAppConfigByName(req.StackName)
- if appCfg == nil || !appCfg.Deployed {
- jsonError(w, "Az alkalmazás nincs telepítve", http.StatusBadRequest)
- return
- }
-
- currentHDDPath := appCfg.Env["HDD_PATH"]
- if currentHDDPath == "" {
- jsonError(w, "Az alkalmazásnak nincs HDD_PATH beállítva", http.StatusBadRequest)
- return
- }
-
- // SAFETY: StalePath must NOT be the current HDD_PATH
- if req.StalePath == currentHDDPath {
- jsonError(w, "Az aktív tárhely adatai nem törölhetők! Ez az alkalmazás aktuális adattárolója.", http.StatusForbidden)
- return
- }
-
- // SAFETY: StalePath must be a registered storage path
- found := false
- for _, sp := range s.settings.GetStoragePaths() {
- if sp.Path == req.StalePath {
- found = true
- break
- }
- }
- if !found {
- jsonError(w, "A megadott útvonal nem regisztrált adattároló", http.StatusBadRequest)
- return
- }
-
- // Find mounts to delete
- mounts := stacks.ParseComposeHDDMounts(stack.ComposePath, req.StalePath)
- if len(mounts) == 0 {
- jsonError(w, "Nem találhatók törlendő adatok", http.StatusNotFound)
- return
- }
-
- // Protected paths check
- protected := protectedHDDPaths(req.StalePath)
-
- var deleted []string
- var errors []string
- var totalFreed int64
-
- for _, mountPath := range mounts {
- cleanPath := filepath.Clean(mountPath)
-
- // Safety: never delete protected top-level dirs
- if protected != nil && protected[cleanPath] {
- s.logger.Printf("[WARN] Refusing to delete protected HDD path: %s", cleanPath)
- errors = append(errors, fmt.Sprintf("Védett útvonal, nem törölhető: %s", cleanPath))
- continue
- }
-
- // Verify it actually exists and has data
- info, err := os.Stat(cleanPath)
- if err != nil || !info.IsDir() {
- continue
- }
-
- size := dirSizeInt64(cleanPath)
-
- if err := os.RemoveAll(cleanPath); err != nil {
- s.logger.Printf("[ERROR] Failed to remove stale data %s: %v", cleanPath, err)
- errors = append(errors, fmt.Sprintf("Törlés sikertelen: %s — %v", cleanPath, err))
- } else {
- s.logger.Printf("[INFO] Removed stale data: %s (%s) for stack %s", cleanPath, dirSizeBytesHuman(size), req.StackName)
- deleted = append(deleted, cleanPath)
- totalFreed += size
- }
- }
-
- if len(deleted) == 0 && len(errors) > 0 {
- jsonError(w, "Törlés sikertelen: "+strings.Join(errors, "; "), http.StatusInternalServerError)
- return
- }
-
- jsonResponse(w, map[string]interface{}{
- "ok": true,
- "deleted": deleted,
- "freed_human": dirSizeBytesHuman(totalFreed),
- "errors": errors,
- })
-}
-```
-
-Note: `protectedHDDPaths` is in `internal/stacks/delete.go` — you may need to either export it or duplicate the logic. Since it's a simple function, the cleanest approach is to either:
-- Export it from stacks package (`ProtectedHDDPaths`)
-- Or inline the same logic in handlers.go
-
-For simplicity, since the web package already imports stacks, export it:
-
-In `internal/stacks/delete.go`:
-```go
-// BEFORE:
-func protectedHDDPaths(hddPath string) map[string]bool {
-
-// AFTER:
-func ProtectedHDDPaths(hddPath string) map[string]bool {
-```
-
-And update the existing call in `DeleteStack`:
-```go
-// BEFORE:
-protected := protectedHDDPaths(hddPath)
-
-// AFTER:
-protected := ProtectedHDDPaths(hddPath)
-```
-
-Then in handlers.go, the call becomes:
-```go
-protected := stacks.ProtectedHDDPaths(req.StalePath)
-```
-
-#### Change 5: Sync FileBrowser after migration (Task 2)
-
-In `storageMigrateAPIHandler`, in the goroutine where migration runs, add FileBrowser sync after success:
-
-```go
- go func() {
- progressCh := make(chan storage.MigrateProgress, 64)
- go func() {
- for p := range progressCh {
- job.appendMigProg(p)
- }
- }()
-
- if err := storage.MigrateAppData(migrReq, stopFn, startFn, updateFn, progressCh); err != nil {
- s.logger.Printf("[ERROR] Migration failed: stack=%s: %v", req.StackName, err)
- } else {
- s.logger.Printf("[INFO] Migration complete: stack=%s → %s", req.StackName, req.TargetPath)
- // Sync FileBrowser mounts (storage paths may now have new app data)
- go s.syncFileBrowserMounts()
- }
- close(progressCh)
- }()
-```
-
-### `internal/web/templates/deploy.html`
-
-#### Change 1: Dynamic title (Task 3)
+Add section after "Alkalmazás adatok":
```html
-
-
{{.Meta.DisplayName}} — Telepítés
+
+{{if .Backup.CrossDriveSummary}}
+
+
Másolatok másik meghajtóra
+
Alkalmazás adatok biztonsági másolata külső meghajtóra.
-```
-
-#### Change 2: Stale data card (Task 1)
-
-Add after the StorageInfo block (after the `{{end}}` that closes `{{if .StorageInfo}}`) and before the closing `{{end}}` of `{{if .AlreadyDeployed}}`:
-
-```html
- {{if .StaleData}}
-
-
🗑️ Korábbi adatok
-
- Az alkalmazás adatainak másolata megtalálható egy másik tárolón is.
- Ez általában áthelyezés után marad hátra.
-
- {{range .StaleData}}
-
-
-
- Tárhely
- {{.Label}} ({{.Path}})
-
-
- Méret
- {{.SizeHuman}}
-
-
- Mappák
- {{range .Mounts}}{{.}} {{end}}
-
-
-
-
+ {{if .Backup.CrossDriveWarnings}}
+
+ {{range .Backup.CrossDriveWarnings}}
+
{{.}}
{{end}}
{{end}}
-```
-#### Change 3: Add stale data delete JS function
-
-Add to the `