feat: 0.11.7 — Stale data cleanup + FileBrowser sync after migration + deploy title fix

- Detect stale data on non-active storage paths after migration; show on
  deploy/settings page with size info and two-step delete confirmation
- Add POST /api/storage/stale-cleanup handler with safety checks (active
  path protection, registered-path validation, ProtectedHDDPaths guard)
- Export ProtectedHDDPaths() from stacks package for reuse in web handlers
- Sync FileBrowser mounts after successful app data migration
- Deploy page title/h2 now shows "Beállítások" for already-deployed apps
  instead of always showing "Telepítés"
- Also add delete-old-data button on migration-done card in migrate.html

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 12:45:08 +01:00
parent 264855fb0d
commit fba2eb3631
7 changed files with 376 additions and 8 deletions
+190
View File
@@ -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,
})
}