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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user