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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user