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
|
│ │ ├── sync/ # Git sync — periodic pull of app catalog repo
|
||||||
│ │ ├── api/ # REST API endpoints
|
│ │ ├── api/ # REST API endpoints
|
||||||
│ │ ├── system/ # System info (memory, disk)
|
│ │ ├── 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
|
│ ├── Dockerfile
|
||||||
│ ├── Makefile
|
│ ├── Makefile
|
||||||
│ └── go.mod
|
│ └── go.mod
|
||||||
@@ -148,8 +155,8 @@ manually via the dashboard "Sablonok frissítése" button.
|
|||||||
|
|
||||||
- **Language:** Go 1.22+
|
- **Language:** Go 1.22+
|
||||||
- **Web framework:** stdlib `net/http` + `html/template` (no frameworks)
|
- **Web framework:** stdlib `net/http` + `html/template` (no frameworks)
|
||||||
- **Templates:** Embedded as Go string constants in `templates.go` (Hungarian UI)
|
- **Templates:** go:embed HTML files in `internal/web/templates/` (Hungarian UI)
|
||||||
- **CSS:** Single embedded const in `templates.go` (no external CSS files)
|
- **CSS:** go:embed CSS file in `internal/web/templates/style.css`
|
||||||
- **Auth:** bcrypt password hash + session cookies
|
- **Auth:** bcrypt password hash + session cookies
|
||||||
- **Container orchestration:** Docker Compose via CLI (`docker compose up -d`)
|
- **Container orchestration:** Docker Compose via CLI (`docker compose up -d`)
|
||||||
- **Reverse proxy:** Traefik (separate stack, managed by controller)
|
- **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"
|
> 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
|
## Current project state
|
||||||
|
|
||||||
### felhom-controller (this repo)
|
### felhom-controller (this repo)
|
||||||
- **Version:** v0.2.15
|
- **Version:** v0.3.0
|
||||||
- **Phase 1:** ✅ COMPLETE — Stack Manager + Deploy Flow
|
- **Phase 1:** ✅ COMPLETE — Stack Manager + Deploy Flow
|
||||||
- **First app deployed:** Paperless-ngx on demo-felhom.eu (2026-02-13)
|
- **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
|
- **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
|
- **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:**
|
- **FileBrowser as infrastructure service:**
|
||||||
- Created `scripts/hdd-setup.sh` (adapted from deploy-portainer) — sets up HDD folder structure with `Dokumentumok` user dir
|
- 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
|
- 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 |
|
| Decision | Rationale |
|
||||||
|----------|-----------|
|
|----------|-----------|
|
||||||
| Go stdlib for web (no Gin/Echo) | Minimal dependencies, single binary, easy to embed templates |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
with Traefik routing and health checks. The dashboard correctly shows real-time container states
|
||||||
including health substatus (starting → healthy → running).
|
including health substatus (starting → healthy → running).
|
||||||
|
|
||||||
Current version: **v0.2.111**
|
Current version: **v0.3.0**
|
||||||
|
|
||||||
### What works
|
### What works
|
||||||
- Dashboard with live container state (green/orange/yellow/red)
|
- Dashboard with live container state (green/orange/yellow/red)
|
||||||
@@ -106,8 +106,16 @@ controller/
|
|||||||
│ │ ├── info_linux.go # Linux: /proc/meminfo + statfs
|
│ │ ├── info_linux.go # Linux: /proc/meminfo + statfs
|
||||||
│ │ └── info_other.go # Non-Linux stub
|
│ │ └── info_other.go # Non-Linux stub
|
||||||
│ └── web/
|
│ └── web/
|
||||||
│ ├── server.go # HTTP server, auth, page handlers, asset serving
|
│ ├── server.go # HTTP server, routing, static file serving
|
||||||
│ └── templates.go # Embedded HTML templates + CSS (Hungarian UI)
|
│ ├── 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/
|
├── configs/
|
||||||
│ ├── controller.yaml.example # Full config reference (infrastructure only)
|
│ ├── controller.yaml.example # Full config reference (infrastructure only)
|
||||||
│ └── example-felhom-metadata.yml # .felhom.yml format reference
|
│ └── example-felhom-metadata.yml # .felhom.yml format reference
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ services:
|
|||||||
- TZ=Europe/Budapest
|
- TZ=Europe/Budapest
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "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.entrypoints=websecure"
|
||||||
- "traefik.http.routers.controller.tls=true"
|
- "traefik.http.routers.controller.tls=true"
|
||||||
- "traefik.http.services.controller.loadbalancer.server.port=8080"
|
- "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
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
|
||||||
"crypto/subtle"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
@@ -12,12 +9,9 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
@@ -31,16 +25,6 @@ type Server struct {
|
|||||||
sessionsMu sync.RWMutex
|
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 {
|
func NewServer(cfg *config.Config, stackMgr *stacks.Manager, logger *log.Logger, version string) *Server {
|
||||||
s := &Server{
|
s := &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
@@ -55,129 +39,9 @@ func NewServer(cfg *config.Config, stackMgr *stacks.Manager, logger *log.Logger,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) loadTemplates() {
|
func (s *Server) loadTemplates() {
|
||||||
funcMap := template.FuncMap{
|
s.tmpl = template.Must(
|
||||||
"stateColor": func(state stacks.ContainerState) string {
|
template.New("").Funcs(s.templateFuncMap()).ParseFS(templateFS, "templates/*.html"),
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServeHTTP handles all non-API web requests.
|
// 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")
|
name = strings.TrimSuffix(name, "/deploy")
|
||||||
s.deployHandler(w, r, name)
|
s.deployHandler(w, r, name)
|
||||||
case path == "/static/style.css":
|
case path == "/static/style.css":
|
||||||
w.Header().Set("Content-Type", "text/css")
|
s.serveCSSHandler(w, r)
|
||||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
||||||
fmt.Fprint(w, cssContent)
|
|
||||||
case path == "/static/felhom-logo.svg":
|
case path == "/static/felhom-logo.svg":
|
||||||
w.Header().Set("Content-Type", "image/svg+xml")
|
s.serveLogoHandler(w, r)
|
||||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
||||||
fmt.Fprint(w, felhomLogoSVG)
|
|
||||||
case strings.HasPrefix(path, "/static/assets/"):
|
case strings.HasPrefix(path, "/static/assets/"):
|
||||||
s.serveAsset(w, r, strings.TrimPrefix(path, "/static/assets/"))
|
s.serveAsset(w, r, strings.TrimPrefix(path, "/static/assets/"))
|
||||||
case strings.HasPrefix(path, "/apps/"):
|
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) render(w http.ResponseWriter, name string, data interface{}) {
|
||||||
func (s *Server) RequireAuth(next http.Handler) http.Handler {
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||||
// Skip auth if no password is configured
|
s.logger.Printf("[ERROR] Template error (%s): %v", name, err)
|
||||||
if s.cfg.Web.PasswordHash == "" {
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Page handlers ---
|
// --- Static file / asset serving ---
|
||||||
|
|
||||||
func (s *Server) baseData(page, title string) map[string]interface{} {
|
func (s *Server) serveCSSHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
return map[string]interface{}{
|
data, err := templateFS.ReadFile("templates/style.css")
|
||||||
"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 {
|
if err != nil {
|
||||||
logs = fmt.Sprintf("Hiba a naplók lekérésekor: %v", err)
|
http.Error(w, "CSS not found", 500)
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
return
|
||||||
}
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||||
data := s.baseData("logs", stack.Meta.DisplayName+" — Naplók")
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||||
data["Stack"] = stack
|
w.Write(data)
|
||||||
data["Logs"] = logs
|
|
||||||
s.render(w, "logs", data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) deployHandler(w http.ResponseWriter, _ *http.Request, name string) {
|
func (s *Server) serveLogoHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
meta, appCfg, err := s.stackMgr.GetDeployFields(name)
|
w.Header().Set("Content-Type", "image/svg+xml")
|
||||||
if err != nil {
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
http.NotFound(w, nil)
|
fmt.Fprint(w, felhomLogoSVG)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// serveAsset serves baked-in app assets (logos, screenshots) from /usr/share/felhom/assets/
|
// 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")
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
http.ServeFile(w, r, path)
|
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:
|
DEPLOYING APPLICATIONS:
|
||||||
After infrastructure setup, deploy the felhom-controller to manage apps
|
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:
|
EXAMPLES:
|
||||||
# Felhom customer deployment (recommended — full stack with tunnel)
|
# Felhom customer deployment (recommended — full stack with tunnel)
|
||||||
@@ -1473,7 +1473,7 @@ print_summary() {
|
|||||||
echo " Deploy applications via the Felhom Controller dashboard:"
|
echo " Deploy applications via the Felhom Controller dashboard:"
|
||||||
echo ""
|
echo ""
|
||||||
echo " 1. Deploy felhom-controller (see controller/README.md)"
|
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 " 3. Browse available apps on the Alkalmazások page"
|
||||||
echo " 4. Click Telepítés to deploy"
|
echo " 4. Click Telepítés to deploy"
|
||||||
if [[ -n "$CUSTOMER_ID" ]]; then
|
if [[ -n "$CUSTOMER_ID" ]]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user