From d58003feb74659e02e5d8a18b5f799f22d47a8a4 Mon Sep 17 00:00:00 2001 From: Jonathan Atta Date: Wed, 11 Mar 2026 18:10:28 +0100 Subject: [PATCH] feat: enhance metrics collection with disk and network I/O rates, and update frontend to display new data --- backend/sysinfo.go | 172 +++++++++++++++--- frontend/src/App.tsx | 327 ++++++++++++---------------------- frontend/wailsjs/go/models.ts | 12 ++ server.go | 6 +- 4 files changed, 275 insertions(+), 242 deletions(-) diff --git a/backend/sysinfo.go b/backend/sysinfo.go index 2217aac..ccebcb7 100644 --- a/backend/sysinfo.go +++ b/backend/sysinfo.go @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9f05f19..88bfd94 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState, useCallback, useRef } from 'react' import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime' -import { Activity, Cpu, Database, Monitor, ChevronDown, ChevronRight, Layers, X, RefreshCw, OctagonX, Search } from 'lucide-react' +import { Activity, Cpu, Database, Monitor, ChevronDown, ChevronRight, X, RefreshCw, OctagonX, Search, HardDrive, Network } from 'lucide-react' import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' import { Progress } from '@/components/ui/progress' import { cn } from '@/lib/utils' @@ -14,6 +14,8 @@ type ProcessInfo = { name: string cpu: number mem: number + read_bps: number + write_bps: number } type GPUProcessInfo = { @@ -35,6 +37,10 @@ type Metrics = { gpu_used_mem?: number gpu_util_percent?: number gpu_processes?: GPUProcessInfo[] + disk_read_bps: number + disk_write_bps: number + net_recv_bps: number + net_send_bps: number } type ProcessDetail = { @@ -64,7 +70,7 @@ type ProcessDetail = { conn_count: number } -type SortField = 'cpu' | 'mem' +type SortField = 'cpu' | 'mem' | 'disk' | 'vram' type NavItem = { pid: number; name: string } // --------------------------------------------------------------------------- @@ -520,10 +526,7 @@ export default function App() { const [sortBy, setSortBy] = useState('cpu') const [selectedPid, setSelectedPid] = useState(null) const [page, setPage] = useState(0) - const [gpuPage, setGpuPage] = useState(0) const [procOpen, setProcOpen] = useState(true) - const [gpuOpen, setGpuOpen] = useState(true) - const [gpuSortBy, setGpuSortBy] = useState<'vram' | 'ram'>('vram') const [navStack, setNavStack] = useState([]) const [search, setSearch] = useState('') @@ -533,7 +536,6 @@ export default function App() { }, []) const [pageSize, setPageSize] = useState(20) - const [gpuPageSize, setGpuPageSize] = useState(4) useEffect(() => { const handler = (m: Metrics) => setMetrics(m) @@ -556,17 +558,18 @@ export default function App() { const gpuUtilPct = metrics?.gpu_util_percent ?? 0 const searchLower = search.toLowerCase() - const gpuProcesses = [...(metrics?.gpu_processes ?? [])] - .filter(p => !search || p.name.toLowerCase().includes(searchLower) || String(p.pid).includes(search)) - .sort((a, b) => gpuSortBy === 'vram' ? b.vram_mb - a.vram_mb : b.ram - a.ram) - const gpuTotalPages = Math.ceil(gpuProcesses.length / gpuPageSize) - const safeGpuPage = Math.min(gpuPage, Math.max(0, gpuTotalPages - 1)) - const pagedGpuProcesses = gpuProcesses.slice(safeGpuPage * gpuPageSize, (safeGpuPage + 1) * gpuPageSize) - + const gpuByPid = new Map( + (metrics?.gpu_processes ?? []).map(g => [g.pid, g]) + ) const sortedProcesses = metrics ? [...metrics.processes] .filter(p => !search || p.name.toLowerCase().includes(searchLower) || String(p.pid).includes(search)) - .sort((a, b) => b[sortBy] - a[sortBy]) + .sort((a, b) => { + if (sortBy === 'disk') return (b.read_bps + b.write_bps) - (a.read_bps + a.write_bps) + if (sortBy === 'vram') return (gpuByPid.get(b.pid)?.vram_mb ?? 0) - (gpuByPid.get(a.pid)?.vram_mb ?? 0) + if (sortBy === 'cpu') return b.cpu - a.cpu + return b.mem - a.mem + }) : [] const totalPages = Math.ceil(sortedProcesses.length / pageSize) const safePage = Math.min(page, Math.max(0, totalPages - 1)) @@ -601,12 +604,12 @@ export default function App() { type="text" placeholder="Search by name or PID…" value={search} - onChange={e => { setSearch(e.target.value); setPage(0); setGpuPage(0) }} + onChange={e => { setSearch(e.target.value); setPage(0) }} className="w-full bg-zinc-900/60 border border-zinc-800 rounded-lg pl-8 pr-8 py-2 text-sm text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-600 transition-colors" /> {search && ( - ))} - - -
- - - {Array.from({ length: gpuTotalPages }, (_, i) => i) - .filter(i => i === 0 || i === gpuTotalPages - 1 || Math.abs(i - safeGpuPage) <= 1) - .reduce<(number | '…')[]>((acc, i, idx, arr) => { - if (idx > 0 && typeof arr[idx - 1] === 'number' && (i as number) - (arr[idx - 1] as number) > 1) acc.push('…') - acc.push(i) - return acc - }, []) - .map((item, idx) => - item === '…' ? ( - - ) : ( - - ) - ) - } - - -
- - )} - - )} - - )} - - )} {/* Detail panel */} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 294d9b4..13555b9 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -25,6 +25,8 @@ export namespace backend { name: string; cpu: number; mem: number; + read_bps: number; + write_bps: number; static createFrom(source: any = {}) { return new ProcessInfo(source); @@ -36,6 +38,8 @@ export namespace backend { this.name = source["name"]; this.cpu = source["cpu"]; this.mem = source["mem"]; + this.read_bps = source["read_bps"]; + this.write_bps = source["write_bps"]; } } export class Metrics { @@ -49,6 +53,10 @@ export namespace backend { gpu_used_mem?: number; gpu_util_percent?: number; gpu_processes?: GPUProcessInfo[]; + disk_read_bps: number; + disk_write_bps: number; + net_recv_bps: number; + net_send_bps: number; static createFrom(source: any = {}) { return new Metrics(source); @@ -66,6 +74,10 @@ export namespace backend { this.gpu_used_mem = source["gpu_used_mem"]; this.gpu_util_percent = source["gpu_util_percent"]; this.gpu_processes = this.convertValues(source["gpu_processes"], GPUProcessInfo); + this.disk_read_bps = source["disk_read_bps"]; + this.disk_write_bps = source["disk_write_bps"]; + this.net_recv_bps = source["net_recv_bps"]; + this.net_send_bps = source["net_send_bps"]; } convertValues(a: any, classs: any, asMap: boolean = false): any { diff --git a/server.go b/server.go index 05333cd..a4abc2b 100644 --- a/server.go +++ b/server.go @@ -16,9 +16,11 @@ import ( ) func runServer(port int) { - _, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(context.Background()) defer cancel() + streamSys := backend.NewSysInfoHeadless(ctx) + mux := http.NewServeMux() // GET /api/metrics — latest snapshot @@ -61,7 +63,7 @@ func runServer(port int) { case <-r.Context().Done(): return case <-ticker.C: - m, err := backend.CollectFull(r.Context()) + m, err := streamSys.Snapshot() if err != nil { continue }