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:
2026-02-19 21:49:14 +01:00
parent bdbe170a54
commit 99bf3ca7a8
22 changed files with 1725 additions and 402 deletions
+37 -41
View File
@@ -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"