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
+69 -1
View File
@@ -284,7 +284,7 @@ func main() {
latestVersion = status.LastCheck.LatestVersion
}
}
alertMgr.Refresh(healthReport, cfg, backupMgr, updateAvailable, latestVersion)
alertMgr.Refresh(healthReport, cfg, backupMgr, updateAvailable, latestVersion, sett.GetStoragePaths())
// Notify on health status changes
notifier.NotifyHealthChange(healthReport.Status, healthReport.Issues, healthReport.Warnings)
return nil
@@ -409,6 +409,36 @@ func main() {
}
}
// --- Storage watchdog ---
storageWatchdog := monitor.NewStorageWatchdog(sett, &watchdogStackAdapter{mgr: stackMgr}, notifier, cfg, logger)
storageWatchdog.SetAlertRefresh(func() {
healthReport := monitor.RunHealthCheck(cfg, cpuCollector, sett.GetStoragePaths())
updateAvailable := false
latestVersion := ""
if updater != nil {
status := updater.GetStatus()
if status.LastCheck != nil {
updateAvailable = status.LastCheck.UpdateAvailable
latestVersion = status.LastCheck.LatestVersion
}
}
alertMgr.Refresh(healthReport, cfg, backupMgr, updateAvailable, latestVersion, sett.GetStoragePaths())
})
if hubPusher != nil {
storageWatchdog.SetHubReportPusher(func() {
r := report.BuildReport(cfg, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths())
hubPusher.Push(r)
})
}
if backupMgr != nil {
storageWatchdog.SetRepoUnlocker(func(ctx context.Context, repoPath string) error {
return backupMgr.UnlockRepo(ctx, repoPath)
})
}
sched.Every("storage-watchdog", 5*time.Second, func(ctx context.Context) error {
return storageWatchdog.Check(ctx)
})
sched.Start(ctx)
defer sched.Stop()
@@ -513,6 +543,7 @@ func main() {
// --- Initialize web server ---
webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, crossDriveRunner, sched, sett, alertMgr, notifier, updater, logger, Version)
webServer.SetStorageWatchdog(storageWatchdog)
// Phase 3: Set DR restore mode if a restore plan was built
if restorePlan != nil && len(restorePlan.Apps) > 0 {
@@ -673,6 +704,43 @@ func (a *stackAdapter) GetStackHDDPath(name string) string {
return ""
}
// watchdogStackAdapter implements monitor.WatchdogStackProvider using stacks.Manager.
type watchdogStackAdapter struct {
mgr *stacks.Manager
}
func (a *watchdogStackAdapter) ListDeployedStacks() []monitor.WatchdogStackInfo {
var result []monitor.WatchdogStackInfo
for _, s := range a.mgr.GetStacks() {
if !s.Deployed {
continue
}
result = append(result, monitor.WatchdogStackInfo{Name: s.Name})
}
return result
}
func (a *watchdogStackAdapter) GetStackHDDPath(name string) string {
s, ok := a.mgr.GetStack(name)
if !ok {
return ""
}
stackDir := filepath.Dir(s.ComposePath)
appCfg := stacks.LoadAppConfig(stackDir)
if appCfg != nil && appCfg.Env["HDD_PATH"] != "" {
return filepath.Clean(appCfg.Env["HDD_PATH"])
}
return ""
}
func (a *watchdogStackAdapter) StopStack(name string) error {
return a.mgr.StopStack(name)
}
func (a *watchdogStackAdapter) StartStack(name string) error {
return a.mgr.StartStack(name)
}
// pushInfraBackup builds and sends the infrastructure snapshot to the Hub.
func pushInfraBackup(cfg *config.Config, sett *settings.Settings,
stackProv *stackAdapter, pusher *report.Pusher, logger *log.Logger) {