From fbbdd49fc2213f079572e0cf79ead17cc4a84ea8 Mon Sep 17 00:00:00 2001 From: henrygd Date: Mon, 3 Nov 2025 21:50:33 -0500 Subject: [PATCH] collect top process --- agent/cpu.go | 119 +++++++++++++++++++++++++++++ agent/system.go | 9 +++ internal/entities/system/system.go | 6 ++ 3 files changed, 134 insertions(+) diff --git a/agent/cpu.go b/agent/cpu.go index 4ed52f7d..de2502e7 100644 --- a/agent/cpu.go +++ b/agent/cpu.go @@ -2,14 +2,19 @@ package agent import ( "math" + "path/filepath" "runtime" + "time" "github.com/henrygd/beszel/internal/entities/system" "github.com/shirou/gopsutil/v4/cpu" + "github.com/shirou/gopsutil/v4/process" ) var lastCpuTimes = make(map[uint16]cpu.TimesStat) var lastPerCoreCpuTimes = make(map[uint16][]cpu.TimesStat) +var lastProcessCpuTimes = make(map[uint16]map[int32]float64) +var lastProcessCpuSampleTime = make(map[uint16]time.Time) // init initializes the CPU monitoring by storing the initial CPU times // for the default 60-second cache interval. @@ -20,6 +25,16 @@ func init() { if perCoreTimes, err := cpu.Times(true); err == nil { lastPerCoreCpuTimes[60000] = perCoreTimes } + if processes, err := process.Processes(); err == nil { + snapshot := make(map[int32]float64, len(processes)) + for _, proc := range processes { + if times, err := proc.Times(); err == nil { + snapshot[proc.Pid] = times.Total() + } + } + lastProcessCpuTimes[60000] = snapshot + lastProcessCpuSampleTime[60000] = time.Now() + } } // CpuMetrics contains detailed CPU usage breakdown @@ -105,6 +120,110 @@ func getPerCoreCpuUsage(cacheTimeMs uint16) (system.Uint8Slice, error) { return usage, nil } +// getTopCpuProcess returns the process with the highest CPU usage since the last run +// for the given cache interval. It returns nil if insufficient data is available. +func getTopCpuProcess(cacheTimeMs uint16) (*system.TopCpuProcess, error) { + processes, err := process.Processes() + if err != nil { + return nil, err + } + + now := time.Now() + + lastTimes, ok := lastProcessCpuTimes[cacheTimeMs] + if !ok { + if fallback := lastProcessCpuTimes[60000]; fallback != nil { + copied := make(map[int32]float64, len(fallback)) + for pid, total := range fallback { + copied[pid] = total + } + lastTimes = copied + lastProcessCpuTimes[cacheTimeMs] = copied + } else { + lastTimes = make(map[int32]float64) + lastProcessCpuTimes[cacheTimeMs] = lastTimes + } + } + + lastSample := lastProcessCpuSampleTime[cacheTimeMs] + if lastSample.IsZero() { + if fallback := lastProcessCpuSampleTime[60000]; !fallback.IsZero() { + lastSample = fallback + lastProcessCpuSampleTime[cacheTimeMs] = fallback + } + } + + elapsed := now.Sub(lastSample).Seconds() + if lastSample.IsZero() || elapsed <= 0 { + snapshot := make(map[int32]float64, len(processes)) + for _, proc := range processes { + if times, err := proc.Times(); err == nil { + snapshot[proc.Pid] = times.Total() + } + } + lastProcessCpuTimes[cacheTimeMs] = snapshot + lastProcessCpuSampleTime[cacheTimeMs] = now + return nil, nil + } + + cpuCount := float64(runtime.NumCPU()) + if cpuCount <= 0 { + cpuCount = 1 + } + + snapshot := make(map[int32]float64, len(processes)) + var topName string + var topPercent float64 + + for _, proc := range processes { + times, err := proc.Times() + if err != nil { + continue + } + + total := times.Total() + pid := proc.Pid + snapshot[pid] = total + + lastTotal, ok := lastTimes[pid] + if !ok || total <= lastTotal { + continue + } + + percent := clampPercent((total - lastTotal) / (elapsed * cpuCount) * 100) + if percent <= 0 { + continue + } + + name, err := proc.Name() + if err != nil || name == "" { + if exe, exeErr := proc.Exe(); exeErr == nil && exe != "" { + name = filepath.Base(exe) + } + } + if name == "" { + continue + } + + if percent > topPercent { + topPercent = percent + topName = name + } + } + + lastProcessCpuTimes[cacheTimeMs] = snapshot + lastProcessCpuSampleTime[cacheTimeMs] = now + + if topName == "" { + return nil, nil + } + + return &system.TopCpuProcess{ + Name: topName, + Percent: topPercent, + }, nil +} + // calculateBusy calculates the CPU busy percentage between two time points. // It computes the ratio of busy time to total time elapsed between t1 and t2, // returning a percentage clamped between 0 and 100. diff --git a/agent/system.go b/agent/system.go index f6b3c7d7..6b079551 100644 --- a/agent/system.go +++ b/agent/system.go @@ -98,6 +98,15 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats { slog.Error("Error getting cpu metrics", "err", err) } + if topProcess, err := getTopCpuProcess(cacheTimeMs); err == nil { + if topProcess != nil { + topProcess.Percent = twoDecimals(topProcess.Percent) + systemStats.TopCpuProcess = topProcess + } + } else { + slog.Error("Error getting top cpu process", "err", err) + } + // per-core cpu usage if perCoreUsage, err := getPerCoreCpuUsage(cacheTimeMs); err == nil { systemStats.CpuCoresUsage = perCoreUsage diff --git a/internal/entities/system/system.go b/internal/entities/system/system.go index cf9df3bd..ef850d95 100644 --- a/internal/entities/system/system.go +++ b/internal/entities/system/system.go @@ -47,6 +47,7 @@ type Stats struct { MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes] CpuBreakdown []float64 `json:"cpub,omitempty" cbor:"33,keyasint,omitempty"` // [user, system, iowait, steal, idle] CpuCoresUsage Uint8Slice `json:"cpus,omitempty" cbor:"34,keyasint,omitempty"` // per-core busy usage [CPU0..] + TopCpuProcess *TopCpuProcess `json:"tcp,omitempty" cbor:"35,keyasint,omitempty"` } // Uint8Slice wraps []uint8 to customize JSON encoding while keeping CBOR efficient. @@ -153,3 +154,8 @@ type CombinedData struct { Info Info `json:"info" cbor:"1,keyasint"` Containers []*container.Stats `json:"container" cbor:"2,keyasint"` } + +type TopCpuProcess struct { + Name string `json:"n" cbor:"0,keyasint"` + Percent float64 `json:"p" cbor:"1,keyasint"` +}