95c821deb2
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>
1597 lines
46 KiB
Go
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",
|
|
})
|
|
}
|