af1dd14933
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>
255 lines
6.0 KiB
Go
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)
|
|
}
|