feat: drive migration & Tier 2 restic deprecation (v0.18.0)
Phase 1: Deprecate restic as Tier 2 method (rsync only), auto-migrate on startup Phase 2: Enhanced per-app migration with backup awareness, DB dump copy, auto-cleanup Phase 3: Full drive migration with decommissioned state, rollback support, wizard UI Phase 4: Hub report includes decommissioned drive state Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,10 @@ type StorageBarInfo struct {
|
||||
func (s *Server) buildStorageBars() []StorageBarInfo {
|
||||
var bars []StorageBarInfo
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
// Skip decommissioned drives — they are no longer in active use
|
||||
if sp.Decommissioned {
|
||||
continue
|
||||
}
|
||||
if sp.Disconnected {
|
||||
bars = append(bars, StorageBarInfo{
|
||||
Label: sp.Label,
|
||||
@@ -74,13 +78,15 @@ type StorageAppDetail struct {
|
||||
// StoragePathView extends StoragePath with display data for the settings page.
|
||||
type StoragePathView struct {
|
||||
settings.StoragePath
|
||||
DiskInfo *system.DiskUsageInfo
|
||||
AppCount int
|
||||
IsMounted bool
|
||||
AppDetails []StorageAppDetail
|
||||
FSInfo *system.FSInfo
|
||||
IsUSB bool // true if this is a USB-attached device (safe disconnect available)
|
||||
StoppedApps []string // stacks auto-stopped due to disconnect (for restart UI)
|
||||
DiskInfo *system.DiskUsageInfo
|
||||
AppCount int
|
||||
IsMounted bool
|
||||
AppDetails []StorageAppDetail
|
||||
FSInfo *system.FSInfo
|
||||
IsUSB bool // true if this is a USB-attached device (safe disconnect available)
|
||||
StoppedApps []string // stacks auto-stopped due to disconnect (for restart UI)
|
||||
MigratedToLabel string // label of the drive data was migrated to
|
||||
HasOtherPaths bool // true if other connected non-decommissioned paths exist
|
||||
}
|
||||
|
||||
func (s *Server) baseData(page, title string) map[string]interface{} {
|
||||
@@ -606,14 +612,7 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
tier2GroupMap[item.DestPath] = grp
|
||||
}
|
||||
switch item.Method {
|
||||
case "restic":
|
||||
grp.ResticItems = append(grp.ResticItems, item)
|
||||
case "rsync":
|
||||
grp.RsyncItems = append(grp.RsyncItems, item)
|
||||
default:
|
||||
grp.RsyncItems = append(grp.RsyncItems, item)
|
||||
}
|
||||
grp.Items = append(grp.Items, item)
|
||||
}
|
||||
var tier2Groups []Tier2DriveGroup
|
||||
for _, grp := range tier2GroupMap {
|
||||
@@ -629,10 +628,9 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Tier2DriveGroup holds grouped Tier 2 cross-drive backup items for one destination drive.
|
||||
type Tier2DriveGroup struct {
|
||||
DestPath string
|
||||
DestLabel string
|
||||
ResticItems []backup.CrossDriveSummaryItem
|
||||
RsyncItems []backup.CrossDriveSummaryItem
|
||||
DestPath string
|
||||
DestLabel string
|
||||
Items []backup.CrossDriveSummaryItem
|
||||
}
|
||||
|
||||
// AppBackupRow holds per-tier backup information for one app on the backup page.
|
||||
@@ -659,8 +657,6 @@ type AppBackupRow struct {
|
||||
|
||||
// Tier 2: Cross-drive backup (configurable for all apps)
|
||||
Tier2Configured bool
|
||||
Tier2Method string // "rsync", "restic"
|
||||
Tier2MethodLabel string // "rsync", "restic"
|
||||
Tier2Dest string // destination label
|
||||
Tier2Schedule string // "Naponta", "Hetente"
|
||||
Tier2LastRun string
|
||||
@@ -668,7 +664,6 @@ type AppBackupRow struct {
|
||||
Tier2LastError string
|
||||
Tier2StatusBadge string // "Sikeres", "Hiba", "Fut...", "—"
|
||||
Tier2SizeHuman string
|
||||
Tier2Browsable bool // true for rsync (plain files), false for restic
|
||||
|
||||
// Drive disconnected — app's home drive is currently disconnected
|
||||
DriveDisconnected bool
|
||||
@@ -777,9 +772,6 @@ func (s *Server) buildAppBackupRows(
|
||||
row.Tier2Configured = false
|
||||
} else {
|
||||
row.Tier2Configured = true
|
||||
row.Tier2Method = cfg.Method
|
||||
row.Tier2MethodLabel = cfg.Method // "rsync" or "restic"
|
||||
row.Tier2Browsable = cfg.Method == "rsync"
|
||||
row.Tier2Dest = destLabels[cfg.DestinationPath]
|
||||
if row.Tier2Dest == "" {
|
||||
row.Tier2Dest = cfg.DestinationPath
|
||||
@@ -854,21 +846,15 @@ func (s *Server) settingsCrossBackupHandler(w http.ResponseWriter, r *http.Reque
|
||||
// Preserve existing runtime status fields and config when disabling
|
||||
existing := s.settings.GetCrossDriveConfig(name)
|
||||
|
||||
var method, destPath, schedule string
|
||||
var destPath, schedule string
|
||||
if enabled {
|
||||
method = r.FormValue("cross_drive_method")
|
||||
destPath = r.FormValue("cross_drive_dest")
|
||||
schedule = r.FormValue("cross_drive_schedule")
|
||||
// Validate method and schedule
|
||||
if method != "rsync" && method != "restic" {
|
||||
method = "rsync"
|
||||
}
|
||||
if schedule != "daily" && schedule != "weekly" {
|
||||
schedule = "daily"
|
||||
}
|
||||
} else if existing != nil {
|
||||
// Preserve existing settings when disabling
|
||||
method = existing.Method
|
||||
destPath = existing.DestinationPath
|
||||
schedule = existing.Schedule
|
||||
}
|
||||
@@ -877,7 +863,7 @@ func (s *Server) settingsCrossBackupHandler(w http.ResponseWriter, r *http.Reque
|
||||
if destPath != "" || existing != nil {
|
||||
cfg = &settings.CrossDriveBackup{
|
||||
Enabled: enabled,
|
||||
Method: method,
|
||||
Method: "rsync",
|
||||
DestinationPath: destPath,
|
||||
Schedule: schedule,
|
||||
}
|
||||
@@ -896,8 +882,8 @@ func (s *Server) settingsCrossBackupHandler(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Cross-drive backup config saved for %s: method=%s dest=%s schedule=%s enabled=%v",
|
||||
name, method, destPath, schedule, enabled)
|
||||
s.logger.Printf("[INFO] Cross-drive backup config saved for %s: dest=%s schedule=%s enabled=%v",
|
||||
name, destPath, schedule, enabled)
|
||||
|
||||
http.Redirect(w, r, "/stacks/"+name+"/deploy?flash=Ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1s+mentve.", http.StatusFound)
|
||||
}
|
||||
@@ -966,15 +952,25 @@ func (s *Server) settingsData() map[string]interface{} {
|
||||
|
||||
// Storage paths with display data
|
||||
storagePaths := s.settings.GetStoragePaths()
|
||||
connectedCount := 0
|
||||
for _, sp := range storagePaths {
|
||||
if !sp.Disconnected && !sp.Decommissioned {
|
||||
connectedCount++
|
||||
}
|
||||
}
|
||||
var storageViews []StoragePathView
|
||||
for _, sp := range storagePaths {
|
||||
view := StoragePathView{
|
||||
StoragePath: sp,
|
||||
StoppedApps: sp.StoppedStacks,
|
||||
StoragePath: sp,
|
||||
StoppedApps: sp.StoppedStacks,
|
||||
HasOtherPaths: connectedCount > 1,
|
||||
}
|
||||
if sp.Disconnected {
|
||||
// Skip I/O calls on disconnected drives — they'd hang or fail
|
||||
view.IsMounted = false
|
||||
} else if sp.Decommissioned {
|
||||
view.IsMounted = false
|
||||
view.MigratedToLabel = s.settings.GetStorageLabel(sp.MigratedTo)
|
||||
} else {
|
||||
view.IsMounted = system.IsMountPoint(sp.Path)
|
||||
view.AppDetails = s.appDetailsForPath(sp.Path)
|
||||
@@ -1297,7 +1293,7 @@ func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Storage path added: %s (%s)", path, label)
|
||||
go s.syncFileBrowserMounts()
|
||||
go s.SyncFileBrowserMounts()
|
||||
http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló sikeresen hozzáadva: "+path), http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -1339,7 +1335,7 @@ func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Req
|
||||
|
||||
s.logger.Printf("[INFO] Storage path removed: %s", path)
|
||||
// Sync FileBrowser mounts after storage path removal
|
||||
go s.syncFileBrowserMounts()
|
||||
go s.SyncFileBrowserMounts()
|
||||
http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló eltávolítva: "+path), http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -1392,9 +1388,9 @@ func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Requ
|
||||
http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Megnevezés módosítva: "+label), http.StatusFound)
|
||||
}
|
||||
|
||||
// syncFileBrowserMounts regenerates FileBrowser's docker-compose.yml and config.yaml
|
||||
// SyncFileBrowserMounts regenerates FileBrowser's docker-compose.yml and config.yaml
|
||||
// with volume mounts and sources for all registered storage paths, then recreates the container.
|
||||
func (s *Server) syncFileBrowserMounts() {
|
||||
func (s *Server) SyncFileBrowserMounts() {
|
||||
stackDir := "/opt/docker/stacks/filebrowser"
|
||||
composePath := stackDir + "/docker-compose.yml"
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -47,6 +48,9 @@ type Server struct {
|
||||
// Active raw mount for the attach wizard (empty when not in use)
|
||||
activeRawMount string
|
||||
|
||||
// Drive migration
|
||||
driveMigrator *storage.DriveMigrator
|
||||
|
||||
// DR restore mode state
|
||||
restoreMu sync.RWMutex
|
||||
restorePlan *backup.RestorePlan
|
||||
@@ -85,7 +89,7 @@ func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *syste
|
||||
}
|
||||
|
||||
// Sync FileBrowser config on startup to ensure mounts and sources are current
|
||||
go s.syncFileBrowserMounts()
|
||||
go s.SyncFileBrowserMounts()
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -108,6 +112,11 @@ func (s *Server) SetStorageWatchdog(w *monitor.StorageWatchdog) {
|
||||
s.storageWatchdog = w
|
||||
}
|
||||
|
||||
// SetDriveMigrator sets the drive migration engine for full drive migration.
|
||||
func (s *Server) SetDriveMigrator(dm *storage.DriveMigrator) {
|
||||
s.driveMigrator = dm
|
||||
}
|
||||
|
||||
// InRestoreMode returns true if the server is in DR restore mode.
|
||||
func (s *Server) InRestoreMode() bool {
|
||||
s.restoreMu.RLock()
|
||||
@@ -179,6 +188,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.storageInitHandler(w, r)
|
||||
case path == "/settings/storage/attach":
|
||||
s.storageAttachHandler(w, r)
|
||||
case path == "/settings/storage/migrate-drive":
|
||||
s.migrateDrivePageHandler(w, r)
|
||||
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/migrate"):
|
||||
name := strings.TrimPrefix(path, "/stacks/")
|
||||
name = strings.TrimSuffix(name, "/migrate")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -18,11 +19,12 @@ import (
|
||||
|
||||
// activeDiskJob tracks an in-progress disk operation (format or migrate).
|
||||
type activeDiskJob struct {
|
||||
mu sync.RWMutex
|
||||
jobType string // "format" or "migrate"
|
||||
done bool
|
||||
fmtProg []storage.FormatProgress
|
||||
migProg []storage.MigrateProgress
|
||||
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).
|
||||
@@ -54,6 +56,26 @@ func (j *activeDiskJob) appendMigProg(p storage.MigrateProgress) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -162,6 +184,12 @@ func (s *Server) storageAPIHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
@@ -273,7 +301,7 @@ func (s *Server) storageInitAPIHandler(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
s.logger.Printf("[INFO] Storage path registered: %s (%s)", mountPath, label)
|
||||
// Sync FileBrowser mounts with new storage path
|
||||
s.syncFileBrowserMounts()
|
||||
s.SyncFileBrowserMounts()
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -393,8 +421,9 @@ func (s *Server) migratePageHandler(w http.ResponseWriter, r *http.Request, stac
|
||||
// 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"`
|
||||
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)
|
||||
@@ -476,6 +505,20 @@ func (s *Server) storageMigrateAPIHandler(w http.ResponseWriter, r *http.Request
|
||||
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() {
|
||||
@@ -484,12 +527,12 @@ func (s *Server) storageMigrateAPIHandler(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
}()
|
||||
|
||||
if err := storage.MigrateAppData(migrReq, stopFn, startFn, updateFn, progressCh); err != nil {
|
||||
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()
|
||||
go s.SyncFileBrowserMounts()
|
||||
}
|
||||
close(progressCh)
|
||||
}()
|
||||
@@ -1033,7 +1076,7 @@ func (s *Server) storageAttachAPIHandler(w http.ResponseWriter, r *http.Request)
|
||||
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()
|
||||
s.SyncFileBrowserMounts()
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -1251,3 +1294,228 @@ func (s *Server) storageStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
"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.render(w, "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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -296,7 +296,7 @@
|
||||
<div class="backup-layer-row">
|
||||
<span class="tier-label">2. mentés</span>
|
||||
{{if .Tier2Configured}}
|
||||
<span class="layer-method">{{.Tier2MethodLabel}}</span>
|
||||
<span class="layer-method">rsync</span>
|
||||
<span class="layer-dest">→ {{.Tier2Dest}}</span>
|
||||
<span class="layer-schedule">{{.Tier2Schedule}}</span>
|
||||
{{if .Tier2LastRun}}
|
||||
@@ -308,7 +308,7 @@
|
||||
{{end}}
|
||||
{{if .Tier2SizeHuman}}<span class="tier-size">{{.Tier2SizeHuman}}</span>{{end}}
|
||||
<span class="tier-contents">{{.BackupContents}}</span>
|
||||
{{if .Tier2Browsable}}<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben">📁</span>{{end}}
|
||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben">📁</span>
|
||||
<div class="layer-actions">
|
||||
<a href="/stacks/{{.StackName}}/deploy" class="btn btn-xs btn-outline">Beállítás</a>
|
||||
<button class="btn btn-xs btn-outline"
|
||||
@@ -482,26 +482,10 @@
|
||||
{{range .Tier2DriveGroups}}
|
||||
<div class="drive-detail-card">
|
||||
<div class="drive-detail-header">{{.DestLabel}} <span class="relative-time mono">({{.DestPath}})</span></div>
|
||||
{{if .ResticItems}}
|
||||
<div class="method-group">
|
||||
<div class="method-group-label">Restic:</div>
|
||||
{{range .ResticItems}}
|
||||
<div class="repo-info-row">
|
||||
<span class="repo-label">{{.DisplayName}}</span>
|
||||
<span class="repo-value">{{if .SizeHuman}}{{.SizeHuman}}{{else}}—{{end}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .RsyncItems}}
|
||||
<div class="method-group">
|
||||
<div class="method-group-label">Rsync:</div>
|
||||
{{range .RsyncItems}}
|
||||
<div class="repo-info-row">
|
||||
<span class="repo-label">{{.DisplayName}}</span>
|
||||
<span class="repo-value">{{if .SizeHuman}}{{.SizeHuman}}{{else}}—{{end}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{range .Items}}
|
||||
<div class="repo-info-row">
|
||||
<span class="repo-label">{{.DisplayName}}</span>
|
||||
<span class="repo-value">{{if .SizeHuman}}{{.SizeHuman}}{{else}}—{{end}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@@ -141,31 +141,6 @@
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">
|
||||
Módszer
|
||||
<span class="info-tooltip" tabindex="0">
|
||||
<span class="info-icon">i</span>
|
||||
<span class="info-tooltip-text">
|
||||
<strong>Egyszerű másolat (rsync):</strong> Tükörszerű másolat, a fájlok közvetlenül böngészhetők.
|
||||
Nem titkosított, nem verziózott — mindig a legfrissebb állapotot tartalmazza.
|
||||
<br><br>
|
||||
<strong>Titkosított mentés (restic):</strong> Titkosított, tömörített, verziózott mentés.
|
||||
Korábbi állapotok visszaállíthatók. Nem böngészhető közvetlenül —
|
||||
visszaállításhoz a vezérlőpult szükséges.
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<select name="cross_drive_method" id="cd-method" class="form-control cross-drive-field" style="max-width:20rem"
|
||||
{{if not (and .CrossDriveConfig .CrossDriveConfig.Enabled)}}disabled{{end}}>
|
||||
<option value="rsync" {{if and .CrossDriveConfig (eq .CrossDriveConfig.Method "rsync")}}selected{{end}}>
|
||||
Egyszerű másolat (rsync)
|
||||
</option>
|
||||
<option value="restic" {{if and .CrossDriveConfig (eq .CrossDriveConfig.Method "restic")}}selected{{end}}>
|
||||
Titkosított mentés (restic)
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Ütemezés</span>
|
||||
<div>
|
||||
|
||||
@@ -38,10 +38,19 @@
|
||||
<ul style="margin:.5rem 0 0 1rem;padding:0">
|
||||
<li>Az alkalmazás a mozgatás idejére leáll</li>
|
||||
<li>Nagy adatmennyiségnél ez percekig tarthat</li>
|
||||
<li>A régi adatok megmaradnak biztonsági másolatként</li>
|
||||
<li>DB mentés fájlok is átkerülnek</li>
|
||||
<li>A migráció után azonnal lefut egy biztonsági mentés az új meghajtón</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:1.5rem">
|
||||
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer">
|
||||
<input type="checkbox" id="auto-delete" checked>
|
||||
<span>Régi adatok törlése a forrás meghajtóról</span>
|
||||
</label>
|
||||
<span class="form-hint" style="margin-left:1.5rem">Ha bekapcsolva, a forrás meghajtóról az alkalmazás adatai és DB mentései automatikusan törlődnek a sikeres áthelyezés után.</span>
|
||||
</div>
|
||||
|
||||
<div id="migrate-error" class="alert alert-error" style="display:none;margin-bottom:1rem"></div>
|
||||
|
||||
<div class="form-actions" style="gap:.75rem">
|
||||
@@ -58,6 +67,8 @@
|
||||
<div class="disk-step" id="mstep-copying"><span class="disk-step-icon">○</span> Adatok másolása</div>
|
||||
<div class="disk-step" id="mstep-updating"><span class="disk-step-icon">○</span> Konfiguráció frissítése</div>
|
||||
<div class="disk-step" id="mstep-starting"><span class="disk-step-icon">○</span> Alkalmazás indítása</div>
|
||||
<div class="disk-step" id="mstep-cleaning"><span class="disk-step-icon">○</span> Régi adatok törlése</div>
|
||||
<div class="disk-step" id="mstep-backing_up"><span class="disk-step-icon">○</span> Biztonsági mentés</div>
|
||||
<div class="disk-step" id="mstep-done"><span class="disk-step-icon">○</span> Kész</div>
|
||||
</div>
|
||||
|
||||
@@ -75,16 +86,18 @@
|
||||
|
||||
<div class="settings-card" id="migrate-done-card" style="display:none">
|
||||
<h3>✅ Adatáthelyezés kész!</h3>
|
||||
<p style="margin-top:.75rem;color:var(--text-secondary)">
|
||||
Az alkalmazás az új tárolóról fut.<br>
|
||||
A régi adatok a korábbi helyen megmaradtak biztonsági másolatként.
|
||||
<p id="done-msg" style="margin-top:.75rem;color:var(--text-secondary)">
|
||||
Az alkalmazás az új tárolóról fut.
|
||||
</p>
|
||||
<div class="alert alert-warning" style="margin-top:1rem">
|
||||
<div id="done-tier2-warning" class="alert alert-warning" style="display:none;margin-top:1rem">
|
||||
A 2. szintű mentés törlésre került, mert a cél meghajtó megegyezett a mentési céllal.
|
||||
<a href="/stacks/{{.Meta.Slug}}/deploy">Újrakonfigurálás →</a>
|
||||
</div>
|
||||
<div id="done-manual-steps" class="alert alert-warning" style="margin-top:1rem">
|
||||
<strong>Javasolt lépések:</strong>
|
||||
<ol style="margin:.5rem 0 0 1rem;padding:0">
|
||||
<li>Ellenőrizd, hogy az alkalmazás megfelelően működik</li>
|
||||
<li>Győződj meg róla, hogy minden adat megtalálható</li>
|
||||
<li>Ha minden rendben, törölheted a korábbi adatokat</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div style="margin-top:1.5rem;display:flex;gap:.75rem;flex-wrap:wrap">
|
||||
@@ -111,10 +124,12 @@ function startMigrate() {
|
||||
document.getElementById('migrate-form-card').style.display = 'none';
|
||||
document.getElementById('migrate-progress-card').style.display = 'block';
|
||||
|
||||
var autoDelete = document.getElementById('auto-delete').checked;
|
||||
|
||||
fetch('/api/storage/migrate', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({stack_name: stackName, target_path: targetPath})
|
||||
body: JSON.stringify({stack_name: stackName, target_path: targetPath, auto_delete_stale: autoDelete})
|
||||
})
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
@@ -129,7 +144,7 @@ function startMigrate() {
|
||||
});
|
||||
}
|
||||
|
||||
var migStepOrder = ['stopping','copying','updating','starting','done'];
|
||||
var migStepOrder = ['stopping','copying','updating','starting','cleaning','backing_up','done'];
|
||||
|
||||
function pollMigProgress() {
|
||||
fetch('/api/storage/migrate/status')
|
||||
@@ -194,8 +209,16 @@ function showMigDone() {
|
||||
document.getElementById('migrate-progress-card').style.display = 'none';
|
||||
document.getElementById('migrate-done-card').style.display = 'block';
|
||||
document.getElementById('migrate-done-card').scrollIntoView({behavior:'smooth'});
|
||||
// Show the delete button (old data is at the source path)
|
||||
document.getElementById('migrate-delete-old-btn').style.display = '';
|
||||
|
||||
var autoDeleteChecked = document.getElementById('auto-delete').checked;
|
||||
if (autoDeleteChecked) {
|
||||
document.getElementById('done-msg').textContent =
|
||||
'Az alkalmazás az új tárolóról fut. A régi adatok automatikusan törölve lettek.';
|
||||
} else {
|
||||
document.getElementById('done-msg').innerHTML =
|
||||
'Az alkalmazás az új tárolóról fut.<br>A régi adatok a korábbi helyen megmaradtak.';
|
||||
document.getElementById('migrate-delete-old-btn').style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
function deleteOldMigrationData() {
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
{{define "migrate_drive"}}
|
||||
{{template "layout_start" .}}
|
||||
|
||||
<div class="page-header">
|
||||
<div style="display:flex;align-items:center;gap:.5rem">
|
||||
<a href="/settings" class="btn btn-sm btn-outline">← Vissza</a>
|
||||
<h2>Meghajtó kiváltása</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" id="drive-mig-form-card">
|
||||
<h3>Összes adat átköltöztetése másik meghajtóra</h3>
|
||||
|
||||
<div class="settings-grid" style="margin-bottom:1.5rem">
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Forrás meghajtó</span>
|
||||
<span class="settings-value mono">{{.SourceLabel}} ({{.SourcePath}})</span>
|
||||
</div>
|
||||
{{if .SourceDiskInfo}}
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Használat</span>
|
||||
<span class="settings-value mono">{{.SourceDiskInfo.UsedHuman}} / {{.SourceDiskInfo.TotalHuman}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Alkalmazások</span>
|
||||
<span class="settings-value">{{range $i, $app := .AppsOnSource}}{{if $i}}, {{end}}{{$app.DisplayName}}{{end}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dest-path">Cél meghajtó <span class="required">*</span></label>
|
||||
<select id="dest-path" class="form-control">
|
||||
{{range .DestPaths}}
|
||||
<option value="{{.Path}}">{{.Label}} ({{.Path}}) — {{.FreeHuman}} szabad</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning" style="margin-bottom:1.5rem">
|
||||
<strong>Figyelmeztetések:</strong>
|
||||
<ul style="margin:.5rem 0 0 1rem;padding:0">
|
||||
<li>Minden alkalmazás leáll a mozgatás idejére</li>
|
||||
<li>Nagy adatmennyiségnél ez hosszabb ideig tarthat</li>
|
||||
<li>A restic mentés repók NEM kerülnek átmásolásra (helyet spórolunk)</li>
|
||||
<li>A forrás meghajtó "Kiváltva" állapotba kerül</li>
|
||||
<li>A 2. szintű mentések automatikusan átirányításra kerülnek</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{{if .Tier2Impact}}
|
||||
<div class="alert alert-info" style="margin-bottom:1.5rem">
|
||||
<strong>Mentési hatás:</strong>
|
||||
<ul style="margin:.5rem 0 0 1rem;padding:0">
|
||||
{{range .Tier2Impact}}
|
||||
<li>{{.}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div id="drive-mig-error" class="alert alert-error" style="display:none;margin-bottom:1rem"></div>
|
||||
|
||||
<div class="form-actions" style="gap:.75rem">
|
||||
<button class="btn btn-primary" onclick="startDriveMigrate()">📦 Meghajtó kiváltás indítása</button>
|
||||
<a href="/settings" class="btn btn-outline">Mégsem</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" id="drive-mig-progress-card" style="display:none">
|
||||
<h3>Meghajtó kiváltás folyamatban...</h3>
|
||||
|
||||
<div class="disk-progress-steps" id="dm-steps">
|
||||
<div class="disk-step" id="dmstep-validating"><span class="disk-step-icon">○</span> Ellenőrzés</div>
|
||||
<div class="disk-step" id="dmstep-stopping"><span class="disk-step-icon">○</span> Alkalmazások leállítása</div>
|
||||
<div class="disk-step" id="dmstep-copying"><span class="disk-step-icon">○</span> Adatok másolása</div>
|
||||
<div class="disk-step" id="dmstep-verifying"><span class="disk-step-icon">○</span> Ellenőrzés</div>
|
||||
<div class="disk-step" id="dmstep-configuring"><span class="disk-step-icon">○</span> Konfiguráció</div>
|
||||
<div class="disk-step" id="dmstep-starting"><span class="disk-step-icon">○</span> Alkalmazások indítása</div>
|
||||
<div class="disk-step" id="dmstep-backup"><span class="disk-step-icon">○</span> Biztonsági mentés</div>
|
||||
<div class="disk-step" id="dmstep-done"><span class="disk-step-icon">○</span> Kész</div>
|
||||
</div>
|
||||
|
||||
<div class="disk-progress-bar-wrap" style="margin-top:1.5rem">
|
||||
<div class="system-bar" style="height:12px;border-radius:6px">
|
||||
<div class="system-bar-fill system-bar-green" id="dm-progress-bar" style="width:0%;transition:width .4s ease;height:12px;border-radius:6px"></div>
|
||||
</div>
|
||||
<span class="mono form-hint" id="dm-progress-pct">0%</span>
|
||||
</div>
|
||||
|
||||
<div id="dm-progress-msg" class="form-hint" style="margin-top:.75rem"></div>
|
||||
<div id="dm-progress-detail" class="form-hint mono" style="margin-top:.25rem;font-size:.85rem"></div>
|
||||
<div id="dm-elapsed" class="form-hint mono" style="margin-top:.25rem"></div>
|
||||
<div id="dm-progress-error" class="alert alert-error" style="display:none;margin-top:1rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" id="drive-mig-done-card" style="display:none">
|
||||
<h3>Meghajtó kiváltás kész!</h3>
|
||||
<p id="dm-done-msg" style="margin-top:.75rem;color:var(--text-secondary)"></p>
|
||||
<div class="alert alert-info" style="margin-top:1rem">
|
||||
<strong>A forrás meghajtó biztonságosan eltávolítható.</strong>
|
||||
Ha nem szándékozod újrafelhasználni, a Beállítások oldalon eltávolíthatod a rendszerből.
|
||||
</div>
|
||||
<div style="margin-top:1.5rem;display:flex;gap:.75rem;flex-wrap:wrap">
|
||||
<a href="/settings" class="btn btn-primary">Beállítások</a>
|
||||
<a href="/backups" class="btn btn-outline">Mentések</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var sourcePath = '{{.SourcePath}}';
|
||||
var dmPollTimer = null;
|
||||
|
||||
function startDriveMigrate() {
|
||||
var destPath = document.getElementById('dest-path').value;
|
||||
if (!destPath) {
|
||||
document.getElementById('drive-mig-error').textContent = 'Válasszon cél meghajtót.';
|
||||
document.getElementById('drive-mig-error').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('Biztosan ki szeretné váltani a forrás meghajtót?\n\nMinden alkalmazás leáll a migráció idejére.\nEz a művelet nem vonható vissza egyszerűen.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('drive-mig-form-card').style.display = 'none';
|
||||
document.getElementById('drive-mig-progress-card').style.display = 'block';
|
||||
|
||||
fetch('/api/storage/migrate-drive', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({source_path: sourcePath, dest_path: destPath})
|
||||
})
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.ok) {
|
||||
showDMError(data.error || 'Ismeretlen hiba');
|
||||
return;
|
||||
}
|
||||
dmPollTimer = setInterval(pollDMProgress, 2000);
|
||||
})
|
||||
.catch(function(e) {
|
||||
showDMError('Hálózati hiba: ' + e.message);
|
||||
});
|
||||
}
|
||||
|
||||
var dmStepOrder = ['validating','stopping','copying','verifying','configuring','starting','backup','done'];
|
||||
|
||||
function pollDMProgress() {
|
||||
fetch('/api/storage/migrate-drive/status')
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.ok) return;
|
||||
updateDMUI(data);
|
||||
if (data.done) {
|
||||
clearInterval(dmPollTimer);
|
||||
if (data.step === 'done') {
|
||||
showDMDone(data.msg);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function(){});
|
||||
}
|
||||
|
||||
function updateDMUI(data) {
|
||||
var currentIdx = dmStepOrder.indexOf(data.step);
|
||||
if (currentIdx < 0 && data.step === 'rolling_back') {
|
||||
currentIdx = dmStepOrder.indexOf('copying');
|
||||
}
|
||||
|
||||
dmStepOrder.forEach(function(s, i) {
|
||||
var el = document.getElementById('dmstep-' + s);
|
||||
if (!el) return;
|
||||
var icon = el.querySelector('.disk-step-icon');
|
||||
if (i < currentIdx) {
|
||||
el.className = 'disk-step disk-step-done';
|
||||
icon.textContent = '\u2705';
|
||||
} else if (i === currentIdx) {
|
||||
el.className = 'disk-step disk-step-active';
|
||||
icon.textContent = (data.step === 'error' || data.step === 'rolling_back') ? '\u274C' : '\u23F3';
|
||||
} else {
|
||||
el.className = 'disk-step';
|
||||
icon.textContent = '\u25CB';
|
||||
}
|
||||
});
|
||||
|
||||
var pct = data.pct || 0;
|
||||
document.getElementById('dm-progress-bar').style.width = pct + '%';
|
||||
document.getElementById('dm-progress-pct').textContent = pct + '%';
|
||||
document.getElementById('dm-progress-msg').textContent = data.msg || '';
|
||||
document.getElementById('dm-progress-detail').textContent = data.detail || '';
|
||||
|
||||
if (data.elapsed_sec) {
|
||||
document.getElementById('dm-elapsed').textContent = data.elapsed_sec + ' másodperce fut';
|
||||
}
|
||||
|
||||
if (data.step === 'error' || (data.error && data.error !== '')) {
|
||||
showDMError(data.error || data.msg || 'Ismeretlen hiba');
|
||||
}
|
||||
}
|
||||
|
||||
function showDMError(msg) {
|
||||
clearInterval(dmPollTimer);
|
||||
document.getElementById('dm-progress-error').textContent = 'Hiba: ' + msg;
|
||||
document.getElementById('dm-progress-error').style.display = 'block';
|
||||
document.getElementById('drive-mig-progress-card').querySelector('h3').textContent = 'Meghajtó kiváltás sikertelen';
|
||||
}
|
||||
|
||||
function showDMDone(msg) {
|
||||
document.getElementById('drive-mig-progress-card').style.display = 'none';
|
||||
document.getElementById('drive-mig-done-card').style.display = 'block';
|
||||
document.getElementById('dm-done-msg').textContent = msg || 'A meghajtó sikeresen kiváltva.';
|
||||
document.getElementById('drive-mig-done-card').scrollIntoView({behavior:'smooth'});
|
||||
}
|
||||
</script>
|
||||
|
||||
{{template "layout_end" .}}
|
||||
{{end}}
|
||||
@@ -205,18 +205,20 @@ function pollUntilBack() {
|
||||
{{if .StoragePaths}}
|
||||
<div class="storage-paths-list">
|
||||
{{range .StoragePaths}}
|
||||
<div class="storage-path-item{{if .Disconnected}} storage-disconnected{{end}}">
|
||||
<div class="storage-path-item{{if .Disconnected}} storage-disconnected{{else if .Decommissioned}} storage-decommissioned{{end}}">
|
||||
<div class="storage-path-header">
|
||||
<div class="storage-path-info">
|
||||
<div class="storage-path-label-wrap" id="label-wrap-{{.Path}}">
|
||||
<span class="storage-path-label" id="label-display-{{.Path}}">{{.Label}}</span>
|
||||
{{if not .Disconnected}}<button class="btn btn-xs btn-ghost" onclick="editStorageLabel('{{.Path}}', '{{.Label}}')" title="Átnevezés">✏️</button>{{end}}
|
||||
{{if not (or .Disconnected .Decommissioned)}}<button class="btn btn-xs btn-ghost" onclick="editStorageLabel('{{.Path}}', '{{.Label}}')" title="Átnevezés">✏️</button>{{end}}
|
||||
</div>
|
||||
<span class="storage-path-path mono">{{.Path}}</span>
|
||||
</div>
|
||||
<div class="storage-path-badges">
|
||||
{{if .Disconnected}}
|
||||
<span class="badge badge-error">Leválasztva</span>
|
||||
{{else if .Decommissioned}}
|
||||
<span class="badge state-gray">Kiváltva</span>
|
||||
{{else}}
|
||||
{{if .IsDefault}}<span class="badge state-green">Alapértelmezett</span>{{end}}
|
||||
{{if .Schedulable}}<span class="badge" style="background:rgba(0,136,204,0.15);color:var(--accent-light)">Aktív</span>{{else}}<span class="badge state-gray">Inaktív</span>{{end}}
|
||||
@@ -236,6 +238,20 @@ function pollUntilBack() {
|
||||
<div class="storage-path-actions" id="storage-actions-{{.Path}}">
|
||||
<button class="btn btn-xs btn-primary" onclick="storageReconnect('{{.Path}}')">Csatlakoztatás</button>
|
||||
</div>
|
||||
{{else if .Decommissioned}}
|
||||
<div class="storage-path-details">
|
||||
<div class="storage-disconnected-info">
|
||||
<span class="form-hint">Adatok átköltöztetve ide: <strong>{{.MigratedToLabel}}</strong> ({{.MigratedTo}})</span>
|
||||
{{if .DecommissionedAt}}<span class="form-hint">Időpont: {{.DecommissionedAt}}</span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="storage-path-actions">
|
||||
<form method="POST" action="/settings/storage/remove" style="display:inline"
|
||||
onsubmit="return confirm('Biztosan eltávolítja a(z) {{.Label}} ({{.Path}}) meghajtót a rendszerből?\n\nA meghajtó adatai NEM törlődnek.')">
|
||||
<input type="hidden" name="storage_path" value="{{.Path}}">
|
||||
<button type="submit" class="btn btn-xs btn-outline">Eltávolítás a rendszerből</button>
|
||||
</form>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="storage-path-details">
|
||||
{{if .DiskInfo}}
|
||||
@@ -311,6 +327,9 @@ function pollUntilBack() {
|
||||
<button type="submit" class="btn btn-xs btn-danger-outline">Eltávolítás</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if and (gt .AppCount 0) .HasOtherPaths}}
|
||||
<a href="/settings/storage/migrate-drive?source={{.Path}}" class="btn btn-xs btn-outline">📦 Összes adat átköltöztetése</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@@ -2264,7 +2264,12 @@ a.stat-card:hover {
|
||||
opacity: 0.75;
|
||||
border-style: dashed;
|
||||
}
|
||||
.storage-disconnected .storage-disconnected-info {
|
||||
.storage-decommissioned {
|
||||
opacity: 0.6;
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
.storage-disconnected .storage-disconnected-info,
|
||||
.storage-decommissioned .storage-disconnected-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .25rem;
|
||||
|
||||
Reference in New Issue
Block a user