package web import ( "encoding/json" "fmt" "net/http" "os" "path/filepath" "strings" "sync" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/storage" "gitea.dooplex.hu/admin/felhom-controller/internal/system" ) // activeDiskJob tracks an in-progress disk operation (format or migrate). type activeDiskJob struct { mu sync.RWMutex jobType string // "format" or "migrate" done bool fmtProg []storage.FormatProgress migProg []storage.MigrateProgress } // DeployStorageInfo holds storage info for the deploy page (already-deployed apps). type DeployStorageInfo struct { Path string Label string DataSizeHuman string FreeHuman string FreePercent float64 } // appendFmtProg adds a format progress update to the job. func (j *activeDiskJob) appendFmtProg(p storage.FormatProgress) { j.mu.Lock() defer j.mu.Unlock() j.fmtProg = append(j.fmtProg, p) if p.Step == "done" || p.Step == "error" { j.done = true } } // appendMigProg adds a migration progress update to the job. func (j *activeDiskJob) appendMigProg(p storage.MigrateProgress) { j.mu.Lock() defer j.mu.Unlock() j.migProg = append(j.migProg, p) if p.Step == "done" || p.Step == "error" { j.done = true } } // lastFmtProg returns the most recent format progress snapshot. func (j *activeDiskJob) lastFmtProg() (storage.FormatProgress, bool) { j.mu.RLock() defer j.mu.RUnlock() if len(j.fmtProg) == 0 { return storage.FormatProgress{}, false } return j.fmtProg[len(j.fmtProg)-1], true } // lastMigProg returns the most recent migration progress snapshot. func (j *activeDiskJob) lastMigProg() (storage.MigrateProgress, bool) { j.mu.RLock() defer j.mu.RUnlock() if len(j.migProg) == 0 { return storage.MigrateProgress{}, false } return j.migProg[len(j.migProg)-1], true } // isDone returns true if the job has finished. func (j *activeDiskJob) isDone() bool { j.mu.RLock() defer j.mu.RUnlock() return j.done } // jsonResponse writes a JSON response. func jsonResponse(w http.ResponseWriter, v interface{}) { w.Header().Set("Content-Type", "application/json; charset=utf-8") json.NewEncoder(w).Encode(v) } // jsonError writes a JSON error response. func jsonError(w http.ResponseWriter, msg string, code int) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(code) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, "error": msg, }) } // tryStartDiskJob attempts to start a new disk operation job. // Returns false if another job is already active. func (s *Server) tryStartDiskJob(jobType string) (*activeDiskJob, bool) { s.diskJobMu.Lock() defer s.diskJobMu.Unlock() if s.diskJob != nil && !s.diskJob.isDone() { return nil, false } job := &activeDiskJob{jobType: jobType} s.diskJob = job return job, true } // currentDiskJob returns the current disk job (may be nil or done). func (s *Server) currentDiskJob() *activeDiskJob { s.diskJobMu.Lock() defer s.diskJobMu.Unlock() return s.diskJob } // --- Storage Init Wizard --- // storageInitHandler serves the storage init wizard page. func (s *Server) storageInitHandler(w http.ResponseWriter, r *http.Request) { data := s.baseData("settings", "Meghajtó inicializálása") s.render(w, "storage_init", data) } // storageAPIHandler is the main handler for /api/storage/* routes. func (s *Server) storageAPIHandler(w http.ResponseWriter, r *http.Request) { path := r.URL.Path switch { case path == "/api/storage/scan" && r.Method == http.MethodPost: s.storageScanAPIHandler(w, r) case path == "/api/storage/init" && r.Method == http.MethodPost: s.storageInitAPIHandler(w, r) case path == "/api/storage/init/status" && r.Method == http.MethodGet: s.storageInitStatusAPIHandler(w, r) case path == "/api/storage/migrate" && r.Method == http.MethodPost: s.storageMigrateAPIHandler(w, r) case path == "/api/storage/migrate/status" && r.Method == http.MethodGet: s.storageMigrateStatusAPIHandler(w, r) default: http.NotFound(w, r) } } // storageScanAPIHandler handles POST /api/storage/scan. func (s *Server) storageScanAPIHandler(w http.ResponseWriter, r *http.Request) { result, err := storage.ScanDisks() if err != nil { s.logger.Printf("[ERROR] storageScan: %v", err) jsonError(w, "Meghajtók keresése sikertelen: "+err.Error(), http.StatusInternalServerError) return } jsonResponse(w, map[string]interface{}{ "ok": true, "available": result.AvailableDisks, "system": result.SystemDisks, "available_count": len(result.AvailableDisks), }) } // storageInitAPIHandler handles POST /api/storage/init — starts format+mount job. func (s *Server) storageInitAPIHandler(w http.ResponseWriter, r *http.Request) { var req struct { DevicePath string `json:"device_path"` MountName string `json:"mount_name"` Label string `json:"label"` CreatePartition bool `json:"create_partition"` SetDefault bool `json:"set_default"` Confirm string `json:"confirm"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, "Érvénytelen kérés", http.StatusBadRequest) return } if req.Confirm != "FORMÁZÁS" { jsonError(w, "Megerősítés szükséges: írja be 'FORMÁZÁS'", http.StatusBadRequest) return } if req.DevicePath == "" || req.MountName == "" { jsonError(w, "Hiányos paraméterek", http.StatusBadRequest) return } job, ok := s.tryStartDiskJob("format") if !ok { jsonError(w, "Egy másik lemezművelet folyamatban van", http.StatusConflict) return } s.logger.Printf("[INFO] Storage init started: device=%s mountName=%s by %s", req.DevicePath, req.MountName, r.RemoteAddr) fmtReq := storage.FormatRequest{ DevicePath: req.DevicePath, MountName: req.MountName, Label: req.Label, CreatePartition: req.CreatePartition, SetDefault: req.SetDefault, } // Smart partition: if disk has exactly 1 partition with no filesystem, // skip destructive repartitioning and format the existing partition directly. if fmtReq.CreatePartition { if scanResult, scanErr := storage.ScanDisks(); scanErr == nil { for _, disk := range scanResult.AvailableDisks { if disk.Path == req.DevicePath && len(disk.Partitions) == 1 && disk.Partitions[0].FSType == "" { s.logger.Printf("[INFO] Disk %s has 1 empty partition (%s) — skipping repartition", req.DevicePath, disk.Partitions[0].Path) fmtReq.DevicePath = disk.Partitions[0].Path fmtReq.CreatePartition = false break } } } } go func() { progressCh := make(chan storage.FormatProgress, 32) // Collect progress go func() { for p := range progressCh { job.appendFmtProg(p) } }() mountPath, err := storage.FormatAndMount(fmtReq, progressCh) close(progressCh) if err != nil { s.logger.Printf("[ERROR] Storage init failed: %v", err) return } // Auto-register the new storage path label := req.Label if label == "" { label = settings.InferStorageLabel(mountPath) } sp := settings.StoragePath{ Path: mountPath, Label: label, IsDefault: req.SetDefault, Schedulable: true, AddedAt: time.Now().UTC().Format(time.RFC3339), } 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) } }() jsonResponse(w, map[string]interface{}{ "ok": true, "msg": "Inicializálás elindítva", }) } // storageInitStatusAPIHandler handles GET /api/storage/init/status. func (s *Server) storageInitStatusAPIHandler(w http.ResponseWriter, r *http.Request) { job := s.currentDiskJob() if job == nil || job.jobType != "format" { jsonResponse(w, map[string]interface{}{ "ok": true, "active": false, }) return } p, ok := job.lastFmtProg() if !ok { jsonResponse(w, map[string]interface{}{ "ok": true, "active": true, "step": "starting", "msg": "Inicializálás elindult...", "pct": 0, }) return } jsonResponse(w, map[string]interface{}{ "ok": true, "active": !job.isDone(), "step": p.Step, "msg": p.Message, "pct": p.Percent, "error": p.Error, "done": job.isDone(), }) } // --- Migration --- // migratePageHandler serves the migration page for an app. func (s *Server) migratePageHandler(w http.ResponseWriter, r *http.Request, stackName string) { stack, ok := s.stackMgr.GetStack(stackName) if !ok { http.NotFound(w, r) return } appCfg := s.stackMgr.LoadAppConfigByName(stackName) if appCfg == nil || !appCfg.Deployed { http.NotFound(w, r) return } currentHDDPath := appCfg.Env["HDD_PATH"] if currentHDDPath == "" { http.Error(w, "Ez az alkalmazás nem tárol adatot külső meghajtón.", http.StatusBadRequest) return } // Other storage paths (exclude current) var otherPaths []DeployStoragePath for _, sp := range s.settings.GetStoragePaths() { if sp.Path == currentHDDPath { continue } dp := DeployStoragePath{StoragePath: sp} if di := system.GetDiskUsage(sp.Path); di != nil { dp.FreeHuman = formatFreeSpace(di.AvailGB) if di.TotalGB > 0 { dp.FreePercent = di.AvailGB / di.TotalGB * 100 } } otherPaths = append(otherPaths, dp) } if len(otherPaths) == 0 { http.Error(w, "Nincs más elérhető tárhely az áthelyezéshez.", http.StatusBadRequest) return } // Current path label currentLabel := settings.InferStorageLabel(currentHDDPath) for _, sp := range s.settings.GetStoragePaths() { if sp.Path == currentHDDPath { currentLabel = sp.Label break } } // Estimate current data size mounts := stacks.ParseComposeHDDMounts(stack.ComposePath, currentHDDPath) var totalSizeHuman string if len(mounts) > 0 { var total int64 for _, m := range mounts { total += dirSizeInt64(m) } totalSizeHuman = dirSizeBytesHuman(total) } data := s.baseData("stacks", stack.Meta.DisplayName+" — Adatáthelyezés") data["Stack"] = stack data["Meta"] = stack.Meta data["CurrentHDDPath"] = currentHDDPath data["CurrentLabel"] = currentLabel data["OtherPaths"] = otherPaths data["DataSizeHuman"] = totalSizeHuman s.render(w, "migrate", data) } // storageMigrateAPIHandler handles POST /api/storage/migrate — starts migration job. func (s *Server) storageMigrateAPIHandler(w http.ResponseWriter, r *http.Request) { var req struct { StackName string `json:"stack_name"` TargetPath string `json:"target_path"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, "Érvénytelen kérés", http.StatusBadRequest) return } if req.StackName == "" || req.TargetPath == "" { jsonError(w, "Hiányos paraméterek", http.StatusBadRequest) return } 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 } if currentHDDPath == req.TargetPath { jsonError(w, "A forrás és a cél tárhely azonos", http.StatusBadRequest) return } mounts := stacks.ParseComposeHDDMounts(stack.ComposePath, currentHDDPath) if len(mounts) == 0 { jsonError(w, "Az alkalmazáshoz nem találhatók HDD csatlakozások", http.StatusBadRequest) return } job, ok := s.tryStartDiskJob("migrate") if !ok { jsonError(w, "Egy másik lemezművelet folyamatban van", http.StatusConflict) return } s.logger.Printf("[INFO] Migration started: stack=%s from=%s to=%s by %s", req.StackName, currentHDDPath, req.TargetPath, r.RemoteAddr) migrReq := storage.MigrateRequest{ StackName: req.StackName, DisplayName: stack.Meta.DisplayName, CurrentHDDPath: currentHDDPath, TargetPath: req.TargetPath, HDDMounts: mounts, } stopFn := func(name string) error { return s.stackMgr.StopStack(name) } startFn := func(name string) error { return s.stackMgr.StartStack(name) } updateFn := func(name, newPath string) error { return s.updateStackHDDPath(name, newPath) } 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) } close(progressCh) }() jsonResponse(w, map[string]interface{}{ "ok": true, "msg": "Áthelyezés elindítva", }) } // storageMigrateStatusAPIHandler handles GET /api/storage/migrate/status. func (s *Server) storageMigrateStatusAPIHandler(w http.ResponseWriter, r *http.Request) { job := s.currentDiskJob() if job == nil || job.jobType != "migrate" { jsonResponse(w, map[string]interface{}{ "ok": true, "active": false, }) return } p, ok := job.lastMigProg() if !ok { jsonResponse(w, map[string]interface{}{ "ok": true, "active": true, "step": "starting", "msg": "Áthelyezés elindult...", "pct": 0, }) return } jsonResponse(w, map[string]interface{}{ "ok": true, "active": !job.isDone(), "step": p.Step, "msg": p.Message, "pct": p.Percent, "error": p.Error, "done": job.isDone(), "bytes_copied": p.BytesCopied, "bytes_total": p.BytesTotal, "elapsed_sec": p.ElapsedSeconds, }) } // updateStackHDDPath updates the HDD_PATH in a stack's app.yaml. func (s *Server) updateStackHDDPath(stackName, newPath string) error { stack, ok := s.stackMgr.GetStack(stackName) if !ok { return fmt.Errorf("stack not found: %s", stackName) } stackDir := filepath.Dir(stack.ComposePath) appCfg := stacks.LoadAppConfig(stackDir) if appCfg == nil { return fmt.Errorf("app.yaml not found for stack: %s", stackName) } appCfg.Env["HDD_PATH"] = newPath return stacks.SaveAppConfig(stackDir, appCfg) } // storageInfoForStack returns deploy storage info for a deployed stack. func (s *Server) storageInfoForStack(stackName string) *DeployStorageInfo { appCfg := s.stackMgr.LoadAppConfigByName(stackName) if appCfg == nil { return nil } hddPath := appCfg.Env["HDD_PATH"] if hddPath == "" { return nil } info := &DeployStorageInfo{Path: hddPath} // Find label for _, sp := range s.settings.GetStoragePaths() { if sp.Path == hddPath { info.Label = sp.Label break } } if info.Label == "" { info.Label = settings.InferStorageLabel(hddPath) } // Data size stack, ok := s.stackMgr.GetStack(stackName) if ok { mounts := stacks.ParseComposeHDDMounts(stack.ComposePath, hddPath) var total int64 for _, m := range mounts { total += dirSizeInt64(m) } if total > 0 { info.DataSizeHuman = dirSizeBytesHuman(total) } } // Free space if di := system.GetDiskUsage(hddPath); di != nil { info.FreeHuman = formatFreeSpace(di.AvailGB) if di.TotalGB > 0 { info.FreePercent = di.AvailGB / di.TotalGB * 100 } } return info } // dirSizeInt64 returns total bytes in a directory. func dirSizeInt64(path string) int64 { var total int64 filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return nil } total += info.Size() return nil }) return total } // dirSizeBytesHuman formats bytes as human-readable. func dirSizeBytesHuman(b int64) string { const ( KB = 1024 MB = KB * 1024 GB = MB * 1024 ) switch { case b >= GB: return fmt.Sprintf("%.1f GB", float64(b)/float64(GB)) case b >= MB: return fmt.Sprintf("%.0f MB", float64(b)/float64(MB)) case b >= KB: return fmt.Sprintf("%.0f KB", float64(b)/float64(KB)) default: return fmt.Sprintf("%d B", b) } } // otherStoragePathsForStack returns storage paths excluding the one the app is on. func (s *Server) otherStoragePathsForStack(stackName string) []settings.StoragePath { appCfg := s.stackMgr.LoadAppConfigByName(stackName) if appCfg == nil { return nil } currentHDDPath := appCfg.Env["HDD_PATH"] var others []settings.StoragePath for _, sp := range s.settings.GetStoragePaths() { if sp.Path != currentHDDPath { others = append(others, sp) } } return others } // storageSectionLabel returns the label for a given path. func (s *Server) storageLabelForPath(path string) string { for _, sp := range s.settings.GetStoragePaths() { if sp.Path == path { return sp.Label } } return strings.TrimPrefix(path, "/mnt/") }