v0.5.0: Backup bugfixes + monitoring page with metrics store

- Fix "Helyi mentés" showing "–" after controller restart by synthesizing
  LastBackup from snapshot history and LastDBDump from dump files on disk
- New monitoring page (/monitoring) with system info, metrics charts, and
  container resource overview
- SQLite metrics store (modernc.org/sqlite, pure Go, no CGO) with 60s
  collection interval and 30-day auto-prune
- REST API endpoints: /api/metrics/system, /api/metrics/containers/summary,
  /api/metrics/containers/{name}, /api/metrics/sysinfo
- Chart.js 4.4.7 embedded locally for offline environments
- System info provider reads hostname, OS, kernel, CPU, uptime from /proc
- Docker compose updated with /etc/os-release host mount

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 10:14:46 +01:00
parent 87e79548b0
commit 3e8baebfa5
19 changed files with 1691 additions and 5 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
# =============================================================================
# --- Build stage ---
FROM golang:1.22-bookworm AS builder
FROM golang:1.24-bookworm AS builder
ARG TARGETOS=linux
ARG TARGETARCH
+31 -1
View File
@@ -14,6 +14,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/api"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
@@ -74,6 +75,23 @@ func main() {
cpuCollector.Start(ctx)
defer cpuCollector.Stop()
// --- Initialize metrics store + collector ---
metricsDBPath := "/opt/docker/felhom-controller/data/metrics.db"
metricsStore, err := metrics.NewMetricsStore(metricsDBPath, logger)
if err != nil {
logger.Printf("[WARN] Failed to initialize metrics store: %v — monitoring disabled", err)
} else {
logger.Printf("[INFO] Metrics store opened at %s", metricsDBPath)
}
if metricsStore != nil {
defer metricsStore.Close()
metricsCollector := metrics.NewMetricsCollector(metricsStore, cpuCollector, cfg.Paths.HDDPath, logger)
metricsCollector.Start(ctx)
defer metricsCollector.Stop()
logger.Println("[INFO] Metrics collector started (60s interval)")
}
// --- Initialize health pinger ---
pinger := monitor.NewPinger(&cfg.Monitoring, logger)
@@ -135,6 +153,18 @@ func main() {
})
}
// Metrics prune — daily at 04:00
if metricsStore != nil {
sched.Daily("metrics-prune", "04:00", func(ctx context.Context) error {
deleted, err := metricsStore.Prune(30 * 24 * time.Hour)
if err != nil {
return err
}
logger.Printf("[INFO] Pruned %d old metric rows", deleted)
return nil
})
}
sched.Start(ctx)
defer sched.Stop()
@@ -148,7 +178,7 @@ func main() {
}
// --- Initialize API router ---
apiRouter := api.NewRouter(cfg, stackMgr, syncer, cpuCollector, backupMgr, logger)
apiRouter := api.NewRouter(cfg, stackMgr, syncer, cpuCollector, backupMgr, metricsStore, logger)
// --- Initialize web server ---
webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, sched, logger, Version)
+2
View File
@@ -25,6 +25,8 @@ services:
- ${HDD_PATH:-/mnt/hdd_placeholder}:${HDD_PATH:-/mnt/hdd_placeholder}:ro
# Host /sys — for CPU temperature reading (read-only)
- /sys:/host/sys:ro
# Host OS info — for monitoring page system info
- /etc/os-release:/host/etc/os-release:ro
environment:
- TZ=Europe/Budapest
labels:
+15 -1
View File
@@ -1,8 +1,22 @@
module gitea.dooplex.hu/admin/felhom-controller
go 1.22
go 1.24.0
require (
golang.org/x/crypto v0.31.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.45.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sys v0.37.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+53
View File
@@ -1,6 +1,59 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
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=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs=
modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+163 -2
View File
@@ -8,9 +8,11 @@ import (
"net/http"
"strconv"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
@@ -23,11 +25,12 @@ type Router struct {
syncer *catalogsync.Syncer
cpuCollector *system.CPUCollector
backupMgr *backup.Manager
metricsStore *metrics.MetricsStore
logger *log.Logger
}
func NewRouter(cfg *config.Config, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, logger *log.Logger) *Router {
return &Router{cfg: cfg, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, logger: logger}
func NewRouter(cfg *config.Config, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, metricsStore *metrics.MetricsStore, logger *log.Logger) *Router {
return &Router{cfg: cfg, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, metricsStore: metricsStore, logger: logger}
}
type apiResponse struct {
@@ -111,6 +114,23 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
case path == "/backup/run" && req.Method == http.MethodPost:
r.triggerBackup(w, req)
// GET /api/metrics/system
case path == "/metrics/system" && req.Method == http.MethodGet:
r.metricsSystem(w, req)
// GET /api/metrics/containers/summary
case path == "/metrics/containers/summary" && req.Method == http.MethodGet:
r.metricsContainerSummary(w, req)
// GET /api/metrics/containers/{name}
case strings.HasPrefix(path, "/metrics/containers/") && req.Method == http.MethodGet:
name := strings.TrimPrefix(path, "/metrics/containers/")
r.metricsContainer(w, req, name)
// GET /api/metrics/sysinfo
case path == "/metrics/sysinfo" && req.Method == http.MethodGet:
r.metricsSysInfo(w, req)
default:
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "endpoint not found"})
}
@@ -399,6 +419,147 @@ func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"})
}
// --- Metrics handlers ---
func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) {
if r.metricsStore == nil {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"labels": []int{}, "cpu": []float64{}, "memory": []float64{}, "temp": []float64{}, "load1": []float64{}}})
return
}
from, to := parseTimeRange(req)
resolution := parseResolution(req, 200)
samples, err := r.metricsStore.QuerySystemMetrics(from, to, resolution)
if err != nil {
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
return
}
// Flatten into arrays for Chart.js
labels := make([]int64, len(samples))
cpu := make([]float64, len(samples))
memory := make([]float64, len(samples))
temp := make([]float64, len(samples))
load1 := make([]float64, len(samples))
for i, s := range samples {
labels[i] = s.Timestamp
cpu[i] = s.CPUPercent
memory[i] = float64(s.MemUsedMB) / 1024.0 // Convert to GB
temp[i] = s.TempCelsius
load1[i] = s.LoadAvg1
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{
"labels": labels,
"cpu": cpu,
"memory": memory,
"temp": temp,
"load1": load1,
}})
}
func (r *Router) metricsContainerSummary(w http.ResponseWriter, _ *http.Request) {
if r.metricsStore == nil {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: []interface{}{}})
return
}
summary, err := r.metricsStore.QueryContainerSummary()
if err != nil {
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: summary})
}
func (r *Router) metricsContainer(w http.ResponseWriter, req *http.Request, name string) {
if r.metricsStore == nil {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"labels": []int{}, "cpu": []float64{}, "memory": []float64{}}})
return
}
from, to := parseTimeRange(req)
resolution := parseResolution(req, 150)
samples, err := r.metricsStore.QueryContainerMetrics(name, from, to, resolution)
if err != nil {
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
return
}
labels := make([]int64, len(samples))
cpu := make([]float64, len(samples))
memory := make([]float64, len(samples))
for i, s := range samples {
labels[i] = s.Timestamp
cpu[i] = s.CPUPercent
memory[i] = s.MemUsageMB
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{
"labels": labels,
"cpu": cpu,
"memory": memory,
}})
}
func (r *Router) metricsSysInfo(w http.ResponseWriter, _ *http.Request) {
info := metrics.GetStaticInfo()
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: info})
}
// parseTimeRange reads range or from/to query params.
func parseTimeRange(req *http.Request) (from, to time.Time) {
to = time.Now()
if rangeStr := req.URL.Query().Get("range"); rangeStr != "" {
switch rangeStr {
case "1h":
from = to.Add(-1 * time.Hour)
case "6h":
from = to.Add(-6 * time.Hour)
case "24h":
from = to.Add(-24 * time.Hour)
case "7d":
from = to.Add(-7 * 24 * time.Hour)
case "30d":
from = to.Add(-30 * 24 * time.Hour)
default:
from = to.Add(-24 * time.Hour) // default 24h
}
return
}
if fromStr := req.URL.Query().Get("from"); fromStr != "" {
if t, err := time.Parse(time.RFC3339, fromStr); err == nil {
from = t
}
}
if toStr := req.URL.Query().Get("to"); toStr != "" {
if t, err := time.Parse(time.RFC3339, toStr); err == nil {
to = t
}
}
if from.IsZero() {
from = to.Add(-24 * time.Hour)
}
return
}
// parseResolution reads the resolution query param.
func parseResolution(req *http.Request, defaultVal int) int {
if v := req.URL.Query().Get("resolution"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return n
}
}
return defaultVal
}
// --- Helpers ---
func hasSuffix(path, suffix string) bool { return strings.HasSuffix(path, suffix) }
+34
View File
@@ -439,6 +439,40 @@ func (m *Manager) GetFullStatus(nextDBDump, nextBackup time.Time) *FullBackupSta
for i, j := 0, len(m.cachedStatus.SnapshotHistory)-1; i < j; i, j = i+1, j-1 {
m.cachedStatus.SnapshotHistory[i], m.cachedStatus.SnapshotHistory[j] = m.cachedStatus.SnapshotHistory[j], m.cachedStatus.SnapshotHistory[i]
}
// Synthesize LastBackup from snapshot history if not in memory (e.g., after restart)
if m.cachedStatus.LastBackup == nil && len(m.cachedStatus.SnapshotHistory) > 0 {
latest := m.cachedStatus.SnapshotHistory[0] // already reversed, newest first
m.cachedStatus.LastBackup = &BackupStatus{
LastRun: latest.Time,
Success: latest.Success,
Snapshot: &SnapshotResult{
SnapshotID: latest.SnapshotID,
},
}
}
// Synthesize LastDBDump from DumpFiles on disk if not in memory
if m.cachedStatus.LastDBDump == nil && len(m.cachedStatus.DumpFiles) > 0 {
var results []DumpResult
var latestTime time.Time
for _, f := range m.cachedStatus.DumpFiles {
results = append(results, DumpResult{
DB: DiscoveredDB{StackName: f.StackName, DBType: f.DBType, ContainerName: f.StackName},
FilePath: f.FileName,
Size: f.Size,
})
if f.ModTime.After(latestTime) {
latestTime = f.ModTime
}
}
m.cachedStatus.LastDBDump = &DBDumpStatus{
LastRun: latestTime,
Results: results,
Success: true,
}
}
return m.cachedStatus
}
+249
View File
@@ -0,0 +1,249 @@
package metrics
import (
"context"
"fmt"
"log"
"os/exec"
"strconv"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)
// MetricsCollector periodically samples system and container metrics and stores them.
type MetricsCollector struct {
store *MetricsStore
cpuCollector *system.CPUCollector
hddPath string
logger *log.Logger
cancel context.CancelFunc
}
// NewMetricsCollector creates a new collector.
func NewMetricsCollector(store *MetricsStore, cpuCollector *system.CPUCollector, hddPath string, logger *log.Logger) *MetricsCollector {
return &MetricsCollector{
store: store,
cpuCollector: cpuCollector,
hddPath: hddPath,
logger: logger,
}
}
// Start begins the background collection loop (every 60 seconds).
func (c *MetricsCollector) Start(ctx context.Context) {
ctx, c.cancel = context.WithCancel(ctx)
go c.loop(ctx)
}
// Stop cancels the collection loop.
func (c *MetricsCollector) Stop() {
if c.cancel != nil {
c.cancel()
}
}
func (c *MetricsCollector) loop(ctx context.Context) {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
// Sample immediately on start
c.sample()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
c.sample()
}
}
}
func (c *MetricsCollector) sample() {
sys := c.sampleSystem()
if err := c.store.InsertSystemMetrics(sys); err != nil {
c.logger.Printf("[WARN] Failed to store system metrics: %v", err)
}
containers := c.sampleContainers()
if err := c.store.InsertContainerMetrics(containers); err != nil {
c.logger.Printf("[WARN] Failed to store container metrics: %v", err)
}
}
func (c *MetricsCollector) sampleSystem() SystemSample {
info := system.GetInfo(c.hddPath, c.cpuCollector)
return SystemSample{
Timestamp: time.Now().Unix(),
CPUPercent: info.CPUPercent,
MemUsedMB: int(info.UsedMemMB),
MemTotalMB: int(info.TotalMemMB),
TempCelsius: info.TemperatureCelsius,
LoadAvg1: info.LoadAvg1,
LoadAvg5: info.LoadAvg5,
LoadAvg15: info.LoadAvg15,
DiskUsedGB: info.DiskUsedGB,
DiskTotalGB: info.DiskTotalGB,
HDDUsedGB: info.HDDUsedGB,
HDDTotalGB: info.HDDTotalGB,
}
}
func (c *MetricsCollector) sampleContainers() []ContainerSample {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", "stats", "--no-stream",
"--format", "{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}")
out, err := cmd.Output()
if err != nil {
c.logger.Printf("[WARN] docker stats failed: %v", err)
return nil
}
now := time.Now().Unix()
var samples []ContainerSample
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
parts := strings.Split(line, "\t")
if len(parts) < 5 {
continue
}
name := parts[0]
cpuPct := parsePercent(parts[1])
memUsage, memLimit := parseMemUsage(parts[2])
netRx, netTx := parseIOPair(parts[3])
blkRead, blkWrite := parseIOPair(parts[4])
samples = append(samples, ContainerSample{
Timestamp: now,
ContainerName: name,
CPUPercent: cpuPct,
MemUsageMB: memUsage,
MemLimitMB: memLimit,
NetRxBytes: netRx,
NetTxBytes: netTx,
BlockReadBytes: blkRead,
BlockWriteBytes: blkWrite,
})
}
return samples
}
// parsePercent parses "2.50%" → 2.50
func parsePercent(s string) float64 {
s = strings.TrimSpace(s)
s = strings.TrimSuffix(s, "%")
v, _ := strconv.ParseFloat(s, 64)
return v
}
// parseMemUsage parses "150.5MiB / 512MiB" → (150.5, 512.0)
func parseMemUsage(s string) (usage, limit float64) {
parts := strings.Split(s, "/")
if len(parts) != 2 {
return 0, 0
}
usage = parseSizeToMB(strings.TrimSpace(parts[0]))
limit = parseSizeToMB(strings.TrimSpace(parts[1]))
return
}
// parseIOPair parses "1.5MB / 2.3MB" → (1500000, 2300000) as bytes
func parseIOPair(s string) (int64, int64) {
parts := strings.Split(s, "/")
if len(parts) != 2 {
return 0, 0
}
return parseSizeToBytes(strings.TrimSpace(parts[0])), parseSizeToBytes(strings.TrimSpace(parts[1]))
}
// parseSizeToMB parses "150.5MiB", "1.5GiB", "500kB" → value in MB
func parseSizeToMB(s string) float64 {
s = strings.TrimSpace(s)
multipliers := []struct {
suffix string
factor float64
}{
{"GiB", 1024},
{"MiB", 1},
{"KiB", 1.0 / 1024},
{"GB", 1000},
{"MB", 1},
{"KB", 1.0 / 1000},
{"kB", 1.0 / 1000},
{"B", 1.0 / (1024 * 1024)},
}
for _, m := range multipliers {
if strings.HasSuffix(s, m.suffix) {
numStr := strings.TrimSpace(strings.TrimSuffix(s, m.suffix))
val, err := strconv.ParseFloat(numStr, 64)
if err != nil {
return 0
}
return val * m.factor
}
}
// Fallback: try to parse as plain number
val, _ := strconv.ParseFloat(s, 64)
return val
}
// parseSizeToBytes parses "1.5MB", "500kB", "2.3GB" → bytes
func parseSizeToBytes(s string) int64 {
s = strings.TrimSpace(s)
multipliers := []struct {
suffix string
factor float64
}{
{"GiB", 1024 * 1024 * 1024},
{"MiB", 1024 * 1024},
{"KiB", 1024},
{"GB", 1e9},
{"MB", 1e6},
{"KB", 1e3},
{"kB", 1e3},
{"B", 1},
}
for _, m := range multipliers {
if strings.HasSuffix(s, m.suffix) {
numStr := strings.TrimSpace(strings.TrimSuffix(s, m.suffix))
val, err := strconv.ParseFloat(numStr, 64)
if err != nil {
return 0
}
return int64(val * m.factor)
}
}
val, _ := strconv.ParseFloat(s, 64)
return int64(val)
}
// FormatUptime formats seconds into "X nap, Y óra" style for Hungarian display.
func FormatUptime(seconds int64) string {
days := seconds / 86400
hours := (seconds % 86400) / 3600
minutes := (seconds % 3600) / 60
if days > 0 {
return fmt.Sprintf("%d nap, %d óra", days, hours)
}
if hours > 0 {
return fmt.Sprintf("%d óra, %d perc", hours, minutes)
}
return fmt.Sprintf("%d perc", minutes)
}
+322
View File
@@ -0,0 +1,322 @@
package metrics
import (
"database/sql"
"fmt"
"log"
"time"
_ "modernc.org/sqlite"
)
// MetricsStore manages SQLite storage for system and container metrics.
type MetricsStore struct {
db *sql.DB
logger *log.Logger
}
// NewMetricsStore opens (or creates) a SQLite database at dbPath and initializes the schema.
func NewMetricsStore(dbPath string, logger *log.Logger) (*MetricsStore, error) {
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
// Set pragmas for performance and concurrency
pragmas := []string{
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=NORMAL",
"PRAGMA busy_timeout=5000",
}
for _, p := range pragmas {
if _, err := db.Exec(p); err != nil {
db.Close()
return nil, fmt.Errorf("pragma %q: %w", p, err)
}
}
// Create tables
schema := []string{
`CREATE TABLE IF NOT EXISTS system_metrics (
ts INTEGER NOT NULL,
cpu_percent REAL NOT NULL,
mem_used_mb INTEGER NOT NULL,
mem_total_mb INTEGER NOT NULL,
temp_celsius REAL,
load_avg_1 REAL,
load_avg_5 REAL,
load_avg_15 REAL,
disk_used_gb REAL,
disk_total_gb REAL,
hdd_used_gb REAL,
hdd_total_gb REAL
)`,
`CREATE INDEX IF NOT EXISTS idx_system_ts ON system_metrics(ts)`,
`CREATE TABLE IF NOT EXISTS container_metrics (
ts INTEGER NOT NULL,
container_name TEXT NOT NULL,
cpu_percent REAL NOT NULL,
mem_usage_mb REAL NOT NULL,
mem_limit_mb REAL,
net_rx_bytes INTEGER,
net_tx_bytes INTEGER,
block_read_bytes INTEGER,
block_write_bytes INTEGER
)`,
`CREATE INDEX IF NOT EXISTS idx_container_ts ON container_metrics(ts)`,
`CREATE INDEX IF NOT EXISTS idx_container_name ON container_metrics(container_name, ts)`,
}
for _, s := range schema {
if _, err := db.Exec(s); err != nil {
db.Close()
return nil, fmt.Errorf("schema: %w", err)
}
}
return &MetricsStore{db: db, logger: logger}, nil
}
// Close closes the underlying database connection.
func (s *MetricsStore) Close() error {
return s.db.Close()
}
// InsertSystemMetrics inserts a single system metrics sample.
func (s *MetricsStore) InsertSystemMetrics(m SystemSample) error {
_, err := s.db.Exec(
`INSERT INTO system_metrics (ts, cpu_percent, mem_used_mb, mem_total_mb, temp_celsius,
load_avg_1, load_avg_5, load_avg_15, disk_used_gb, disk_total_gb, hdd_used_gb, hdd_total_gb)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
m.Timestamp, m.CPUPercent, m.MemUsedMB, m.MemTotalMB, m.TempCelsius,
m.LoadAvg1, m.LoadAvg5, m.LoadAvg15, m.DiskUsedGB, m.DiskTotalGB, m.HDDUsedGB, m.HDDTotalGB,
)
return err
}
// InsertContainerMetrics inserts a batch of container metrics samples.
func (s *MetricsStore) InsertContainerMetrics(samples []ContainerSample) error {
if len(samples) == 0 {
return nil
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.Prepare(
`INSERT INTO container_metrics (ts, container_name, cpu_percent, mem_usage_mb, mem_limit_mb,
net_rx_bytes, net_tx_bytes, block_read_bytes, block_write_bytes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
if err != nil {
return err
}
defer stmt.Close()
for _, c := range samples {
if _, err := stmt.Exec(c.Timestamp, c.ContainerName, c.CPUPercent, c.MemUsageMB, c.MemLimitMB,
c.NetRxBytes, c.NetTxBytes, c.BlockReadBytes, c.BlockWriteBytes); err != nil {
return err
}
}
return tx.Commit()
}
// QuerySystemMetrics returns downsampled system metrics between from and to.
// resolution controls the approximate number of data points returned.
func (s *MetricsStore) QuerySystemMetrics(from, to time.Time, resolution int) ([]SystemSample, error) {
fromTS := from.Unix()
toTS := to.Unix()
if resolution <= 0 {
resolution = 200
}
rangeSeconds := toTS - fromTS
if rangeSeconds <= 0 {
return nil, nil
}
bucketSeconds := rangeSeconds / int64(resolution)
if bucketSeconds < 1 {
bucketSeconds = 1
}
rows, err := s.db.Query(`
SELECT
(ts / ?) * ? AS bucket_ts,
AVG(cpu_percent),
AVG(mem_used_mb),
AVG(mem_total_mb),
AVG(temp_celsius),
AVG(load_avg_1),
AVG(load_avg_5),
AVG(load_avg_15),
AVG(disk_used_gb),
AVG(disk_total_gb),
AVG(hdd_used_gb),
AVG(hdd_total_gb)
FROM system_metrics
WHERE ts >= ? AND ts <= ?
GROUP BY ts / ?
ORDER BY bucket_ts ASC`,
bucketSeconds, bucketSeconds, fromTS, toTS, bucketSeconds,
)
if err != nil {
return nil, err
}
defer rows.Close()
var result []SystemSample
for rows.Next() {
var m SystemSample
var tempC, load1, load5, load15, diskUsed, diskTotal, hddUsed, hddTotal sql.NullFloat64
if err := rows.Scan(&m.Timestamp, &m.CPUPercent, &m.MemUsedMB, &m.MemTotalMB,
&tempC, &load1, &load5, &load15, &diskUsed, &diskTotal, &hddUsed, &hddTotal); err != nil {
return nil, err
}
if tempC.Valid {
m.TempCelsius = tempC.Float64
}
if load1.Valid {
m.LoadAvg1 = load1.Float64
}
if load5.Valid {
m.LoadAvg5 = load5.Float64
}
if load15.Valid {
m.LoadAvg15 = load15.Float64
}
if diskUsed.Valid {
m.DiskUsedGB = diskUsed.Float64
}
if diskTotal.Valid {
m.DiskTotalGB = diskTotal.Float64
}
if hddUsed.Valid {
m.HDDUsedGB = hddUsed.Float64
}
if hddTotal.Valid {
m.HDDTotalGB = hddTotal.Float64
}
result = append(result, m)
}
return result, rows.Err()
}
// QueryContainerMetrics returns downsampled metrics for a specific container.
func (s *MetricsStore) QueryContainerMetrics(name string, from, to time.Time, resolution int) ([]ContainerSample, error) {
fromTS := from.Unix()
toTS := to.Unix()
if resolution <= 0 {
resolution = 200
}
rangeSeconds := toTS - fromTS
if rangeSeconds <= 0 {
return nil, nil
}
bucketSeconds := rangeSeconds / int64(resolution)
if bucketSeconds < 1 {
bucketSeconds = 1
}
rows, err := s.db.Query(`
SELECT
(ts / ?) * ? AS bucket_ts,
container_name,
AVG(cpu_percent),
AVG(mem_usage_mb),
AVG(mem_limit_mb),
AVG(net_rx_bytes),
AVG(net_tx_bytes),
AVG(block_read_bytes),
AVG(block_write_bytes)
FROM container_metrics
WHERE container_name = ? AND ts >= ? AND ts <= ?
GROUP BY ts / ?
ORDER BY bucket_ts ASC`,
bucketSeconds, bucketSeconds, name, fromTS, toTS, bucketSeconds,
)
if err != nil {
return nil, err
}
defer rows.Close()
var result []ContainerSample
for rows.Next() {
var c ContainerSample
var memLimit, netRx, netTx, blkRead, blkWrite sql.NullFloat64
if err := rows.Scan(&c.Timestamp, &c.ContainerName, &c.CPUPercent, &c.MemUsageMB,
&memLimit, &netRx, &netTx, &blkRead, &blkWrite); err != nil {
return nil, err
}
if memLimit.Valid {
c.MemLimitMB = memLimit.Float64
}
if netRx.Valid {
c.NetRxBytes = int64(netRx.Float64)
}
if netTx.Valid {
c.NetTxBytes = int64(netTx.Float64)
}
if blkRead.Valid {
c.BlockReadBytes = int64(blkRead.Float64)
}
if blkWrite.Valid {
c.BlockWriteBytes = int64(blkWrite.Float64)
}
result = append(result, c)
}
return result, rows.Err()
}
// QueryContainerSummary returns the latest metrics for all containers.
func (s *MetricsStore) QueryContainerSummary() ([]ContainerCurrentStats, error) {
rows, err := s.db.Query(`
SELECT container_name, cpu_percent, mem_usage_mb, COALESCE(mem_limit_mb, 0)
FROM container_metrics
WHERE ts = (SELECT MAX(ts) FROM container_metrics)
ORDER BY cpu_percent DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var result []ContainerCurrentStats
for rows.Next() {
var c ContainerCurrentStats
if err := rows.Scan(&c.ContainerName, &c.CPUPercent, &c.MemUsageMB, &c.MemLimitMB); err != nil {
return nil, err
}
result = append(result, c)
}
return result, rows.Err()
}
// Prune deletes rows older than the given duration. Returns the number of deleted rows.
func (s *MetricsStore) Prune(olderThan time.Duration) (int64, error) {
cutoff := time.Now().Add(-olderThan).Unix()
var total int64
res, err := s.db.Exec("DELETE FROM system_metrics WHERE ts < ?", cutoff)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
total += n
res, err = s.db.Exec("DELETE FROM container_metrics WHERE ts < ?", cutoff)
if err != nil {
return total, err
}
n, _ = res.RowsAffected()
total += n
return total, nil
}
+110
View File
@@ -0,0 +1,110 @@
//go:build linux
package metrics
import (
"bufio"
"fmt"
"os"
"runtime"
"strings"
"time"
)
// GetStaticInfo reads host-level static system information.
// Reads from /proc and /etc (which reflect the host in Docker containers).
func GetStaticInfo() StaticSystemInfo {
info := StaticSystemInfo{}
// Hostname
info.Hostname, _ = os.Hostname()
// OS — try host mount first, fall back to container's
info.OS = readOSRelease("/host/etc/os-release")
if info.OS == "" {
info.OS = readOSRelease("/etc/os-release")
}
// Kernel version
if data, err := os.ReadFile("/proc/sys/kernel/osrelease"); err == nil {
info.Kernel = strings.TrimSpace(string(data))
}
// Architecture
if data, err := os.ReadFile("/proc/sys/kernel/arch"); err == nil {
info.Architecture = strings.TrimSpace(string(data))
}
if info.Architecture == "" {
// Fallback: use uname -m equivalent from /proc/cpuinfo or runtime
info.Architecture = runtime.GOARCH
// Try to get the actual host arch
if data, err := os.ReadFile("/proc/version"); err == nil {
v := string(data)
if strings.Contains(v, "x86_64") {
info.Architecture = "x86_64"
} else if strings.Contains(v, "aarch64") {
info.Architecture = "aarch64"
}
}
}
// CPU model and cores
info.CPUModel, info.CPUCores = readCPUInfo()
if info.CPUCores == 0 {
info.CPUCores = runtime.NumCPU()
}
// Uptime
if data, err := os.ReadFile("/proc/uptime"); err == nil {
var uptimeSec float64
if _, err := fmt.Sscanf(string(data), "%f", &uptimeSec); err == nil {
info.UptimeSeconds = int64(uptimeSec)
info.BootTime = time.Now().Add(-time.Duration(info.UptimeSeconds) * time.Second)
}
}
return info
}
// readOSRelease reads PRETTY_NAME from an os-release file.
func readOSRelease(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "PRETTY_NAME=") {
val := strings.TrimPrefix(line, "PRETTY_NAME=")
val = strings.Trim(val, `"`)
return val
}
}
return ""
}
// readCPUInfo reads the CPU model name and number of cores from /proc/cpuinfo.
func readCPUInfo() (model string, cores int) {
f, err := os.Open("/proc/cpuinfo")
if err != nil {
return "", 0
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "model name") {
if idx := strings.Index(line, ":"); idx >= 0 {
model = strings.TrimSpace(line[idx+1:])
}
}
if strings.HasPrefix(line, "processor") {
cores++
}
}
return model, cores
}
@@ -0,0 +1,19 @@
//go:build !linux
package metrics
import (
"os"
"runtime"
)
// GetStaticInfo returns placeholder system info on non-Linux platforms.
func GetStaticInfo() StaticSystemInfo {
hostname, _ := os.Hostname()
return StaticSystemInfo{
Hostname: hostname,
OS: runtime.GOOS,
Architecture: runtime.GOARCH,
CPUCores: runtime.NumCPU(),
}
}
+52
View File
@@ -0,0 +1,52 @@
package metrics
import "time"
// SystemSample holds one system-wide metrics snapshot.
type SystemSample struct {
Timestamp int64 `json:"ts"`
CPUPercent float64 `json:"cpu"`
MemUsedMB int `json:"mem_used"`
MemTotalMB int `json:"mem_total"`
TempCelsius float64 `json:"temp"`
LoadAvg1 float64 `json:"load1"`
LoadAvg5 float64 `json:"load5"`
LoadAvg15 float64 `json:"load15"`
DiskUsedGB float64 `json:"disk_used"`
DiskTotalGB float64 `json:"disk_total"`
HDDUsedGB float64 `json:"hdd_used"`
HDDTotalGB float64 `json:"hdd_total"`
}
// ContainerSample holds one per-container metrics snapshot.
type ContainerSample struct {
Timestamp int64 `json:"ts"`
ContainerName string `json:"name"`
CPUPercent float64 `json:"cpu"`
MemUsageMB float64 `json:"mem_usage"`
MemLimitMB float64 `json:"mem_limit"`
NetRxBytes int64 `json:"net_rx"`
NetTxBytes int64 `json:"net_tx"`
BlockReadBytes int64 `json:"blk_read"`
BlockWriteBytes int64 `json:"blk_write"`
}
// ContainerCurrentStats holds the latest snapshot for a single container.
type ContainerCurrentStats struct {
ContainerName string `json:"name"`
CPUPercent float64 `json:"cpu_percent"`
MemUsageMB float64 `json:"mem_usage_mb"`
MemLimitMB float64 `json:"mem_limit_mb"`
}
// StaticSystemInfo holds static (or slowly-changing) host information.
type StaticSystemInfo struct {
Hostname string `json:"hostname"`
OS string `json:"os"`
Kernel string `json:"kernel"`
Architecture string `json:"architecture"`
CPUModel string `json:"cpu_model"`
CPUCores int `json:"cpu_cores"`
UptimeSeconds int64 `json:"uptime_seconds"`
BootTime time.Time `json:"boot_time"`
}
+3
View File
@@ -4,3 +4,6 @@ import "embed"
//go:embed templates/*.html templates/*.css
var templateFS embed.FS
//go:embed static/chart.min.js
var chartJS []byte
+6
View File
@@ -191,6 +191,12 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, _ *http.Request, slug s
s.render(w, "app_info", data)
}
func (s *Server) monitoringHandler(w http.ResponseWriter, _ *http.Request) {
data := s.baseData("monitoring", "Rendszermonitor")
data["SystemInfo"] = system.GetInfo(s.cfg.Paths.HDDPath, s.cpuCollector)
s.render(w, "monitoring", data)
}
func (s *Server) backupsHandler(w http.ResponseWriter, _ *http.Request) {
data := s.baseData("backups", "Biztonsági mentés")
+10
View File
@@ -64,6 +64,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.stacksHandler(w, r)
case path == "/backups":
s.backupsHandler(w, r)
case path == "/monitoring":
s.monitoringHandler(w, r)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/logs"):
name := strings.TrimPrefix(path, "/stacks/")
name = strings.TrimSuffix(name, "/logs")
@@ -74,6 +76,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.deployHandler(w, r, name)
case path == "/static/style.css":
s.serveCSSHandler(w, r)
case path == "/static/chart.min.js":
s.serveChartJSHandler(w, r)
case path == "/static/felhom-logo.svg":
s.serveLogoHandler(w, r)
case strings.HasPrefix(path, "/static/assets/"):
@@ -107,6 +111,12 @@ func (s *Server) serveCSSHandler(w http.ResponseWriter, r *http.Request) {
w.Write(data)
}
func (s *Server) serveChartJSHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Write(chartJS)
}
func (s *Server) serveLogoHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "public, max-age=86400")
File diff suppressed because one or more lines are too long
@@ -17,6 +17,7 @@
<li><a href="/" class="{{if eq .Page "dashboard"}}active{{end}}">Vezérlőpult</a></li>
<li><a href="/stacks" class="{{if eq .Page "stacks"}}active{{end}}">Alkalmazások</a></li>
<li><a href="/backups" class="{{if eq .Page "backups"}}active{{end}}">Biztonsági mentés</a></li>
<li><a href="/monitoring" class="{{if eq .Page "monitoring"}}active{{end}}">Rendszermonitor</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">v{{.Version}}</span>
@@ -0,0 +1,478 @@
{{define "monitoring"}}
{{template "layout_start" .}}
<div class="page-header">
<h2>Rendszermonitor</h2>
</div>
<!-- Section 1: System Overview -->
<div class="monitor-card">
<h3>Rendszer áttekintés</h3>
<div class="sysinfo-grid">
<div class="sysinfo-row">
<span class="sysinfo-label">Gépnév</span>
<span class="sysinfo-value" id="sysinfo-hostname"></span>
</div>
<div class="sysinfo-row">
<span class="sysinfo-label">Operációs rendszer</span>
<span class="sysinfo-value" id="sysinfo-os"></span>
</div>
<div class="sysinfo-row">
<span class="sysinfo-label">Kernel</span>
<span class="sysinfo-value" id="sysinfo-kernel"></span>
</div>
<div class="sysinfo-row">
<span class="sysinfo-label">Processzor</span>
<span class="sysinfo-value" id="sysinfo-cpu"></span>
</div>
<div class="sysinfo-row">
<span class="sysinfo-label">Üzemidő</span>
<span class="sysinfo-value" id="sysinfo-uptime"></span>
</div>
<div class="sysinfo-row">
<span class="sysinfo-label">Indítás</span>
<span class="sysinfo-value" id="sysinfo-boot"></span>
</div>
</div>
</div>
<!-- Section 2: System Metrics Charts -->
<div class="monitor-card">
<div class="monitor-card-header">
<h3>Rendszer metrikák</h3>
<div class="time-range-bar" id="system-range-bar">
<button class="filter-btn" data-range="1h">1 óra</button>
<button class="filter-btn" data-range="6h">6 óra</button>
<button class="filter-btn active" data-range="24h">24 óra</button>
<button class="filter-btn" data-range="7d">7 nap</button>
<button class="filter-btn" data-range="30d">30 nap</button>
</div>
</div>
<div class="charts-grid" id="system-charts">
<div class="chart-box">
<div class="chart-title">CPU használat (%)</div>
<div class="chart-wrap"><canvas id="chart-cpu"></canvas></div>
</div>
<div class="chart-box">
<div class="chart-title">Memória használat (GB)</div>
<div class="chart-wrap"><canvas id="chart-memory"></canvas></div>
</div>
<div class="chart-box">
<div class="chart-title">Hőmérséklet (°C)</div>
<div class="chart-wrap"><canvas id="chart-temp"></canvas></div>
</div>
<div class="chart-box">
<div class="chart-title">Terhelés (Load Average)</div>
<div class="chart-wrap"><canvas id="chart-load"></canvas></div>
</div>
</div>
<div class="chart-empty" id="system-charts-empty" style="display:none">
Még nincsenek adatok. A metrikák gyűjtése elindult, az első adatok néhány perc múlva megjelennek.
</div>
</div>
<!-- Section 3: Container Resources -->
<div class="monitor-card">
<h3>Alkalmazás erőforrások</h3>
<div class="container-charts-row" id="container-charts">
<div class="chart-box chart-box-half">
<div class="chart-title">CPU használat (%)</div>
<div class="chart-wrap chart-wrap-bar"><canvas id="chart-container-cpu"></canvas></div>
</div>
<div class="chart-box chart-box-half">
<div class="chart-title">Memória használat (MB)</div>
<div class="chart-wrap chart-wrap-bar"><canvas id="chart-container-mem"></canvas></div>
</div>
</div>
<div class="chart-empty" id="container-charts-empty" style="display:none">
Még nincsenek konténer adatok.
</div>
</div>
<!-- Section 4: Per-container detail (expandable) -->
<div class="monitor-card" id="container-detail-panel" style="display:none">
<div class="monitor-card-header">
<h3 id="container-detail-title"></h3>
<div class="time-range-bar" id="container-range-bar">
<button class="filter-btn" data-range="1h">1 óra</button>
<button class="filter-btn" data-range="6h">6 óra</button>
<button class="filter-btn active" data-range="24h">24 óra</button>
<button class="filter-btn" data-range="7d">7 nap</button>
</div>
<button class="btn btn-outline btn-sm" onclick="closeContainerDetail()">Bezárás</button>
</div>
<div class="charts-grid charts-grid-2">
<div class="chart-box">
<div class="chart-title">CPU %</div>
<div class="chart-wrap"><canvas id="chart-detail-cpu"></canvas></div>
</div>
<div class="chart-box">
<div class="chart-title">Memória (MB)</div>
<div class="chart-wrap"><canvas id="chart-detail-mem"></canvas></div>
</div>
</div>
</div>
<!-- Section 5: Storage -->
<div class="monitor-card">
<h3>Tárhely</h3>
<div class="storage-bars">
{{with .SystemInfo}}
<div class="storage-item">
<div class="storage-header">
<span class="storage-label">SSD (/)</span>
<span class="storage-value">{{fmtGB .DiskUsedGB}} / {{fmtGB .DiskTotalGB}} ({{printf "%.0f" .DiskPercent}}%)</span>
</div>
<div class="system-bar">
<div class="system-bar-fill {{usageColor .DiskPercent | printf "system-bar-%s"}}" style="width:{{printf "%.1f" .DiskPercent}}%"></div>
</div>
</div>
{{if .HDDConfigured}}
<div class="storage-item">
<div class="storage-header">
<span class="storage-label">Külső HDD</span>
<span class="storage-value">{{fmtGB .HDDUsedGB}} / {{fmtGB .HDDTotalGB}} ({{printf "%.0f" .HDDPercent}}%)</span>
</div>
<div class="system-bar">
<div class="system-bar-fill {{usageColor .HDDPercent | printf "system-bar-%s"}}" style="width:{{printf "%.1f" .HDDPercent}}%"></div>
</div>
</div>
{{end}}
{{end}}
</div>
</div>
<script src="/static/chart.min.js"></script>
<script>
(function() {
// --- Chart.js dark theme defaults ---
const colors = {
cpu: {border: '#0088cc', bg: 'rgba(0,136,204,0.1)'},
memory: {border: '#238636', bg: 'rgba(35,134,54,0.1)'},
temp: {border: '#d29922', bg: 'rgba(210,153,34,0.1)'},
load: {border: '#db6d28', bg: 'rgba(219,109,40,0.1)'},
};
const chartOpts = (yLabel, beginAtZero) => ({
responsive: true,
maintainAspectRatio: false,
animation: {duration: 300},
plugins: {
legend: {display: false},
tooltip: {
backgroundColor: '#1c2128',
titleColor: '#e6edf3',
bodyColor: '#8b949e',
borderColor: '#30363d',
borderWidth: 1,
callbacks: {
title: function(items) {
if (!items.length) return '';
return formatTimestamp(items[0].parsed.x || items[0].label);
}
}
}
},
scales: {
x: {
grid: {color: 'rgba(48,54,61,0.5)'},
ticks: {color: '#8b949e', maxTicksLimit: 8, callback: function(v) { return formatTimeLabel(this.getLabelForValue(v)); }}
},
y: {
grid: {color: 'rgba(48,54,61,0.5)'},
ticks: {color: '#8b949e'},
beginAtZero: beginAtZero !== false,
title: {display: !!yLabel, text: yLabel || '', color: '#6e7681', font: {size: 11}}
}
}
});
const lineDataset = (color) => ({
borderColor: color.border,
backgroundColor: color.bg,
borderWidth: 2,
pointRadius: 0,
pointHitRadius: 10,
tension: 0.3,
fill: true,
spanGaps: true
});
// --- Timezone formatting ---
const budaTZ = 'Europe/Budapest';
function formatTimestamp(ts) {
if (!ts) return '';
const d = new Date(typeof ts === 'number' && ts < 1e12 ? ts * 1000 : ts);
return d.toLocaleString('hu-HU', {timeZone: budaTZ, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'});
}
function formatTimeLabel(ts) {
if (!ts) return '';
const d = new Date(typeof ts === 'number' && ts < 1e12 ? ts * 1000 : ts);
return d.toLocaleTimeString('hu-HU', {timeZone: budaTZ, hour: '2-digit', minute: '2-digit'});
}
// --- System charts ---
let systemRange = '24h';
let chartCPU, chartMem, chartTemp, chartLoad;
function initSystemCharts() {
const mkChart = (id, color, yLabel, beginAtZero) => {
return new Chart(document.getElementById(id), {
type: 'line',
data: {labels: [], datasets: [{data: [], ...lineDataset(color)}]},
options: chartOpts(yLabel, beginAtZero)
});
};
chartCPU = mkChart('chart-cpu', colors.cpu, '%', true);
chartMem = mkChart('chart-memory', colors.memory, 'GB', true);
chartTemp = mkChart('chart-temp', colors.temp, '°C', false);
chartLoad = mkChart('chart-load', colors.load, '', true);
}
async function loadSystemMetrics() {
try {
const resp = await fetch('/api/metrics/system?range=' + systemRange + '&resolution=200');
const json = await resp.json();
if (!json.ok || !json.data) return;
const d = json.data;
if (!d.labels || d.labels.length === 0) {
document.getElementById('system-charts').style.display = 'none';
document.getElementById('system-charts-empty').style.display = 'block';
return;
}
document.getElementById('system-charts').style.display = '';
document.getElementById('system-charts-empty').style.display = 'none';
const labels = d.labels.map(ts => ts * 1000); // ms for Date
function updateChart(chart, labels, data) {
chart.data.labels = labels;
chart.data.datasets[0].data = data;
chart.update('none');
}
updateChart(chartCPU, labels, d.cpu);
updateChart(chartMem, labels, d.memory);
updateChart(chartTemp, labels, d.temp);
updateChart(chartLoad, labels, d.load1);
} catch(e) {
console.error('Failed to load system metrics:', e);
}
}
// Range bar clicks
document.getElementById('system-range-bar').addEventListener('click', function(e) {
const btn = e.target.closest('.filter-btn');
if (!btn) return;
this.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
systemRange = btn.dataset.range;
loadSystemMetrics();
});
// --- Container bar charts ---
let chartContainerCPU, chartContainerMem;
let containerNames = [];
function initContainerCharts() {
const barOpts = (xLabel) => ({
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
animation: {duration: 300},
plugins: {
legend: {display: false},
tooltip: {
backgroundColor: '#1c2128',
titleColor: '#e6edf3',
bodyColor: '#8b949e',
borderColor: '#30363d',
borderWidth: 1
}
},
scales: {
x: {
grid: {color: 'rgba(48,54,61,0.5)'},
ticks: {color: '#8b949e'},
beginAtZero: true,
title: {display: true, text: xLabel, color: '#6e7681', font: {size: 11}}
},
y: {
grid: {display: false},
ticks: {color: '#8b949e', font: {size: 12}}
}
},
onClick: function(evt, elements) {
if (elements.length > 0) {
const idx = elements[0].index;
if (containerNames[idx]) {
showContainerDetail(containerNames[idx]);
}
}
}
});
chartContainerCPU = new Chart(document.getElementById('chart-container-cpu'), {
type: 'bar',
data: {labels: [], datasets: [{data: [], backgroundColor: '#0088cc', borderRadius: 4}]},
options: barOpts('%')
});
chartContainerMem = new Chart(document.getElementById('chart-container-mem'), {
type: 'bar',
data: {labels: [], datasets: [{data: [], backgroundColor: '#238636', borderRadius: 4}]},
options: barOpts('MB')
});
}
async function loadContainerSummary() {
try {
const resp = await fetch('/api/metrics/containers/summary');
const json = await resp.json();
if (!json.ok || !json.data) return;
const data = json.data;
if (!data.length) {
document.getElementById('container-charts').style.display = 'none';
document.getElementById('container-charts-empty').style.display = 'block';
return;
}
document.getElementById('container-charts').style.display = '';
document.getElementById('container-charts-empty').style.display = 'none';
containerNames = data.map(c => c.name);
const cpuData = data.map(c => Math.round(c.cpu_percent * 100) / 100);
const memData = data.map(c => Math.round(c.mem_usage_mb));
// Adjust bar chart height based on container count
const h = Math.max(200, data.length * 35 + 60);
document.querySelectorAll('.chart-wrap-bar').forEach(el => el.style.height = h + 'px');
chartContainerCPU.data.labels = containerNames;
chartContainerCPU.data.datasets[0].data = cpuData;
chartContainerCPU.update('none');
chartContainerMem.data.labels = containerNames;
chartContainerMem.data.datasets[0].data = memData;
chartContainerMem.update('none');
} catch(e) {
console.error('Failed to load container summary:', e);
}
}
// --- Container detail ---
let detailChartCPU, detailChartMem;
let detailContainer = '';
let detailRange = '24h';
function initDetailCharts() {
const mkChart = (id, color, yLabel) => {
return new Chart(document.getElementById(id), {
type: 'line',
data: {labels: [], datasets: [{data: [], ...lineDataset(color)}]},
options: chartOpts(yLabel, true)
});
};
detailChartCPU = mkChart('chart-detail-cpu', colors.cpu, '%');
detailChartMem = mkChart('chart-detail-mem', colors.memory, 'MB');
}
window.showContainerDetail = async function(name) {
detailContainer = name;
document.getElementById('container-detail-title').textContent = name + ' — Erőforrás előzmények';
document.getElementById('container-detail-panel').style.display = '';
document.getElementById('container-detail-panel').scrollIntoView({behavior: 'smooth'});
await loadContainerDetail();
};
window.closeContainerDetail = function() {
document.getElementById('container-detail-panel').style.display = 'none';
detailContainer = '';
};
async function loadContainerDetail() {
if (!detailContainer) return;
try {
const resp = await fetch('/api/metrics/containers/' + encodeURIComponent(detailContainer) + '?range=' + detailRange + '&resolution=150');
const json = await resp.json();
if (!json.ok || !json.data) return;
const d = json.data;
const labels = (d.labels || []).map(ts => ts * 1000);
detailChartCPU.data.labels = labels;
detailChartCPU.data.datasets[0].data = d.cpu || [];
detailChartCPU.update('none');
detailChartMem.data.labels = labels;
detailChartMem.data.datasets[0].data = d.memory || [];
detailChartMem.update('none');
} catch(e) {
console.error('Failed to load container detail:', e);
}
}
document.getElementById('container-range-bar').addEventListener('click', function(e) {
const btn = e.target.closest('.filter-btn');
if (!btn) return;
this.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
detailRange = btn.dataset.range;
loadContainerDetail();
});
// --- Static system info ---
async function loadSysInfo() {
try {
const resp = await fetch('/api/metrics/sysinfo');
const json = await resp.json();
if (!json.ok || !json.data) return;
const d = json.data;
document.getElementById('sysinfo-hostname').textContent = d.hostname || '';
document.getElementById('sysinfo-os').textContent = d.os || '';
document.getElementById('sysinfo-kernel').textContent = d.kernel || '';
let cpuText = d.cpu_model || '';
if (d.cpu_cores > 0) cpuText += ' (' + d.cpu_cores + ' mag)';
document.getElementById('sysinfo-cpu').textContent = cpuText;
if (d.uptime_seconds > 0) {
document.getElementById('sysinfo-uptime').textContent = formatUptime(d.uptime_seconds);
}
if (d.boot_time) {
const bt = new Date(d.boot_time);
document.getElementById('sysinfo-boot').textContent = bt.toLocaleString('hu-HU', {timeZone: budaTZ, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'});
}
} catch(e) {
console.error('Failed to load sysinfo:', e);
}
}
function formatUptime(seconds) {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return days + ' nap, ' + hours + ' óra';
if (hours > 0) return hours + ' óra, ' + minutes + ' perc';
return minutes + ' perc';
}
// --- Init ---
initSystemCharts();
initContainerCharts();
initDetailCharts();
loadSysInfo();
loadSystemMetrics();
loadContainerSummary();
// Auto-refresh every 60 seconds
setInterval(function() {
loadSystemMetrics();
loadContainerSummary();
if (detailContainer) loadContainerDetail();
}, 60000);
})();
</script>
{{template "layout_end" .}}
{{end}}
+122
View File
@@ -1477,6 +1477,125 @@ a.stat-card:hover {
font-size: .8rem;
}
/* --- Monitoring page --- */
.monitor-card {
background: var(--bg-card);
border-radius: var(--radius);
padding: 1.25rem;
border: 1px solid var(--border-color);
margin-bottom: 1.5rem;
}
.monitor-card h3 {
margin-bottom: .75rem;
}
.monitor-card-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.monitor-card-header h3 {
margin-bottom: 0;
flex: 1;
min-width: 150px;
}
.time-range-bar {
display: flex;
gap: .35rem;
}
.sysinfo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: .5rem;
}
.sysinfo-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: .35rem .5rem;
border-bottom: 1px solid rgba(48, 54, 61, 0.3);
font-size: .9rem;
}
.sysinfo-row:last-child { border-bottom: none; }
.sysinfo-label {
color: var(--text-secondary);
font-weight: 500;
}
.sysinfo-value {
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
font-size: .85rem;
}
.charts-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.charts-grid-2 {
grid-template-columns: 1fr 1fr;
}
.chart-box {
background: var(--bg-secondary);
border-radius: 8px;
padding: .75rem;
border: 1px solid rgba(48, 54, 61, 0.5);
}
.chart-box-half {
flex: 1;
min-width: 0;
}
.chart-title {
font-size: .8rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: .5rem;
text-transform: uppercase;
letter-spacing: .3px;
}
.chart-wrap {
position: relative;
height: 180px;
}
.chart-wrap-bar {
position: relative;
height: 250px;
}
.container-charts-row {
display: flex;
gap: 1rem;
}
.chart-empty {
text-align: center;
padding: 2rem;
color: var(--text-muted);
font-size: .9rem;
}
.storage-bars {
display: flex;
flex-direction: column;
gap: 1rem;
}
.storage-item {
padding: .25rem 0;
}
.storage-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: .4rem;
}
.storage-label {
font-size: .85rem;
font-weight: 600;
color: var(--text-secondary);
}
.storage-value {
font-size: .8rem;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
/* Responsive */
@media(max-width: 768px) {
.sidebar { width: 100%; height: auto; position: relative; border-right: none; border-bottom: 1px solid var(--border-color); }
@@ -1491,4 +1610,7 @@ a.stat-card:hover {
.stats-grid { grid-template-columns: repeat(3, 1fr); }
.deploy-info { flex-direction: column; }
.system-info-items { flex-direction: column; gap: 1rem; }
.charts-grid { grid-template-columns: 1fr; }
.container-charts-row { flex-direction: column; }
.sysinfo-grid { grid-template-columns: 1fr; }
}