diff --git a/CHANGELOG.md b/CHANGELOG.md index 4017c96..8e2d1b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ ## Changelog +### What was just completed (2026-02-17 session 34) +- **v0.11.7 — Stale Data Cleanup + FileBrowser Sync + UI Title Fix:** + - **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 (storage root, 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 browser page title and the `

` heading. + - **Internal: Exported `ProtectedHDDPaths()`** from stacks package for reuse in web handlers. + - **Files modified (7):** `internal/stacks/delete.go`, `internal/web/handlers.go`, `internal/web/storage_handlers.go`, `internal/web/templates/deploy.html`, `internal/web/templates/migrate.html`, `internal/web/templates/style.css` + ### What was just completed (2026-02-17 session 33) - **v0.11.6 — FileBrowser Auto-Mount Sync + UI Polish (3 fixes):** - **Feature: FileBrowser auto-mount sync** — Added `syncFileBrowserMounts()` and `generateFileBrowserCompose()` to `handlers.go`. After a storage path is added (via storage init wizard) or removed, the controller regenerates `/opt/docker/stacks/filebrowser/docker-compose.yml` with volume mounts for all registered paths (`/mnt/hdd_1:/srv/hdd_1` etc.), then recreates the FileBrowser container. Domain is read from FileBrowser's `.env`. If FileBrowser isn't deployed, the function silently returns. The generated compose is self-contained (no env vars). diff --git a/controller/internal/stacks/delete.go b/controller/internal/stacks/delete.go index f9ef8d3..3ace0fe 100644 --- a/controller/internal/stacks/delete.go +++ b/controller/internal/stacks/delete.go @@ -33,8 +33,8 @@ type HDDPath struct { Exists bool `json:"exists"` } -// protectedHDDPaths returns the set of top-level HDD directories that must never be deleted. -func protectedHDDPaths(hddPath string) map[string]bool { +// ProtectedHDDPaths returns the set of top-level HDD directories that must never be deleted. +func ProtectedHDDPaths(hddPath string) map[string]bool { if hddPath == "" { return nil } @@ -100,7 +100,7 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse, } // Step 4: Handle HDD data - protected := protectedHDDPaths(hddPath) + protected := ProtectedHDDPaths(hddPath) for _, mount := range hddMounts { // Safety: never delete protected top-level dirs cleanPath := filepath.Clean(mount) @@ -165,7 +165,7 @@ func (m *Manager) GetStackHDDData(name string) (*HDDDataResponse, error) { } mounts := ParseComposeHDDMounts(stack.ComposePath, hddPath) - protected := protectedHDDPaths(hddPath) + protected := ProtectedHDDPaths(hddPath) for _, mount := range mounts { cleanPath := filepath.Clean(mount) diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 3aac977..7855b37 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -164,7 +164,11 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri stack, _ := s.stackMgr.GetStack(name) alreadyDeployed := appCfg != nil && appCfg.Deployed - data := s.baseData("deploy", meta.DisplayName+" — Telepítés") + pageTitle := meta.DisplayName + " — Telepítés" + if alreadyDeployed { + pageTitle = meta.DisplayName + " — Beállítások" + } + data := s.baseData("deploy", pageTitle) data["Stack"] = stack data["Meta"] = meta data["AppConfig"] = appCfg @@ -195,6 +199,11 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri data["StorageInfo"] = storageInfo data["OtherStoragePaths"] = s.otherStoragePathsForStack(name) } + // Stale data from previous migrations (only for deployed apps with HDD data) + staleData := s.findStaleStorageData(name) + if len(staleData) > 0 { + data["StaleData"] = staleData + } } // Memory info for deploy page (only for non-deployed apps) diff --git a/controller/internal/web/storage_handlers.go b/controller/internal/web/storage_handlers.go index ed3af1a..3166c28 100644 --- a/controller/internal/web/storage_handlers.go +++ b/controller/internal/web/storage_handlers.go @@ -140,6 +140,8 @@ func (s *Server) storageAPIHandler(w http.ResponseWriter, r *http.Request) { s.storageMigrateAPIHandler(w, r) case path == "/api/storage/migrate/status" && r.Method == http.MethodGet: s.storageMigrateStatusAPIHandler(w, r) + case path == "/api/storage/stale-cleanup" && r.Method == http.MethodPost: + s.staleDataCleanupHandler(w, r) default: http.NotFound(w, r) } @@ -452,6 +454,8 @@ func (s *Server) storageMigrateAPIHandler(w http.ResponseWriter, r *http.Request 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) }() @@ -619,3 +623,189 @@ func (s *Server) storageLabelForPath(path string) string { } return strings.TrimPrefix(path, "/mnt/") } + +// 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 +} + +// 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 := stacks.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, + }) +} diff --git a/controller/internal/web/templates/deploy.html b/controller/internal/web/templates/deploy.html index dccabe0..070411b 100644 --- a/controller/internal/web/templates/deploy.html +++ b/controller/internal/web/templates/deploy.html @@ -4,7 +4,7 @@ @@ -58,6 +58,36 @@ {{end}} {{end}} + {{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}} {{end}} {{if and (not .AlreadyDeployed) .MemoryInfo}} @@ -236,6 +266,52 @@ function generatePassword(fieldId) { document.getElementById(fieldId).value = pass; } +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'; + }); +} + document.getElementById('deploy-form').addEventListener('submit', async function(e) { e.preventDefault(); diff --git a/controller/internal/web/templates/migrate.html b/controller/internal/web/templates/migrate.html index 32185bb..da74f53 100644 --- a/controller/internal/web/templates/migrate.html +++ b/controller/internal/web/templates/migrate.html @@ -79,8 +79,19 @@ Az alkalmazás az új tárolóról fut.
A régi adatok a korábbi helyen megmaradtak biztonsági másolatként.

-
- Alkalmazások megtekintése +
+ Javasolt lépések: +
    +
  1. Ellenőrizd, hogy az alkalmazás megfelelően működik
  2. +
  3. Győződj meg róla, hogy minden adat megtalálható
  4. +
  5. Ha minden rendben, törölheted a korábbi adatokat
  6. +
+
+
+ Alkalmazások megtekintése + Beállítások
@@ -183,6 +194,46 @@ 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'; + }); } diff --git a/controller/internal/web/templates/style.css b/controller/internal/web/templates/style.css index 19cc2c5..f22b861 100644 --- a/controller/internal/web/templates/style.css +++ b/controller/internal/web/templates/style.css @@ -2231,3 +2231,37 @@ a.stat-card:hover { .storage-app-link:hover { text-decoration: underline; } +/* 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; +}