fix: USB badge detection for bind-mounted drives + graceful Tier2 backup on disconnected destinations
- IsUSBDevice/diskModel: strip findmnt bind-mount suffix [/subdir] before parsing device path (fixes USB badge not showing for attach-wizard drives) - crossdrive.go: skip disconnected src/dest drives with WARN log instead of returning error (prevents noisy error status in settings.json) - handlers.go: detect Tier2 destination disconnection, set yellow status dot instead of red, skip ValidateDestination for disconnected paths - backups.html: new template branch showing "Cél meghajtó leválasztva" badge with grayed-out info and hidden "Futtatás most" button Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -90,25 +90,15 @@ func (r *CrossDriveRunner) RunAppBackup(ctx context.Context, stackName string) e
|
|||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Check if source or destination drive is disconnected
|
// Check if source or destination drive is disconnected — skip silently (not an error)
|
||||||
srcDrive := r.stackProvider.GetStackHDDPath(stackName)
|
srcDrive := r.stackProvider.GetStackHDDPath(stackName)
|
||||||
if srcDrive != "" && r.sett.IsDisconnected(srcDrive) {
|
if srcDrive != "" && r.sett.IsDisconnected(srcDrive) {
|
||||||
if r.debug {
|
r.logger.Printf("[WARN] [backup] Cross-drive backup skipped for %s: source drive disconnected (%s)", stackName, srcDrive)
|
||||||
r.logger.Printf("[DEBUG] RunAppBackup: source drive disconnected for %s: %s", stackName, srcDrive)
|
return nil
|
||||||
}
|
|
||||||
r.mu.Lock()
|
|
||||||
r.running[stackName] = false
|
|
||||||
r.mu.Unlock()
|
|
||||||
return fmt.Errorf("source drive disconnected: %s", srcDrive)
|
|
||||||
}
|
}
|
||||||
if r.sett.IsDisconnected(cfg.DestinationPath) {
|
if r.sett.IsDisconnected(cfg.DestinationPath) {
|
||||||
if r.debug {
|
r.logger.Printf("[WARN] [backup] Cross-drive backup skipped for %s: destination drive disconnected (%s)", stackName, cfg.DestinationPath)
|
||||||
r.logger.Printf("[DEBUG] RunAppBackup: destination drive disconnected for %s: %s", stackName, cfg.DestinationPath)
|
return nil
|
||||||
}
|
|
||||||
r.mu.Lock()
|
|
||||||
r.running[stackName] = false
|
|
||||||
r.mu.Unlock()
|
|
||||||
return fmt.Errorf("destination drive disconnected: %s", cfg.DestinationPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark as running in settings
|
// Mark as running in settings
|
||||||
|
|||||||
@@ -248,6 +248,10 @@ func stripPartition(base string) string {
|
|||||||
|
|
||||||
// diskModel reads the disk model from /sys/block/<dev>/device/model.
|
// diskModel reads the disk model from /sys/block/<dev>/device/model.
|
||||||
func diskModel(device string) string {
|
func diskModel(device string) string {
|
||||||
|
// Strip bind-mount subdir suffix (e.g., "/dev/sdb1[/felhom_data]" → "/dev/sdb1")
|
||||||
|
if idx := strings.IndexByte(device, '['); idx >= 0 {
|
||||||
|
device = device[:idx]
|
||||||
|
}
|
||||||
disk := stripPartition(filepath.Base(device))
|
disk := stripPartition(filepath.Base(device))
|
||||||
modelPath := "/sys/block/" + disk + "/device/model"
|
modelPath := "/sys/block/" + disk + "/device/model"
|
||||||
data, err := os.ReadFile(modelPath)
|
data, err := os.ReadFile(modelPath)
|
||||||
@@ -321,8 +325,13 @@ func ProbeStoragePath(path string) ProbeResult {
|
|||||||
|
|
||||||
// IsUSBDevice checks if a block device is connected via USB.
|
// IsUSBDevice checks if a block device is connected via USB.
|
||||||
// devicePath should be like "/dev/sdb" or "/dev/sdb1".
|
// devicePath should be like "/dev/sdb" or "/dev/sdb1".
|
||||||
|
// Also handles bind-mount suffixes from findmnt (e.g., "/dev/sdb1[/felhom_data]").
|
||||||
// Checks the sysfs symlink for the disk — if the path contains "/usb", it's a USB device.
|
// Checks the sysfs symlink for the disk — if the path contains "/usb", it's a USB device.
|
||||||
func IsUSBDevice(devicePath string) bool {
|
func IsUSBDevice(devicePath string) bool {
|
||||||
|
// Strip bind-mount subdir suffix (e.g., "/dev/sdb1[/felhom_data]" → "/dev/sdb1")
|
||||||
|
if idx := strings.IndexByte(devicePath, '['); idx >= 0 {
|
||||||
|
devicePath = devicePath[:idx]
|
||||||
|
}
|
||||||
disk := stripPartition(filepath.Base(devicePath))
|
disk := stripPartition(filepath.Base(devicePath))
|
||||||
if disk == "" {
|
if disk == "" {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -785,6 +785,8 @@ type AppBackupRow struct {
|
|||||||
|
|
||||||
// Drive disconnected — app's home drive is currently disconnected
|
// Drive disconnected — app's home drive is currently disconnected
|
||||||
DriveDisconnected bool
|
DriveDisconnected bool
|
||||||
|
// Tier2 destination drive is currently disconnected (backup paused, not failed)
|
||||||
|
Tier2DestDisconnected bool
|
||||||
|
|
||||||
// Warnings accumulated for this app
|
// Warnings accumulated for this app
|
||||||
Warnings []string
|
Warnings []string
|
||||||
@@ -926,8 +928,22 @@ func (s *Server) buildAppBackupRows(
|
|||||||
// Tier2 configured but never run — stay yellow
|
// Tier2 configured but never run — stay yellow
|
||||||
}
|
}
|
||||||
|
|
||||||
// Destination health check — can downgrade green to yellow/red
|
// Check if Tier2 destination drive is disconnected
|
||||||
if cfg.DestinationPath != "" && s.crossDriveRunner != nil {
|
if cfg.DestinationPath != "" {
|
||||||
|
for dp := range disconnectedPaths {
|
||||||
|
if cfg.DestinationPath == dp || strings.HasPrefix(cfg.DestinationPath, dp+"/") {
|
||||||
|
row.Tier2DestDisconnected = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if row.Tier2DestDisconnected {
|
||||||
|
// Disconnected destination — treat as paused, not failed
|
||||||
|
row.Status = "yellow"
|
||||||
|
row.StatusText = "2. mentés szünetel — cél meghajtó leválasztva"
|
||||||
|
} else if cfg.DestinationPath != "" && s.crossDriveRunner != nil {
|
||||||
|
// Destination health check — can downgrade green to yellow/red
|
||||||
if err := s.crossDriveRunner.ValidateDestination(cfg.DestinationPath); err != nil {
|
if err := s.crossDriveRunner.ValidateDestination(cfg.DestinationPath); err != nil {
|
||||||
if strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "not writable") {
|
if strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "not writable") {
|
||||||
row.Status = "red"
|
row.Status = "red"
|
||||||
|
|||||||
@@ -295,7 +295,18 @@
|
|||||||
<!-- Tier 2: Cross-drive backup (opt-in, different device) -->
|
<!-- Tier 2: Cross-drive backup (opt-in, different device) -->
|
||||||
<div class="backup-layer-row">
|
<div class="backup-layer-row">
|
||||||
<span class="tier-label">2. mentés</span>
|
<span class="tier-label">2. mentés</span>
|
||||||
{{if .Tier2Configured}}
|
{{if and .Tier2Configured .Tier2DestDisconnected}}
|
||||||
|
<span class="layer-method" style="opacity:.6">rsync</span>
|
||||||
|
<span class="layer-dest" style="opacity:.6">→ {{.Tier2Dest}}</span>
|
||||||
|
<span class="badge badge-warn" style="font-size:.7rem">Cél meghajtó leválasztva</span>
|
||||||
|
{{if .Tier2LastRun}}
|
||||||
|
<span class="layer-last" style="opacity:.6">Utolsó: {{.Tier2LastRun}}</span>
|
||||||
|
{{end}}
|
||||||
|
<span class="tier-contents" style="opacity:.6">{{.BackupContents}}</span>
|
||||||
|
<div class="layer-actions">
|
||||||
|
<a href="/stacks/{{.StackName}}/deploy" class="btn btn-xs btn-outline">Beállítás</a>
|
||||||
|
</div>
|
||||||
|
{{else if .Tier2Configured}}
|
||||||
<span class="layer-method">rsync</span>
|
<span class="layer-method">rsync</span>
|
||||||
<span class="layer-dest">→ {{.Tier2Dest}}</span>
|
<span class="layer-dest">→ {{.Tier2Dest}}</span>
|
||||||
<span class="layer-schedule">{{.Tier2Schedule}}</span>
|
<span class="layer-schedule">{{.Tier2Schedule}}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user