From 264855fb0d29f0b6ddd70e3c3fc0b9f646fc4801 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Tue, 17 Feb 2026 12:33:47 +0100 Subject: [PATCH] =?UTF-8?q?0.11.7=20=E2=80=94=20Stale=20Data=20Cleanup=20+?= =?UTF-8?q?=20FileBrowser=20Sync=20+=20UI=20Title=20Fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TASK.md | 885 ++++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 567 insertions(+), 318 deletions(-) diff --git a/TASK.md b/TASK.md index e8dcd95..174e3e3 100644 --- a/TASK.md +++ b/TASK.md @@ -1,372 +1,621 @@ -# TASK: FileBrowser Auto-Mount on New Storage + UI Polish (3 fixes) +# 0.11.7 — Stale Data Cleanup + FileBrowser Sync + UI Title Fix -**Version:** 0.11.6 -**Scope:** Go handler + template/CSS changes +## 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 --- -## Feature: Auto-update FileBrowser mounts when storage paths change +## 1. Stale Data Cleanup -### Context +### Concept -FileBrowser Quantum is the infrastructure file manager deployed at `files.`. -It's how customers access their files on external drives via the web. +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 -**Current problem:** FileBrowser's docker-compose.yml has hardcoded volume mounts -pointing to `${HDD_PATH}/storage`, `${HDD_PATH}/media`, `${HDD_PATH}/Dokumentumok`. -When a new drive is initialized via the storage wizard, FileBrowser doesn't get access. -The user has to manually edit the compose — which defeats the managed appliance model. +### Files Modified -**Current FileBrowser compose** (`/opt/docker/stacks/filebrowser/docker-compose.yml`): -```yaml -services: - filebrowser: - image: gtstef/filebrowser:latest - container_name: filebrowser - restart: unless-stopped - environment: - - TZ=Europe/Budapest - volumes: - - filebrowser_data:/home/filebrowser/data - - ${HDD_PATH}/storage:/srv/storage:ro - - ${HDD_PATH}/media:/srv/media - - ${HDD_PATH}/Dokumentumok:/srv/Dokumentumok - # ...traefik labels etc... -``` +| 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 | -**Goal:** After a new storage path is registered (or removed), the controller -automatically regenerates FileBrowser's compose with all registered storage paths -as volume mounts, then recreates the container. +--- -### New mount layout +## 2. FileBrowser Sync After Migration -Instead of mounting specific subdirectories, mount each registered storage path -as a top-level directory: +### Files Modified -```yaml - volumes: - - filebrowser_data:/home/filebrowser/data - # Auto-generated storage mounts: - - /mnt/hdd_1:/srv/hdd_1 # "Külső HDD 1TB" - - /mnt/hdd_2:/srv/hdd_2 # (if second drive added later) -``` +| File | Change | +|------|--------| +| `internal/web/handlers.go` | Add `syncFileBrowserMounts()` call after successful migration | -The user sees in FileBrowser: -``` -/srv/ - hdd_1/ - Dokumentumok/ - media/ - storage/ - hdd_2/ - ... -``` +--- -Each storage path's subdirectories (created during init: `storage/`, `Dokumentumok/`, `media/`) -are visible as subdirectories under the mount name. +## 3. UI Title Fix -### Implementation +### Files Modified -#### 1. New function: `syncFileBrowserMounts()` in `handlers.go` +| File | Change | +|------|--------| +| `internal/web/handlers.go` | Dynamic page title based on `alreadyDeployed` | +| `internal/web/templates/deploy.html` | Dynamic `

