feat: enhance metrics collection with disk and network I/O rates, and update frontend to display new data

This commit is contained in:
Jonathan Atta
2026-03-11 18:10:28 +01:00
parent 29e8f9b887
commit d58003feb7
4 changed files with 275 additions and 242 deletions

View File

@@ -6,10 +6,13 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/shirou/gopsutil/v3/cpu"
psdisk "github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/mem"
psnet "github.com/shirou/gopsutil/v3/net"
ps "github.com/shirou/gopsutil/v3/process"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
@@ -17,15 +20,35 @@ import (
type SysInfo struct {
ctx context.Context
cancel context.CancelFunc
mu sync.Mutex
lastProcIO map[int32][2]uint64 // pid -> [readBytes, writeBytes]
lastDiskIO [2]uint64 // [totalReadBytes, totalWriteBytes]
lastNetIO [2]uint64 // [bytesRecv, bytesSent]
lastTime time.Time
}
func NewSysInfo(ctx context.Context) *SysInfo {
cctx, cancel := context.WithCancel(ctx)
s := &SysInfo{ctx: cctx, cancel: cancel}
s := &SysInfo{
ctx: cctx,
cancel: cancel,
lastProcIO: make(map[int32][2]uint64),
}
go s.startEmitter()
return s
}
// NewSysInfoHeadless creates a SysInfo for use without a Wails GUI context.
// Use Snapshot() to collect metrics; no Wails events are emitted.
func NewSysInfoHeadless(ctx context.Context) *SysInfo {
cctx, cancel := context.WithCancel(ctx)
return &SysInfo{
ctx: cctx,
cancel: cancel,
lastProcIO: make(map[int32][2]uint64),
}
}
func (s *SysInfo) startEmitter() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
@@ -34,27 +57,19 @@ func (s *SysInfo) startEmitter() {
case <-s.ctx.Done():
return
case <-ticker.C:
info, _ := s.CollectOnce()
// attach GPU global stats
name, tot, used, util := queryGPU(s.ctx)
if name != "" {
info.GPUName = name
info.GPUTotal = tot
info.GPUUsed = used
info.GPUUtil = util
// attach per-process GPU stats
info.GPUProcesses = queryGPUProcesses(s.ctx)
}
info, _ := s.Snapshot()
runtime.EventsEmit(s.ctx, "metrics", info)
}
}
}
type ProcessInfo struct {
PID int32 `json:"pid"`
Name string `json:"name"`
CPU float64 `json:"cpu"`
Mem uint64 `json:"mem"`
PID int32 `json:"pid"`
Name string `json:"name"`
CPU float64 `json:"cpu"`
Mem uint64 `json:"mem"`
ReadBps uint64 `json:"read_bps"`
WriteBps uint64 `json:"write_bps"`
}
// GPUProcessInfo holds per-process GPU stats from nvidia-smi pmon.
@@ -77,15 +92,33 @@ type Metrics struct {
GPUUsed uint64 `json:"gpu_used_mem,omitempty"`
GPUUtil float64 `json:"gpu_util_percent,omitempty"`
GPUProcesses []GPUProcessInfo `json:"gpu_processes,omitempty"`
DiskReadBps uint64 `json:"disk_read_bps"`
DiskWriteBps uint64 `json:"disk_write_bps"`
NetRecvBps uint64 `json:"net_recv_bps"`
NetSendBps uint64 `json:"net_send_bps"`
}
// CollectOnce gathers a snapshot of system metrics.
func (s *SysInfo) CollectOnce() (*Metrics, error) {
now := time.Now()
// Snapshot previous IO state
s.mu.Lock()
elapsed := 0.0
if !s.lastTime.IsZero() {
elapsed = now.Sub(s.lastTime).Seconds()
}
prevProcIO := s.lastProcIO
prevDiskIO := s.lastDiskIO
prevNetIO := s.lastNetIO
s.mu.Unlock()
cpuPercents, _ := cpu.PercentWithContext(s.ctx, 0, false)
vm, _ := mem.VirtualMemory()
// Collect ALL processes, then sort and keep top 50
// Collect all processes with disk I/O rates
procs, _ := ps.Processes()
newProcIO := make(map[int32][2]uint64, len(procs))
var list []ProcessInfo
for _, p := range procs {
name, _ := p.Name()
@@ -95,7 +128,28 @@ func (s *SysInfo) CollectOnce() (*Metrics, error) {
if memInfo != nil {
memBytes = memInfo.RSS
}
list = append(list, ProcessInfo{PID: p.Pid, Name: name, CPU: cpuPct, Mem: memBytes})
var readBps, writeBps uint64
if ioStats, err := p.IOCounters(); err == nil {
newProcIO[p.Pid] = [2]uint64{ioStats.ReadBytes, ioStats.WriteBytes}
if elapsed > 0 {
if prev, ok := prevProcIO[p.Pid]; ok {
if ioStats.ReadBytes >= prev[0] {
readBps = uint64(float64(ioStats.ReadBytes-prev[0]) / elapsed)
}
if ioStats.WriteBytes >= prev[1] {
writeBps = uint64(float64(ioStats.WriteBytes-prev[1]) / elapsed)
}
}
}
}
list = append(list, ProcessInfo{
PID: p.Pid,
Name: name,
CPU: cpuPct,
Mem: memBytes,
ReadBps: readBps,
WriteBps: writeBps,
})
}
// Sort: primary by CPU desc, secondary by Mem desc
sort.Slice(list, func(i, j int) bool {
@@ -110,16 +164,58 @@ func (s *SysInfo) CollectOnce() (*Metrics, error) {
cpuVal = cpuPercents[0]
}
// System-wide disk I/O rates
var diskReadBps, diskWriteBps uint64
var newDiskIO [2]uint64
if diskCounters, err := psdisk.IOCounters(); err == nil {
for _, d := range diskCounters {
newDiskIO[0] += d.ReadBytes
newDiskIO[1] += d.WriteBytes
}
if elapsed > 0 && (prevDiskIO[0] > 0 || prevDiskIO[1] > 0) {
if newDiskIO[0] >= prevDiskIO[0] {
diskReadBps = uint64(float64(newDiskIO[0]-prevDiskIO[0]) / elapsed)
}
if newDiskIO[1] >= prevDiskIO[1] {
diskWriteBps = uint64(float64(newDiskIO[1]-prevDiskIO[1]) / elapsed)
}
}
}
// System-wide network I/O rates
var netRecvBps, netSendBps uint64
var newNetIO [2]uint64
if netCounters, err := psnet.IOCounters(false); err == nil && len(netCounters) > 0 {
newNetIO[0] = netCounters[0].BytesRecv
newNetIO[1] = netCounters[0].BytesSent
if elapsed > 0 && (prevNetIO[0] > 0 || prevNetIO[1] > 0) {
if newNetIO[0] >= prevNetIO[0] {
netRecvBps = uint64(float64(newNetIO[0]-prevNetIO[0]) / elapsed)
}
if newNetIO[1] >= prevNetIO[1] {
netSendBps = uint64(float64(newNetIO[1]-prevNetIO[1]) / elapsed)
}
}
}
// Update state under lock
s.mu.Lock()
s.lastTime = now
s.lastProcIO = newProcIO
s.lastDiskIO = newDiskIO
s.lastNetIO = newNetIO
s.mu.Unlock()
return &Metrics{
CPUPercent: cpuVal,
TotalMem: vm.Total,
FreeMem: vm.Available,
Processes: list,
Timestamp: time.Now().UnixMilli(),
GPUName: "",
GPUTotal: 0,
GPUUsed: 0,
GPUUtil: 0,
CPUPercent: cpuVal,
TotalMem: vm.Total,
FreeMem: vm.Available,
Processes: list,
Timestamp: time.Now().UnixMilli(),
DiskReadBps: diskReadBps,
DiskWriteBps: diskWriteBps,
NetRecvBps: netRecvBps,
NetSendBps: netSendBps,
}, nil
}
@@ -198,8 +294,26 @@ func queryGPU(ctx context.Context) (string, uint64, uint64, float64) {
// GetMetrics is an exported method callable from frontend.
func (s *SysInfo) GetMetrics() (*Metrics, error) {
return s.CollectOnce()
return s.Snapshot()
}
// Snapshot collects a complete metrics snapshot including GPU data.
func (s *SysInfo) Snapshot() (*Metrics, error) {
m, err := s.CollectOnce()
if err != nil {
return nil, err
}
name, tot, used, util := queryGPU(s.ctx)
if name != "" {
m.GPUName = name
m.GPUTotal = tot
m.GPUUsed = used
m.GPUUtil = util
m.GPUProcesses = queryGPUProcesses(s.ctx)
}
return m, nil
}
// ProcessDetail contains all available details about a single process.
type ProcessDetail struct {
PID int32 `json:"pid"`
@@ -317,7 +431,7 @@ func KillProcess(pid int32, force bool) error {
// Unlike NewSysInfo, it does not start a background emitter and does not
// require a Wails runtime context — safe to call from server mode.
func CollectFull(ctx context.Context) (*Metrics, error) {
s := &SysInfo{ctx: ctx}
s := &SysInfo{ctx: ctx, lastProcIO: make(map[int32][2]uint64)}
m, err := s.CollectOnce()
if err != nil {
return nil, err