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) }