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}}
-
-
-
-
- {{if .SystemInfo.HDDConfigured}}
-
- {{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}}
-
-
-
- {{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}}
-
-
-
- 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}}
-
-
-
-
-
Telepítés folyamatban...
-
-
- ⏳
- Konfiguráció mentése...
-
-
- ⏳
- Konténer(ek) indítása...
-
-
- ⏳
- Alkalmazás inicializálása...
-
-
-
-
-
-
-
-
-
-
-{{template "layout_end" .}}
-{{end}}
-`
-
-const loginTmpl = `
-{{define "login"}}
-
-
-
-
-
- Bejelentkezés — Felhom
-
-
-
-
-

-
{{.CustomerName}}
- {{if .Error}}
{{.Error}}
{{end}}
-
-
-
-
-
-{{end}}
-`
-
-const logsTmpl = `
-{{define "logs"}}
-{{template "layout_start" .}}
-
-
-
-
- É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ó?
-
- {{range .AppInfo.UseCases}}- {{.}}
{{end}}
-
-
- {{end}}
-
- {{if .AppInfo.FirstSteps}}
-
-
Első lépések
-
- {{range .AppInfo.FirstSteps}}- {{.}}
{{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}}
-
- {{end}}
-
-{{end}}
-
-{{if .HasOptionalConfig}}
-
-
Opcionális beállítások
- {{range .OptionalConfig}}
-
-
{{.Group}}
- {{if .Description}}
{{.Description}}
{{end}}
-
-
- {{range .Fields}}
-
- {{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}}- {{.}}
{{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}}
+
+ {{end}}
+
+{{end}}
+
+{{if .HasOptionalConfig}}
+
+
Opcionális beállítások
+ {{range .OptionalConfig}}
+
+
{{.Group}}
+ {{if .Description}}
{{.Description}}
{{end}}
+
+
+ {{range .Fields}}
+
+ {{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}}
+
+
+
+
+ {{if .SystemInfo.HDDConfigured}}
+
+ {{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}}
+
+
+
+ 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}}
+
+
+
+
+
Telepítés folyamatban...
+
+
+ ⏳
+ Konfiguráció mentése...
+
+
+ ⏳
+ Konténer(ek) indítása...
+
+
+ ⏳
+ Alkalmazás inicializálása...
+
+
+
+
+
+
+
+
+
+
+{{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
+
+
+
+
+

+
{{.CustomerName}}
+ {{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+
+{{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" .}}
+
+
+
+
+ É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}}
+
+
+
+ {{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