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