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:
2026-02-17 09:04:28 +01:00
parent 465dec443f
commit aca3b8680a
17 changed files with 963 additions and 33 deletions
+193 -3
View File
@@ -4,7 +4,10 @@ import (
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
@@ -13,6 +16,14 @@ import (
"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{} {
data := map[string]interface{}{
"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["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["UserFields"] = meta.UserFacingFields()
data["AutoFields"] = meta.AutoGeneratedFields()
data["StoragePaths"] = s.settings.GetSchedulableStoragePaths()
// Memory info for deploy page (only for non-deployed apps)
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) {
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
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")
// 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 {
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["HubEnabled"] = s.cfg.Hub.Enabled
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
}
@@ -486,3 +515,164 @@ func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http
data["NotificationSuccess"] = "Teszt email elküldve."
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)
}
+16
View File
@@ -94,6 +94,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.settingsNotificationsHandler(w, r)
case path == "/settings/notifications/test" && r.Method == http.MethodPost:
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:
s.settingsAppBackupHandler(w, r)
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{}) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
@@ -114,6 +114,22 @@
{{if $.AlreadyDeployed}}disabled{{end}}>
<span class="toggle-label">Igen</span>
</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}}
<input type="text" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
class="form-control" value="{{.Default}}"
@@ -63,6 +63,104 @@
</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 -->
<div class="settings-card">
<h3>Jelszó módosítás</h3>
@@ -1976,6 +1976,85 @@ a.stat-card:hover {
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 */
@media(max-width: 768px) {
.sidebar { width: 100%; height: auto; position: relative; border-right: none; border-bottom: 1px solid var(--border-color); }