From 69698a89e843b51ddb3399bfbc653211bc83087b Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Tue, 17 Feb 2026 09:48:51 +0100 Subject: [PATCH] =?UTF-8?q?v0.10.0:=20Phase=20B=20=E2=80=94=20Storage=20Ma?= =?UTF-8?q?nagement=20UI=20Polish=20&=20Health=20Severity=20Fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Health severity fix: mount-point check downgraded from issue (FAIL) to warning (WARN) - All storage health messages translated to Hungarian - Success flash messages for all storage operations - Edit storage path labels (inline edit UI + backend) - App details per storage path on settings page (expandable list with names + sizes) - Storage badge on stacks page showing which storage each app uses - Deploy dropdown with free space display and low-space warning (<20%) - Filesystem & disk info on settings page (ext4/btrfs, device, model via findmnt) - Backup page storage context with per-app storage label badges Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 14 +- CONTEXT.md | 10 +- controller/README.md | 18 +- controller/internal/backup/appdata.go | 1 + controller/internal/monitor/healthcheck.go | 11 +- controller/internal/settings/settings.go | 13 ++ controller/internal/system/mounts_linux.go | 50 +++++ controller/internal/system/mounts_other.go | 10 + controller/internal/web/handlers.go | 183 ++++++++++++++++-- controller/internal/web/server.go | 2 + .../internal/web/templates/backups.html | 7 +- controller/internal/web/templates/deploy.html | 24 ++- .../internal/web/templates/settings.html | 48 ++++- controller/internal/web/templates/stacks.html | 1 + controller/internal/web/templates/style.css | 50 +++++ 15 files changed, 412 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b22c2b..6665b63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,18 @@ ## Changelog -### What was just completed (2026-02-17 session 26) +### What was just completed (2026-02-17 session 27) +- **v0.10.0 — Phase B: Storage Management UI Polish & Health Severity Fix:** + - **Step 0: Health severity fix** — `checkStoragePaths()` mount-point check reclassified from **issue** (FAIL) to **warning** (WARN). All storage health messages translated to Hungarian. Added `.monitoring-banner-warn` CSS class for yellow warning banners. Prevents false FAIL status on demo/test environments where storage is intentionally on SSD. + - **Step 1: Success flash messages** — All 4 storage handlers (add/remove/set-default/toggle-schedulable) now redirect with `?storage_msg=success&storage_detail=...` query params. Settings page displays green "alert-info" flash on success. Consistent with backup page flash pattern. + - **Step 2: Edit storage path labels** — New `SetStorageLabel()` method in `settings.go`. New `POST /settings/storage/label` route + handler. Inline edit UI with ✏️ button, text input, OK/Cancel. Added `.btn-ghost` CSS class. + - **Step 3: App details per storage path** — Settings page now shows expandable `
` list per storage path with app names, sizes, and links to deploy page. New `StorageAppDetail` struct + `appDetailsForPath()` helper. Added CSS for `.storage-app-details`, `.storage-app-list`, `.storage-app-row`. + - **Step 4: Storage badge on stacks page** — Deployed app cards show "💾 Label" badge indicating which registered storage path the app uses. `StorageLabels` map built from deployed apps' HDD_PATH → registered storage path label lookup. Added `.meta-badge-storage` CSS. + - **Step 5: Deploy dropdown enhancements** — Storage path dropdown now shows free space ("234 GB szabad"). `DeployStoragePath` struct wraps `StoragePath` with `FreeHuman`/`FreePercent` from `GetDiskUsage()`. JS `checkStorageSpace()` shows yellow warning when selected storage has <20% free. + - **Step 6: Filesystem & disk info** — New `FSInfo` struct + `GetFSInfo()` in `mounts_linux.go` using `findmnt` command + `/sys/block/` sysfs reads for disk model. Settings page shows "ext4 · /dev/sdb1 · WD Elements" below disk usage bar. Non-Linux stub returns nil. + - **Step 7: Backup page storage context** — Added `StorageLabel` field to `AppBackupInfo`. Backup page shows storage label badge per app by matching HDD path prefixes against registered storage paths. Uses existing `.meta-badge-storage` CSS. + - **Files modified (12):** `healthcheck.go`, `settings.go`, `mounts_linux.go`, `mounts_other.go`, `appdata.go`, `handlers.go`, `server.go`, `settings.html`, `stacks.html`, `deploy.html`, `backups.html`, `style.css` + +### What was previously completed (2026-02-17 session 26) - **v0.9.0 — Phase A: Storage Paths Foundation & Backup Toggle Fix:** - **Root cause:** Per-app backup toggles (v0.8.0) didn't appear because `controller.yaml` had no `paths.hdd_path` set → `ParseComposeHDDMounts` returned nil. Even with global hdd_path, apps with different HDD_PATH values wouldn't match. - **Core fix: Per-app HDD_PATH resolution** — `stackAdapter.GetStackHDDMounts()` now reads each app's own `HDD_PATH` from its `app.yaml` env section (Priority 1), falling back to all registered storage paths (Priority 2). Removed dependency on global `cfg.Paths.HDDPath`. diff --git a/CONTEXT.md b/CONTEXT.md index 76d277f..238445a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,7 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-02-17 (session 26) +Last updated: 2026-02-17 (session 27) --- @@ -20,7 +20,7 @@ Last updated: 2026-02-17 (session 26) - Customer deployments use Docker Compose (not Kubernetes) for simplicity ### felhom-controller (this repo) -- **Version:** v0.9.0 +- **Version:** v0.10.0 - **Phase 1:** ✅ COMPLETE — Stack Manager + Deploy Flow - **Phase 2:** ✅ COMPLETE — Monitoring & Health (scheduler, CPU/temp, healthchecks.io pings) - **Phase 3:** ✅ COMPLETE — Backups (DB dumps, restic integration, manual trigger, **dedicated backup page**) @@ -28,6 +28,8 @@ Last updated: 2026-02-17 (session 26) - **Phase 5:** ✅ COMPLETE — Authentication, Persistence & Settings Page (settings.json, password change, session management) - **Phase 6:** ✅ COMPLETE — Monitoring Warnings, Dashboard Alerts & Notification System - **Phase 7:** ✅ COMPLETE — Storage Overview, Per-App Backup Toggles & Limited Restore +- **Phase A:** ✅ COMPLETE — Storage Paths Foundation (registry, auto-discovery, per-app HDD_PATH, deploy dropdown, health monitoring) +- **Phase B:** ✅ COMPLETE — Storage Management UI Polish & Health Severity Fix (flash messages, label editing, app details, FS info, deploy free space, backup context) - **First app deployed:** Paperless-ngx on demo-felhom.eu (2026-02-13) - **Running on:** demo-felhom (N100 mini PC) at 192.168.0.162:8080 - **All Phase 1-5 features working:** deploy, start/stop/restart/update, logs, health-aware states, auth, monitoring, backups, backup detail page, system monitoring page, settings page @@ -77,6 +79,10 @@ Last updated: 2026-02-17 (session 26) | /mnt:/mnt:rw mount in controller | Replaces per-path HDD_PATH mount. Enables multi-storage + restore writes. All customer HDD mounts are under /mnt/ by convention | | Per-app HDD_PATH resolution (app.yaml > global) | App's own env HDD_PATH is Priority 1, registered storage paths as fallback. Eliminates dependency on global controller.yaml hdd_path | | Mount-point detection via syscall.Stat_t.Dev | Compares device ID of path vs parent dir — reliable check that path is on separate filesystem. Prevents data writes to SSD | +| Health severity: mount-point = warning | Non-mount-point is informational, not a service failure. FAIL reserved for genuinely broken things. Avoids false alarms on demo/test environments | +| FS info via findmnt + sysfs | `findmnt -n -o SOURCE,FSTYPE --target ` for filesystem type/device. `/sys/block//device/model` for disk model. Best-effort, returns nil on failure | +| Query param flash messages | Stateless, no session store needed. Consistent with backup page pattern. `?storage_msg=success&storage_detail=...` | +| StorageLabels map on stacks page | Separate map passed to template (not modifying Stack struct). Built from deployed apps' HDD_PATH → registered path label lookup | | Metrics downsampling via SQL | Bucket-based AVG in GROUP BY keeps Chart.js responsive with up to 30 days of data | | 60s metrics collection interval | Good balance of resolution vs. storage — ~44K rows/month for system metrics | | /etc/os-release mounted read-only | Container can't read host OS info directly — mount to /host/etc/os-release:ro | diff --git a/controller/README.md b/controller/README.md index 9103abc..c9741fb 100644 --- a/controller/README.md +++ b/controller/README.md @@ -24,7 +24,7 @@ controller generates secrets, saves app.yaml, runs `docker compose up -d`, and t with Traefik routing and health checks. The dashboard correctly shows real-time container states including health substatus (starting → healthy → running). -Current version: **v0.7.2** +Current version: **v0.10.0** ### What works - Dashboard with live container state (green/orange/yellow/red) @@ -61,6 +61,12 @@ Current version: **v0.7.2** - Central hub reporting (periodic JSON push to felhom-hub service) - Notification preferences sync to hub (controller → hub on save + startup) - Notification system with email delivery via Resend API (hub relay) +- Storage paths registry with auto-discovery, per-app HDD_PATH resolution, and multi-storage support +- Storage management UI: add/remove paths, edit labels, set default, toggle schedulable +- Per-app storage visibility: storage badges on stacks page, app details per storage path on settings page +- Deploy dropdown with free space display and low-space warnings +- Filesystem & disk info (ext4/btrfs, device, model) on settings page +- Backup page storage context with per-app storage label badges ### Known issues / next priorities - Cloudflare Tunnel + Traefik TLS: paperless.demo-felhom.eu works locally but shows "Not secure" (certificate chain not fully validated through tunnel) @@ -122,7 +128,9 @@ controller/ │ │ ├── info_linux.go # Linux: /proc/meminfo + statfs + loadavg + temperature │ │ ├── info_other.go # Non-Linux stub │ │ ├── cpu_linux.go # CPU collector (background /proc/stat sampling) -│ │ └── cpu_other.go # CPU collector stub (non-Linux) +│ │ ├── cpu_other.go # CPU collector stub (non-Linux) +│ │ ├── mounts_linux.go # Mount point, disk usage, FS info (findmnt, sysfs) +│ │ └── mounts_other.go # Non-Linux stubs for mount/disk/FS functions │ ├── monitor/ │ │ ├── pinger.go # Healthchecks.io HTTP ping client │ │ └── healthcheck.go # System health checks (disk, mem, CPU, temp, Docker) @@ -151,6 +159,7 @@ controller/ │ └── templates/ # go:embed HTML/CSS files (Hungarian UI) │ ├── layout.html, dashboard.html, stacks.html, login.html │ ├── logs.html, deploy.html, app_info.html +│ ├── settings.html, backups.html, monitoring.html │ └── style.css ├── configs/ │ ├── controller.yaml.example # Full config reference (infrastructure only) @@ -171,7 +180,8 @@ controller/ | **Config** | `internal/config/` | ✅ Done | Load & validate controller.yaml, env overrides | | **Stacks** | `internal/stacks/` | ✅ Done | Compose operations, scanning, metadata, deploy flow | | **API** | `internal/api/` | ✅ Done | REST endpoints (stacks, deploy, rescan, system info, health) | -| **System** | `internal/system/` | ✅ Done | System resource info (RAM, disk, CPU, temperature, load) | +| **Settings** | `internal/settings/` | ✅ Done | Persistent settings (password, notifications, storage paths, app backup prefs) | +| **System** | `internal/system/` | ✅ Done | System resource info (RAM, disk, CPU, temperature, load, mount points, FS info) | | **Web** | `internal/web/` | ✅ Done | Hungarian dashboard, auth, deploy pages, asset serving | | **Sync** | `internal/sync/` | ✅ Done | Git-based app catalog sync (clone/pull, content-hash copy) | | **Scheduler** | `internal/scheduler/` | ✅ Done | Central job scheduler (periodic + daily, skip-if-running) | @@ -391,7 +401,7 @@ docker compose up -d | Node | Hardware | Domain | IP | Status | |------|----------|--------|----|--------| -| demo-felhom | Acemagic GK3PLUS N100, 16G RAM, 512G SSD + 1TB HDD | demo-felhom.eu | 192.168.0.162 | ✅ Controller v0.6.0 + Paperless-ngx running | +| demo-felhom | Acemagic GK3PLUS N100, 16G RAM, 512G SSD + 1TB HDD | demo-felhom.eu | 192.168.0.162 | ✅ Controller v0.10.0 + Paperless-ngx running | | pi-customer-1 | Raspberry Pi 3B+, 1G RAM, 32G SD | pi-customer-1.local | — | 📲 Not yet tested | ### First deployment log (Paperless-ngx on demo-felhom) diff --git a/controller/internal/backup/appdata.go b/controller/internal/backup/appdata.go index 6b8495d..03db7cb 100644 --- a/controller/internal/backup/appdata.go +++ b/controller/internal/backup/appdata.go @@ -36,6 +36,7 @@ type AppBackupInfo struct { BackupEnabled bool HasHDDData bool HasDBDump bool + StorageLabel string // resolved from registered storage paths } // AppDataPath represents a single HDD bind mount path. diff --git a/controller/internal/monitor/healthcheck.go b/controller/internal/monitor/healthcheck.go index bf12c81..66a3d95 100644 --- a/controller/internal/monitor/healthcheck.go +++ b/controller/internal/monitor/healthcheck.go @@ -174,21 +174,22 @@ func checkStoragePaths(paths []settings.StoragePath) (issues, warnings []string) for _, sp := range paths { // Path accessible? if _, err := os.Stat(sp.Path); err != nil { - warnings = append(warnings, fmt.Sprintf("Storage path not accessible: %s", sp.Path)) + warnings = append(warnings, fmt.Sprintf("Adattároló nem elérhető: %s", sp.Path)) continue } - // Mount point check + // Mount point check — warning, not issue (avoids false FAIL on demo/test environments) if !system.IsMountPoint(sp.Path) { - issues = append(issues, fmt.Sprintf("Storage path %s is NOT a mount point — data writes to SSD!", sp.Path)) + warnings = append(warnings, fmt.Sprintf( + "Az adattároló (%s) nem külön meghajtón van — az adatok a rendszermeghajtóra íródnak", sp.Path)) } // Disk usage if di := system.GetDiskUsage(sp.Path); di != nil { if di.UsedPercent >= 95 { - issues = append(issues, fmt.Sprintf("Storage %s nearly full: %.0f%%", sp.Path, di.UsedPercent)) + issues = append(issues, fmt.Sprintf("Adattároló majdnem megtelt: %s (%.0f%%)", sp.Path, di.UsedPercent)) } else if di.UsedPercent >= 90 { - warnings = append(warnings, fmt.Sprintf("Storage %s usage high: %.0f%%", sp.Path, di.UsedPercent)) + warnings = append(warnings, fmt.Sprintf("Adattároló használat magas: %s (%.0f%%)", sp.Path, di.UsedPercent)) } } } diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index aba0361..a796891 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -337,6 +337,19 @@ func (s *Settings) SetSchedulable(path string, schedulable bool) error { return fmt.Errorf("storage path %q not found", path) } +// SetStorageLabel updates the label for a storage path. +func (s *Settings) SetStorageLabel(path, label string) error { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.StoragePaths { + if s.StoragePaths[i].Path == path { + s.StoragePaths[i].Label = label + return s.save() + } + } + return fmt.Errorf("storage path %q not found", path) +} + // AutoDiscoverStoragePaths scans for HDD_PATH values and registers them if none exist. // discoveredPaths are pre-scanned HDD_PATH values from deployed apps' app.yaml. // fallbackHDDPath is the legacy controller.yaml paths.hdd_path (may be empty). diff --git a/controller/internal/system/mounts_linux.go b/controller/internal/system/mounts_linux.go index 627bfeb..1222622 100644 --- a/controller/internal/system/mounts_linux.go +++ b/controller/internal/system/mounts_linux.go @@ -5,6 +5,7 @@ package system import ( "fmt" "os" + "os/exec" "path/filepath" "strings" "syscall" @@ -90,3 +91,52 @@ func formatGB(gb float64) string { } return fmt.Sprintf("%.1f GB", gb) } + +// FSInfo holds filesystem type, device, and disk model info. +type FSInfo struct { + FSType string // "ext4", "btrfs" + Device string // "/dev/sda1" + Model string // "WD Elements 25A2" (best-effort from sysfs) +} + +// GetFSInfo returns filesystem info for a path using findmnt, or nil on error. +func GetFSInfo(path string) *FSInfo { + out, err := exec.Command("findmnt", "-n", "-o", "SOURCE,FSTYPE", "--target", path).Output() + if err != nil { + return nil + } + fields := strings.Fields(strings.TrimSpace(string(out))) + if len(fields) < 2 { + return nil + } + info := &FSInfo{ + Device: fields[0], + FSType: fields[1], + } + // Try to get disk model from sysfs + info.Model = diskModel(info.Device) + return info +} + +// diskModel reads the disk model from /sys/block//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 + if strings.HasPrefix(base, "nvme") { + // nvme0n1p1 → find last 'p' followed by digits + if idx := strings.LastIndex(base, "p"); idx > 4 { + disk = base[:idx] + } + } else { + // sda1 → sda: strip trailing digits + disk = strings.TrimRight(base, "0123456789") + } + modelPath := "/sys/block/" + disk + "/device/model" + data, err := os.ReadFile(modelPath) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} diff --git a/controller/internal/system/mounts_other.go b/controller/internal/system/mounts_other.go index 59b69d1..b0c7243 100644 --- a/controller/internal/system/mounts_other.go +++ b/controller/internal/system/mounts_other.go @@ -47,3 +47,13 @@ type DiskUsageInfo struct { // GetDiskUsage returns nil on non-Linux. func GetDiskUsage(_ string) *DiskUsageInfo { return nil } + +// FSInfo holds filesystem type, device, and disk model info. +type FSInfo struct { + FSType string + Device string + Model string +} + +// GetFSInfo returns nil on non-Linux. +func GetFSInfo(_ string) *FSInfo { return nil } diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 9dc1e1d..c5525f0 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -16,12 +16,28 @@ import ( "golang.org/x/crypto/bcrypt" ) +// DeployStoragePath extends StoragePath with free space data for the deploy dropdown. +type DeployStoragePath struct { + settings.StoragePath + FreeHuman string // "234.5 GB" + FreePercent float64 // 67.5 +} + +// StorageAppDetail holds info about an app using a specific storage path. +type StorageAppDetail struct { + Name string // Display name (e.g., "Immich") + Stack string // Stack name (for link) + SizeHuman string // Data size on this path +} + // StoragePathView extends StoragePath with display data for the settings page. type StoragePathView struct { settings.StoragePath - DiskInfo *system.DiskUsageInfo - AppCount int - IsMounted bool + DiskInfo *system.DiskUsageInfo + AppCount int + IsMounted bool + AppDetails []StorageAppDetail + FSInfo *system.FSInfo } func (s *Server) baseData(page, title string) map[string]interface{} { @@ -88,6 +104,27 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, _ *http.Request) { func (s *Server) stacksHandler(w http.ResponseWriter, _ *http.Request) { data := s.baseData("stacks", "Alkalmazások") data["Stacks"] = s.stackMgr.GetStacks() + + // Build storage label lookup for deployed apps + storageLabels := make(map[string]string) // stack name → storage label + storagePaths := s.settings.GetStoragePaths() + for _, stack := range s.stackMgr.GetStacks() { + if !stack.Deployed { + continue + } + if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil { + if hddPath := appCfg.Env["HDD_PATH"]; hddPath != "" { + for _, sp := range storagePaths { + if sp.Path == hddPath { + storageLabels[stack.Name] = sp.Label + break + } + } + } + } + } + data["StorageLabels"] = storageLabels + s.render(w, "stacks", data) } @@ -136,7 +173,19 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri data["AppPageURL"] = s.cfg.AppPageURL(meta.Slug) data["UserFields"] = meta.UserFacingFields() data["AutoFields"] = meta.AutoGeneratedFields() - data["StoragePaths"] = s.settings.GetSchedulableStoragePaths() + // Storage paths with free space info for deploy dropdown + var deployPaths []DeployStoragePath + for _, sp := range s.settings.GetSchedulableStoragePaths() { + dp := DeployStoragePath{StoragePath: sp} + if di := system.GetDiskUsage(sp.Path); di != nil { + dp.FreeHuman = formatFreeSpace(di.AvailGB) + if di.TotalGB > 0 { + dp.FreePercent = di.AvailGB / di.TotalGB * 100 + } + } + deployPaths = append(deployPaths, dp) + } + data["StoragePaths"] = deployPaths // Memory info for deploy page (only for non-deployed apps) if !alreadyDeployed { @@ -268,6 +317,22 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) { fullStatus.FlashError = flashErr } + // Enrich AppDataInfo with storage labels + storagePaths := s.settings.GetStoragePaths() + for i := range fullStatus.AppDataInfo { + app := &fullStatus.AppDataInfo[i] + if len(app.HDDPaths) > 0 { + hddPath := app.HDDPaths[0].HostPath + // Match HDD path prefix against registered storage paths + for _, sp := range storagePaths { + if strings.HasPrefix(hddPath, sp.Path) { + app.StorageLabel = sp.Label + break + } + } + } + } + data["Backup"] = fullStatus // Restic password for display @@ -365,8 +430,10 @@ func (s *Server) settingsData() map[string]interface{} { view := StoragePathView{ StoragePath: sp, IsMounted: system.IsMountPoint(sp.Path), - AppCount: s.countAppsUsingPath(sp.Path), + AppDetails: s.appDetailsForPath(sp.Path), + FSInfo: system.GetFSInfo(sp.Path), } + view.AppCount = len(view.AppDetails) if di := system.GetDiskUsage(sp.Path); di != nil { view.DiskInfo = di } @@ -377,8 +444,12 @@ func (s *Server) settingsData() map[string]interface{} { return data } -func (s *Server) settingsHandler(w http.ResponseWriter, _ *http.Request) { - s.render(w, "settings", s.settingsData()) +func (s *Server) settingsHandler(w http.ResponseWriter, r *http.Request) { + data := s.settingsData() + if msg := r.URL.Query().Get("storage_msg"); msg == "success" { + data["StorageSuccess"] = r.URL.Query().Get("storage_detail") + } + s.render(w, "settings", data) } func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request) { @@ -548,6 +619,68 @@ func (s *Server) appsUsingPath(storagePath string) []string { return names } +func (s *Server) appDetailsForPath(storagePath string) []StorageAppDetail { + var details []StorageAppDetail + for _, stack := range s.stackMgr.GetStacks() { + if !stack.Deployed { + continue + } + appCfg := s.stackMgr.LoadAppConfigByName(stack.Name) + if appCfg == nil { + continue + } + hddPath := appCfg.Env["HDD_PATH"] + if hddPath != storagePath { + continue + } + detail := StorageAppDetail{ + Name: stack.Meta.DisplayName, + Stack: stack.Meta.Slug, + } + // Try to get data size from the storage subdirectory + appDataDir := filepath.Join(storagePath, "storage", stack.Name) + if fi, err := os.Stat(appDataDir); err == nil && fi.IsDir() { + detail.SizeHuman = dirSizeHuman(appDataDir) + } + details = append(details, detail) + } + return details +} + +// dirSizeHuman returns a human-readable size for a directory. +func dirSizeHuman(path string) string { + var total int64 + filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + total += info.Size() + return nil + }) + const ( + KB = 1024 + MB = KB * 1024 + GB = MB * 1024 + ) + switch { + case total >= GB: + return fmt.Sprintf("%.1f GB", float64(total)/float64(GB)) + case total >= MB: + return fmt.Sprintf("%.1f MB", float64(total)/float64(MB)) + case total >= KB: + return fmt.Sprintf("%.1f KB", float64(total)/float64(KB)) + default: + return fmt.Sprintf("%d B", total) + } +} + +func formatFreeSpace(gb float64) string { + if gb >= 1000 { + return fmt.Sprintf("%.1f TB", gb/1024) + } + return fmt.Sprintf("%.1f GB", gb) +} + func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() @@ -613,7 +746,7 @@ func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Reques } s.logger.Printf("[INFO] Storage path added: %s (%s)", path, label) - http.Redirect(w, r, "/settings", http.StatusFound) + http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló sikeresen hozzáadva: "+path), http.StatusFound) } func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Request) { @@ -653,7 +786,7 @@ func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Req } s.logger.Printf("[INFO] Storage path removed: %s", path) - http.Redirect(w, r, "/settings", http.StatusFound) + http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló eltávolítva: "+path), http.StatusFound) } func (s *Server) settingsStorageDefaultHandler(w http.ResponseWriter, r *http.Request) { @@ -662,8 +795,10 @@ func (s *Server) settingsStorageDefaultHandler(w http.ResponseWriter, r *http.Re if err := s.settings.SetDefaultStoragePath(path); err != nil { s.logger.Printf("[ERROR] Failed to set default storage path: %v", err) + http.Redirect(w, r, "/settings", http.StatusFound) + return } - http.Redirect(w, r, "/settings", http.StatusFound) + http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Alapértelmezett adattároló beállítva: "+path), http.StatusFound) } func (s *Server) settingsStorageSchedulableHandler(w http.ResponseWriter, r *http.Request) { @@ -673,6 +808,32 @@ func (s *Server) settingsStorageSchedulableHandler(w http.ResponseWriter, r *htt if err := s.settings.SetSchedulable(path, schedulable); err != nil { s.logger.Printf("[ERROR] Failed to update schedulable: %v", err) + http.Redirect(w, r, "/settings", http.StatusFound) + return } - http.Redirect(w, r, "/settings", http.StatusFound) + http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló állapot módosítva: "+path), http.StatusFound) +} + +func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + path := r.FormValue("storage_path") + label := strings.TrimSpace(r.FormValue("storage_label")) + + if label == "" || len(label) > 50 { + data := s.settingsData() + data["StorageError"] = "A megnevezés nem lehet üres és legfeljebb 50 karakter." + s.render(w, "settings", data) + return + } + + if err := s.settings.SetStorageLabel(path, label); err != nil { + s.logger.Printf("[ERROR] Failed to set storage label: %v", err) + data := s.settingsData() + data["StorageError"] = "Hiba a megnevezés mentésekor." + s.render(w, "settings", data) + return + } + + s.logger.Printf("[INFO] Storage label updated: %s → %q", path, label) + http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Megnevezés módosítva: "+label), http.StatusFound) } diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 4593cf8..73aa719 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -102,6 +102,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.settingsStorageDefaultHandler(w, r) case path == "/settings/storage/schedulable" && r.Method == http.MethodPost: s.settingsStorageSchedulableHandler(w, r) + case path == "/settings/storage/label" && r.Method == http.MethodPost: + s.settingsStorageLabelHandler(w, r) case path == "/settings/app-backup" && r.Method == http.MethodPost: s.settingsAppBackupHandler(w, r) case path == "/backup/restore" && r.Method == http.MethodPost: diff --git a/controller/internal/web/templates/backups.html b/controller/internal/web/templates/backups.html index f7b3bfe..1848568 100644 --- a/controller/internal/web/templates/backups.html +++ b/controller/internal/web/templates/backups.html @@ -254,9 +254,10 @@ {{.DisplayName}} {{end}} - {{if .HasHDDData}} - {{.HDDSizeHuman}} (HDD) - {{end}} +
+ {{if .StorageLabel}}{{.StorageLabel}}{{end}} + {{if .HasHDDData}}{{.HDDSizeHuman}}{{end}} +
{{range .HDDPaths}} diff --git a/controller/internal/web/templates/deploy.html b/controller/internal/web/templates/deploy.html index b722dfb..69119b5 100644 --- a/controller/internal/web/templates/deploy.html +++ b/controller/internal/web/templates/deploy.html @@ -117,11 +117,18 @@ {{else if eq .Type "path"}} {{if $.StoragePaths}} + {{else}} {{template "layout_end" .}} {{end}} diff --git a/controller/internal/web/templates/stacks.html b/controller/internal/web/templates/stacks.html index edbffd6..adf6429 100644 --- a/controller/internal/web/templates/stacks.html +++ b/controller/internal/web/templates/stacks.html @@ -43,6 +43,7 @@ {{if .Meta.Resources.MemRequest}}~{{.Meta.Resources.MemRequest}}{{end}} {{if .Meta.Resources.PiCompatible}}Pi kompatibilis{{end}} {{if .Meta.Resources.NeedsHDD}}HDD szükséges{{end}} + {{if and .Deployed (index $.StorageLabels .Name)}}💾 {{index $.StorageLabels .Name}}{{end}}
{{if .Containers}} diff --git a/controller/internal/web/templates/style.css b/controller/internal/web/templates/style.css index 9a39851..06aaa14 100644 --- a/controller/internal/web/templates/style.css +++ b/controller/internal/web/templates/style.css @@ -1147,6 +1147,10 @@ a.stat-card:hover { } .config-save-ok { color: var(--green); } .config-save-err { color: var(--red); } +.meta-badge-storage { + background: rgba(0, 136, 204, 0.12); + color: var(--accent-light); +} .meta-badge-warn { background: rgba(255, 152, 0, 0.1) !important; color: var(--orange) !important; @@ -1563,6 +1567,11 @@ a.stat-card:hover { color: var(--orange); border-color: rgba(219, 109, 40, 0.3); } +.monitoring-banner-warn { + background: rgba(255, 193, 7, 0.15); + border-left: 4px solid var(--yellow); + color: var(--yellow); +} .ping-status-ok { color: var(--green); } .ping-status-warn { color: var(--yellow); } .ping-schedule { @@ -2021,6 +2030,10 @@ a.stat-card:hover { .storage-path-disk { margin-bottom: .5rem; } +.storage-path-fsinfo { + font-size: .75rem; + margin-bottom: .35rem; +} .storage-path-meta { font-size: .8rem; color: var(--text-muted); @@ -2036,6 +2049,15 @@ a.stat-card:hover { font-size: .75rem; border-radius: 6px; } +.btn-ghost { + background: transparent; + border: none; + color: var(--text-muted); + padding: .1rem .3rem; +} +.btn-ghost:hover { + color: var(--accent-light); +} .btn-danger-outline { background: transparent; border: 1px solid rgba(218, 54, 51, 0.5); @@ -2045,6 +2067,34 @@ a.stat-card:hover { background: var(--red-bg); border-color: var(--red); } +.storage-app-details summary { + font-size: .85rem; +} +.storage-app-list { + margin-top: .5rem; + display: flex; + flex-direction: column; + gap: .25rem; +} +.storage-app-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: .2rem .5rem; + font-size: .85rem; +} +.storage-app-link { + color: var(--accent-light); + text-decoration: none; +} +.storage-app-link:hover { + text-decoration: underline; +} +.storage-path-label-wrap { + display: flex; + align-items: center; + gap: .25rem; +} .storage-add-details { margin-top: .5rem; }