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:
@@ -68,11 +68,14 @@ type CrossDriveBackup struct {
|
||||
|
||||
// StoragePath represents a registered external storage location.
|
||||
type StoragePath struct {
|
||||
Path string `json:"path"` // e.g., "/mnt/hdd_1"
|
||||
Label string `json:"label,omitempty"` // e.g., "Külső HDD 1TB"
|
||||
IsDefault bool `json:"is_default,omitempty"` // new apps use this by default
|
||||
Schedulable bool `json:"schedulable"` // whether new apps can be deployed here
|
||||
AddedAt string `json:"added_at"` // RFC3339
|
||||
Path string `json:"path"` // e.g., "/mnt/hdd_1"
|
||||
Label string `json:"label,omitempty"` // e.g., "Külső HDD 1TB"
|
||||
IsDefault bool `json:"is_default,omitempty"` // new apps use this by default
|
||||
Schedulable bool `json:"schedulable"` // whether new apps can be deployed here
|
||||
AddedAt string `json:"added_at"` // RFC3339
|
||||
Disconnected bool `json:"disconnected,omitempty"` // true when drive detected as disconnected
|
||||
DisconnectedAt string `json:"disconnected_at,omitempty"` // RFC3339 timestamp of disconnect detection
|
||||
StoppedStacks []string `json:"stopped_stacks,omitempty"` // stacks auto-stopped on disconnect
|
||||
}
|
||||
|
||||
// NotificationPrefs holds customer notification preferences.
|
||||
@@ -87,6 +90,8 @@ var DefaultEnabledEvents = []string{
|
||||
"disk_warning",
|
||||
"backup_failed",
|
||||
"update_available",
|
||||
"storage_disconnected",
|
||||
"storage_reconnected",
|
||||
}
|
||||
|
||||
// DBValidationCache holds cached DB dump validation results.
|
||||
@@ -499,3 +504,109 @@ func InferStorageLabel(path string) string {
|
||||
}
|
||||
return fmt.Sprintf("Tárhely (%s)", base)
|
||||
}
|
||||
|
||||
// SetDisconnected marks a storage path as disconnected (or connected) and records which stacks were stopped.
|
||||
func (s *Settings) SetDisconnected(path string, disconnected bool, stoppedStacks []string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.StoragePaths {
|
||||
if s.StoragePaths[i].Path == path {
|
||||
s.StoragePaths[i].Disconnected = disconnected
|
||||
if disconnected {
|
||||
s.StoragePaths[i].DisconnectedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
s.StoragePaths[i].StoppedStacks = stoppedStacks
|
||||
} else {
|
||||
s.StoragePaths[i].DisconnectedAt = ""
|
||||
// Preserve StoppedStacks on reconnect so the UI can offer restart
|
||||
if stoppedStacks != nil {
|
||||
s.StoragePaths[i].StoppedStacks = stoppedStacks
|
||||
}
|
||||
}
|
||||
return s.save()
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("storage path %q not found", path)
|
||||
}
|
||||
|
||||
// ClearDisconnected marks a path as connected and clears all disconnect-related fields.
|
||||
func (s *Settings) ClearDisconnected(path string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.StoragePaths {
|
||||
if s.StoragePaths[i].Path == path {
|
||||
s.StoragePaths[i].Disconnected = false
|
||||
s.StoragePaths[i].DisconnectedAt = ""
|
||||
s.StoragePaths[i].StoppedStacks = nil
|
||||
return s.save()
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("storage path %q not found", path)
|
||||
}
|
||||
|
||||
// IsDisconnected returns whether a storage path is marked as disconnected.
|
||||
func (s *Settings) IsDisconnected(path string) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, sp := range s.StoragePaths {
|
||||
if sp.Path == path {
|
||||
return sp.Disconnected
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetDisconnectedPaths returns a copy of all storage paths that are marked disconnected.
|
||||
func (s *Settings) GetDisconnectedPaths() []StoragePath {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var result []StoragePath
|
||||
for _, sp := range s.StoragePaths {
|
||||
if sp.Disconnected {
|
||||
result = append(result, sp)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetConnectedPaths returns a copy of all storage paths that are NOT disconnected.
|
||||
func (s *Settings) GetConnectedPaths() []StoragePath {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var result []StoragePath
|
||||
for _, sp := range s.StoragePaths {
|
||||
if !sp.Disconnected {
|
||||
result = append(result, sp)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetStoppedStacks returns the list of stacks that were auto-stopped for a storage path.
|
||||
func (s *Settings) GetStoppedStacks(path string) []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, sp := range s.StoragePaths {
|
||||
if sp.Path == path {
|
||||
if len(sp.StoppedStacks) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, len(sp.StoppedStacks))
|
||||
copy(result, sp.StoppedStacks)
|
||||
return result
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearStoppedStacks removes the stopped stacks list for a storage path (e.g., after restart).
|
||||
func (s *Settings) ClearStoppedStacks(path string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.StoragePaths {
|
||||
if s.StoragePaths[i].Path == path {
|
||||
s.StoragePaths[i].StoppedStacks = nil
|
||||
return s.save()
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("storage path %q not found", path)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user