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.

- -

{{.Meta.DisplayName}} — {{if .AlreadyDeployed}}Beállítások{{else}}Telepítés{{end}}

-``` - -#### 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 `