v0.3.0: structural refactor — go:embed templates, server split, domain rename
- Migrate all 7 HTML templates + CSS from Go string constants to individual go:embed files in internal/web/templates/ (templates.go: 2150→35 lines) - Split server.go into auth.go, handlers.go, funcmap.go (server.go: 540→120 lines) - Rename controller subdomain from dashboard.* to felhom.* in Traefik labels - Update documentation (CLAUDE.md, README.md, CONTEXT.md) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
+17
-4
@@ -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 |
|
||||
|
||||
+11
-3
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed templates/*.html templates/*.css
|
||||
var templateFS embed.FS
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,182 @@
|
||||
{{define "app_info"}}
|
||||
{{template "layout_start" .}}
|
||||
|
||||
<div class="page-header">
|
||||
<div style="display:flex;align-items:center;gap:1rem">
|
||||
<a href="/stacks" class="btn btn-sm btn-outline">← Alkalmazások</a>
|
||||
<h2>{{.Meta.DisplayName}}</h2>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:.5rem">
|
||||
{{if .Stack.Deployed}}
|
||||
<span class="stack-state-badge state-{{stateColor .Stack.State}}">{{stateLabel .Stack.State}}</span>
|
||||
{{if .Stack.Orphaned}}<span class="badge badge-orphaned">Elavult</span>{{end}}
|
||||
<a href="https://{{.Meta.Subdomain}}.{{.Domain}}" target="_blank" class="btn btn-sm btn-outline">Megnyitás ↗</a>
|
||||
<a href="/stacks/{{.Stack.Name}}/logs" class="btn btn-sm btn-outline">Napló</a>
|
||||
{{if .Stack.Orphaned}}
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteOrphanStack('{{.Stack.Name}}')">Törlés</button>
|
||||
{{else}}
|
||||
<a href="/stacks/{{.Stack.Name}}/deploy" class="btn btn-sm btn-outline">Beállítások</a>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<a href="/stacks/{{.Stack.Name}}/deploy" class="btn btn-sm btn-primary" onclick="return checkBeforeDeploy(event, '{{.Stack.Name}}')">Telepítés</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero section -->
|
||||
<div class="app-info-hero">
|
||||
<img class="app-info-logo" src="{{logoURL .Meta.Slug}}"
|
||||
alt="{{.Meta.DisplayName}}"
|
||||
onerror="this.onerror=function(){this.style.display='none'};this.src='{{logoPNGURL .Meta.Slug}}'">
|
||||
<div class="app-info-hero-text">
|
||||
{{if .AppInfo.Tagline}}
|
||||
<p class="app-info-tagline">{{.AppInfo.Tagline}}</p>
|
||||
{{else}}
|
||||
<p class="app-info-tagline">{{.Meta.Description}}</p>
|
||||
{{end}}
|
||||
<div class="stack-meta-badges">
|
||||
<span class="meta-badge">~{{.Meta.Resources.MemRequest}} RAM</span>
|
||||
<span class="meta-badge">{{.Meta.Category}}</span>
|
||||
{{if .Meta.Resources.NeedsHDD}}<span class="meta-badge meta-badge-warn">HDD szükséges</span>{{end}}
|
||||
{{if .Meta.Resources.PiCompatible}}<span class="meta-badge meta-badge-ok">Pi kompatibilis</span>{{else}}<span class="meta-badge meta-badge-warn">Csak x86</span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Screenshots (graceful — hidden if assets don't exist) -->
|
||||
<div class="app-screenshots" id="screenshots">
|
||||
<img src="{{screenshotURL .Meta.Slug 1}}" alt="" class="app-screenshot"
|
||||
onerror="this.style.display='none'">
|
||||
<img src="{{screenshotURL .Meta.Slug 2}}" alt="" class="app-screenshot"
|
||||
onerror="this.style.display='none'">
|
||||
<img src="{{screenshotURL .Meta.Slug 3}}" alt="" class="app-screenshot"
|
||||
onerror="this.style.display='none'">
|
||||
</div>
|
||||
|
||||
{{if .HasAppInfo}}
|
||||
<div class="app-info-grid">
|
||||
{{if .AppInfo.UseCases}}
|
||||
<div class="app-info-card">
|
||||
<h3>Mire használható?</h3>
|
||||
<ul class="app-info-list">
|
||||
{{range .AppInfo.UseCases}}<li>{{.}}</li>{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .AppInfo.FirstSteps}}
|
||||
<div class="app-info-card">
|
||||
<h3>Első lépések</h3>
|
||||
<ol class="app-info-list">
|
||||
{{range .AppInfo.FirstSteps}}<li>{{.}}</li>{{end}}
|
||||
</ol>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .AppInfo.Prerequisites}}
|
||||
<div class="app-info-card">
|
||||
<h3>Előfeltételek</h3>
|
||||
<ul class="app-info-list">
|
||||
{{range .AppInfo.Prerequisites}}<li>{{.}}</li>{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .AppInfo.DefaultCreds}}
|
||||
<div class="app-info-card">
|
||||
<h3>Alapértelmezett belépés</h3>
|
||||
<p class="app-info-creds">{{.AppInfo.DefaultCreds}}</p>
|
||||
<p class="app-info-creds-warn">Az első bejelentkezés után azonnal változtasd meg!</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .AppInfo.DocsURL}}
|
||||
<div class="app-info-card">
|
||||
<h3>Dokumentáció</h3>
|
||||
<p><a href="{{.AppInfo.DocsURL}}" target="_blank" class="app-info-link">Hivatalos dokumentáció ↗</a></p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .HasOptionalConfig}}
|
||||
<div class="app-optional-config">
|
||||
<h3>Opcionális beállítások</h3>
|
||||
{{range .OptionalConfig}}
|
||||
<div class="config-group">
|
||||
<h4>{{.Group}}</h4>
|
||||
{{if .Description}}<p class="config-group-desc">{{.Description}}</p>{{end}}
|
||||
|
||||
<div class="config-fields">
|
||||
{{range .Fields}}
|
||||
<div class="config-field">
|
||||
<label for="opt-{{.EnvVar}}">{{.Label}}</label>
|
||||
{{if .HelpText}}<p class="config-field-help">{{.HelpText}}</p>{{end}}
|
||||
{{if .HelpURL}}<p class="config-field-help"><a href="{{.HelpURL}}" target="_blank">Regisztrációs útmutató ↗</a></p>{{end}}
|
||||
<input type="{{if eq .Type "secret_input"}}password{{else}}text{{end}}"
|
||||
id="opt-{{.EnvVar}}"
|
||||
name="{{.EnvVar}}"
|
||||
class="config-input"
|
||||
value="{{index $.CurrentValues .EnvVar}}"
|
||||
placeholder="{{.Label}}"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="config-actions">
|
||||
<button class="btn btn-primary" id="save-optional-config" onclick="saveOptionalConfig('{{.Stack.Name}}')">
|
||||
Mentés
|
||||
</button>
|
||||
<span id="config-save-status" class="config-save-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function saveOptionalConfig(stackName) {
|
||||
const btn = document.getElementById('save-optional-config');
|
||||
const status = document.getElementById('config-save-status');
|
||||
const inputs = document.querySelectorAll('.config-input');
|
||||
|
||||
const values = {};
|
||||
inputs.forEach(function(input) {
|
||||
values[input.name] = input.value;
|
||||
});
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Mentés...';
|
||||
status.textContent = '';
|
||||
status.className = 'config-save-status';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/stacks/' + stackName + '/optional-config', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({values: values})
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.ok) {
|
||||
status.textContent = (data.message || 'Mentve');
|
||||
status.className = 'config-save-status config-save-ok';
|
||||
} else {
|
||||
status.textContent = (data.error || 'Hiba');
|
||||
status.className = 'config-save-status config-save-err';
|
||||
}
|
||||
} catch(err) {
|
||||
status.textContent = 'Hálózati hiba';
|
||||
status.className = 'config-save-status config-save-err';
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Mentés';
|
||||
|
||||
setTimeout(function() { status.textContent = ''; }, 5000);
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{template "layout_end" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,101 @@
|
||||
{{define "dashboard"}}
|
||||
{{template "layout_start" .}}
|
||||
|
||||
<div class="page-header">
|
||||
<h2>Vezérlőpult</h2>
|
||||
<span class="domain-badge">{{.Domain}}</span>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card stat-running">
|
||||
<div class="stat-value">{{.RunningCount}}</div>
|
||||
<div class="stat-label">Futó alkalmazás</div>
|
||||
</div>
|
||||
<div class="stat-card stat-stopped">
|
||||
<div class="stat-value">{{.StoppedCount}}</div>
|
||||
<div class="stat-label">Leállítva</div>
|
||||
</div>
|
||||
<div class="stat-card stat-total">
|
||||
<div class="stat-value">{{.TotalCount}}</div>
|
||||
<div class="stat-label">Összes alkalmazás</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .SystemInfo.TotalMemMB}}
|
||||
<div class="system-info-card">
|
||||
<div class="system-info-items">
|
||||
<div class="system-info-item">
|
||||
<div class="system-info-header">
|
||||
<span class="system-info-label">Memória</span>
|
||||
<span class="system-info-value">{{fmtMB .SystemInfo.UsedMemMB}} / {{fmtMB .SystemInfo.TotalMemMB}} ({{printf "%.0f" .SystemInfo.MemPercent}}%)</span>
|
||||
</div>
|
||||
<div class="system-bar">
|
||||
<div class="system-bar-fill system-bar-{{usageColor .SystemInfo.MemPercent}}" style="width:{{printf "%.0f" .SystemInfo.MemPercent}}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="system-info-item">
|
||||
<div class="system-info-header">
|
||||
<span class="system-info-label">SSD tárhely</span>
|
||||
<span class="system-info-value">{{fmtGB .SystemInfo.DiskUsedGB}} / {{fmtGB .SystemInfo.DiskTotalGB}} ({{printf "%.0f" .SystemInfo.DiskPercent}}%)</span>
|
||||
</div>
|
||||
<div class="system-bar">
|
||||
<div class="system-bar-fill system-bar-{{usageColor .SystemInfo.DiskPercent}}" style="width:{{printf "%.0f" .SystemInfo.DiskPercent}}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{{if .SystemInfo.HDDConfigured}}
|
||||
<div class="system-info-item">
|
||||
<div class="system-info-header">
|
||||
<span class="system-info-label">Külső HDD</span>
|
||||
<span class="system-info-value">{{fmtGB .SystemInfo.HDDUsedGB}} / {{fmtGB .SystemInfo.HDDTotalGB}} ({{printf "%.0f" .SystemInfo.HDDPercent}}%)</span>
|
||||
</div>
|
||||
<div class="system-bar">
|
||||
<div class="system-bar-fill system-bar-{{usageColor .SystemInfo.HDDPercent}}" style="width:{{printf "%.0f" .SystemInfo.HDDPercent}}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<h3>Alkalmazások állapota</h3>
|
||||
|
||||
<div class="stack-list">
|
||||
{{range .Stacks}}
|
||||
<div class="stack-card stack-state-{{stateColor .State}}"{{if not .Protected}} data-href="/apps/{{.Meta.Slug}}"{{end}}>
|
||||
<div class="stack-info">
|
||||
<img class="stack-logo" src="{{logoURL .Meta.Slug}}"
|
||||
alt="{{.Meta.DisplayName}}" onerror="this.onerror=function(){this.style.display='none'};this.src='{{logoPNGURL .Meta.Slug}}'">
|
||||
<div>
|
||||
<strong class="stack-name">{{.Meta.DisplayName}}</strong>
|
||||
{{if .Meta.Description}}<span class="stack-desc">{{.Meta.Description}}</span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stack-actions">
|
||||
<span class="stack-state-label">{{stateLabel .State}}</span>
|
||||
{{if .Orphaned}}<span class="badge badge-orphaned">Elavult</span>{{end}}
|
||||
|
||||
{{if .Protected}}
|
||||
<span class="badge badge-protected">Védett</span>
|
||||
{{else if not .Deployed}}
|
||||
<a href="/stacks/{{.Name}}/deploy" class="btn btn-sm btn-primary" onclick="return checkBeforeDeploy(event, '{{.Name}}')">Telepítés</a>
|
||||
{{else}}
|
||||
{{if isOperational .State}}
|
||||
<button class="btn btn-sm btn-warning" onclick="stackAction('{{.Name}}', 'restart')">↻</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="stackAction('{{.Name}}', 'stop')">■</button>
|
||||
{{else}}
|
||||
<button class="btn btn-sm btn-success" onclick="stackAction('{{.Name}}', 'start')">▶</button>
|
||||
{{end}}
|
||||
<a href="/stacks/{{.Name}}/logs" class="btn btn-sm btn-outline">Napló</a>
|
||||
{{if .Orphaned}}<button class="btn btn-sm btn-danger" onclick="deleteOrphanStack('{{.Name}}')">Törlés</button>{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<p>Nincs elérhető alkalmazás.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{template "layout_end" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,330 @@
|
||||
{{define "deploy"}}
|
||||
{{template "layout_start" .}}
|
||||
|
||||
<div class="page-header">
|
||||
<div style="display:flex;align-items:center;gap:.5rem">
|
||||
<a href="/stacks" class="btn btn-sm btn-outline">← Vissza</a>
|
||||
<h2>{{.Meta.DisplayName}} — Telepítés</h2>
|
||||
</div>
|
||||
<a href="/apps/{{.Meta.Slug}}" class="btn btn-sm btn-outline">ℹ️ Részletek</a>
|
||||
</div>
|
||||
|
||||
<div class="deploy-container">
|
||||
<div class="deploy-info">
|
||||
<img class="deploy-logo" src="{{.LogoURL}}" alt="" onerror="this.onerror=function(){this.style.display='none'};this.src='{{.LogoPNGURL}}'">
|
||||
<div>
|
||||
<h3>{{.Meta.DisplayName}}</h3>
|
||||
{{if .Meta.Description}}<p>{{.Meta.Description}}</p>{{end}}
|
||||
<div class="stack-meta-badges">
|
||||
{{if .Meta.Resources.MemRequest}}<span class="meta-badge">~{{.Meta.Resources.MemRequest}}</span>{{end}}
|
||||
{{if .Meta.Resources.PiCompatible}}<span class="meta-badge meta-badge-ok">Pi kompatibilis</span>{{end}}
|
||||
{{if .Meta.Resources.NeedsHDD}}<span class="meta-badge">HDD szükséges</span>{{end}}
|
||||
</div>
|
||||
<a href="/apps/{{.Meta.Slug}}" class="btn btn-sm btn-outline" style="margin-top:0.5rem">
|
||||
Részletes leírás, képernyőképek
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .AlreadyDeployed}}
|
||||
<div class="alert alert-info">
|
||||
Ez az alkalmazás már telepítve van. Az alábbi beállítások csak olvashatók.
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if and (not .AlreadyDeployed) .MemoryInfo}}
|
||||
{{with .MemoryInfo}}
|
||||
{{if .Available}}
|
||||
<div class="memory-summary{{if .Blocked}} memory-blocked{{end}}">
|
||||
{{if .Blocked}}
|
||||
<div class="alert alert-error" style="margin-bottom:0">
|
||||
Nincs elég memória! Foglalás telepítés után: {{.AfterMB}} MB / {{.UsableMB}} MB
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="memory-summary-header">
|
||||
<span class="memory-summary-label">Memória foglalás</span>
|
||||
<span class="memory-summary-value">{{.AfterMB}} MB / {{.UsableMB}} MB ({{.Percent}}%)</span>
|
||||
</div>
|
||||
<div class="memory-bar-stacked">
|
||||
<div class="memory-bar-segment memory-bar-committed" style="width:{{.CommittedPercent}}%" title="Jelenlegi foglalás: {{.CommittedMB}} MB"></div>
|
||||
<div class="memory-bar-segment memory-bar-new" style="width:{{subtract .Percent .CommittedPercent}}%" title="{{$.Meta.DisplayName}}: +{{.NewRequestMB}} MB"></div>
|
||||
</div>
|
||||
<div class="memory-bar-legend">
|
||||
<span class="memory-legend-item"><span class="memory-legend-dot memory-legend-committed"></span>Jelenlegi foglalás ({{.CommittedMB}} MB)</span>
|
||||
<span class="memory-legend-item"><span class="memory-legend-dot memory-legend-new"></span>{{$.Meta.DisplayName}} (+{{.NewRequestMB}} MB)</span>
|
||||
</div>
|
||||
{{if .OvercommitWarn}}
|
||||
<div class="alert alert-warning" style="margin-top:0.5rem;margin-bottom:0">
|
||||
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.
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
<form id="deploy-form" class="deploy-form">
|
||||
{{if .AutoFields}}
|
||||
<div class="form-section">
|
||||
<h4>Automatikusan generált értékek</h4>
|
||||
<p class="form-section-desc">Ezek az értékek automatikusan létrejönnek a telepítéskor.</p>
|
||||
{{range .AutoFields}}
|
||||
<div class="form-group form-group-auto">
|
||||
<label>{{.Label}}</label>
|
||||
<span class="auto-generated-badge">✓ Automatikusan generálva</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .UserFields}}
|
||||
<div class="form-section">
|
||||
<h4>Beállítások</h4>
|
||||
{{range .UserFields}}
|
||||
<div class="form-group">
|
||||
<label for="field-{{.EnvVar}}">
|
||||
{{.Label}}
|
||||
{{if or .Required (eq .Type "password")}}<span class="required">*</span>{{end}}
|
||||
{{if .LockedAfterDeploy}}<span class="locked-hint">telepítés után nem módosítható</span>{{end}}
|
||||
</label>
|
||||
|
||||
{{if eq .Type "select"}}
|
||||
<select id="field-{{.EnvVar}}" name="{{.EnvVar}}" class="form-control"
|
||||
{{if $.AlreadyDeployed}}disabled{{end}}>
|
||||
{{range .Options}}
|
||||
<option value="{{.Value}}">{{.Label}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
{{else if eq .Type "password"}}
|
||||
<div class="input-with-button">
|
||||
<input type="text" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
|
||||
class="form-control" value="{{.Default}}"
|
||||
placeholder="{{.Placeholder}}"
|
||||
data-field-type="password"
|
||||
required
|
||||
{{if $.AlreadyDeployed}}disabled{{end}}>
|
||||
<button type="button" class="btn btn-sm btn-outline"
|
||||
onclick="generatePassword('field-{{.EnvVar}}')">Generálás</button>
|
||||
</div>
|
||||
{{else if eq .Type "boolean"}}
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="field-{{.EnvVar}}" name="{{.EnvVar}}" value="true"
|
||||
{{if $.AlreadyDeployed}}disabled{{end}}>
|
||||
<span class="toggle-label">Igen</span>
|
||||
</label>
|
||||
{{else}}
|
||||
<input type="text" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
|
||||
class="form-control" value="{{.Default}}"
|
||||
placeholder="{{.Placeholder}}"
|
||||
{{if .Required}}required{{end}}
|
||||
{{if $.AlreadyDeployed}}disabled{{end}}>
|
||||
{{end}}
|
||||
|
||||
{{if .Description}}
|
||||
<span class="form-hint">{{.Description}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if not .AlreadyDeployed}}
|
||||
<div class="deploy-actions">
|
||||
<button type="submit" class="btn btn-primary btn-lg"{{if and .MemoryInfo (index .MemoryInfo "Blocked")}} disabled title="Nincs elég memória"{{end}}>Telepítés indítása</button>
|
||||
<a href="/stacks" class="btn btn-outline">Mégsem</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</form>
|
||||
|
||||
<div id="deploy-progress" class="deploy-progress" style="display:none">
|
||||
<h3>Telepítés folyamatban...</h3>
|
||||
<div class="deploy-steps">
|
||||
<div class="deploy-step active" id="step-config">
|
||||
<span class="step-icon">⏳</span>
|
||||
<span class="step-text">Konfiguráció mentése...</span>
|
||||
</div>
|
||||
<div class="deploy-step" id="step-containers">
|
||||
<span class="step-icon">⏳</span>
|
||||
<span class="step-text">Konténer(ek) indítása...</span>
|
||||
</div>
|
||||
<div class="deploy-step" id="step-health">
|
||||
<span class="step-icon">⏳</span>
|
||||
<span class="step-text">Alkalmazás inicializálása...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="deploy-warning" class="alert alert-warning" style="display:none"></div>
|
||||
<div id="deploy-result" style="display:none"></div>
|
||||
<p class="deploy-elapsed" id="deploy-elapsed"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function generatePassword(fieldId) {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let pass = '';
|
||||
const arr = new Uint8Array(16);
|
||||
crypto.getRandomValues(arr);
|
||||
for (let i = 0; i < 16; i++) {
|
||||
pass += chars[arr[i] % chars.length];
|
||||
}
|
||||
document.getElementById(fieldId).value = pass;
|
||||
}
|
||||
|
||||
document.getElementById('deploy-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Client-side validation: check all password fields are filled
|
||||
const passwordFields = e.target.querySelectorAll('input[data-field-type="password"]');
|
||||
for (const pf of passwordFields) {
|
||||
if (!pf.disabled && pf.value.trim() === '') {
|
||||
const label = pf.closest('.form-group').querySelector('label').textContent.trim();
|
||||
alert('Kötelező mező: ' + label + '\nHasználja a Generálás gombot vagy írjon be egy jelszót.');
|
||||
pf.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Client-side validation: check all required fields
|
||||
const requiredFields = e.target.querySelectorAll('input[required], select[required]');
|
||||
for (const rf of requiredFields) {
|
||||
if (!rf.disabled && rf.value.trim() === '') {
|
||||
const label = rf.closest('.form-group').querySelector('label').textContent.trim();
|
||||
alert('Kötelező mező: ' + label);
|
||||
rf.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const btn = e.target.querySelector('[type=submit]');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Telepítés folyamatban...';
|
||||
|
||||
const values = {};
|
||||
const inputs = e.target.querySelectorAll('input, select');
|
||||
inputs.forEach(function(el) {
|
||||
if (el.name && !el.disabled) {
|
||||
if (el.type === 'checkbox') {
|
||||
values[el.name] = el.checked ? 'true' : 'false';
|
||||
} else {
|
||||
values[el.name] = el.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var stackName = '{{.Stack.Name}}';
|
||||
var progressEl = document.getElementById('deploy-progress');
|
||||
var formEl = document.getElementById('deploy-form');
|
||||
var stepConfig = document.getElementById('step-config');
|
||||
var stepContainers = document.getElementById('step-containers');
|
||||
var stepHealth = document.getElementById('step-health');
|
||||
var warningEl = document.getElementById('deploy-warning');
|
||||
var resultEl = document.getElementById('deploy-result');
|
||||
var elapsedEl = document.getElementById('deploy-elapsed');
|
||||
|
||||
function setStep(el, status, text) {
|
||||
el.className = 'deploy-step ' + status;
|
||||
if (text) el.querySelector('.step-text').textContent = text;
|
||||
var icon = el.querySelector('.step-icon');
|
||||
if (status === 'done') icon.textContent = '\u2705';
|
||||
else if (status === 'error') icon.textContent = '\u274C';
|
||||
else if (status === 'warn') icon.textContent = '\u26A0\uFE0F';
|
||||
else if (status === 'active') icon.textContent = '\u23F3';
|
||||
}
|
||||
|
||||
// Phase 1: Deploy request
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + stackName + '/deploy', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({values: values})
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (!data.ok) {
|
||||
alert('Hiba: ' + data.error);
|
||||
btn.textContent = 'Telepítés indítása';
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Deploy API returned success — switch to progress view
|
||||
formEl.style.display = 'none';
|
||||
progressEl.style.display = 'block';
|
||||
setStep(stepConfig, 'done', 'Konfiguráció mentve');
|
||||
setStep(stepContainers, 'active', 'Konténer(ek) indítása...');
|
||||
|
||||
if (data.data && data.data.warning) {
|
||||
warningEl.textContent = data.data.warning;
|
||||
warningEl.style.display = 'block';
|
||||
}
|
||||
|
||||
// Phase 2: Poll stack status
|
||||
var startTime = Date.now();
|
||||
var pollTimeout = 120000;
|
||||
var pollTimer = setInterval(async function() {
|
||||
var elapsed = Math.round((Date.now() - startTime) / 1000);
|
||||
elapsedEl.textContent = elapsed + ' másodperce...';
|
||||
|
||||
if (Date.now() - startTime > pollTimeout) {
|
||||
clearInterval(pollTimer);
|
||||
setStep(stepHealth, 'warn', 'Időtúllépés — az alkalmazás még indulhat');
|
||||
resultEl.innerHTML = '<div class="alert alert-warning" style="margin-top:1rem">' +
|
||||
'A telepítés időtúllépésbe futott. Az alkalmazás még indulhat.' +
|
||||
'</div><a href="/stacks" class="btn btn-primary" style="margin-top:.75rem">Alkalmazások megtekintése</a>';
|
||||
resultEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var sr = await fetch('/api/stacks/' + stackName);
|
||||
var sd = await sr.json();
|
||||
if (!sd.ok || !sd.data) return;
|
||||
var state = sd.data.state;
|
||||
|
||||
if (state === 'running') {
|
||||
clearInterval(pollTimer);
|
||||
setStep(stepContainers, 'done', 'Konténerek elindultak');
|
||||
setStep(stepHealth, 'done', 'Alkalmazás kész!');
|
||||
progressEl.querySelector('h3').textContent = 'Telepítés sikeres!';
|
||||
resultEl.innerHTML = '<div class="alert alert-info" style="margin-top:1rem">' +
|
||||
'Az alkalmazás fut. Átirányítás 3 másodperc múlva...' +
|
||||
'</div>';
|
||||
resultEl.style.display = 'block';
|
||||
setTimeout(function() { window.location.href = '/stacks'; }, 3000);
|
||||
} else if (state === 'starting') {
|
||||
setStep(stepContainers, 'done', 'Konténerek elindultak');
|
||||
setStep(stepHealth, 'active', 'Alkalmazás inicializálása...');
|
||||
} else if (state === 'unhealthy') {
|
||||
clearInterval(pollTimer);
|
||||
setStep(stepContainers, 'done', 'Konténerek elindultak');
|
||||
setStep(stepHealth, 'warn', 'Állapotjelző: nem egészséges');
|
||||
resultEl.innerHTML = '<div class="alert alert-warning" style="margin-top:1rem">' +
|
||||
'Az alkalmazás elindult, de az állapotjelző nem egészséges. ' +
|
||||
'Ez normális lehet az első percekben.' +
|
||||
'</div><a href="/stacks" class="btn btn-primary" style="margin-top:.75rem">Alkalmazások megtekintése</a>';
|
||||
resultEl.style.display = 'block';
|
||||
} else if (state === 'exited' || state === 'stopped') {
|
||||
clearInterval(pollTimer);
|
||||
setStep(stepContainers, 'error', 'A konténer leállt');
|
||||
setStep(stepHealth, 'error');
|
||||
progressEl.querySelector('h3').textContent = 'Telepítés sikertelen';
|
||||
resultEl.innerHTML = '<div class="alert alert-error" style="margin-top:1rem">' +
|
||||
'A konténer leállt. Ellenőrizze a naplókat.' +
|
||||
'</div><a href="/stacks/' + stackName + '/logs" class="btn btn-outline" style="margin-top:.75rem">Naplók megtekintése</a>' +
|
||||
' <a href="/stacks" class="btn btn-primary" style="margin-top:.75rem">Alkalmazások</a>';
|
||||
resultEl.style.display = 'block';
|
||||
}
|
||||
} catch(pollErr) {}
|
||||
}, 3000);
|
||||
|
||||
} catch (err) {
|
||||
alert('Hálózati hiba: ' + err.message);
|
||||
btn.textContent = 'Telepítés indítása';
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{{template "layout_end" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,192 @@
|
||||
{{define "layout_start"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}}Felhom.eu</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<img src="/static/felhom-logo.svg" alt="Felhom.eu" class="sidebar-logo">
|
||||
<span class="customer-name">{{.CustomerName}}</span>
|
||||
</div>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/" class="{{if eq .Page "dashboard"}}active{{end}}">Vezérlőpult</a></li>
|
||||
<li><a href="/stacks" class="{{if eq .Page "stacks"}}active{{end}}">Alkalmazások</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">v{{.Version}}</span>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="content">
|
||||
{{end}}
|
||||
|
||||
{{define "layout_end"}}
|
||||
</main>
|
||||
<script>
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest('a, button, .btn, input, select, textarea, .stack-actions, .stack-detail-actions')) return;
|
||||
var card = e.target.closest('[data-href]');
|
||||
if (card) window.location.href = card.dataset.href;
|
||||
});
|
||||
async function checkBeforeDeploy(e, name) {
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name);
|
||||
var data = await resp.json();
|
||||
if (data.ok && data.data && data.data.deployed) {
|
||||
e.preventDefault();
|
||||
alert('Ez az alkalmazás már telepítve van.');
|
||||
window.location.reload();
|
||||
return false;
|
||||
}
|
||||
} catch(err) {}
|
||||
return true;
|
||||
}
|
||||
async function syncTemplates() {
|
||||
const btn = document.getElementById('sync-btn');
|
||||
const toast = document.getElementById('sync-toast');
|
||||
if (!btn) return;
|
||||
const origText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '↻ Frissítés...';
|
||||
btn.classList.add('loading');
|
||||
try {
|
||||
const resp = await fetch('/api/sync', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'}
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (toast) {
|
||||
toast.textContent = data.ok ? (data.message || 'Sablonok frissítve') : ('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
toast.className = 'sync-toast ' + (data.ok ? 'sync-toast-ok' : 'sync-toast-err');
|
||||
toast.style.display = 'block';
|
||||
setTimeout(function() { toast.style.display = 'none'; }, 5000);
|
||||
}
|
||||
if (data.ok && data.data && (data.data.new_apps && data.data.new_apps.length > 0 || data.data.updated && data.data.updated.length > 0)) {
|
||||
setTimeout(function() { window.location.reload(); }, 1500);
|
||||
}
|
||||
} catch (err) {
|
||||
if (toast) {
|
||||
toast.textContent = 'Hálózati hiba: ' + err.message;
|
||||
toast.className = 'sync-toast sync-toast-err';
|
||||
toast.style.display = 'block';
|
||||
setTimeout(function() { toast.style.display = 'none'; }, 5000);
|
||||
}
|
||||
}
|
||||
btn.innerHTML = origText;
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
}
|
||||
async function stackAction(name, action) {
|
||||
const btn = event.currentTarget;
|
||||
const origText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Folyamatban...';
|
||||
btn.classList.add('loading');
|
||||
try {
|
||||
const resp = await fetch('/api/stacks/' + name + '/' + action, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'}
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!data.ok) {
|
||||
alert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
btn.textContent = origText;
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
alert('Hálózati hiba: ' + err.message);
|
||||
btn.textContent = origText;
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
async function deleteOrphanStack(name) {
|
||||
var modal = document.createElement('div');
|
||||
modal.className = 'modal-overlay';
|
||||
modal.id = 'delete-modal';
|
||||
modal.innerHTML = '<div class="modal-card"><h3>Betöltés...</h3></div>';
|
||||
modal.addEventListener('click', function(e) { if (e.target === modal) closeDeleteModal(); });
|
||||
document.body.appendChild(modal);
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name + '/hdd-data');
|
||||
var data = await resp.json();
|
||||
var hddInfo = '';
|
||||
var checkboxHTML = '';
|
||||
if (data.ok && data.data && data.data.has_hdd_data) {
|
||||
hddInfo = '<div class="modal-hdd-info"><strong>Felhasználói adatok a merevlemezen:</strong>';
|
||||
data.data.hdd_paths.forEach(function(p) {
|
||||
hddInfo += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
});
|
||||
hddInfo += '</div>';
|
||||
checkboxHTML = '<label class="modal-checkbox"><input type="checkbox" id="delete-hdd-check"> Felhasználói adatok törlése a merevlemezről</label>';
|
||||
}
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Alkalmazás törlése: ' + name + '</h3>' +
|
||||
'<p style="color:var(--text-secondary);font-size:.9rem;margin-bottom:.75rem">Ez a művelet eltávolítja a konténereket, a köteteket és a konfigurációs fájlokat.</p>' +
|
||||
'<div class="alert alert-warning" style="margin-bottom:.75rem">Ez a művelet nem visszavonható!</div>' +
|
||||
hddInfo + checkboxHTML +
|
||||
'<div class="modal-actions">' +
|
||||
'<button class="btn btn-outline" onclick="closeDeleteModal()">Mégsem</button>' +
|
||||
'<button class="btn btn-danger" id="confirm-delete-btn" onclick="confirmDelete(\'' + name + '\')">Törlés</button>' +
|
||||
'</div>';
|
||||
} catch (err) {
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Hiba</h3><p style="color:var(--text-secondary)">Nem sikerült lekérni az adatokat: ' + err.message + '</p>' +
|
||||
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeDeleteModal()">Bezárás</button></div>';
|
||||
}
|
||||
}
|
||||
function closeDeleteModal() {
|
||||
var modal = document.getElementById('delete-modal');
|
||||
if (modal) modal.remove();
|
||||
}
|
||||
async function confirmDelete(name) {
|
||||
var btn = document.getElementById('confirm-delete-btn');
|
||||
var checkbox = document.getElementById('delete-hdd-check');
|
||||
var removeHDD = checkbox ? checkbox.checked : false;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Törlés folyamatban...';
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name, {
|
||||
method: 'DELETE',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({remove_hdd_data: removeHDD})
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (data.ok) {
|
||||
var modal = document.getElementById('delete-modal');
|
||||
var removedInfo = '';
|
||||
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
|
||||
removedInfo = '<p style="color:var(--text-secondary);font-size:.85rem;margin-top:.5rem">Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '</p>';
|
||||
}
|
||||
var preservedInfo = '';
|
||||
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
|
||||
preservedInfo = '<p style="color:var(--text-secondary);font-size:.85rem;margin-top:.5rem">Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
|
||||
}
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Sikeresen törölve!</h3>' +
|
||||
'<p style="color:var(--text-secondary)">Az alkalmazás (' + name + ') törölve lett.</p>' +
|
||||
removedInfo + preservedInfo +
|
||||
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">Bezárás</button></div>';
|
||||
} else {
|
||||
alert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Törlés';
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Hálózati hiba: ' + err.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Törlés';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,28 @@
|
||||
{{define "login"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Bejelentkezés — Felhom</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="login-card">
|
||||
<img src="/static/felhom-logo.svg" alt="Felhom.eu" class="login-logo">
|
||||
<p class="login-subtitle">{{.CustomerName}}</p>
|
||||
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
|
||||
<form method="POST" action="/login">
|
||||
<div class="form-group">
|
||||
<label for="password">Jelszó</label>
|
||||
<input type="password" id="password" name="password" required autofocus
|
||||
placeholder="Adja meg a jelszavát" class="form-control">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-full">Bejelentkezés</button>
|
||||
</form>
|
||||
<p class="login-footer">Felhom — Otthoni szerver kezelés<br>
|
||||
<a href="https://felhom.eu" target="_blank">felhom.eu</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,72 @@
|
||||
{{define "logs"}}
|
||||
{{template "layout_start" .}}
|
||||
<div class="page-header">
|
||||
<a href="/stacks" class="btn btn-sm btn-outline">← Vissza</a>
|
||||
<h2>{{.Stack.Meta.DisplayName}} — Naplók</h2>
|
||||
</div>
|
||||
<div class="logs-container" id="logs-container">
|
||||
<pre class="logs-output" id="logs-output">{{.Logs}}</pre>
|
||||
</div>
|
||||
<div class="logs-actions">
|
||||
<span class="logs-live-indicator" id="live-indicator">
|
||||
<span class="logs-live-dot"></span> Élő
|
||||
</span>
|
||||
<button class="btn btn-outline btn-sm" id="logs-toggle" onclick="toggleLive()">Szüneteltetés</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="fetchLogs()">Frissítés</button>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var container = document.getElementById('logs-container');
|
||||
var output = document.getElementById('logs-output');
|
||||
var indicator = document.getElementById('live-indicator');
|
||||
var toggleBtn = document.getElementById('logs-toggle');
|
||||
var live = true;
|
||||
var timer = null;
|
||||
var stackName = '{{.Stack.Name}}';
|
||||
|
||||
function isAtBottom() {
|
||||
return container.scrollHeight - container.scrollTop - container.clientHeight < 50;
|
||||
}
|
||||
|
||||
window.fetchLogs = function() {
|
||||
fetch('/stacks/' + stackName + '/logs?raw=1')
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(text) {
|
||||
var wasAtBottom = isAtBottom();
|
||||
output.textContent = text;
|
||||
if (wasAtBottom) container.scrollTop = container.scrollHeight;
|
||||
})
|
||||
.catch(function() {});
|
||||
};
|
||||
|
||||
function startPolling() {
|
||||
if (timer) return;
|
||||
timer = setInterval(window.fetchLogs, 3000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (timer) { clearInterval(timer); timer = null; }
|
||||
}
|
||||
|
||||
window.toggleLive = function() {
|
||||
live = !live;
|
||||
if (live) {
|
||||
startPolling();
|
||||
indicator.className = 'logs-live-indicator';
|
||||
indicator.innerHTML = '<span class="logs-live-dot"></span> Élő';
|
||||
toggleBtn.textContent = 'Szüneteltetés';
|
||||
} else {
|
||||
stopPolling();
|
||||
indicator.className = 'logs-live-indicator logs-live-paused';
|
||||
indicator.innerHTML = '⏸ Szünetelve';
|
||||
toggleBtn.textContent = 'Folytatás';
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-scroll to bottom on initial load
|
||||
container.scrollTop = container.scrollHeight;
|
||||
startPolling();
|
||||
})();
|
||||
</script>
|
||||
{{template "layout_end" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,76 @@
|
||||
{{define "stacks"}}
|
||||
{{template "layout_start" .}}
|
||||
|
||||
<div class="page-header">
|
||||
<h2>Alkalmazások</h2>
|
||||
<span class="domain-badge">{{.Domain}}</span>
|
||||
<button class="btn btn-sm btn-outline" id="sync-btn" onclick="syncTemplates()" title="Sablonok frissítése a központi katalógusból">↻ Sablonok frissítése</button>
|
||||
</div>
|
||||
<div id="sync-toast" class="sync-toast" style="display:none"></div>
|
||||
|
||||
<div class="stack-grid">
|
||||
{{range .Stacks}}
|
||||
<div class="stack-detail-card stack-state-{{stateColor .State}}"{{if not .Protected}} data-href="/apps/{{.Meta.Slug}}"{{end}}>
|
||||
<div class="stack-detail-header">
|
||||
<div class="stack-title-row">
|
||||
<img class="stack-logo-lg" src="{{logoURL .Meta.Slug}}"
|
||||
alt="" onerror="this.onerror=function(){this.style.display='none'};this.src='{{logoPNGURL .Meta.Slug}}'">
|
||||
<div>
|
||||
<h3>{{.Meta.DisplayName}}</h3>
|
||||
{{if .Meta.Subdomain}}
|
||||
<a class="subdomain-link" href="https://{{.Meta.Subdomain}}.{{$.Domain}}" target="_blank">
|
||||
{{.Meta.Subdomain}}.{{$.Domain}} ↗
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<span class="stack-state-badge state-{{stateColor .State}}">{{stateLabel .State}}</span>
|
||||
{{if .Orphaned}}<span class="badge badge-orphaned">Elavult</span>{{end}}
|
||||
</div>
|
||||
|
||||
{{if .Meta.Description}}
|
||||
<p class="stack-detail-desc">{{.Meta.Description}}</p>
|
||||
{{end}}
|
||||
|
||||
<div class="stack-meta-badges">
|
||||
{{if .Meta.Resources.MemRequest}}<span class="meta-badge">~{{.Meta.Resources.MemRequest}}</span>{{end}}
|
||||
{{if .Meta.Resources.PiCompatible}}<span class="meta-badge meta-badge-ok">Pi kompatibilis</span>{{end}}
|
||||
{{if .Meta.Resources.NeedsHDD}}<span class="meta-badge">HDD szükséges</span>{{end}}
|
||||
</div>
|
||||
|
||||
{{if .Containers}}
|
||||
<div class="container-list">
|
||||
{{range .Containers}}
|
||||
<div class="container-row">
|
||||
<span class="container-name">{{.Name}}</span>
|
||||
<span class="container-status state-text-{{stateColor .State}}">{{.Status}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="stack-detail-actions">
|
||||
{{if .Protected}}
|
||||
<span class="badge badge-protected">Védett rendszerkomponens</span>
|
||||
{{else if not .Deployed}}
|
||||
<a href="/stacks/{{.Name}}/deploy" class="btn btn-primary" onclick="return checkBeforeDeploy(event, '{{.Name}}')">Telepítés</a>
|
||||
<a href="{{appPageURL .Meta.Slug}}" class="btn btn-outline">Részletek</a>
|
||||
{{else}}
|
||||
{{if isOperational .State}}
|
||||
{{if not .Orphaned}}<button class="btn btn-success" onclick="stackAction('{{.Name}}', 'update')">Frissítés</button>{{end}}
|
||||
<button class="btn btn-warning" onclick="stackAction('{{.Name}}', 'restart')">Újraindítás</button>
|
||||
<button class="btn btn-danger" onclick="stackAction('{{.Name}}', 'stop')">Leállítás</button>
|
||||
{{else}}
|
||||
<button class="btn btn-success" onclick="stackAction('{{.Name}}', 'start')">Indítás</button>
|
||||
{{end}}
|
||||
<a href="/stacks/{{.Name}}/logs" class="btn btn-outline">Naplók</a>
|
||||
{{if not .Orphaned}}<a href="{{appPageURL .Meta.Slug}}" class="btn btn-outline">Részletek</a>{{end}}
|
||||
{{if .Orphaned}}<button class="btn btn-danger" onclick="deleteOrphanStack('{{.Name}}')">Törlés</button>{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{template "layout_end" .}}
|
||||
{{end}}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.<domain>.
|
||||
via the web dashboard at felhom.<domain>.
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user