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

View File

@@ -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<SortField>('cpu')
const [selectedPid, setSelectedPid] = useState<number | null>(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<NavItem[]>([])
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<number, GPUProcessInfo>(
(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 && (
<button
onClick={() => { setSearch(''); setPage(0); setGpuPage(0) }}
onClick={() => { setSearch(''); setPage(0) }}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-zinc-400 transition-colors"
>
<X className="w-3.5 h-3.5" />
@@ -640,12 +643,24 @@ export default function App() {
</div>
<div className="text-right">
<p className="text-[10px] text-zinc-600 mb-0.5">Memory</p>
<span className={cn('text-sm font-bold tabular-nums', memPct >= 90 ? 'text-red-400' : memPct >= 70 ? 'text-amber-400' : 'text-zinc-100')}>{fmtBytes(usedMem)}</span>
<span className={cn('text-sm font-bold tabular-nums', memPct >= 90 ? 'text-red-400' : memPct >= 70 ? 'text-amber-400' : 'text-zinc-100')}>{memPct.toFixed(1)}%</span>
</div>
{metrics?.gpu_name && (
<div className="text-right">
<p className="text-[10px] text-zinc-600 mb-0.5">VRAM</p>
<span className={cn('text-sm font-bold tabular-nums', gpuUsedPct >= 90 ? 'text-red-400' : gpuUsedPct >= 70 ? 'text-amber-400' : 'text-zinc-100')}>{metrics.gpu_used_mem ? fmtBytes(metrics.gpu_used_mem) : '—'}</span>
</div>
)}
{metrics?.gpu_name && (
<div className="text-right">
<p className="text-[10px] text-zinc-600 mb-0.5">GPU</p>
<span className={cn('text-sm font-bold tabular-nums', gpuUtilPct >= 90 ? 'text-red-400' : gpuUtilPct >= 70 ? 'text-amber-400' : 'text-zinc-100')}>{gpuUtilPct.toFixed(0)}%</span>
</div>
)}
</div>
{procOpen && (
<div className="flex gap-1 ml-2">
{(['cpu', 'mem'] as SortField[]).map((field) => (
{(['cpu', 'mem', 'disk', ...(metrics?.gpu_name ? ['vram'] : [])] as SortField[]).map((field) => (
<button
key={field}
onClick={() => toggleSort(field)}
@@ -683,6 +698,38 @@ export default function App() {
</div>
<Progress value={memPct} />
</div>
<div>
<div className="flex justify-between text-xs text-zinc-600 mb-1.5">
<span className="flex items-center gap-1"><HardDrive className="w-3 h-3" /> Disk</span>
<span className="font-mono text-zinc-400">{fmtBytes(metrics?.disk_read_bps ?? 0)}/s {fmtBytes(metrics?.disk_write_bps ?? 0)}/s</span>
</div>
<Progress value={Math.min(100, ((metrics?.disk_read_bps ?? 0) + (metrics?.disk_write_bps ?? 0)) / (500 * 1024 * 1024) * 100)} />
</div>
<div>
<div className="flex justify-between text-xs text-zinc-600 mb-1.5">
<span className="flex items-center gap-1"><Network className="w-3 h-3" /> Network</span>
<span className="font-mono text-zinc-400">{fmtBytes(metrics?.net_recv_bps ?? 0)}/s {fmtBytes(metrics?.net_send_bps ?? 0)}/s</span>
</div>
<Progress value={Math.min(100, ((metrics?.net_recv_bps ?? 0) + (metrics?.net_send_bps ?? 0)) / (125 * 1024 * 1024) * 100)} />
</div>
{metrics?.gpu_name && (
<div>
<div className="flex justify-between text-xs text-zinc-600 mb-1.5">
<span className="flex items-center gap-1"><Monitor className="w-3 h-3" /> VRAM <span className="text-zinc-700 ml-1 normal-case tracking-normal font-normal">{metrics.gpu_name}</span></span>
<span className={cn('font-mono', gpuUsedPct >= 90 ? 'text-red-400' : gpuUsedPct >= 70 ? 'text-amber-400' : 'text-zinc-400')}>{metrics.gpu_used_mem ? fmtBytes(metrics.gpu_used_mem) : '—'} / {metrics.gpu_total_mem ? fmtBytes(metrics.gpu_total_mem) : '—'}</span>
</div>
<Progress value={gpuUsedPct} />
</div>
)}
{metrics?.gpu_name && (
<div>
<div className="flex justify-between text-xs text-zinc-600 mb-1.5">
<span className="flex items-center gap-1"><Monitor className="w-3 h-3" /> GPU Util</span>
<span className={cn('font-mono', gpuUtilPct >= 90 ? 'text-red-400' : gpuUtilPct >= 70 ? 'text-amber-400' : 'text-zinc-400')}>{gpuUtilPct.toFixed(1)}%</span>
</div>
<Progress value={gpuUtilPct} />
</div>
)}
</div>
<table className="w-full text-sm">
<thead>
@@ -691,6 +738,8 @@ export default function App() {
<th className="py-2 text-left text-xs font-medium text-zinc-600 uppercase tracking-wider cursor-help" title="Executable name of the process">Name</th>
<th className="py-2 px-5 text-right text-xs font-medium text-zinc-600 uppercase tracking-wider w-28 cursor-help" title="CPU usage as a % of a single core (can exceed 100% on multi-threaded processes)">CPU</th>
<th className="py-2 px-5 text-right text-xs font-medium text-zinc-600 uppercase tracking-wider w-28 cursor-help" title="Physical RAM (RSS) currently used by this process">Memory</th>
<th className="py-2 px-3 text-right text-xs font-medium text-zinc-600 uppercase tracking-wider w-24 cursor-help" title="Disk I/O — combined read + write throughput for this process">Disk</th>
{metrics?.gpu_name && <th className="py-2 px-3 text-right text-xs font-medium text-zinc-600 uppercase tracking-wider w-24 cursor-help" title="GPU type and VRAM — C = Compute, G = Graphics, C+G = both">GPU</th>}
</tr>
</thead>
<tbody>
@@ -707,26 +756,61 @@ export default function App() {
>
<td className="py-2 px-5 text-zinc-700 text-xs font-mono">{p.pid}</td>
<td className="py-2 text-zinc-300 truncate max-w-xs">{p.name}</td>
<td className="py-2 px-5 text-right">
<span
className={cn(
'tabular-nums font-mono text-xs',
p.cpu >= 50 ? 'text-red-400' : p.cpu >= 20 ? 'text-amber-400' : 'text-zinc-500'
)}
>
{p.cpu.toFixed(1)}%
</span>
<td className="py-2 px-3 text-right">
<div className="flex flex-col items-end gap-0.5">
<span className={cn('tabular-nums font-mono text-xs', p.cpu >= 50 ? 'text-red-400' : p.cpu >= 20 ? 'text-amber-400' : 'text-zinc-500')}>
{p.cpu.toFixed(1)}%
</span>
<div className="w-14 h-1 bg-zinc-800 rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${Math.min(100, p.cpu)}%`, backgroundColor: p.cpu >= 50 ? 'rgb(248 113 113/0.7)' : p.cpu >= 20 ? 'rgb(251 191 36/0.7)' : 'rgb(113 113 122/0.4)' }} />
</div>
</div>
</td>
<td className="py-2 px-5 text-right">
<span className="tabular-nums font-mono text-xs text-zinc-500">
{fmtBytes(p.mem)}
</span>
<td className="py-2 px-3 text-right">
<div className="flex flex-col items-end gap-0.5">
<span className="tabular-nums font-mono text-xs text-zinc-500">
{metrics ? ((p.mem / metrics.total_mem) * 100).toFixed(1) : '0'}%
</span>
<div className="w-14 h-1 bg-zinc-800 rounded-full overflow-hidden">
<div className="h-full bg-sky-700/50 rounded-full" style={{ width: `${metrics ? Math.min(100, (p.mem / metrics.total_mem) * 100) : 0}%` }} />
</div>
</div>
</td>
<td className="py-2 px-3 text-right">
{(p.read_bps + p.write_bps) > 0 ? (
<div className="flex flex-col items-end gap-0.5">
<span className="tabular-nums font-mono text-[10px] text-zinc-500">{fmtBytes(p.read_bps + p.write_bps)}/s</span>
<div className="w-14 h-1 bg-zinc-800 rounded-full overflow-hidden">
<div className="h-full bg-sky-600/40 rounded-full" style={{ width: `${Math.min(100, (p.read_bps + p.write_bps) / (100 * 1024 * 1024) * 100)}%` }} />
</div>
</div>
) : (
<span className="font-mono text-xs text-zinc-800"></span>
)}
</td>
{metrics?.gpu_name && (gpuByPid.has(p.pid) ? (
<td className="py-2 px-3 text-right">
<div className="flex flex-col items-end gap-0.5">
<span
title={gpuByPid.get(p.pid)!.type === 'C' ? 'Compute (CUDA/OpenCL)' : gpuByPid.get(p.pid)!.type === 'G' ? 'Graphics (rendering)' : 'Compute + Graphics'}
className={cn('text-[10px] font-mono px-1 py-0.5 rounded cursor-help',
gpuByPid.get(p.pid)!.type === 'C' ? 'bg-violet-900/50 text-violet-300' :
gpuByPid.get(p.pid)!.type === 'G' ? 'bg-blue-900/50 text-blue-300' :
'bg-indigo-900/50 text-indigo-300'
)}>
{gpuByPid.get(p.pid)!.type}
</span>
<span className="tabular-nums font-mono text-[10px] text-zinc-600">{gpuByPid.get(p.pid)!.vram_mb} MB</span>
</div>
</td>
) : (
<td className="py-2 px-3 text-right text-zinc-800 text-xs"></td>
))}
</tr>
))}
{pagedProcesses.length === 0 && (
<tr>
<td colSpan={4} className="py-10 text-center text-zinc-700 text-sm">
<td colSpan={metrics?.gpu_name ? 6 : 5} className="py-10 text-center text-zinc-700 text-sm">
Waiting for metrics
</td>
</tr>
@@ -802,185 +886,6 @@ export default function App() {
)}
</Card>
{/* GPU */}
{metrics?.gpu_name && (
<Card>
<CardHeader
className="cursor-pointer select-none"
onClick={() => setGpuOpen(o => !o)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
{gpuOpen
? <ChevronDown className="w-3.5 h-3.5 text-zinc-600" />
: <ChevronRight className="w-3.5 h-3.5 text-zinc-600" />
}
<Monitor className="w-3.5 h-3.5 text-zinc-500" />
<CardTitle>GPU</CardTitle>
<span className="text-xs text-zinc-600 font-normal normal-case tracking-normal ml-1">
{metrics.gpu_name}
</span>
</div>
<div className="flex items-center gap-4" onClick={e => e.stopPropagation()}>
<div className="text-right">
<p className="text-[10px] text-zinc-600 mb-0.5">VRAM</p>
<span className={cn('text-sm font-bold tabular-nums', gpuUsedPct >= 90 ? 'text-red-400' : gpuUsedPct >= 70 ? 'text-amber-400' : 'text-zinc-100')}>{metrics.gpu_used_mem ? fmtBytes(metrics.gpu_used_mem) : '—'}</span>
</div>
<div className="text-right">
<p className="text-[10px] text-zinc-600 mb-0.5">Util</p>
<span className={cn('text-sm font-bold tabular-nums', gpuUtilPct >= 90 ? 'text-red-400' : gpuUtilPct >= 70 ? 'text-amber-400' : 'text-zinc-100')}>{gpuUtilPct.toFixed(0)}%</span>
</div>
{gpuOpen && (
<div className="flex gap-1 ml-2">
{(['vram', 'ram'] as const).map((field) => (
<button
key={field}
onClick={() => { setGpuSortBy(field); setGpuPage(0) }}
className={cn(
'flex items-center gap-0.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors',
gpuSortBy === field
? 'bg-zinc-800 text-zinc-100'
: 'text-zinc-600 hover:text-zinc-400 hover:bg-zinc-900'
)}
>
{field.toUpperCase()}
{gpuSortBy === field && <ChevronDown className="w-3 h-3 ml-0.5" />}
</button>
))}
</div>
)}
</div>
</div>
</CardHeader>
{gpuOpen && (
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="flex justify-between text-xs text-zinc-600 mb-1.5">
<span>VRAM usage</span>
<span>
{metrics.gpu_used_mem ? fmtBytes(metrics.gpu_used_mem) : '—'}
{' / '}
{metrics.gpu_total_mem ? fmtBytes(metrics.gpu_total_mem) : '—'}
</span>
</div>
<Progress value={gpuUsedPct} />
</div>
<div>
<div className="flex justify-between text-xs text-zinc-600 mb-1.5">
<span>GPU utilization</span>
<span>{gpuUtilPct.toFixed(1)}%</span>
</div>
<Progress value={gpuUtilPct} />
</div>
</div>
{metrics.gpu_processes && metrics.gpu_processes.length > 0 && (
<div className="mt-4 pt-4 border-t border-zinc-800/60">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-1.5">
<Layers className="w-3 h-3 text-zinc-600" />
<span className="text-xs font-semibold uppercase tracking-widest text-zinc-500">
GPU Processes
</span>
<span className="text-[10px] text-zinc-700">{gpuProcesses.length} total</span>
</div>
</div>
<table className="w-full">
<thead>
<tr>
<th className="pb-1.5 text-left text-xs font-medium text-zinc-700 uppercase tracking-wider w-14 cursor-help" title="Process ID">PID</th>
<th className="pb-1.5 text-left text-xs font-medium text-zinc-700 uppercase tracking-wider cursor-help" title="Process name using this GPU">Name</th>
<th className="pb-1.5 text-left text-xs font-medium text-zinc-700 uppercase tracking-wider w-16 cursor-help" title="C = Compute (CUDA/OpenCL), G = Graphics (rendering), C+G = both">Type</th>
<th className="pb-1.5 text-right text-xs font-medium text-zinc-700 uppercase tracking-wider w-24 cursor-help" title="GPU VRAM used by this process (video memory on the graphics card)">VRAM</th>
<th className="pb-1.5 text-right text-xs font-medium text-zinc-700 uppercase tracking-wider w-28 cursor-help" title="System RAM (CPU-side memory) used by this process">RAM</th>
</tr>
</thead>
<tbody>
{pagedGpuProcesses.map((p) => (
<tr
key={p.pid}
className="border-t border-zinc-900 hover:bg-zinc-800/30 transition-colors cursor-pointer"
onClick={() => openDetail(p.pid)}
>
<td className="py-1.5 text-zinc-700 text-xs font-mono">{p.pid}</td>
<td className="py-1.5 text-zinc-300 text-xs truncate max-w-[180px]">{p.name}</td>
<td className="py-1.5">
<span
title={
p.type === 'C' ? 'C — Compute: uses the GPU for compute workloads (CUDA, OpenCL, Vulkan Compute…)' :
p.type === 'G' ? 'G — Graphics: uses the GPU for rendering and display output' :
'C+G — Compute + Graphics: uses the GPU for both compute and rendering workloads'
}
className={cn(
'inline-block px-1.5 py-0.5 rounded text-xs font-mono font-semibold cursor-help',
p.type === 'C' ? 'bg-violet-900/50 text-violet-300' :
p.type === 'G' ? 'bg-blue-900/50 text-blue-300' :
'bg-indigo-900/50 text-indigo-300'
)}
>
{p.type}
</span>
</td>
<td className="py-1.5 text-right text-xs font-mono text-zinc-400">{p.vram_mb} MB</td>
<td className="py-1.5 text-right text-xs font-mono text-zinc-500">{p.ram > 0 ? fmtBytes(p.ram) : '—'}</td>
</tr>
))}
</tbody>
</table>
{/* GPU pagination */}
{gpuTotalPages > 0 && (
<div className="flex items-center justify-between pt-2 mt-1 border-t border-zinc-900">
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-600">
{safeGpuPage * gpuPageSize + 1}{Math.min((safeGpuPage + 1) * gpuPageSize, gpuProcesses.length)} of {gpuProcesses.length}
</span>
<div className="flex rounded border border-zinc-800 overflow-hidden" title="Items per page">
{[4, 10, 20, 50].map(n => (
<button key={n} onClick={() => { setGpuPageSize(n); setGpuPage(0) }}
className={cn('px-1.5 py-0.5 text-xs transition-colors', gpuPageSize === n ? 'bg-zinc-700 text-zinc-200' : 'bg-zinc-900 text-zinc-600 hover:text-zinc-400 hover:bg-zinc-800')}>
{n}
</button>
))}
</div>
</div>
<div className="flex items-center gap-1">
<button onClick={() => setGpuPage(0)} disabled={safeGpuPage === 0}
className="px-2 py-1 rounded text-xs text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">«</button>
<button onClick={() => setGpuPage(p => Math.max(0, p - 1))} disabled={safeGpuPage === 0}
className="px-2 py-1 rounded text-xs text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"></button>
{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 === '…' ? (
<span key={`ge${idx}`} className="px-1 text-xs text-zinc-700"></span>
) : (
<button key={item} onClick={() => setGpuPage(item as number)}
className={cn('w-7 h-7 rounded text-xs font-medium transition-colors',
safeGpuPage === item ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800'
)}>{(item as number) + 1}</button>
)
)
}
<button onClick={() => setGpuPage(p => Math.min(gpuTotalPages - 1, p + 1))} disabled={safeGpuPage >= gpuTotalPages - 1}
className="px-2 py-1 rounded text-xs text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"></button>
<button onClick={() => setGpuPage(gpuTotalPages - 1)} disabled={safeGpuPage >= gpuTotalPages - 1}
className="px-2 py-1 rounded text-xs text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">»</button>
</div>
</div>
)}
</div>
)}
</CardContent>
)}
</Card>
)}
</main>
{/* Detail panel */}

View File

@@ -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 {

View File

@@ -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
}