6b7ca566df
After an interrupted attach wizard, the raw mount stays behind, causing the device to appear as "mounted" in scan results. Now the scan button calls cancel first, which unmounts any stale raw mounts that have no bind mount in fstab. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1094 lines
30 KiB
Go
1094 lines
30 KiB
Go
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)
|
|
case path == "/api/storage/stale-cleanup" && r.Method == http.MethodPost:
|
|
s.staleDataCleanupHandler(w, r)
|
|
case path == "/api/storage/attach/mount-raw" && r.Method == http.MethodPost:
|
|
s.storageAttachMountRawHandler(w, r)
|
|
case path == "/api/storage/attach/browse" && r.Method == http.MethodGet:
|
|
s.storageAttachBrowseHandler(w, r)
|
|
case path == "/api/storage/attach/mkdir" && r.Method == http.MethodPost:
|
|
s.storageAttachMkdirHandler(w, r)
|
|
case path == "/api/storage/attach" && r.Method == http.MethodPost:
|
|
s.storageAttachAPIHandler(w, r)
|
|
case path == "/api/storage/attach/status" && r.Method == http.MethodGet:
|
|
s.storageAttachStatusAPIHandler(w, r)
|
|
case path == "/api/storage/attach/cancel" && r.Method == http.MethodPost:
|
|
s.storageAttachCancelHandler(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)
|
|
// Sync FileBrowser mounts with new storage path
|
|
s.syncFileBrowserMounts()
|
|
}
|
|
}()
|
|
|
|
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
|
|
}
|
|
|
|
// C8: Validate TargetPath against registered storage paths to prevent path traversal.
|
|
registeredPaths := s.settings.GetStoragePaths()
|
|
validTarget := false
|
|
for _, sp := range registeredPaths {
|
|
if req.TargetPath == sp.Path {
|
|
validTarget = true
|
|
break
|
|
}
|
|
}
|
|
if !validTarget {
|
|
jsonError(w, "Érvénytelen célútvonal: nem regisztrált adattároló", 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)
|
|
// Sync FileBrowser mounts (storage paths may now have new app data)
|
|
go s.syncFileBrowserMounts()
|
|
}
|
|
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/")
|
|
}
|
|
|
|
// 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,
|
|
})
|
|
}
|
|
|
|
// --- Attach Existing Drive Wizard ---
|
|
|
|
// storageAttachHandler serves the attach wizard page.
|
|
func (s *Server) storageAttachHandler(w http.ResponseWriter, r *http.Request) {
|
|
data := s.baseData("settings", "Meglévő meghajtó csatolása")
|
|
s.render(w, "storage_attach", data)
|
|
}
|
|
|
|
// storageAttachMountRawHandler handles POST /api/storage/attach/mount-raw.
|
|
// Temporarily mounts a partition at a staging path for browsing.
|
|
func (s *Server) storageAttachMountRawHandler(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
DevicePath string `json:"device_path"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.DevicePath == "" {
|
|
jsonError(w, "Hiányzó eszközútvonal", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Clean up any previous raw mount first
|
|
s.diskJobMu.Lock()
|
|
if s.activeRawMount != "" {
|
|
_ = storage.CleanupRawMount(s.activeRawMount)
|
|
s.activeRawMount = ""
|
|
}
|
|
s.diskJobMu.Unlock()
|
|
|
|
rawPath, err := storage.MountRaw(req.DevicePath)
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] storageAttachMountRaw: %v", err)
|
|
jsonError(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
s.diskJobMu.Lock()
|
|
s.activeRawMount = rawPath
|
|
s.diskJobMu.Unlock()
|
|
|
|
s.logger.Printf("[INFO] Raw mount for attach: %s → %s", req.DevicePath, rawPath)
|
|
|
|
jsonResponse(w, map[string]interface{}{
|
|
"ok": true,
|
|
"raw_path": rawPath,
|
|
})
|
|
}
|
|
|
|
// storageAttachBrowseHandler handles GET /api/storage/attach/browse?path=...
|
|
// Lists directories at the given path within the raw mount staging area.
|
|
func (s *Server) storageAttachBrowseHandler(w http.ResponseWriter, r *http.Request) {
|
|
browsePath := r.URL.Query().Get("path")
|
|
if browsePath == "" {
|
|
jsonError(w, "Hiányzó útvonal paraméter", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Security: validate path is under the raw mount staging area
|
|
cleanPath := filepath.Clean(browsePath)
|
|
if !strings.HasPrefix(cleanPath, storage.RawMountBase) {
|
|
jsonError(w, "Érvénytelen útvonal", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
dirs, err := storage.ListDirectories(cleanPath)
|
|
if err != nil {
|
|
jsonError(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
jsonResponse(w, map[string]interface{}{
|
|
"ok": true,
|
|
"path": cleanPath,
|
|
"dirs": dirs,
|
|
})
|
|
}
|
|
|
|
// storageAttachMkdirHandler handles POST /api/storage/attach/mkdir.
|
|
// Creates a new directory in the raw mount staging area.
|
|
func (s *Server) storageAttachMkdirHandler(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Path == "" || req.Name == "" {
|
|
jsonError(w, "Hiányos paraméterek", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Security: validate path is under the raw mount staging area
|
|
cleanPath := filepath.Clean(req.Path)
|
|
if !strings.HasPrefix(cleanPath, storage.RawMountBase) {
|
|
jsonError(w, "Érvénytelen útvonal", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
createdPath, err := storage.CreateDirectory(cleanPath, req.Name)
|
|
if err != nil {
|
|
jsonError(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
s.logger.Printf("[INFO] Created directory for attach: %s", createdPath)
|
|
|
|
jsonResponse(w, map[string]interface{}{
|
|
"ok": true,
|
|
"created_path": createdPath,
|
|
})
|
|
}
|
|
|
|
// storageAttachAPIHandler handles POST /api/storage/attach — starts the final attach job.
|
|
func (s *Server) storageAttachAPIHandler(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
DevicePath string `json:"device_path"`
|
|
MountName string `json:"mount_name"`
|
|
SubPath string `json:"sub_path"`
|
|
Label string `json:"label"`
|
|
SetDefault bool `json:"set_default"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.DevicePath == "" || req.MountName == "" || req.SubPath == "" {
|
|
jsonError(w, "Hiányos paraméterek", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
job, ok := s.tryStartDiskJob("attach")
|
|
if !ok {
|
|
jsonError(w, "Egy másik lemezművelet folyamatban van", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
s.logger.Printf("[INFO] Storage attach started: device=%s mountName=%s subPath=%s by %s",
|
|
req.DevicePath, req.MountName, req.SubPath, r.RemoteAddr)
|
|
|
|
attachReq := storage.AttachRequest{
|
|
DevicePath: req.DevicePath,
|
|
MountName: req.MountName,
|
|
SubPath: req.SubPath,
|
|
Label: req.Label,
|
|
SetDefault: req.SetDefault,
|
|
}
|
|
|
|
go func() {
|
|
progressCh := make(chan storage.FormatProgress, 32)
|
|
go func() {
|
|
for p := range progressCh {
|
|
job.appendFmtProg(p)
|
|
}
|
|
}()
|
|
|
|
mountPath, err := storage.FinalizeAttach(attachReq, progressCh)
|
|
close(progressCh)
|
|
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] Storage attach failed: %v", err)
|
|
return
|
|
}
|
|
|
|
// Clear raw mount tracking (it's now permanent via fstab)
|
|
s.diskJobMu.Lock()
|
|
s.activeRawMount = ""
|
|
s.diskJobMu.Unlock()
|
|
|
|
// 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 attach: %v", err)
|
|
} else {
|
|
s.logger.Printf("[INFO] Storage path registered: %s (%s)", mountPath, label)
|
|
s.syncFileBrowserMounts()
|
|
}
|
|
}()
|
|
|
|
jsonResponse(w, map[string]interface{}{
|
|
"ok": true,
|
|
"msg": "Csatolás elindítva",
|
|
})
|
|
}
|
|
|
|
// storageAttachStatusAPIHandler handles GET /api/storage/attach/status.
|
|
func (s *Server) storageAttachStatusAPIHandler(w http.ResponseWriter, r *http.Request) {
|
|
job := s.currentDiskJob()
|
|
if job == nil || job.jobType != "attach" {
|
|
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": "Csatolá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(),
|
|
})
|
|
}
|
|
|
|
// storageAttachCancelHandler handles POST /api/storage/attach/cancel.
|
|
// Cleans up the temporary raw mount when the user cancels the wizard.
|
|
// Also cleans up any stale raw mounts from interrupted previous sessions.
|
|
func (s *Server) storageAttachCancelHandler(w http.ResponseWriter, r *http.Request) {
|
|
s.diskJobMu.Lock()
|
|
rawMount := s.activeRawMount
|
|
s.activeRawMount = ""
|
|
s.diskJobMu.Unlock()
|
|
|
|
if rawMount != "" {
|
|
if err := storage.CleanupRawMount(rawMount); err != nil {
|
|
s.logger.Printf("[WARN] Failed to cleanup raw mount %s: %v", rawMount, err)
|
|
} else {
|
|
s.logger.Printf("[INFO] Cleaned up raw mount: %s", rawMount)
|
|
}
|
|
}
|
|
|
|
// Also clean up any stale raw mounts from previous interrupted sessions
|
|
storage.CleanupStaleRawMounts()
|
|
|
|
jsonResponse(w, map[string]interface{}{"ok": true})
|
|
}
|