diff --git a/CLAUDE.md b/CLAUDE.md index b374639..df65fe6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,14 @@ E:\git\deploy-felhom-compose\ (or /e/git/deploy-felhom-compose/ in Git Bash) │ │ ├── sync/ # Git sync — periodic pull of app catalog repo │ │ ├── api/ # REST API endpoints │ │ ├── system/ # System info (memory, disk) -│ │ └── web/ # Dashboard UI (embedded HTML/CSS templates) +│ │ └── web/ # Dashboard UI +│ │ ├── server.go # Server struct, routing, static serving +│ │ ├── auth.go # Session auth, login/logout handlers +│ │ ├── handlers.go # Page handlers (dashboard, stacks, deploy, etc.) +│ │ ├── funcmap.go # Template function map +│ │ ├── embed.go # go:embed directive for templates +│ │ ├── templates.go # Felhom logo SVG constant +│ │ └── templates/ # go:embed HTML/CSS files (Hungarian UI) │ ├── Dockerfile │ ├── Makefile │ └── go.mod @@ -148,8 +155,8 @@ manually via the dashboard "Sablonok frissítése" button. - **Language:** Go 1.22+ - **Web framework:** stdlib `net/http` + `html/template` (no frameworks) -- **Templates:** Embedded as Go string constants in `templates.go` (Hungarian UI) -- **CSS:** Single embedded const in `templates.go` (no external CSS files) +- **Templates:** go:embed HTML files in `internal/web/templates/` (Hungarian UI) +- **CSS:** go:embed CSS file in `internal/web/templates/style.css` - **Auth:** bcrypt password hash + session cookies - **Container orchestration:** Docker Compose via CLI (`docker compose up -d`) - **Reverse proxy:** Traefik (separate stack, managed by controller) diff --git a/CONTEXT.md b/CONTEXT.md index fc97a3d..8b99c80 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,7 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-02-15 (session 8) +Last updated: 2026-02-15 (session 9) --- @@ -22,13 +22,26 @@ Last updated: 2026-02-15 (session 8) ## Current project state ### felhom-controller (this repo) -- **Version:** v0.2.15 +- **Version:** v0.3.0 - **Phase 1:** ✅ COMPLETE — Stack Manager + Deploy Flow - **First app deployed:** Paperless-ngx on demo-felhom.eu (2026-02-13) - **Running on:** demo-felhom (N100 mini PC) at 192.168.0.162:8080 - **All Phase 1 features working:** deploy, start/stop/restart/update, logs, health-aware states, auth -### What was just completed (2026-02-15 session 8) +### What was just completed (2026-02-15 session 9) +- **v0.3.0 — Structural refactoring (templates + server split + domain rename):** + - **Templates: go:embed migration** — moved all 7 HTML templates + CSS from Go string constants to individual files in `internal/web/templates/`. Created `embed.go` with `//go:embed` directive. Template loading now uses `ParseFS()` instead of `Parse()`. CSS served from embed.FS via `ReadFile()`. Zero runtime file dependencies — still compiled into the binary. + - **Server decomposition** — split monolithic `server.go` (540 lines) into focused files: + - `auth.go`: session struct, auth middleware, login/logout handlers, session management + - `handlers.go`: page handlers (dashboard, stacks, logs, deploy, app detail) + - `funcmap.go`: template FuncMap with 14 custom functions + - `server.go`: Server struct, NewServer, loadTemplates (3-liner), ServeHTTP routing, render helper, static file serving + - **Domain rename** — controller subdomain changed from `dashboard.*` to `felhom.*` in Traefik labels and setup script + - **Documentation updated** — CLAUDE.md, README.md, CONTEXT.md all reflect new file structure + - **Reminder for Viktor:** Update Cloudflare Tunnel public hostname (`dashboard.demo-felhom.eu` → `felhom.demo-felhom.eu`) and Pi-hole DNS if needed +- **Controller version:** v0.3.0 + +### What was previously completed (2026-02-15 session 8) - **FileBrowser as infrastructure service:** - Created `scripts/hdd-setup.sh` (adapted from deploy-portainer) — sets up HDD folder structure with `Dokumentumok` user dir - Created `scripts/docker-setup.sh` (adapted from deploy-portainer) — installs Docker, Traefik, FileBrowser as infra services @@ -191,7 +204,7 @@ Last updated: 2026-02-15 (session 8) | Decision | Rationale | |----------|-----------| | Go stdlib for web (no Gin/Echo) | Minimal dependencies, single binary, easy to embed templates | -| Templates as Go string constants | Zero runtime file dependencies, everything in the binary | +| Templates as go:embed HTML/CSS files | Zero runtime file dependencies (compiled into binary), but each template is a separate editable file | | Docker Compose for customers (not k8s) | Simpler troubleshooting, customers don't need k8s knowledge | | k3s for management infra only | Viktor's own services (gitea, monitoring, website) run on k3s | | Cloudflare Tunnel for remote access | No port forwarding needed, works behind any NAT | diff --git a/controller/README.md b/controller/README.md index a2dc906..7ef7d40 100644 --- a/controller/README.md +++ b/controller/README.md @@ -24,7 +24,7 @@ controller generates secrets, saves app.yaml, runs `docker compose up -d`, and t with Traefik routing and health checks. The dashboard correctly shows real-time container states including health substatus (starting → healthy → running). -Current version: **v0.2.111** +Current version: **v0.3.0** ### What works - Dashboard with live container state (green/orange/yellow/red) @@ -106,8 +106,16 @@ controller/ │ │ ├── info_linux.go # Linux: /proc/meminfo + statfs │ │ └── info_other.go # Non-Linux stub │ └── web/ -│ ├── server.go # HTTP server, auth, page handlers, asset serving -│ └── templates.go # Embedded HTML templates + CSS (Hungarian UI) +│ ├── server.go # HTTP server, routing, static file serving +│ ├── auth.go # Session auth, login/logout handlers +│ ├── handlers.go # Page handlers (dashboard, stacks, deploy, etc.) +│ ├── funcmap.go # Template function map (state colors, formatting) +│ ├── embed.go # go:embed directive for templates +│ ├── templates.go # Felhom logo SVG constant +│ └── templates/ # go:embed HTML/CSS files (Hungarian UI) +│ ├── layout.html, dashboard.html, stacks.html, login.html +│ ├── logs.html, deploy.html, app_info.html +│ └── style.css ├── configs/ │ ├── controller.yaml.example # Full config reference (infrastructure only) │ └── example-felhom-metadata.yml # .felhom.yml format reference diff --git a/controller/docker-compose.yml b/controller/docker-compose.yml index a6de6c1..bd451f9 100644 --- a/controller/docker-compose.yml +++ b/controller/docker-compose.yml @@ -29,7 +29,7 @@ services: - TZ=Europe/Budapest labels: - "traefik.enable=true" - - "traefik.http.routers.controller.rule=Host(`dashboard.${DOMAIN}`)" + - "traefik.http.routers.controller.rule=Host(`felhom.${DOMAIN}`)" - "traefik.http.routers.controller.entrypoints=websecure" - "traefik.http.routers.controller.tls=true" - "traefik.http.services.controller.loadbalancer.server.port=8080" diff --git a/controller/internal/web/auth.go b/controller/internal/web/auth.go new file mode 100644 index 0000000..c81c388 --- /dev/null +++ b/controller/internal/web/auth.go @@ -0,0 +1,155 @@ +package web + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "fmt" + "net/http" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +type session struct { + token string + expiresAt time.Time +} + +const ( + sessionCookieName = "felhom_session" + sessionMaxAge = 24 * time.Hour +) + +// RequireAuth returns middleware that checks for valid session or shows login. +func (s *Server) RequireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Skip auth if no password is configured + if s.cfg.Web.PasswordHash == "" { + next.ServeHTTP(w, r) + return + } + + if r.URL.Path == "/api/health" { + next.ServeHTTP(w, r) + return + } + + if r.URL.Path == "/login" && r.Method == http.MethodPost { + s.handleLogin(w, r) + return + } + if r.URL.Path == "/login" { + s.renderLogin(w, "") + return + } + if r.URL.Path == "/logout" { + s.handleLogout(w, r) + return + } + + cookie, err := r.Cookie(sessionCookieName) + if err != nil || !s.isValidSession(cookie.Value) { + if strings.HasPrefix(r.URL.Path, "/api/") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, `{"ok":false,"error":"authentication required"}`) + return + } + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + next.ServeHTTP(w, r) + }) +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + password := r.FormValue("password") + + if password == "" { + s.renderLogin(w, "Kérjük adja meg a jelszót") + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(s.cfg.Web.PasswordHash), []byte(password)); err != nil { + s.logger.Printf("[WARN] Failed login from %s", r.RemoteAddr) + s.renderLogin(w, "Hibás jelszó") + return + } + + token := s.createSession() + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: token, + Path: "/", + MaxAge: int(sessionMaxAge.Seconds()), + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Secure: true, + }) + + s.logger.Printf("[INFO] Login from %s", r.RemoteAddr) + http.Redirect(w, r, "/", http.StatusFound) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie(sessionCookieName); err == nil { + s.sessionsMu.Lock() + delete(s.sessions, cookie.Value) + s.sessionsMu.Unlock() + } + http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1}) + http.Redirect(w, r, "/login", http.StatusFound) +} + +func (s *Server) createSession() string { + b := make([]byte, 32) + _, _ = rand.Read(b) + token := hex.EncodeToString(b) + + s.sessionsMu.Lock() + s.sessions[token] = &session{token: token, expiresAt: time.Now().Add(sessionMaxAge)} + s.sessionsMu.Unlock() + + return token +} + +func (s *Server) isValidSession(token string) bool { + s.sessionsMu.RLock() + defer s.sessionsMu.RUnlock() + + sess, ok := s.sessions[token] + if !ok || time.Now().After(sess.expiresAt) { + return false + } + return subtle.ConstantTimeCompare([]byte(sess.token), []byte(token)) == 1 +} + +func (s *Server) cleanupSessions() { + for range time.Tick(15 * time.Minute) { + s.sessionsMu.Lock() + now := time.Now() + for t, sess := range s.sessions { + if now.After(sess.expiresAt) { + delete(s.sessions, t) + } + } + s.sessionsMu.Unlock() + } +} + +func (s *Server) renderLogin(w http.ResponseWriter, errorMsg string) { + data := map[string]interface{}{ + "Title": "Bejelentkezés", + "CustomerName": s.cfg.Customer.Name, + "Error": errorMsg, + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.tmpl.ExecuteTemplate(w, "login", data); err != nil { + s.logger.Printf("[ERROR] Template error (login): %v", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + } +} diff --git a/controller/internal/web/embed.go b/controller/internal/web/embed.go new file mode 100644 index 0000000..1f3ec85 --- /dev/null +++ b/controller/internal/web/embed.go @@ -0,0 +1,6 @@ +package web + +import "embed" + +//go:embed templates/*.html templates/*.css +var templateFS embed.FS diff --git a/controller/internal/web/funcmap.go b/controller/internal/web/funcmap.go new file mode 100644 index 0000000..8d220ef --- /dev/null +++ b/controller/internal/web/funcmap.go @@ -0,0 +1,133 @@ +package web + +import ( + "fmt" + "html/template" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// templateFuncMap returns the FuncMap used by all HTML templates. +func (s *Server) templateFuncMap() template.FuncMap { + return template.FuncMap{ + "stateColor": func(state stacks.ContainerState) string { + switch state { + case stacks.StateRunning: + return "green" + case stacks.StateStarting: + return "orange" + case stacks.StateUnhealthy: + return "yellow" + case stacks.StateStopped, stacks.StateExited: + return "red" + case stacks.StateRestarting: + return "yellow" + default: + return "gray" + } + }, + "stateLabel": func(state stacks.ContainerState) string { + switch state { + case stacks.StateRunning: + return "Fut" + case stacks.StateStarting: + return "Indulás..." + case stacks.StateUnhealthy: + return "Nem egészséges" + case stacks.StateStopped, stacks.StateExited: + return "Leállítva" + case stacks.StateRestarting: + return "Újraindítás..." + case stacks.StateNotDeployed: + return "Nincs telepítve" + case stacks.StatePaused: + return "Szüneteltetve" + default: + return "Ismeretlen" + } + }, + "stateIcon": func(state stacks.ContainerState) string { + switch state { + case stacks.StateRunning: + return "●" + case stacks.StateStarting: + return "◐" + case stacks.StateUnhealthy: + return "◑" + case stacks.StateStopped, stacks.StateExited: + return "○" + case stacks.StateRestarting: + return "◐" + default: + return "◌" + } + }, + "stateStr": func(state stacks.ContainerState) string { + return string(state) + }, + // isOperational returns true for any state where the stack has containers + // and is not stopped/exited — used by templates for showing action buttons + "isOperational": func(state stacks.ContainerState) bool { + switch state { + case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting: + return true + default: + return false + } + }, + "logoURL": func(slug string) string { + return s.cfg.AppLogoURL(slug) + }, + "logoPNGURL": func(slug string) string { + return s.cfg.AppLogoPNGURL(slug) + }, + "appPageURL": func(slug string) string { + return s.cfg.AppPageURL(slug) + }, + "usageColor": func(percent float64) string { + if percent >= 85 { + return "red" + } + if percent >= 70 { + return "yellow" + } + return "green" + }, + "fmtMB": func(mb uint64) string { + if mb >= 1024 { + gb := float64(mb) / 1024.0 + if gb >= 10 { + return fmt.Sprintf("%.0f GB", gb) + } + return fmt.Sprintf("%.1f GB", gb) + } + return fmt.Sprintf("%d MB", mb) + }, + "fmtGB": func(gb float64) string { + if gb >= 100 { + return fmt.Sprintf("%.0f GB", gb) + } + if gb >= 10 { + return fmt.Sprintf("%.1f GB", gb) + } + return fmt.Sprintf("%.2f GB", gb) + }, + "subtract": func(a, b int) int { + r := a - b + if r < 0 { + return 0 + } + return r + }, + "screenshotURL": func(slug string, index int) string { + return s.cfg.AppScreenshotURL(slug, index) + }, + "seq": func(n int) []int { + result := make([]int, n) + for i := range result { + result[i] = i + 1 + } + return result + }, + } +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go new file mode 100644 index 0000000..d8f9f91 --- /dev/null +++ b/controller/internal/web/handlers.go @@ -0,0 +1,173 @@ +package web + +import ( + "fmt" + "net/http" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" + "gitea.dooplex.hu/admin/felhom-controller/internal/system" +) + +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, + } +} + +func (s *Server) dashboardHandler(w http.ResponseWriter, _ *http.Request) { + stackList := s.stackMgr.GetStacks() + + running, stopped := 0, 0 + for _, st := range stackList { + switch st.State { + case stacks.StateRunning: + running++ + case stacks.StateStopped, stacks.StateExited: + stopped++ + case stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting: + // Count starting/unhealthy/restarting as "running" for the dashboard stat + // (they have containers, they're just not fully healthy yet) + running++ + } + } + + sysInfo := system.GetInfo(s.cfg.Paths.HDDPath) + + data := s.baseData("dashboard", "Vezérlőpult") + data["Stacks"] = stackList + data["RunningCount"] = running + data["StoppedCount"] = stopped + data["TotalCount"] = len(stackList) + data["SystemInfo"] = sysInfo + + 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, _ *http.Request, name string) { + meta, appCfg, err := s.stackMgr.GetDeployFields(name) + if err != nil { + http.NotFound(w, nil) + 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, _ *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, nil) + 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) +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 53cf3cb..97f0aed 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -1,9 +1,6 @@ package web import ( - "crypto/rand" - "crypto/subtle" - "encoding/hex" "fmt" "html/template" "log" @@ -12,12 +9,9 @@ import ( "path/filepath" "strings" "sync" - "time" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" - "gitea.dooplex.hu/admin/felhom-controller/internal/system" - "golang.org/x/crypto/bcrypt" ) type Server struct { @@ -31,16 +25,6 @@ type Server struct { sessionsMu sync.RWMutex } -type session struct { - token string - expiresAt time.Time -} - -const ( - sessionCookieName = "felhom_session" - sessionMaxAge = 24 * time.Hour -) - func NewServer(cfg *config.Config, stackMgr *stacks.Manager, logger *log.Logger, version string) *Server { s := &Server{ cfg: cfg, @@ -55,129 +39,9 @@ func NewServer(cfg *config.Config, stackMgr *stacks.Manager, logger *log.Logger, } func (s *Server) loadTemplates() { - funcMap := template.FuncMap{ - "stateColor": func(state stacks.ContainerState) string { - switch state { - case stacks.StateRunning: - return "green" - case stacks.StateStarting: - return "orange" - case stacks.StateUnhealthy: - return "yellow" - case stacks.StateStopped, stacks.StateExited: - return "red" - case stacks.StateRestarting: - return "yellow" - default: - return "gray" - } - }, - "stateLabel": func(state stacks.ContainerState) string { - switch state { - case stacks.StateRunning: - return "Fut" - case stacks.StateStarting: - return "Indulás..." - case stacks.StateUnhealthy: - return "Nem egészséges" - case stacks.StateStopped, stacks.StateExited: - return "Leállítva" - case stacks.StateRestarting: - return "Újraindítás..." - case stacks.StateNotDeployed: - return "Nincs telepítve" - case stacks.StatePaused: - return "Szüneteltetve" - default: - return "Ismeretlen" - } - }, - "stateIcon": func(state stacks.ContainerState) string { - switch state { - case stacks.StateRunning: - return "●" - case stacks.StateStarting: - return "◐" - case stacks.StateUnhealthy: - return "◑" - case stacks.StateStopped, stacks.StateExited: - return "○" - case stacks.StateRestarting: - return "◐" - default: - return "◌" - } - }, - "stateStr": func(state stacks.ContainerState) string { - return string(state) - }, - // isOperational returns true for any state where the stack has containers - // and is not stopped/exited — used by templates for showing action buttons - "isOperational": func(state stacks.ContainerState) bool { - switch state { - case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting: - return true - default: - return false - } - }, - "logoURL": func(slug string) string { - return s.cfg.AppLogoURL(slug) - }, - "logoPNGURL": func(slug string) string { - return s.cfg.AppLogoPNGURL(slug) - }, - "appPageURL": func(slug string) string { - return s.cfg.AppPageURL(slug) - }, - "usageColor": func(percent float64) string { - if percent >= 85 { - return "red" - } - if percent >= 70 { - return "yellow" - } - return "green" - }, - "fmtMB": func(mb uint64) string { - if mb >= 1024 { - gb := float64(mb) / 1024.0 - if gb >= 10 { - return fmt.Sprintf("%.0f GB", gb) - } - return fmt.Sprintf("%.1f GB", gb) - } - return fmt.Sprintf("%d MB", mb) - }, - "fmtGB": func(gb float64) string { - if gb >= 100 { - return fmt.Sprintf("%.0f GB", gb) - } - if gb >= 10 { - return fmt.Sprintf("%.1f GB", gb) - } - return fmt.Sprintf("%.2f GB", gb) - }, - "subtract": func(a, b int) int { - r := a - b - if r < 0 { - return 0 - } - return r - }, - "screenshotURL": func(slug string, index int) string { - return s.cfg.AppScreenshotURL(slug, index) - }, - "seq": func(n int) []int { - result := make([]int, n) - for i := range result { - result[i] = i + 1 - } - return result - }, - } - - s.tmpl = template.Must(template.New("").Funcs(funcMap).Parse(allTemplates)) + s.tmpl = template.Must( + template.New("").Funcs(s.templateFuncMap()).ParseFS(templateFS, "templates/*.html"), + ) } // ServeHTTP handles all non-API web requests. @@ -198,13 +62,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { name = strings.TrimSuffix(name, "/deploy") s.deployHandler(w, r, name) case path == "/static/style.css": - w.Header().Set("Content-Type", "text/css") - w.Header().Set("Cache-Control", "public, max-age=3600") - fmt.Fprint(w, cssContent) + s.serveCSSHandler(w, r) case path == "/static/felhom-logo.svg": - w.Header().Set("Content-Type", "image/svg+xml") - w.Header().Set("Cache-Control", "public, max-age=86400") - fmt.Fprint(w, felhomLogoSVG) + s.serveLogoHandler(w, r) case strings.HasPrefix(path, "/static/assets/"): s.serveAsset(w, r, strings.TrimPrefix(path, "/static/assets/")) case strings.HasPrefix(path, "/apps/"): @@ -215,258 +75,31 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } -// RequireAuth returns middleware that checks for valid session or shows login. -func (s *Server) RequireAuth(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Skip auth if no password is configured - if s.cfg.Web.PasswordHash == "" { - next.ServeHTTP(w, r) - return - } - - if r.URL.Path == "/api/health" { - next.ServeHTTP(w, r) - return - } - - if r.URL.Path == "/login" && r.Method == http.MethodPost { - s.handleLogin(w, r) - return - } - if r.URL.Path == "/login" { - s.renderLogin(w, "") - return - } - if r.URL.Path == "/logout" { - s.handleLogout(w, r) - return - } - - cookie, err := r.Cookie(sessionCookieName) - if err != nil || !s.isValidSession(cookie.Value) { - if strings.HasPrefix(r.URL.Path, "/api/") { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusUnauthorized) - fmt.Fprint(w, `{"ok":false,"error":"authentication required"}`) - return - } - http.Redirect(w, r, "/login", http.StatusFound) - return - } - - next.ServeHTTP(w, r) - }) -} - -// --- Auth helpers --- - -func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { - _ = r.ParseForm() - password := r.FormValue("password") - - if password == "" { - s.renderLogin(w, "Kérjük adja meg a jelszót") - return - } - - if err := bcrypt.CompareHashAndPassword([]byte(s.cfg.Web.PasswordHash), []byte(password)); err != nil { - s.logger.Printf("[WARN] Failed login from %s", r.RemoteAddr) - s.renderLogin(w, "Hibás jelszó") - return - } - - token := s.createSession() - http.SetCookie(w, &http.Cookie{ - Name: sessionCookieName, - Value: token, - Path: "/", - MaxAge: int(sessionMaxAge.Seconds()), - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - Secure: true, - }) - - s.logger.Printf("[INFO] Login from %s", r.RemoteAddr) - http.Redirect(w, r, "/", http.StatusFound) -} - -func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { - if cookie, err := r.Cookie(sessionCookieName); err == nil { - s.sessionsMu.Lock() - delete(s.sessions, cookie.Value) - s.sessionsMu.Unlock() - } - http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1}) - http.Redirect(w, r, "/login", http.StatusFound) -} - -func (s *Server) createSession() string { - b := make([]byte, 32) - _, _ = rand.Read(b) - token := hex.EncodeToString(b) - - s.sessionsMu.Lock() - s.sessions[token] = &session{token: token, expiresAt: time.Now().Add(sessionMaxAge)} - s.sessionsMu.Unlock() - - return token -} - -func (s *Server) isValidSession(token string) bool { - s.sessionsMu.RLock() - defer s.sessionsMu.RUnlock() - - sess, ok := s.sessions[token] - if !ok || time.Now().After(sess.expiresAt) { - return false - } - return subtle.ConstantTimeCompare([]byte(sess.token), []byte(token)) == 1 -} - -func (s *Server) cleanupSessions() { - for range time.Tick(15 * time.Minute) { - s.sessionsMu.Lock() - now := time.Now() - for t, sess := range s.sessions { - if now.After(sess.expiresAt) { - delete(s.sessions, t) - } - } - s.sessionsMu.Unlock() +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 { + s.logger.Printf("[ERROR] Template error (%s): %v", name, err) + http.Error(w, "Internal error", http.StatusInternalServerError) } } -// --- Page handlers --- +// --- Static file / asset serving --- -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, - } -} - -func (s *Server) dashboardHandler(w http.ResponseWriter, _ *http.Request) { - stackList := s.stackMgr.GetStacks() - - running, stopped := 0, 0 - for _, st := range stackList { - switch st.State { - case stacks.StateRunning: - running++ - case stacks.StateStopped, stacks.StateExited: - stopped++ - case stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting: - // Count starting/unhealthy/restarting as "running" for the dashboard stat - // (they have containers, they're just not fully healthy yet) - running++ - } - } - - sysInfo := system.GetInfo(s.cfg.Paths.HDDPath) - - data := s.baseData("dashboard", "Vezérlőpult") - data["Stacks"] = stackList - data["RunningCount"] = running - data["StoppedCount"] = stopped - data["TotalCount"] = len(stackList) - data["SystemInfo"] = sysInfo - - 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) +func (s *Server) serveCSSHandler(w http.ResponseWriter, r *http.Request) { + data, err := templateFS.ReadFile("templates/style.css") 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) + http.Error(w, "CSS not found", 500) return } - - data := s.baseData("logs", stack.Meta.DisplayName+" — Naplók") - data["Stack"] = stack - data["Logs"] = logs - s.render(w, "logs", data) + w.Header().Set("Content-Type", "text/css; charset=utf-8") + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Write(data) } -func (s *Server) deployHandler(w http.ResponseWriter, _ *http.Request, name string) { - meta, appCfg, err := s.stackMgr.GetDeployFields(name) - if err != nil { - http.NotFound(w, nil) - 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) serveLogoHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/svg+xml") + w.Header().Set("Cache-Control", "public, max-age=86400") + fmt.Fprint(w, felhomLogoSVG) } // serveAsset serves baked-in app assets (logos, screenshots) from /usr/share/felhom/assets/ @@ -484,57 +117,3 @@ func (s *Server) serveAsset(w http.ResponseWriter, r *http.Request, filename str w.Header().Set("Cache-Control", "public, max-age=86400") http.ServeFile(w, r, path) } - -func (s *Server) appDetailHandler(w http.ResponseWriter, _ *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, nil) - 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) renderLogin(w http.ResponseWriter, errorMsg string) { - data := map[string]interface{}{ - "Title": "Bejelentkezés", - "CustomerName": s.cfg.Customer.Name, - "Error": errorMsg, - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := s.tmpl.ExecuteTemplate(w, "login", data); err != nil { - s.logger.Printf("[ERROR] Template error (login): %v", err) - http.Error(w, "Internal error", http.StatusInternalServerError) - } -} - -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 { - s.logger.Printf("[ERROR] Template error (%s): %v", name, err) - http.Error(w, "Internal error", http.StatusInternalServerError) - } -} \ No newline at end of file diff --git a/controller/internal/web/templates.go b/controller/internal/web/templates.go index d1f4ce7..9e4feb9 100644 --- a/controller/internal/web/templates.go +++ b/controller/internal/web/templates.go @@ -1,2157 +1,5 @@ package web -// All HTML templates and CSS are embedded as Go strings. -// Compiled into the binary — zero external file dependencies at runtime. -// As the UI grows, switch to go:embed for easier editing. - -const allTemplates = layoutTmpl + dashboardTmpl + stacksTmpl + loginTmpl + logsTmpl + deployTmpl + appInfoTmpl - -const layoutTmpl = ` -{{define "layout_start"}} - - - - - - {{.Title}}Felhom.eu - - - - -
-{{end}} - -{{define "layout_end"}} -
- - - -{{end}} -` - -const dashboardTmpl = ` -{{define "dashboard"}} -{{template "layout_start" .}} - - - -
-
-
{{.RunningCount}}
-
Futó alkalmazás
-
-
-
{{.StoppedCount}}
-
Leállítva
-
-
-
{{.TotalCount}}
-
Összes alkalmazás
-
-
- -{{if .SystemInfo.TotalMemMB}} -
-
-
-
- Memória - {{fmtMB .SystemInfo.UsedMemMB}} / {{fmtMB .SystemInfo.TotalMemMB}} ({{printf "%.0f" .SystemInfo.MemPercent}}%) -
-
-
-
-
-
-
- SSD tárhely - {{fmtGB .SystemInfo.DiskUsedGB}} / {{fmtGB .SystemInfo.DiskTotalGB}} ({{printf "%.0f" .SystemInfo.DiskPercent}}%) -
-
-
-
-
- {{if .SystemInfo.HDDConfigured}} -
-
- Külső HDD - {{fmtGB .SystemInfo.HDDUsedGB}} / {{fmtGB .SystemInfo.HDDTotalGB}} ({{printf "%.0f" .SystemInfo.HDDPercent}}%) -
-
-
-
-
- {{end}} -
-
-{{end}} - -

