Files
deploy-felhom-compose/controller/internal/web/storage_handlers.go
T
admin b4bda38fa1 feat: format empty partitions on system disk (v0.32.6)
Detect and offer to format empty (no filesystem) partitions on the system
disk. Adds IsSystemPartition() for granular per-partition safety checks
instead of blocking the entire system disk. Init wizard shows formatable
partitions with appropriate warnings. Add felhotest demo node to docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 16:54:16 +01:00

1601 lines
46 KiB
Go

package web
import (
"context"
"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", "migrate", or "migrate-drive"
done bool
fmtProg []storage.FormatProgress
migProg []storage.MigrateProgress
driveMigProg []storage.DriveMigrateProgress
}
// 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
}
}
// appendDriveMigProg adds a drive migration progress update to the job.
func (j *activeDiskJob) appendDriveMigProg(p storage.DriveMigrateProgress) {
j.mu.Lock()
defer j.mu.Unlock()
j.driveMigProg = append(j.driveMigProg, p)
if p.Step == "done" || p.Step == "error" {
j.done = true
}
}
// lastDriveMigProg returns the most recent drive migration progress.
func (j *activeDiskJob) lastDriveMigProg() (storage.DriveMigrateProgress, bool) {
j.mu.RLock()
defer j.mu.RUnlock()
if len(j.driveMigProg) == 0 {
return storage.DriveMigrateProgress{}, false
}
return j.driveMigProg[len(j.driveMigProg)-1], 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.executeTemplate(w, r, "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
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageAPI: %s %s from %s", r.Method, path, r.RemoteAddr)
}
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)
case path == "/api/storage/disconnect" && r.Method == http.MethodPost:
s.storageDisconnectHandler(w, r)
case path == "/api/storage/reconnect" && r.Method == http.MethodPost:
s.storageReconnectHandler(w, r)
case path == "/api/storage/restart-apps" && r.Method == http.MethodPost:
s.storageRestartAppsHandler(w, r)
case path == "/api/storage/status" && r.Method == http.MethodGet:
s.storageStatusHandler(w, r)
case path == "/api/storage/migrate-drive" && r.Method == http.MethodPost:
s.driveMigrateAPIHandler(w, r)
case path == "/api/storage/migrate-drive/status" && r.Method == http.MethodGet:
s.driveMigrateStatusHandler(w, r)
case path == "/api/storage/decommission/remove" && r.Method == http.MethodPost:
s.decommissionRemoveHandler(w, r)
default:
http.NotFound(w, r)
}
}
// storageScanAPIHandler handles POST /api/storage/scan.
func (s *Server) storageScanAPIHandler(w http.ResponseWriter, r *http.Request) {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageScan: scanning disks")
}
result, err := storage.ScanDisks(s.logger, s.cfg.Logging.Level == "debug")
if err != nil {
s.logger.Printf("[ERROR] [web] storageScan: %v", err)
jsonError(w, "Meghajtók keresése sikertelen: "+err.Error(), http.StatusInternalServerError)
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageScan: found %d available disks, %d system disks", len(result.AvailableDisks), len(result.SystemDisks))
}
jsonResponse(w, map[string]interface{}{
"ok": true,
"available": result.AvailableDisks,
"system": result.SystemDisks,
"available_count": len(result.AvailableDisks),
"formatable_partitions": result.FormatablePartitions,
})
}
// 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 s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageInit: device=%s mountName=%s label=%q partition=%v default=%v from %s",
req.DevicePath, req.MountName, req.Label, req.CreatePartition, req.SetDefault, r.RemoteAddr)
}
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] [web] 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,
Logger: s.logger,
Debug: s.cfg.Logging.Level == "debug",
}
// 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(s.logger, s.cfg.Logging.Level == "debug"); scanErr == nil {
for _, disk := range scanResult.AvailableDisks {
if disk.Path == req.DevicePath && len(disk.Partitions) == 1 && disk.Partitions[0].FSType == "" {
s.logger.Printf("[INFO] [web] 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] [web] 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] [web] Failed to register storage path after init: %v", err)
} else {
s.logger.Printf("[INFO] [web] 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.executeTemplate(w, r, "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"`
AutoDeleteStale *bool `json:"auto_delete_stale"` // default true
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageMigrate: stack=%s target=%s from %s", req.StackName, req.TargetPath, r.RemoteAddr)
}
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] [web] 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,
Logger: s.logger,
Debug: s.cfg.Logging.Level == "debug",
}
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)
}
autoDelete := true
if req.AutoDeleteStale != nil {
autoDelete = *req.AutoDeleteStale
}
orch := &storage.MigrateOrchestrator{
Sett: s.settings,
BackupTrigger: s.backupMgr,
Logger: s.logger,
}
opts := storage.MigrateOptions{
AutoDeleteStale: autoDelete,
}
go func() {
progressCh := make(chan storage.MigrateProgress, 64)
go func() {
for p := range progressCh {
job.appendMigProg(p)
}
}()
if err := orch.RunEnhancedMigration(migrReq, stopFn, startFn, updateFn, opts, progressCh); err != nil {
s.logger.Printf("[ERROR] [web] Migration failed: stack=%s: %v", req.StackName, err)
} else {
s.logger.Printf("[INFO] [web] 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
meta := stacks.LoadMetadata(stackDir)
return stacks.SaveAppConfig(stackDir, appCfg, s.encKey, stacks.SensitiveEnvVars(&meta))
}
// 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 s.isDebug() {
s.logger.Printf("[DEBUG] [web] staleDataCleanup: stack=%s stalePath=%s from %s", req.StackName, req.StalePath, r.RemoteAddr)
}
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] [web] 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] [web] 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] [web] 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.executeTemplate(w, r, "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
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageAttachMountRaw: device=%s from %s", req.DevicePath, r.RemoteAddr)
}
// Hold lock across entire cleanup+mount+set to prevent races
s.diskJobMu.Lock()
if s.activeRawMount != "" {
_ = storage.CleanupRawMount(s.activeRawMount)
s.activeRawMount = ""
}
rawPath, err := storage.MountRaw(req.DevicePath)
if err != nil {
s.diskJobMu.Unlock()
s.logger.Printf("[ERROR] [web] storageAttachMountRaw: %v", err)
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
s.activeRawMount = rawPath
s.diskJobMu.Unlock()
s.logger.Printf("[INFO] [web] 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 cleanPath != storage.RawMountBase && !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] [web] 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 s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageAttach: device=%s mountName=%s subPath=%s label=%q default=%v from %s",
req.DevicePath, req.MountName, req.SubPath, req.Label, req.SetDefault, r.RemoteAddr)
}
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] [web] 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,
Logger: s.logger,
Debug: s.cfg.Logging.Level == "debug",
}
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] [web] 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] [web] Failed to register storage path after attach: %v", err)
} else {
s.logger.Printf("[INFO] [web] 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] [web] Failed to cleanup raw mount %s: %v", rawMount, err)
} else {
s.logger.Printf("[INFO] [web] 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})
}
// storageDisconnectHandler handles POST /api/storage/disconnect.
// Performs a safe disconnect: stops affected apps, syncs, unmounts.
func (s *Server) storageDisconnectHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
return
}
if req.Path == "" {
jsonError(w, "Hiányzó útvonal", http.StatusBadRequest)
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageDisconnect: path=%s from %s", req.Path, r.RemoteAddr)
}
if s.storageWatchdog == nil {
jsonError(w, "Szolgáltatás nem elérhető", http.StatusServiceUnavailable)
return
}
// Check if USB device (only USB drives can be safely disconnected)
fsInfo := system.GetFSInfo(req.Path)
if fsInfo != nil && fsInfo.Device != "" && !system.IsUSBDevice(fsInfo.Device) {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageDisconnect: path=%s device=%s is not USB, rejecting", req.Path, fsInfo.Device)
}
jsonError(w, "Csak USB meghajtó választható le biztonságosan", http.StatusBadRequest)
return
}
stoppedStacks, err := s.storageWatchdog.SafeDisconnect(r.Context(), req.Path)
if err != nil {
s.logger.Printf("[ERROR] [web] Safe disconnect %s: %v", req.Path, err)
jsonError(w, fmt.Sprintf("Leválasztás sikertelen: %v", err), http.StatusInternalServerError)
return
}
s.logger.Printf("[INFO] [web] Disk disconnect completed: %s (stopped %d stacks)", req.Path, len(stoppedStacks))
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageDisconnect: path=%s success, stopped %d stacks", req.Path, len(stoppedStacks))
}
jsonResponse(w, map[string]interface{}{
"ok": true,
"message": "A meghajtó biztonságosan eltávolítható.",
"stopped_stacks": stoppedStacks,
})
}
// storageReconnectHandler handles POST /api/storage/reconnect.
// Attempts to remount a disconnected drive.
func (s *Server) storageReconnectHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
return
}
if req.Path == "" {
jsonError(w, "Hiányzó útvonal", http.StatusBadRequest)
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageReconnect: path=%s from %s", req.Path, r.RemoteAddr)
}
if s.storageWatchdog == nil {
jsonError(w, "Szolgáltatás nem elérhető", http.StatusServiceUnavailable)
return
}
stoppedStacks, err := s.storageWatchdog.Reconnect(r.Context(), req.Path)
if err != nil {
s.logger.Printf("[ERROR] [web] Reconnect %s: %v", req.Path, err)
jsonError(w, fmt.Sprintf("Csatlakoztatás sikertelen: %v", err), http.StatusInternalServerError)
return
}
s.logger.Printf("[INFO] [web] Disk reconnect completed: %s", req.Path)
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageReconnect: path=%s success, previously stopped stacks=%v", req.Path, stoppedStacks)
}
jsonResponse(w, map[string]interface{}{
"ok": true,
"message": "Meghajtó sikeresen csatlakoztatva.",
"stopped_stacks": stoppedStacks,
})
}
// storageRestartAppsHandler handles POST /api/storage/restart-apps.
// Restarts apps that were auto-stopped due to a drive disconnect.
func (s *Server) storageRestartAppsHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
return
}
if req.Path == "" {
jsonError(w, "Hiányzó útvonal", http.StatusBadRequest)
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageRestartApps: path=%s from %s", req.Path, r.RemoteAddr)
}
if s.storageWatchdog == nil {
jsonError(w, "Szolgáltatás nem elérhető", http.StatusServiceUnavailable)
return
}
// Validate drive is connected
if s.settings.IsDisconnected(req.Path) {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageRestartApps: path=%s is disconnected, rejecting", req.Path)
}
jsonError(w, "A meghajtó jelenleg leválasztva — először csatlakoztassa", http.StatusBadRequest)
return
}
started, failed := s.storageWatchdog.RestartStoppedApps(req.Path)
s.logger.Printf("[INFO] [web] Restart apps for %s: started=%d failed=%d", req.Path, len(started), len(failed))
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageRestartApps: path=%s started=%v failed=%v", req.Path, started, failed)
}
jsonResponse(w, map[string]interface{}{
"ok": true,
"started": started,
"failed": failed,
})
}
// storageStatusHandler handles GET /api/storage/status.
// Returns status of all storage paths including connection state and USB detection.
func (s *Server) storageStatusHandler(w http.ResponseWriter, r *http.Request) {
paths := s.settings.GetStoragePaths()
type pathStatus struct {
Path string `json:"path"`
Label string `json:"label"`
Connected bool `json:"connected"`
IsUSB bool `json:"is_usb"`
DisconnectedAt string `json:"disconnected_at"`
StoppedStacks []string `json:"stopped_stacks"`
}
result := make([]pathStatus, 0, len(paths))
for _, sp := range paths {
ps := pathStatus{
Path: sp.Path,
Label: sp.Label,
Connected: !sp.Disconnected,
DisconnectedAt: sp.DisconnectedAt,
StoppedStacks: sp.StoppedStacks,
}
if ps.StoppedStacks == nil {
ps.StoppedStacks = []string{}
}
// Detect USB for connected drives
if !sp.Disconnected {
if fsInfo := system.GetFSInfo(sp.Path); fsInfo != nil && fsInfo.Device != "" {
ps.IsUSB = system.IsUSBDevice(fsInfo.Device)
}
}
result = append(result, ps)
}
jsonResponse(w, map[string]interface{}{
"ok": true,
"data": result,
})
}
// migrateDrivePageHandler handles GET /settings/storage/migrate-drive.
func (s *Server) migrateDrivePageHandler(w http.ResponseWriter, r *http.Request) {
sourcePath := r.URL.Query().Get("source")
if sourcePath == "" {
http.Redirect(w, r, "/settings", http.StatusFound)
return
}
data := s.baseData("settings", "Meghajtó kiváltása")
data["SourcePath"] = sourcePath
data["SourceLabel"] = s.settings.GetStorageLabel(sourcePath)
data["SourceDiskInfo"] = system.GetDiskUsage(sourcePath)
// Find apps on source drive
type appInfo struct {
Name string
DisplayName string
}
var appsOnSource []appInfo
for _, stack := range s.stackMgr.GetStacks() {
if !stack.Deployed {
continue
}
cfg := s.stackMgr.LoadAppConfigByName(stack.Name)
if cfg != nil && cfg.Env["HDD_PATH"] == sourcePath {
appsOnSource = append(appsOnSource, appInfo{
Name: stack.Name,
DisplayName: stack.Meta.DisplayName,
})
}
}
data["AppsOnSource"] = appsOnSource
// Available destination paths
type destPathInfo struct {
Path string
Label string
FreeHuman string
}
var destPaths []destPathInfo
for _, sp := range s.settings.GetConnectedPaths() {
if sp.Path == sourcePath {
continue
}
free := ""
if di := system.GetDiskUsage(sp.Path); di != nil {
free = fmt.Sprintf("%.1f GB", di.AvailGB)
}
destPaths = append(destPaths, destPathInfo{
Path: sp.Path,
Label: sp.Label,
FreeHuman: free,
})
}
data["DestPaths"] = destPaths
// Tier 2 impact analysis
var tier2Impact []string
allCrossConfigs := s.settings.GetAllCrossDriveConfigs()
for _, app := range appsOnSource {
if cfg := allCrossConfigs[app.Name]; cfg != nil && cfg.Enabled {
tier2Impact = append(tier2Impact, fmt.Sprintf("%s: 2. szintű mentés → %s (automatikusan átirányításra kerül)",
app.DisplayName, s.settings.GetStorageLabel(cfg.DestinationPath)))
}
}
data["Tier2Impact"] = tier2Impact
s.executeTemplate(w, r, "migrate_drive", data)
}
// driveMigrateAPIHandler handles POST /api/storage/migrate-drive — starts drive migration.
func (s *Server) driveMigrateAPIHandler(w http.ResponseWriter, r *http.Request) {
if s.driveMigrator == nil {
jsonError(w, "Meghajtó-migráció nem elérhető", http.StatusServiceUnavailable)
return
}
var req struct {
SourcePath string `json:"source_path"`
DestPath string `json:"dest_path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] driveMigrate: source=%s dest=%s from %s", req.SourcePath, req.DestPath, r.RemoteAddr)
}
if req.SourcePath == "" || req.DestPath == "" {
jsonError(w, "Hiányos paraméterek", http.StatusBadRequest)
return
}
// Validate against registered paths
registeredPaths := s.settings.GetStoragePaths()
validSrc, validDst := false, false
for _, sp := range registeredPaths {
if sp.Path == req.SourcePath {
validSrc = true
}
if sp.Path == req.DestPath {
validDst = true
}
}
if !validSrc || !validDst {
jsonError(w, "Érvénytelen útvonal: nem regisztrált adattároló", http.StatusBadRequest)
return
}
job, ok := s.tryStartDiskJob("migrate-drive")
if !ok {
jsonError(w, "Egy másik lemezművelet folyamatban van", http.StatusConflict)
return
}
s.logger.Printf("[INFO] [web] Drive migration started: %s → %s by %s", req.SourcePath, req.DestPath, r.RemoteAddr)
go func() {
ctx := context.Background()
progressCh := make(chan storage.DriveMigrateProgress, 64)
go func() {
for p := range progressCh {
job.appendDriveMigProg(p)
}
}()
migrReq := storage.DriveMigrateRequest{
SourcePath: req.SourcePath,
DestPath: req.DestPath,
}
if err := s.driveMigrator.MigrateDrive(ctx, migrReq, progressCh); err != nil {
s.logger.Printf("[ERROR] [web] Drive migration failed: %v", err)
} else {
s.logger.Printf("[INFO] [web] Drive migration complete: %s → %s", req.SourcePath, req.DestPath)
go s.SyncFileBrowserMounts()
}
close(progressCh)
}()
jsonResponse(w, map[string]interface{}{
"ok": true,
"msg": "Meghajtó kiváltás elindítva",
})
}
// driveMigrateStatusHandler handles GET /api/storage/migrate-drive/status.
func (s *Server) driveMigrateStatusHandler(w http.ResponseWriter, r *http.Request) {
job := s.currentDiskJob()
if job == nil || job.jobType != "migrate-drive" {
jsonResponse(w, map[string]interface{}{
"ok": true,
"active": false,
})
return
}
p, ok := job.lastDriveMigProg()
if !ok {
jsonResponse(w, map[string]interface{}{
"ok": true,
"active": true,
"step": "validating",
"msg": "Meghajtó kiváltás elindult...",
"pct": 0,
})
return
}
jsonResponse(w, map[string]interface{}{
"ok": true,
"active": !job.isDone(),
"step": p.Step,
"msg": p.Message,
"detail": p.Detail,
"pct": p.Percent,
"error": p.Error,
"done": job.isDone(),
"elapsed_sec": p.ElapsedSeconds,
})
}
// decommissionRemoveHandler handles POST /api/storage/decommission/remove.
func (s *Server) decommissionRemoveHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"storage_path"`
}
// Support both form and JSON
if r.Header.Get("Content-Type") == "application/json" {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
return
}
} else {
_ = r.ParseForm()
req.Path = r.FormValue("storage_path")
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] decommissionRemove: path=%s from %s", req.Path, r.RemoteAddr)
}
if req.Path == "" {
jsonError(w, "Hiányzó útvonal", http.StatusBadRequest)
return
}
if !s.settings.IsDecommissioned(req.Path) {
jsonError(w, "A meghajtó nincs kiváltva állapotban", http.StatusBadRequest)
return
}
if err := s.settings.RemoveStoragePath(req.Path); err != nil {
s.logger.Printf("[ERROR] [web] Failed to remove decommissioned path %s: %v", req.Path, err)
jsonError(w, "Eltávolítás sikertelen: "+err.Error(), http.StatusInternalServerError)
return
}
s.logger.Printf("[INFO] [web] Decommissioned storage path removed: %s", req.Path)
// For form submissions, redirect back to settings
if r.Header.Get("Content-Type") != "application/json" {
http.Redirect(w, r, "/settings?storage_msg=success&storage_detail=Meghajtó+eltávolítva+a+rendszerből.", http.StatusFound)
return
}
jsonResponse(w, map[string]interface{}{
"ok": true,
"msg": "Meghajtó eltávolítva a rendszerből",
})
}