updated memory calculation and logo

This commit is contained in:
2026-02-14 12:41:08 +01:00
parent 6a7737ee1c
commit 44a7d0de2c
10 changed files with 365 additions and 47 deletions
+21 -5
View File
@@ -7,7 +7,7 @@
>
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
Last updated: 2026-02-14 (evening)
Last updated: 2026-02-15
---
@@ -28,7 +28,20 @@ Last updated: 2026-02-14 (evening)
- **Running on:** demo-felhom (N100 mini PC) at 192.168.0.162:8080
- **All Phase 1 features working:** deploy, start/stop/restart/update, logs, health-aware states, auth
### What was just completed (2026-02-14)
### What was just completed (2026-02-15)
- **Memory validation during deployment**:
- Pre-deploy memory check: compares `mem_request` sum against usable system RAM
- Hard block if requests exceed usable memory (total - 384MB reserved)
- Soft warning if `mem_limit` sum exceeds total RAM (overcommit OK for limits)
- `ParseMemoryMB()` supports "500M", "1G", "1.5G", "1024" formats
- `CommittedMemory()` sums requests/limits across all deployed stacks
- Memory summary bar shown on deploy page before user clicks deploy
- `system.reserved_memory_mb` configurable in controller.yaml (default: 384)
- **Display: `~` prefix on mem_request** in UI badges (display-only, exact value stored)
- **Felhom.eu logo** replaced text logos in sidebar and login page with actual SVG logo
- Logo SVG embedded as Go string constant, served at `/static/felhom-logo.svg`
### Previously completed (2026-02-14)
- **System info bar on Vezérlőpult dashboard**: RAM, SSD, and optional HDD usage
- Progress bars with color coding (green < 70%, yellow 70-85%, red > 85%)
- New `internal/system` package reads `/proc/meminfo` + `syscall.Statfs`
@@ -56,9 +69,9 @@ Last updated: 2026-02-14 (evening)
1. Deploy a second app (e.g., Immich, Jellyfin) to validate the template system
2. Test on Raspberry Pi (pi-customer-1)
3. Add `paths.hdd_path` to demo-felhom controller.yaml to enable HDD bar
4. Phase 2 continued: CPU/temperature metrics, Healthchecks.io pings
5. Phase 3: Backup system (DB dumps + restic)
6. Add memory limits to other app catalog templates (Immich, Jellyfin, etc.)
4. Add memory limits + `mem_request`/`mem_limit` to other app catalog templates (Immich, Jellyfin, etc.)
5. Phase 2 continued: CPU/temperature metrics, Healthchecks.io pings
6. Phase 3: Backup system (DB dumps + restic)
## Architecture decisions
@@ -74,6 +87,9 @@ Last updated: 2026-02-14 (evening)
| Health-aware state from Docker Status field | Docker's State says "running" even for unhealthy containers |
| Memory limits via deploy.resources.limits | Prevents runaway containers; ~50% headroom over expected usage |
| System info from /proc/meminfo + statfs | No external dependencies, cheap to read on each page load |
| mem_request vs mem_limit (K8s-inspired) | Requests = expected usage (hard block), limits = peak (overcommit OK) |
| 384MB reserved for system | Prevents deploying apps that would starve the OS/controller |
| Logo SVG embedded as Go constant | Same approach as CSS/HTML — zero external file deps |
## Key file locations on demo-felhom
+30
View File
@@ -38,6 +38,9 @@ Current version: **v0.2.1**
- Protected stacks (traefik, cloudflared, felhom-controller) can't be stopped
- System info bar on dashboard: RAM, SSD, and HDD usage with progress bars
- Docker Compose memory limits enforced via `deploy.resources.limits.memory`
- Pre-deploy memory validation (hard block on `mem_request` overcommit, soft warning on `mem_limit` overcommit)
- Memory summary bar shown on deploy page before deployment
- Felhom.eu logo SVG in sidebar and login page
### Known issues / next priorities
- Cloudflare Tunnel + Traefik TLS: paperless.demo-felhom.eu works locally but shows "Not secure" (certificate chain not fully validated through tunnel)
@@ -139,6 +142,7 @@ controller/
- **User input**: HDD path, admin password, language, etc.
- **"🎲 Generálás"** button next to password fields
3. Clicks "Telepítés" → controller:
- **Memory validation**: checks `mem_request` against available system RAM (see below)
- Validates all required fields (password fields must be explicitly filled or generated)
- Generates auto-secrets (DB passwords, hex keys)
- Saves `app.yaml` (env vars + locked fields list)
@@ -147,6 +151,30 @@ controller/
4. Post-deploy: locked fields (DB_PASSWORD, etc.) become read-only
5. "Részletek" button opens deploy page in read-only mode showing current config
### Memory validation during deploy
Before deploying an app, the controller checks if there's enough RAM. This uses the Kubernetes-inspired `mem_request` / `mem_limit` model:
| Field | In `.felhom.yml` | Purpose | Validation |
|-------|-------------------|---------|------------|
| `mem_request` | `resources.mem_request: "500M"` | Expected memory usage during normal operation | **Hard block** — sum of requests must not exceed usable RAM |
| `mem_limit` | `resources.mem_limit: "1152M"` | Docker `deploy.resources.limits.memory` total across all containers | **Soft warning** — overcommit is allowed for limits |
| `pi_compatible` | `resources.pi_compatible: true` | Whether the app can run on Raspberry Pi | Display-only hint |
| `needs_hdd` | `resources.needs_hdd: true` | Whether the app needs external storage | Display-only hint |
**How it works:**
- `usable_memory = total_ram - reserved_memory_mb` (default: 384MB reserved for OS + controller)
- If `sum(deployed mem_requests) + new_mem_request > usable_memory` → deploy is **blocked** with error
- If `sum(deployed mem_limits) + new_mem_limit > total_ram` → deploy proceeds with **warning**
- Apps without `mem_request` set are treated as 0MB (never blocked)
- The deploy page shows a memory summary bar before the user clicks deploy
Configure the reserved memory via `controller.yaml`:
```yaml
system:
reserved_memory_mb: 384 # default
```
### Container state display
The dashboard shows health-aware container states with distinct colors:
@@ -314,6 +342,8 @@ docker compose up -d
### Phase 2 — Monitoring & Health
- [x] System metrics on dashboard (RAM, SSD, HDD usage bars)
- [x] `/api/system/info` endpoint with live resource data
- [x] Pre-deploy memory validation (mem_request hard block, mem_limit soft warning)
- [x] Memory summary bar on deploy page
- [ ] CPU and temperature metrics
- [ ] Healthchecks.io ping integration
- [ ] Customer notifications (email/Telegram)
+8 -3
View File
@@ -153,20 +153,25 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
Values: body.Values,
}
if err := r.stackMgr.DeployStack(deployReq); err != nil {
warning, err := r.stackMgr.DeployStack(deployReq)
if err != nil {
r.logger.Printf("[API] Deploy failed for %s: %v", name, err)
status := http.StatusInternalServerError
if strings.Contains(err.Error(), "already deployed") {
status = http.StatusConflict
}
if strings.Contains(err.Error(), "required field") || strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "kötelező") {
if strings.Contains(err.Error(), "required field") || strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "kötelező") || strings.Contains(err.Error(), "memória") {
status = http.StatusBadRequest
}
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Stack " + name + " deployed"})
resp := apiResponse{OK: true, Message: "Stack " + name + " deployed"}
if warning != "" {
resp.Data = map[string]string{"warning": warning}
}
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
+6
View File
@@ -24,6 +24,11 @@ type Config struct {
Notifications NotificationsConfig `yaml:"notifications"`
Logging LoggingConfig `yaml:"logging"`
Assets AssetsConfig `yaml:"assets"`
System SystemConfig `yaml:"system"`
}
type SystemConfig struct {
ReservedMemoryMB int `yaml:"reserved_memory_mb"`
}
type CustomerConfig struct {
@@ -196,6 +201,7 @@ func applyDefaults(cfg *Config) {
di(&cfg.Logging.MaxSizeMB, 10)
di(&cfg.Logging.MaxFiles, 3)
d(&cfg.Assets.SourceURL, "https://felhom.eu")
di(&cfg.System.ReservedMemoryMB, 384)
}
func applyEnvOverrides(cfg *Config) {
+59 -20
View File
@@ -10,6 +10,7 @@ import (
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
"gopkg.in/yaml.v3"
)
@@ -28,18 +29,20 @@ type DeployRequest struct {
Values map[string]string `json:"values"` // env_var -> user-provided value
}
// DeployStack handles first-time deployment of an app:
// 1. Load metadata (.felhom.yml) to know what fields exist
// 2. Auto-generate secrets for secret fields (hidden from user)
// 3. Auto-fill domain from controller config
// 4. Validate all user-provided values (password, path, required fields)
// 5. Save app.yaml
// 6. Run docker compose up -d with env vars
// 7. Update in-memory stack state
func (m *Manager) DeployStack(req DeployRequest) error {
// DeployStack handles first-time deployment of an app.
// Returns a warning message (empty if none) and an error if deployment is blocked.
// 1. Check available memory against app requirements
// 2. Load metadata (.felhom.yml) to know what fields exist
// 3. Auto-generate secrets for secret fields (hidden from user)
// 4. Auto-fill domain from controller config
// 5. Validate all user-provided values (password, path, required fields)
// 6. Save app.yaml
// 7. Run docker compose up -d with env vars
// 8. Update in-memory stack state
func (m *Manager) DeployStack(req DeployRequest) (string, error) {
stack, ok := m.GetStack(req.StackName)
if !ok {
return fmt.Errorf("stack %q not found", req.StackName)
return "", fmt.Errorf("stack %q not found", req.StackName)
}
stackDir := filepath.Dir(stack.ComposePath)
@@ -48,7 +51,43 @@ func (m *Manager) DeployStack(req DeployRequest) error {
// Check if already deployed
existing := LoadAppConfig(stackDir)
if existing != nil && existing.Deployed {
return fmt.Errorf("stack %q is already deployed; use update instead", req.StackName)
return "", fmt.Errorf("stack %q is already deployed; use update instead", req.StackName)
}
// --- Memory validation ---
var deployWarning string
reservedMB := m.cfg.System.ReservedMemoryMB
totalMB, memErr := system.GetTotalMemoryMB()
if memErr != nil {
m.logger.Printf("[WARN] Cannot read system memory: %v — skipping memory check", memErr)
} else {
usableMB := totalMB - reservedMB
currentReqMB, currentLimitMB := m.CommittedMemory()
newReqMB := ParseMemoryMB(meta.Resources.MemRequest)
newLimitMB := ParseMemoryMB(meta.Resources.MemLimit)
m.logger.Printf("[INFO] Memory check: total=%dMB, reserved=%dMB, usable=%dMB, committed_req=%dMB, new_req=%dMB, remaining=%dMB",
totalMB, reservedMB, usableMB, currentReqMB, newReqMB, usableMB-currentReqMB-newReqMB)
// Hard block: requests exceed usable memory
if newReqMB > 0 && currentReqMB+newReqMB > usableMB {
return "", fmt.Errorf(
"Nincs elég memória az alkalmazás telepítéséhez. "+
"Szükséges: %d MB, Elérhető: %d MB "+
"(összesen: %d MB, ebből %d MB már foglalt, %d MB rendszer számára fenntartva)",
newReqMB,
usableMB-currentReqMB,
totalMB,
currentReqMB,
reservedMB,
)
}
// Soft warning: limits exceed total (overcommit)
if newLimitMB > 0 && currentLimitMB+newLimitMB > totalMB {
deployWarning = "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."
}
}
// Debug: log received values (redact passwords/secrets)
@@ -77,7 +116,7 @@ func (m *Manager) DeployStack(req DeployRequest) error {
// Always auto-generate, user never sees these
generated, err := generateValue(field.Generate)
if err != nil {
return fmt.Errorf("generating %s: %w", field.EnvVar, err)
return "", fmt.Errorf("generating %s: %w", field.EnvVar, err)
}
value = generated
@@ -87,7 +126,7 @@ func (m *Manager) DeployStack(req DeployRequest) error {
if userVal, ok := req.Values[field.EnvVar]; ok && userVal != "" {
value = userVal
} else {
return fmt.Errorf("a(z) %q mező kitöltése kötelező — használja a Generálás gombot vagy írjon be egy jelszót", field.Label)
return "", fmt.Errorf("a(z) %q mező kitöltése kötelező — használja a Generálás gombot vagy írjon be egy jelszót", field.Label)
}
default:
@@ -101,13 +140,13 @@ func (m *Manager) DeployStack(req DeployRequest) error {
// Validate required fields
if field.Required && value == "" {
return fmt.Errorf("a(z) %q (%s) mező kitöltése kötelező", field.Label, field.EnvVar)
return "", fmt.Errorf("a(z) %q (%s) mező kitöltése kötelező", field.Label, field.EnvVar)
}
// Validate path fields exist on disk (inside the container's filesystem)
if field.Type == "path" && value != "" {
if _, err := os.Stat(value); os.IsNotExist(err) {
return fmt.Errorf("path %q does not exist for field %q", value, field.Label)
return "", fmt.Errorf("path %q does not exist for field %q", value, field.Label)
}
}
@@ -129,7 +168,7 @@ func (m *Manager) DeployStack(req DeployRequest) error {
}
if err := SaveAppConfig(stackDir, appCfg); err != nil {
return fmt.Errorf("saving app config: %w", err)
return "", fmt.Errorf("saving app config: %w", err)
}
// Debug: log final env var keys (not values)
@@ -140,12 +179,12 @@ func (m *Manager) DeployStack(req DeployRequest) error {
m.logger.Printf("[INFO] Deploying stack %s with %d env vars: [%s]", req.StackName, len(env), strings.Join(envKeys, ", "))
// Run docker compose up -d
_, err := m.composeExecWithEnv(stackDir, env, "up", "-d")
if err != nil {
_, composeErr := m.composeExecWithEnv(stackDir, env, "up", "-d")
if composeErr != nil {
// Deployment failed — keep app.yaml for debugging but mark as not deployed
appCfg.Deployed = false
_ = SaveAppConfig(stackDir, appCfg)
return fmt.Errorf("docker compose up failed: %w", err)
return "", fmt.Errorf("docker compose up failed: %w", composeErr)
}
// Update in-memory stack state immediately so the UI reflects the deployment
@@ -158,7 +197,7 @@ func (m *Manager) DeployStack(req DeployRequest) error {
m.mu.Unlock()
m.logger.Printf("[INFO] Stack %s deployed successfully", req.StackName)
return m.RefreshStatus()
return deployWarning, m.RefreshStatus()
}
// UpdateStackConfig updates non-locked fields for a deployed stack.
+64
View File
@@ -8,6 +8,7 @@ import (
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -517,3 +518,66 @@ func (m *Manager) execCommand(name string, args ...string) (string, error) {
return stdout.String(), nil
}
// --- Memory helpers ---
// ParseMemoryMB parses a memory string like "500M", "1G", "1.5G", "1024M", "768"
// into megabytes. Returns 0 for empty or unparseable values. Case-insensitive.
func ParseMemoryMB(s string) int {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
upper := strings.ToUpper(s)
if strings.HasSuffix(upper, "GB") {
val, err := strconv.ParseFloat(strings.TrimSuffix(upper, "GB"), 64)
if err != nil {
return 0
}
return int(val * 1024)
}
if strings.HasSuffix(upper, "G") {
val, err := strconv.ParseFloat(strings.TrimSuffix(upper, "G"), 64)
if err != nil {
return 0
}
return int(val * 1024)
}
if strings.HasSuffix(upper, "MB") {
val, err := strconv.ParseFloat(strings.TrimSuffix(upper, "MB"), 64)
if err != nil {
return 0
}
return int(val)
}
if strings.HasSuffix(upper, "M") {
val, err := strconv.ParseFloat(strings.TrimSuffix(upper, "M"), 64)
if err != nil {
return 0
}
return int(val)
}
// Plain number — assume MB
val, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0
}
return int(val)
}
// CommittedMemory returns the sum of mem_request and mem_limit across all deployed stacks.
func (m *Manager) CommittedMemory() (requestMB int, limitMB int) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, s := range m.stacks {
if !s.Deployed {
continue
}
requestMB += ParseMemoryMB(s.Meta.Resources.MemRequest)
limitMB += ParseMemoryMB(s.Meta.Resources.MemLimit)
}
return
}
+11
View File
@@ -4,6 +4,7 @@ package system
import (
"bufio"
"fmt"
"os"
"strings"
"syscall"
@@ -29,6 +30,16 @@ func GetInfo(hddPath string) SystemInfo {
return info
}
// GetTotalMemoryMB reads total system memory from /proc/meminfo.
func GetTotalMemoryMB() (int, error) {
info := SystemInfo{}
readMemInfo(&info)
if info.TotalMemMB == 0 {
return 0, fmt.Errorf("could not read MemTotal from /proc/meminfo")
}
return int(info.TotalMemMB), nil
}
func readMemInfo(info *SystemInfo) {
f, err := os.Open("/proc/meminfo")
if err != nil {
+7
View File
@@ -2,7 +2,14 @@
package system
import "fmt"
// GetInfo returns empty system info on non-Linux platforms.
func GetInfo(_ string) SystemInfo {
return SystemInfo{}
}
// GetTotalMemoryMB is not available on non-Linux platforms.
func GetTotalMemoryMB() (int, error) {
return 0, fmt.Errorf("/proc/meminfo not available on this platform")
}
+37 -1
View File
@@ -184,6 +184,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css")
w.Header().Set("Cache-Control", "public, max-age=3600")
fmt.Fprint(w, cssContent)
case path == "/static/felhom-logo.svg":
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "public, max-age=86400")
fmt.Fprint(w, felhomLogoSVG)
case strings.HasPrefix(path, "/static/assets/"):
s.serveAsset(w, r, strings.TrimPrefix(path, "/static/assets/"))
case strings.HasPrefix(path, "/apps/"):
@@ -388,18 +392,50 @@ func (s *Server) deployHandler(w http.ResponseWriter, _ *http.Request, name stri
}
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"] = appCfg != nil && appCfg.Deployed
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
}
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["Blocked"] = newReqMB > 0 && afterReqMB > usableMB
memInfo["OvercommitWarn"] = newLimitMB > 0 && afterLimitMB > totalMB
}
data["MemoryInfo"] = memInfo
}
s.render(w, "deploy", data)
}
+121 -17
View File
@@ -19,7 +19,7 @@ const layoutTmpl = `
<body>
<nav class="sidebar">
<div class="sidebar-header">
<h1 class="logo">Felhom.eu</h1>
<img src="/static/felhom-logo.svg" alt="Felhom.eu" class="sidebar-logo">
<span class="customer-name">{{.CustomerName}}</span>
</div>
<ul class="nav-links">
@@ -205,7 +205,7 @@ const stacksTmpl = `
{{end}}
<div class="stack-meta-badges">
{{if .Meta.Resources.MemRequest}}<span class="meta-badge">{{.Meta.Resources.MemRequest}}</span>{{end}}
{{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>
@@ -263,7 +263,7 @@ const deployTmpl = `
<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.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>
@@ -279,6 +279,34 @@ const deployTmpl = `
</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="system-bar" style="margin-bottom:0">
<div class="system-bar-fill system-bar-{{if ge .Percent 85}}red{{else if ge .Percent 70}}yellow{{else}}green{{end}}" style="width:{{.Percent}}%"></div>
</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">
@@ -346,7 +374,7 @@ const deployTmpl = `
{{if not .AlreadyDeployed}}
<div class="deploy-actions">
<button type="submit" class="btn btn-primary btn-lg">Telepítés indítása</button>
<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}}
@@ -420,7 +448,11 @@ document.getElementById('deploy-form').addEventListener('submit', async function
btn.disabled = false;
return;
}
alert('Sikeres telepítés!');
if (data.data && data.data.warning) {
alert('Sikeres telepítés!\n\nFigyelmeztetés: ' + data.data.warning);
} else {
alert('Sikeres telepítés!');
}
window.location.href = '/stacks';
} catch (err) {
alert('Hálózati hiba: ' + err.message);
@@ -446,7 +478,7 @@ const loginTmpl = `
</head>
<body class="login-body">
<div class="login-card">
<h1 class="logo">Felhom</h1>
<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">
@@ -553,14 +585,17 @@ body::before {
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.logo {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
background: linear-gradient(135deg, var(--accent-light), var(--accent-blue));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
.sidebar-logo {
width: 140px;
height: auto;
display: block;
margin-bottom: 0.25rem;
}
.login-logo {
width: 200px;
height: auto;
display: block;
margin: 0 auto 0.5rem;
}
.customer-name {
display: block;
@@ -1070,6 +1105,41 @@ select.form-control option { background: var(--bg-secondary); color: var(--text-
color: var(--accent-light);
border-color: rgba(0, 136, 204, 0.3);
}
.alert-warning {
background: var(--yellow-bg);
color: var(--yellow);
border-color: rgba(210, 153, 34, 0.3);
}
/* Memory summary on deploy page */
.memory-summary {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
.memory-blocked {
border-color: rgba(218, 54, 51, 0.5);
}
.memory-summary-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.memory-summary-label {
font-size: .8rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: .5px;
}
.memory-summary-value {
font-size: .8rem;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
/* Logs */
.logs-container {
@@ -1124,9 +1194,9 @@ select.form-control option { background: var(--bg-secondary); color: var(--text-
position: relative;
z-index: 1;
}
.login-card .logo {
margin-bottom: .25rem;
font-size: 1.8rem;
.login-card .login-logo {
width: 220px;
margin-bottom: 0.5rem;
}
.login-subtitle {
color: var(--text-secondary);
@@ -1160,3 +1230,37 @@ select.form-control option { background: var(--bg-secondary); color: var(--text-
.system-info-items { flex-direction: column; gap: 1rem; }
}
`
// felhomLogoSVG is the felhom.eu logo, served at /static/felhom-logo.svg.
// Cleaned from the original Inkscape SVG, removing editor metadata.
const felhomLogoSVG = `<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 645.30703 408.36403" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<linearGradient id="lg9"><stop offset="0"/><stop offset="0.99875164" style="stop-color:rgb(4,114,187)"/></linearGradient>
<linearGradient id="g1"><stop offset="0" style="stop-color:rgb(0,64,141)"/><stop offset="1" style="stop-color:rgb(0,141,223)"/></linearGradient>
<linearGradient id="g1-0" xlink:href="#g1" gradientUnits="userSpaceOnUse" x1="30.771" y1="2283.52" x2="30.771" y2="2416.4089" spreadMethod="pad" gradientTransform="matrix(0.999122,0,0,0.848244,1717.8096,192.633)"/>
<linearGradient id="g1-1" xlink:href="#g1" gradientUnits="userSpaceOnUse" x1="30.849001" y1="2446.3101" x2="30.849001" y2="2588.6721" gradientTransform="matrix(0.996573,0,0,0.791798,1717.8096,192.63306)"/>
<linearGradient id="g2"><stop offset="0.002"/><stop offset="1" style="stop-color:rgb(4,114,187)"/></linearGradient>
<linearGradient id="lg14" xlink:href="#g1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0.999122,0,0,0.848244,-306.27885,-1772.6719)" x1="30.771" y1="2283.52" x2="30.771" y2="2416.4089" spreadMethod="pad"/>
<linearGradient id="lg15" xlink:href="#g1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0.996573,0,0,0.791798,-306.27895,-1772.6718)" x1="30.849001" y1="2446.3101" x2="30.849001" y2="2588.6721"/>
<linearGradient id="lg17" xlink:href="#lg9" x1="160.76199" y1="268.35672" x2="284.18887" y2="268.80402" gradientUnits="userSpaceOnUse"/>
<linearGradient id="lg1" xlink:href="#lg9" gradientUnits="userSpaceOnUse" x1="160.76199" y1="268.35672" x2="284.18887" y2="268.80402" gradientTransform="translate(-9.2828853,15.718777)"/>
</defs>
<rect x="1740.2544" y="2129.6233" width="16.597" height="112.721" style="fill:url(#g1-0);stroke:url(#g1-1);paint-order:fill" transform="rotate(-89.99513)"/>
<g transform="translate(43.276659,-1.4142135)">
<text style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:140.132px;font-family:'Vremena Grotesk',Arial,sans-serif;text-align:center;text-anchor:middle;fill:#00408d;stroke:#051343;stroke-width:2.33554" x="189.29001" y="402.45694"><tspan x="189.29001" y="402.45694">f<tspan style="font-family:'M+ 2c',Arial,sans-serif">e</tspan>lhom</tspan></text>
<text style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:140.132px;font-family:'M+ 2c',Arial,sans-serif;text-align:center;text-anchor:middle;fill:#008ddf;stroke:#0472bb;stroke-width:2.33554" x="520.41119" y="401.63116"><tspan x="520.41119" y="401.63116">e<tspan style="font-family:'Vremena Grotesk',Arial,sans-serif">u</tspan></tspan></text>
<circle style="fill:#008ddf;stroke:#0472bb;stroke-width:1.85226" cx="426.38022" cy="392.96091" r="10.150504"/>
</g>
<g>
<path style="fill:#00408d;stroke:#051343;stroke-width:2px" d="m 153.81527,161.41782 v 28.90235 h 105.70117 v -10.96094 h -24.28711 v -17.94141 z m 20.66992,8.46485 a 5.556,5.556 0 0 1 5.55664,5.55664 5.556,5.556 0 0 1 -5.55664,5.55468 5.556,5.556 0 0 1 -5.55664,-5.55468 5.556,5.556 0 0 1 5.55664,-5.55664 z m 20.47852,0.0215 a 5.556,5.556 0 0 1 5.55664,5.55664 5.556,5.556 0 0 1 -5.55664,5.55664 5.556,5.556 0 0 1 -5.55664,-5.55664 5.556,5.556 0 0 1 5.55664,-5.55664 z m 20.25586,0.14258 a 5.556,5.556 0 0 1 5.55664,5.55664 5.556,5.556 0 0 1 -5.55664,5.55469 5.556,5.556 0 0 1 -5.55664,-5.55469 5.556,5.556 0 0 1 5.55664,-5.55664 z"/>
<path style="fill:#00408d;stroke:#051343;stroke-width:2px" d="m 153.72738,200.94907 v 28.90039 h 105.70117 v -28.90039 z m 20.67188,9.46289 a 5.556,5.556 0 0 1 5.55468,5.55664 5.556,5.556 0 0 1 -5.55468,5.55664 5.556,5.556 0 0 1 -5.55665,-5.55664 5.556,5.556 0 0 1 5.55665,-5.55664 z m 20.47656,0.0234 a 5.556,5.556 0 0 1 5.55664,5.55469 5.556,5.556 0 0 1 -5.55664,5.55664 5.556,5.556 0 0 1 -5.55469,-5.55664 5.556,5.556 0 0 1 5.55469,-5.55469 z"/>
<path style="fill:#00408d;stroke:#051343;stroke-width:1.9" d="m 197.77426,121.21274 v 28.90039 h 53.76953 l 38.35351,-28.90039 z m 20.67187,9.46289 a 5.556,5.556 0 0 1 5.55469,5.55664 5.556,5.556 0 0 1 -5.55469,5.55469 5.556,5.556 0 0 1 -5.55664,-5.55469 5.556,5.556 0 0 1 5.55664,-5.55664 z"/>
<path d="m 257.94649,264.90079 c -53.034,0 -99.796,-10.762 -127.437,-27.136 19.291,8.807 47.768,14.377 79.534,14.377 23.242,0 44.724,-2.982 62.135,-8.032 v -75.023 l -28.665,-0.001 115.772,-87.280004 48.409,36.495004 v -12.853 h 25.034 v 31.726 l 42.329,31.912 h -28.353 v 77.268 l -78.822,0.005 c -27.916,11.441 -66.856,18.542 -109.935,18.542 z m 102.245,-115.364 c -12.363,0 -22.385,10.022 -22.385,22.385 0,8.343 4.565,15.621 11.334,19.471 l -7.782,41.652 h 37.666 l -7.782,-41.652 c 6.769,-3.85 11.334,-11.127 11.334,-19.471 0,-12.363 -10.022,-22.385 -22.385,-22.385 z" style="fill:#00408d;stroke:#051343;stroke-width:2px"/>
<path style="fill:#008ddf;stroke:#0472bb" d="m 522.38281,159.26953 c -16.13,0.388 -18.85575,5.44224 -48.34375,27.74024 -31.401,25.206 -62.71092,49.374 -117.91992,67 -12.11688,3.8684 -22.84543,6.83673 -36.66992,9.16601 -3.52327,0.53538 -7.07021,1.0572 -10.49219,1.52734 -11.11981,1.52774 -21.588,2.81477 -31.93359,3.76563 l -0.37205,20.49219 c 13.36471,0.34187 35.32192,0.38638 61.20023,-0.23523 25.87831,-0.62162 55.67773,-1.90936 84.72687,-4.23198 29.04914,-2.32263 73.441,-8.14839 90.28521,-12.88581 11.83463,-3.32848 28.09255,-8.72249 37.92989,-19.04615 9.46724,-10.59763 17.11931,-21.57912 18.56188,-36.64771 0.76581,-7.99943 -1.41558,-15.75159 -4.01758,-22.93359 -7.941,-19.793 -24.84508,-34.14694 -42.95508,-33.71094 z"/>
<path d="m 297.34961,5.4492188 c -65.399,0 -119.66288,43.7706692 -129.92188,101.1386712 -0.925,-0.029 -1.85411,-0.043 -2.78711,-0.043 -48.98199,0 -88.689448,39.70841 -88.689448,88.69141 0,48.968 50.024968,85.48542 92.356838,89.13334 l -3.4892,-16.75561 c -32.82055,-3.42533 -71.546884,-33.52411 -71.546884,-73.16211 0,-40.362 33.061744,-73.31914 73.491744,-73.31914 1.843,0 3.67147,0.0672 5.48047,0.20117 v -0.2246 c 4,0.257 7.90183,0.8211 11.67383,1.6621 2.252,-55.916995 52.66917,-100.619136 114.53515,-100.619136 53.04001,0 97.66433,32.85775 110.73633,77.46875 0.309,-0.159 0.61964,-0.315703 0.93164,-0.470703 0.006,0.02 0.0116,0.04055 0.0176,0.06055 11.026,-5.378001 23.57472,-8.419922 36.88672,-8.419922 37.486,0 68.91847,24.124511 77.35547,56.603511 C 520.38986,108.44553 485.73998,78 443.58398,78 c -8.339,0 -16.38403,1.191298 -23.95703,3.404297 -19.419,-44.513 -66.84934,-75.9550782 -122.27734,-75.9550782 z" style="fill:#00408d;stroke:#051343;stroke-width:2px"/>
<path style="fill:url(#lg14);stroke:url(#lg15);paint-order:fill" d="m -267.63252,164.31767 -15.70118,-0.12404 -3.54906,37.20629 -1.95109,76.01487 20.15622,-0.37475 c -4.12353,-45.07233 -3.02437,-82.7491 1.04511,-112.72275 z" transform="rotate(-89.99513)"/>
<path style="fill:none;stroke:url(#lg17);stroke-width:1" d="m 161.24828,267.14668 c 40.90805,5.43949 88.8177,6.68378 159.17408,-4.13383"/>
<path style="fill:none;stroke:url(#lg1);stroke-width:1" d="m 151.96539,281.42915 c 19.27385,6.64292 99.62693,7.03138 134.89483,7.72"/>
</g>
</svg>`