v0.10.0: Phase B — Storage Management UI Polish & Health Severity Fix

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 09:48:51 +01:00
parent 61d8451c69
commit 69698a89e8
15 changed files with 412 additions and 30 deletions
+13 -1
View File
@@ -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 `<details>` 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`.
+8 -2
View File
@@ -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 <path>` for filesystem type/device. `/sys/block/<dev>/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 |
+14 -4
View File
@@ -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)
+1
View File
@@ -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.
+6 -5
View File
@@ -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))
}
}
}
+13
View File
@@ -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).
@@ -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/<dev>/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))
}
@@ -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 }
+169 -8
View File
@@ -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
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?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?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)
}
+2
View File
@@ -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:
@@ -254,9 +254,10 @@
<span class="app-backup-name">{{.DisplayName}}</span>
</div>
{{end}}
{{if .HasHDDData}}
<span class="app-backup-size mono">{{.HDDSizeHuman}} (HDD)</span>
{{end}}
<div style="display:flex;align-items:center;gap:.5rem">
{{if .StorageLabel}}<span class="meta-badge meta-badge-storage">{{.StorageLabel}}</span>{{end}}
{{if .HasHDDData}}<span class="app-backup-size mono">{{.HDDSizeHuman}}</span>{{end}}
</div>
</div>
<div class="app-backup-details">
{{range .HDDPaths}}
+22 -2
View File
@@ -117,11 +117,18 @@
{{else if eq .Type "path"}}
{{if $.StoragePaths}}
<select id="field-{{.EnvVar}}" name="{{.EnvVar}}" class="form-control"
{{if $.AlreadyDeployed}}disabled{{end}}>
{{if $.AlreadyDeployed}}disabled{{end}}
onchange="checkStorageSpace(this)">
{{range $.StoragePaths}}
<option value="{{.Path}}">{{.Label}} ({{.Path}})</option>
<option value="{{.Path}}" data-free-percent="{{printf "%.0f" .FreePercent}}"
{{if .IsDefault}}selected{{end}}>
{{.Label}} — {{.FreeHuman}} szabad{{if .IsDefault}} ★{{end}}
</option>
{{end}}
</select>
<div id="storage-space-warn" class="form-hint" style="color:var(--yellow);display:none">
⚠️ A kiválasztott tárhely majdnem megtelt.
</div>
{{else}}
<input type="text" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
class="form-control" value="{{.Default}}"
@@ -177,6 +184,19 @@
</div>
<script>
function checkStorageSpace(sel) {
var opt = sel.options[sel.selectedIndex];
var warn = document.getElementById('storage-space-warn');
if (!warn) return;
var freePct = parseFloat(opt.getAttribute('data-free-percent') || '100');
warn.style.display = freePct < 20 ? 'block' : 'none';
}
// Check on page load
document.addEventListener('DOMContentLoaded', function() {
var sel = document.querySelector('select[onchange="checkStorageSpace(this)"]');
if (sel) checkStorageSpace(sel);
});
function generatePassword(fieldId) {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let pass = '';
@@ -69,6 +69,7 @@
<p class="settings-card-desc">Külső meghajtók kezelése alkalmazásadatok tárolásához.</p>
{{if .StorageError}}<div class="alert alert-error">{{.StorageError}}</div>{{end}}
{{if .StorageSuccess}}<div class="alert alert-info">{{.StorageSuccess}}</div>{{end}}
{{if .StoragePaths}}
<div class="storage-paths-list">
@@ -76,7 +77,10 @@
<div class="storage-path-item">
<div class="storage-path-header">
<div class="storage-path-info">
<span class="storage-path-label">{{.Label}}</span>
<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>
</div>
<span class="storage-path-path mono">{{.Path}}</span>
</div>
<div class="storage-path-badges">
@@ -97,8 +101,29 @@
</div>
</div>
{{end}}
{{if .FSInfo}}
<div class="storage-path-fsinfo mono form-hint">
{{.FSInfo.FSType}} · {{.FSInfo.Device}}{{if .FSInfo.Model}} · {{.FSInfo.Model}}{{end}}
</div>
{{end}}
<div class="storage-path-meta">
<span class="form-hint">{{.AppCount}} alkalmazás használja</span>
{{if .AppDetails}}
<details class="storage-app-details">
<summary class="form-hint" style="cursor:pointer">
{{.AppCount}} alkalmazás használja
</summary>
<div class="storage-app-list">
{{range .AppDetails}}
<div class="storage-app-row">
<a href="/apps/{{.Stack}}" class="storage-app-link">{{.Name}}</a>
{{if .SizeHuman}}<span class="mono form-hint">{{.SizeHuman}}</span>{{end}}
</div>
{{end}}
</div>
</details>
{{else}}
<span class="form-hint">Nincs alkalmazás ezen a tárolón</span>
{{end}}
</div>
</div>
<div class="storage-path-actions">
@@ -246,5 +271,24 @@
{{end}}
</div>
<script>
function editStorageLabel(path, currentLabel) {
var wrap = document.getElementById('label-wrap-' + path);
if (!wrap) return;
wrap.innerHTML = '<form method="POST" action="/settings/storage/label" style="display:inline-flex;gap:.5rem;align-items:center">' +
'<input type="hidden" name="storage_path" value="' + path + '">' +
'<input type="text" name="storage_label" class="form-control" value="' + currentLabel.replace(/"/g, '&quot;') + '" style="width:200px;padding:.3rem .5rem;font-size:.9rem" maxlength="50">' +
'<button type="submit" class="btn btn-xs btn-primary">OK</button>' +
'<button type="button" class="btn btn-xs btn-outline" onclick="cancelEditLabel(\'' + path + '\', \'' + currentLabel.replace(/'/g, "\\'") + '\')"></button>' +
'</form>';
wrap.querySelector('input[name=storage_label]').focus();
}
function cancelEditLabel(path, label) {
var wrap = document.getElementById('label-wrap-' + path);
if (!wrap) return;
wrap.innerHTML = '<span class="storage-path-label" id="label-display-' + path + '">' + label + '</span>' +
' <button class="btn btn-xs btn-ghost" onclick="editStorageLabel(\'' + path + '\', \'' + label.replace(/'/g, "\\'") + '\')" title="Átnevezés">✏️</button>';
}
</script>
{{template "layout_end" .}}
{{end}}
@@ -43,6 +43,7 @@
{{if .Meta.Resources.MemRequest}}<span class="meta-badge">~{{.Meta.Resources.MemRequest}}</span>{{end}}
{{if .Meta.Resources.PiCompatible}}<span class="meta-badge meta-badge-ok">Pi kompatibilis</span>{{end}}
{{if .Meta.Resources.NeedsHDD}}<span class="meta-badge">HDD szükséges</span>{{end}}
{{if and .Deployed (index $.StorageLabels .Name)}}<span class="meta-badge meta-badge-storage" title="Adattároló: {{index $.StorageLabels .Name}}">💾 {{index $.StorageLabels .Name}}</span>{{end}}
</div>
{{if .Containers}}
@@ -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;
}