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
+22 -2
View File
@@ -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{
+67 -20
View File
@@ -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)
}
+9
View File
@@ -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()
+160
View File
@@ -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,
})
}
+13 -1
View File
@@ -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 {