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:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user