Alkalmazások állapota

- -
- {{range .Stacks}} -
-
- -
- {{.Meta.DisplayName}} - {{if .Meta.Description}}{{.Meta.Description}}{{end}} -
-
-
- {{stateLabel .State}} - {{if .Orphaned}}Elavult{{end}} - - {{if .Protected}} - Védett - {{else if not .Deployed}} - Telepítés - {{else}} - {{if isOperational .State}} - - - {{else}} - - {{end}} - Napló - {{if .Orphaned}}{{end}} - {{end}} -
-
- {{else}} -
-

Nincs elérhető alkalmazás.

-
- {{end}} -
- -{{template "layout_end" .}} -{{end}} -` - -const stacksTmpl = ` -{{define "stacks"}} -{{template "layout_start" .}} - - - - -
- {{range .Stacks}} -
-
-
- -
-

{{.Meta.DisplayName}}

- {{if .Meta.Subdomain}} - - {{.Meta.Subdomain}}.{{$.Domain}} ↗ - - {{end}} -
-
- {{stateLabel .State}} - {{if .Orphaned}}Elavult{{end}} -
- - {{if .Meta.Description}} -

{{.Meta.Description}}

- {{end}} - -
- {{if .Meta.Resources.MemRequest}}~{{.Meta.Resources.MemRequest}}{{end}} - {{if .Meta.Resources.PiCompatible}}Pi kompatibilis{{end}} - {{if .Meta.Resources.NeedsHDD}}HDD szükséges{{end}} -
- - {{if .Containers}} -
- {{range .Containers}} -
- {{.Name}} - {{.Status}} -
- {{end}} -
- {{end}} - -
- {{if .Protected}} - Védett rendszerkomponens - {{else if not .Deployed}} - Telepítés - Részletek - {{else}} - {{if isOperational .State}} - {{if not .Orphaned}}{{end}} - - - {{else}} - - {{end}} - Naplók - {{if not .Orphaned}}Részletek{{end}} - {{if .Orphaned}}{{end}} - {{end}} -
-
- {{end}} -
- -{{template "layout_end" .}} -{{end}} -` - -const deployTmpl = ` -{{define "deploy"}} -{{template "layout_start" .}} - - - -
-
- -
-

