# 0.11.7 — Stale Data Cleanup + FileBrowser Sync + UI Title Fix ## Summary 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 --- ## 1. Stale Data Cleanup ### 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) ```go // BEFORE (line ~13105): data := s.baseData("deploy", meta.DisplayName+" — Telepítés") // AFTER: pageTitle := meta.DisplayName + " — Telepítés" if alreadyDeployed { pageTitle = meta.DisplayName + " — Beállítások" } data := s.baseData("deploy", pageTitle) ``` #### Change 2: Add stale data to deploy page context (Task 1) 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 for _, sp := range s.settings.GetStoragePaths() { if sp.Path == currentHDDPath { continue } // Use ParseComposeHDDMounts to find what dirs WOULD exist on this path mounts := stacks.ParseComposeHDDMounts(stack.ComposePath, sp.Path) if len(mounts) == 0 { continue } // 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 } } 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 } ``` #### Change 4: Add stale data cleanup API handler (Task 1) Add to storage API handler switch in `storageAPIHandler()`: ```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) ```html

{{.Meta.DisplayName}} — Telepítés

{{.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}}
{{end}}
{{end}} ``` #### Change 3: Add stale data delete JS function Add to the `