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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
+20
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}}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user