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