Files
deploy-felhom-compose/TASK.md
T

20 KiB

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_placeholderhdd_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 <h2> title

Detailed Changes

internal/web/handlers.go

Change 1: Dynamic page title (Task 3)

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

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

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

    case path == "/api/storage/stale-cleanup" && r.Method == http.MethodPost:
        s.staleDataCleanupHandler(w, r)

Add the handler function:

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

// BEFORE:
func protectedHDDPaths(hddPath string) map[string]bool {

// AFTER:
func ProtectedHDDPaths(hddPath string) map[string]bool {

And update the existing call in DeleteStack:

// BEFORE:
protected := protectedHDDPaths(hddPath)

// AFTER:
protected := ProtectedHDDPaths(hddPath)

Then in handlers.go, the call becomes:

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

<!-- BEFORE (line 15717): -->
<h2>{{.Meta.DisplayName}} — Telepítés</h2>

<!-- AFTER: -->
<h2>{{.Meta.DisplayName}} — {{if .AlreadyDeployed}}Beállítások{{else}}Telepítés{{end}}</h2>

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

    {{if .StaleData}}
    <div class="deploy-stale-data">
        <h4>🗑️ Korábbi adatok</h4>
        <p class="form-hint" style="margin-bottom:1rem">
            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.
        </p>
        {{range .StaleData}}
        <div class="stale-data-item">
            <div class="settings-grid" style="margin-bottom:.75rem">
                <div class="settings-row">
                    <span class="settings-label">Tárhely</span>
                    <span class="settings-value">{{.Label}} <span class="mono" style="color:var(--text-secondary)">({{.Path}})</span></span>
                </div>
                <div class="settings-row">
                    <span class="settings-label">Méret</span>
                    <span class="settings-value mono">{{.SizeHuman}}</span>
                </div>
                <div class="settings-row">
                    <span class="settings-label">Mappák</span>
                    <span class="settings-value mono" style="font-size:.85rem">{{range .Mounts}}{{.}}<br>{{end}}</span>
                </div>
            </div>
            <button class="btn btn-sm btn-danger" onclick="deleteStaleData('{{$.Meta.Slug}}', '{{.Path}}', this)">
                🗑️ Korábbi adatok törlése
            </button>
        </div>
        {{end}}
    </div>
    {{end}}

Change 3: Add stale data delete JS function

Add to the <script> section at the bottom of deploy.html:

function deleteStaleData(stackName, stalePath, btn) {
    if (!confirm('Biztosan törölni szeretnéd a korábbi adatokat?\n\nTárhely: ' + stalePath + '\n\n⚠️ Ez a művelet visszavonhatatlan!\nElőtte győződj meg róla, hogy az alkalmazás az új tárolóról megfelelően működik.')) {
        return;
    }
    // Second confirmation
    if (!confirm('UTOLSÓ FIGYELMEZTETÉS!\n\nA törlés visszavonhatatlan. Biztosan folytatod?')) {
        return;
    }

    btn.disabled = true;
    btn.textContent = 'Törlés folyamatban...';

    fetch('/api/storage/stale-cleanup', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({stack_name: stackName, stale_path: stalePath})
    })
    .then(function(r) { return r.json(); })
    .then(function(data) {
        if (!data.ok) {
            alert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
            btn.disabled = false;
            btn.textContent = '🗑️ Korábbi adatok törlése';
            return;
        }
        var msg = '✅ Korábbi adatok törölve!\n\nFelszabadított hely: ' + (data.freed_human || '?');
        if (data.errors && data.errors.length > 0) {
            msg += '\n\n⚠️ Néhány hiba történt:\n' + data.errors.join('\n');
        }
        alert(msg);
        // Remove the stale data card from DOM
        var item = btn.closest('.stale-data-item');
        if (item) item.remove();
        // If no more stale items, remove the whole section
        var container = document.querySelector('.deploy-stale-data');
        if (container && container.querySelectorAll('.stale-data-item').length === 0) {
            container.remove();
        }
    })
    .catch(function(e) {
        alert('Hálózati hiba: ' + e.message);
        btn.disabled = false;
        btn.textContent = '🗑️ Korábbi adatok törlése';
    });
}

internal/web/templates/migrate.html

Change: Add delete old data button to migration-done card (Task 1)

Replace the migrate-done-card div:

<div class="settings-card" id="migrate-done-card" style="display:none">
    <h3>✅ Adatáthelyezés kész!</h3>
    <p style="margin-top:.75rem;color:var(--text-secondary)">
        Az alkalmazás az új tárolóról fut.<br>
        A régi adatok a korábbi helyen megmaradtak biztonsági másolatként.
    </p>
    <div class="alert alert-warning" style="margin-top:1rem">
        <strong>Javasolt lépések:</strong>
        <ol style="margin:.5rem 0 0 1rem;padding:0">
            <li>Ellenőrizd, hogy az alkalmazás megfelelően működik</li>
            <li>Győződj meg róla, hogy minden adat megtalálható</li>
            <li>Ha minden rendben, törölheted a korábbi adatokat</li>
        </ol>
    </div>
    <div style="margin-top:1.5rem;display:flex;gap:.75rem;flex-wrap:wrap">
        <a href="/stacks/{{.Meta.Slug}}/deploy" class="btn btn-primary">Alkalmazások megtekintése</a>
        <button id="migrate-delete-old-btn" class="btn btn-outline btn-danger" onclick="deleteOldMigrationData()" style="display:none">
            🗑️ Korábbi adatok törlése
        </button>
        <a href="/settings" class="btn btn-outline">Beállítások</a>
    </div>
</div>

Add to the <script> section, update showMigDone():

function showMigDone() {
    document.getElementById('migrate-progress-card').style.display = 'none';
    document.getElementById('migrate-done-card').style.display = 'block';
    document.getElementById('migrate-done-card').scrollIntoView({behavior:'smooth'});
    // Show the delete button (old data is at the source path)
    document.getElementById('migrate-delete-old-btn').style.display = '';
}

function deleteOldMigrationData() {
    var oldPath = '{{.CurrentHDDPath}}';
    if (!confirm('Biztosan törölni szeretnéd a korábbi adatokat?\n\nTárhely: ' + oldPath + '\n\n⚠️ Ez a művelet visszavonhatatlan!\nElőtte győződj meg róla, hogy az alkalmazás az új tárolóról megfelelően működik.')) {
        return;
    }
    if (!confirm('UTOLSÓ FIGYELMEZTETÉS!\n\nA törlés visszavonhatatlan. Biztosan folytatod?')) {
        return;
    }

    var btn = document.getElementById('migrate-delete-old-btn');
    btn.disabled = true;
    btn.textContent = 'Törlés folyamatban...';

    fetch('/api/storage/stale-cleanup', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({stack_name: stackName, stale_path: oldPath})
    })
    .then(function(r) { return r.json(); })
    .then(function(data) {
        if (!data.ok) {
            alert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
            btn.disabled = false;
            btn.textContent = '🗑️ Korábbi adatok törlése';
            return;
        }
        btn.textContent = '✅ Korábbi adatok törölve (' + (data.freed_human || '') + ')';
        btn.classList.remove('btn-danger');
        btn.classList.add('btn-outline');
        btn.onclick = null;
    })
    .catch(function(e) {
        alert('Hálózati hiba: ' + e.message);
        btn.disabled = false;
        btn.textContent = '🗑️ Korábbi adatok törlése';
    });
}

