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
+161 -10
View File
@@ -3,12 +3,14 @@
package system
import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
)
// IsMountPoint checks if a path is on a different device than its parent.
@@ -212,21 +214,22 @@ func isSameBlockDevice(pathA, pathB string) bool {
return statA.Dev == statB.Dev
}
// diskModel reads the disk model from /sys/block/<dev>/device/model.
func diskModel(device string) string {
// /dev/sda1 → sda, /dev/nvme0n1p1 → nvme0n1
base := filepath.Base(device)
// Strip partition number: sda1 → sda, nvme0n1p1 → nvme0n1
disk := base
// stripPartition strips the partition suffix from a device name.
// e.g., "sda1" → "sda", "nvme0n1p1" → "nvme0n1".
func stripPartition(base string) string {
if strings.HasPrefix(base, "nvme") {
// nvme0n1p1 → find last 'p' followed by digits
if idx := strings.LastIndex(base, "p"); idx > 4 {
disk = base[:idx]
return base[:idx]
}
} else {
// sda1 → sda: strip trailing digits
disk = strings.TrimRight(base, "0123456789")
return strings.TrimRight(base, "0123456789")
}
return base
}
// diskModel reads the disk model from /sys/block/<dev>/device/model.
func diskModel(device string) string {
disk := stripPartition(filepath.Base(device))
modelPath := "/sys/block/" + disk + "/device/model"
data, err := os.ReadFile(modelPath)
if err != nil {
@@ -234,3 +237,151 @@ func diskModel(device string) string {
}
return strings.TrimSpace(string(data))
}
// ProbeStatus represents the result of a storage path probe.
type ProbeStatus int
const (
ProbeConnected ProbeStatus = iota
ProbeDisconnected
ProbeTimeout
)
// ProbeResult holds the outcome of a storage path probe.
type ProbeResult struct {
Status ProbeStatus
Err error
}
// ProbeStoragePath checks if a storage path is responsive.
// Uses a goroutine with a 3-second timeout to avoid blocking on dead mounts.
func ProbeStoragePath(path string) ProbeResult {
// Quick check: does the path exist at all?
if _, err := os.Lstat(path); os.IsNotExist(err) {
return ProbeResult{Status: ProbeDisconnected, Err: err}
}
type statResult struct {
err error
}
ch := make(chan statResult, 1)
go func() {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
ch <- statResult{err: err}
}()
select {
case res := <-ch:
if res.err == nil {
return ProbeResult{Status: ProbeConnected}
}
errStr := res.err.Error()
if strings.Contains(errStr, "transport endpoint") ||
strings.Contains(errStr, "input/output error") ||
strings.Contains(errStr, "no such device") {
return ProbeResult{Status: ProbeDisconnected, Err: res.err}
}
return ProbeResult{Status: ProbeDisconnected, Err: res.err}
case <-time.After(3 * time.Second):
return ProbeResult{Status: ProbeTimeout, Err: fmt.Errorf("stat timed out after 3s")}
}
}
// IsUSBDevice checks if a block device is connected via USB.
// devicePath should be like "/dev/sdb" or "/dev/sdb1".
// Checks the sysfs symlink for the disk — if the path contains "/usb", it's a USB device.
func IsUSBDevice(devicePath string) bool {
disk := stripPartition(filepath.Base(devicePath))
if disk == "" {
return false
}
// Try /host/sys first (Docker mount), then /sys (native)
for _, prefix := range []string{"/host/sys", "/sys"} {
link, err := os.Readlink(prefix + "/block/" + disk)
if err != nil {
continue
}
if strings.Contains(link, "/usb") {
return true
}
return false // found the sysfs entry, but not USB
}
return false
}
// ParseFstabUUID extracts the UUID for a given mount point from an fstab file.
// Returns empty string if the mount point is not found or has no UUID.
func ParseFstabUUID(fstabPath, mountPath string) string {
f, err := os.Open(fstabPath)
if err != nil {
return ""
}
defer f.Close()
cleanMount := filepath.Clean(mountPath)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
if filepath.Clean(fields[1]) != cleanMount {
continue
}
source := fields[0]
if strings.HasPrefix(source, "UUID=") {
return strings.TrimPrefix(source, "UUID=")
}
}
return ""
}
// HasFelhomRawMount checks if a mount path was set up via the attach wizard
// (which uses a raw mount at /mnt/.felhom-raw/<x> + a bind mount).
// Returns the raw mount path if found, e.g., "/mnt/.felhom-raw/hdd_1".
func HasFelhomRawMount(fstabPath, mountPath string) (rawPath string, ok bool) {
f, err := os.Open(fstabPath)
if err != nil {
return "", false
}
defer f.Close()
cleanMount := filepath.Clean(mountPath)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
if len(fields) < 4 {
continue
}
// Look for a bind mount line targeting our mount path
// Format: /mnt/.felhom-raw/hdd_1/subfolder /mnt/hdd_1 none bind,...
target := filepath.Clean(fields[1])
if target != cleanMount {
continue
}
source := fields[0]
if !strings.Contains(source, ".felhom-raw") {
continue
}
// Extract the raw mount path: /mnt/.felhom-raw/hdd_1/subfolder → /mnt/.felhom-raw/hdd_1
// The raw mount is the first two components after /mnt/.felhom-raw/
parts := strings.Split(filepath.Clean(source), string(os.PathSeparator))
// parts: ["", "mnt", ".felhom-raw", "hdd_1", "subfolder"]
for i, p := range parts {
if p == ".felhom-raw" && i+1 < len(parts) {
rawPath = string(os.PathSeparator) + filepath.Join(parts[1:i+2]...)
return rawPath, true
}
}
}
return "", false
}
@@ -80,3 +80,30 @@ func CheckBackupDestination(path string) DestinationHealth {
Severity: "ok",
}
}
// ProbeStatus represents the result of a storage path probe.
type ProbeStatus int
const (
ProbeConnected ProbeStatus = iota
ProbeDisconnected
ProbeTimeout
)
// ProbeResult holds the outcome of a storage path probe.
type ProbeResult struct {
Status ProbeStatus
Err error
}
// ProbeStoragePath always returns connected on non-Linux.
func ProbeStoragePath(_ string) ProbeResult { return ProbeResult{Status: ProbeConnected} }
// IsUSBDevice always returns false on non-Linux.
func IsUSBDevice(_ string) bool { return false }
// ParseFstabUUID always returns empty on non-Linux.
func ParseFstabUUID(_, _ string) string { return "" }
// HasFelhomRawMount always returns false on non-Linux.
func HasFelhomRawMount(_, _ string) (string, bool) { return "", false }