Files
deploy-felhom-compose/controller/internal/metrics/collector.go
T
admin af1dd14933 fix: standardize log prefixes, remove duplicates, add missing module tags
Second-pass logging cleanup: consistent [LEVEL] [module] format across
all 41 files. Remove stale prefixes ([CF], [SYNC], [SCHED], [API],
[STORAGE], [HEALTH], [ROLLBACK]). Remove 5 duplicate log lines. Gate
ungated DEBUG lines. Fix wrong log levels (restore start WARN→INFO).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:20:09 +01:00

255 lines
6.0 KiB
Go

package metrics
import (
"context"
"fmt"
"log"
"os/exec"
"strconv"
"strings"
"sync"
"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
startOnce sync.Once
}
// 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).
// Safe to call multiple times — only the first call starts the loop.
func (c *MetricsCollector) Start(ctx context.Context) {
c.startOnce.Do(func() {
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.sampleWith(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
c.sampleWith(ctx)
}
}
}
func (c *MetricsCollector) sampleWith(ctx context.Context) {
sys := c.sampleSystem()
if err := c.store.InsertSystemMetrics(sys); err != nil {
c.logger.Printf("[WARN] [metrics] Failed to store system metrics: %v", err)
}
containers := c.sampleContainers(ctx)
if err := c.store.InsertContainerMetrics(containers); err != nil {
c.logger.Printf("[WARN] [metrics] 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(parentCtx context.Context) []ContainerSample {
ctx, cancel := context.WithTimeout(parentCtx, 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] [metrics] 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)
}