` title | -Add a method on `*Server` that regenerates FileBrowser's compose from the current -registered storage paths: +--- + +## Detailed Changes + +### `internal/web/handlers.go` + +#### Change 1: Dynamic page title (Task 3) ```go -// syncFileBrowserMounts regenerates FileBrowser's docker-compose.yml -// with volume mounts for all registered storage paths, then recreates the container. -func (s *Server) syncFileBrowserMounts() { - composePath := "/opt/docker/stacks/filebrowser/docker-compose.yml" - - // Check if FileBrowser stack exists - if _, err := os.Stat(composePath); os.IsNotExist(err) { - s.logger.Printf("[WARN] FileBrowser stack not found at %s — skipping mount sync", composePath) - return +// 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 + } } - - // Get all active storage paths - paths := s.settings.GetStoragePaths() // all, not just schedulable - - // Read domain from .env - envPath := "/opt/docker/stacks/filebrowser/.env" - domain := "" - if data, err := os.ReadFile(envPath); err == nil { - for _, line := range strings.Split(string(data), "\n") { - if strings.HasPrefix(line, "DOMAIN=") { - domain = strings.TrimPrefix(line, "DOMAIN=") - break +``` + +#### 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 domain == "" { - s.logger.Printf("[WARN] Cannot read DOMAIN from FileBrowser .env — skipping mount sync") - return - } - - // Build volume mount lines - var storageMounts []string - for _, sp := range paths { - mountName := filepath.Base(sp.Path) // "/mnt/hdd_1" → "hdd_1" - // Mount each storage path as /srv/ - line := fmt.Sprintf(" - %s:/srv/%s", sp.Path, mountName) - storageMounts = append(storageMounts, line) - } - - // Generate compose from template - compose := generateFileBrowserCompose(domain, storageMounts) - - // Write compose - if err := os.WriteFile(composePath, []byte(compose), 0644); err != nil { - s.logger.Printf("[ERROR] Failed to write FileBrowser compose: %v", err) - return - } - - // Recreate container - cmd := exec.Command("docker", "compose", "up", "-d", "--remove-orphans") - cmd.Dir = filepath.Dir(composePath) - if out, err := cmd.CombinedOutput(); err != nil { - s.logger.Printf("[ERROR] Failed to recreate FileBrowser: %s — %v", string(out), err) - } else { - s.logger.Printf("[INFO] FileBrowser mounts synced — %d storage path(s)", len(paths)) - } -} -``` -#### 2. Compose template function - -```go -func generateFileBrowserCompose(domain string, storageMounts []string) string { - storageSection := "" - if len(storageMounts) > 0 { - storageSection = "\n # Storage paths (auto-generated by felhom-controller)\n" + - strings.Join(storageMounts, "\n") - } - - return fmt.Sprintf(`# FileBrowser Quantum — Infrastructure file manager -# Domain: files.%s -# Deployed by docker-setup.sh — managed by felhom-controller -# WARNING: Volume mounts are auto-generated. Manual edits will be overwritten. - -services: - filebrowser: - image: gtstef/filebrowser:latest - container_name: filebrowser - restart: unless-stopped - environment: - - TZ=Europe/Budapest - volumes: - - filebrowser_data:/home/filebrowser/data%s - networks: - - traefik-public - deploy: - resources: - limits: - memory: 256M - healthcheck: - test: ["CMD", "wget", "--spider", "-q", "http://localhost:80/"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 15s - labels: - - "traefik.enable=true" - - "traefik.http.routers.filebrowser.rule=Host(` + "`" + `files.%s` + "`" + `)" - - "traefik.http.routers.filebrowser.entrypoints=websecure" - - "traefik.http.routers.filebrowser.tls=true" - - "traefik.http.services.filebrowser.loadbalancer.server.port=80" - - "traefik.docker.network=traefik-public" - -volumes: - filebrowser_data: - -networks: - traefik-public: - external: true -`, domain, storageSection, domain) -} -``` - -**Note on Go string templating:** The backticks in the Traefik label make raw string -literals tricky. You can either: -- Use `fmt.Sprintf` with concatenation for the backtick part (shown above) -- Or use `text/template` with a separate template string -- Either approach is fine — choose whichever is cleaner in the actual code - -#### 3. Call syncFileBrowserMounts() after storage changes - -In `storageInitAPIHandler` (handlers.go), after `AddStoragePath` succeeds, -add the sync call inside the goroutine: - -```go - if err := s.settings.AddStoragePath(sp); err != nil { - s.logger.Printf("[WARN] Failed to register storage path after init: %v", err) - } else { - s.logger.Printf("[INFO] Storage path registered: %s (%s)", mountPath, label) - // Sync FileBrowser mounts with new storage path - s.syncFileBrowserMounts() + if len(existingMounts) == 0 { + continue } -``` -Also call it in any handler that removes or modifies storage paths: -- `removeStoragePath` handler (if exists) -- `setDefaultStoragePath` handler — this one only changes the default flag, - does NOT need a FileBrowser sync (mounts don't change) + label := sp.Label + if label == "" { + label = settings.InferStorageLabel(sp.Path) + } -#### 4. Handle edge case: FileBrowser not deployed + result = append(result, StaleStorageData{ + Path: sp.Path, + Label: label, + Mounts: existingMounts, + SizeHuman: dirSizeBytesHuman(totalSize), + SizeBytes: totalSize, + }) + } -The `syncFileBrowserMounts` function already checks if the compose file exists. -If FileBrowser isn't deployed yet, it logs a warning and returns. This is correct — -the user might initialize storage before deploying FileBrowser. - -When FileBrowser IS later deployed (via catalog or docker-setup.sh), it uses `${HDD_PATH}` -from `.env`. The controller could also call `syncFileBrowserMounts()` after deploying -FileBrowser, but that's a separate enhancement. For now, the sync runs on storage changes. - -#### 5. Also preserve .felhom.yml and .env - -`syncFileBrowserMounts()` only writes `docker-compose.yml`. The `.felhom.yml` (metadata) -and `.env` files should NOT be touched — they're managed separately. - -The generated compose does NOT use `${HDD_PATH}` or `${DOMAIN}` env vars — it bakes in -the actual values (domain from .env, paths from settings). This is intentional: it makes -the compose self-contained and debuggable. No hidden env var expansion. - -### Test plan - -```bash -# 1. Before: check current FileBrowser mounts -docker inspect filebrowser --format '{{range .Mounts}}{{.Source}}→{{.Destination}} {{end}}' - -# 2. Initialize a new drive (or use already-initialized hdd_1) -# After init completes, check compose was rewritten: -cat /opt/docker/stacks/filebrowser/docker-compose.yml -# Should show: - /mnt/hdd_1:/srv/hdd_1 - -# 3. Verify FileBrowser was recreated -docker inspect filebrowser --format '{{range .Mounts}}{{.Source}}→{{.Destination}} {{end}}' -# Should include /mnt/hdd_1 → /srv/hdd_1 - -# 4. Open files. — navigate to /srv/hdd_1/ -# Should see: Dokumentumok/, media/, storage/ -``` - ---- - -## UI Fix 1: "Nincs csatolva!" badge → "Rendszermeghajtón" - -### Problem -`/mnt/hdd_placeholder` shows a red "Nincs csatolva!" badge. It's on the system SSD, -which isn't a separate mount point. This looks like an error but it's just informational. - -### Where -`controller/internal/web/templates/settings.html` — find the badge rendering for -storage paths that aren't mount points. Look for "Nincs csatolva!" text. - -### Fix -Change from red/danger badge to yellow/warning badge with new text: - -**Before:** -```html -Nincs csatolva! -``` - -**After:** -```html -Rendszermeghajtón -``` - -Add badge style if `badge-warn` doesn't exist in `style.css`: -```css -.badge-warn { - background: rgba(250, 204, 21, 0.15); - color: #facc15; + return result } ``` ---- +#### Change 4: Add stale data cleanup API handler (Task 1) -## UI Fix 2: Init progress bar — remove disk usage zone gradient +Add to storage API handler switch in `storageAPIHandler()`: -### Problem -The storage init progress bar uses the disk-usage bar style which has -green→yellow→red gradient zones. At 30% progress it looks alarming. A task -progress bar should be a simple single-color fill on neutral background. +```go + case path == "/api/storage/stale-cleanup" && r.Method == http.MethodPost: + s.staleDataCleanupHandler(w, r) +``` -### Where -`controller/internal/web/templates/storage_init.html` — find the progress bar. -`controller/internal/web/templates/style.css` — add new class. +Add the handler function: -### Fix -Add a new CSS class for task progress (not disk usage): +```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 + } -```css -.progress-bar-task { - height: 8px; - background: var(--border); - border-radius: 4px; - overflow: hidden; - width: 100%; -} + if req.StackName == "" || req.StalePath == "" { + jsonError(w, "Hiányos paraméterek", http.StatusBadRequest) + return + } -.progress-bar-task .progress-fill { - height: 100%; - background: var(--accent); - border-radius: 4px; - transition: width 0.3s ease; + // 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, + }) } ``` -In `storage_init.html`, replace the current progress bar element (which reuses the -disk usage bar class) with: +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 `