updated memory calculation and logo
This commit is contained in:
+21
-5
@@ -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-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
|
- **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-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
|
- **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%)
|
- Progress bars with color coding (green < 70%, yellow 70-85%, red > 85%)
|
||||||
- New `internal/system` package reads `/proc/meminfo` + `syscall.Statfs`
|
- 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
|
1. Deploy a second app (e.g., Immich, Jellyfin) to validate the template system
|
||||||
2. Test on Raspberry Pi (pi-customer-1)
|
2. Test on Raspberry Pi (pi-customer-1)
|
||||||
3. Add `paths.hdd_path` to demo-felhom controller.yaml to enable HDD bar
|
3. Add `paths.hdd_path` to demo-felhom controller.yaml to enable HDD bar
|
||||||
4. Phase 2 continued: CPU/temperature metrics, Healthchecks.io pings
|
4. Add memory limits + `mem_request`/`mem_limit` to other app catalog templates (Immich, Jellyfin, etc.)
|
||||||
5. Phase 3: Backup system (DB dumps + restic)
|
5. Phase 2 continued: CPU/temperature metrics, Healthchecks.io pings
|
||||||
6. Add memory limits to other app catalog templates (Immich, Jellyfin, etc.)
|
6. Phase 3: Backup system (DB dumps + restic)
|
||||||
|
|
||||||
## Architecture decisions
|
## 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 |
|
| 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 |
|
| 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 |
|
| 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
|
## Key file locations on demo-felhom
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ Current version: **v0.2.1**
|
|||||||
- Protected stacks (traefik, cloudflared, felhom-controller) can't be stopped
|
- Protected stacks (traefik, cloudflared, felhom-controller) can't be stopped
|
||||||
- System info bar on dashboard: RAM, SSD, and HDD usage with progress bars
|
- System info bar on dashboard: RAM, SSD, and HDD usage with progress bars
|
||||||
- Docker Compose memory limits enforced via `deploy.resources.limits.memory`
|
- 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
|
### 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)
|
- 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.
|
- **User input**: HDD path, admin password, language, etc.
|
||||||
- **"🎲 Generálás"** button next to password fields
|
- **"🎲 Generálás"** button next to password fields
|
||||||
3. Clicks "Telepítés" → controller:
|
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)
|
- Validates all required fields (password fields must be explicitly filled or generated)
|
||||||
- Generates auto-secrets (DB passwords, hex keys)
|
- Generates auto-secrets (DB passwords, hex keys)
|
||||||
- Saves `app.yaml` (env vars + locked fields list)
|
- Saves `app.yaml` (env vars + locked fields list)
|
||||||
@@ -147,6 +151,30 @@ controller/
|
|||||||
4. Post-deploy: locked fields (DB_PASSWORD, etc.) become read-only
|
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
|
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
|
### Container state display
|
||||||
|
|
||||||
The dashboard shows health-aware container states with distinct colors:
|
The dashboard shows health-aware container states with distinct colors:
|
||||||
@@ -314,6 +342,8 @@ docker compose up -d
|
|||||||
### Phase 2 — Monitoring & Health
|
### Phase 2 — Monitoring & Health
|
||||||
- [x] System metrics on dashboard (RAM, SSD, HDD usage bars)
|
- [x] System metrics on dashboard (RAM, SSD, HDD usage bars)
|
||||||
- [x] `/api/system/info` endpoint with live resource data
|
- [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
|
- [ ] CPU and temperature metrics
|
||||||
- [ ] Healthchecks.io ping integration
|
- [ ] Healthchecks.io ping integration
|
||||||
- [ ] Customer notifications (email/Telegram)
|
- [ ] Customer notifications (email/Telegram)
|
||||||
|
|||||||
@@ -153,20 +153,25 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
|
|||||||
Values: body.Values,
|
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)
|
r.logger.Printf("[API] Deploy failed for %s: %v", name, err)
|
||||||
status := http.StatusInternalServerError
|
status := http.StatusInternalServerError
|
||||||
if strings.Contains(err.Error(), "already deployed") {
|
if strings.Contains(err.Error(), "already deployed") {
|
||||||
status = http.StatusConflict
|
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
|
status = http.StatusBadRequest
|
||||||
}
|
}
|
||||||
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
|
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
|
||||||
return
|
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) {
|
func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ type Config struct {
|
|||||||
Notifications NotificationsConfig `yaml:"notifications"`
|
Notifications NotificationsConfig `yaml:"notifications"`
|
||||||
Logging LoggingConfig `yaml:"logging"`
|
Logging LoggingConfig `yaml:"logging"`
|
||||||
Assets AssetsConfig `yaml:"assets"`
|
Assets AssetsConfig `yaml:"assets"`
|
||||||
|
System SystemConfig `yaml:"system"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SystemConfig struct {
|
||||||
|
ReservedMemoryMB int `yaml:"reserved_memory_mb"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CustomerConfig struct {
|
type CustomerConfig struct {
|
||||||
@@ -196,6 +201,7 @@ func applyDefaults(cfg *Config) {
|
|||||||
di(&cfg.Logging.MaxSizeMB, 10)
|
di(&cfg.Logging.MaxSizeMB, 10)
|
||||||
di(&cfg.Logging.MaxFiles, 3)
|
di(&cfg.Logging.MaxFiles, 3)
|
||||||
d(&cfg.Assets.SourceURL, "https://felhom.eu")
|
d(&cfg.Assets.SourceURL, "https://felhom.eu")
|
||||||
|
di(&cfg.System.ReservedMemoryMB, 384)
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyEnvOverrides(cfg *Config) {
|
func applyEnvOverrides(cfg *Config) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,18 +29,20 @@ type DeployRequest struct {
|
|||||||
Values map[string]string `json:"values"` // env_var -> user-provided value
|
Values map[string]string `json:"values"` // env_var -> user-provided value
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeployStack handles first-time deployment of an app:
|
// DeployStack handles first-time deployment of an app.
|
||||||
// 1. Load metadata (.felhom.yml) to know what fields exist
|
// Returns a warning message (empty if none) and an error if deployment is blocked.
|
||||||
// 2. Auto-generate secrets for secret fields (hidden from user)
|
// 1. Check available memory against app requirements
|
||||||
// 3. Auto-fill domain from controller config
|
// 2. Load metadata (.felhom.yml) to know what fields exist
|
||||||
// 4. Validate all user-provided values (password, path, required fields)
|
// 3. Auto-generate secrets for secret fields (hidden from user)
|
||||||
// 5. Save app.yaml
|
// 4. Auto-fill domain from controller config
|
||||||
// 6. Run docker compose up -d with env vars
|
// 5. Validate all user-provided values (password, path, required fields)
|
||||||
// 7. Update in-memory stack state
|
// 6. Save app.yaml
|
||||||
func (m *Manager) DeployStack(req DeployRequest) error {
|
// 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)
|
stack, ok := m.GetStack(req.StackName)
|
||||||
if !ok {
|
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)
|
stackDir := filepath.Dir(stack.ComposePath)
|
||||||
@@ -48,7 +51,43 @@ func (m *Manager) DeployStack(req DeployRequest) error {
|
|||||||
// Check if already deployed
|
// Check if already deployed
|
||||||
existing := LoadAppConfig(stackDir)
|
existing := LoadAppConfig(stackDir)
|
||||||
if existing != nil && existing.Deployed {
|
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)
|
// 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
|
// Always auto-generate, user never sees these
|
||||||
generated, err := generateValue(field.Generate)
|
generated, err := generateValue(field.Generate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("generating %s: %w", field.EnvVar, err)
|
return "", fmt.Errorf("generating %s: %w", field.EnvVar, err)
|
||||||
}
|
}
|
||||||
value = generated
|
value = generated
|
||||||
|
|
||||||
@@ -87,7 +126,7 @@ func (m *Manager) DeployStack(req DeployRequest) error {
|
|||||||
if userVal, ok := req.Values[field.EnvVar]; ok && userVal != "" {
|
if userVal, ok := req.Values[field.EnvVar]; ok && userVal != "" {
|
||||||
value = userVal
|
value = userVal
|
||||||
} else {
|
} 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:
|
default:
|
||||||
@@ -101,13 +140,13 @@ func (m *Manager) DeployStack(req DeployRequest) error {
|
|||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if field.Required && value == "" {
|
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)
|
// Validate path fields exist on disk (inside the container's filesystem)
|
||||||
if field.Type == "path" && value != "" {
|
if field.Type == "path" && value != "" {
|
||||||
if _, err := os.Stat(value); os.IsNotExist(err) {
|
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 {
|
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)
|
// 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, ", "))
|
m.logger.Printf("[INFO] Deploying stack %s with %d env vars: [%s]", req.StackName, len(env), strings.Join(envKeys, ", "))
|
||||||
|
|
||||||
// Run docker compose up -d
|
// Run docker compose up -d
|
||||||
_, err := m.composeExecWithEnv(stackDir, env, "up", "-d")
|
_, composeErr := m.composeExecWithEnv(stackDir, env, "up", "-d")
|
||||||
if err != nil {
|
if composeErr != nil {
|
||||||
// Deployment failed — keep app.yaml for debugging but mark as not deployed
|
// Deployment failed — keep app.yaml for debugging but mark as not deployed
|
||||||
appCfg.Deployed = false
|
appCfg.Deployed = false
|
||||||
_ = SaveAppConfig(stackDir, appCfg)
|
_ = 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
|
// 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.mu.Unlock()
|
||||||
|
|
||||||
m.logger.Printf("[INFO] Stack %s deployed successfully", req.StackName)
|
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.
|
// UpdateStackConfig updates non-locked fields for a deployed stack.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -516,4 +517,67 @@ func (m *Manager) execCommand(name string, args ...string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return stdout.String(), nil
|
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 (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -29,6 +30,16 @@ func GetInfo(hddPath string) SystemInfo {
|
|||||||
return info
|
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) {
|
func readMemInfo(info *SystemInfo) {
|
||||||
f, err := os.Open("/proc/meminfo")
|
f, err := os.Open("/proc/meminfo")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,7 +2,14 @@
|
|||||||
|
|
||||||
package system
|
package system
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
// GetInfo returns empty system info on non-Linux platforms.
|
// GetInfo returns empty system info on non-Linux platforms.
|
||||||
func GetInfo(_ string) SystemInfo {
|
func GetInfo(_ string) SystemInfo {
|
||||||
return 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("Content-Type", "text/css")
|
||||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||||
fmt.Fprint(w, cssContent)
|
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/"):
|
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/"):
|
||||||
@@ -388,18 +392,50 @@ func (s *Server) deployHandler(w http.ResponseWriter, _ *http.Request, name stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
stack, _ := s.stackMgr.GetStack(name)
|
stack, _ := s.stackMgr.GetStack(name)
|
||||||
|
alreadyDeployed := appCfg != nil && appCfg.Deployed
|
||||||
|
|
||||||
data := s.baseData("deploy", meta.DisplayName+" — Telepítés")
|
data := s.baseData("deploy", meta.DisplayName+" — Telepítés")
|
||||||
data["Stack"] = stack
|
data["Stack"] = stack
|
||||||
data["Meta"] = meta
|
data["Meta"] = meta
|
||||||
data["AppConfig"] = appCfg
|
data["AppConfig"] = appCfg
|
||||||
data["AlreadyDeployed"] = appCfg != nil && appCfg.Deployed
|
data["AlreadyDeployed"] = alreadyDeployed
|
||||||
data["LogoURL"] = s.cfg.AppLogoURL(meta.Slug)
|
data["LogoURL"] = s.cfg.AppLogoURL(meta.Slug)
|
||||||
data["LogoPNGURL"] = s.cfg.AppLogoPNGURL(meta.Slug)
|
data["LogoPNGURL"] = s.cfg.AppLogoPNGURL(meta.Slug)
|
||||||
data["AppPageURL"] = s.cfg.AppPageURL(meta.Slug)
|
data["AppPageURL"] = s.cfg.AppPageURL(meta.Slug)
|
||||||
data["UserFields"] = meta.UserFacingFields()
|
data["UserFields"] = meta.UserFacingFields()
|
||||||
data["AutoFields"] = meta.AutoGeneratedFields()
|
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)
|
s.render(w, "deploy", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const layoutTmpl = `
|
|||||||
<body>
|
<body>
|
||||||
<nav class="sidebar">
|
<nav class="sidebar">
|
||||||
<div class="sidebar-header">
|
<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>
|
<span class="customer-name">{{.CustomerName}}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul class="nav-links">
|
<ul class="nav-links">
|
||||||
@@ -205,7 +205,7 @@ const stacksTmpl = `
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<div class="stack-meta-badges">
|
<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.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}}
|
{{if .Meta.Resources.NeedsHDD}}<span class="meta-badge">HDD szükséges</span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
@@ -263,7 +263,7 @@ const deployTmpl = `
|
|||||||
<h3>{{.Meta.DisplayName}}</h3>
|
<h3>{{.Meta.DisplayName}}</h3>
|
||||||
{{if .Meta.Description}}<p>{{.Meta.Description}}</p>{{end}}
|
{{if .Meta.Description}}<p>{{.Meta.Description}}</p>{{end}}
|
||||||
<div class="stack-meta-badges">
|
<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.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}}
|
{{if .Meta.Resources.NeedsHDD}}<span class="meta-badge">HDD szükséges</span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
@@ -279,6 +279,34 @@ const deployTmpl = `
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{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">
|
<form id="deploy-form" class="deploy-form">
|
||||||
{{if .AutoFields}}
|
{{if .AutoFields}}
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
@@ -346,7 +374,7 @@ const deployTmpl = `
|
|||||||
|
|
||||||
{{if not .AlreadyDeployed}}
|
{{if not .AlreadyDeployed}}
|
||||||
<div class="deploy-actions">
|
<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>
|
<a href="/stacks" class="btn btn-outline">Mégsem</a>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -420,7 +448,11 @@ document.getElementById('deploy-form').addEventListener('submit', async function
|
|||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
return;
|
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';
|
window.location.href = '/stacks';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Hálózati hiba: ' + err.message);
|
alert('Hálózati hiba: ' + err.message);
|
||||||
@@ -446,7 +478,7 @@ const loginTmpl = `
|
|||||||
</head>
|
</head>
|
||||||
<body class="login-body">
|
<body class="login-body">
|
||||||
<div class="login-card">
|
<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>
|
<p class="login-subtitle">{{.CustomerName}}</p>
|
||||||
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
|
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
|
||||||
<form method="POST" action="/login">
|
<form method="POST" action="/login">
|
||||||
@@ -553,14 +585,17 @@ body::before {
|
|||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
.logo {
|
.sidebar-logo {
|
||||||
font-size: 1.5rem;
|
width: 140px;
|
||||||
font-weight: 700;
|
height: auto;
|
||||||
color: var(--text-primary);
|
display: block;
|
||||||
background: linear-gradient(135deg, var(--accent-light), var(--accent-blue));
|
margin-bottom: 0.25rem;
|
||||||
-webkit-background-clip: text;
|
}
|
||||||
-webkit-text-fill-color: transparent;
|
.login-logo {
|
||||||
background-clip: text;
|
width: 200px;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 0.5rem;
|
||||||
}
|
}
|
||||||
.customer-name {
|
.customer-name {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -1070,6 +1105,41 @@ select.form-control option { background: var(--bg-secondary); color: var(--text-
|
|||||||
color: var(--accent-light);
|
color: var(--accent-light);
|
||||||
border-color: rgba(0, 136, 204, 0.3);
|
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 */
|
||||||
.logs-container {
|
.logs-container {
|
||||||
@@ -1124,9 +1194,9 @@ select.form-control option { background: var(--bg-secondary); color: var(--text-
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
.login-card .logo {
|
.login-card .login-logo {
|
||||||
margin-bottom: .25rem;
|
width: 220px;
|
||||||
font-size: 1.8rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
.login-subtitle {
|
.login-subtitle {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -1159,4 +1229,38 @@ select.form-control option { background: var(--bg-secondary); color: var(--text-
|
|||||||
.deploy-info { flex-direction: column; }
|
.deploy-info { flex-direction: column; }
|
||||||
.system-info-items { flex-direction: column; gap: 1rem; }
|
.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