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
+22 -7
View File
@@ -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 Last updated: 2026-02-14 (evening)
--- ---
@@ -28,7 +28,20 @@ Last updated: 2026-02-14
- **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-13/14) ### What was just completed (2026-02-14)
- **System info bar on Vezérlőpult dashboard**: RAM, SSD, and optional HDD usage
- Progress bars with color coding (green < 70%, yellow 70-85%, red > 85%)
- New `internal/system` package reads `/proc/meminfo` + `syscall.Statfs`
- Platform-specific: Linux impl + non-Linux stub (build tags)
- Hungarian labels: "Memória", "SSD tárhely", "Külső HDD"
- **Docker Compose memory limits** on paperless-ngx template:
- paperless-webserver: 768M, postgres: 256M, redis: 128M
- Added `mem_limit` field to `.felhom.yml` ResourceHints (total: 1152M)
- **`/api/system/info` endpoint** now returns live system metrics (was customer info)
- **Config**: Added `paths.hdd_path` for external HDD monitoring
- Controller image builds via build.sh, pushes to Gitea container registry
### Previously completed (2026-02-13)
- Built the entire felhom-controller from scratch (Go, no frameworks) - Built the entire felhom-controller from scratch (Go, no frameworks)
- Debugged and fixed 7 issues during first real deployment: - Debugged and fixed 7 issues during first real deployment:
1. Password validation (empty passwords accepted) 1. Password validation (empty passwords accepted)
@@ -38,14 +51,14 @@ Last updated: 2026-02-14
5. "Részletek" button redirect for deployed apps 5. "Részletek" button redirect for deployed apps
6. Paperless OCR language installation (LANGUAGES vs LANGUAGE env var) 6. Paperless OCR language installation (LANGUAGES vs LANGUAGE env var)
7. Documentation: restart vs up -d for image updates 7. Documentation: restart vs up -d for image updates
- Controller image builds via build.sh, pushes to Gitea container registry
### What's next (priorities) ### What's next (priorities)
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. Phase 2: Monitoring & Healthchecks integration 3. Add `paths.hdd_path` to demo-felhom controller.yaml to enable HDD bar
4. Phase 3: Backup system (DB dumps + restic) 4. Phase 2 continued: CPU/temperature metrics, Healthchecks.io pings
5. Dashboard dark theme (align with felhom.eu website) 5. Phase 3: Backup system (DB dumps + restic)
6. Add memory limits to other app catalog templates (Immich, Jellyfin, etc.)
## Architecture decisions ## Architecture decisions
@@ -59,6 +72,8 @@ Last updated: 2026-02-14
| app.yaml per stack | Separates deploy config from compose files, survives git pulls | | app.yaml per stack | Separates deploy config from compose files, survives git pulls |
| Password fields require explicit input | Prevents accidental empty-password deployments | | Password fields require explicit input | Prevents accidental empty-password deployments |
| 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 |
| System info from /proc/meminfo + statfs | No external dependencies, cheap to read on each page load |
## Key file locations on demo-felhom ## Key file locations on demo-felhom
@@ -89,7 +104,7 @@ Last updated: 2026-02-14
| Repository | Status | Notes | | Repository | Status | Notes |
|------------|--------|-------| |------------|--------|-------|
| deploy-felhom-compose | Active | This repo. Controller code + deploy scripts | | deploy-felhom-compose | Active | This repo. Controller code + deploy scripts |
| app-catalog-felhom.eu | Active | 49 app templates, needs PAPERLESS_OCR_LANGUAGES fix | | app-catalog-felhom.eu | Active | 49 app templates, paperless-ngx has memory limits |
| felhom.eu | Stable | Website live, SEO indexed, email working | | felhom.eu | Stable | Website live, SEO indexed, email working |
| homelab-manifests | Stable | k3s cluster running (dooplex.hu services) | | homelab-manifests | Stable | k3s cluster running (dooplex.hu services) |
| misc-scripts | Utility | collect-repo.sh, backup helpers | | misc-scripts | Utility | collect-repo.sh, backup helpers |
+11 -3
View File
@@ -36,6 +36,8 @@ Current version: **v0.2.1**
- Manual rescan endpoint (`POST /api/stacks/rescan`) - Manual rescan endpoint (`POST /api/stacks/rescan`)
- Alphabetically sorted stack display (consistent card ordering) - Alphabetically sorted stack display (consistent card ordering)
- 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
- Docker Compose memory limits enforced via `deploy.resources.limits.memory`
### 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)
@@ -88,6 +90,10 @@ controller/
│ │ ├── metadata.go # Parse .felhom.yml app metadata │ │ ├── metadata.go # Parse .felhom.yml app metadata
│ │ └── deploy.go # First-deploy flow: secret gen, app.yaml, compose up │ │ └── deploy.go # First-deploy flow: secret gen, app.yaml, compose up
│ ├── api/router.go # REST API endpoints │ ├── 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/ │ └── web/
│ ├── server.go # HTTP server, auth, page handlers, asset serving │ ├── server.go # HTTP server, auth, page handlers, asset serving
│ └── templates.go # Embedded HTML templates + CSS (Hungarian UI) │ └── templates.go # Embedded HTML templates + CSS (Hungarian UI)
@@ -110,6 +116,7 @@ controller/
| **Config** | `internal/config/` | ✅ Done | Load & validate controller.yaml, env overrides | | **Config** | `internal/config/` | ✅ Done | Load & validate controller.yaml, env overrides |
| **Stacks** | `internal/stacks/` | ✅ Done | Compose operations, scanning, metadata, deploy flow | | **Stacks** | `internal/stacks/` | ✅ Done | Compose operations, scanning, metadata, deploy flow |
| **API** | `internal/api/` | ✅ Done | REST endpoints (stacks, deploy, rescan, system info, health) | | **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 | | **Web** | `internal/web/` | ✅ Done | Hungarian dashboard, auth, deploy pages, asset serving |
| **Backup** | `internal/backup/` | 📲 Phase 3 | DB dumps, restic snapshots, restore | | **Backup** | `internal/backup/` | 📲 Phase 3 | DB dumps, restic snapshots, restore |
| **Monitor** | `internal/monitor/` | 📲 Phase 2 | Health checks, Healthchecks pings, system metrics | | **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 | | POST | `/api/stacks/{name}/update` | Yes | Pull images + recreate |
| GET | `/api/stacks/{name}/logs` | Yes | Container logs | | GET | `/api/stacks/{name}/logs` | Yes | Container logs |
| POST | `/api/stacks/rescan` | Yes | Trigger manual stack discovery | | 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 ## Status & Roadmap
@@ -305,9 +312,10 @@ docker compose up -d
- [x] Deploy page doubles as read-only config viewer for deployed apps - [x] Deploy page doubles as read-only config viewer for deployed apps
### Phase 2 — Monitoring & Health ### 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 - [ ] Healthchecks.io ping integration
- [ ] Dashboard system health panel
- [ ] Customer notifications (email/Telegram) - [ ] Customer notifications (email/Telegram)
### Phase 3 — Backups ### Phase 3 — Backups
+1 -1
View File
@@ -3,6 +3,6 @@ module gitea.dooplex.hu/admin/felhom-controller
go 1.22 go 1.22
require ( require (
gopkg.in/yaml.v3 v3.0.1
golang.org/x/crypto v0.31.0 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/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
) )
// Router handles all /api/* requests. // 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) { func (r *Router) systemInfo(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{ info := system.GetInfo(r.cfg.Paths.HDDPath)
"customer_id": r.cfg.Customer.ID, writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: info})
"customer_name": r.cfg.Customer.Name,
"domain": r.cfg.Customer.Domain,
"backup_enabled": r.cfg.Backup.Enabled,
"monitor_enabled": r.cfg.Monitoring.Enabled,
}})
} }
// --- Helpers --- // --- Helpers ---
+1
View File
@@ -44,6 +44,7 @@ type PathsConfig struct {
DataDir string `yaml:"data_dir"` DataDir string `yaml:"data_dir"`
BackupDir string `yaml:"backup_dir"` BackupDir string `yaml:"backup_dir"`
DBDumpDir string `yaml:"db_dump_dir"` DBDumpDir string `yaml:"db_dump_dir"`
HDDPath string `yaml:"hdd_path"`
} }
type WebConfig struct { type WebConfig struct {
+1
View File
@@ -22,6 +22,7 @@ type Metadata struct {
// ResourceHints describe what the app needs. // ResourceHints describe what the app needs.
type ResourceHints struct { type ResourceHints struct {
RAM string `yaml:"ram" json:"ram"` RAM string `yaml:"ram" json:"ram"`
MemLimit string `yaml:"mem_limit" json:"mem_limit"`
PiCompatible bool `yaml:"pi_compatible" json:"pi_compatible"` PiCompatible bool `yaml:"pi_compatible" json:"pi_compatible"`
NeedsHDD bool `yaml:"needs_hdd" json:"needs_hdd"` 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/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -129,6 +130,34 @@ func (s *Server) loadTemplates() {
"appPageURL": func(slug string) string { "appPageURL": func(slug string) string {
return s.cfg.AppPageURL(slug) 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)) 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 := s.baseData("dashboard", "Vezérlőpult")
data["Stacks"] = stackList data["Stacks"] = stackList
data["RunningCount"] = running data["RunningCount"] = running
data["StoppedCount"] = stopped data["StoppedCount"] = stopped
data["TotalCount"] = len(stackList) data["TotalCount"] = len(stackList)
data["SystemInfo"] = sysInfo
s.render(w, "dashboard", data) s.render(w, "dashboard", data)
} }
+90 -2
View File
@@ -94,6 +94,42 @@ const dashboardTmpl = `
</div> </div>
</div> </div>
{{if .SystemInfo.TotalMemMB}}
<div class="system-info-card">
<div class="system-info-items">
<div class="system-info-item">
<div class="system-info-header">
<span class="system-info-label">Memória</span>
<span class="system-info-value">{{fmtMB .SystemInfo.UsedMemMB}} / {{fmtMB .SystemInfo.TotalMemMB}} ({{printf "%.0f" .SystemInfo.MemPercent}}%)</span>
</div>
<div class="system-bar">
<div class="system-bar-fill system-bar-{{usageColor .SystemInfo.MemPercent}}" style="width:{{printf "%.0f" .SystemInfo.MemPercent}}%"></div>
</div>
</div>
<div class="system-info-item">
<div class="system-info-header">
<span class="system-info-label">SSD tárhely</span>
<span class="system-info-value">{{fmtGB .SystemInfo.DiskUsedGB}} / {{fmtGB .SystemInfo.DiskTotalGB}} ({{printf "%.0f" .SystemInfo.DiskPercent}}%)</span>
</div>
<div class="system-bar">
<div class="system-bar-fill system-bar-{{usageColor .SystemInfo.DiskPercent}}" style="width:{{printf "%.0f" .SystemInfo.DiskPercent}}%"></div>
</div>
</div>
{{if .SystemInfo.HDDConfigured}}
<div class="system-info-item">
<div class="system-info-header">
<span class="system-info-label">Külső HDD</span>
<span class="system-info-value">{{fmtGB .SystemInfo.HDDUsedGB}} / {{fmtGB .SystemInfo.HDDTotalGB}} ({{printf "%.0f" .SystemInfo.HDDPercent}}%)</span>
</div>
<div class="system-bar">
<div class="system-bar-fill system-bar-{{usageColor .SystemInfo.HDDPercent}}" style="width:{{printf "%.0f" .SystemInfo.HDDPercent}}%"></div>
</div>
</div>
{{end}}
</div>
</div>
{{end}}
<h3>Alkalmazások állapota</h3> <h3>Alkalmazások állapota</h3>
<div class="stack-list"> <div class="stack-list">
@@ -195,9 +231,9 @@ const stacksTmpl = `
{{if isOperational .State}} {{if isOperational .State}}
<button class="btn btn-success" onclick="stackAction('{{.Name}}', 'update')">Frissítés</button> <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-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}} {{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}} {{end}}
<a href="/stacks/{{.Name}}/logs" class="btn btn-outline">Naplók</a> <a href="/stacks/{{.Name}}/logs" class="btn btn-outline">Naplók</a>
<a href="{{appPageURL .Meta.Slug}}" class="btn btn-outline">Részletek</a> <a href="{{appPageURL .Meta.Slug}}" class="btn btn-outline">Részletek</a>
@@ -635,6 +671,57 @@ h3 {
margin-top: .25rem; 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 (dashboard) */
.stack-list { .stack-list {
display: flex; display: flex;
@@ -1039,5 +1126,6 @@ select.form-control option { background: var(--bg-secondary); color: var(--text-
.stack-grid { grid-template-columns: 1fr; } .stack-grid { grid-template-columns: 1fr; }
.stats-grid { grid-template-columns: repeat(3, 1fr); } .stats-grid { grid-template-columns: repeat(3, 1fr); }
.deploy-info { flex-direction: column; } .deploy-info { flex-direction: column; }
.system-info-items { flex-direction: column; gap: 1rem; }
} }
` `