Files
deploy-felhom-compose/controller/internal/web/handlers.go
T
admin 69698a89e8 v0.10.0: Phase B — Storage Management UI Polish & Health Severity Fix
- Health severity fix: mount-point check downgraded from issue (FAIL) to warning (WARN)
- All storage health messages translated to Hungarian
- Success flash messages for all storage operations
- Edit storage path labels (inline edit UI + backend)
- App details per storage path on settings page (expandable list with names + sizes)
- Storage badge on stacks page showing which storage each app uses
- Deploy dropdown with free space display and low-space warning (<20%)
- Filesystem & disk info on settings page (ext4/btrfs, device, model via findmnt)
- Backup page storage context with per-app storage label badges

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:48:51 +01:00

840 lines
26 KiB
Go

package web
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"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
"golang.org/x/crypto/bcrypt"
)
// DeployStoragePath extends StoragePath with free space data for the deploy dropdown.
type DeployStoragePath struct {
settings.StoragePath
FreeHuman string // "234.5 GB"
FreePercent float64 // 67.5
}
// StorageAppDetail holds info about an app using a specific storage path.
type StorageAppDetail struct {
Name string // Display name (e.g., "Immich")
Stack string // Stack name (for link)
SizeHuman string // Data size on this path
}
// StoragePathView extends StoragePath with display data for the settings page.
type StoragePathView struct {
settings.StoragePath
DiskInfo *system.DiskUsageInfo
AppCount int
IsMounted bool
AppDetails []StorageAppDetail
FSInfo *system.FSInfo
}
func (s *Server) baseData(page, title string) map[string]interface{} {
data := map[string]interface{}{
"Page": page,
"Title": title,
"CustomerName": s.cfg.Customer.Name,
"Domain": s.cfg.Customer.Domain,
"Version": s.version,
"AuthEnabled": s.authEnabled(),
}
if s.alertManager != nil {
data["Alerts"] = s.alertManager.GetAlerts()
}
return data
}
func (s *Server) dashboardHandler(w http.ResponseWriter, _ *http.Request) {
stackList := s.stackMgr.GetStacks()
// Filter to deployed + protected stacks first
var deployedStacks []stacks.Stack
for _, st := range stackList {
if st.Deployed || st.Protected {
deployedStacks = append(deployedStacks, st)
}
}
// Count from the DISPLAYED set only
running, stopped := 0, 0
for _, st := range deployedStacks {
switch st.State {
case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting:
running++
case stacks.StateStopped, stacks.StateExited:
stopped++
}
}
sysInfo := system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
data := s.baseData("dashboard", "Vezérlőpult")
data["Stacks"] = deployedStacks
data["RunningCount"] = running
data["StoppedCount"] = stopped
data["TotalCount"] = len(stackList)
data["SystemInfo"] = sysInfo
// Backup status
data["BackupEnabled"] = s.cfg.Backup.Enabled
if s.backupMgr != nil {
nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
nextBackup := scheduler.NextDailyRun(s.cfg.Backup.ResticSchedule)
fullStatus := s.backupMgr.GetFullStatus(nextDBDump, nextBackup)
data["DBDumpStatus"] = fullStatus.LastDBDump
data["BackupStatus"] = fullStatus.LastBackup
data["BackupRunning"] = fullStatus.Running
data["BackupMaxAgeHours"] = s.cfg.Monitoring.Thresholds.BackupMaxAgeHours
}
s.render(w, "dashboard", data)
}
func (s *Server) stacksHandler(w http.ResponseWriter, _ *http.Request) {
data := s.baseData("stacks", "Alkalmazások")
data["Stacks"] = s.stackMgr.GetStacks()
// Build storage label lookup for deployed apps
storageLabels := make(map[string]string) // stack name → storage label
storagePaths := s.settings.GetStoragePaths()
for _, stack := range s.stackMgr.GetStacks() {
if !stack.Deployed {
continue
}
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
if hddPath := appCfg.Env["HDD_PATH"]; hddPath != "" {
for _, sp := range storagePaths {
if sp.Path == hddPath {
storageLabels[stack.Name] = sp.Label
break
}
}
}
}
}
data["StorageLabels"] = storageLabels
s.render(w, "stacks", data)
}
func (s *Server) logsHandler(w http.ResponseWriter, r *http.Request, name string) {
stack, ok := s.stackMgr.GetStack(name)
if !ok {
http.NotFound(w, r)
return
}
logs, err := s.stackMgr.GetLogs(name, 200)
if err != nil {
logs = fmt.Sprintf("Hiba a naplók lekérésekor: %v", err)
}
// Raw mode: return plain text for AJAX polling
if r.URL.Query().Get("raw") == "1" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprint(w, logs)
return
}
data := s.baseData("logs", stack.Meta.DisplayName+" — Naplók")
data["Stack"] = stack
data["Logs"] = logs
s.render(w, "logs", data)
}
func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name string) {
meta, appCfg, err := s.stackMgr.GetDeployFields(name)
if err != nil {
http.NotFound(w, r)
return
}
stack, _ := s.stackMgr.GetStack(name)
alreadyDeployed := appCfg != nil && appCfg.Deployed
data := s.baseData("deploy", meta.DisplayName+" — Telepítés")
data["Stack"] = stack
data["Meta"] = meta
data["AppConfig"] = appCfg
data["AlreadyDeployed"] = alreadyDeployed
data["LogoURL"] = s.cfg.AppLogoURL(meta.Slug)
data["LogoPNGURL"] = s.cfg.AppLogoPNGURL(meta.Slug)
data["AppPageURL"] = s.cfg.AppPageURL(meta.Slug)
data["UserFields"] = meta.UserFacingFields()
data["AutoFields"] = meta.AutoGeneratedFields()
// Storage paths with free space info for deploy dropdown
var deployPaths []DeployStoragePath
for _, sp := range s.settings.GetSchedulableStoragePaths() {
dp := DeployStoragePath{StoragePath: sp}
if di := system.GetDiskUsage(sp.Path); di != nil {
dp.FreeHuman = formatFreeSpace(di.AvailGB)
if di.TotalGB > 0 {
dp.FreePercent = di.AvailGB / di.TotalGB * 100
}
}
deployPaths = append(deployPaths, dp)
}
data["StoragePaths"] = deployPaths
// Memory info for deploy page (only for non-deployed apps)
if !alreadyDeployed {
memInfo := map[string]interface{}{"Available": false}
totalMB, memErr := system.GetTotalMemoryMB()
if memErr == nil {
reservedMB := s.cfg.System.ReservedMemoryMB
usableMB := totalMB - reservedMB
committedReqMB, committedLimitMB := s.stackMgr.CommittedMemory()
newReqMB := stacks.ParseMemoryMB(meta.Resources.MemRequest)
newLimitMB := stacks.ParseMemoryMB(meta.Resources.MemLimit)
afterReqMB := committedReqMB + newReqMB
afterLimitMB := committedLimitMB + newLimitMB
percent := 0
if usableMB > 0 {
percent = afterReqMB * 100 / usableMB
}
committedPercent := 0
if usableMB > 0 {
committedPercent = committedReqMB * 100 / usableMB
}
memInfo["Available"] = true
memInfo["TotalMB"] = totalMB
memInfo["ReservedMB"] = reservedMB
memInfo["UsableMB"] = usableMB
memInfo["CommittedMB"] = committedReqMB
memInfo["NewRequestMB"] = newReqMB
memInfo["AfterMB"] = afterReqMB
memInfo["Percent"] = percent
memInfo["CommittedPercent"] = committedPercent
memInfo["Blocked"] = newReqMB > 0 && afterReqMB > usableMB
memInfo["OvercommitWarn"] = newLimitMB > 0 && afterLimitMB > totalMB
}
data["MemoryInfo"] = memInfo
}
s.render(w, "deploy", data)
}
func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug string) {
var found *stacks.Stack
for _, stack := range s.stackMgr.GetStacks() {
if stack.Meta.Slug == slug {
found = &stack
break
}
}
if found == nil {
http.NotFound(w, r)
return
}
// Load current optional config values from app.yaml
currentValues := make(map[string]string)
if appCfg := s.stackMgr.LoadAppConfigByName(found.Name); appCfg != nil {
for k, v := range appCfg.Env {
currentValues[k] = v
}
}
data := s.baseData("stacks", found.Meta.DisplayName)
data["Stack"] = found
data["Meta"] = found.Meta
data["AppInfo"] = found.Meta.AppInfo
data["OptionalConfig"] = found.Meta.OptionalConfig
data["CurrentValues"] = currentValues
data["HasAppInfo"] = found.Meta.HasAppInfo()
data["HasOptionalConfig"] = found.Meta.HasOptionalConfig()
s.render(w, "app_info", data)
}
func (s *Server) monitoringHandler(w http.ResponseWriter, _ *http.Request) {
data := s.baseData("monitoring", "Rendszermonitor")
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 {
data["Alerts"] = s.alertManager.GetAlerts("pings-missing")
}
// Ping status section
data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled
if s.cfg.Monitoring.Enabled {
pings := []map[string]interface{}{
{"Label": "Életjel (Heartbeat)", "Icon": "💓", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Heartbeat), "Schedule": "5 percenként"},
{"Label": "Rendszer állapot", "Icon": "🖥️", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.SystemHealth), "Schedule": "5 percenként"},
{"Label": "Adatbázis mentés", "Icon": "🗄️", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.DBDump), "Schedule": "Naponta " + s.cfg.Backup.DBDumpSchedule},
{"Label": "Biztonsági mentés", "Icon": "💾", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Backup), "Schedule": "Naponta " + s.cfg.Backup.ResticSchedule},
{"Label": "Mentés integritás", "Icon": "🔍", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.BackupIntegrity), "Schedule": "Hetente (vasárnap)"},
}
allConfigured := true
for _, p := range pings {
if !p["Configured"].(bool) {
allConfigured = false
break
}
}
data["PingStatus"] = pings
data["AllPingsConfigured"] = allConfigured
}
s.render(w, "monitoring", data)
}
// isPingConfigured returns true if a healthcheck ping UUID is non-empty and not a placeholder.
func isPingConfigured(uuid string) bool {
return uuid != "" && !strings.HasPrefix(uuid, "CHANGEME")
}
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.primaryHDDPath(), s.cpuCollector)
if s.backupMgr != nil {
nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
nextBackup := scheduler.NextDailyRun(s.cfg.Backup.ResticSchedule)
fullStatus := s.backupMgr.GetFullStatus(nextDBDump, nextBackup)
// Pass flash messages from query params (set by redirect handlers)
if flash := r.URL.Query().Get("flash"); flash != "" {
fullStatus.FlashSuccess = flash
}
if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" {
fullStatus.FlashError = flashErr
}
// Enrich AppDataInfo with storage labels
storagePaths := s.settings.GetStoragePaths()
for i := range fullStatus.AppDataInfo {
app := &fullStatus.AppDataInfo[i]
if len(app.HDDPaths) > 0 {
hddPath := app.HDDPaths[0].HostPath
// Match HDD path prefix against registered storage paths
for _, sp := range storagePaths {
if strings.HasPrefix(hddPath, sp.Path) {
app.StorageLabel = sp.Label
break
}
}
}
}
data["Backup"] = fullStatus
// Restic password for display
if pw, err := s.backupMgr.GetResticPassword(); err == nil {
data["ResticPassword"] = pw
}
} else {
data["Backup"] = nil
}
s.render(w, "backups", data)
}
func (s *Server) settingsAppBackupHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
if s.backupMgr == nil {
http.Redirect(w, r, "/backups", http.StatusFound)
return
}
// Get current app data info to know which stacks have HDD data
nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
nextBackup := scheduler.NextDailyRun(s.cfg.Backup.ResticSchedule)
fullStatus := s.backupMgr.GetFullStatus(nextDBDump, nextBackup)
prefs := make(map[string]bool)
for _, app := range fullStatus.AppDataInfo {
if app.HasHDDData {
prefs[app.StackName] = r.FormValue("backup_"+app.StackName) == "on"
}
}
if err := s.settings.SetAppBackupBulk(prefs); err != nil {
s.logger.Printf("[ERROR] Failed to save app backup prefs: %v", err)
http.Redirect(w, r, "/backups?flash_error=Hiba+a+ment%C3%A9skor", http.StatusFound)
return
}
s.logger.Printf("[INFO] App backup preferences updated: %v", prefs)
// Trigger cache refresh so the page shows updated data
go s.backupMgr.RefreshCache(nextDBDump, nextBackup)
http.Redirect(w, r, "/backups?flash=Alkalmaz%C3%A1s+ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1sok+mentve.", http.StatusFound)
}
func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
stackName := r.FormValue("stack_name")
snapshotID := r.FormValue("snapshot_id")
if stackName == "" || snapshotID == "" {
http.Redirect(w, r, "/backups?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound)
return
}
if s.backupMgr == nil {
http.Redirect(w, r, "/backups?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound)
return
}
s.logger.Printf("[WARN] Restore requested: stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
if err := s.backupMgr.RestoreApp(stackName, snapshotID); err != nil {
s.logger.Printf("[ERROR] Restore failed: %v", err)
errMsg := url.QueryEscape("Visszaállítás sikertelen: " + err.Error())
http.Redirect(w, r, "/backups?flash_error="+errMsg, http.StatusFound)
return
}
msg := url.QueryEscape(stackName + " visszaállítva (" + snapshotID + ").")
http.Redirect(w, r, "/backups?flash="+msg, http.StatusFound)
}
func (s *Server) settingsData() map[string]interface{} {
data := s.baseData("settings", "Beállítások")
data["CustomerID"] = s.cfg.Customer.ID
data["CustomerDomain"] = s.cfg.Customer.Domain
data["GitRepoURL"] = s.cfg.Git.RepoURL
data["GitSyncInterval"] = s.cfg.Git.SyncInterval
data["BackupEnabled"] = s.cfg.Backup.Enabled
data["DBDumpSchedule"] = s.cfg.Backup.DBDumpSchedule
data["ResticSchedule"] = s.cfg.Backup.ResticSchedule
data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled
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),
AppDetails: s.appDetailsForPath(sp.Path),
FSInfo: system.GetFSInfo(sp.Path),
}
view.AppCount = len(view.AppDetails)
if di := system.GetDiskUsage(sp.Path); di != nil {
view.DiskInfo = di
}
storageViews = append(storageViews, view)
}
data["StoragePaths"] = storageViews
return data
}
func (s *Server) settingsHandler(w http.ResponseWriter, r *http.Request) {
data := s.settingsData()
if msg := r.URL.Query().Get("storage_msg"); msg == "success" {
data["StorageSuccess"] = r.URL.Query().Get("storage_detail")
}
s.render(w, "settings", data)
}
func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
currentPassword := r.FormValue("current_password")
newPassword := r.FormValue("new_password")
confirmPassword := r.FormValue("confirm_password")
data := s.settingsData()
// Validate current password
effectiveHash := s.effectivePasswordHash()
if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(currentPassword)); err != nil {
data["PasswordError"] = "Hibás jelenlegi jelszó"
s.render(w, "settings", data)
return
}
// Validate new password length
if len(newPassword) < 8 {
data["PasswordError"] = "A jelszónak legalább 8 karakter hosszúnak kell lennie"
s.render(w, "settings", data)
return
}
// Validate passwords match
if newPassword != confirmPassword {
data["PasswordError"] = "A két jelszó nem egyezik"
s.render(w, "settings", data)
return
}
// Generate bcrypt hash
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), 10)
if err != nil {
s.logger.Printf("[ERROR] Failed to hash new password: %v", err)
data["PasswordError"] = "Belső hiba a jelszó mentésekor"
s.render(w, "settings", data)
return
}
// Save to settings.json
if err := s.settings.SetPasswordHash(string(hash)); err != nil {
s.logger.Printf("[ERROR] Failed to save password to settings.json: %v", err)
data["PasswordError"] = "Belső hiba a jelszó mentésekor"
s.render(w, "settings", data)
return
}
s.logger.Printf("[INFO] Password changed via settings page from %s", r.RemoteAddr)
// Invalidate all sessions (force re-login)
s.invalidateAllSessions()
// Redirect to login with flash message
flash := url.QueryEscape("Jelszó sikeresen módosítva. Kérjük, jelentkezzen be az új jelszóval.")
http.Redirect(w, r, "/login?flash="+flash, http.StatusFound)
}
func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
email := strings.TrimSpace(r.FormValue("notification_email"))
cooldownStr := r.FormValue("cooldown_hours")
cooldownHours := 6
if cooldownStr != "" {
if n, err := fmt.Sscanf(cooldownStr, "%d", &cooldownHours); n != 1 || err != nil {
cooldownHours = 6
}
}
if cooldownHours < 1 {
cooldownHours = 1
}
if cooldownHours > 168 {
cooldownHours = 168
}
// Collect enabled events from checkboxes
var enabledEvents []string
for _, evt := range []string{"disk_warning", "backup_failed", "update_available", "security_update"} {
if r.FormValue("event_"+evt) == "on" {
enabledEvents = append(enabledEvents, evt)
}
}
prefs := &settings.NotificationPrefs{
Email: email,
EnabledEvents: enabledEvents,
CooldownHours: cooldownHours,
}
if err := s.settings.SetNotificationPrefs(prefs); err != nil {
s.logger.Printf("[ERROR] Failed to save notification prefs: %v", err)
data := s.settingsData()
data["NotificationError"] = "Hiba a beállítások mentésekor"
s.render(w, "settings", data)
return
}
s.logger.Printf("[INFO] Notification preferences updated: email=%s, events=%v", email, enabledEvents)
// Sync preferences to hub
data := s.settingsData()
if s.notifier != nil && s.notifier.IsEnabled() {
if err := s.notifier.SyncPreferences(email, enabledEvents); err != nil {
s.logger.Printf("[WARN] Failed to sync preferences to hub: %v", err)
data["NotificationSuccess"] = fmt.Sprintf("Értesítési beállítások mentve (helyi). A központi szinkronizálás sikertelen: %v", err)
} else {
data["NotificationSuccess"] = "Értesítési beállítások mentve."
}
} else {
data["NotificationSuccess"] = "Értesítési beállítások mentve."
}
s.render(w, "settings", data)
}
func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http.Request) {
data := s.settingsData()
if s.notifier == nil {
data["NotificationError"] = "Az értesítések nincsenek bekapcsolva"
s.render(w, "settings", data)
return
}
err := s.notifier.SendTest()
if err != nil {
s.logger.Printf("[ERROR] Test notification failed: %v", err)
data["NotificationError"] = fmt.Sprintf("Teszt email küldése sikertelen: %v", err)
s.render(w, "settings", data)
return
}
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) appDetailsForPath(storagePath string) []StorageAppDetail {
var details []StorageAppDetail
for _, stack := range s.stackMgr.GetStacks() {
if !stack.Deployed {
continue
}
appCfg := s.stackMgr.LoadAppConfigByName(stack.Name)
if appCfg == nil {
continue
}
hddPath := appCfg.Env["HDD_PATH"]
if hddPath != storagePath {
continue
}
detail := StorageAppDetail{
Name: stack.Meta.DisplayName,
Stack: stack.Meta.Slug,
}
// Try to get data size from the storage subdirectory
appDataDir := filepath.Join(storagePath, "storage", stack.Name)
if fi, err := os.Stat(appDataDir); err == nil && fi.IsDir() {
detail.SizeHuman = dirSizeHuman(appDataDir)
}
details = append(details, detail)
}
return details
}
// dirSizeHuman returns a human-readable size for a directory.
func dirSizeHuman(path string) string {
var total int64
filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
total += info.Size()
return nil
})
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
)
switch {
case total >= GB:
return fmt.Sprintf("%.1f GB", float64(total)/float64(GB))
case total >= MB:
return fmt.Sprintf("%.1f MB", float64(total)/float64(MB))
case total >= KB:
return fmt.Sprintf("%.1f KB", float64(total)/float64(KB))
default:
return fmt.Sprintf("%d B", total)
}
}
func formatFreeSpace(gb float64) string {
if gb >= 1000 {
return fmt.Sprintf("%.1f TB", gb/1024)
}
return fmt.Sprintf("%.1f GB", gb)
}
func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
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?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló sikeresen hozzáadva: "+path), http.StatusFound)
}
func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Request) {
_ = 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?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló eltávolítva: "+path), http.StatusFound)
}
func (s *Server) settingsStorageDefaultHandler(w http.ResponseWriter, r *http.Request) {
_ = 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)
return
}
http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Alapértelmezett adattároló beállítva: "+path), http.StatusFound)
}
func (s *Server) settingsStorageSchedulableHandler(w http.ResponseWriter, r *http.Request) {
_ = 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)
return
}
http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló állapot módosítva: "+path), http.StatusFound)
}
func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
path := r.FormValue("storage_path")
label := strings.TrimSpace(r.FormValue("storage_label"))
if label == "" || len(label) > 50 {
data := s.settingsData()
data["StorageError"] = "A megnevezés nem lehet üres és legfeljebb 50 karakter."
s.render(w, "settings", data)
return
}
if err := s.settings.SetStorageLabel(path, label); err != nil {
s.logger.Printf("[ERROR] Failed to set storage label: %v", err)
data := s.settingsData()
data["StorageError"] = "Hiba a megnevezés mentésekor."
s.render(w, "settings", data)
return
}
s.logger.Printf("[INFO] Storage label updated: %s → %q", path, label)
http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Megnevezés módosítva: "+label), http.StatusFound)
}