internal/web/templates/style.css

Add stale data styling:

/* Stale data cleanup */
.deploy-stale-data {
    background: var(--card-bg);
    border: 1px solid var(--orange);
    border-radius: var(--radius);
    padding: 1.5rem;
    margin-top: 1rem;
}
.deploy-stale-data h4 {
    margin: 0 0 0.5rem 0;
    color: var(--orange);
}
.stale-data-item {
    padding: 1rem;
    background: rgba(255, 165, 0, 0.05);
    border-radius: var(--radius);
    margin-bottom: 0.75rem;
}
.stale-data-item:last-child {
    margin-bottom: 0;
}
.btn-danger {
    background: var(--red);
    color: white;
    border-color: var(--red);
}
.btn-danger:hover {
    opacity: 0.85;
}

internal/stacks/delete.go — Export protectedHDDPaths

// BEFORE:
func protectedHDDPaths(hddPath string) map[string]bool {

// AFTER:
func ProtectedHDDPaths(hddPath string) map[string]bool {

Update the call in DeleteStack:

// BEFORE:
protected := protectedHDDPaths(hddPath)

// AFTER:
protected := ProtectedHDDPaths(hddPath)

Changelog entry

- **0.11.7 — Stale Data Cleanup + FileBrowser Sync + UI Polish:**
  - **Feature: Stale data cleanup** — After app data migration, the deploy/settings page now shows leftover data on previous storage paths with size info and a delete button. Two-step confirmation required before deletion. Protected paths (top-level storage, media, Dokumentumok, appdata) cannot be deleted. Also available immediately after migration on the migration-done page.
  - **Fix: FileBrowser sync after migration** — `syncFileBrowserMounts()` now called after successful data migration, ensuring FileBrowser mounts reflect the current storage layout.
  - **Fix: Deploy page title** — Already-deployed apps now show "Beállítások" (Settings) instead of "Telepítés" (Deploy) in both the page title and the `<h2>` heading.
  - **Internal: Exported `ProtectedHDDPaths()`** from stacks package for reuse in web handlers.
  - **Files modified (8):** `handlers.go`, `server.go`, `delete.go`, `deploy.html`, `migrate.html`, `style.css`, `router.go` (if adding route there, but actually it's in storageAPIHandler)

Testing Checklist

  1. Stale data detection:

    • Deploy immich on hdd_placeholder
    • Migrate to hdd_1
    • Visit immich deploy/settings page → should show "Korábbi adatok" card with hdd_placeholder data size
    • Delete the stale data → confirm both prompts → data removed, card disappears
  2. Migration-done page cleanup:

    • Start a new migration
    • After completion, "Korábbi adatok törlése" button should appear
    • Click → confirm → old data deleted
  3. FileBrowser sync:

    • After migration, check docker logs felhom-controller | grep FileBrowser
    • Verify FileBrowser compose was regenerated
  4. Title fix:

    • Visit a non-deployed app → title shows "Telepítés"
    • Visit a deployed app → title shows "Beállítások"
  5. Safety:

    • Try to delete active HDD path via API → should be rejected (403)
    • Try to delete unregistered path → should be rejected (400)
    • Protected paths (storage root, media root) should never be deleted