// Package statcollector provides system information collection package statcollector import ( "log/slog" "os" "os/exec" "regexp" "strconv" "strings" "time" "git.eeqj.de/sneak/hdmistat/internal/netmon" "github.com/dustin/go-humanize" "github.com/shirou/gopsutil/v3/cpu" "github.com/shirou/gopsutil/v3/disk" "github.com/shirou/gopsutil/v3/host" "github.com/shirou/gopsutil/v3/mem" psnet "github.com/shirou/gopsutil/v3/net" "github.com/shirou/gopsutil/v3/process" ) const ( // Process collection constants maxProcesses = 100 processTimeout = 50 * time.Millisecond processStableTime = 100 * time.Millisecond msToSecondsDivisor = 1000 // Network constants bitsPerMegabit = 1000 * 1000 ) // SystemInfo represents overall system information type SystemInfo struct { Hostname string Uptime time.Duration MemoryTotal uint64 MemoryUsed uint64 MemoryFree uint64 CPUPercent []float64 Temperature map[string]float64 DiskUsage []DiskInfo Network []NetworkInfo Processes []ProcessInfo CollectedAt time.Time } // DiskInfo represents disk usage information type DiskInfo struct { Path string Total uint64 Used uint64 Free uint64 UsedPercent float64 } // NetworkInfo represents network interface information type NetworkInfo struct { Name string IPAddresses []string LinkSpeed uint64 BytesSent uint64 BytesRecv uint64 BitsSentRate uint64 // bits per second BitsRecvRate uint64 // bits per second } // FormatSentRate returns the send rate as a human-readable string func (n *NetworkInfo) FormatSentRate() string { return humanize.SI(float64(n.BitsSentRate), "bit/s") } // FormatRecvRate returns the receive rate as a human-readable string func (n *NetworkInfo) FormatRecvRate() string { return humanize.SI(float64(n.BitsRecvRate), "bit/s") } // ProcessInfo represents process information type ProcessInfo struct { PID int32 Name string CPUPercent float64 MemoryRSS uint64 MemoryVMS uint64 Username string } // Collector interface for collecting system information type Collector interface { Collect() (*SystemInfo, error) } // SystemCollector implements Collector type SystemCollector struct { logger *slog.Logger netMonitor *netmon.Monitor lastCollectTime time.Time } // NewSystemCollector creates a new system collector func NewSystemCollector(logger *slog.Logger) *SystemCollector { nm := netmon.New(logger) nm.Start() return &SystemCollector{ logger: logger, netMonitor: nm, } } // Stop stops the system collector func (c *SystemCollector) Stop() { if c.netMonitor != nil { c.netMonitor.Stop() } } // Collect gathers system information func (c *SystemCollector) Collect() (*SystemInfo, error) { info := &SystemInfo{ CollectedAt: time.Now(), Temperature: make(map[string]float64), } // Hostname hostname, err := os.Hostname() if err != nil { c.logger.Warn("getting hostname", "error", err) info.Hostname = "unknown" } else { info.Hostname = hostname } // Uptime uptimeSecs, err := host.Uptime() if err != nil { c.logger.Warn("getting uptime", "error", err) } else { if uptimeSecs > 0 { // Convert uint64 to int64 safely to avoid overflow maxInt64 := ^uint64(0) >> 1 if uptimeSecs <= maxInt64 { info.Uptime = time.Duration(int64(uptimeSecs)) * time.Second } } } // Memory vmStat, err := mem.VirtualMemory() if err != nil { c.logger.Warn("getting memory stats", "error", err) } else { info.MemoryTotal = vmStat.Total info.MemoryUsed = vmStat.Used info.MemoryFree = vmStat.Available } // CPU cpuPercent, err := cpu.Percent(time.Second, true) if err != nil { c.logger.Warn("getting cpu percent", "error", err) } else { info.CPUPercent = cpuPercent } // Temperature temps, err := host.SensorsTemperatures() if err != nil { c.logger.Warn("getting temperatures", "error", err) } else { for _, temp := range temps { if temp.Temperature > 0 { info.Temperature[temp.SensorKey] = temp.Temperature } } } // Disk usage partitions, err := disk.Partitions(false) if err != nil { c.logger.Warn("getting partitions", "error", err) } else { for _, partition := range partitions { if strings.HasPrefix(partition.Mountpoint, "/dev") || strings.HasPrefix(partition.Mountpoint, "/sys") || strings.HasPrefix(partition.Mountpoint, "/proc") { continue } usage, err := disk.Usage(partition.Mountpoint) if err != nil { continue } info.DiskUsage = append(info.DiskUsage, DiskInfo{ Path: partition.Mountpoint, Total: usage.Total, Used: usage.Used, Free: usage.Free, UsedPercent: usage.UsedPercent, }) } } // Network - get rates from network monitor netStats := c.netMonitor.GetStats() // Also get interface details for IP addresses interfaces, err := psnet.Interfaces() if err != nil { c.logger.Warn("getting network interfaces", "error", err) } else { // Create a map of interface names to IPs and link speeds ifaceIPs := make(map[string][]string) ifaceSpeeds := make(map[string]uint64) for _, iface := range interfaces { if iface.Name == "lo" || strings.HasPrefix(iface.Name, "docker") { continue } var ips []string for _, addr := range iface.Addrs { ips = append(ips, addr.Addr) } ifaceIPs[iface.Name] = ips // Try to get link speed with ethtool if speed := c.getLinkSpeed(iface.Name); speed > 0 { ifaceSpeeds[iface.Name] = speed } } // Combine network monitor stats with interface details for _, stat := range netStats { netInfo := NetworkInfo{ Name: stat.Name, BytesSent: stat.BytesSent, BytesRecv: stat.BytesRecv, BitsSentRate: stat.BitsSentRate, BitsRecvRate: stat.BitsRecvRate, } // Add IP addresses if available if ips, ok := ifaceIPs[stat.Name]; ok { netInfo.IPAddresses = ips } // Add link speed if available if speed, ok := ifaceSpeeds[stat.Name]; ok { netInfo.LinkSpeed = speed } info.Network = append(info.Network, netInfo) } } // Processes processes, err := process.Processes() if err != nil { c.logger.Warn("getting processes", "error", err) } else { // Limit to top processes to avoid hanging processCount := 0 for _, p := range processes { if processCount >= maxProcesses { break } // Skip kernel threads and very short-lived processes name, err := p.Name() if err != nil || name == "" { continue } // Use CreateTime to skip very new processes that might not have stable stats createTime, err := p.CreateTime() if err != nil || time.Since(time.Unix(createTime/msToSecondsDivisor, 0)) < processStableTime { continue } // Get CPU percent with timeout - this is the call that can hang cpuPercent := 0.0 cpuChan := make(chan float64, 1) go func() { cpu, _ := p.CPUPercent() cpuChan <- cpu }() select { case cpu := <-cpuChan: cpuPercent = cpu case <-time.After(processTimeout): // Skip this process if CPU sampling takes too long c.logger.Debug("skipping process due to CPU timeout", "pid", p.Pid, "name", name) continue } memInfo, _ := p.MemoryInfo() username, _ := p.Username() info.Processes = append(info.Processes, ProcessInfo{ PID: p.Pid, Name: name, CPUPercent: cpuPercent, MemoryRSS: memInfo.RSS, MemoryVMS: memInfo.VMS, Username: username, }) processCount++ } } c.lastCollectTime = time.Now() return info, nil } // getLinkSpeed gets the link speed for an interface using ethtool func (c *SystemCollector) getLinkSpeed(ifaceName string) uint64 { // Run ethtool to get link speed output, err := exec.Command("ethtool", ifaceName).Output() if err != nil { return 0 } // Parse the output for speed // Look for lines like "Speed: 1000Mb/s" or "Speed: 10000Mb/s" speedRegex := regexp.MustCompile(`Speed:\s+(\d+)Mb/s`) matches := speedRegex.FindSubmatch(output) if len(matches) < 2 { //nolint:mnd return 0 } // Convert from Mb/s to bits/s mbps, err := strconv.ParseUint(string(matches[1]), 10, 64) if err != nil { return 0 } return mbps * bitsPerMegabit // Convert to bits per second }