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:
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user