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:
2026-02-19 19:42:26 +01:00
parent 276be5a88e
commit bdbe170a54
22 changed files with 1537 additions and 57 deletions
+116 -5
View File
@@ -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)
}