From 61d8451c6966be0592742d8e227caf7f25eb0c5d Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Tue, 17 Feb 2026 09:35:15 +0100 Subject: [PATCH] =?UTF-8?q?Phase=20B=20=E2=80=94=20Storage=20Management=20?= =?UTF-8?q?UI=20Polish=20&=20Health=20Severity=20Fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TASK.md | 951 ++++++++++++++++++++++---------------------------------- 1 file changed, 366 insertions(+), 585 deletions(-) diff --git a/TASK.md b/TASK.md index b42d91d..731de42 100644 --- a/TASK.md +++ b/TASK.md @@ -1,686 +1,467 @@ -# TASK: Phase A — Storage Paths Foundation & Backup Toggle Fix +# TASK: Phase B — Storage Management UI Polish & Health Severity Fix -**Version target:** controller 0.9.0 -**Repos:** `deploy-felhom-compose` (controller), `app-catalog-felhom.eu` (compose mount change) +**Version target:** controller 0.10.0 +**Repo:** `deploy-felhom-compose` (controller) ## Overview -Per-app backup toggles (implemented in v0.8.0) don't appear on the backup page. Root cause is two-layered: +Phase A (v0.9.0) delivered the storage paths foundation: registry in settings.json, auto-discovery, per-app HDD_PATH resolution, settings UI with CRUD, deploy dropdown, and health monitoring. All functional — but health check now FAILS on demo-felhom because `/mnt/hdd_placeholder` is (correctly) detected as not a real mount point. -1. **`controller.yaml` has no `paths.hdd_path`** → `m.cfg.Paths.HDDPath` is `""` → `ParseComposeHDDMounts` returns nil → `DiscoverAppData` finds zero HDD data → no backup toggles shown -2. **Even with a global hdd_path, the parser uses ONE path** → apps deployed with a different `HDD_PATH` (e.g., `/mnt/hdd_placeholder` vs `/mnt/hdd_1`) won't match +**Immediate fix:** Health severity reclassification — non-mount-point is a **warning**, not an **issue** that causes FAIL. The FAIL status should be reserved for genuinely broken things (services down, disk critically full, backup failing), not informational findings. -The fix: stop relying on a single global HDD path. Instead, read each app's **own** `HDD_PATH` from its `app.yaml` env section, and introduce a storage paths registry in `settings.json` that auto-discovers paths from deployed apps. +Phase B then polishes the UI and fills gaps: -This phase also lays the foundation for multi-storage management (Phase B: UI polish, Phase C: migration wizard). +1. **Health severity fix** — mount-point check: warning not issue +2. **Success flash messages** — storage operations only show errors, never success +3. **Edit labels** — can't rename a storage path after adding it +4. **App names per storage path** — settings page shows count, not which apps +5. **Per-app storage info on stacks page** — no visibility into which storage each app uses +6. **Deploy dropdown enhancements** — show free space, disk usage warning +7. **Filesystem & disk info** — show ext4/btrfs, device, model on settings page +8. **Backup page: storage path context** — show which storage path each app is on --- -## 1. Root Cause Analysis +## 0. Health Severity Fix (URGENT — do first) -### Current broken flow +### 0.1 Problem -``` -controller.yaml → paths.hdd_path = "" (missing) - ↓ -backup.Manager.stackProvider.GetStackHDDMounts(name) - → stacks.ParseComposeHDDMounts(composePath, hddPath="") - → hddPath == "" → return nil ← BUG: exits early - ↓ -DiscoverAppData → info.HasHDDData = false for ALL apps - ↓ -backups.html → {{if .HasHDDData}} → false → no checkbox rendered -``` - -### Second layer (would hit even with hdd_path set) - -``` -controller.yaml → paths.hdd_path = "/mnt/hdd_1" - ↓ -ParseComposeHDDMounts(composePath, "/mnt/hdd_1") - → reads compose: "- ${HDD_PATH}/storage/immich:/usr/src/app/upload" - → resolves ${HDD_PATH} → "/mnt/hdd_1/storage/immich" - → BUT app.yaml has HDD_PATH="/mnt/hdd_placeholder" - → actual data is at /mnt/hdd_placeholder/storage/immich - → os.Stat("/mnt/hdd_1/storage/immich") → not found - → path.Exists = false, SizeBytes = 0 -``` - -### Correct approach - -Read each app's actual `HDD_PATH` from its `app.yaml` env section. The compose template uses `${HDD_PATH}` which gets resolved at `docker compose up` time from the environment. We need to resolve it the same way during discovery. - ---- - -## 2. Storage Paths Registry - -### 2.1 New struct in `settings.go` +`checkStoragePaths()` in `healthcheck.go` currently classifies non-mount-point as an **issue**: ```go -// StoragePath represents a registered external storage location. -type StoragePath struct { - Path string `json:"path"` // e.g., "/mnt/hdd_1" - Label string `json:"label,omitempty"` // e.g., "Külső HDD 1TB" - IsDefault bool `json:"is_default,omitempty"` // new apps use this by default - Schedulable bool `json:"schedulable"` // whether new apps can be deployed here - AddedAt string `json:"added_at"` // RFC3339 +// CURRENT (line ~6751): +if !system.IsMountPoint(sp.Path) { + issues = append(issues, fmt.Sprintf("Storage path %s is NOT a mount point — data writes to SSD!", sp.Path)) } ``` -### 2.2 Add to Settings struct +Issues → `status = "fail"` → Healthchecks shows FAIL → Healthchecks triggers alert → hub shows "STATUS: FAIL". This cascades into a false alarm for any setup where the storage path is intentionally on SSD (demo environments, test environments, customers who haven't connected an external drive yet). + +### 0.2 Fix: Warning + Hungarian message ```go -type Settings struct { - // ... existing fields ... - - // Storage paths registry - StoragePaths []StoragePath `json:"storage_paths,omitempty"` +// FIXED: +if !system.IsMountPoint(sp.Path) { + warnings = append(warnings, fmt.Sprintf( + "Storage path %s is not a separate mount point — data is stored on the system drive", + sp.Path)) } ``` -### 2.3 Helper methods on Settings +Health status becomes `"warn"` instead of `"fail"`. The warning still appears on: +- Controller monitoring page (red banner → yellow banner) +- Hub customer detail page (Issues → Warnings section) +- Healthchecks ping body (status: WARN instead of FAIL) + +### 0.3 When should non-mount-point be an ISSUE? + +In the future (Phase C or later), consider an "acknowledged" flag per storage path: +- When adding a path that's not a mount point, show a confirmation dialog: "Ez az útvonal a rendszermeghajtón van. Biztosan folytatja?" +- If acknowledged, the health check uses warning level +- If a previously-mount-point path STOPS being a mount point (drive disconnected), that IS an issue — it means something changed unexpectedly + +For now, the simple severity downgrade to warning is sufficient. The informational value is preserved, without false alarms. + +### 0.4 Also: Hungarian messages in health check + +Currently health messages are in English: +- "Storage path /mnt/hdd_placeholder is NOT a mount point — data writes to SSD!" +- "Storage path not accessible: ..." +- "Storage ... nearly full: ..." + +These appear on the customer-facing monitoring page. Change to Hungarian: ```go -// GetStoragePaths returns all registered storage paths. -func (s *Settings) GetStoragePaths() []StoragePath +// Path not accessible +warnings = append(warnings, fmt.Sprintf("Adattároló nem elérhető: %s", sp.Path)) -// GetDefaultStoragePath returns the default storage path, or "" if none. -func (s *Settings) GetDefaultStoragePath() string +// Not a mount point +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)) -// GetSchedulableStoragePaths returns paths where new apps can be deployed. -func (s *Settings) GetSchedulableStoragePaths() []StoragePath +// Disk usage critical (≥95%) — this stays as issue +issues = append(issues, fmt.Sprintf("Adattároló majdnem megtelt: %s (%.0f%%)", sp.Path, di.UsedPercent)) -// AddStoragePath registers a new storage path after validation. -func (s *Settings) AddStoragePath(path, label string, isDefault bool) error - -// RemoveStoragePath removes a path only if no apps reference it. -// Returns error with list of apps using this path if removal is blocked. -func (s *Settings) RemoveStoragePath(path string, appChecker func(string) []string) error - -// SetDefaultStoragePath changes which path is the default. -func (s *Settings) SetDefaultStoragePath(path string) error - -// SetSchedulable enables/disables a path for new deployments. -func (s *Settings) SetSchedulable(path string, schedulable bool) error +// Disk usage high (≥90%) — warning +warnings = append(warnings, fmt.Sprintf("Adattároló használat magas: %s (%.0f%%)", sp.Path, di.UsedPercent)) ``` -### 2.4 Validation rules (in AddStoragePath) +Note: Hub and Healthchecks receive the raw text. Hub is operator-facing (English would also be fine there), but since the same messages show on the customer controller, Hungarian is better for consistency. -1. **Must be a real mount point** — compare device ID of path vs parent using `syscall.Stat`. Reject paths that are just directories on the boot SSD. Error message: "Ez az útvonal nem külön csatlakoztatott meghajtó. Adatok az SSD-re kerülnének." -2. **Must exist and be a directory** — `os.Stat` check -3. **Must be writable** — attempt to create + remove a temp file -4. **No overlapping paths** — reject if new path is a parent or child of an existing path (e.g., `/mnt/hdd_1` and `/mnt/hdd_1/subdir`) -5. **No duplicates** — reject if path already registered (normalized with `filepath.Clean`) -6. **Must be under `/mnt/`** — soft warning, not hard block (log a WARN for edge cases) +### 0.5 Monitoring page banner color -### 2.5 Auto-discovery on startup - -On controller startup, if `StoragePaths` is empty in settings.json, auto-discover from deployed apps: - -```go -func (s *Settings) AutoDiscoverStoragePaths(stacksDir string, fallbackHDDPath string, logger *log.Logger) { - if len(s.StoragePaths) > 0 { - return // already configured, don't override - } - - // Scan all deployed apps' app.yaml for HDD_PATH values - seen := map[string]bool{} - entries, _ := os.ReadDir(stacksDir) - for _, e := range entries { - if !e.IsDir() { continue } - appCfg := LoadAppConfig(filepath.Join(stacksDir, e.Name())) - if appCfg == nil || !appCfg.Deployed { continue } - if hddPath, ok := appCfg.Env["HDD_PATH"]; ok && hddPath != "" { - cleaned := filepath.Clean(hddPath) - if !seen[cleaned] { - seen[cleaned] = true - } - } - } - - // Also use controller.yaml paths.hdd_path as fallback seed - if fallbackHDDPath != "" { - cleaned := filepath.Clean(fallbackHDDPath) - if !seen[cleaned] { - seen[cleaned] = true - } - } - - // Register discovered paths - isFirst := true - for path := range seen { - sp := StoragePath{ - Path: path, - Label: inferStorageLabel(path), - IsDefault: isFirst, - Schedulable: true, - AddedAt: time.Now().UTC().Format(time.RFC3339), - } - s.StoragePaths = append(s.StoragePaths, sp) - isFirst = false - } - - if len(s.StoragePaths) > 0 { - s.Save() - logger.Printf("[INFO] Auto-discovered %d storage path(s)", len(s.StoragePaths)) - for _, sp := range s.StoragePaths { - logger.Printf("[INFO] %s (%s) default=%v", sp.Path, sp.Label, sp.IsDefault) - } - } -} -``` - -Helper `inferStorageLabel(path)`: -- `/mnt/hdd_1` → "Külső tárhely (hdd_1)" -- `/mnt/hdd_placeholder` → "Külső tárhely (hdd_placeholder)" -- Anything else → path basename - -Note: `LoadAppConfig` is in the `stacks` package. To avoid circular imports, either pass a scanner function or do the scanning in `main.go` and pass the discovered paths in. Use whichever approach avoids import issues. - ---- - -## 3. Fix `GetStackHDDMounts` — Per-App HDD_PATH Resolution - -### 3.1 Change the adapter - -Currently: -```go -func (a *stackAdapter) GetStackHDDMounts(name string) []string { - s, ok := a.mgr.GetStack(name) - if !ok { return nil } - return stacks.ParseComposeHDDMounts(s.ComposePath, a.hddPath) // ← uses global path -} -``` - -Change to: -```go -func (a *stackAdapter) GetStackHDDMounts(name string) []string { - s, ok := a.mgr.GetStack(name) - if !ok { return nil } - - // Priority 1: Read the app's own HDD_PATH from its app.yaml - stackDir := filepath.Dir(s.ComposePath) - appCfg := stacks.LoadAppConfig(stackDir) - if appCfg != nil && appCfg.Env["HDD_PATH"] != "" { - return stacks.ParseComposeHDDMounts(s.ComposePath, appCfg.Env["HDD_PATH"]) - } - - // Priority 2: Try all registered storage paths (fallback) - var allMounts []string - seen := map[string]bool{} - for _, sp := range a.getStoragePaths() { - mounts := stacks.ParseComposeHDDMounts(s.ComposePath, sp.Path) - for _, m := range mounts { - if !seen[m] { - seen[m] = true - allMounts = append(allMounts, m) - } - } - } - return allMounts -} -``` - -### 3.2 Update adapter struct - -```go -type stackAdapter struct { - mgr *stacks.Manager - getStoragePaths func() []settings.StoragePath // getter from settings -} -``` - -Wire in `main.go`: -```go -adapter := &stackAdapter{ - mgr: stackMgr, - getStoragePaths: func() []settings.StoragePath { return settingsMgr.GetStoragePaths() }, -} -``` - -Remove the old `hddPath string` field from the adapter. - -### 3.3 `resolveAppBackupPaths()` in backup Manager - -No changes needed — it already calls `m.stackProvider.GetStackHDDMounts(stackName)` which we're fixing above. The fix propagates automatically. - ---- - -## 4. Controller Docker Compose Mount Change - -### 4.1 Change in `controller/docker-compose.yml` - -Replace: -```yaml - - ${HDD_PATH:-/mnt/hdd_placeholder}:${HDD_PATH:-/mnt/hdd_placeholder}:ro -``` - -With: -```yaml - # All external storage — covers /mnt/* paths for multi-storage support + restore - - /mnt:/mnt:rw -``` - -**Why `:rw`:** Required for restore feature (already implemented). Also needed for mount-point validation (write test in AddStoragePath). - -**Why `/mnt:/mnt`:** All standard storage mounts are under `/mnt`. Covers current and future drives with one entry. No controller restart when adding new drives. - -### 4.2 Also update `docker-setup.sh` - -If `docker-setup.sh` generates the controller compose, update it to emit `/mnt:/mnt:rw` instead of the old `${HDD_PATH}` line. - ---- - -## 5. Deploy Page: Storage Path Dropdown - -### 5.1 When app has a `path` type deploy field (HDD_PATH) - -Currently renders as a plain text input. Change to dropdown when storage paths exist: +Currently the monitoring page shows issues as red banners. Warnings should be yellow/amber: ```html -{{if eq .Type "path"}} - {{if $.StoragePaths}} - - {{else}} - - - Nincs regisztrált adattároló. Adjon hozzá egyet a Beállítások oldalon. - - {{end}} +{{range .Warnings}} +
+ ⚠️ {{.}} +
{{end}} ``` -### 5.2 Pass storage paths to deploy page template data +Check if the monitoring template already differentiates issue vs warning banners. If not, add CSS class: -In the deploy handler: -```go -data["StoragePaths"] = settingsMgr.GetSchedulableStoragePaths() -``` - -### 5.3 Already-deployed: show current path read-only - -When `AlreadyDeployed == true`, the field is disabled. Show the actual `HDD_PATH` from `app.yaml` as the displayed value (existing behavior works as-is with a disabled select or text input). - -### 5.4 Edge case: no schedulable paths but app needs HDD - -If `resources.needs_hdd: true` and zero schedulable paths: -- Show warning: "Nincs elérhető adattároló. Csatlakoztasson külső meghajtót és adja hozzá a Beállítások oldalon." -- Disable deploy button (add `data-needs-storage="true"` to form, JS disables submit) - ---- - -## 6. Storage Path Monitoring Integration - -### 6.1 Periodic mount-point check - -Add to existing system health check (runs every `system_health_interval`, default 5m): - -```go -func checkStoragePaths(paths []settings.StoragePath, logger *log.Logger) []string { - var warnings []string - for _, sp := range paths { - // Check 1: path exists and is directory - fi, err := os.Stat(sp.Path) - if err != nil { - warnings = append(warnings, fmt.Sprintf("Adattároló nem elérhető: %s", sp.Path)) - continue - } - if !fi.IsDir() { - warnings = append(warnings, fmt.Sprintf("Adattároló nem mappa: %s", sp.Path)) - continue - } - - // Check 2: still a real mount point (not writing to SSD) - if !isMountPoint(sp.Path) { - warnings = append(warnings, - fmt.Sprintf("FIGYELEM: %s nincs felcsatolva (leválasztva?) — "+ - "az adatok az SSD-re íródnának!", sp.Path)) - } - - // Check 3: disk space (reuse existing threshold config) - usage := getDiskUsage(sp.Path) - if usage.UsedPercent >= 90 { - warnings = append(warnings, - fmt.Sprintf("Adattároló majdnem tele: %s (%.0f%%)", sp.Path, usage.UsedPercent)) - } - } - return warnings +```css +.monitoring-banner-warn { + background: rgba(255, 193, 7, 0.15); + border-left: 4px solid var(--yellow); + color: var(--yellow); } ``` -### 6.2 `isMountPoint()` implementation +--- -New file: `controller/internal/system/mounts.go` +## 1. Success Flash Messages for Storage Operations +### 1.1 Problem + +All storage handlers (`/settings/storage/add`, `/remove`, `/default`, `/schedulable`) only set `StorageError` on failure. On success they redirect without feedback. + +### 1.2 Fix: Query param flash (consistent with backup page) + +Use the existing backup page pattern: redirect with query params `?storage_msg=success&storage_detail=...` + +In settings handler, parse: ```go -// IsMountPoint checks if a path is a mount point by comparing device IDs. -// Returns true if path is on a different device than its parent. -func IsMountPoint(path string) bool { - var pathStat, parentStat syscall.Stat_t - if err := syscall.Stat(path, &pathStat); err != nil { - return false - } - parent := filepath.Dir(path) - if err := syscall.Stat(parent, &parentStat); err != nil { - return false - } - return pathStat.Dev != parentStat.Dev +if msg := r.URL.Query().Get("storage_msg"); msg == "success" { + data["StorageSuccess"] = r.URL.Query().Get("storage_detail") } ``` -Platform-specific: needs build tag `//go:build linux` (consistent with existing system package). Add stub for non-Linux that always returns true. +Success messages: +- **Add:** "Adattároló sikeresen hozzáadva: /mnt/hdd_1" +- **Remove:** "Adattároló eltávolítva: /mnt/hdd_1" +- **Set default:** "Alapértelmezett adattároló beállítva: /mnt/hdd_1" +- **Toggle schedulable:** "Adattároló állapot módosítva: /mnt/hdd_1" -### 6.3 Surface warnings +### 1.3 Template -- Add storage warnings to monitoring page warnings section (existing infrastructure) -- Include in hub report `SystemWarnings` field (already exists) -- Fire `disk_warning` notification event for enabled customers +Add to settings.html (after `StorageError`): +```html +{{if .StorageSuccess}}
{{.StorageSuccess}}
{{end}} +``` --- -## 7. Beállítások Page: Storage Section +## 2. Edit Storage Path Labels -Add between "Rendszer konfiguráció" and "Jelszó módosítás" sections. +### 2.1 UI: Inline edit -### 7.1 Template: "Adattárolók" section +Add edit button next to label. JS toggles between display and inline form: ```html - -
-

Adattárolók

-

- Külső meghajtók, ahol az alkalmazások felhasználói adatai tárolódnak. -

- - {{if .StorageError}}
{{.StorageError}}
{{end}} - {{if .StorageSuccess}}
{{.StorageSuccess}}
{{end}} - - {{if .StoragePaths}} -
- {{range .StoragePaths}} -
-
-
- {{.Label}} - {{.Path}} -
-
- {{if .IsDefault}}alapértelmezett{{end}} - {{if not .Schedulable}}nem ütemezhet{{end}} - {{if .DiskInfo}} - - {{.DiskInfo.UsedHuman}} / {{.DiskInfo.TotalHuman}} ({{.DiskInfo.UsedPercent}}%) - - {{end}} -
-
- {{if .AppCount}} -
- {{.AppCount}} alkalmazás használja -
- {{end}} -
- {{if not .IsDefault}} -
- - -
- {{end}} -
- - - -
- {{if and (not .IsDefault) (eq .AppCount 0)}} -
- - -
- {{end}} -
-
- {{end}} -
- {{else}} -
- Nincs regisztrált adattároló. Csatlakoztasson külső meghajtót és adja hozzá alább. -
- {{end}} - - -
- + Új adattároló hozzáadása -
-
- - - A felcsatolt meghajtó elérési útja (pl. /mnt/hdd_1) -
-
- - -
-
- -
- -
-
+
+ {{.Label}} +
``` -### 7.2 Routes +JS `editStorageLabel()` replaces content with: +```html +
+ + + + +
+``` -| Method | Path | Auth? | Handler | -|--------|------|-------|---------| -| POST | `/settings/storage/add` | Yes | Add storage path with validation | -| POST | `/settings/storage/remove` | Yes | Remove (blocked if apps use it) | -| POST | `/settings/storage/default` | Yes | Set default storage path | -| POST | `/settings/storage/schedulable` | Yes | Toggle schedulable on/off | +### 2.2 Route & handler -### 7.3 Handler: `/settings/storage/add` +| Method | Path | Auth? | +|--------|------|-------| +| POST | `/settings/storage/label` | Yes | -1. Parse form: `storage_path`, `storage_label`, `storage_default` -2. Clean path with `filepath.Clean` -3. Validate (Section 2.4): - - Exists + is directory → error: "Az útvonal nem létezik vagy nem mappa." - - Is mount point → error: "Ez az útvonal nem külön csatlakoztatott meghajtó. Adatok az SSD-re kerülnének!" - - Writable → error: "Az útvonal nem írható." - - No overlap → error: "Az útvonal átfedi a már regisztrált XYZ útvonalat." - - No duplicate → error: "Ez az útvonal már regisztrálva van." -4. If `storage_default`, unset previous default -5. Add to settings.json via `AddStoragePath()` -6. Redirect to `/settings` with flash: "Adattároló sikeresen hozzáadva: /mnt/hdd_1" -7. On error: redirect with error flash +Handler: parse `storage_path` + `storage_label`, validate (non-empty, max 50 chars), call `settings.SetStorageLabel()`, redirect with success flash. -### 7.4 Handler: `/settings/storage/remove` +### 2.3 Settings method -1. Parse form: `storage_path` -2. Count apps using this path (scan app.yaml files for `HDD_PATH`) -3. If apps found → error: "Nem törölhető: az alábbi alkalmazások használják: Immich, Paperless-ngx" -4. Cannot be the default path → error: "Az alapértelmezett adattároló nem törölhető." -5. If only one path left → error: "Az utolsó adattároló nem törölhető." -6. Remove from settings.json -7. Redirect with success flash - -### 7.5 Template data for settings page - -Add to the settings handler: ```go +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) +} +``` + +--- + +## 3. App Names Per Storage Path (Settings Page) + +### 3.1 Current: "3 alkalmazás használja" — no names + +### 3.2 Enhancement: Expandable list with names + sizes + +Extend `StoragePathView`: +```go +type StorageAppDetail struct { + Name string // Display name (e.g., "Immich") + Stack string // Stack name (for link) + SizeHuman string // Data size on this path +} + type StoragePathView struct { - settings.StoragePath - DiskInfo *DiskUsageInfo // total, used, percent - AppCount int // number of deployed apps with HDD_PATH matching this - IsMounted bool // mount-point check result + // ... existing fields ... + AppDetails []StorageAppDetail // NEW } ``` -Populate by scanning `app.yaml` files for `HDD_PATH` and matching against registered paths. +Template: +```html +
+ {{if .AppDetails}} +
+ + {{.AppCount}} alkalmazás használja + +
+ {{range .AppDetails}} +
+ {{.Name}} + {{.SizeHuman}} +
+ {{end}} +
+
+ {{else}} + Nincs alkalmazás ezen a tárolón + {{end}} +
+``` + +### 3.3 Populate in handler + +Scan deployed app.yaml files, match `HDD_PATH` against registered paths, collect display names + data sizes via `GetStackHDDData()`. --- -## 8. Deprecate `paths.hdd_path` from controller.yaml +## 4. Per-App Storage Badge on Stacks Page -### 8.1 Backward compatibility +### 4.1 Current: No info about which storage path a deployed app uses -Keep reading `paths.hdd_path` — use it only as fallback seed for auto-discovery on first run. +### 4.2 Enhancement: Badge on deployed app cards + +```html +{{if and .Deployed .StoragePath}} + + 💾 {{.StorageLabel}} + +{{end}} +``` + +### 4.3 Data source + +Add `StoragePath` and `StorageLabel` to stack view model. Populate from `app.yaml` `HDD_PATH` + lookup against registered storage paths for the label. + +### 4.4 CSS + +```css +.meta-badge-storage { + background: rgba(0, 136, 204, 0.12); + color: var(--accent-light); +} +``` + +--- + +## 5. Deploy Dropdown Enhancements + +### 5.1 Current: "Külső tárhely (hdd_placeholder) (/mnt/hdd_placeholder)" + +### 5.2 Enhancement A: Show free space in option text + +```html + +``` + +### 5.3 Enhancement B: Low-space warning on selection + +```html + +``` + +JS: on dropdown change, read `data-free-percent` from selected option. Show warning if < 20%. + +### 5.4 Data struct -Startup order in `main.go`: ```go -// 1. Load settings.json -settingsMgr, _ := settings.Load(...) - -// 2. Auto-discover storage paths (if settings.json has none) -settingsMgr.AutoDiscoverStoragePaths(cfg.Paths.StacksDir, cfg.Paths.HDDPath, logger) - -// 3. Wire adapter with storage paths getter -adapter := &stackAdapter{ - mgr: stackMgr, - getStoragePaths: func() []settings.StoragePath { return settingsMgr.GetStoragePaths() }, +type DeployStoragePath struct { + settings.StoragePath + FreeHuman string // "234.5 GB" + FreePercent float64 // 67.5 } ``` -### 8.2 Update configs/controller.yaml.example +Populate via `system.GetDiskUsage(sp.Path)` in deploy handler. -```yaml -paths: - stacks_dir: "/opt/docker/stacks" - # hdd_path is DEPRECATED — storage paths are managed via web UI (Beállítások > Adattárolók) - # Existing value is auto-migrated to settings.json on first startup - # hdd_path: "/mnt/hdd_1" +--- + +## 6. Filesystem & Disk Info on Settings Page + +### 6.1 New function: `GetFSInfo(path)` + +In `system/mounts_linux.go`: + +```go +type FSInfo struct { + FSType string // "ext4", "btrfs" + Device string // "/dev/sda1" + Model string // "WD Elements 25A2" (from sysfs, best-effort) +} + +func GetFSInfo(path string) *FSInfo { + // findmnt -n -o SOURCE,FSTYPE --target + // /sys/block//device/model for disk model +} +``` + +Non-Linux stub returns nil. + +### 6.2 Template + +Below the disk usage bar: +```html +{{if .FSInfo}} +
+ {{.FSInfo.FSType}} · {{.FSInfo.Device}}{{if .FSInfo.Model}} · {{.FSInfo.Model}}{{end}} +
+{{end}} ``` --- -## 9. Implementation Steps +## 7. Backup Page: Storage Path Context -### Step 1: Storage paths in settings.json -- Add `StoragePath` struct and `StoragePaths` field to `Settings` -- Add all getter/setter methods -- Add `AutoDiscoverStoragePaths()` — note: may need to pass a scanner function or do scanning in main.go to avoid circular import with stacks package -- Add `inferStorageLabel()` helper -- **Test:** Manually create a `settings.json` with `storage_paths` → Load → verify GetStoragePaths returns them +### 7.1 Current: Paths shown like `/mnt/hdd_placeholder/storage/immich (92 MB)` — no context about which registered storage path -### Step 2: Mount-point validation utilities -- Create `controller/internal/system/mounts.go` with `IsMountPoint()` (Linux + stub) -- Add overlap check helper: `PathsOverlap(a, b string) bool` -- Add writable check helper: `IsWritable(path string) bool` -- **Test:** `IsMountPoint("/mnt/hdd_1")` → true, `IsMountPoint("/tmp")` → false +### 7.2 Enhancement: Storage label badge per app -### Step 3: Wire auto-discovery on startup -- In `main.go`: call `AutoDiscoverStoragePaths` after loading settings -- Pass `cfg.Paths.HDDPath` as fallback -- **Test:** Start controller on demo-felhom with empty settings.json → should auto-discover `/mnt/hdd_placeholder` from deployed apps' app.yaml +Add `StorageLabel` to `AppBackupInfo`: +```go +type AppBackupInfo struct { + // ... existing ... + StorageLabel string // NEW: resolved from registered storage paths +} +``` -### Step 4: Fix per-app HDD_PATH resolution (THE CORE FIX) -- Update `stackAdapter` struct: replace `hddPath string` with `getStoragePaths func()` -- Update `GetStackHDDMounts()` to read per-app HDD_PATH from app.yaml first, fallback to registered paths -- Wire updated adapter in `main.go` -- **Test:** Backup page → "Alkalmazás adatok" section now shows with correct paths (e.g., `/mnt/hdd_placeholder/storage/immich`). Backup toggles are visible and functional. +In template (backup section): +```html +
+ {{if .StorageLabel}}{{.StorageLabel}}{{end}} + {{.HDDSizeHuman}} +
+``` -### Step 5: Controller compose mount change -- Change `controller/docker-compose.yml`: `/mnt:/mnt:rw` -- Update `docker-setup.sh` controller compose generation -- **Test:** Recreate controller container → verify both `/mnt/hdd_1` and `/mnt/hdd_placeholder` accessible inside container - -### Step 6: Beállítások storage section -- Add "Adattárolók" section to `settings.html` -- Add handlers: `POST /settings/storage/add`, `/remove`, `/default`, `/schedulable` -- Add `StoragePathView` type for template data with disk info + app count -- Pass storage data to settings page handler -- Add CSS for storage path items -- **Test:** Add path via UI → validation catches non-mount-point. Remove path with apps → blocked with app list. Set default → badge updates. - -### Step 7: Deploy page dropdown -- Modify `deploy.html`: path field becomes dropdown when storage paths exist -- Pass `StoragePaths` to deploy page template data -- Fall back to text input if no paths registered -- Block deploy if app needs HDD but no schedulable paths -- **Test:** Deploy page for Immich → dropdown shows registered paths. Already-deployed shows current path read-only. - -### Step 8: Storage monitoring integration -- Add `checkStoragePaths()` to system health check -- Surface warnings on monitoring page -- Include in hub report -- Fire `disk_warning` notification for unmounted drives -- **Test:** (Simulated) If path doesn't exist → warning appears on monitoring page within one health check cycle - -### Step 9: Cleanup & version bump -- Deprecate `paths.hdd_path` in controller.yaml.example -- Create new CHANGELOG.md, changelogs will be updated there, not in CONTEXT.md. CONTEXT.md will only have information about architecture decisions, roadmap, information about the project -- Regenerate CONTEXT.md with current architecture, deparating different sections, with detailed descriptions how it should work, what is planned, what is the architecture. Sections like: Storage management, Notifications, Backup management, App management, Monitoring, Infra, Settings management, etc-etc.. -- Update CONTEXT.md / CHANGELOG.md / CLAUDE.md -- Bump to 0.9.0 -- Build + deploy -- **Test end-to-end:** Auto-discover → backup toggles visible → enable Immich backup → manual backup → verify HDD data in restic snapshot → storage management in Beállítások works +Populate by matching each app's HDD_PATH prefix against registered paths during `DiscoverAppData()`. --- -## 10. Files to Create / Modify +## 8. Implementation Steps -### New files: -- `controller/internal/system/mounts.go` — `IsMountPoint()`, `IsWritable()`, `PathsOverlap()` + non-Linux stub -- (No new packages — StoragePath goes in existing `settings` package) +### Step 0: Health severity fix (URGENT) +- In `checkStoragePaths()`: move mount-point check from `issues` to `warnings` +- Translate all storage health messages to Hungarian +- Verify monitoring page template differentiates warning vs issue banner colors +- Add `.monitoring-banner-warn` CSS if missing +- **Test:** Restart controller → monitoring page shows yellow warning instead of red. Healthchecks goes back to OK/WARN instead of FAIL. Hub shows warning under Warnings section, not Issues. + +### Step 1: Success flash messages +- Add query param flash parsing in settings handler +- Set success flash in all 4 storage handlers on successful redirect +- Add `{{if .StorageSuccess}}` to settings.html +- **Test:** Add storage path → green "Sikeresen hozzáadva" flash + +### Step 2: Edit labels +- Add `SetStorageLabel()` to settings.go +- Add `POST /settings/storage/label` route + handler +- Add JS `editStorageLabel()` / `cancelEditLabel()` to settings.html +- Update template with inline edit UI +- **Test:** Click ✏️ → input appears → change → save → label updated, flash shown + +### Step 3: App details per storage path +- Extend `StoragePathView` with `AppDetails []StorageAppDetail` +- Populate in settings handler (scan app.yaml + HDD data) +- Replace count with expandable `
` list in settings.html +- **Test:** Click "3 alkalmazás" → expands to show names + sizes with links + +### Step 4: Storage badge on stacks page +- Add `StoragePath`/`StorageLabel` to stack view model +- Populate from app.yaml + registered paths lookup +- Add badge to stacks.html + CSS +- **Test:** Immich card shows "💾 Külső tárhely" + +### Step 5: Deploy dropdown enhancements +- Create `DeployStoragePath` with free space data +- Populate via `GetDiskUsage` in deploy handler +- Update dropdown option text + `data-free-percent` attr +- Add JS for low-space warning +- **Test:** Dropdown shows "234 GB szabad" → select near-full → warning + +### Step 6: Filesystem info +- Add `GetFSInfo()` to `mounts_linux.go` (using `findmnt`) +- Add non-Linux stub +- Add to `StoragePathView` + template +- **Test:** Settings → "ext4 · /dev/sdb1 · WD Elements" + +### Step 7: Backup page storage context +- Add `StorageLabel` to `AppBackupInfo` +- Populate during `DiscoverAppData()` +- Add badge to backups.html +- **Test:** Backup page → Immich shows storage label badge + +### Step 8: Version bump & cleanup +- Update CHANGELOG.md / CONTEXT.md / CLAUDE.md / README.md +- Bump to 0.10.0 +- Build + deploy +- **Full test:** Health OK, all pages show storage context, deploy warns on low space + +--- + +## 9. Files to Create / Modify ### Modified files: -- `controller/internal/settings/settings.go` — `StoragePath` struct, `StoragePaths` field, all getter/setter methods, `AutoDiscoverStoragePaths()`, `inferStorageLabel()`, validation logic -- `controller/cmd/controller/main.go` — Wire auto-discovery, update stackAdapter struct (replace `hddPath` with `getStoragePaths`), update adapter constructor -- `controller/internal/web/handlers.go` — Storage management handlers (`/settings/storage/*`), pass StoragePaths to deploy + settings templates -- `controller/internal/web/server.go` — Register new storage routes -- `controller/internal/web/templates/settings.html` — New "Adattárolók" section -- `controller/internal/web/templates/deploy.html` — Path field → dropdown, edge case warnings -- `controller/internal/web/templates/style.css` — Storage path item styles, badges -- `controller/internal/monitoring/health.go` (or wherever health checks live) — Add `checkStoragePaths()` -- `controller/docker-compose.yml` — `/mnt:/mnt:rw` -- `controller/configs/controller.yaml.example` — Deprecation comment -- `docker-setup.sh` — Update controller compose generation +- `controller/internal/monitor/healthcheck.go` — **Step 0**: severity fix, Hungarian messages +- `controller/internal/settings/settings.go` — **Step 2**: `SetStorageLabel()` +- `controller/internal/system/mounts_linux.go` — **Step 6**: `GetFSInfo()`, `FSInfo` struct +- `controller/internal/system/mounts_other.go` — **Step 6**: `GetFSInfo()` stub +- `controller/internal/backup/appdata.go` — **Step 7**: `StorageLabel` in `AppBackupInfo` +- `controller/internal/web/handlers.go` — **Steps 1-7**: flash parsing, label handler, deploy/settings/stacks/backup data enrichment +- `controller/internal/web/server.go` — **Step 2**: register `/settings/storage/label` route +- `controller/internal/web/templates/settings.html` — **Steps 1-3,6**: flash, edit UI, app details, FS info +- `controller/internal/web/templates/stacks.html` — **Step 4**: storage badge +- `controller/internal/web/templates/deploy.html` — **Step 5**: free space in dropdown, warning +- `controller/internal/web/templates/backups.html` — **Step 7**: storage label badge +- `controller/internal/web/templates/monitoring.html` — **Step 0**: warning vs issue banner differentiation +- `controller/internal/web/templates/style.css` — **Steps 0,2,3,4**: warn banner, edit label, app list, storage badge --- -## 11. Design Decisions & Notes +## 10. Design Decisions -### Why settings.json, not controller.yaml? -controller.yaml is operator-controlled (read-only from customer perspective). User-configurable state lives in settings.json. Clear separation: operator configures infrastructure, customer configures preferences. +### Why downgrade mount-point to warning instead of removing the check? +The check is genuinely useful — it detects USB drives that got disconnected, or misconfigured storage. But it's informational, not a service-affecting failure. The customer can still use the system; they just should know their data location. A FAIL status implies something is actively broken and needs immediate attention. -### Why auto-discover from app.yaml? -Existing deployments already have apps with HDD_PATH set. Auto-discovery makes the upgrade seamless — backup toggles appear without manual intervention after the update. +### Why not add an "acknowledge" mechanism now? +It adds UI complexity (modal confirmation, per-path flag in settings.json, conditional severity logic). The warning downgrade solves the immediate false-alarm problem. An acknowledge system can be added in Phase C if needed, especially for the scenario where a previously-mounted drive disappears. -### Why `/mnt:/mnt:rw` instead of individual mounts? -Single mount covers all current and future storage devices. No controller restart when adding new drives. Required for restore + validation. Trade-off: broader access, but controller already has Docker socket access and is a trusted component. +### Why query param flashes instead of session-based? +No session store — consistent with backup page pattern. Stateless, simple. -### Why validate mount points? -If a USB drive is disconnected but the empty directory remains (created by Docker or `nofail` fstab), data silently writes to the SSD boot drive. Mount-point check (device ID comparison) catches this. This is the #1 gotcha for home server setups. - -### Why no overlapping paths? -If `/mnt/hdd_1` and `/mnt/hdd_1/storage` are both registered, discovery counts files twice and backup may include data twice. One entry per physical mount point keeps it simple. - -### Why fallback chain in GetStackHDDMounts? -1. App's own HDD_PATH from app.yaml (most accurate — the actual deployed value) -2. Try all registered storage paths (handles edge cases: missing app.yaml, new template before deploy) -Ensures backup discovery works even in degraded states. - -### Why `Schedulable` toggle? -An "unschedulable" path means existing apps stay put but no new apps can use it. Useful for drives that are filling up or being deprecated — prevents new deployments while allowing existing apps to continue operating. - -### Future phases (NOT in scope) -- **Phase B:** Storage management UI polish — disk usage bars per path, per-app storage column on apps page -- **Phase C:** Migration wizard — "Mozgatás" button per app, rsync with progress reporting, automatic app.yaml HDD_PATH update + restart, old data cleanup option \ No newline at end of file +### What's NOT in Phase B +- **Migration wizard (Phase C):** "Mozgatás" button, rsync with progress +- **Disk SMART health:** Needs smartmontools +- **Auto-detect new mounts:** inotify/polling for new /mnt/* — future +- **Acknowledge mechanism:** For known non-mount-point paths — future \ No newline at end of file