Added memory limits and system info for memory

This commit is contained in:
2026-02-14 11:44:06 +01:00
parent e0e15867e9
commit 67ba0fe759
12 changed files with 295 additions and 20 deletions
+11 -3
View File
@@ -36,6 +36,8 @@ Current version: **v0.2.1**
- Manual rescan endpoint (`POST /api/stacks/rescan`)
- Alphabetically sorted stack display (consistent card ordering)
- 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`
### 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)
@@ -88,6 +90,10 @@ controller/
│ │ ├── metadata.go # Parse .felhom.yml app metadata
│ │ └── deploy.go # First-deploy flow: secret gen, app.yaml, compose up
│ ├── api/router.go # REST API endpoints
│ ├── system/
│ │ ├── info.go # SystemInfo struct
│ │ ├── info_linux.go # Linux: /proc/meminfo + statfs
│ │ └── info_other.go # Non-Linux stub
│ └── web/
│ ├── server.go # HTTP server, auth, page handlers, asset serving
│ └── templates.go # Embedded HTML templates + CSS (Hungarian UI)
@@ -110,6 +116,7 @@ controller/
| **Config** | `internal/config/` | ✅ Done | Load & validate controller.yaml, env overrides |
| **Stacks** | `internal/stacks/` | ✅ Done | Compose operations, scanning, metadata, deploy flow |
| **API** | `internal/api/` | ✅ Done | REST endpoints (stacks, deploy, rescan, system info, health) |
| **System** | `internal/system/` | ✅ Done | System resource info (RAM, disk usage) for dashboard & API |
| **Web** | `internal/web/` | ✅ Done | Hungarian dashboard, auth, deploy pages, asset serving |
| **Backup** | `internal/backup/` | 📲 Phase 3 | DB dumps, restic snapshots, restore |
| **Monitor** | `internal/monitor/` | 📲 Phase 2 | Health checks, Healthchecks pings, system metrics |
@@ -277,7 +284,7 @@ docker compose up -d
| POST | `/api/stacks/{name}/update` | Yes | Pull images + recreate |
| GET | `/api/stacks/{name}/logs` | Yes | Container logs |
| POST | `/api/stacks/rescan` | Yes | Trigger manual stack discovery |
| GET | `/api/system/info` | Yes | Customer/domain info |
| GET | `/api/system/info` | Yes | System resource usage (RAM, disk, HDD) |
## Status & Roadmap
@@ -305,9 +312,10 @@ docker compose up -d
- [x] Deploy page doubles as read-only config viewer for deployed apps
### Phase 2 — Monitoring & Health
- [ ] System metrics collection (CPU, RAM, disk, temperature)
- [x] System metrics on dashboard (RAM, SSD, HDD usage bars)
- [x] `/api/system/info` endpoint with live resource data
- [ ] CPU and temperature metrics
- [ ] Healthchecks.io ping integration
- [ ] Dashboard system health panel
- [ ] Customer notifications (email/Telegram)
### Phase 3 — Backups
+1 -1
View File
@@ -3,6 +3,6 @@ module gitea.dooplex.hu/admin/felhom-controller
go 1.22
require (
gopkg.in/yaml.v3 v3.0.1
golang.org/x/crypto v0.31.0
gopkg.in/yaml.v3 v3.0.1
)
+6
View File
@@ -0,0 +1,6 @@
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+3 -7
View File
@@ -10,6 +10,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)
// Router handles all /api/* requests.
@@ -215,13 +216,8 @@ func (r *Router) getStackLogs(w http.ResponseWriter, req *http.Request, name str
}
func (r *Router) systemInfo(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{
"customer_id": r.cfg.Customer.ID,
"customer_name": r.cfg.Customer.Name,
"domain": r.cfg.Customer.Domain,
"backup_enabled": r.cfg.Backup.Enabled,
"monitor_enabled": r.cfg.Monitoring.Enabled,
}})
info := system.GetInfo(r.cfg.Paths.HDDPath)
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: info})
}
// --- Helpers ---
+1
View File
@@ -44,6 +44,7 @@ type PathsConfig struct {
DataDir string `yaml:"data_dir"`
BackupDir string `yaml:"backup_dir"`
DBDumpDir string `yaml:"db_dump_dir"`
HDDPath string `yaml:"hdd_path"`
}
type WebConfig struct {
+1
View File
@@ -22,6 +22,7 @@ type Metadata struct {
// ResourceHints describe what the app needs.
type ResourceHints struct {
RAM string `yaml:"ram" json:"ram"`
MemLimit string `yaml:"mem_limit" json:"mem_limit"`
PiCompatible bool `yaml:"pi_compatible" json:"pi_compatible"`
NeedsHDD bool `yaml:"needs_hdd" json:"needs_hdd"`
}
+20
View File
@@ -0,0 +1,20 @@
package system
// SystemInfo holds system resource usage information.
type SystemInfo struct {
TotalMemMB uint64 `json:"total_mem_mb"`
UsedMemMB uint64 `json:"used_mem_mb"`
AvailMemMB uint64 `json:"avail_mem_mb"`
MemPercent float64 `json:"mem_percent"`
DiskTotalGB float64 `json:"disk_total_gb"`
DiskUsedGB float64 `json:"disk_used_gb"`
DiskAvailGB float64 `json:"disk_avail_gb"`
DiskPercent float64 `json:"disk_percent"`
HDDTotalGB float64 `json:"hdd_total_gb,omitempty"`
HDDUsedGB float64 `json:"hdd_used_gb,omitempty"`
HDDAvailGB float64 `json:"hdd_avail_gb,omitempty"`
HDDPercent float64 `json:"hdd_percent,omitempty"`
HDDConfigured bool `json:"hdd_configured"`
}
+100
View File
@@ -0,0 +1,100 @@
//go:build linux
package system
import (
"bufio"
"os"
"strings"
"syscall"
)
// GetInfo reads system memory and disk usage.
// hddPath is the mount path for external HDD; if empty, HDD info is skipped.
func GetInfo(hddPath string) SystemInfo {
info := SystemInfo{}
// --- Memory from /proc/meminfo ---
readMemInfo(&info)
// --- Root filesystem disk usage ---
readDiskUsage("/", &info.DiskTotalGB, &info.DiskUsedGB, &info.DiskAvailGB, &info.DiskPercent)
// --- HDD disk usage (if configured) ---
if hddPath != "" {
info.HDDConfigured = true
readDiskUsage(hddPath, &info.HDDTotalGB, &info.HDDUsedGB, &info.HDDAvailGB, &info.HDDPercent)
}
return info
}
func readMemInfo(info *SystemInfo) {
f, err := os.Open("/proc/meminfo")
if err != nil {
return
}
defer f.Close()
var totalKB, availKB uint64
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
switch {
case strings.HasPrefix(line, "MemTotal:"):
totalKB = parseMemLine(line)
case strings.HasPrefix(line, "MemAvailable:"):
availKB = parseMemLine(line)
}
if totalKB > 0 && availKB > 0 {
break
}
}
if totalKB > 0 {
info.TotalMemMB = totalKB / 1024
info.AvailMemMB = availKB / 1024
info.UsedMemMB = info.TotalMemMB - info.AvailMemMB
info.MemPercent = float64(info.UsedMemMB) / float64(info.TotalMemMB) * 100
}
}
// parseMemLine extracts the kB value from a /proc/meminfo line like "MemTotal: 16384000 kB"
func parseMemLine(line string) uint64 {
// Remove label prefix up to ':'
parts := strings.SplitN(line, ":", 2)
if len(parts) < 2 {
return 0
}
valStr := strings.TrimSpace(parts[1])
valStr = strings.TrimSuffix(valStr, " kB")
valStr = strings.TrimSpace(valStr)
var val uint64
for _, c := range valStr {
if c >= '0' && c <= '9' {
val = val*10 + uint64(c-'0')
}
}
return val
}
func readDiskUsage(path string, totalGB, usedGB, availGB *float64, percent *float64) {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return
}
bsize := uint64(stat.Bsize)
total := stat.Blocks * bsize
avail := stat.Bavail * bsize
used := total - (stat.Bfree * bsize) // Bfree includes reserved blocks
const gb = 1024 * 1024 * 1024
*totalGB = float64(total) / gb
*usedGB = float64(used) / gb
*availGB = float64(avail) / gb
if total > 0 {
*percent = float64(used) / float64(total) * 100
}
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !linux
package system
// GetInfo returns empty system info on non-Linux platforms.
func GetInfo(_ string) SystemInfo {
return SystemInfo{}
}
+32
View File
@@ -16,6 +16,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
"golang.org/x/crypto/bcrypt"
)
@@ -129,6 +130,34 @@ func (s *Server) loadTemplates() {
"appPageURL": func(slug string) string {
return s.cfg.AppPageURL(slug)
},
"usageColor": func(percent float64) string {
if percent >= 85 {
return "red"
}
if percent >= 70 {
return "yellow"
}
return "green"
},
"fmtMB": func(mb uint64) string {
if mb >= 1024 {
gb := float64(mb) / 1024.0
if gb >= 10 {
return fmt.Sprintf("%.0f GB", gb)
}
return fmt.Sprintf("%.1f GB", gb)
}
return fmt.Sprintf("%d MB", mb)
},
"fmtGB": func(gb float64) string {
if gb >= 100 {
return fmt.Sprintf("%.0f GB", gb)
}
if gb >= 10 {
return fmt.Sprintf("%.1f GB", gb)
}
return fmt.Sprintf("%.2f GB", gb)
},
}
s.tmpl = template.Must(template.New("").Funcs(funcMap).Parse(allTemplates))
@@ -315,11 +344,14 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, _ *http.Request) {
}
}
sysInfo := system.GetInfo(s.cfg.Paths.HDDPath)
data := s.baseData("dashboard", "Vezérlőpult")
data["Stacks"] = stackList
data["RunningCount"] = running
data["StoppedCount"] = stopped
data["TotalCount"] = len(stackList)
data["SystemInfo"] = sysInfo
s.render(w, "dashboard", data)
}
+90 -2
View File
@@ -94,6 +94,42 @@ const dashboardTmpl = `
</div>
</div>
{{if .SystemInfo.TotalMemMB}}
<div class="system-info-card">
<div class="system-info-items">
<div class="system-info-item">
<div class="system-info-header">
<span class="system-info-label">Memória</span>
<span class="system-info-value">{{fmtMB .SystemInfo.UsedMemMB}} / {{fmtMB .SystemInfo.TotalMemMB}} ({{printf "%.0f" .SystemInfo.MemPercent}}%)</span>
</div>
<div class="system-bar">
<div class="system-bar-fill system-bar-{{usageColor .SystemInfo.MemPercent}}" style="width:{{printf "%.0f" .SystemInfo.MemPercent}}%"></div>
</div>
</div>
<div class="system-info-item">
<div class="system-info-header">
<span class="system-info-label">SSD tárhely</span>
<span class="system-info-value">{{fmtGB .SystemInfo.DiskUsedGB}} / {{fmtGB .SystemInfo.DiskTotalGB}} ({{printf "%.0f" .SystemInfo.DiskPercent}}%)</span>
</div>
<div class="system-bar">
<div class="system-bar-fill system-bar-{{usageColor .SystemInfo.DiskPercent}}" style="width:{{printf "%.0f" .SystemInfo.DiskPercent}}%"></div>
</div>
</div>
{{if .SystemInfo.HDDConfigured}}
<div class="system-info-item">
<div class="system-info-header">
<span class="system-info-label">Külső HDD</span>
<span class="system-info-value">{{fmtGB .SystemInfo.HDDUsedGB}} / {{fmtGB .SystemInfo.HDDTotalGB}} ({{printf "%.0f" .SystemInfo.HDDPercent}}%)</span>
</div>
<div class="system-bar">
<div class="system-bar-fill system-bar-{{usageColor .SystemInfo.HDDPercent}}" style="width:{{printf "%.0f" .SystemInfo.HDDPercent}}%"></div>
</div>
</div>
{{end}}
</div>
</div>
{{end}}
<h3>Alkalmazások állapota</h3>
<div class="stack-list">
@@ -195,9 +231,9 @@ const stacksTmpl = `
{{if isOperational .State}}
<button class="btn btn-success" onclick="stackAction('{{.Name}}', 'update')">Frissítés</button>
<button class="btn btn-warning" onclick="stackAction('{{.Name}}', 'restart')">Újraindítás</button>
<button class="btn btn-danger" onclick="stackAction('{{.Name}}', 'stop')">Leállítás</button>
<button class="btn btn-danger" onclick="stackAction('{{.Name}}', 'stop')">Leállítás</button>
{{else}}
<button class="btn btn-success" onclick="stackAction('{{.Name}}', 'start')">Indítás</button>
<button class="btn btn-success" onclick="stackAction('{{.Name}}', 'start')">Indítás</button>
{{end}}
<a href="/stacks/{{.Name}}/logs" class="btn btn-outline">Naplók</a>
<a href="{{appPageURL .Meta.Slug}}" class="btn btn-outline">Részletek</a>
@@ -635,6 +671,57 @@ h3 {
margin-top: .25rem;
}
/* System info bar */
.system-info-card {
background: var(--bg-card);
border-radius: var(--radius);
padding: 1rem 1.25rem;
border: 1px solid var(--border-color);
margin-bottom: 2rem;
}
.system-info-items {
display: flex;
gap: 2rem;
flex-wrap: wrap;
}
.system-info-item {
flex: 1;
min-width: 200px;
}
.system-info-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: .4rem;
}
.system-info-label {
font-size: .8rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: .5px;
}
.system-info-value {
font-size: .8rem;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
.system-bar {
width: 100%;
height: 6px;
background: var(--bg-primary);
border-radius: 3px;
overflow: hidden;
}
.system-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
}
.system-bar-green { background: var(--green); }
.system-bar-yellow { background: var(--yellow); }
.system-bar-red { background: var(--red); }
/* Stack list (dashboard) */
.stack-list {
display: flex;
@@ -1039,5 +1126,6 @@ select.form-control option { background: var(--bg-secondary); color: var(--text-
.stack-grid { grid-template-columns: 1fr; }
.stats-grid { grid-template-columns: repeat(3, 1fr); }
.deploy-info { flex-direction: column; }
.system-info-items { flex-direction: column; gap: 1rem; }
}
`