Files
deploy-felhom-compose/controller/internal/web/storage_handlers.go
T
admin 95c821deb2 feat: comprehensive debug logging across all controller modules
Add detailed [DEBUG] logging to every controller module when
logging.level is set to "debug". Each module with stateful debug
uses SetDebug(bool) wired from main.go. Covers stacks, backup,
cloudflare, integrations, system, monitor, settings, scheduler,
web handlers, storage, metrics, API, selfupdate, and assets.

Also includes the app export/import (.fab bundles) feature from
v0.32.0 and its debug page integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:14:43 +01:00

1597 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] 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),
})
}
// 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] 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] 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.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] 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] 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
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] 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.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] storageAttachMountRaw: %v", err)
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
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 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] 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] 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] 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})
}
// 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] Safe disconnect %s: %v", req.Path, err)
jsonError(w, fmt.Sprintf("Leválasztás sikertelen: %v", err), http.StatusInternalServerError)
return
}
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] Reconnect %s: %v", req.Path, err)
jsonError(w, fmt.Sprintf("Csatlakoztatás sikertelen: %v", err), http.StatusInternalServerError)
return
}
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)
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] 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] Drive migration failed: %v", err)
} else {
s.logger.Printf("[INFO] 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] 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] 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",
})
}