{{.Meta.DisplayName}}

- {{if .Meta.Description}}

{{.Meta.Description}}

{{end}} -
- {{if .Meta.Resources.MemRequest}}~{{.Meta.Resources.MemRequest}}{{end}} - {{if .Meta.Resources.PiCompatible}}Pi kompatibilis{{end}} - {{if .Meta.Resources.NeedsHDD}}HDD szükséges{{end}} -
- - Részletes leírás, képernyőképek - -
-
- - {{if .AlreadyDeployed}} -
- Ez az alkalmazás már telepítve van. Az alábbi beállítások csak olvashatók. -
- {{end}} - - {{if and (not .AlreadyDeployed) .MemoryInfo}} - {{with .MemoryInfo}} - {{if .Available}} -
- {{if .Blocked}} -
- Nincs elég memória! Foglalás telepítés után: {{.AfterMB}} MB / {{.UsableMB}} MB -
- {{else}} -
- Memória foglalás - {{.AfterMB}} MB / {{.UsableMB}} MB ({{.Percent}}%) -
-
-
-
-
-
- Jelenlegi foglalás ({{.CommittedMB}} MB) - {{$.Meta.DisplayName}} (+{{.NewRequestMB}} MB) -
- {{if .OvercommitWarn}} -
- Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát. - Normál használat mellett ez nem okoz problémát. -
- {{end}} - {{end}} -
- {{end}} - {{end}} - {{end}} - -
- {{if .AutoFields}} -
-

Automatikusan generált értékek

-

Ezek az értékek automatikusan létrejönnek a telepítéskor.

- {{range .AutoFields}} -
- - ✓ Automatikusan generálva -
- {{end}} -
- {{end}} - - {{if .UserFields}} -
-

Beállítások

- {{range .UserFields}} -
- - - {{if eq .Type "select"}} - - {{else if eq .Type "password"}} -
- - -
- {{else if eq .Type "boolean"}} - - {{else}} - - {{end}} - - {{if .Description}} - {{.Description}} - {{end}} -
- {{end}} -
- {{end}} - - {{if not .AlreadyDeployed}} -
- - Mégsem -
- {{end}} -
- - -
- - - -{{template "layout_end" .}} -{{end}} -` - -const loginTmpl = ` -{{define "login"}} - - - - - - Bejelentkezés — Felhom - - - -
- -

{{.CustomerName}}

- {{if .Error}}
{{.Error}}
{{end}} -
-
- - -
- -
- -
- - -{{end}} -` - -const logsTmpl = ` -{{define "logs"}} -{{template "layout_start" .}} - -
-
{{.Logs}}
-
-
- - Élő - - - -
- -{{template "layout_end" .}} -{{end}} -` - -const appInfoTmpl = ` -{{define "app_info"}} -{{template "layout_start" .}} - - - - -
- -
- {{if .AppInfo.Tagline}} -

{{.AppInfo.Tagline}}

- {{else}} -

{{.Meta.Description}}

- {{end}} -
- ~{{.Meta.Resources.MemRequest}} RAM - {{.Meta.Category}} - {{if .Meta.Resources.NeedsHDD}}HDD szükséges{{end}} - {{if .Meta.Resources.PiCompatible}}Pi kompatibilis{{else}}Csak x86{{end}} -
-
-
- - -
- - - -
- -{{if .HasAppInfo}} -
- {{if .AppInfo.UseCases}} -
-

Mire használható?

- -
- {{end}} - - {{if .AppInfo.FirstSteps}} -
-

Első lépések

-
    - {{range .AppInfo.FirstSteps}}
  1. {{.}}
  2. {{end}} -
-
- {{end}} - - {{if .AppInfo.Prerequisites}} -
-

Előfeltételek

- -
- {{end}} - - {{if .AppInfo.DefaultCreds}} -
-

Alapértelmezett belépés

-

{{.AppInfo.DefaultCreds}}

-

Az első bejelentkezés után azonnal változtasd meg!

-
- {{end}} - - {{if .AppInfo.DocsURL}} -
-

Dokumentáció

-

Hivatalos dokumentáció ↗

-
- {{end}} -
-{{end}} - -{{if .HasOptionalConfig}} -
-

Opcionális beállítások

- {{range .OptionalConfig}} -
-

{{.Group}}

- {{if .Description}}

{{.Description}}

{{end}} - -
- {{range .Fields}} -
- - {{if .HelpText}}

{{.HelpText}}

{{end}} - {{if .HelpURL}}

Regisztrációs útmutató ↗

