feat: add network connections and SM utilization to process metrics

This commit is contained in:
Jonathan Atta
2026-03-11 18:46:36 +01:00
parent d58003feb7
commit 3ad8bf68a4
3 changed files with 146 additions and 70 deletions

View File

@@ -70,6 +70,7 @@ type ProcessInfo struct {
Mem uint64 `json:"mem"` Mem uint64 `json:"mem"`
ReadBps uint64 `json:"read_bps"` ReadBps uint64 `json:"read_bps"`
WriteBps uint64 `json:"write_bps"` WriteBps uint64 `json:"write_bps"`
NetConns int `json:"net_conns"`
} }
// GPUProcessInfo holds per-process GPU stats from nvidia-smi pmon. // GPUProcessInfo holds per-process GPU stats from nvidia-smi pmon.
@@ -79,6 +80,7 @@ type GPUProcessInfo struct {
Type string `json:"type"` // "C", "G", or "C+G" Type string `json:"type"` // "C", "G", or "C+G"
VRAM uint64 `json:"vram_mb"` // framebuffer memory in MB VRAM uint64 `json:"vram_mb"` // framebuffer memory in MB
RAM uint64 `json:"ram"` // resident set size in bytes RAM uint64 `json:"ram"` // resident set size in bytes
SMUtil uint32 `json:"sm_util"` // SM (CUDA core) utilization %
} }
type Metrics struct { type Metrics struct {
@@ -116,6 +118,16 @@ func (s *SysInfo) CollectOnce() (*Metrics, error) {
cpuPercents, _ := cpu.PercentWithContext(s.ctx, 0, false) cpuPercents, _ := cpu.PercentWithContext(s.ctx, 0, false)
vm, _ := mem.VirtualMemory() vm, _ := mem.VirtualMemory()
// Aggregate network connections per PID in one call
netConnByPid := make(map[int32]int)
if allConns, err := psnet.Connections("all"); err == nil {
for _, c := range allConns {
if c.Pid > 0 {
netConnByPid[c.Pid]++
}
}
}
// Collect all processes with disk I/O rates // Collect all processes with disk I/O rates
procs, _ := ps.Processes() procs, _ := ps.Processes()
newProcIO := make(map[int32][2]uint64, len(procs)) newProcIO := make(map[int32][2]uint64, len(procs))
@@ -149,6 +161,7 @@ func (s *SysInfo) CollectOnce() (*Metrics, error) {
Mem: memBytes, Mem: memBytes,
ReadBps: readBps, ReadBps: readBps,
WriteBps: writeBps, WriteBps: writeBps,
NetConns: netConnByPid[p.Pid],
}) })
} }
// Sort: primary by CPU desc, secondary by Mem desc // Sort: primary by CPU desc, secondary by Mem desc
@@ -221,7 +234,9 @@ func (s *SysInfo) CollectOnce() (*Metrics, error) {
// queryGPUProcesses lists all processes currently using the GPU via nvidia-smi pmon. // queryGPUProcesses lists all processes currently using the GPU via nvidia-smi pmon.
func queryGPUProcesses(ctx context.Context) []GPUProcessInfo { func queryGPUProcesses(ctx context.Context) []GPUProcessInfo {
cmd := exec.CommandContext(ctx, "nvidia-smi", "pmon", "-s", "m", "-c", "1") // -s mu: SM utilization + memory (fb)
// columns: gpuIdx pid type sm% mem% enc% dec% fbMB ccpmMB command...
cmd := exec.CommandContext(ctx, "nvidia-smi", "pmon", "-s", "mu", "-c", "1")
out, err := cmd.Output() out, err := cmd.Output()
if err != nil { if err != nil {
return nil return nil
@@ -233,19 +248,18 @@ func queryGPUProcesses(ctx context.Context) []GPUProcessInfo {
continue continue
} }
fields := strings.Fields(line) fields := strings.Fields(line)
// expected: [gpuIdx, pid, type, fbMB, ccpmMB, name...] // expected: [gpuIdx, pid, type, sm%, mem%, enc%, dec%, fbMB, ccpmMB, name...]
if len(fields) < 5 { if len(fields) < 9 {
continue continue
} }
pid64, err := strconv.ParseInt(fields[1], 10, 32) pid64, err := strconv.ParseInt(fields[1], 10, 32)
if err != nil { if err != nil {
continue continue
} }
vram, _ := strconv.ParseUint(fields[3], 10, 64) sm64, _ := strconv.ParseUint(fields[3], 10, 32)
name := "" vram, _ := strconv.ParseUint(fields[7], 10, 64)
if len(fields) >= 6 { name := strings.Join(fields[9:], " ")
name = strings.Join(fields[5:], " ") if name == "" {
} else {
name = fields[len(fields)-1] name = fields[len(fields)-1]
} }
gp := GPUProcessInfo{ gp := GPUProcessInfo{
@@ -253,6 +267,7 @@ func queryGPUProcesses(ctx context.Context) []GPUProcessInfo {
Name: name, Name: name,
Type: fields[2], Type: fields[2],
VRAM: vram, VRAM: vram,
SMUtil: uint32(sm64),
} }
// look up RAM (RSS) for this process // look up RAM (RSS) for this process
if p, err := ps.NewProcess(int32(pid64)); err == nil { if p, err := ps.NewProcess(int32(pid64)); err == nil {

View File

@@ -16,6 +16,7 @@ type ProcessInfo = {
mem: number mem: number
read_bps: number read_bps: number
write_bps: number write_bps: number
net_conns: number
} }
type GPUProcessInfo = { type GPUProcessInfo = {
@@ -24,6 +25,7 @@ type GPUProcessInfo = {
type: string type: string
vram_mb: number vram_mb: number
ram: number ram: number
sm_util: number
} }
type Metrics = { type Metrics = {
@@ -70,7 +72,7 @@ type ProcessDetail = {
conn_count: number conn_count: number
} }
type SortField = 'cpu' | 'mem' | 'disk' | 'vram' type SortField = 'pid' | 'name' | 'cpu' | 'mem' | 'disk' | 'net' | 'vram' | 'gpu'
type NavItem = { pid: number; name: string } type NavItem = { pid: number; name: string }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -244,7 +246,7 @@ function ProcessDetailPanel({
/> />
{/* Panel */} {/* Panel */}
<div className="fixed inset-y-0 right-0 w-[420px] z-50 bg-zinc-950 border-l border-zinc-800 shadow-2xl flex flex-col overflow-hidden"> <div className="fixed inset-y-0 right-0 w-full sm:w-[520px] lg:w-[600px] z-50 bg-zinc-950 border-l border-zinc-800 shadow-2xl flex flex-col overflow-hidden">
{/* Panel header */} {/* Panel header */}
<div className="flex items-start justify-between px-5 py-4 border-b border-zinc-800/80 shrink-0"> <div className="flex items-start justify-between px-5 py-4 border-b border-zinc-800/80 shrink-0">
<div className="min-w-0 flex-1 pr-4"> <div className="min-w-0 flex-1 pr-4">
@@ -523,7 +525,8 @@ function ProcessDetailPanel({
export default function App() { export default function App() {
const [metrics, setMetrics] = useState<Metrics | null>(null) const [metrics, setMetrics] = useState<Metrics | null>(null)
const [sortBy, setSortBy] = useState<SortField>('cpu') const [sortField, setSortField] = useState<SortField>('cpu')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
const [selectedPid, setSelectedPid] = useState<number | null>(null) const [selectedPid, setSelectedPid] = useState<number | null>(null)
const [page, setPage] = useState(0) const [page, setPage] = useState(0)
const [procOpen, setProcOpen] = useState(true) const [procOpen, setProcOpen] = useState(true)
@@ -543,8 +546,12 @@ export default function App() {
return () => EventsOff('metrics') return () => EventsOff('metrics')
}, []) }, [])
const toggleSort = useCallback((field: SortField) => { const handleSort = useCallback((field: SortField) => {
setSortBy(field) setSortField(prev => {
if (prev === field) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
else setSortDir('desc')
return field
})
setPage(0) setPage(0)
}, []) }, [])
@@ -565,10 +572,19 @@ export default function App() {
? [...metrics.processes] ? [...metrics.processes]
.filter(p => !search || p.name.toLowerCase().includes(searchLower) || String(p.pid).includes(search)) .filter(p => !search || p.name.toLowerCase().includes(searchLower) || String(p.pid).includes(search))
.sort((a, b) => { .sort((a, b) => {
if (sortBy === 'disk') return (b.read_bps + b.write_bps) - (a.read_bps + a.write_bps) let va: number | string = 0, vb: number | string = 0
if (sortBy === 'vram') return (gpuByPid.get(b.pid)?.vram_mb ?? 0) - (gpuByPid.get(a.pid)?.vram_mb ?? 0) switch (sortField) {
if (sortBy === 'cpu') return b.cpu - a.cpu case 'pid': va = a.pid; vb = b.pid; break
return b.mem - a.mem case 'name': va = a.name; vb = b.name; break
case 'cpu': va = a.cpu; vb = b.cpu; break
case 'mem': va = a.mem; vb = b.mem; break
case 'disk': va = a.read_bps + a.write_bps; vb = b.read_bps + b.write_bps; break
case 'net': va = a.net_conns; vb = b.net_conns; break
case 'vram': va = gpuByPid.get(a.pid)?.vram_mb ?? 0; vb = gpuByPid.get(b.pid)?.vram_mb ?? 0; break
case 'gpu': va = gpuByPid.get(a.pid)?.sm_util ?? 0; vb = gpuByPid.get(b.pid)?.sm_util ?? 0; break
}
if (typeof va === 'string') return sortDir === 'asc' ? va.localeCompare(vb as string) : (vb as string).localeCompare(va)
return sortDir === 'asc' ? (va as number) - (vb as number) : (vb as number) - (va as number)
}) })
: [] : []
const totalPages = Math.ceil(sortedProcesses.length / pageSize) const totalPages = Math.ceil(sortedProcesses.length / pageSize)
@@ -596,7 +612,7 @@ export default function App() {
</div> </div>
</header> </header>
<main className="p-5 space-y-4 max-w-4xl mx-auto"> <main className="px-3 py-4 sm:p-5 space-y-4 w-full">
{/* Search */} {/* Search */}
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-600 pointer-events-none" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-600 pointer-events-none" />
@@ -658,32 +674,13 @@ export default function App() {
</div> </div>
)} )}
</div> </div>
{procOpen && (
<div className="flex gap-1 ml-2">
{(['cpu', 'mem', 'disk', ...(metrics?.gpu_name ? ['vram'] : [])] as SortField[]).map((field) => (
<button
key={field}
onClick={() => toggleSort(field)}
className={cn(
'flex items-center gap-0.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors',
sortBy === field
? 'bg-zinc-800 text-zinc-100'
: 'text-zinc-600 hover:text-zinc-400 hover:bg-zinc-900'
)}
>
{field.toUpperCase()}
{sortBy === field && <ChevronDown className="w-3 h-3 ml-0.5" />}
</button>
))}
</div>
)}
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
{procOpen && ( {procOpen && (
<CardContent className="pt-1 px-0 pb-0"> <CardContent className="pt-1 px-0 pb-0">
{/* System resource bars */} {/* System resource bars */}
<div className="grid grid-cols-2 gap-4 px-5 pb-4 pt-2 border-b border-zinc-800/60"> <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3 px-4 pb-4 pt-2 border-b border-zinc-800/60">
<div> <div>
<div className="flex justify-between text-xs text-zinc-600 mb-1.5"> <div className="flex justify-between text-xs text-zinc-600 mb-1.5">
<span className="flex items-center gap-1"><Cpu className="w-3 h-3" /> CPU</span> <span className="flex items-center gap-1"><Cpu className="w-3 h-3" /> CPU</span>
@@ -731,15 +728,45 @@ export default function App() {
</div> </div>
)} )}
</div> </div>
<table className="w-full text-sm"> <div className="overflow-x-auto">
<table className="w-full text-sm min-w-[560px]">
<thead> <thead>
<tr className="border-b border-zinc-800/80"> <tr className="border-b border-zinc-800/80">
<th className="py-2 px-5 text-left text-xs font-medium text-zinc-600 uppercase tracking-wider w-16 cursor-help" title="Process ID — unique identifier assigned by the OS">PID</th> {([
<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> { f: 'pid', label: 'PID', title: 'Process ID', align: 'left' },
<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> { f: 'name', label: 'Name', title: 'Executable name', align: 'left' },
<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> { f: 'cpu', label: 'CPU', title: 'CPU % of one core', align: 'right' },
<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> { f: 'mem', label: 'Mem', title: 'Memory % of total RAM', align: 'right' },
{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>} { f: 'disk', label: 'Disk', title: 'Disk I/O throughput', align: 'right' },
{ f: 'net', label: 'Net', title: 'Active network connections', align: 'right' },
...(metrics?.gpu_name ? [
{ f: 'gpu', label: 'GPU%', title: 'GPU SM (shader) utilization', align: 'right' },
{ f: 'vram', label: 'VRAM', title: 'GPU VRAM used by this process', align: 'right' },
{ f: 'type', label: 'Type', title: 'C=Compute G=Graphics C+G=both', align: 'center' },
] : []),
] as Array<{ f: string; label: string; title: string; align: string }>).map(({ f, label, title, align }) => {
const sortable = f !== 'type'
const active = sortField === f
return (
<th key={f} title={title}
onClick={sortable ? () => handleSort(f as SortField) : undefined}
className={cn(
'py-2 px-3 text-xs font-medium uppercase tracking-wider select-none',
align === 'right' ? 'text-right' : align === 'center' ? 'text-center' : 'text-left',
sortable ? 'cursor-pointer' : '',
active ? 'text-zinc-300' : 'text-zinc-600 hover:text-zinc-400'
)}
>
<span className={cn('inline-flex items-center gap-0.5', align === 'right' && 'justify-end', align === 'center' && 'justify-center')}>
{label}
{sortable && (active
? <ChevronDown className={cn('w-3 h-3 shrink-0 transition-transform', sortDir === 'asc' && 'rotate-180')} />
: <ChevronDown className="w-3 h-3 shrink-0 opacity-20" />
)}
</span>
</th>
)
})}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -754,8 +781,8 @@ export default function App() {
: 'hover:bg-zinc-900/60' : 'hover:bg-zinc-900/60'
)} )}
> >
<td className="py-2 px-5 text-zinc-700 text-xs font-mono">{p.pid}</td> <td className="py-2 px-3 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-3 text-zinc-300 truncate max-w-[160px]">{p.name}</td>
<td className="py-2 px-3 text-right"> <td className="py-2 px-3 text-right">
<div className="flex flex-col items-end gap-0.5"> <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')}> <span className={cn('tabular-nums font-mono text-xs', p.cpu >= 50 ? 'text-red-400' : p.cpu >= 20 ? 'text-amber-400' : 'text-zinc-500')}>
@@ -788,11 +815,40 @@ export default function App() {
<span className="font-mono text-xs text-zinc-800"></span> <span className="font-mono text-xs text-zinc-800"></span>
)} )}
</td> </td>
{metrics?.gpu_name && (gpuByPid.has(p.pid) ? ( <td className="py-2 px-3 text-right">
{p.net_conns > 0 ? (
<div className="flex flex-col items-end gap-0.5">
<span className="tabular-nums font-mono text-[10px] text-zinc-500">{p.net_conns}</span>
<div className="w-12 h-1 bg-zinc-800 rounded-full overflow-hidden">
<div className="h-full bg-emerald-600/40 rounded-full" style={{ width: `${Math.min(100, p.net_conns / 50 * 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">
{gpuByPid.get(p.pid)!.sm_util > 0 ? (
<div className="flex flex-col items-end gap-0.5">
<span className={cn('tabular-nums font-mono text-xs',
gpuByPid.get(p.pid)!.sm_util >= 50 ? 'text-violet-400' : 'text-violet-600'
)}>{gpuByPid.get(p.pid)!.sm_util}%</span>
<div className="w-12 h-1 bg-zinc-800 rounded-full overflow-hidden">
<div className="h-full bg-violet-500/40 rounded-full" style={{ width: `${Math.min(100, gpuByPid.get(p.pid)!.sm_util)}%` }} />
</div>
</div>
) : <span className="font-mono text-xs text-zinc-800"></span>}
</td>
<td className="py-2 px-3 text-right"> <td className="py-2 px-3 text-right">
<div className="flex flex-col items-end gap-0.5"> <div className="flex flex-col items-end gap-0.5">
<span className="tabular-nums font-mono text-[10px] text-zinc-500">{gpuByPid.get(p.pid)!.vram_mb} MB</span>
<div className="w-12 h-1 bg-zinc-800 rounded-full overflow-hidden">
<div className="h-full bg-violet-600/40 rounded-full" style={{ width: `${metrics.gpu_total_mem ? Math.min(100, gpuByPid.get(p.pid)!.vram_mb * 1024 * 1024 / metrics.gpu_total_mem * 100) : 0}%` }} />
</div>
</div>
</td>
<td className="py-2 px-3 text-center">
<span <span
title={gpuByPid.get(p.pid)!.type === 'C' ? 'Compute (CUDA/OpenCL)' : gpuByPid.get(p.pid)!.type === 'G' ? 'Graphics (rendering)' : 'Compute + Graphics'} title={gpuByPid.get(p.pid)!.type === 'C' ? 'Compute (CUDA/OpenCL/Vulkan)' : gpuByPid.get(p.pid)!.type === 'G' ? 'Graphics (display/rendering)' : 'Compute + Graphics'}
className={cn('text-[10px] font-mono px-1 py-0.5 rounded cursor-help', 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 === 'C' ? 'bg-violet-900/50 text-violet-300' :
gpuByPid.get(p.pid)!.type === 'G' ? 'bg-blue-900/50 text-blue-300' : gpuByPid.get(p.pid)!.type === 'G' ? 'bg-blue-900/50 text-blue-300' :
@@ -800,23 +856,24 @@ export default function App() {
)}> )}>
{gpuByPid.get(p.pid)!.type} {gpuByPid.get(p.pid)!.type}
</span> </span>
<span className="tabular-nums font-mono text-[10px] text-zinc-600">{gpuByPid.get(p.pid)!.vram_mb} MB</span>
</div>
</td> </td>
) : ( </>) : (<>
<td className="py-2 px-3 text-right text-zinc-800 text-xs"></td> <td className="py-2 px-3 text-right text-zinc-800 text-xs"></td>
))} <td className="py-2 px-3 text-right text-zinc-800 text-xs"></td>
<td className="py-2 px-3 text-center text-zinc-800 text-xs"></td>
</>))}
</tr> </tr>
))} ))}
{pagedProcesses.length === 0 && ( {pagedProcesses.length === 0 && (
<tr> <tr>
<td colSpan={metrics?.gpu_name ? 6 : 5} className="py-10 text-center text-zinc-700 text-sm"> <td colSpan={metrics?.gpu_name ? 9 : 6} className="py-10 text-center text-zinc-700 text-sm">
Waiting for metrics Waiting for metrics
</td> </td>
</tr> </tr>
)} )}
</tbody> </tbody>
</table> </table>
</div>
{/* Pagination */} {/* Pagination */}
{totalPages > 0 && ( {totalPages > 0 && (

View File

@@ -6,6 +6,7 @@ export namespace backend {
type: string; type: string;
vram_mb: number; vram_mb: number;
ram: number; ram: number;
sm_util: number;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new GPUProcessInfo(source); return new GPUProcessInfo(source);
@@ -18,6 +19,7 @@ export namespace backend {
this.type = source["type"]; this.type = source["type"];
this.vram_mb = source["vram_mb"]; this.vram_mb = source["vram_mb"];
this.ram = source["ram"]; this.ram = source["ram"];
this.sm_util = source["sm_util"];
} }
} }
export class ProcessInfo { export class ProcessInfo {
@@ -27,6 +29,7 @@ export namespace backend {
mem: number; mem: number;
read_bps: number; read_bps: number;
write_bps: number; write_bps: number;
net_conns: number;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new ProcessInfo(source); return new ProcessInfo(source);
@@ -40,6 +43,7 @@ export namespace backend {
this.mem = source["mem"]; this.mem = source["mem"];
this.read_bps = source["read_bps"]; this.read_bps = source["read_bps"];
this.write_bps = source["write_bps"]; this.write_bps = source["write_bps"];
this.net_conns = source["net_conns"];
} }
} }
export class Metrics { export class Metrics {