4053245be8
- New settings.json persistence layer (internal/settings/settings.go) - Atomic write (tmp + rename), thread-safe with sync.RWMutex - Stores password hash overrides and DB validation cache - Auto-creates on first save, graceful handling if missing - Auth improvements - Password resolution priority: settings.json > controller.yaml > none - Session duration extended to 7 days (was 24h) - ?next= redirect after session expiry (returns to original page) - Flash messages on login page (used after password change) - Conditional logout link (hidden when auth disabled) - Session invalidation on password change - New Settings page (/settings) - Read-only system config display (customer, domain, git, backup, monitoring) - Password change form with validation (min 8 chars, match check) - Sidebar "Beállítások" item pinned to bottom above version - DB validation persistence - Validation results saved to settings.json after each dump - Cached data survives container restarts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
301 lines
9.3 KiB
Go
301 lines
9.3 KiB
Go
package web
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
func (s *Server) baseData(page, title string) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"Page": page,
|
|
"Title": title,
|
|
"CustomerName": s.cfg.Customer.Name,
|
|
"Domain": s.cfg.Customer.Domain,
|
|
"Version": s.version,
|
|
"AuthEnabled": s.authEnabled(),
|
|
}
|
|
}
|
|
|
|
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.cfg.Paths.HDDPath, 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()
|
|
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()
|
|
|
|
// 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.cfg.Paths.HDDPath, s.cpuCollector)
|
|
s.render(w, "monitoring", data)
|
|
}
|
|
|
|
func (s *Server) backupsHandler(w http.ResponseWriter, _ *http.Request) {
|
|
data := s.baseData("backups", "Biztonsági mentés")
|
|
|
|
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["Backup"] = fullStatus
|
|
} else {
|
|
data["Backup"] = nil
|
|
}
|
|
|
|
s.render(w, "backups", data)
|
|
}
|
|
|
|
func (s *Server) settingsHandler(w http.ResponseWriter, _ *http.Request) {
|
|
data := s.baseData("settings", "Beállítások")
|
|
|
|
// System configuration (read-only display from controller.yaml)
|
|
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
|
|
|
|
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.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
|
|
|
|
// 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)
|
|
}
|