updated memory calculation and logo
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -516,4 +517,67 @@ 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -1159,4 +1229,38 @@ select.form-control option { background: var(--bg-secondary); color: var(--text-
|
||||
.deploy-info { flex-direction: column; }
|
||||
.system-info-items { flex-direction: column; gap: 1rem; }
|
||||
}
|
||||
`
|
||||
`
|
||||
|
||||
// felhomLogoSVG is the felhom.eu logo, served at /static/felhom-logo.svg.
|
||||
// Cleaned from the original Inkscape SVG, removing editor metadata.
|
||||
const felhomLogoSVG = `<?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>`
|
||||
Reference in New Issue
Block a user