hdmistat/internal/statcollector/collector.go
2025-07-24 14:32:50 +02:00

344 lines
8.1 KiB
Go

// 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 {
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
}