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:
@@ -242,6 +242,14 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
|
||||
|
||||
for _, db := range dbs {
|
||||
drivePath := m.GetAppDrivePath(db.StackName)
|
||||
|
||||
// Skip if drive is disconnected
|
||||
if m.settings != nil && m.settings.IsDisconnected(drivePath) {
|
||||
m.logger.Printf("[WARN] Skipping DB dump for %s — drive disconnected: %s", db.StackName, drivePath)
|
||||
summary = append(summary, fmt.Sprintf("SKIP %s (drive disconnected)", db.ContainerName))
|
||||
continue
|
||||
}
|
||||
|
||||
dumpDir := AppDBDumpPath(drivePath, db.StackName)
|
||||
|
||||
result := DumpOne(ctx, db, dumpDir, m.logger)
|
||||
@@ -331,6 +339,12 @@ func (m *Manager) runBackupInternal(ctx context.Context) error {
|
||||
driveCount := 0
|
||||
|
||||
for drivePath, stacks := range driveStacks {
|
||||
// Skip disconnected drives
|
||||
if m.settings != nil && m.settings.IsDisconnected(drivePath) {
|
||||
m.logger.Printf("[WARN] Skipping backup for drive %s — disconnected", drivePath)
|
||||
continue
|
||||
}
|
||||
|
||||
repoPath := PrimaryResticRepoPath(drivePath)
|
||||
|
||||
// Ensure repo is initialized
|
||||
@@ -650,6 +664,20 @@ func (m *Manager) SetStackProvider(provider StackDataProvider) {
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// UnlockRepo runs restic unlock on the given repo path.
|
||||
func (m *Manager) UnlockRepo(ctx context.Context, repoPath string) error {
|
||||
if !m.restic.RepoExists(repoPath) {
|
||||
return nil // no repo to unlock
|
||||
}
|
||||
cmd := m.restic.UnlockCommand(ctx, repoPath)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("restic unlock: %v (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
m.logger.Printf("[INFO] Restic repo unlocked: %s", repoPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStackHDDMounts returns HDD mount paths for the named stack via the stack provider.
|
||||
func (m *Manager) GetStackHDDMounts(name string) []string {
|
||||
if m.stackProvider == nil {
|
||||
|
||||
Reference in New Issue
Block a user