v0.9.0: Storage paths registry, per-app HDD_PATH resolution, storage management UI
- Fix backup toggles not appearing (read each app's own HDD_PATH from app.yaml) - Storage paths registry in settings.json with auto-discovery from deployed apps - Settings page "Adattárolók" section with disk usage, add/remove/default/schedulable - Deploy page path field as dropdown of registered storage paths - Health check storage monitoring (mount point, disk usage alerts) - Mount-point validation utilities (Linux syscall + cross-platform stubs) - Controller docker-compose mount changed to /mnt:/mnt:rw for multi-storage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## v0.9.0 — Storage Paths Foundation & Backup Toggle Fix (2026-02-17)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Per-app backup toggles not appearing on backup page (root cause: missing global `hdd_path` in controller.yaml made `ParseComposeHDDMounts` return nil)
|
||||||
|
- Each app's HDD_PATH is now read from its own `app.yaml` env section instead of relying on a global config value
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Storage paths registry** — multiple external storage paths managed in `settings.json` with auto-discovery from deployed apps on startup
|
||||||
|
- **Settings page "Adattárolók" section** — view, add, remove, set default, toggle schedulable for storage paths. Disk usage bars, app counts, mount status badges
|
||||||
|
- **Deploy page storage dropdown** — `path` field type shows registered schedulable paths as dropdown instead of free-text input
|
||||||
|
- **Health check storage monitoring** — checks path accessibility, mount-point validation (warns if data would write to SSD), disk usage alerts at 90%/95%
|
||||||
|
- **Mount-point validation** — `IsMountPoint()`, `IsWritable()`, `PathsOverlap()`, `GetDiskUsage()` (Linux via syscall, stubs for other platforms)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Controller docker-compose mount changed from `${HDD_PATH}:${HDD_PATH}:ro` to `/mnt:/mnt:rw` for multi-storage + restore support
|
||||||
|
- `RunHealthCheck()` now accepts storage paths parameter for per-path monitoring
|
||||||
|
- `BuildReport()` accepts storage paths for accurate system info in hub reports
|
||||||
|
- Removed unused `hddPath` parameter from `DiscoverAppData()` signature
|
||||||
|
|
||||||
|
## v0.8.0 — Storage Overview, Per-App Backup Toggles & Limited Restore (2026-02-16)
|
||||||
|
|
||||||
|
- Storage overview on backup page (SSD/HDD progress bars + repo stats)
|
||||||
|
- Restic password visibility with show/copy for disaster recovery
|
||||||
|
- App data discovery (`DiscoverAppData()` with `StackDataProvider` interface)
|
||||||
|
- Per-app backup toggles (HDD data paths per app)
|
||||||
|
- Dynamic backup paths based on enabled apps
|
||||||
|
- Limited app restore from restic snapshots
|
||||||
|
- Flash messages on backup page
|
||||||
|
|
||||||
|
## v0.7.2 — Fix Notification Preferences Sync (2026-02-16)
|
||||||
|
|
||||||
|
- Hub: `POST /api/v1/preferences` endpoint
|
||||||
|
- Hub: Notification section on customer detail page
|
||||||
|
- Controller: `SyncPreferences` method for hub sync
|
||||||
|
- Controller: Sync on settings save + startup
|
||||||
|
|
||||||
|
## v0.7.1 — Monitoring Warnings, Dashboard Alerts & Notification System (2026-02-16)
|
||||||
|
|
||||||
|
- Monitoring page "Távoli monitoring" section
|
||||||
|
- Dashboard alert banners (error/warning/info)
|
||||||
|
- Hub notification relay (Resend email)
|
||||||
|
- Controller-side notifier with cooldown tracking
|
||||||
|
- Notification preferences UI on settings page
|
||||||
|
|
||||||
|
## v0.7.0 — Authentication, Persistence & Settings Page (2026-02-16)
|
||||||
|
|
||||||
|
- `settings.json` persistence layer
|
||||||
|
- Password change via settings page
|
||||||
|
- Session management improvements
|
||||||
|
- DB validation persistence across restarts
|
||||||
|
|
||||||
|
## v0.6.3 — Bug fixes (2026-02-16)
|
||||||
|
|
||||||
|
- `--hdd-path` validation in docker-setup.sh
|
||||||
|
- `window.event` deprecation fix
|
||||||
|
- Page title separator fix
|
||||||
|
- `nextPruneLabel()` Sunday fix
|
||||||
|
|
||||||
|
## v0.6.0 — Healthcheck + Central Push + Hub Dashboard (2026-02-16)
|
||||||
|
|
||||||
|
- Heartbeat + backup integrity pings
|
||||||
|
- Central hub reporting (report builder + pusher)
|
||||||
|
- Hub service with SQLite store + dark theme dashboard
|
||||||
|
|
||||||
|
## v0.5.0 — Monitoring Page with Metrics Store (2026-02-16)
|
||||||
|
|
||||||
|
- SQLite metrics store with WAL mode
|
||||||
|
- Background metrics collector (60s interval)
|
||||||
|
- System + container charts (Chart.js 4.4.7)
|
||||||
|
- Per-container detail views
|
||||||
|
|
||||||
|
## v0.4.5 — Dedicated Backup Page (2026-02-16)
|
||||||
|
|
||||||
|
- Full backup system visibility
|
||||||
|
- DB dump engine + restic integration
|
||||||
|
- Snapshot history + validation
|
||||||
|
- Manual backup trigger
|
||||||
|
|
||||||
|
## v0.4.0 — Monitoring & Health + Backups (2026-02-15)
|
||||||
|
|
||||||
|
- Central job scheduler
|
||||||
|
- CPU/temperature/load monitoring
|
||||||
|
- Healthchecks.io integration
|
||||||
|
- Database dump + restic backup engine
|
||||||
|
|
||||||
|
## v0.3.0 — Templates + Server Split (2026-02-15)
|
||||||
|
|
||||||
|
- go:embed template migration
|
||||||
|
- Server decomposition (auth, handlers, funcmap)
|
||||||
|
- Domain rename (dashboard → felhom)
|
||||||
|
|
||||||
|
## v0.2.x — Initial Development (2026-02-13 to 2026-02-15)
|
||||||
|
|
||||||
|
- Core stack management (deploy, start, stop, restart, update)
|
||||||
|
- Dashboard, stacks page, deploy flow, logs
|
||||||
|
- System info, memory validation
|
||||||
|
- Git sync, app catalog, delete flow
|
||||||
+30
-13
@@ -7,7 +7,7 @@
|
|||||||
>
|
>
|
||||||
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
|
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
|
||||||
|
|
||||||
Last updated: 2026-02-16 (session 25)
|
Last updated: 2026-02-17 (session 26)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ Last updated: 2026-02-16 (session 25)
|
|||||||
## Current project state
|
## Current project state
|
||||||
|
|
||||||
### felhom-controller (this repo)
|
### felhom-controller (this repo)
|
||||||
- **Version:** v0.8.0
|
- **Version:** v0.9.0
|
||||||
- **Phase 1:** ✅ COMPLETE — Stack Manager + Deploy Flow
|
- **Phase 1:** ✅ COMPLETE — Stack Manager + Deploy Flow
|
||||||
- **Phase 2:** ✅ COMPLETE — Monitoring & Health (scheduler, CPU/temp, healthchecks.io pings)
|
- **Phase 2:** ✅ COMPLETE — Monitoring & Health (scheduler, CPU/temp, healthchecks.io pings)
|
||||||
- **Phase 3:** ✅ COMPLETE — Backups (DB dumps, restic integration, manual trigger, **dedicated backup page**)
|
- **Phase 3:** ✅ COMPLETE — Backups (DB dumps, restic integration, manual trigger, **dedicated backup page**)
|
||||||
@@ -34,7 +34,22 @@ Last updated: 2026-02-16 (session 25)
|
|||||||
- **Running on:** demo-felhom (N100 mini PC) at 192.168.0.162:8080
|
- **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
|
- **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
|
||||||
|
|
||||||
### What was just completed (2026-02-16 session 25)
|
### What was just 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`.
|
||||||
|
- **Storage paths registry** (`settings.json`) — new `StoragePath` struct with Path, Label, IsDefault, Schedulable, AddedAt. Thread-safe CRUD methods in `settings.go` (Get/Add/Remove/SetDefault/SetSchedulable). Multiple external storage paths supported.
|
||||||
|
- **Auto-discovery** — On startup, `discoverHDDPaths()` scans deployed apps' `app.yaml` for `HDD_PATH` values. `AutoDiscoverStoragePaths()` registers discovered paths with inferred labels. Legacy `cfg.Paths.HDDPath` used as fallback.
|
||||||
|
- **Mount-point validation** — New `mounts_linux.go` (build-tagged): `IsMountPoint()` via `syscall.Stat_t.Dev` comparison, `IsWritable()`, `PathsOverlap()`, `GetDiskUsage()` via `syscall.Statfs`. Non-Linux stubs in `mounts_other.go`.
|
||||||
|
- **Settings page "Adattárolók" section** — Lists registered paths with label, path, disk usage bar, app count, badges (default/active/unmounted). Actions: set default, toggle schedulable, remove (with guards). Expandable "Új adattároló hozzáadása" form with 5-step validation (exists, mount point, writable, no overlap, no duplicate).
|
||||||
|
- **Deploy page storage dropdown** — `path` field type renders as `<select>` dropdown of schedulable storage paths. Falls back to text input with warning if no paths registered.
|
||||||
|
- **Health check storage monitoring** — `RunHealthCheck()` now accepts `storagePaths` parameter. Checks: path accessible (warning), not a mount point (issue — data writes to SSD!), disk usage ≥95% (issue) / ≥90% (warning).
|
||||||
|
- **Controller docker-compose.yml** — Changed HDD mount from `${HDD_PATH:-/mnt/hdd_placeholder}:...:ro` to `/mnt:/mnt:rw` for multi-storage support + restore capability.
|
||||||
|
- **Removed unused `hddPath` param** from `DiscoverAppData()` signature in backup/appdata.go.
|
||||||
|
- **Files created (2):** `system/mounts_linux.go`, `system/mounts_other.go`
|
||||||
|
- **Files modified (11):** `settings.go`, `main.go`, `appdata.go`, `backup.go`, `handlers.go`, `server.go`, `settings.html`, `deploy.html`, `style.css`, `healthcheck.go`, `docker-compose.yml`, `report/builder.go`
|
||||||
|
|
||||||
|
### What was previously completed (2026-02-16 session 25)
|
||||||
- **v0.8.0 — Phase 7: Storage Overview, Per-App Backup Toggles & Limited Restore:**
|
- **v0.8.0 — Phase 7: Storage Overview, Per-App Backup Toggles & Limited Restore:**
|
||||||
- **Storage overview on backup page** — new "Tárhely áttekintés" section as first section on backup page showing SSD/HDD progress bars + backup repo stats (repo size, dump file count, snapshot count). Reuses existing `system.GetInfo()` and `RepoStats`.
|
- **Storage overview on backup page** — new "Tárhely áttekintés" section as first section on backup page showing SSD/HDD progress bars + backup repo stats (repo size, dump file count, snapshot count). Reuses existing `system.GetInfo()` and `RepoStats`.
|
||||||
- **Restic password visibility** — new "Titkosítási kulcs" section inside the repository card. Masked password field with show/copy buttons (JS toggle). Password synced to hub via periodic report for disaster recovery (`ResticPassword` field added to `BackupReport`).
|
- **Restic password visibility** — new "Titkosítási kulcs" section inside the repository card. Masked password field with show/copy buttons (JS toggle). Password synced to hub via periodic report for disaster recovery (`ResticPassword` field added to `BackupReport`).
|
||||||
@@ -530,16 +545,14 @@ Last updated: 2026-02-16 (session 25)
|
|||||||
7. Documentation: restart vs up -d for image updates
|
7. Documentation: restart vs up -d for image updates
|
||||||
|
|
||||||
### What's next (priorities)
|
### What's next (priorities)
|
||||||
1. **Deploy v0.8.0** — Build + deploy controller v0.8.0 to demo-felhom.eu
|
1. **Test per-app backup** — enable backup for Paperless-ngx HDD data, trigger manual backup, verify restic snapshot includes HDD paths
|
||||||
2. **Test per-app backup** — enable backup for Paperless-ngx HDD data, trigger manual backup, verify restic snapshot includes HDD paths
|
2. **Test restore** — restore app data from snapshot, verify file recovery (now possible with /mnt:rw mount)
|
||||||
3. **Test restore** — restore app data from snapshot, verify file recovery
|
3. **Deploy Immich** — tests HDD path + secrets + multi-storage (biggest real-world test)
|
||||||
4. **Change HDD mount to :rw** — currently `:ro` in controller docker-compose.yml; required for restore to work
|
4. Add `app_info` + `optional_config` to more apps (Immich, Mealie, Vaultwarden)
|
||||||
5. Add `app_info` + `optional_config` to more apps (start with Immich, Mealie, Vaultwarden)
|
5. Test on Raspberry Pi (pi-customer-1)
|
||||||
6. Deploy a second app (e.g., ActualBudget — simplest, or Immich — tests HDD + secrets)
|
6. Self-update mechanism
|
||||||
7. Test on Raspberry Pi (pi-customer-1)
|
7. Hub alerting (webhook to Healthchecks for stale customers)
|
||||||
8. Self-update mechanism
|
8. Docker volume backup (mount `/var/lib/docker/volumes:ro` into controller)
|
||||||
9. Hub alerting (webhook to Healthchecks for stale customers)
|
|
||||||
10. Docker volume backup (mount `/var/lib/docker/volumes:ro` into controller)
|
|
||||||
|
|
||||||
## Architecture decisions
|
## Architecture decisions
|
||||||
|
|
||||||
@@ -582,6 +595,10 @@ Last updated: 2026-02-16 (session 25)
|
|||||||
| Password sync to hub via report | Restic password in Docker named volume on SSD. Hub sync provides redundancy for disaster recovery |
|
| Password sync to hub via report | Restic password in Docker named volume on SSD. Hub sync provides redundancy for disaster recovery |
|
||||||
| App backup via HDD mounts only | Docker volumes at /var/lib/docker/volumes/ not mounted in controller. HDD data is the important user data; DB in volumes covered by nightly dump |
|
| App backup via HDD mounts only | Docker volumes at /var/lib/docker/volumes/ not mounted in controller. HDD data is the important user data; DB in volumes covered by nightly dump |
|
||||||
| Restore uses running mutex | Prevents concurrent backup+restore on same restic repo. Reuses existing `m.running` flag |
|
| Restore uses running mutex | Prevents concurrent backup+restore on same restic repo. Reuses existing `m.running` flag |
|
||||||
|
| Storage paths registry in settings.json | Multi-storage support: each app's HDD_PATH from app.yaml is authoritative. Auto-discovery on startup avoids manual config. Registry enables UI management + health monitoring per path |
|
||||||
|
| /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 |
|
||||||
| Metrics downsampling via SQL | Bucket-based AVG in GROUP BY keeps Chart.js responsive with up to 30 days of data |
|
| 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 |
|
| 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 |
|
| /etc/os-release mounted read-only | Container can't read host OS info directly — mount to /host/etc/os-release:ro |
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -60,6 +61,10 @@ func main() {
|
|||||||
logger.Fatalf("[FATAL] Failed to load settings from %s: %v", settingsPath, err)
|
logger.Fatalf("[FATAL] Failed to load settings from %s: %v", settingsPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Auto-discover storage paths from deployed apps ---
|
||||||
|
discoveredPaths := discoverHDDPaths(cfg.Paths.StacksDir, logger)
|
||||||
|
sett.AutoDiscoverStoragePaths(discoveredPaths, cfg.Paths.HDDPath, logger)
|
||||||
|
|
||||||
// --- Initialize stack manager ---
|
// --- Initialize stack manager ---
|
||||||
stackMgr, err := stacks.NewManager(cfg, logger)
|
stackMgr, err := stacks.NewManager(cfg, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -96,7 +101,11 @@ func main() {
|
|||||||
|
|
||||||
if metricsStore != nil {
|
if metricsStore != nil {
|
||||||
defer metricsStore.Close()
|
defer metricsStore.Close()
|
||||||
metricsCollector := metrics.NewMetricsCollector(metricsStore, cpuCollector, cfg.Paths.HDDPath, logger)
|
metricsHDDPath := cfg.Paths.HDDPath
|
||||||
|
if paths := sett.GetStoragePaths(); len(paths) > 0 {
|
||||||
|
metricsHDDPath = paths[0].Path
|
||||||
|
}
|
||||||
|
metricsCollector := metrics.NewMetricsCollector(metricsStore, cpuCollector, metricsHDDPath, logger)
|
||||||
metricsCollector.Start(ctx)
|
metricsCollector.Start(ctx)
|
||||||
defer metricsCollector.Stop()
|
defer metricsCollector.Stop()
|
||||||
logger.Println("[INFO] Metrics collector started (60s interval)")
|
logger.Println("[INFO] Metrics collector started (60s interval)")
|
||||||
@@ -109,7 +118,10 @@ func main() {
|
|||||||
var backupMgr *backup.Manager
|
var backupMgr *backup.Manager
|
||||||
if cfg.Backup.Enabled {
|
if cfg.Backup.Enabled {
|
||||||
backupMgr = backup.NewManager(cfg, pinger, sett, logger)
|
backupMgr = backup.NewManager(cfg, pinger, sett, logger)
|
||||||
backupMgr.SetStackProvider(&stackAdapter{mgr: stackMgr, hddPath: cfg.Paths.HDDPath})
|
backupMgr.SetStackProvider(&stackAdapter{
|
||||||
|
mgr: stackMgr,
|
||||||
|
getStoragePaths: func() []settings.StoragePath { return sett.GetStoragePaths() },
|
||||||
|
})
|
||||||
backupMgr.AfterBackup = func() {
|
backupMgr.AfterBackup = func() {
|
||||||
nextDBDump := scheduler.NextDailyRun(cfg.Backup.DBDumpSchedule)
|
nextDBDump := scheduler.NextDailyRun(cfg.Backup.DBDumpSchedule)
|
||||||
nextBackup := scheduler.NextDailyRun(cfg.Backup.ResticSchedule)
|
nextBackup := scheduler.NextDailyRun(cfg.Backup.ResticSchedule)
|
||||||
@@ -147,7 +159,7 @@ func main() {
|
|||||||
healthInterval = 5 * time.Minute
|
healthInterval = 5 * time.Minute
|
||||||
}
|
}
|
||||||
sched.Every("system-health", healthInterval, func(ctx context.Context) error {
|
sched.Every("system-health", healthInterval, func(ctx context.Context) error {
|
||||||
healthReport := monitor.RunHealthCheck(cfg, cpuCollector)
|
healthReport := monitor.RunHealthCheck(cfg, cpuCollector, sett.GetStoragePaths())
|
||||||
body := healthReport.FormatMessage()
|
body := healthReport.FormatMessage()
|
||||||
healthUUID := cfg.Monitoring.PingUUIDs.SystemHealth
|
healthUUID := cfg.Monitoring.PingUUIDs.SystemHealth
|
||||||
if healthReport.Status == "fail" {
|
if healthReport.Status == "fail" {
|
||||||
@@ -220,7 +232,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
pusher := report.NewPusher(&cfg.Hub, logger)
|
pusher := report.NewPusher(&cfg.Hub, logger)
|
||||||
sched.Every("hub-report", pushInterval, func(ctx context.Context) error {
|
sched.Every("hub-report", pushInterval, func(ctx context.Context) error {
|
||||||
r := report.BuildReport(cfg, stackMgr, backupMgr, cpuCollector, metricsStore, Version)
|
r := report.BuildReport(cfg, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths())
|
||||||
return pusher.Push(r)
|
return pusher.Push(r)
|
||||||
})
|
})
|
||||||
logger.Printf("[INFO] Hub reporting enabled (every %s to %s)", pushInterval, cfg.Hub.URL)
|
logger.Printf("[INFO] Hub reporting enabled (every %s to %s)", pushInterval, cfg.Hub.URL)
|
||||||
@@ -252,7 +264,7 @@ func main() {
|
|||||||
|
|
||||||
// Initial alert refresh (so alerts appear immediately, not after first 5min health check)
|
// Initial alert refresh (so alerts appear immediately, not after first 5min health check)
|
||||||
go func() {
|
go func() {
|
||||||
report := monitor.RunHealthCheck(cfg, cpuCollector)
|
report := monitor.RunHealthCheck(cfg, cpuCollector, sett.GetStoragePaths())
|
||||||
alertMgr.Refresh(report, cfg, backupMgr)
|
alertMgr.Refresh(report, cfg, backupMgr)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -318,8 +330,8 @@ func setupLogger(cfg *config.Config) *log.Logger {
|
|||||||
|
|
||||||
// stackAdapter implements backup.StackDataProvider using stacks.Manager.
|
// stackAdapter implements backup.StackDataProvider using stacks.Manager.
|
||||||
type stackAdapter struct {
|
type stackAdapter struct {
|
||||||
mgr *stacks.Manager
|
mgr *stacks.Manager
|
||||||
hddPath string
|
getStoragePaths func() []settings.StoragePath
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *stackAdapter) GetStackComposePath(name string) (string, bool) {
|
func (a *stackAdapter) GetStackComposePath(name string) (string, bool) {
|
||||||
@@ -351,5 +363,53 @@ func (a *stackAdapter) GetStackHDDMounts(name string) []string {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return stacks.ParseComposeHDDMounts(s.ComposePath, a.hddPath)
|
|
||||||
|
// 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 := make(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
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoverHDDPaths scans deployed apps' app.yaml for HDD_PATH env values.
|
||||||
|
func discoverHDDPaths(stacksDir string, logger *log.Logger) []string {
|
||||||
|
entries, err := os.ReadDir(stacksDir)
|
||||||
|
if err != nil {
|
||||||
|
logger.Printf("[WARN] Cannot read stacks dir for HDD path discovery: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var paths []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
appCfg := stacks.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
|
||||||
|
paths = append(paths, cleaned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ paths:
|
|||||||
data_dir: "/opt/docker/felhom-controller/data"
|
data_dir: "/opt/docker/felhom-controller/data"
|
||||||
backup_dir: "/srv/backups"
|
backup_dir: "/srv/backups"
|
||||||
db_dump_dir: "/srv/backups/db-dumps"
|
db_dump_dir: "/srv/backups/db-dumps"
|
||||||
hdd_path: "" # Optional: HDD mount path (e.g., /mnt/hdd)
|
hdd_path: "" # DEPRECATED: use Settings > Adattárolók instead. Fallback only for auto-discovery.
|
||||||
|
|
||||||
# --- System ---
|
# --- System ---
|
||||||
system:
|
system:
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ services:
|
|||||||
- /opt/docker/stacks:/opt/docker/stacks
|
- /opt/docker/stacks:/opt/docker/stacks
|
||||||
# Backup directories (restic repo + db dumps)
|
# Backup directories (restic repo + db dumps)
|
||||||
- /srv/backups:/srv/backups
|
- /srv/backups:/srv/backups
|
||||||
# HDD mount (if available, for monitoring disk usage)
|
# All external storage — /mnt/* for multi-storage + restore
|
||||||
- ${HDD_PATH:-/mnt/hdd_placeholder}:${HDD_PATH:-/mnt/hdd_placeholder}:ro
|
- /mnt:/mnt:rw
|
||||||
# Host /sys — for CPU temperature reading (read-only)
|
# Host /sys — for CPU temperature reading (read-only)
|
||||||
- /sys:/host/sys:ro
|
- /sys:/host/sys:ro
|
||||||
# Host OS info — for monitoring page system info
|
# Host OS info — for monitoring page system info
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ type AppDockerVolume struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DiscoverAppData discovers backup-relevant data for all deployed apps.
|
// DiscoverAppData discovers backup-relevant data for all deployed apps.
|
||||||
func DiscoverAppData(provider StackDataProvider, hddPath string, backupPrefs map[string]bool, discoveredDBs []DiscoveredDB) []AppBackupInfo {
|
func DiscoverAppData(provider StackDataProvider, backupPrefs map[string]bool, discoveredDBs []DiscoveredDB) []AppBackupInfo {
|
||||||
if provider == nil {
|
if provider == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -514,7 +514,7 @@ func (m *Manager) RefreshCache(nextDBDump, nextBackup time.Time) {
|
|||||||
// Discover app data (for per-app backup toggles)
|
// Discover app data (for per-app backup toggles)
|
||||||
if m.stackProvider != nil {
|
if m.stackProvider != nil {
|
||||||
backupPrefs := m.settings.GetAppBackupMap()
|
backupPrefs := m.settings.GetAppBackupMap()
|
||||||
status.AppDataInfo = DiscoverAppData(m.stackProvider, m.cfg.Paths.HDDPath, backupPrefs, status.DiscoveredDBs)
|
status.AppDataInfo = DiscoverAppData(m.stackProvider, backupPrefs, status.DiscoveredDBs)
|
||||||
|
|
||||||
// Include enabled app backup paths in the displayed BackupPaths
|
// Include enabled app backup paths in the displayed BackupPaths
|
||||||
appPaths := m.resolveAppBackupPaths()
|
appPaths := m.resolveAppBackupPaths()
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package monitor
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,13 +22,17 @@ type HealthReport struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RunHealthCheck runs system checks and returns a diagnostic report.
|
// RunHealthCheck runs system checks and returns a diagnostic report.
|
||||||
func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector) *HealthReport {
|
func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, storagePaths []settings.StoragePath) *HealthReport {
|
||||||
report := &HealthReport{
|
report := &HealthReport{
|
||||||
Status: "ok",
|
Status: "ok",
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
sysInfo := system.GetInfo(cfg.Paths.HDDPath, cpuCollector)
|
hddPath := cfg.Paths.HDDPath
|
||||||
|
if len(storagePaths) > 0 {
|
||||||
|
hddPath = storagePaths[0].Path
|
||||||
|
}
|
||||||
|
sysInfo := system.GetInfo(hddPath, cpuCollector)
|
||||||
|
|
||||||
// 1. Disk usage (SSD)
|
// 1. Disk usage (SSD)
|
||||||
if sysInfo.DiskPercent > 0 {
|
if sysInfo.DiskPercent > 0 {
|
||||||
@@ -88,6 +94,11 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector) *Heal
|
|||||||
report.Issues = append(report.Issues, fmt.Sprintf("Protected container not running: %s", name))
|
report.Issues = append(report.Issues, fmt.Sprintf("Protected container not running: %s", name))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 7. Storage paths
|
||||||
|
storageIssues, storageWarnings := checkStoragePaths(storagePaths)
|
||||||
|
report.Issues = append(report.Issues, storageIssues...)
|
||||||
|
report.Warnings = append(report.Warnings, storageWarnings...)
|
||||||
|
|
||||||
// Determine status
|
// Determine status
|
||||||
if len(report.Issues) > 0 {
|
if len(report.Issues) > 0 {
|
||||||
report.Status = "fail"
|
report.Status = "fail"
|
||||||
@@ -158,3 +169,28 @@ func checkProtectedContainers(protected []string) []string {
|
|||||||
}
|
}
|
||||||
return missing
|
return missing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mount point check
|
||||||
|
if !system.IsMountPoint(sp.Path) {
|
||||||
|
issues = append(issues, fmt.Sprintf("Storage path %s is NOT a mount point — data writes to SSD!", 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))
|
||||||
|
} else if di.UsedPercent >= 90 {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("Storage %s usage high: %.0f%%", sp.Path, di.UsedPercent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||||
)
|
)
|
||||||
@@ -22,6 +23,7 @@ func BuildReport(
|
|||||||
cpuCollector *system.CPUCollector,
|
cpuCollector *system.CPUCollector,
|
||||||
metricsStore *metrics.MetricsStore,
|
metricsStore *metrics.MetricsStore,
|
||||||
version string,
|
version string,
|
||||||
|
storagePaths []settings.StoragePath,
|
||||||
) *Report {
|
) *Report {
|
||||||
r := &Report{
|
r := &Report{
|
||||||
Version: 1,
|
Version: 1,
|
||||||
@@ -33,7 +35,11 @@ func BuildReport(
|
|||||||
|
|
||||||
// System info
|
// System info
|
||||||
staticInfo := metrics.GetStaticInfo()
|
staticInfo := metrics.GetStaticInfo()
|
||||||
sysInfo := system.GetInfo(cfg.Paths.HDDPath, cpuCollector)
|
hddPath := cfg.Paths.HDDPath
|
||||||
|
if len(storagePaths) > 0 {
|
||||||
|
hddPath = storagePaths[0].Path
|
||||||
|
}
|
||||||
|
sysInfo := system.GetInfo(hddPath, cpuCollector)
|
||||||
|
|
||||||
r.System = SystemReport{
|
r.System = SystemReport{
|
||||||
Hostname: staticInfo.Hostname,
|
Hostname: staticInfo.Hostname,
|
||||||
@@ -72,7 +78,7 @@ func BuildReport(
|
|||||||
r.Backup = buildBackupReport(cfg, backupMgr)
|
r.Backup = buildBackupReport(cfg, backupMgr)
|
||||||
|
|
||||||
// Health
|
// Health
|
||||||
healthReport := monitor.RunHealthCheck(cfg, cpuCollector)
|
healthReport := monitor.RunHealthCheck(cfg, cpuCollector, storagePaths)
|
||||||
r.Health = HealthReport{
|
r.Health = HealthReport{
|
||||||
Status: healthReport.Status,
|
Status: healthReport.Status,
|
||||||
Issues: healthReport.Issues,
|
Issues: healthReport.Issues,
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Settings holds customer-modifiable overrides and cached state.
|
// Settings holds customer-modifiable overrides and cached state.
|
||||||
@@ -27,6 +29,9 @@ type Settings struct {
|
|||||||
|
|
||||||
// Per-app backup preferences
|
// Per-app backup preferences
|
||||||
AppBackup map[string]AppBackupPrefs `json:"app_backup,omitempty"`
|
AppBackup map[string]AppBackupPrefs `json:"app_backup,omitempty"`
|
||||||
|
|
||||||
|
// Storage paths registry
|
||||||
|
StoragePaths []StoragePath `json:"storage_paths,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AppBackupPrefs holds per-app backup toggle state.
|
// AppBackupPrefs holds per-app backup toggle state.
|
||||||
@@ -34,6 +39,15 @@ type AppBackupPrefs struct {
|
|||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
// NotificationPrefs holds customer notification preferences.
|
// NotificationPrefs holds customer notification preferences.
|
||||||
type NotificationPrefs struct {
|
type NotificationPrefs struct {
|
||||||
Email string `json:"email,omitempty"`
|
Email string `json:"email,omitempty"`
|
||||||
@@ -224,3 +238,161 @@ func (s *Settings) SetAppBackupBulk(prefs map[string]bool) error {
|
|||||||
}
|
}
|
||||||
return s.save()
|
return s.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Storage Paths ---
|
||||||
|
|
||||||
|
// GetStoragePaths returns a copy of all registered storage paths.
|
||||||
|
func (s *Settings) GetStoragePaths() []StoragePath {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
if len(s.StoragePaths) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]StoragePath, len(s.StoragePaths))
|
||||||
|
copy(result, s.StoragePaths)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultStoragePath returns the default storage path string, or "".
|
||||||
|
func (s *Settings) GetDefaultStoragePath() string {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
for _, sp := range s.StoragePaths {
|
||||||
|
if sp.IsDefault {
|
||||||
|
return sp.Path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSchedulableStoragePaths returns paths available for new deployments.
|
||||||
|
func (s *Settings) GetSchedulableStoragePaths() []StoragePath {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
var result []StoragePath
|
||||||
|
for _, sp := range s.StoragePaths {
|
||||||
|
if sp.Schedulable {
|
||||||
|
result = append(result, sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddStoragePath registers a new storage path. Validation is done by caller.
|
||||||
|
func (s *Settings) AddStoragePath(sp StoragePath) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if sp.IsDefault {
|
||||||
|
for i := range s.StoragePaths {
|
||||||
|
s.StoragePaths[i].IsDefault = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.StoragePaths = append(s.StoragePaths, sp)
|
||||||
|
return s.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveStoragePath removes a path by its path string.
|
||||||
|
func (s *Settings) RemoveStoragePath(path string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
var kept []StoragePath
|
||||||
|
for _, sp := range s.StoragePaths {
|
||||||
|
if sp.Path != path {
|
||||||
|
kept = append(kept, sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.StoragePaths = kept
|
||||||
|
return s.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDefaultStoragePath changes which path is the default.
|
||||||
|
func (s *Settings) SetDefaultStoragePath(path string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
found := false
|
||||||
|
for i := range s.StoragePaths {
|
||||||
|
if s.StoragePaths[i].Path == path {
|
||||||
|
s.StoragePaths[i].IsDefault = true
|
||||||
|
found = true
|
||||||
|
} else {
|
||||||
|
s.StoragePaths[i].IsDefault = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("storage path %q not found", path)
|
||||||
|
}
|
||||||
|
return s.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSchedulable enables/disables a path for new deployments.
|
||||||
|
func (s *Settings) SetSchedulable(path string, schedulable bool) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for i := range s.StoragePaths {
|
||||||
|
if s.StoragePaths[i].Path == path {
|
||||||
|
s.StoragePaths[i].Schedulable = schedulable
|
||||||
|
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).
|
||||||
|
func (s *Settings) AutoDiscoverStoragePaths(discoveredPaths []string, fallbackHDDPath string, logger *log.Logger) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if len(s.StoragePaths) > 0 {
|
||||||
|
return // already configured
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var ordered []string
|
||||||
|
for _, p := range discoveredPaths {
|
||||||
|
cleaned := filepath.Clean(p)
|
||||||
|
if cleaned != "" && !seen[cleaned] {
|
||||||
|
seen[cleaned] = true
|
||||||
|
ordered = append(ordered, cleaned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fallbackHDDPath != "" {
|
||||||
|
cleaned := filepath.Clean(fallbackHDDPath)
|
||||||
|
if !seen[cleaned] {
|
||||||
|
seen[cleaned] = true
|
||||||
|
ordered = append(ordered, cleaned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, path := range ordered {
|
||||||
|
sp := StoragePath{
|
||||||
|
Path: path,
|
||||||
|
Label: InferStorageLabel(path),
|
||||||
|
IsDefault: i == 0,
|
||||||
|
Schedulable: true,
|
||||||
|
AddedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
s.StoragePaths = append(s.StoragePaths, sp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(s.StoragePaths) > 0 {
|
||||||
|
if err := s.save(); err != nil {
|
||||||
|
logger.Printf("[ERROR] Failed to save auto-discovered storage paths: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InferStorageLabel generates a human-readable label for a storage path.
|
||||||
|
func InferStorageLabel(path string) string {
|
||||||
|
base := filepath.Base(path)
|
||||||
|
if strings.HasPrefix(base, "hdd") || strings.HasPrefix(base, "ssd") || strings.HasPrefix(base, "usb") {
|
||||||
|
return fmt.Sprintf("Külső tárhely (%s)", base)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Tárhely (%s)", base)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package system
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsMountPoint checks if a path is on a different device than its parent.
|
||||||
|
// Returns true if the path is a mount point (different device ID from 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsWritable checks if the given path is writable by attempting to create+remove a temp file.
|
||||||
|
func IsWritable(path string) bool {
|
||||||
|
testFile := filepath.Join(path, ".felhom-write-test")
|
||||||
|
f, err := os.Create(testFile)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
os.Remove(testFile)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathsOverlap returns true if one path is a parent or child of the other.
|
||||||
|
func PathsOverlap(a, b string) bool {
|
||||||
|
a = filepath.Clean(a)
|
||||||
|
b = filepath.Clean(b)
|
||||||
|
if a == b {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
aSep := a + string(os.PathSeparator)
|
||||||
|
bSep := b + string(os.PathSeparator)
|
||||||
|
return strings.HasPrefix(aSep, bSep) || strings.HasPrefix(bSep, aSep)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiskUsageInfo holds disk usage statistics for a path.
|
||||||
|
type DiskUsageInfo struct {
|
||||||
|
TotalGB float64
|
||||||
|
UsedGB float64
|
||||||
|
AvailGB float64
|
||||||
|
UsedPercent float64
|
||||||
|
TotalHuman string
|
||||||
|
UsedHuman string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDiskUsage returns disk usage info for a path, or nil on error.
|
||||||
|
func GetDiskUsage(path string) *DiskUsageInfo {
|
||||||
|
var stat syscall.Statfs_t
|
||||||
|
if err := syscall.Statfs(path, &stat); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
bsize := uint64(stat.Bsize)
|
||||||
|
total := stat.Blocks * bsize
|
||||||
|
avail := stat.Bavail * bsize
|
||||||
|
used := total - (stat.Bfree * bsize)
|
||||||
|
|
||||||
|
const gb = 1024 * 1024 * 1024
|
||||||
|
info := &DiskUsageInfo{
|
||||||
|
TotalGB: float64(total) / float64(gb),
|
||||||
|
UsedGB: float64(used) / float64(gb),
|
||||||
|
AvailGB: float64(avail) / float64(gb),
|
||||||
|
}
|
||||||
|
if total > 0 {
|
||||||
|
info.UsedPercent = float64(used) / float64(total) * 100
|
||||||
|
}
|
||||||
|
info.TotalHuman = formatGB(info.TotalGB)
|
||||||
|
info.UsedHuman = formatGB(info.UsedGB)
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatGB(gb float64) string {
|
||||||
|
if gb >= 1000 {
|
||||||
|
return fmt.Sprintf("%.1f TB", gb/1024)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f GB", gb)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package system
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsMountPoint always returns true on non-Linux (assume OK for dev/testing).
|
||||||
|
func IsMountPoint(_ string) bool { return true }
|
||||||
|
|
||||||
|
// IsWritable checks if the given path is writable by attempting to create+remove a temp file.
|
||||||
|
func IsWritable(path string) bool {
|
||||||
|
testFile := filepath.Join(path, ".felhom-write-test")
|
||||||
|
f, err := os.Create(testFile)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
os.Remove(testFile)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathsOverlap returns true if one path is a parent or child of the other.
|
||||||
|
func PathsOverlap(a, b string) bool {
|
||||||
|
a = filepath.Clean(a)
|
||||||
|
b = filepath.Clean(b)
|
||||||
|
if a == b {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
aSep := a + string(os.PathSeparator)
|
||||||
|
bSep := b + string(os.PathSeparator)
|
||||||
|
return strings.HasPrefix(aSep, bSep) || strings.HasPrefix(bSep, aSep)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiskUsageInfo holds disk usage statistics for a path.
|
||||||
|
type DiskUsageInfo struct {
|
||||||
|
TotalGB float64
|
||||||
|
UsedGB float64
|
||||||
|
AvailGB float64
|
||||||
|
UsedPercent float64
|
||||||
|
TotalHuman string
|
||||||
|
UsedHuman string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDiskUsage returns nil on non-Linux.
|
||||||
|
func GetDiskUsage(_ string) *DiskUsageInfo { return nil }
|
||||||
@@ -4,7 +4,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
@@ -13,6 +16,14 @@ import (
|
|||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// StoragePathView extends StoragePath with display data for the settings page.
|
||||||
|
type StoragePathView struct {
|
||||||
|
settings.StoragePath
|
||||||
|
DiskInfo *system.DiskUsageInfo
|
||||||
|
AppCount int
|
||||||
|
IsMounted bool
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) baseData(page, title string) map[string]interface{} {
|
func (s *Server) baseData(page, title string) map[string]interface{} {
|
||||||
data := map[string]interface{}{
|
data := map[string]interface{}{
|
||||||
"Page": page,
|
"Page": page,
|
||||||
@@ -50,7 +61,7 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, _ *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sysInfo := system.GetInfo(s.cfg.Paths.HDDPath, s.cpuCollector)
|
sysInfo := system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
|
||||||
|
|
||||||
data := s.baseData("dashboard", "Vezérlőpult")
|
data := s.baseData("dashboard", "Vezérlőpult")
|
||||||
data["Stacks"] = deployedStacks
|
data["Stacks"] = deployedStacks
|
||||||
@@ -125,6 +136,7 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
|
|||||||
data["AppPageURL"] = s.cfg.AppPageURL(meta.Slug)
|
data["AppPageURL"] = s.cfg.AppPageURL(meta.Slug)
|
||||||
data["UserFields"] = meta.UserFacingFields()
|
data["UserFields"] = meta.UserFacingFields()
|
||||||
data["AutoFields"] = meta.AutoGeneratedFields()
|
data["AutoFields"] = meta.AutoGeneratedFields()
|
||||||
|
data["StoragePaths"] = s.settings.GetSchedulableStoragePaths()
|
||||||
|
|
||||||
// Memory info for deploy page (only for non-deployed apps)
|
// Memory info for deploy page (only for non-deployed apps)
|
||||||
if !alreadyDeployed {
|
if !alreadyDeployed {
|
||||||
@@ -201,7 +213,7 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
|
|||||||
|
|
||||||
func (s *Server) monitoringHandler(w http.ResponseWriter, _ *http.Request) {
|
func (s *Server) monitoringHandler(w http.ResponseWriter, _ *http.Request) {
|
||||||
data := s.baseData("monitoring", "Rendszermonitor")
|
data := s.baseData("monitoring", "Rendszermonitor")
|
||||||
data["SystemInfo"] = system.GetInfo(s.cfg.Paths.HDDPath, s.cpuCollector)
|
data["SystemInfo"] = system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
|
||||||
|
|
||||||
// On monitoring page, exclude the "pings-missing" alert since the detailed table is visible
|
// On monitoring page, exclude the "pings-missing" alert since the detailed table is visible
|
||||||
if s.alertManager != nil {
|
if s.alertManager != nil {
|
||||||
@@ -241,7 +253,7 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
data := s.baseData("backups", "Biztonsági mentés")
|
data := s.baseData("backups", "Biztonsági mentés")
|
||||||
|
|
||||||
// System info for storage overview bars
|
// System info for storage overview bars
|
||||||
data["SystemInfo"] = system.GetInfo(s.cfg.Paths.HDDPath, s.cpuCollector)
|
data["SystemInfo"] = system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
|
||||||
|
|
||||||
if s.backupMgr != nil {
|
if s.backupMgr != nil {
|
||||||
nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
|
nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
|
||||||
@@ -345,6 +357,23 @@ func (s *Server) settingsData() map[string]interface{} {
|
|||||||
data["HealthchecksBase"] = s.cfg.Monitoring.HealthchecksBase
|
data["HealthchecksBase"] = s.cfg.Monitoring.HealthchecksBase
|
||||||
data["HubEnabled"] = s.cfg.Hub.Enabled
|
data["HubEnabled"] = s.cfg.Hub.Enabled
|
||||||
data["NotificationPrefs"] = s.settings.GetNotificationPrefs()
|
data["NotificationPrefs"] = s.settings.GetNotificationPrefs()
|
||||||
|
|
||||||
|
// Storage paths with display data
|
||||||
|
storagePaths := s.settings.GetStoragePaths()
|
||||||
|
var storageViews []StoragePathView
|
||||||
|
for _, sp := range storagePaths {
|
||||||
|
view := StoragePathView{
|
||||||
|
StoragePath: sp,
|
||||||
|
IsMounted: system.IsMountPoint(sp.Path),
|
||||||
|
AppCount: s.countAppsUsingPath(sp.Path),
|
||||||
|
}
|
||||||
|
if di := system.GetDiskUsage(sp.Path); di != nil {
|
||||||
|
view.DiskInfo = di
|
||||||
|
}
|
||||||
|
storageViews = append(storageViews, view)
|
||||||
|
}
|
||||||
|
data["StoragePaths"] = storageViews
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,3 +515,164 @@ func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http
|
|||||||
data["NotificationSuccess"] = "Teszt email elküldve."
|
data["NotificationSuccess"] = "Teszt email elküldve."
|
||||||
s.render(w, "settings", data)
|
s.render(w, "settings", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Storage path management handlers ---
|
||||||
|
|
||||||
|
func (s *Server) countAppsUsingPath(storagePath string) int {
|
||||||
|
count := 0
|
||||||
|
for _, stack := range s.stackMgr.GetStacks() {
|
||||||
|
if !stack.Deployed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
|
||||||
|
if appCfg.Env["HDD_PATH"] == storagePath {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) appsUsingPath(storagePath string) []string {
|
||||||
|
var names []string
|
||||||
|
for _, stack := range s.stackMgr.GetStacks() {
|
||||||
|
if !stack.Deployed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
|
||||||
|
if appCfg.Env["HDD_PATH"] == storagePath {
|
||||||
|
names = append(names, stack.Meta.DisplayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = r.ParseForm()
|
||||||
|
|
||||||
|
path := filepath.Clean(r.FormValue("storage_path"))
|
||||||
|
label := strings.TrimSpace(r.FormValue("storage_label"))
|
||||||
|
isDefault := r.FormValue("storage_default") == "true"
|
||||||
|
|
||||||
|
if label == "" {
|
||||||
|
label = settings.InferStorageLabel(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := s.settingsData()
|
||||||
|
|
||||||
|
// 1. Exists and is directory
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
if err != nil || !fi.IsDir() {
|
||||||
|
data["StorageError"] = "Az útvonal nem létezik vagy nem mappa."
|
||||||
|
s.render(w, "settings", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Is mount point
|
||||||
|
if !system.IsMountPoint(path) {
|
||||||
|
data["StorageError"] = "Ez az útvonal nem külön csatlakoztatott meghajtó. Adatok az SSD-re kerülnének!"
|
||||||
|
s.render(w, "settings", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Writable
|
||||||
|
if !system.IsWritable(path) {
|
||||||
|
data["StorageError"] = "Az útvonal nem írható."
|
||||||
|
s.render(w, "settings", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. No overlap with existing paths
|
||||||
|
for _, existing := range s.settings.GetStoragePaths() {
|
||||||
|
if system.PathsOverlap(path, existing.Path) {
|
||||||
|
data["StorageError"] = fmt.Sprintf("Az útvonal átfedi a már regisztrált %s útvonalat.", existing.Path)
|
||||||
|
s.render(w, "settings", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Soft warning if not under /mnt/
|
||||||
|
if !strings.HasPrefix(path, "/mnt/") {
|
||||||
|
s.logger.Printf("[WARN] Storage path %s is not under /mnt/ — unusual but allowed", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
sp := settings.StoragePath{
|
||||||
|
Path: path,
|
||||||
|
Label: label,
|
||||||
|
IsDefault: isDefault,
|
||||||
|
Schedulable: true,
|
||||||
|
AddedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.settings.AddStoragePath(sp); err != nil {
|
||||||
|
s.logger.Printf("[ERROR] Failed to add storage path: %v", err)
|
||||||
|
data["StorageError"] = "Hiba a mentés során."
|
||||||
|
s.render(w, "settings", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Printf("[INFO] Storage path added: %s (%s)", path, label)
|
||||||
|
http.Redirect(w, r, "/settings", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = r.ParseForm()
|
||||||
|
path := r.FormValue("storage_path")
|
||||||
|
|
||||||
|
data := s.settingsData()
|
||||||
|
|
||||||
|
// Check: apps using this path
|
||||||
|
apps := s.appsUsingPath(path)
|
||||||
|
if len(apps) > 0 {
|
||||||
|
data["StorageError"] = fmt.Sprintf("Nem törölhető: az alábbi alkalmazások használják: %s", strings.Join(apps, ", "))
|
||||||
|
s.render(w, "settings", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check: cannot remove default
|
||||||
|
for _, sp := range s.settings.GetStoragePaths() {
|
||||||
|
if sp.Path == path && sp.IsDefault {
|
||||||
|
data["StorageError"] = "Az alapértelmezett adattároló nem törölhető."
|
||||||
|
s.render(w, "settings", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check: last path
|
||||||
|
if len(s.settings.GetStoragePaths()) <= 1 {
|
||||||
|
data["StorageError"] = "Az utolsó adattároló nem törölhető."
|
||||||
|
s.render(w, "settings", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.settings.RemoveStoragePath(path); err != nil {
|
||||||
|
data["StorageError"] = "Hiba a törlés során."
|
||||||
|
s.render(w, "settings", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Printf("[INFO] Storage path removed: %s", path)
|
||||||
|
http.Redirect(w, r, "/settings", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) settingsStorageDefaultHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = r.ParseForm()
|
||||||
|
path := r.FormValue("storage_path")
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) settingsStorageSchedulableHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = r.ParseForm()
|
||||||
|
path := r.FormValue("storage_path")
|
||||||
|
schedulable := r.FormValue("schedulable") == "true"
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,6 +94,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.settingsNotificationsHandler(w, r)
|
s.settingsNotificationsHandler(w, r)
|
||||||
case path == "/settings/notifications/test" && r.Method == http.MethodPost:
|
case path == "/settings/notifications/test" && r.Method == http.MethodPost:
|
||||||
s.settingsNotificationsTestHandler(w, r)
|
s.settingsNotificationsTestHandler(w, r)
|
||||||
|
case path == "/settings/storage/add" && r.Method == http.MethodPost:
|
||||||
|
s.settingsStorageAddHandler(w, r)
|
||||||
|
case path == "/settings/storage/remove" && r.Method == http.MethodPost:
|
||||||
|
s.settingsStorageRemoveHandler(w, r)
|
||||||
|
case path == "/settings/storage/default" && r.Method == http.MethodPost:
|
||||||
|
s.settingsStorageDefaultHandler(w, r)
|
||||||
|
case path == "/settings/storage/schedulable" && r.Method == http.MethodPost:
|
||||||
|
s.settingsStorageSchedulableHandler(w, r)
|
||||||
case path == "/settings/app-backup" && r.Method == http.MethodPost:
|
case path == "/settings/app-backup" && r.Method == http.MethodPost:
|
||||||
s.settingsAppBackupHandler(w, r)
|
s.settingsAppBackupHandler(w, r)
|
||||||
case path == "/backup/restore" && r.Method == http.MethodPost:
|
case path == "/backup/restore" && r.Method == http.MethodPost:
|
||||||
@@ -122,6 +130,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// primaryHDDPath returns the first registered storage path, or the legacy config value.
|
||||||
|
func (s *Server) primaryHDDPath() string {
|
||||||
|
if paths := s.settings.GetStoragePaths(); len(paths) > 0 {
|
||||||
|
return paths[0].Path
|
||||||
|
}
|
||||||
|
return s.cfg.Paths.HDDPath
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) render(w http.ResponseWriter, name string, data interface{}) {
|
func (s *Server) render(w http.ResponseWriter, name string, data interface{}) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||||
|
|||||||
@@ -114,6 +114,22 @@
|
|||||||
{{if $.AlreadyDeployed}}disabled{{end}}>
|
{{if $.AlreadyDeployed}}disabled{{end}}>
|
||||||
<span class="toggle-label">Igen</span>
|
<span class="toggle-label">Igen</span>
|
||||||
</label>
|
</label>
|
||||||
|
{{else if eq .Type "path"}}
|
||||||
|
{{if $.StoragePaths}}
|
||||||
|
<select id="field-{{.EnvVar}}" name="{{.EnvVar}}" class="form-control"
|
||||||
|
{{if $.AlreadyDeployed}}disabled{{end}}>
|
||||||
|
{{range $.StoragePaths}}
|
||||||
|
<option value="{{.Path}}">{{.Label}} ({{.Path}})</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
{{else}}
|
||||||
|
<input type="text" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
|
||||||
|
class="form-control" value="{{.Default}}"
|
||||||
|
placeholder="{{.Placeholder}}"
|
||||||
|
{{if .Required}}required{{end}}
|
||||||
|
{{if $.AlreadyDeployed}}disabled{{end}}>
|
||||||
|
<span class="form-hint" style="color:var(--yellow)">Nincs regisztrált adattároló — adja meg kézzel az útvonalat</span>
|
||||||
|
{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<input type="text" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
|
<input type="text" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
|
||||||
class="form-control" value="{{.Default}}"
|
class="form-control" value="{{.Default}}"
|
||||||
|
|||||||
@@ -63,6 +63,104 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Section: Storage Paths -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<h3>Adattárolók</h3>
|
||||||
|
<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 .StoragePaths}}
|
||||||
|
<div class="storage-paths-list">
|
||||||
|
{{range .StoragePaths}}
|
||||||
|
<div class="storage-path-item">
|
||||||
|
<div class="storage-path-header">
|
||||||
|
<div class="storage-path-info">
|
||||||
|
<span class="storage-path-label">{{.Label}}</span>
|
||||||
|
<span class="storage-path-path mono">{{.Path}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="storage-path-badges">
|
||||||
|
{{if .IsDefault}}<span class="badge state-green">Alapértelmezett</span>{{end}}
|
||||||
|
{{if .Schedulable}}<span class="badge" style="background:rgba(0,136,204,0.15);color:var(--accent-light)">Aktív</span>{{else}}<span class="badge state-gray">Inaktív</span>{{end}}
|
||||||
|
{{if not .IsMounted}}<span class="badge state-red">Nincs csatolva!</span>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="storage-path-details">
|
||||||
|
{{if .DiskInfo}}
|
||||||
|
<div class="storage-path-disk">
|
||||||
|
<div class="system-info-header">
|
||||||
|
<span class="system-info-value">{{.DiskInfo.UsedHuman}} / {{.DiskInfo.TotalHuman}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="system-bar">
|
||||||
|
<div class="system-bar-fill {{if ge .DiskInfo.UsedPercent 90.0}}system-bar-red{{else if ge .DiskInfo.UsedPercent 70.0}}system-bar-yellow{{else}}system-bar-green{{end}}"
|
||||||
|
style="width:{{printf "%.0f" .DiskInfo.UsedPercent}}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<div class="storage-path-meta">
|
||||||
|
<span class="form-hint">{{.AppCount}} alkalmazás használja</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="storage-path-actions">
|
||||||
|
{{if not .IsDefault}}
|
||||||
|
<form method="POST" action="/settings/storage/default" style="display:inline">
|
||||||
|
<input type="hidden" name="storage_path" value="{{.Path}}">
|
||||||
|
<button type="submit" class="btn btn-xs btn-outline">Alapértelmezett</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
{{if .Schedulable}}
|
||||||
|
<form method="POST" action="/settings/storage/schedulable" style="display:inline">
|
||||||
|
<input type="hidden" name="storage_path" value="{{.Path}}">
|
||||||
|
<input type="hidden" name="schedulable" value="false">
|
||||||
|
<button type="submit" class="btn btn-xs btn-outline">Letiltás</button>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<form method="POST" action="/settings/storage/schedulable" style="display:inline">
|
||||||
|
<input type="hidden" name="storage_path" value="{{.Path}}">
|
||||||
|
<input type="hidden" name="schedulable" value="true">
|
||||||
|
<button type="submit" class="btn btn-xs btn-outline">Engedélyezés</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
{{if and (not .IsDefault) (eq .AppCount 0)}}
|
||||||
|
<form method="POST" action="/settings/storage/remove" style="display:inline"
|
||||||
|
onsubmit="return confirm('Biztosan eltávolítja a(z) {{.Path}} adattárolót?')">
|
||||||
|
<input type="hidden" name="storage_path" value="{{.Path}}">
|
||||||
|
<button type="submit" class="btn btn-xs btn-danger-outline">Eltávolítás</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="empty-state" style="padding:1.5rem">
|
||||||
|
Nincs regisztrált adattároló. Adjon hozzá egyet az alábbi űrlappal.
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<details class="storage-add-details">
|
||||||
|
<summary class="btn btn-sm btn-outline" style="margin-top:1rem;cursor:pointer">Új adattároló hozzáadása</summary>
|
||||||
|
<form method="POST" action="/settings/storage/add" class="storage-add-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="storage_path">Elérési út</label>
|
||||||
|
<input type="text" id="storage_path" name="storage_path" class="form-control"
|
||||||
|
placeholder="/mnt/hdd_1" required>
|
||||||
|
<span class="form-hint">Pl. /mnt/hdd_1 — a meghajtónak már csatolva kell lennie</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="storage_label">Megnevezés (opcionális)</label>
|
||||||
|
<input type="text" id="storage_label" name="storage_label" class="form-control"
|
||||||
|
placeholder="Külső HDD 1TB">
|
||||||
|
</div>
|
||||||
|
<label class="toggle" style="margin-bottom:1rem">
|
||||||
|
<input type="checkbox" name="storage_default" value="true">
|
||||||
|
<span class="toggle-label">Legyen alapértelmezett új telepítéseknél</span>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn btn-primary">Hozzáadás</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Section B: Password Change -->
|
<!-- Section B: Password Change -->
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<h3>Jelszó módosítás</h3>
|
<h3>Jelszó módosítás</h3>
|
||||||
|
|||||||
@@ -1976,6 +1976,85 @@ a.stat-card:hover {
|
|||||||
border-color: rgba(218, 54, 51, 0.3);
|
border-color: rgba(218, 54, 51, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Settings page: Storage paths --- */
|
||||||
|
.storage-paths-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
.storage-path-item {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.storage-path-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
}
|
||||||
|
.storage-path-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .15rem;
|
||||||
|
}
|
||||||
|
.storage-path-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: .95rem;
|
||||||
|
}
|
||||||
|
.storage-path-path {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.storage-path-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: .35rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.storage-path-details {
|
||||||
|
margin: .5rem 0;
|
||||||
|
}
|
||||||
|
.storage-path-disk {
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
}
|
||||||
|
.storage-path-meta {
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.storage-path-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
margin-top: .75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.btn-xs {
|
||||||
|
padding: .2rem .5rem;
|
||||||
|
font-size: .75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.btn-danger-outline {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(218, 54, 51, 0.5);
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
.btn-danger-outline:hover {
|
||||||
|
background: var(--red-bg);
|
||||||
|
border-color: var(--red);
|
||||||
|
}
|
||||||
|
.storage-add-details {
|
||||||
|
margin-top: .5rem;
|
||||||
|
}
|
||||||
|
.storage-add-details[open] summary {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.storage-add-form {
|
||||||
|
margin-top: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media(max-width: 768px) {
|
@media(max-width: 768px) {
|
||||||
.sidebar { width: 100%; height: auto; position: relative; border-right: none; border-bottom: 1px solid var(--border-color); }
|
.sidebar { width: 100%; height: auto; position: relative; border-right: none; border-bottom: 1px solid var(--border-color); }
|
||||||
|
|||||||
Reference in New Issue
Block a user