{{end}} - -
- {{end}} -
-
- {{end}} - -
- - -
-
- - -{{end}} - -{{template "layout_end" .}} -{{end}} -` - -// CSS is defined in a separate const for readability. -// Served at /static/style.css -const cssContent = ` -@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); - -:root { - --bg-primary: #0d1117; - --bg-secondary: #161b22; - --bg-card: #1c2128; - --text-primary: #e6edf3; - --text-secondary: #8b949e; - --text-muted: #6e7681; - --accent-blue: #0088cc; - --accent-light: #00aaff; - --accent-glow: rgba(0, 136, 204, 0.3); - --border-color: #30363d; - --green: #238636; - --green-bg: rgba(35, 134, 54, 0.15); - --red: #da3633; - --red-bg: rgba(218, 54, 51, 0.15); - --yellow: #d29922; - --yellow-bg: rgba(210, 153, 34, 0.15); - --orange: #db6d28; - --orange-bg: rgba(219, 109, 40, 0.15); - --gray: #6e7681; - --gray-bg: rgba(110, 118, 129, 0.15); - --radius: 12px; - --shadow: 0 2px 8px rgba(0, 0, 0, 0.3); -} - -* { margin: 0; padding: 0; box-sizing: border-box; } -html { scroll-behavior: smooth; } - -body { - font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif; - background: var(--bg-primary); - color: var(--text-primary); - display: flex; - min-height: 100vh; - line-height: 1.6; -} - -body::before { - content: ''; - position: fixed; - top: 0; left: 0; - width: 100%; height: 100%; - background-image: - linear-gradient(rgba(0, 136, 204, 0.03) 1px, transparent 1px), - linear-gradient(90deg, rgba(0, 136, 204, 0.03) 1px, transparent 1px); - background-size: 50px 50px; - pointer-events: none; - z-index: -1; -} - -/* Sidebar */ -.sidebar { - width: 240px; - background: var(--bg-secondary); - color: var(--text-primary); - display: flex; - flex-direction: column; - position: fixed; - height: 100vh; - overflow-y: auto; - border-right: 1px solid var(--border-color); -} -.sidebar-header { - padding: 1.5rem; - border-bottom: 1px solid var(--border-color); -} -.sidebar-logo { - width: 140px; - height: auto; - display: block; - margin-bottom: 0.25rem; -} -.login-logo { - width: 200px; - height: auto; - display: block; - margin: 0 auto 0.5rem; -} -.customer-name { - display: block; - font-size: .85rem; - color: var(--text-secondary); - margin-top: .25rem; -} -.nav-links { - list-style: none; - padding: 1rem 0; - flex: 1; -} -.nav-links a { - display: block; - padding: .75rem 1.5rem; - color: var(--text-secondary); - text-decoration: none; - font-size: .95rem; - font-weight: 500; - transition: color 0.2s ease, background 0.2s ease; -} -.nav-links a:hover { - color: var(--accent-light); - background: rgba(0, 136, 204, 0.08); -} -.nav-links a.active { - color: var(--accent-light); - background: rgba(0, 136, 204, 0.12); - border-left: 3px solid var(--accent-blue); -} -.sidebar-footer { - padding: 1rem 1.5rem; - border-top: 1px solid var(--border-color); - display: flex; - justify-content: space-between; - align-items: center; - font-size: .8rem; -} -.version { color: var(--text-muted); } -.logout-link { color: var(--text-muted); text-decoration: none; transition: color 0.2s ease; } -.logout-link:hover { color: var(--accent-light); } - -/* Main content */ -.content { - margin-left: 240px; - padding: 2rem; - flex: 1; - max-width: 1200px; -} -.page-header { - display: flex; - align-items: center; - gap: 1rem; - margin-bottom: 1.5rem; -} -.page-header h2 { - font-size: 1.5rem; - font-weight: 600; - color: var(--text-primary); -} -.domain-badge { - background: rgba(0, 136, 204, 0.15); - color: var(--accent-light); - padding: .25rem .75rem; - border-radius: 999px; - font-size: .8rem; - font-weight: 500; -} - -h3 { - font-size: 1.1rem; - font-weight: 600; - color: var(--text-primary); - margin-bottom: 1rem; -} - -/* Stats grid */ -.stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 1rem; - margin-bottom: 2rem; -} -.stat-card { - background: var(--bg-card); - border-radius: var(--radius); - padding: 1.25rem; - border: 1px solid var(--border-color); - border-left: 4px solid var(--gray); - transition: border-color 0.3s ease; -} -.stat-running { border-left-color: var(--green); } -.stat-stopped { border-left-color: var(--red); } -.stat-total { border-left-color: var(--accent-blue); } -.stat-value { - font-size: 2rem; - font-weight: 700; - background: linear-gradient(135deg, var(--accent-light), var(--accent-blue)); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; -} -.stat-running .stat-value { background: var(--green); -webkit-background-clip: text; background-clip: text; } -.stat-stopped .stat-value { background: var(--red); -webkit-background-clip: text; background-clip: text; } -.stat-label { - color: var(--text-secondary); - font-size: .85rem; - margin-top: .25rem; -} - -/* System info bar */ -.system-info-card { - background: var(--bg-card); - border-radius: var(--radius); - padding: 1rem 1.25rem; - border: 1px solid var(--border-color); - margin-bottom: 2rem; -} -.system-info-items { - display: flex; - gap: 2rem; - flex-wrap: wrap; -} -.system-info-item { - flex: 1; - min-width: 200px; -} -.system-info-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: .5rem; -} -.system-info-label { - font-size: .8rem; - font-weight: 600; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: .5px; -} -.system-info-value { - font-size: .8rem; - color: var(--text-muted); - font-family: 'JetBrains Mono', monospace; -} -.system-bar { - width: 100%; - height: 10px; - border-radius: 5px; - position: relative; - background: linear-gradient(to right, - rgba(35, 134, 54, 0.10) 0%, rgba(35, 134, 54, 0.10) 70%, - rgba(210, 153, 34, 0.18) 70%, rgba(210, 153, 34, 0.18) 85%, - rgba(218, 54, 51, 0.18) 85%, rgba(218, 54, 51, 0.18) 100% - ); -} -.system-bar::before { - content: ''; - position: absolute; - left: 70%; - top: -1px; - bottom: -1px; - width: 2px; - background: var(--yellow); - border-radius: 1px; - opacity: 0.5; - z-index: 2; -} -.system-bar::after { - content: ''; - position: absolute; - left: 85%; - top: -1px; - bottom: -1px; - width: 2px; - background: var(--red); - border-radius: 1px; - opacity: 0.5; - z-index: 2; -} -.system-bar-fill { - height: 100%; - border-radius: 5px; - position: relative; - z-index: 1; - transition: width 0.3s ease; - min-width: 3px; -} -.system-bar-green { background: var(--green); box-shadow: 0 0 6px rgba(35, 134, 54, 0.4); } -.system-bar-yellow { background: var(--yellow); box-shadow: 0 0 6px rgba(210, 153, 34, 0.4); } -.system-bar-red { background: var(--red); box-shadow: 0 0 6px rgba(218, 54, 51, 0.4); } - -/* Stack list (dashboard) */ -.stack-list { - display: flex; - flex-direction: column; - gap: .5rem; -} -.stack-card { - background: var(--bg-card); - border-radius: var(--radius); - padding: 1rem 1.25rem; - border: 1px solid var(--border-color); - border-left: 4px solid var(--gray); - display: flex; - justify-content: space-between; - align-items: center; - transition: border-color 0.3s ease, transform 0.2s ease; -} -.stack-card:hover { - border-color: var(--accent-blue); -} -.stack-state-green { border-left-color: var(--green); } -.stack-state-red { border-left-color: var(--red); } -.stack-state-yellow { border-left-color: var(--yellow); } -.stack-state-orange { border-left-color: var(--orange); } -.stack-state-gray { border-left-color: var(--gray); } -.stack-info { - display: flex; - align-items: center; - gap: .75rem; -} -.stack-logo { - width: 32px; - height: 32px; - border-radius: 6px; - object-fit: contain; - background: var(--bg-secondary); - padding: 4px; -} -.stack-logo-lg { - width: 48px; - height: 48px; - border-radius: 8px; - object-fit: contain; - background: var(--bg-secondary); - padding: 6px; -} -.stack-name { font-size: 1rem; font-weight: 500; } -.stack-desc { display: block; font-size: .8rem; color: var(--text-secondary); } -.stack-actions { - display: flex; - align-items: center; - gap: .5rem; -} -.stack-state-label { - font-size: .8rem; - color: var(--text-secondary); - margin-right: .5rem; -} - -/* Stack detail grid (applications page) */ -.stack-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); - gap: 1rem; -} -.stack-detail-card { - background: var(--bg-card); - border-radius: var(--radius); - padding: 1.25rem; - border: 1px solid var(--border-color); - border-top: 4px solid var(--gray); - transition: border-color 0.3s ease, transform 0.3s ease; -} -.stack-detail-card:hover { - border-color: var(--accent-blue); - transform: translateY(-2px); -} -.stack-detail-card.stack-state-green { border-top-color: var(--green); } -.stack-detail-card.stack-state-red { border-top-color: var(--red); } -.stack-detail-card.stack-state-orange { border-top-color: var(--orange); } -.stack-detail-card.stack-state-yellow { border-top-color: var(--yellow); } -.stack-detail-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: .75rem; -} -.stack-title-row { - display: flex; - align-items: center; - gap: .75rem; -} -.subdomain-link { - font-size: .8rem; - color: var(--accent-light); - text-decoration: none; - transition: color 0.2s ease; -} -.subdomain-link:hover { text-decoration: underline; } -.stack-state-badge { - padding: .2rem .6rem; - border-radius: 999px; - font-size: .75rem; - font-weight: 600; - white-space: nowrap; -} -.state-green { background: var(--green-bg); color: var(--green); } -.state-red { background: var(--red-bg); color: var(--red); } -.state-yellow { background: var(--yellow-bg); color: var(--yellow); } -.state-orange { background: var(--orange-bg); color: var(--orange); } -.state-gray { background: var(--gray-bg); color: var(--gray); } -.state-text-green { color: var(--green); } -.state-text-red { color: var(--red); } -.state-text-orange { color: var(--orange); } -.state-text-yellow { color: var(--yellow); } -.stack-detail-desc { - color: var(--text-secondary); - font-size: .85rem; - margin-bottom: .75rem; -} -.stack-meta-badges { - display: flex; - flex-wrap: wrap; - gap: .4rem; - margin: .5rem 0; -} -.meta-badge { - background: rgba(0, 136, 204, 0.1); - color: var(--text-secondary); - padding: .15rem .5rem; - border-radius: 6px; - font-size: .75rem; -} -.meta-badge-ok { - background: var(--green-bg); - color: var(--green); -} -.container-list { margin: .75rem 0; } -.container-list h4 { font-size: .8rem; color: var(--text-muted); margin-bottom: .4rem; } -.container-row { - display: flex; - justify-content: space-between; - font-size: .8rem; - padding: .3rem 0; - border-bottom: 1px solid rgba(48, 54, 61, 0.5); -} -.container-row:last-child { border-bottom: none; } -.container-name { font-family: 'JetBrains Mono', monospace; color: var(--text-secondary); } -.container-status { font-size: .75rem; } -.stack-detail-actions { - display: flex; - gap: .5rem; - margin-top: 1rem; - flex-wrap: wrap; -} - -/* Buttons */ -.btn { - display: inline-flex; - align-items: center; - gap: .3rem; - padding: .5rem 1rem; - border: none; - border-radius: 8px; - font-size: .85rem; - font-weight: 600; - cursor: pointer; - transition: all 0.2s ease; - text-decoration: none; - color: #fff; - font-family: inherit; -} -.btn:hover { transform: translateY(-1px); } -.btn:active { transform: scale(.97); } -.btn:disabled { opacity: .5; cursor: not-allowed; transform: none; } -.btn.loading { opacity: .6; } -.btn-sm { padding: .3rem .6rem; font-size: .8rem; } -.btn-lg { padding: .65rem 1.5rem; font-size: 1rem; } -.btn-full { width: 100%; justify-content: center; } -.btn-primary { - background: linear-gradient(135deg, var(--accent-blue), var(--accent-light)); - box-shadow: 0 4px 12px var(--accent-glow); -} -.btn-primary:hover { box-shadow: 0 6px 20px var(--accent-glow); } -.btn-success { background: var(--green); } -.btn-success:hover { box-shadow: 0 4px 12px rgba(35, 134, 54, 0.3); } -.btn-warning { background: var(--yellow); color: #0d1117; } -.btn-danger { background: var(--red); } -.btn-danger:hover { box-shadow: 0 4px 12px rgba(218, 54, 51, 0.3); } -.btn-outline { - background: transparent; - border: 1px solid var(--border-color); - color: var(--text-secondary); -} -.btn-outline:hover { - border-color: var(--accent-blue); - color: var(--accent-light); - background: rgba(0, 136, 204, 0.08); -} -.badge { - display: inline-flex; - align-items: center; - gap: .25rem; - padding: .2rem .6rem; - border-radius: 999px; - font-size: .75rem; - font-weight: 500; -} -.badge-protected { - background: var(--gray-bg); - color: var(--text-muted); -} - -/* Deploy page */ -.deploy-container { max-width: 700px; } -.deploy-info { - display: flex; - gap: 1rem; - align-items: flex-start; - background: var(--bg-card); - padding: 1.25rem; - border-radius: var(--radius); - border: 1px solid var(--border-color); - margin-bottom: 1.5rem; -} -.deploy-logo { - width: 64px; - height: 64px; - border-radius: 12px; - object-fit: contain; - background: var(--bg-secondary); - padding: 8px; - flex-shrink: 0; -} -.deploy-info h3 { font-size: 1.2rem; margin-bottom: .25rem; } -.deploy-info p { color: var(--text-secondary); font-size: .9rem; } -.deploy-form { - background: var(--bg-card); - padding: 1.5rem; - border-radius: var(--radius); - border: 1px solid var(--border-color); -} -.form-section { margin-bottom: 1.5rem; } -.form-section h4 { font-size: 1rem; margin-bottom: .5rem; color: var(--text-primary); } -.form-section-desc { color: var(--text-secondary); font-size: .85rem; margin-bottom: .75rem; } -.form-group { margin-bottom: 1rem; } -.form-group label { display: block; font-size: .85rem; font-weight: 500; margin-bottom: .4rem; color: var(--text-primary); } -.form-group-auto { - display: flex; - justify-content: space-between; - align-items: center; - padding: .5rem .75rem; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 8px; -} -.form-group-auto label { margin: 0; } -.auto-generated-badge { color: var(--green); font-size: .8rem; font-weight: 500; } -.form-control { - width: 100%; - padding: .55rem .75rem; - border: 1px solid var(--border-color); - border-radius: 8px; - font-size: .9rem; - background: var(--bg-secondary); - color: var(--text-primary); - font-family: inherit; - transition: border-color 0.2s ease, box-shadow 0.2s ease; -} -.form-control:focus { - outline: none; - border-color: var(--accent-blue); - box-shadow: 0 0 0 3px var(--accent-glow); -} -.form-control:disabled { - background: var(--bg-primary); - color: var(--text-muted); - cursor: not-allowed; -} -select.form-control { appearance: auto; } -select.form-control option { background: var(--bg-secondary); color: var(--text-primary); } -.input-with-button { display: flex; gap: .5rem; } -.input-with-button .form-control { flex: 1; } -.form-hint { display: block; font-size: .8rem; color: var(--text-muted); margin-top: .25rem; } -.required { color: var(--red); } -.locked-hint { font-size: .75rem; color: var(--text-muted); font-weight: 400; margin-left: .5rem; } -.deploy-actions { - display: flex; - gap: .75rem; - margin-top: 1.5rem; - padding-top: 1rem; - border-top: 1px solid var(--border-color); -} - -/* Deploy progress */ -.deploy-progress { - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: var(--radius); - padding: 1.5rem; -} -.deploy-progress h3 { - margin-bottom: 0.5rem; -} -.deploy-steps { - margin: 1rem 0; -} -.deploy-step { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.5rem 0; - font-size: .95rem; - color: var(--text-muted); -} -.deploy-step.active { - color: var(--text-primary); -} -.deploy-step.done { - color: var(--text-primary); -} -.deploy-step.done .step-icon { - color: var(--green); -} -.deploy-step.error .step-icon { - color: var(--red); -} -.deploy-step.warn .step-icon { - color: var(--yellow); -} -.step-icon { - font-size: 1.1rem; - width: 1.5rem; - text-align: center; - flex-shrink: 0; -} -.deploy-elapsed { - color: var(--text-muted); - font-size: .85rem; - margin-top: 0.5rem; -} - -/* Toggle switch */ -.toggle { cursor: pointer; display: flex; align-items: center; gap: .5rem; } -.toggle input[type="checkbox"] { accent-color: var(--accent-blue); } -.toggle-label { font-size: .9rem; color: var(--text-secondary); } - -/* Alerts */ -.alert { - padding: .75rem 1rem; - border-radius: 8px; - margin-bottom: 1rem; - font-size: .85rem; - border: 1px solid; -} -.alert-error { - background: var(--red-bg); - color: var(--red); - border-color: rgba(218, 54, 51, 0.3); -} -.alert-info { - background: rgba(0, 136, 204, 0.1); - color: var(--accent-light); - border-color: rgba(0, 136, 204, 0.3); -} -.alert-warning { - background: var(--yellow-bg); - color: var(--yellow); - border-color: rgba(210, 153, 34, 0.3); -} - -/* Memory summary on deploy page */ -.memory-summary { - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: var(--radius); - padding: 1rem 1.25rem; - margin-bottom: 1rem; -} -.memory-blocked { - border-color: rgba(218, 54, 51, 0.5); -} -.memory-summary-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.5rem; -} -.memory-summary-label { - font-size: .8rem; - font-weight: 600; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: .5px; -} -.memory-summary-value { - font-size: .8rem; - color: var(--text-muted); - font-family: 'JetBrains Mono', monospace; -} -.memory-bar-stacked { - width: 100%; - height: 10px; - border-radius: 5px; - display: flex; - overflow: hidden; - position: relative; - background: linear-gradient(to right, - rgba(35, 134, 54, 0.10) 0%, rgba(35, 134, 54, 0.10) 70%, - rgba(210, 153, 34, 0.18) 70%, rgba(210, 153, 34, 0.18) 85%, - rgba(218, 54, 51, 0.18) 85%, rgba(218, 54, 51, 0.18) 100% - ); -} -.memory-bar-stacked::before { - content: ''; - position: absolute; - left: 70%; - top: -1px; - bottom: -1px; - width: 2px; - background: var(--yellow); - border-radius: 1px; - opacity: 0.5; - z-index: 2; -} -.memory-bar-stacked::after { - content: ''; - position: absolute; - left: 85%; - top: -1px; - bottom: -1px; - width: 2px; - background: var(--red); - border-radius: 1px; - opacity: 0.5; - z-index: 2; -} -.memory-bar-segment { - height: 100%; - position: relative; - z-index: 1; - transition: width 0.3s ease; -} -.memory-bar-segment:not([style*="width:0%"]) { - min-width: 3px; -} -.memory-bar-committed { - background: var(--green); - box-shadow: 0 0 6px rgba(35, 134, 54, 0.4); - border-radius: 5px 0 0 5px; -} -.memory-bar-new { - background: rgba(35, 134, 54, 0.45); - border-right: 2px solid #4edf72; - border-radius: 0 5px 5px 0; -} -.memory-bar-legend { - display: flex; - gap: 1.25rem; - margin-top: 0.5rem; -} -.memory-legend-item { - display: flex; - align-items: center; - gap: 0.35rem; - font-size: .75rem; - color: var(--text-secondary); -} -.memory-legend-dot { - display: inline-block; - width: 10px; - height: 10px; - border-radius: 3px; - flex-shrink: 0; -} -.memory-legend-committed { - background: var(--green); -} -.memory-legend-new { - background: rgba(35, 134, 54, 0.45); - border: 1px solid #4edf72; -} - -/* Logs */ -.logs-container { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: var(--radius); - padding: 1rem; - overflow-x: auto; - overflow-y: auto; - max-height: 70vh; - margin-bottom: 1rem; -} -.logs-output { - color: var(--text-primary); - font-family: 'JetBrains Mono', 'Fira Code', monospace; - font-size: .8rem; - line-height: 1.5; - white-space: pre-wrap; - word-break: break-all; - margin: 0; -} -.logs-actions { - display: flex; - gap: .5rem; - align-items: center; -} -.logs-live-indicator { - display: inline-flex; - align-items: center; - gap: .35rem; - font-size: .8rem; - font-weight: 600; - color: var(--green); - margin-right: .5rem; -} -.logs-live-indicator.logs-live-paused { - color: var(--text-muted); -} -.logs-live-dot { - display: inline-block; - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--green); - animation: logs-pulse 1.5s ease-in-out infinite; -} -@keyframes logs-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.3; } -} - -/* Sync toast */ -.sync-toast { - padding: .6rem 1rem; - border-radius: 8px; - font-size: .85rem; - margin-bottom: 1rem; - border: 1px solid; - transition: opacity 0.3s ease; -} -.sync-toast-ok { - background: var(--green-bg); - color: var(--green); - border-color: rgba(35, 134, 54, 0.3); -} -.sync-toast-err { - background: var(--red-bg); - color: var(--red); - border-color: rgba(218, 54, 51, 0.3); -} - -/* Clickable cards */ -[data-href] { cursor: pointer; } - -.empty-state { text-align: center; padding: 3rem; color: var(--text-muted); } - -/* Login page */ -.login-body { - display: flex; - justify-content: center; - align-items: center; - min-height: 100vh; - background: var(--bg-primary); -} -.login-body::before { - content: ''; - position: fixed; - top: 0; left: 0; - width: 100%; height: 100%; - background-image: - linear-gradient(rgba(0, 136, 204, 0.03) 1px, transparent 1px), - linear-gradient(90deg, rgba(0, 136, 204, 0.03) 1px, transparent 1px); - background-size: 50px 50px; - pointer-events: none; - z-index: 0; -} -.login-card { - background: var(--bg-card); - padding: 2.5rem; - border-radius: 16px; - border: 1px solid var(--border-color); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); - width: 100%; - max-width: 380px; - text-align: center; - position: relative; - z-index: 1; -} -.login-card .login-logo { - width: 220px; - margin-bottom: 0.5rem; -} -.login-subtitle { - color: var(--text-secondary); - margin-bottom: 1.5rem; -} -.login-footer { - margin-top: 1.5rem; - font-size: .75rem; - color: var(--text-muted); -} -.login-footer a { - color: var(--accent-light); - text-decoration: none; - transition: color 0.2s ease; -} -.login-footer a:hover { color: var(--accent-blue); } - -/* --- App Info Page --- */ -.app-info-hero { - display: flex; - align-items: center; - gap: 1.5rem; - padding: 1.5rem; - background: var(--bg-card); - border-radius: var(--radius); - border: 1px solid var(--border-color); - margin-bottom: 1.5rem; -} -.app-info-logo { - width: 80px; - height: 80px; - min-width: 80px; - min-height: 80px; - max-width: 80px; - max-height: 80px; - border-radius: 12px; - object-fit: contain; - background: var(--bg-secondary); - padding: 10px; - flex-shrink: 0; - overflow: hidden; -} -.app-info-tagline { - font-size: 1.1rem; - color: var(--text-primary); - margin: 0 0 .75rem 0; -} -.app-screenshots { - display: flex; - gap: 1rem; - margin-bottom: 1.5rem; - overflow-x: auto; - padding-bottom: .5rem; -} -.app-screenshot { - max-height: 220px; - border-radius: var(--radius); - border: 1px solid var(--border-color); - object-fit: cover; -} -.app-info-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - gap: 1rem; - margin-bottom: 1.5rem; -} -.app-info-card { - background: var(--bg-card); - border-radius: var(--radius); - padding: 1.25rem; - border: 1px solid var(--border-color); -} -.app-info-card h3 { - margin: 0 0 .75rem 0; - font-size: .95rem; - color: var(--text-primary); -} -.app-info-list { - margin: 0; - padding-left: 1.25rem; - color: var(--text-secondary); - font-size: .9rem; - line-height: 1.6; -} -.app-info-creds { - font-family: monospace; - font-size: 1rem; - color: var(--accent-light); - background: var(--bg-secondary); - padding: .5rem .75rem; - border-radius: 4px; - display: inline-block; - margin: 0 0 .5rem 0; -} -.app-info-creds-warn { - color: var(--orange); - font-size: .85rem; - margin: 0; -} -.app-info-link { - color: var(--accent-light); - text-decoration: none; -} -.app-info-link:hover { text-decoration: underline; } - -/* Optional config section */ -.app-optional-config { - background: var(--bg-card); - border-radius: var(--radius); - padding: 1.5rem; - border: 1px solid var(--border-color); - margin-bottom: 1.5rem; -} -.app-optional-config h3 { - margin: 0 0 1rem 0; - font-size: 1.05rem; -} -.config-group { - margin-bottom: 1.5rem; -} -.config-group h4 { - margin: 0 0 .5rem 0; - font-size: .95rem; - color: var(--text-primary); -} -.config-group-desc { - color: var(--text-secondary); - font-size: .85rem; - margin: 0 0 1rem 0; -} -.config-fields { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); - gap: 1rem; -} -.config-field { - display: flex; - flex-direction: column; - gap: .25rem; -} -.config-field label { - font-size: .85rem; - font-weight: 500; - color: var(--text-primary); -} -.config-field-help { - font-size: .8rem; - color: var(--text-secondary); - margin: 0; - line-height: 1.4; -} -.config-field-help a { - color: var(--accent-light); - text-decoration: none; -} -.config-field-help a:hover { text-decoration: underline; } -.config-input { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 4px; - padding: .5rem .75rem; - color: var(--text-primary); - font-size: .9rem; - font-family: monospace; - width: 100%; - box-sizing: border-box; -} -.config-input:focus { - outline: none; - border-color: var(--accent-blue); -} -.config-actions { - display: flex; - align-items: center; - gap: 1rem; - margin-top: 1rem; -} -.config-save-status { - font-size: .9rem; - transition: opacity .3s; -} -.config-save-ok { color: var(--green); } -.config-save-err { color: var(--red); } -.meta-badge-warn { - background: rgba(255, 152, 0, 0.1) !important; - color: var(--orange) !important; -} -.meta-badge-ok { - background: rgba(76, 175, 80, 0.1) !important; - color: var(--green) !important; -} - -/* Orphan badge */ -.badge-orphaned { - background: var(--orange-bg); - color: var(--orange); -} - -/* Delete modal */ -.modal-overlay { - position: fixed; - top: 0; left: 0; - width: 100%; height: 100%; - background: rgba(0, 0, 0, 0.7); - display: flex; - justify-content: center; - align-items: center; - z-index: 1000; -} -.modal-card { - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: var(--radius); - padding: 1.5rem; - max-width: 500px; - width: 90%; - max-height: 80vh; - overflow-y: auto; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); -} -.modal-card h3 { - margin-bottom: .75rem; -} -.modal-hdd-info { - background: var(--bg-secondary); - border-radius: 8px; - padding: .75rem 1rem; - margin: .75rem 0; - font-size: .85rem; - color: var(--text-secondary); -} -.modal-hdd-path { - font-family: 'JetBrains Mono', monospace; - font-size: .8rem; - color: var(--text-muted); - padding: .2rem 0; -} -.modal-checkbox { - display: flex; - align-items: center; - gap: .5rem; - margin: .75rem 0; - padding: .75rem 1rem; - background: var(--red-bg); - border: 1px solid rgba(218, 54, 51, 0.3); - border-radius: 8px; - font-size: .85rem; - color: var(--red); - cursor: pointer; -} -.modal-checkbox input[type="checkbox"] { - accent-color: var(--red); -} -.modal-actions { - display: flex; - gap: .75rem; - margin-top: 1rem; - justify-content: flex-end; -} - -/* Responsive */ -@media(max-width: 768px) { - .sidebar { width: 100%; height: auto; position: relative; border-right: none; border-bottom: 1px solid var(--border-color); } - .nav-links { display: flex; padding: 0; overflow-x: auto; } - .nav-links a { padding: .5rem 1rem; white-space: nowrap; } - .nav-links a.active { border-left: none; border-bottom: 2px solid var(--accent-blue); } - .content { margin-left: 0; padding: 1rem; } - body { flex-direction: column; } - .stack-card { flex-direction: column; align-items: flex-start; gap: .75rem; } - .stack-actions { width: 100%; justify-content: flex-end; } - .stack-grid { grid-template-columns: 1fr; } - .stats-grid { grid-template-columns: repeat(3, 1fr); } - .deploy-info { flex-direction: column; } - .system-info-items { flex-direction: column; gap: 1rem; } -} -` - // felhomLogoSVG is the felhom.eu logo, served at /static/felhom-logo.svg. // Cleaned from the original Inkscape SVG, removing editor metadata. const felhomLogoSVG = ` @@ -2184,4 +32,4 @@ const felhomLogoSVG = ` -` \ No newline at end of file +` diff --git a/controller/internal/web/templates/app_info.html b/controller/internal/web/templates/app_info.html new file mode 100644 index 0000000..6a0d13a --- /dev/null +++ b/controller/internal/web/templates/app_info.html @@ -0,0 +1,182 @@ +{{define "app_info"}} +{{template "layout_start" .}} + + + + +
+ +
+ {{if .AppInfo.Tagline}} +

{{.AppInfo.Tagline}}

+ {{else}} +

{{.Meta.Description}}

+ {{end}} +
+ ~{{.Meta.Resources.MemRequest}} RAM + {{.Meta.Category}} + {{if .Meta.Resources.NeedsHDD}}HDD szükséges{{end}} + {{if .Meta.Resources.PiCompatible}}Pi kompatibilis{{else}}Csak x86{{end}} +
+
+
+ + +
+ + + +
+ +{{if .HasAppInfo}} +
+ {{if .AppInfo.UseCases}} +
+

Mire használható?

+
    + {{range .AppInfo.UseCases}}
  • {{.}}
  • {{end}} +
+
+ {{end}} + + {{if .AppInfo.FirstSteps}} +
+

Első lépések

+
    + {{range .AppInfo.FirstSteps}}
  1. {{.}}
  2. {{end}} +
+
+ {{end}} + + {{if .AppInfo.Prerequisites}} +
+

Előfeltételek

+
    + {{range .AppInfo.Prerequisites}}
  • {{.}}
  • {{end}} +
+
+ {{end}} + + {{if .AppInfo.DefaultCreds}} +
+

Alapértelmezett belépés

+

{{.AppInfo.DefaultCreds}}

+

Az első bejelentkezés után azonnal változtasd meg!

+
+ {{end}} + + {{if .AppInfo.DocsURL}} +
+

Dokumentáció

+

Hivatalos dokumentáció ↗

+
+ {{end}} +
+{{end}} + +{{if .HasOptionalConfig}} +
+

Opcionális beállítások

+ {{range .OptionalConfig}} +
+

{{.Group}}

+ {{if .Description}}

{{.Description}}

{{end}} + +
+ {{range .Fields}} +
+ + {{if .HelpText}}

{{.HelpText}}

{{end}} + {{if .HelpURL}}

Regisztrációs útmutató ↗

{{end}} + +
+ {{end}} +
+
+ {{end}} + +
+ + +
+
+ + +{{end}} + +{{template "layout_end" .}} +{{end}} diff --git a/controller/internal/web/templates/dashboard.html b/controller/internal/web/templates/dashboard.html new file mode 100644 index 0000000..cd2c55b --- /dev/null +++ b/controller/internal/web/templates/dashboard.html @@ -0,0 +1,101 @@ +{{define "dashboard"}} +{{template "layout_start" .}} + + + +
+
+
{{.RunningCount}}
+
Futó alkalmazás
+
+
+
{{.StoppedCount}}
+
Leállítva
+
+
+
{{.TotalCount}}
+
Összes alkalmazás
+
+
+ +{{if .SystemInfo.TotalMemMB}} +
+
+
+
+ Memória + {{fmtMB .SystemInfo.UsedMemMB}} / {{fmtMB .SystemInfo.TotalMemMB}} ({{printf "%.0f" .SystemInfo.MemPercent}}%) +
+
+
+
+
+
+
+ SSD tárhely + {{fmtGB .SystemInfo.DiskUsedGB}} / {{fmtGB .SystemInfo.DiskTotalGB}} ({{printf "%.0f" .SystemInfo.DiskPercent}}%) +
+
+
+
+
+ {{if .SystemInfo.HDDConfigured}} +
+
+ Külső HDD + {{fmtGB .SystemInfo.HDDUsedGB}} / {{fmtGB .SystemInfo.HDDTotalGB}} ({{printf "%.0f" .SystemInfo.HDDPercent}}%) +
+
+
+
+
+ {{end}} +
+
+{{end}} + +

Alkalmazások állapota

+ +
+ {{range .Stacks}} +
+
+ +
+ {{.Meta.DisplayName}} + {{if .Meta.Description}}{{.Meta.Description}}{{end}} +
+
+
+ {{stateLabel .State}} + {{if .Orphaned}}Elavult{{end}} + + {{if .Protected}} + Védett + {{else if not .Deployed}} + Telepítés + {{else}} + {{if isOperational .State}} + + + {{else}} + + {{end}} + Napló + {{if .Orphaned}}{{end}} + {{end}} +
+
+ {{else}} +
+

Nincs elérhető alkalmazás.

+
+ {{end}} +
+ +{{template "layout_end" .}} +{{end}} diff --git a/controller/internal/web/templates/deploy.html b/controller/internal/web/templates/deploy.html new file mode 100644 index 0000000..8f298b2 --- /dev/null +++ b/controller/internal/web/templates/deploy.html @@ -0,0 +1,330 @@ +{{define "deploy"}} +{{template "layout_start" .}} + + + +
+
+ +
+

{{.Meta.DisplayName}}

+ {{if .Meta.Description}}

{{.Meta.Description}}

{{end}} +
+ {{if .Meta.Resources.MemRequest}}~{{.Meta.Resources.MemRequest}}{{end}} + {{if .Meta.Resources.PiCompatible}}Pi kompatibilis{{end}} + {{if .Meta.Resources.NeedsHDD}}HDD szükséges{{end}} +
+ + Részletes leírás, képernyőképek + +
+
+ + {{if .AlreadyDeployed}} +
+ Ez az alkalmazás már telepítve van. Az alábbi beállítások csak olvashatók. +
+ {{end}} + + {{if and (not .AlreadyDeployed) .MemoryInfo}} + {{with .MemoryInfo}} + {{if .Available}} +
+ {{if .Blocked}} +
+ Nincs elég memória! Foglalás telepítés után: {{.AfterMB}} MB / {{.UsableMB}} MB +
+ {{else}} +
+ Memória foglalás + {{.AfterMB}} MB / {{.UsableMB}} MB ({{.Percent}}%) +
+
+
+
+
+
+ Jelenlegi foglalás ({{.CommittedMB}} MB) + {{$.Meta.DisplayName}} (+{{.NewRequestMB}} MB) +
+ {{if .OvercommitWarn}} +
+ Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát. + Normál használat mellett ez nem okoz problémát. +
+ {{end}} + {{end}} +
+ {{end}} + {{end}} + {{end}} + +
+ {{if .AutoFields}} +
+

Automatikusan generált értékek

+

Ezek az értékek automatikusan létrejönnek a telepítéskor.

+ {{range .AutoFields}} +
+ + ✓ Automatikusan generálva +
+ {{end}} +
+ {{end}} + + {{if .UserFields}} +
+

Beállítások

+ {{range .UserFields}} +
+ + + {{if eq .Type "select"}} + + {{else if eq .Type "password"}} +
+ + +
+ {{else if eq .Type "boolean"}} + + {{else}} + + {{end}} + + {{if .Description}} + {{.Description}} + {{end}} +
+ {{end}} +
+ {{end}} + + {{if not .AlreadyDeployed}} +
+ + Mégsem +
+ {{end}} +
+ + +
+ + + +{{template "layout_end" .}} +{{end}} diff --git a/controller/internal/web/templates/layout.html b/controller/internal/web/templates/layout.html new file mode 100644 index 0000000..4461cc4 --- /dev/null +++ b/controller/internal/web/templates/layout.html @@ -0,0 +1,192 @@ +{{define "layout_start"}} + + + + + + {{.Title}}Felhom.eu + + + + +
+{{end}} + +{{define "layout_end"}} +
+ + + +{{end}} diff --git a/controller/internal/web/templates/login.html b/controller/internal/web/templates/login.html new file mode 100644 index 0000000..711dea8 --- /dev/null +++ b/controller/internal/web/templates/login.html @@ -0,0 +1,28 @@ +{{define "login"}} + + + + + + Bejelentkezés — Felhom + + + + + + +{{end}} diff --git a/controller/internal/web/templates/logs.html b/controller/internal/web/templates/logs.html new file mode 100644 index 0000000..906795c --- /dev/null +++ b/controller/internal/web/templates/logs.html @@ -0,0 +1,72 @@ +{{define "logs"}} +{{template "layout_start" .}} + +
+
{{.Logs}}
+
+
+ + Élő + + + +
+ +{{template "layout_end" .}} +{{end}} diff --git a/controller/internal/web/templates/stacks.html b/controller/internal/web/templates/stacks.html new file mode 100644 index 0000000..5af9933 --- /dev/null +++ b/controller/internal/web/templates/stacks.html @@ -0,0 +1,76 @@ +{{define "stacks"}} +{{template "layout_start" .}} + + + + +
+ {{range .Stacks}} +
+
+
+ +
+

{{.Meta.DisplayName}}

+ {{if .Meta.Subdomain}} + + {{.Meta.Subdomain}}.{{$.Domain}} ↗ + + {{end}} +
+
+ {{stateLabel .State}} + {{if .Orphaned}}Elavult{{end}} +
+ + {{if .Meta.Description}} +

{{.Meta.Description}}

+ {{end}} + +
+ {{if .Meta.Resources.MemRequest}}~{{.Meta.Resources.MemRequest}}{{end}} + {{if .Meta.Resources.PiCompatible}}Pi kompatibilis{{end}} + {{if .Meta.Resources.NeedsHDD}}HDD szükséges{{end}} +
+ + {{if .Containers}} +
+ {{range .Containers}} +
+ {{.Name}} + {{.Status}} +
+ {{end}} +
+ {{end}} + +
+ {{if .Protected}} + Védett rendszerkomponens + {{else if not .Deployed}} + Telepítés + Részletek + {{else}} + {{if isOperational .State}} + {{if not .Orphaned}}{{end}} + + + {{else}} + + {{end}} + Naplók + {{if not .Orphaned}}Részletek{{end}} + {{if .Orphaned}}{{end}} + {{end}} +
+
+ {{end}} +
+ +{{template "layout_end" .}} +{{end}} diff --git a/controller/internal/web/templates/style.css b/controller/internal/web/templates/style.css new file mode 100644 index 0000000..b822824 --- /dev/null +++ b/controller/internal/web/templates/style.css @@ -0,0 +1,1139 @@ +@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); + +:root { + --bg-primary: #0d1117; + --bg-secondary: #161b22; + --bg-card: #1c2128; + --text-primary: #e6edf3; + --text-secondary: #8b949e; + --text-muted: #6e7681; + --accent-blue: #0088cc; + --accent-light: #00aaff; + --accent-glow: rgba(0, 136, 204, 0.3); + --border-color: #30363d; + --green: #238636; + --green-bg: rgba(35, 134, 54, 0.15); + --red: #da3633; + --red-bg: rgba(218, 54, 51, 0.15); + --yellow: #d29922; + --yellow-bg: rgba(210, 153, 34, 0.15); + --orange: #db6d28; + --orange-bg: rgba(219, 109, 40, 0.15); + --gray: #6e7681; + --gray-bg: rgba(110, 118, 129, 0.15); + --radius: 12px; + --shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +* { margin: 0; padding: 0; box-sizing: border-box; } +html { scroll-behavior: smooth; } + +body { + font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + display: flex; + min-height: 100vh; + line-height: 1.6; +} + +body::before { + content: ''; + position: fixed; + top: 0; left: 0; + width: 100%; height: 100%; + background-image: + linear-gradient(rgba(0, 136, 204, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 136, 204, 0.03) 1px, transparent 1px); + background-size: 50px 50px; + pointer-events: none; + z-index: -1; +} + +/* Sidebar */ +.sidebar { + width: 240px; + background: var(--bg-secondary); + color: var(--text-primary); + display: flex; + flex-direction: column; + position: fixed; + height: 100vh; + overflow-y: auto; + border-right: 1px solid var(--border-color); +} +.sidebar-header { + padding: 1.5rem; + border-bottom: 1px solid var(--border-color); +} +.sidebar-logo { + width: 140px; + height: auto; + display: block; + margin-bottom: 0.25rem; +} +.login-logo { + width: 200px; + height: auto; + display: block; + margin: 0 auto 0.5rem; +} +.customer-name { + display: block; + font-size: .85rem; + color: var(--text-secondary); + margin-top: .25rem; +} +.nav-links { + list-style: none; + padding: 1rem 0; + flex: 1; +} +.nav-links a { + display: block; + padding: .75rem 1.5rem; + color: var(--text-secondary); + text-decoration: none; + font-size: .95rem; + font-weight: 500; + transition: color 0.2s ease, background 0.2s ease; +} +.nav-links a:hover { + color: var(--accent-light); + background: rgba(0, 136, 204, 0.08); +} +.nav-links a.active { + color: var(--accent-light); + background: rgba(0, 136, 204, 0.12); + border-left: 3px solid var(--accent-blue); +} +.sidebar-footer { + padding: 1rem 1.5rem; + border-top: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; + font-size: .8rem; +} +.version { color: var(--text-muted); } +.logout-link { color: var(--text-muted); text-decoration: none; transition: color 0.2s ease; } +.logout-link:hover { color: var(--accent-light); } + +/* Main content */ +.content { + margin-left: 240px; + padding: 2rem; + flex: 1; + max-width: 1200px; +} +.page-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; +} +.page-header h2 { + font-size: 1.5rem; + font-weight: 600; + color: var(--text-primary); +} +.domain-badge { + background: rgba(0, 136, 204, 0.15); + color: var(--accent-light); + padding: .25rem .75rem; + border-radius: 999px; + font-size: .8rem; + font-weight: 500; +} + +h3 { + font-size: 1.1rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 1rem; +} + +/* Stats grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} +.stat-card { + background: var(--bg-card); + border-radius: var(--radius); + padding: 1.25rem; + border: 1px solid var(--border-color); + border-left: 4px solid var(--gray); + transition: border-color 0.3s ease; +} +.stat-running { border-left-color: var(--green); } +.stat-stopped { border-left-color: var(--red); } +.stat-total { border-left-color: var(--accent-blue); } +.stat-value { + font-size: 2rem; + font-weight: 700; + background: linear-gradient(135deg, var(--accent-light), var(--accent-blue)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} +.stat-running .stat-value { background: var(--green); -webkit-background-clip: text; background-clip: text; } +.stat-stopped .stat-value { background: var(--red); -webkit-background-clip: text; background-clip: text; } +.stat-label { + color: var(--text-secondary); + font-size: .85rem; + margin-top: .25rem; +} + +/* System info bar */ +.system-info-card { + background: var(--bg-card); + border-radius: var(--radius); + padding: 1rem 1.25rem; + border: 1px solid var(--border-color); + margin-bottom: 2rem; +} +.system-info-items { + display: flex; + gap: 2rem; + flex-wrap: wrap; +} +.system-info-item { + flex: 1; + min-width: 200px; +} +.system-info-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: .5rem; +} +.system-info-label { + font-size: .8rem; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: .5px; +} +.system-info-value { + font-size: .8rem; + color: var(--text-muted); + font-family: 'JetBrains Mono', monospace; +} +.system-bar { + width: 100%; + height: 10px; + border-radius: 5px; + position: relative; + background: linear-gradient(to right, + rgba(35, 134, 54, 0.10) 0%, rgba(35, 134, 54, 0.10) 70%, + rgba(210, 153, 34, 0.18) 70%, rgba(210, 153, 34, 0.18) 85%, + rgba(218, 54, 51, 0.18) 85%, rgba(218, 54, 51, 0.18) 100% + ); +} +.system-bar::before { + content: ''; + position: absolute; + left: 70%; + top: -1px; + bottom: -1px; + width: 2px; + background: var(--yellow); + border-radius: 1px; + opacity: 0.5; + z-index: 2; +} +.system-bar::after { + content: ''; + position: absolute; + left: 85%; + top: -1px; + bottom: -1px; + width: 2px; + background: var(--red); + border-radius: 1px; + opacity: 0.5; + z-index: 2; +} +.system-bar-fill { + height: 100%; + border-radius: 5px; + position: relative; + z-index: 1; + transition: width 0.3s ease; + min-width: 3px; +} +.system-bar-green { background: var(--green); box-shadow: 0 0 6px rgba(35, 134, 54, 0.4); } +.system-bar-yellow { background: var(--yellow); box-shadow: 0 0 6px rgba(210, 153, 34, 0.4); } +.system-bar-red { background: var(--red); box-shadow: 0 0 6px rgba(218, 54, 51, 0.4); } + +/* Stack list (dashboard) */ +.stack-list { + display: flex; + flex-direction: column; + gap: .5rem; +} +.stack-card { + background: var(--bg-card); + border-radius: var(--radius); + padding: 1rem 1.25rem; + border: 1px solid var(--border-color); + border-left: 4px solid var(--gray); + display: flex; + justify-content: space-between; + align-items: center; + transition: border-color 0.3s ease, transform 0.2s ease; +} +.stack-card:hover { + border-color: var(--accent-blue); +} +.stack-state-green { border-left-color: var(--green); } +.stack-state-red { border-left-color: var(--red); } +.stack-state-yellow { border-left-color: var(--yellow); } +.stack-state-orange { border-left-color: var(--orange); } +.stack-state-gray { border-left-color: var(--gray); } +.stack-info { + display: flex; + align-items: center; + gap: .75rem; +} +.stack-logo { + width: 32px; + height: 32px; + border-radius: 6px; + object-fit: contain; + background: var(--bg-secondary); + padding: 4px; +} +.stack-logo-lg { + width: 48px; + height: 48px; + border-radius: 8px; + object-fit: contain; + background: var(--bg-secondary); + padding: 6px; +} +.stack-name { font-size: 1rem; font-weight: 500; } +.stack-desc { display: block; font-size: .8rem; color: var(--text-secondary); } +.stack-actions { + display: flex; + align-items: center; + gap: .5rem; +} +.stack-state-label { + font-size: .8rem; + color: var(--text-secondary); + margin-right: .5rem; +} + +/* Stack detail grid (applications page) */ +.stack-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 1rem; +} +.stack-detail-card { + background: var(--bg-card); + border-radius: var(--radius); + padding: 1.25rem; + border: 1px solid var(--border-color); + border-top: 4px solid var(--gray); + transition: border-color 0.3s ease, transform 0.3s ease; +} +.stack-detail-card:hover { + border-color: var(--accent-blue); + transform: translateY(-2px); +} +.stack-detail-card.stack-state-green { border-top-color: var(--green); } +.stack-detail-card.stack-state-red { border-top-color: var(--red); } +.stack-detail-card.stack-state-orange { border-top-color: var(--orange); } +.stack-detail-card.stack-state-yellow { border-top-color: var(--yellow); } +.stack-detail-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: .75rem; +} +.stack-title-row { + display: flex; + align-items: center; + gap: .75rem; +} +.subdomain-link { + font-size: .8rem; + color: var(--accent-light); + text-decoration: none; + transition: color 0.2s ease; +} +.subdomain-link:hover { text-decoration: underline; } +.stack-state-badge { + padding: .2rem .6rem; + border-radius: 999px; + font-size: .75rem; + font-weight: 600; + white-space: nowrap; +} +.state-green { background: var(--green-bg); color: var(--green); } +.state-red { background: var(--red-bg); color: var(--red); } +.state-yellow { background: var(--yellow-bg); color: var(--yellow); } +.state-orange { background: var(--orange-bg); color: var(--orange); } +.state-gray { background: var(--gray-bg); color: var(--gray); } +.state-text-green { color: var(--green); } +.state-text-red { color: var(--red); } +.state-text-orange { color: var(--orange); } +.state-text-yellow { color: var(--yellow); } +.stack-detail-desc { + color: var(--text-secondary); + font-size: .85rem; + margin-bottom: .75rem; +} +.stack-meta-badges { + display: flex; + flex-wrap: wrap; + gap: .4rem; + margin: .5rem 0; +} +.meta-badge { + background: rgba(0, 136, 204, 0.1); + color: var(--text-secondary); + padding: .15rem .5rem; + border-radius: 6px; + font-size: .75rem; +} +.meta-badge-ok { + background: var(--green-bg); + color: var(--green); +} +.container-list { margin: .75rem 0; } +.container-list h4 { font-size: .8rem; color: var(--text-muted); margin-bottom: .4rem; } +.container-row { + display: flex; + justify-content: space-between; + font-size: .8rem; + padding: .3rem 0; + border-bottom: 1px solid rgba(48, 54, 61, 0.5); +} +.container-row:last-child { border-bottom: none; } +.container-name { font-family: 'JetBrains Mono', monospace; color: var(--text-secondary); } +.container-status { font-size: .75rem; } +.stack-detail-actions { + display: flex; + gap: .5rem; + margin-top: 1rem; + flex-wrap: wrap; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + gap: .3rem; + padding: .5rem 1rem; + border: none; + border-radius: 8px; + font-size: .85rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; + color: #fff; + font-family: inherit; +} +.btn:hover { transform: translateY(-1px); } +.btn:active { transform: scale(.97); } +.btn:disabled { opacity: .5; cursor: not-allowed; transform: none; } +.btn.loading { opacity: .6; } +.btn-sm { padding: .3rem .6rem; font-size: .8rem; } +.btn-lg { padding: .65rem 1.5rem; font-size: 1rem; } +.btn-full { width: 100%; justify-content: center; } +.btn-primary { + background: linear-gradient(135deg, var(--accent-blue), var(--accent-light)); + box-shadow: 0 4px 12px var(--accent-glow); +} +.btn-primary:hover { box-shadow: 0 6px 20px var(--accent-glow); } +.btn-success { background: var(--green); } +.btn-success:hover { box-shadow: 0 4px 12px rgba(35, 134, 54, 0.3); } +.btn-warning { background: var(--yellow); color: #0d1117; } +.btn-danger { background: var(--red); } +.btn-danger:hover { box-shadow: 0 4px 12px rgba(218, 54, 51, 0.3); } +.btn-outline { + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-secondary); +} +.btn-outline:hover { + border-color: var(--accent-blue); + color: var(--accent-light); + background: rgba(0, 136, 204, 0.08); +} +.badge { + display: inline-flex; + align-items: center; + gap: .25rem; + padding: .2rem .6rem; + border-radius: 999px; + font-size: .75rem; + font-weight: 500; +} +.badge-protected { + background: var(--gray-bg); + color: var(--text-muted); +} + +/* Deploy page */ +.deploy-container { max-width: 700px; } +.deploy-info { + display: flex; + gap: 1rem; + align-items: flex-start; + background: var(--bg-card); + padding: 1.25rem; + border-radius: var(--radius); + border: 1px solid var(--border-color); + margin-bottom: 1.5rem; +} +.deploy-logo { + width: 64px; + height: 64px; + border-radius: 12px; + object-fit: contain; + background: var(--bg-secondary); + padding: 8px; + flex-shrink: 0; +} +.deploy-info h3 { font-size: 1.2rem; margin-bottom: .25rem; } +.deploy-info p { color: var(--text-secondary); font-size: .9rem; } +.deploy-form { + background: var(--bg-card); + padding: 1.5rem; + border-radius: var(--radius); + border: 1px solid var(--border-color); +} +.form-section { margin-bottom: 1.5rem; } +.form-section h4 { font-size: 1rem; margin-bottom: .5rem; color: var(--text-primary); } +.form-section-desc { color: var(--text-secondary); font-size: .85rem; margin-bottom: .75rem; } +.form-group { margin-bottom: 1rem; } +.form-group label { display: block; font-size: .85rem; font-weight: 500; margin-bottom: .4rem; color: var(--text-primary); } +.form-group-auto { + display: flex; + justify-content: space-between; + align-items: center; + padding: .5rem .75rem; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; +} +.form-group-auto label { margin: 0; } +.auto-generated-badge { color: var(--green); font-size: .8rem; font-weight: 500; } +.form-control { + width: 100%; + padding: .55rem .75rem; + border: 1px solid var(--border-color); + border-radius: 8px; + font-size: .9rem; + background: var(--bg-secondary); + color: var(--text-primary); + font-family: inherit; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} +.form-control:focus { + outline: none; + border-color: var(--accent-blue); + box-shadow: 0 0 0 3px var(--accent-glow); +} +.form-control:disabled { + background: var(--bg-primary); + color: var(--text-muted); + cursor: not-allowed; +} +select.form-control { appearance: auto; } +select.form-control option { background: var(--bg-secondary); color: var(--text-primary); } +.input-with-button { display: flex; gap: .5rem; } +.input-with-button .form-control { flex: 1; } +.form-hint { display: block; font-size: .8rem; color: var(--text-muted); margin-top: .25rem; } +.required { color: var(--red); } +.locked-hint { font-size: .75rem; color: var(--text-muted); font-weight: 400; margin-left: .5rem; } +.deploy-actions { + display: flex; + gap: .75rem; + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid var(--border-color); +} + +/* Deploy progress */ +.deploy-progress { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 1.5rem; +} +.deploy-progress h3 { + margin-bottom: 0.5rem; +} +.deploy-steps { + margin: 1rem 0; +} +.deploy-step { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0; + font-size: .95rem; + color: var(--text-muted); +} +.deploy-step.active { + color: var(--text-primary); +} +.deploy-step.done { + color: var(--text-primary); +} +.deploy-step.done .step-icon { + color: var(--green); +} +.deploy-step.error .step-icon { + color: var(--red); +} +.deploy-step.warn .step-icon { + color: var(--yellow); +} +.step-icon { + font-size: 1.1rem; + width: 1.5rem; + text-align: center; + flex-shrink: 0; +} +.deploy-elapsed { + color: var(--text-muted); + font-size: .85rem; + margin-top: 0.5rem; +} + +/* Toggle switch */ +.toggle { cursor: pointer; display: flex; align-items: center; gap: .5rem; } +.toggle input[type="checkbox"] { accent-color: var(--accent-blue); } +.toggle-label { font-size: .9rem; color: var(--text-secondary); } + +/* Alerts */ +.alert { + padding: .75rem 1rem; + border-radius: 8px; + margin-bottom: 1rem; + font-size: .85rem; + border: 1px solid; +} +.alert-error { + background: var(--red-bg); + color: var(--red); + border-color: rgba(218, 54, 51, 0.3); +} +.alert-info { + background: rgba(0, 136, 204, 0.1); + color: var(--accent-light); + border-color: rgba(0, 136, 204, 0.3); +} +.alert-warning { + background: var(--yellow-bg); + color: var(--yellow); + border-color: rgba(210, 153, 34, 0.3); +} + +/* Memory summary on deploy page */ +.memory-summary { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 1rem 1.25rem; + margin-bottom: 1rem; +} +.memory-blocked { + border-color: rgba(218, 54, 51, 0.5); +} +.memory-summary-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} +.memory-summary-label { + font-size: .8rem; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: .5px; +} +.memory-summary-value { + font-size: .8rem; + color: var(--text-muted); + font-family: 'JetBrains Mono', monospace; +} +.memory-bar-stacked { + width: 100%; + height: 10px; + border-radius: 5px; + display: flex; + overflow: hidden; + position: relative; + background: linear-gradient(to right, + rgba(35, 134, 54, 0.10) 0%, rgba(35, 134, 54, 0.10) 70%, + rgba(210, 153, 34, 0.18) 70%, rgba(210, 153, 34, 0.18) 85%, + rgba(218, 54, 51, 0.18) 85%, rgba(218, 54, 51, 0.18) 100% + ); +} +.memory-bar-stacked::before { + content: ''; + position: absolute; + left: 70%; + top: -1px; + bottom: -1px; + width: 2px; + background: var(--yellow); + border-radius: 1px; + opacity: 0.5; + z-index: 2; +} +.memory-bar-stacked::after { + content: ''; + position: absolute; + left: 85%; + top: -1px; + bottom: -1px; + width: 2px; + background: var(--red); + border-radius: 1px; + opacity: 0.5; + z-index: 2; +} +.memory-bar-segment { + height: 100%; + position: relative; + z-index: 1; + transition: width 0.3s ease; +} +.memory-bar-segment:not([style*="width:0%"]) { + min-width: 3px; +} +.memory-bar-committed { + background: var(--green); + box-shadow: 0 0 6px rgba(35, 134, 54, 0.4); + border-radius: 5px 0 0 5px; +} +.memory-bar-new { + background: rgba(35, 134, 54, 0.45); + border-right: 2px solid #4edf72; + border-radius: 0 5px 5px 0; +} +.memory-bar-legend { + display: flex; + gap: 1.25rem; + margin-top: 0.5rem; +} +.memory-legend-item { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: .75rem; + color: var(--text-secondary); +} +.memory-legend-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 3px; + flex-shrink: 0; +} +.memory-legend-committed { + background: var(--green); +} +.memory-legend-new { + background: rgba(35, 134, 54, 0.45); + border: 1px solid #4edf72; +} + +/* Logs */ +.logs-container { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 1rem; + overflow-x: auto; + overflow-y: auto; + max-height: 70vh; + margin-bottom: 1rem; +} +.logs-output { + color: var(--text-primary); + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: .8rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-all; + margin: 0; +} +.logs-actions { + display: flex; + gap: .5rem; + align-items: center; +} +.logs-live-indicator { + display: inline-flex; + align-items: center; + gap: .35rem; + font-size: .8rem; + font-weight: 600; + color: var(--green); + margin-right: .5rem; +} +.logs-live-indicator.logs-live-paused { + color: var(--text-muted); +} +.logs-live-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--green); + animation: logs-pulse 1.5s ease-in-out infinite; +} +@keyframes logs-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +/* Sync toast */ +.sync-toast { + padding: .6rem 1rem; + border-radius: 8px; + font-size: .85rem; + margin-bottom: 1rem; + border: 1px solid; + transition: opacity 0.3s ease; +} +.sync-toast-ok { + background: var(--green-bg); + color: var(--green); + border-color: rgba(35, 134, 54, 0.3); +} +.sync-toast-err { + background: var(--red-bg); + color: var(--red); + border-color: rgba(218, 54, 51, 0.3); +} + +/* Clickable cards */ +[data-href] { cursor: pointer; } + +.empty-state { text-align: center; padding: 3rem; color: var(--text-muted); } + +/* Login page */ +.login-body { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + background: var(--bg-primary); +} +.login-body::before { + content: ''; + position: fixed; + top: 0; left: 0; + width: 100%; height: 100%; + background-image: + linear-gradient(rgba(0, 136, 204, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 136, 204, 0.03) 1px, transparent 1px); + background-size: 50px 50px; + pointer-events: none; + z-index: 0; +} +.login-card { + background: var(--bg-card); + padding: 2.5rem; + border-radius: 16px; + border: 1px solid var(--border-color); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + width: 100%; + max-width: 380px; + text-align: center; + position: relative; + z-index: 1; +} +.login-card .login-logo { + width: 220px; + margin-bottom: 0.5rem; +} +.login-subtitle { + color: var(--text-secondary); + margin-bottom: 1.5rem; +} +.login-footer { + margin-top: 1.5rem; + font-size: .75rem; + color: var(--text-muted); +} +.login-footer a { + color: var(--accent-light); + text-decoration: none; + transition: color 0.2s ease; +} +.login-footer a:hover { color: var(--accent-blue); } + +/* --- App Info Page --- */ +.app-info-hero { + display: flex; + align-items: center; + gap: 1.5rem; + padding: 1.5rem; + background: var(--bg-card); + border-radius: var(--radius); + border: 1px solid var(--border-color); + margin-bottom: 1.5rem; +} +.app-info-logo { + width: 80px; + height: 80px; + min-width: 80px; + min-height: 80px; + max-width: 80px; + max-height: 80px; + border-radius: 12px; + object-fit: contain; + background: var(--bg-secondary); + padding: 10px; + flex-shrink: 0; + overflow: hidden; +} +.app-info-tagline { + font-size: 1.1rem; + color: var(--text-primary); + margin: 0 0 .75rem 0; +} +.app-screenshots { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + overflow-x: auto; + padding-bottom: .5rem; +} +.app-screenshot { + max-height: 220px; + border-radius: var(--radius); + border: 1px solid var(--border-color); + object-fit: cover; +} +.app-info-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} +.app-info-card { + background: var(--bg-card); + border-radius: var(--radius); + padding: 1.25rem; + border: 1px solid var(--border-color); +} +.app-info-card h3 { + margin: 0 0 .75rem 0; + font-size: .95rem; + color: var(--text-primary); +} +.app-info-list { + margin: 0; + padding-left: 1.25rem; + color: var(--text-secondary); + font-size: .9rem; + line-height: 1.6; +} +.app-info-creds { + font-family: monospace; + font-size: 1rem; + color: var(--accent-light); + background: var(--bg-secondary); + padding: .5rem .75rem; + border-radius: 4px; + display: inline-block; + margin: 0 0 .5rem 0; +} +.app-info-creds-warn { + color: var(--orange); + font-size: .85rem; + margin: 0; +} +.app-info-link { + color: var(--accent-light); + text-decoration: none; +} +.app-info-link:hover { text-decoration: underline; } + +/* Optional config section */ +.app-optional-config { + background: var(--bg-card); + border-radius: var(--radius); + padding: 1.5rem; + border: 1px solid var(--border-color); + margin-bottom: 1.5rem; +} +.app-optional-config h3 { + margin: 0 0 1rem 0; + font-size: 1.05rem; +} +.config-group { + margin-bottom: 1.5rem; +} +.config-group h4 { + margin: 0 0 .5rem 0; + font-size: .95rem; + color: var(--text-primary); +} +.config-group-desc { + color: var(--text-secondary); + font-size: .85rem; + margin: 0 0 1rem 0; +} +.config-fields { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 1rem; +} +.config-field { + display: flex; + flex-direction: column; + gap: .25rem; +} +.config-field label { + font-size: .85rem; + font-weight: 500; + color: var(--text-primary); +} +.config-field-help { + font-size: .8rem; + color: var(--text-secondary); + margin: 0; + line-height: 1.4; +} +.config-field-help a { + color: var(--accent-light); + text-decoration: none; +} +.config-field-help a:hover { text-decoration: underline; } +.config-input { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: .5rem .75rem; + color: var(--text-primary); + font-size: .9rem; + font-family: monospace; + width: 100%; + box-sizing: border-box; +} +.config-input:focus { + outline: none; + border-color: var(--accent-blue); +} +.config-actions { + display: flex; + align-items: center; + gap: 1rem; + margin-top: 1rem; +} +.config-save-status { + font-size: .9rem; + transition: opacity .3s; +} +.config-save-ok { color: var(--green); } +.config-save-err { color: var(--red); } +.meta-badge-warn { + background: rgba(255, 152, 0, 0.1) !important; + color: var(--orange) !important; +} +.meta-badge-ok { + background: rgba(76, 175, 80, 0.1) !important; + color: var(--green) !important; +} + +/* Orphan badge */ +.badge-orphaned { + background: var(--orange-bg); + color: var(--orange); +} + +/* Delete modal */ +.modal-overlay { + position: fixed; + top: 0; left: 0; + width: 100%; height: 100%; + background: rgba(0, 0, 0, 0.7); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} +.modal-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 1.5rem; + max-width: 500px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} +.modal-card h3 { + margin-bottom: .75rem; +} +.modal-hdd-info { + background: var(--bg-secondary); + border-radius: 8px; + padding: .75rem 1rem; + margin: .75rem 0; + font-size: .85rem; + color: var(--text-secondary); +} +.modal-hdd-path { + font-family: 'JetBrains Mono', monospace; + font-size: .8rem; + color: var(--text-muted); + padding: .2rem 0; +} +.modal-checkbox { + display: flex; + align-items: center; + gap: .5rem; + margin: .75rem 0; + padding: .75rem 1rem; + background: var(--red-bg); + border: 1px solid rgba(218, 54, 51, 0.3); + border-radius: 8px; + font-size: .85rem; + color: var(--red); + cursor: pointer; +} +.modal-checkbox input[type="checkbox"] { + accent-color: var(--red); +} +.modal-actions { + display: flex; + gap: .75rem; + margin-top: 1rem; + justify-content: flex-end; +} + +/* Responsive */ +@media(max-width: 768px) { + .sidebar { width: 100%; height: auto; position: relative; border-right: none; border-bottom: 1px solid var(--border-color); } + .nav-links { display: flex; padding: 0; overflow-x: auto; } + .nav-links a { padding: .5rem 1rem; white-space: nowrap; } + .nav-links a.active { border-left: none; border-bottom: 2px solid var(--accent-blue); } + .content { margin-left: 0; padding: 1rem; } + body { flex-direction: column; } + .stack-card { flex-direction: column; align-items: flex-start; gap: .75rem; } + .stack-actions { width: 100%; justify-content: flex-end; } + .stack-grid { grid-template-columns: 1fr; } + .stats-grid { grid-template-columns: repeat(3, 1fr); } + .deploy-info { flex-direction: column; } + .system-info-items { flex-direction: column; gap: 1rem; } +} diff --git a/scripts/docker-setup.sh b/scripts/docker-setup.sh index 22d196d..800109a 100644 --- a/scripts/docker-setup.sh +++ b/scripts/docker-setup.sh @@ -244,7 +244,7 @@ WHAT THIS SCRIPT INSTALLS: DEPLOYING APPLICATIONS: After infrastructure setup, deploy the felhom-controller to manage apps - via the web dashboard at dashboard.. + via the web dashboard at felhom.. EXAMPLES: # Felhom customer deployment (recommended — full stack with tunnel) @@ -1473,7 +1473,7 @@ print_summary() { echo " Deploy applications via the Felhom Controller dashboard:" echo "" echo " 1. Deploy felhom-controller (see controller/README.md)" - echo " 2. Open https://dashboard.${BASE_DOMAIN}" + echo " 2. Open https://felhom.${BASE_DOMAIN}" echo " 3. Browse available apps on the Alkalmazások page" echo " 4. Click Telepítés to deploy" if [[ -n "$CUSTOMER_ID" ]]; then