be7803c0ac
- Add [DEBUG] logging across all modules (backup, storage, sync, selfupdate, monitor, notify, report, assets, setup) gated behind logging.level: "debug" - Add /api/debug/dump endpoint returning full controller state JSON (debug only) - Add startup self-test validating 9 subsystems (Docker, dirs, storage, hub, restic repos, metrics DB) with pass/warn/fail summary - New packages: internal/selftest, internal/util - Constructor/signature changes: debug bool params, logger params on RunHealthCheck and BuildReport, smart watchdog probe logging Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1528 lines
43 KiB
Go
1528 lines
43 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
|
|
|
|
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) {
|
|
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
|
|
}
|
|
jsonResponse(w, map[string]interface{}{
|
|
"ok": true,
|
|
"available": result.AvailableDisks,
|
|
"system": result.SystemDisks,
|
|
"available_count": len(result.AvailableDisks),
|
|
})
|
|
}
|
|
|
|
// storageInitAPIHandler handles POST /api/storage/init — starts format+mount job.
|
|
func (s *Server) storageInitAPIHandler(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
DevicePath string `json:"device_path"`
|
|
MountName string `json:"mount_name"`
|
|
Label string `json:"label"`
|
|
CreatePartition bool `json:"create_partition"`
|
|
SetDefault bool `json:"set_default"`
|
|
Confirm string `json:"confirm"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Confirm != "FORMÁZÁS" {
|
|
jsonError(w, "Megerősítés szükséges: írja be 'FORMÁZÁS'", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.DevicePath == "" || req.MountName == "" {
|
|
jsonError(w, "Hiányos paraméterek", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
job, ok := s.tryStartDiskJob("format")
|
|
if !ok {
|
|
jsonError(w, "Egy másik lemezművelet folyamatban van", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
s.logger.Printf("[INFO] Storage init started: device=%s mountName=%s by %s", req.DevicePath, req.MountName, r.RemoteAddr)
|
|
|
|
fmtReq := storage.FormatRequest{
|
|
DevicePath: req.DevicePath,
|
|
MountName: req.MountName,
|
|
Label: req.Label,
|
|
CreatePartition: req.CreatePartition,
|
|
SetDefault: req.SetDefault,
|
|
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 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
|
|
return stacks.SaveAppConfig(stackDir, appCfg)
|
|
}
|
|
|
|
// storageInfoForStack returns deploy storage info for a deployed stack.
|
|
func (s *Server) storageInfoForStack(stackName string) *DeployStorageInfo {
|
|
appCfg := s.stackMgr.LoadAppConfigByName(stackName)
|
|
if appCfg == nil {
|
|
return nil
|
|
}
|
|
hddPath := appCfg.Env["HDD_PATH"]
|
|
if hddPath == "" {
|
|
return nil
|
|
}
|
|
|
|
info := &DeployStorageInfo{Path: hddPath}
|
|
|
|
// Find label
|
|
for _, sp := range s.settings.GetStoragePaths() {
|
|
if sp.Path == hddPath {
|
|
info.Label = sp.Label
|
|
break
|
|
}
|
|
}
|
|
if info.Label == "" {
|
|
info.Label = settings.InferStorageLabel(hddPath)
|
|
}
|
|
|
|
// Data size
|
|
stack, ok := s.stackMgr.GetStack(stackName)
|
|
if ok {
|
|
mounts := stacks.ParseComposeHDDMounts(stack.ComposePath, hddPath)
|
|
var total int64
|
|
for _, m := range mounts {
|
|
total += dirSizeInt64(m)
|
|
}
|
|
if total > 0 {
|
|
info.DataSizeHuman = dirSizeBytesHuman(total)
|
|
}
|
|
}
|
|
|
|
// Free space
|
|
if di := system.GetDiskUsage(hddPath); di != nil {
|
|
info.FreeHuman = formatFreeSpace(di.AvailGB)
|
|
if di.TotalGB > 0 {
|
|
info.FreePercent = di.AvailGB / di.TotalGB * 100
|
|
}
|
|
}
|
|
|
|
return info
|
|
}
|
|
|
|
// dirSizeInt64 returns total bytes in a directory.
|
|
func dirSizeInt64(path string) int64 {
|
|
var total int64
|
|
filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
total += info.Size()
|
|
return nil
|
|
})
|
|
return total
|
|
}
|
|
|
|
// dirSizeBytesHuman formats bytes as human-readable.
|
|
func dirSizeBytesHuman(b int64) string {
|
|
const (
|
|
KB = 1024
|
|
MB = KB * 1024
|
|
GB = MB * 1024
|
|
)
|
|
switch {
|
|
case b >= GB:
|
|
return fmt.Sprintf("%.1f GB", float64(b)/float64(GB))
|
|
case b >= MB:
|
|
return fmt.Sprintf("%.0f MB", float64(b)/float64(MB))
|
|
case b >= KB:
|
|
return fmt.Sprintf("%.0f KB", float64(b)/float64(KB))
|
|
default:
|
|
return fmt.Sprintf("%d B", b)
|
|
}
|
|
}
|
|
|
|
// otherStoragePathsForStack returns storage paths excluding the one the app is on.
|
|
func (s *Server) otherStoragePathsForStack(stackName string) []settings.StoragePath {
|
|
appCfg := s.stackMgr.LoadAppConfigByName(stackName)
|
|
if appCfg == nil {
|
|
return nil
|
|
}
|
|
currentHDDPath := appCfg.Env["HDD_PATH"]
|
|
var others []settings.StoragePath
|
|
for _, sp := range s.settings.GetStoragePaths() {
|
|
if sp.Path != currentHDDPath {
|
|
others = append(others, sp)
|
|
}
|
|
}
|
|
return others
|
|
}
|
|
|
|
// storageSectionLabel returns the label for a given path.
|
|
func (s *Server) storageLabelForPath(path string) string {
|
|
for _, sp := range s.settings.GetStoragePaths() {
|
|
if sp.Path == path {
|
|
return sp.Label
|
|
}
|
|
}
|
|
return strings.TrimPrefix(path, "/mnt/")
|
|
}
|
|
|
|
// StaleStorageData describes leftover data on a non-active storage path.
|
|
type StaleStorageData struct {
|
|
Path string // e.g., "/mnt/hdd_placeholder"
|
|
Label string // e.g., "Külső tárhely (hdd_placeholder)"
|
|
Mounts []string // host-side paths with data
|
|
SizeHuman string // e.g., "48 MB"
|
|
SizeBytes int64
|
|
}
|
|
|
|
// findStaleStorageData detects leftover app data on non-active storage paths.
|
|
// This happens after migration: the old data stays on the previous storage path.
|
|
func (s *Server) findStaleStorageData(stackName string) []StaleStorageData {
|
|
appCfg := s.stackMgr.LoadAppConfigByName(stackName)
|
|
if appCfg == nil {
|
|
return nil
|
|
}
|
|
currentHDDPath := appCfg.Env["HDD_PATH"]
|
|
if currentHDDPath == "" {
|
|
return nil
|
|
}
|
|
|
|
stack, ok := s.stackMgr.GetStack(stackName)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
var result []StaleStorageData
|
|
|
|
// Check all registered storage paths except the current one
|
|
for _, sp := range s.settings.GetStoragePaths() {
|
|
if sp.Path == currentHDDPath {
|
|
continue
|
|
}
|
|
|
|
// Use ParseComposeHDDMounts to find what dirs WOULD exist on this path
|
|
mounts := stacks.ParseComposeHDDMounts(stack.ComposePath, sp.Path)
|
|
if len(mounts) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Check which mounts actually have data
|
|
var existingMounts []string
|
|
var totalSize int64
|
|
for _, m := range mounts {
|
|
info, err := os.Stat(m)
|
|
if err != nil || !info.IsDir() {
|
|
continue
|
|
}
|
|
size := dirSizeInt64(m)
|
|
if size > 0 {
|
|
existingMounts = append(existingMounts, m)
|
|
totalSize += size
|
|
}
|
|
}
|
|
|
|
if len(existingMounts) == 0 {
|
|
continue
|
|
}
|
|
|
|
label := sp.Label
|
|
if label == "" {
|
|
label = settings.InferStorageLabel(sp.Path)
|
|
}
|
|
|
|
result = append(result, StaleStorageData{
|
|
Path: sp.Path,
|
|
Label: label,
|
|
Mounts: existingMounts,
|
|
SizeHuman: dirSizeBytesHuman(totalSize),
|
|
SizeBytes: totalSize,
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// staleDataCleanupHandler handles POST /api/storage/stale-cleanup.
|
|
// Deletes leftover app data from a previous storage path after migration.
|
|
func (s *Server) staleDataCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
StackName string `json:"stack_name"`
|
|
StalePath string `json:"stale_path"` // the old storage root, e.g., "/mnt/hdd_placeholder"
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.StackName == "" || req.StalePath == "" {
|
|
jsonError(w, "Hiányos paraméterek", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Verify the app exists and is deployed
|
|
stack, ok := s.stackMgr.GetStack(req.StackName)
|
|
if !ok {
|
|
jsonError(w, "Alkalmazás nem található: "+req.StackName, http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
appCfg := s.stackMgr.LoadAppConfigByName(req.StackName)
|
|
if appCfg == nil || !appCfg.Deployed {
|
|
jsonError(w, "Az alkalmazás nincs telepítve", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
currentHDDPath := appCfg.Env["HDD_PATH"]
|
|
if currentHDDPath == "" {
|
|
jsonError(w, "Az alkalmazásnak nincs HDD_PATH beállítva", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// SAFETY: StalePath must NOT be the current HDD_PATH
|
|
if req.StalePath == currentHDDPath {
|
|
jsonError(w, "Az aktív tárhely adatai nem törölhetők! Ez az alkalmazás aktuális adattárolója.", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// SAFETY: StalePath must be a registered storage path
|
|
found := false
|
|
for _, sp := range s.settings.GetStoragePaths() {
|
|
if sp.Path == req.StalePath {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
jsonError(w, "A megadott útvonal nem regisztrált adattároló", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Find mounts to delete
|
|
mounts := stacks.ParseComposeHDDMounts(stack.ComposePath, req.StalePath)
|
|
if len(mounts) == 0 {
|
|
jsonError(w, "Nem találhatók törlendő adatok", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Protected paths check
|
|
protected := stacks.ProtectedHDDPaths(req.StalePath)
|
|
|
|
var deleted []string
|
|
var errors []string
|
|
var totalFreed int64
|
|
|
|
for _, mountPath := range mounts {
|
|
cleanPath := filepath.Clean(mountPath)
|
|
|
|
// Safety: never delete protected top-level dirs
|
|
if protected != nil && protected[cleanPath] {
|
|
s.logger.Printf("[WARN] Refusing to delete protected HDD path: %s", cleanPath)
|
|
errors = append(errors, fmt.Sprintf("Védett útvonal, nem törölhető: %s", cleanPath))
|
|
continue
|
|
}
|
|
|
|
// Verify it actually exists and has data
|
|
info, err := os.Stat(cleanPath)
|
|
if err != nil || !info.IsDir() {
|
|
continue
|
|
}
|
|
|
|
size := dirSizeInt64(cleanPath)
|
|
|
|
if err := os.RemoveAll(cleanPath); err != nil {
|
|
s.logger.Printf("[ERROR] Failed to remove stale data %s: %v", cleanPath, err)
|
|
errors = append(errors, fmt.Sprintf("Törlés sikertelen: %s — %v", cleanPath, err))
|
|
} else {
|
|
s.logger.Printf("[INFO] Removed stale data: %s (%s) for stack %s", cleanPath, dirSizeBytesHuman(size), req.StackName)
|
|
deleted = append(deleted, cleanPath)
|
|
totalFreed += size
|
|
}
|
|
}
|
|
|
|
if len(deleted) == 0 && len(errors) > 0 {
|
|
jsonError(w, "Törlés sikertelen: "+strings.Join(errors, "; "), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
jsonResponse(w, map[string]interface{}{
|
|
"ok": true,
|
|
"deleted": deleted,
|
|
"freed_human": dirSizeBytesHuman(totalFreed),
|
|
"errors": errors,
|
|
})
|
|
}
|
|
|
|
// --- Attach Existing Drive Wizard ---
|
|
|
|
// storageAttachHandler serves the attach wizard page.
|
|
func (s *Server) storageAttachHandler(w http.ResponseWriter, r *http.Request) {
|
|
data := s.baseData("settings", "Meglévő meghajtó csatolása")
|
|
s.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
|
|
}
|
|
|
|
// Clean up any previous raw mount first
|
|
s.diskJobMu.Lock()
|
|
if s.activeRawMount != "" {
|
|
_ = storage.CleanupRawMount(s.activeRawMount)
|
|
s.activeRawMount = ""
|
|
}
|
|
s.diskJobMu.Unlock()
|
|
|
|
rawPath, err := storage.MountRaw(req.DevicePath)
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] storageAttachMountRaw: %v", err)
|
|
jsonError(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
s.diskJobMu.Lock()
|
|
s.activeRawMount = rawPath
|
|
s.diskJobMu.Unlock()
|
|
|
|
s.logger.Printf("[INFO] Raw mount for attach: %s → %s", req.DevicePath, rawPath)
|
|
|
|
jsonResponse(w, map[string]interface{}{
|
|
"ok": true,
|
|
"raw_path": rawPath,
|
|
})
|
|
}
|
|
|
|
// storageAttachBrowseHandler handles GET /api/storage/attach/browse?path=...
|
|
// Lists directories at the given path within the raw mount staging area.
|
|
func (s *Server) storageAttachBrowseHandler(w http.ResponseWriter, r *http.Request) {
|
|
browsePath := r.URL.Query().Get("path")
|
|
if browsePath == "" {
|
|
jsonError(w, "Hiányzó útvonal paraméter", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Security: validate path is under the raw mount staging area
|
|
cleanPath := filepath.Clean(browsePath)
|
|
if !strings.HasPrefix(cleanPath, storage.RawMountBase) {
|
|
jsonError(w, "Érvénytelen útvonal", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
dirs, err := storage.ListDirectories(cleanPath)
|
|
if err != nil {
|
|
jsonError(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
jsonResponse(w, map[string]interface{}{
|
|
"ok": true,
|
|
"path": cleanPath,
|
|
"dirs": dirs,
|
|
})
|
|
}
|
|
|
|
// storageAttachMkdirHandler handles POST /api/storage/attach/mkdir.
|
|
// Creates a new directory in the raw mount staging area.
|
|
func (s *Server) storageAttachMkdirHandler(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Path == "" || req.Name == "" {
|
|
jsonError(w, "Hiányos paraméterek", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Security: validate path is under the raw mount staging area
|
|
cleanPath := filepath.Clean(req.Path)
|
|
if !strings.HasPrefix(cleanPath, storage.RawMountBase) {
|
|
jsonError(w, "Érvénytelen útvonal", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
createdPath, err := storage.CreateDirectory(cleanPath, req.Name)
|
|
if err != nil {
|
|
jsonError(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
s.logger.Printf("[INFO] Created directory for attach: %s", createdPath)
|
|
|
|
jsonResponse(w, map[string]interface{}{
|
|
"ok": true,
|
|
"created_path": createdPath,
|
|
})
|
|
}
|
|
|
|
// storageAttachAPIHandler handles POST /api/storage/attach — starts the final attach job.
|
|
func (s *Server) storageAttachAPIHandler(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
DevicePath string `json:"device_path"`
|
|
MountName string `json:"mount_name"`
|
|
SubPath string `json:"sub_path"`
|
|
Label string `json:"label"`
|
|
SetDefault bool `json:"set_default"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.DevicePath == "" || req.MountName == "" || req.SubPath == "" {
|
|
jsonError(w, "Hiányos paraméterek", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
job, ok := s.tryStartDiskJob("attach")
|
|
if !ok {
|
|
jsonError(w, "Egy másik lemezművelet folyamatban van", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
s.logger.Printf("[INFO] Storage attach started: device=%s mountName=%s subPath=%s by %s",
|
|
req.DevicePath, req.MountName, req.SubPath, r.RemoteAddr)
|
|
|
|
attachReq := storage.AttachRequest{
|
|
DevicePath: req.DevicePath,
|
|
MountName: req.MountName,
|
|
SubPath: req.SubPath,
|
|
Label: req.Label,
|
|
SetDefault: req.SetDefault,
|
|
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.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) {
|
|
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
|
|
}
|
|
|
|
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.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
|
|
}
|
|
|
|
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.storageWatchdog == nil {
|
|
jsonError(w, "Szolgáltatás nem elérhető", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
// Validate drive is connected
|
|
if s.settings.IsDisconnected(req.Path) {
|
|
jsonError(w, "A meghajtó jelenleg leválasztva — először csatlakoztassa", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
started, failed := s.storageWatchdog.RestartStoppedApps(req.Path)
|
|
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 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 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",
|
|
})
|
|
}
|