feat: storage watchdog — USB disconnect detection, auto-stop, safe eject, auto-reconnect (v0.17.0)
New storage watchdog monitors registered storage paths every 5s. On disconnect (3 consecutive probe failures), auto-stops affected apps, lazy-unmounts stale VFS entries, fires alerts/notifications/hub report. On reconnect (UUID detected), auto-remounts via fstab, cleans stale restic locks, offers app restart. Safe disconnect UI for USB drives: confirmation dialog, stop apps, sync, unmount. Disconnected state visible across all pages (dashboard, settings, backups, monitoring) with hatched red bars and badges. Backup guards skip disconnected drives. 22 files changed (1 new: monitor/watchdog.go), ~1500 lines added. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -22,17 +22,26 @@ import (
|
||||
|
||||
// StorageBarInfo holds data for rendering a storage usage bar on dashboard/monitoring.
|
||||
type StorageBarInfo struct {
|
||||
Label string // e.g., "USB HDD 1TB", "SYS Storage 350G"
|
||||
Path string // e.g., "/mnt/hdd_1"
|
||||
TotalGB float64
|
||||
UsedGB float64
|
||||
Percent float64
|
||||
Label string // e.g., "USB HDD 1TB", "SYS Storage 350G"
|
||||
Path string // e.g., "/mnt/hdd_1"
|
||||
TotalGB float64
|
||||
UsedGB float64
|
||||
Percent float64
|
||||
Disconnected bool
|
||||
}
|
||||
|
||||
// buildStorageBars returns usage bars for all registered storage paths.
|
||||
func (s *Server) buildStorageBars() []StorageBarInfo {
|
||||
var bars []StorageBarInfo
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
if sp.Disconnected {
|
||||
bars = append(bars, StorageBarInfo{
|
||||
Label: sp.Label,
|
||||
Path: sp.Path,
|
||||
Disconnected: true,
|
||||
})
|
||||
continue
|
||||
}
|
||||
di := system.GetDiskUsage(sp.Path)
|
||||
if di == nil {
|
||||
continue
|
||||
@@ -65,11 +74,13 @@ 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
|
||||
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)
|
||||
}
|
||||
|
||||
func (s *Server) baseData(page, title string) map[string]interface{} {
|
||||
@@ -659,6 +670,9 @@ type AppBackupRow struct {
|
||||
Tier2SizeHuman string
|
||||
Tier2Browsable bool // true for rsync (plain files), false for restic
|
||||
|
||||
// Drive disconnected — app's home drive is currently disconnected
|
||||
DriveDisconnected bool
|
||||
|
||||
// Warnings accumulated for this app
|
||||
Warnings []string
|
||||
}
|
||||
@@ -700,10 +714,32 @@ func (s *Server) buildAppBackupRows(
|
||||
}
|
||||
}
|
||||
|
||||
// Build disconnected paths set for drive-disconnected detection
|
||||
disconnectedPaths := make(map[string]bool)
|
||||
for _, dp := range s.settings.GetDisconnectedPaths() {
|
||||
disconnectedPaths[dp.Path] = true
|
||||
}
|
||||
|
||||
var rows []AppBackupRow
|
||||
for _, app := range status.AppDataInfo {
|
||||
hasDB := dbStacks[app.StackName] || app.HasDBDump
|
||||
|
||||
// Check if this app's home drive is disconnected
|
||||
driveDisconnected := false
|
||||
if app.HasHDDData && len(app.HDDPaths) > 0 {
|
||||
for dp := range disconnectedPaths {
|
||||
for _, hp := range app.HDDPaths {
|
||||
if strings.HasPrefix(hp.HostPath, dp+"/") || hp.HostPath == dp {
|
||||
driveDisconnected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if driveDisconnected {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build backup contents label
|
||||
var parts []string
|
||||
if hasDB {
|
||||
@@ -716,10 +752,11 @@ func (s *Server) buildAppBackupRows(
|
||||
contents := strings.Join(parts, " + ")
|
||||
|
||||
row := AppBackupRow{
|
||||
StackName: app.StackName,
|
||||
DisplayName: app.DisplayName,
|
||||
HasHDDData: app.HasHDDData,
|
||||
HasDB: hasDB,
|
||||
StackName: app.StackName,
|
||||
DisplayName: app.DisplayName,
|
||||
HasHDDData: app.HasHDDData,
|
||||
HasDB: hasDB,
|
||||
DriveDisconnected: driveDisconnected,
|
||||
StorageLabel: app.StorageLabel,
|
||||
HDDSizeHuman: app.HDDSizeHuman,
|
||||
BackupContents: contents,
|
||||
@@ -933,13 +970,23 @@ func (s *Server) settingsData() map[string]interface{} {
|
||||
for _, sp := range storagePaths {
|
||||
view := StoragePathView{
|
||||
StoragePath: sp,
|
||||
IsMounted: system.IsMountPoint(sp.Path),
|
||||
AppDetails: s.appDetailsForPath(sp.Path),
|
||||
FSInfo: system.GetFSInfo(sp.Path),
|
||||
StoppedApps: sp.StoppedStacks,
|
||||
}
|
||||
view.AppCount = len(view.AppDetails)
|
||||
if di := system.GetDiskUsage(sp.Path); di != nil {
|
||||
view.DiskInfo = di
|
||||
if sp.Disconnected {
|
||||
// Skip I/O calls on disconnected drives — they'd hang or fail
|
||||
view.IsMounted = false
|
||||
} else {
|
||||
view.IsMounted = system.IsMountPoint(sp.Path)
|
||||
view.AppDetails = s.appDetailsForPath(sp.Path)
|
||||
view.FSInfo = system.GetFSInfo(sp.Path)
|
||||
view.AppCount = len(view.AppDetails)
|
||||
if di := system.GetDiskUsage(sp.Path); di != nil {
|
||||
view.DiskInfo = di
|
||||
}
|
||||
// Detect USB for safe disconnect button
|
||||
if view.FSInfo != nil && view.FSInfo.Device != "" {
|
||||
view.IsUSB = system.IsUSBDevice(view.FSInfo.Device)
|
||||
}
|
||||
}
|
||||
storageViews = append(storageViews, view)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user