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:
@@ -9,6 +9,7 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// Alert represents a persistent dashboard alert banner.
|
||||
@@ -39,10 +40,29 @@ func NewAlertManager(logger *log.Logger) *AlertManager {
|
||||
}
|
||||
|
||||
// Refresh regenerates alerts from the latest health check report and config state.
|
||||
// Called after each health check cycle (every 5 minutes).
|
||||
func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config, backupMgr *backup.Manager, updateAvailable bool, latestVersion string) {
|
||||
// Called after each health check cycle (every 5 minutes) and on storage state changes.
|
||||
func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config, backupMgr *backup.Manager, updateAvailable bool, latestVersion string, storagePaths ...[]settings.StoragePath) {
|
||||
var alerts []Alert
|
||||
|
||||
// Disconnected storage alerts (top-level error banners on all pages)
|
||||
if len(storagePaths) > 0 {
|
||||
for _, sp := range storagePaths[0] {
|
||||
if sp.Disconnected {
|
||||
label := sp.Label
|
||||
if label == "" {
|
||||
label = sp.Path
|
||||
}
|
||||
alerts = append(alerts, Alert{
|
||||
ID: "storage-disconnected-" + simpleHash(sp.Path),
|
||||
Level: "error",
|
||||
Message: fmt.Sprintf("Meghajtó leválasztva: %s (%s)", label, sp.Path),
|
||||
Link: "/settings",
|
||||
LinkText: "Beállítások",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// From health check issues (critical)
|
||||
for _, issue := range report.Issues {
|
||||
alerts = append(alerts, Alert{
|
||||
|
||||
@@ -22,17 +22,26 @@ import (
|
||||
|
||||
// StorageBarInfo holds data for rendering a storage usage bar on dashboard/monitoring.
|
||||
type StorageBarInfo struct {
|
||||
Label string // e.g., "USB HDD 1TB", "SYS Storage 350G"
|
||||
Path string // e.g., "/mnt/hdd_1"
|
||||
TotalGB float64
|
||||
UsedGB float64
|
||||
Percent float64
|
||||
Label string // e.g., "USB HDD 1TB", "SYS Storage 350G"
|
||||
Path string // e.g., "/mnt/hdd_1"
|
||||
TotalGB float64
|
||||
UsedGB float64
|
||||
Percent float64
|
||||
Disconnected bool
|
||||
}
|
||||
|
||||
// buildStorageBars returns usage bars for all registered storage paths.
|
||||
func (s *Server) buildStorageBars() []StorageBarInfo {
|
||||
var bars []StorageBarInfo
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
if sp.Disconnected {
|
||||
bars = append(bars, StorageBarInfo{
|
||||
Label: sp.Label,
|
||||
Path: sp.Path,
|
||||
Disconnected: true,
|
||||
})
|
||||
continue
|
||||
}
|
||||
di := system.GetDiskUsage(sp.Path)
|
||||
if di == nil {
|
||||
continue
|
||||
@@ -65,11 +74,13 @@ type StorageAppDetail struct {
|
||||
// StoragePathView extends StoragePath with display data for the settings page.
|
||||
type StoragePathView struct {
|
||||
settings.StoragePath
|
||||
DiskInfo *system.DiskUsageInfo
|
||||
AppCount int
|
||||
IsMounted bool
|
||||
AppDetails []StorageAppDetail
|
||||
FSInfo *system.FSInfo
|
||||
DiskInfo *system.DiskUsageInfo
|
||||
AppCount int
|
||||
IsMounted bool
|
||||
AppDetails []StorageAppDetail
|
||||
FSInfo *system.FSInfo
|
||||
IsUSB bool // true if this is a USB-attached device (safe disconnect available)
|
||||
StoppedApps []string // stacks auto-stopped due to disconnect (for restart UI)
|
||||
}
|
||||
|
||||
func (s *Server) baseData(page, title string) map[string]interface{} {
|
||||
@@ -659,6 +670,9 @@ type AppBackupRow struct {
|
||||
Tier2SizeHuman string
|
||||
Tier2Browsable bool // true for rsync (plain files), false for restic
|
||||
|
||||
// Drive disconnected — app's home drive is currently disconnected
|
||||
DriveDisconnected bool
|
||||
|
||||
// Warnings accumulated for this app
|
||||
Warnings []string
|
||||
}
|
||||
@@ -700,10 +714,32 @@ func (s *Server) buildAppBackupRows(
|
||||
}
|
||||
}
|
||||
|
||||
// Build disconnected paths set for drive-disconnected detection
|
||||
disconnectedPaths := make(map[string]bool)
|
||||
for _, dp := range s.settings.GetDisconnectedPaths() {
|
||||
disconnectedPaths[dp.Path] = true
|
||||
}
|
||||
|
||||
var rows []AppBackupRow
|
||||
for _, app := range status.AppDataInfo {
|
||||
hasDB := dbStacks[app.StackName] || app.HasDBDump
|
||||
|
||||
// Check if this app's home drive is disconnected
|
||||
driveDisconnected := false
|
||||
if app.HasHDDData && len(app.HDDPaths) > 0 {
|
||||
for dp := range disconnectedPaths {
|
||||
for _, hp := range app.HDDPaths {
|
||||
if strings.HasPrefix(hp.HostPath, dp+"/") || hp.HostPath == dp {
|
||||
driveDisconnected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if driveDisconnected {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build backup contents label
|
||||
var parts []string
|
||||
if hasDB {
|
||||
@@ -716,10 +752,11 @@ func (s *Server) buildAppBackupRows(
|
||||
contents := strings.Join(parts, " + ")
|
||||
|
||||
row := AppBackupRow{
|
||||
StackName: app.StackName,
|
||||
DisplayName: app.DisplayName,
|
||||
HasHDDData: app.HasHDDData,
|
||||
HasDB: hasDB,
|
||||
StackName: app.StackName,
|
||||
DisplayName: app.DisplayName,
|
||||
HasHDDData: app.HasHDDData,
|
||||
HasDB: hasDB,
|
||||
DriveDisconnected: driveDisconnected,
|
||||
StorageLabel: app.StorageLabel,
|
||||
HDDSizeHuman: app.HDDSizeHuman,
|
||||
BackupContents: contents,
|
||||
@@ -933,13 +970,23 @@ func (s *Server) settingsData() map[string]interface{} {
|
||||
for _, sp := range storagePaths {
|
||||
view := StoragePathView{
|
||||
StoragePath: sp,
|
||||
IsMounted: system.IsMountPoint(sp.Path),
|
||||
AppDetails: s.appDetailsForPath(sp.Path),
|
||||
FSInfo: system.GetFSInfo(sp.Path),
|
||||
StoppedApps: sp.StoppedStacks,
|
||||
}
|
||||
view.AppCount = len(view.AppDetails)
|
||||
if di := system.GetDiskUsage(sp.Path); di != nil {
|
||||
view.DiskInfo = di
|
||||
if sp.Disconnected {
|
||||
// Skip I/O calls on disconnected drives — they'd hang or fail
|
||||
view.IsMounted = false
|
||||
} else {
|
||||
view.IsMounted = system.IsMountPoint(sp.Path)
|
||||
view.AppDetails = s.appDetailsForPath(sp.Path)
|
||||
view.FSInfo = system.GetFSInfo(sp.Path)
|
||||
view.AppCount = len(view.AppDetails)
|
||||
if di := system.GetDiskUsage(sp.Path); di != nil {
|
||||
view.DiskInfo = di
|
||||
}
|
||||
// Detect USB for safe disconnect button
|
||||
if view.FSInfo != nil && view.FSInfo.Device != "" {
|
||||
view.IsUSB = system.IsUSBDevice(view.FSInfo.Device)
|
||||
}
|
||||
}
|
||||
storageViews = append(storageViews, view)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate"
|
||||
@@ -49,6 +50,9 @@ type Server struct {
|
||||
// DR restore mode state
|
||||
restoreMu sync.RWMutex
|
||||
restorePlan *backup.RestorePlan
|
||||
|
||||
// Storage watchdog (set after construction to break init ordering)
|
||||
storageWatchdog *monitor.StorageWatchdog
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, sched *scheduler.Scheduler, sett *settings.Settings, alertMgr *AlertManager, notif *notify.Notifier, updater *selfupdate.Updater, logger *log.Logger, version string) *Server {
|
||||
@@ -99,6 +103,11 @@ func (s *Server) SetRestoreState(plan *backup.RestorePlan) {
|
||||
s.restorePlan = plan
|
||||
}
|
||||
|
||||
// SetStorageWatchdog sets the storage watchdog for disconnect/reconnect operations.
|
||||
func (s *Server) SetStorageWatchdog(w *monitor.StorageWatchdog) {
|
||||
s.storageWatchdog = w
|
||||
}
|
||||
|
||||
// InRestoreMode returns true if the server is in DR restore mode.
|
||||
func (s *Server) InRestoreMode() bool {
|
||||
s.restoreMu.RLock()
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -38,6 +38,15 @@
|
||||
</div>
|
||||
</div>
|
||||
{{range $.StorageBars}}
|
||||
{{if .Disconnected}}
|
||||
<div class="storage-item storage-disconnected">
|
||||
<div class="storage-header">
|
||||
<span class="storage-label">{{.Label}}</span>
|
||||
<span class="storage-value badge-error" style="font-size:.75rem">Leválasztva</span>
|
||||
</div>
|
||||
<div class="system-bar"><div class="system-bar-disconnected"></div></div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="storage-item">
|
||||
<div class="storage-header">
|
||||
<span class="storage-label">{{.Label}}</span>
|
||||
@@ -49,6 +58,7 @@
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="storage-stats">
|
||||
{{if .Backup.RepoStats}}
|
||||
@@ -253,7 +263,9 @@
|
||||
<span class="status-dot status-{{.Status}}" title="{{.StatusText}}"></span>
|
||||
<span class="app-backup-row-name">{{.DisplayName}}</span>
|
||||
<div class="app-backup-row-meta">
|
||||
{{if .HasHDDData}}
|
||||
{{if .DriveDisconnected}}
|
||||
<span class="badge badge-error" style="font-size:.7rem">Meghajtó leválasztva</span>
|
||||
{{else if .HasHDDData}}
|
||||
{{if .StorageLabel}}<span class="meta-badge meta-badge-storage">{{.StorageLabel}}</span>{{end}}
|
||||
<span class="mono app-backup-size" style="font-size:.8rem">{{.HDDSizeHuman}}</span>
|
||||
{{else}}
|
||||
|
||||
@@ -66,6 +66,15 @@
|
||||
</div>
|
||||
</div>
|
||||
{{range .StorageBars}}
|
||||
{{if .Disconnected}}
|
||||
<div class="system-info-item storage-disconnected">
|
||||
<div class="system-info-header">
|
||||
<span class="system-info-label">{{.Label}}</span>
|
||||
<span class="system-info-value badge-error" style="font-size:.75rem">Leválasztva</span>
|
||||
</div>
|
||||
<div class="system-bar"><div class="system-bar-disconnected"></div></div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="system-info-item">
|
||||
<div class="system-info-header">
|
||||
<span class="system-info-label">{{.Label}}</span>
|
||||
@@ -76,6 +85,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .DiskWarnings}}
|
||||
<div class="inline-warnings">
|
||||
|
||||
@@ -51,6 +51,15 @@
|
||||
</div>
|
||||
</div>
|
||||
{{range $.StorageBars}}
|
||||
{{if .Disconnected}}
|
||||
<div class="storage-item storage-disconnected">
|
||||
<div class="storage-header">
|
||||
<span class="storage-label">{{.Label}}</span>
|
||||
<span class="storage-value badge-error" style="font-size:.75rem">Leválasztva</span>
|
||||
</div>
|
||||
<div class="system-bar"><div class="system-bar-disconnected"></div></div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="storage-item">
|
||||
<div class="storage-header">
|
||||
<span class="storage-label">{{.Label}}</span>
|
||||
@@ -62,6 +71,7 @@
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .DiskWarnings}}
|
||||
<div class="inline-warnings">
|
||||
|
||||
@@ -205,21 +205,38 @@ function pollUntilBack() {
|
||||
{{if .StoragePaths}}
|
||||
<div class="storage-paths-list">
|
||||
{{range .StoragePaths}}
|
||||
<div class="storage-path-item">
|
||||
<div class="storage-path-item{{if .Disconnected}} storage-disconnected{{end}}">
|
||||
<div class="storage-path-header">
|
||||
<div class="storage-path-info">
|
||||
<div class="storage-path-label-wrap" id="label-wrap-{{.Path}}">
|
||||
<span class="storage-path-label" id="label-display-{{.Path}}">{{.Label}}</span>
|
||||
<button class="btn btn-xs btn-ghost" onclick="editStorageLabel('{{.Path}}', '{{.Label}}')" title="Átnevezés">✏️</button>
|
||||
{{if not .Disconnected}}<button class="btn btn-xs btn-ghost" onclick="editStorageLabel('{{.Path}}', '{{.Label}}')" title="Átnevezés">✏️</button>{{end}}
|
||||
</div>
|
||||
<span class="storage-path-path mono">{{.Path}}</span>
|
||||
</div>
|
||||
<div class="storage-path-badges">
|
||||
{{if .Disconnected}}
|
||||
<span class="badge badge-error">Leválasztva</span>
|
||||
{{else}}
|
||||
{{if .IsDefault}}<span class="badge state-green">Alapértelmezett</span>{{end}}
|
||||
{{if .Schedulable}}<span class="badge" style="background:rgba(0,136,204,0.15);color:var(--accent-light)">Aktív</span>{{else}}<span class="badge state-gray">Inaktív</span>{{end}}
|
||||
{{if not .IsMounted}}<span class="badge badge-warn">Rendszermeghajtón</span>{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{if .Disconnected}}
|
||||
<div class="storage-path-details">
|
||||
<div class="storage-disconnected-info">
|
||||
{{if .DisconnectedAt}}<span class="form-hint">Leválasztva: {{.DisconnectedAt}}</span>{{end}}
|
||||
{{if .StoppedApps}}
|
||||
<span class="form-hint">Leállított alkalmazások: {{range $i, $name := .StoppedApps}}{{if $i}}, {{end}}{{$name}}{{end}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="storage-path-actions" id="storage-actions-{{.Path}}">
|
||||
<button class="btn btn-xs btn-primary" onclick="storageReconnect('{{.Path}}')">Csatlakoztatás</button>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="storage-path-details">
|
||||
{{if .DiskInfo}}
|
||||
<div class="storage-path-disk">
|
||||
@@ -237,6 +254,12 @@ function pollUntilBack() {
|
||||
{{.FSInfo.FSType}} · {{.FSInfo.Device}}{{if .FSInfo.Model}} · {{.FSInfo.Model}}{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .StoppedApps}}
|
||||
<div class="storage-stopped-apps-info" id="storage-stopped-{{.Path}}">
|
||||
<span class="form-hint" style="color:var(--accent-light)">Újraindításra váró alkalmazások: {{range $i, $name := .StoppedApps}}{{if $i}}, {{end}}{{$name}}{{end}}</span>
|
||||
<button class="btn btn-xs btn-primary" onclick="storageRestartApps('{{.Path}}')" style="margin-left:.5rem">Alkalmazások indítása</button>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="storage-path-meta">
|
||||
{{if .AppDetails}}
|
||||
<details class="storage-app-details">
|
||||
@@ -278,6 +301,9 @@ function pollUntilBack() {
|
||||
<button type="submit" class="btn btn-xs btn-outline">Engedélyezés</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if .IsUSB}}
|
||||
<button class="btn btn-xs btn-danger-outline" onclick="storageDisconnect('{{.Path}}', '{{.Label}}', {{.AppCount}})">Leválasztás</button>
|
||||
{{end}}
|
||||
{{if and (not .IsDefault) (eq .AppCount 0)}}
|
||||
<form method="POST" action="/settings/storage/remove" style="display:inline"
|
||||
onsubmit="return confirm('Biztosan eltávolítja a(z) {{.Path}} adattárolót?')">
|
||||
@@ -286,6 +312,7 @@ function pollUntilBack() {
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
@@ -420,6 +447,60 @@ function editStorageLabel(path, currentLabel) {
|
||||
'</form>';
|
||||
wrap.querySelector('input[name=storage_label]').focus();
|
||||
}
|
||||
function storageDisconnect(path, label, appCount) {
|
||||
var msg = 'Biztos leválasztja a meghajtót: ' + label + '?';
|
||||
if (appCount > 0) msg += '\n\nA rajta futó ' + appCount + ' alkalmazás le fog állni.';
|
||||
msg += '\n\nA meghajtó ezután biztonságosan eltávolítható.';
|
||||
if (!confirm(msg)) return;
|
||||
fetch('/api/storage/disconnect', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({path: path})
|
||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||
if (data.ok) {
|
||||
alert('A meghajtó biztonságosan eltávolítható.');
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Hiba: ' + (data.error || 'ismeretlen'));
|
||||
}
|
||||
}).catch(function(e) { alert('Hiba: ' + e); });
|
||||
}
|
||||
function storageReconnect(path) {
|
||||
var actionsDiv = document.getElementById('storage-actions-' + path);
|
||||
if (actionsDiv) actionsDiv.innerHTML = '<span class="form-hint">Csatlakoztatás...</span>';
|
||||
fetch('/api/storage/reconnect', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({path: path})
|
||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||
if (data.ok) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Hiba: ' + (data.error || 'ismeretlen'));
|
||||
if (actionsDiv) actionsDiv.innerHTML = '<button class="btn btn-xs btn-primary" onclick="storageReconnect(\'' + path + '\')">Csatlakoztatás</button>';
|
||||
}
|
||||
}).catch(function(e) {
|
||||
alert('Hiba: ' + e);
|
||||
if (actionsDiv) actionsDiv.innerHTML = '<button class="btn btn-xs btn-primary" onclick="storageReconnect(\'' + path + '\')">Csatlakoztatás</button>';
|
||||
});
|
||||
}
|
||||
function storageRestartApps(path) {
|
||||
fetch('/api/storage/restart-apps', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({path: path})
|
||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||
if (data.ok) {
|
||||
var msg = '';
|
||||
if (data.started && data.started.length) msg += 'Elindítva: ' + data.started.join(', ');
|
||||
if (data.failed && data.failed.length) msg += (msg ? '\n' : '') + 'Sikertelen: ' + data.failed.join(', ');
|
||||
if (msg) alert(msg);
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Hiba: ' + (data.error || 'ismeretlen'));
|
||||
}
|
||||
}).catch(function(e) { alert('Hiba: ' + e); });
|
||||
}
|
||||
function cancelEditLabel(path, label) {
|
||||
var wrap = document.getElementById('label-wrap-' + path);
|
||||
if (!wrap) return;
|
||||
|
||||
@@ -2254,6 +2254,41 @@ a.stat-card:hover {
|
||||
background: rgba(250, 204, 21, 0.15);
|
||||
color: #facc15;
|
||||
}
|
||||
.badge-error {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Disconnected storage path card */
|
||||
.storage-disconnected {
|
||||
opacity: 0.75;
|
||||
border-style: dashed;
|
||||
}
|
||||
.storage-disconnected .storage-disconnected-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .25rem;
|
||||
}
|
||||
.storage-stopped-apps-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: .25rem;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
/* Disconnected bar on dashboard/monitoring */
|
||||
.system-bar-disconnected {
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgba(239, 68, 68, 0.15),
|
||||
rgba(239, 68, 68, 0.15) 5px,
|
||||
transparent 5px,
|
||||
transparent 10px
|
||||
);
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Task progress bar (storage init — not disk usage zone gradient) */
|
||||
.progress-bar-task {
|
||||
|
||||
Reference in New Issue
Block a user