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:
@@ -154,6 +154,14 @@ func (s *Server) storageAPIHandler(w http.ResponseWriter, r *http.Request) {
|
||||
s.storageAttachStatusAPIHandler(w, r)
|
||||
case path == "/api/storage/attach/cancel" && r.Method == http.MethodPost:
|
||||
s.storageAttachCancelHandler(w, r)
|
||||
case path == "/api/storage/disconnect" && r.Method == http.MethodPost:
|
||||
s.storageDisconnectHandler(w, r)
|
||||
case path == "/api/storage/reconnect" && r.Method == http.MethodPost:
|
||||
s.storageReconnectHandler(w, r)
|
||||
case path == "/api/storage/restart-apps" && r.Method == http.MethodPost:
|
||||
s.storageRestartAppsHandler(w, r)
|
||||
case path == "/api/storage/status" && r.Method == http.MethodGet:
|
||||
s.storageStatusHandler(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
@@ -1091,3 +1099,155 @@ func (s *Server) storageAttachCancelHandler(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
jsonResponse(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
// storageDisconnectHandler handles POST /api/storage/disconnect.
|
||||
// Performs a safe disconnect: stops affected apps, syncs, unmounts.
|
||||
func (s *Server) storageDisconnectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Path == "" {
|
||||
jsonError(w, "Hiányzó útvonal", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if s.storageWatchdog == nil {
|
||||
jsonError(w, "Szolgáltatás nem elérhető", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if USB device (only USB drives can be safely disconnected)
|
||||
fsInfo := system.GetFSInfo(req.Path)
|
||||
if fsInfo != nil && fsInfo.Device != "" && !system.IsUSBDevice(fsInfo.Device) {
|
||||
jsonError(w, "Csak USB meghajtó választható le biztonságosan", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
stoppedStacks, err := s.storageWatchdog.SafeDisconnect(r.Context(), req.Path)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] Safe disconnect %s: %v", req.Path, err)
|
||||
jsonError(w, fmt.Sprintf("Leválasztás sikertelen: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"message": "A meghajtó biztonságosan eltávolítható.",
|
||||
"stopped_stacks": stoppedStacks,
|
||||
})
|
||||
}
|
||||
|
||||
// storageReconnectHandler handles POST /api/storage/reconnect.
|
||||
// Attempts to remount a disconnected drive.
|
||||
func (s *Server) storageReconnectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Path == "" {
|
||||
jsonError(w, "Hiányzó útvonal", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if s.storageWatchdog == nil {
|
||||
jsonError(w, "Szolgáltatás nem elérhető", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
stoppedStacks, err := s.storageWatchdog.Reconnect(r.Context(), req.Path)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] Reconnect %s: %v", req.Path, err)
|
||||
jsonError(w, fmt.Sprintf("Csatlakoztatás sikertelen: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"message": "Meghajtó sikeresen csatlakoztatva.",
|
||||
"stopped_stacks": stoppedStacks,
|
||||
})
|
||||
}
|
||||
|
||||
// storageRestartAppsHandler handles POST /api/storage/restart-apps.
|
||||
// Restarts apps that were auto-stopped due to a drive disconnect.
|
||||
func (s *Server) storageRestartAppsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Path == "" {
|
||||
jsonError(w, "Hiányzó útvonal", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if s.storageWatchdog == nil {
|
||||
jsonError(w, "Szolgáltatás nem elérhető", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate drive is connected
|
||||
if s.settings.IsDisconnected(req.Path) {
|
||||
jsonError(w, "A meghajtó jelenleg leválasztva — először csatlakoztassa", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
started, failed := s.storageWatchdog.RestartStoppedApps(req.Path)
|
||||
jsonResponse(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"started": started,
|
||||
"failed": failed,
|
||||
})
|
||||
}
|
||||
|
||||
// storageStatusHandler handles GET /api/storage/status.
|
||||
// Returns status of all storage paths including connection state and USB detection.
|
||||
func (s *Server) storageStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
paths := s.settings.GetStoragePaths()
|
||||
|
||||
type pathStatus struct {
|
||||
Path string `json:"path"`
|
||||
Label string `json:"label"`
|
||||
Connected bool `json:"connected"`
|
||||
IsUSB bool `json:"is_usb"`
|
||||
DisconnectedAt string `json:"disconnected_at"`
|
||||
StoppedStacks []string `json:"stopped_stacks"`
|
||||
}
|
||||
|
||||
result := make([]pathStatus, 0, len(paths))
|
||||
for _, sp := range paths {
|
||||
ps := pathStatus{
|
||||
Path: sp.Path,
|
||||
Label: sp.Label,
|
||||
Connected: !sp.Disconnected,
|
||||
DisconnectedAt: sp.DisconnectedAt,
|
||||
StoppedStacks: sp.StoppedStacks,
|
||||
}
|
||||
if ps.StoppedStacks == nil {
|
||||
ps.StoppedStacks = []string{}
|
||||
}
|
||||
|
||||
// Detect USB for connected drives
|
||||
if !sp.Disconnected {
|
||||
if fsInfo := system.GetFSInfo(sp.Path); fsInfo != nil && fsInfo.Device != "" {
|
||||
ps.IsUSB = system.IsUSBDevice(fsInfo.Device)
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, ps)
|
||||
}
|
||||
|
||||
jsonResponse(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"data": result,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user