feat: add Card and Progress components, utility functions, and improve styles
- Introduced Card component with CardHeader, CardTitle, and CardContent subcomponents for better UI structure. - Added Progress component to visually represent progress with customizable thresholds. - Created utility function `cn` for conditional class names using clsx and tailwind-merge. - Updated styles to integrate Tailwind CSS and improved base styles for better consistency. - Configured Tailwind CSS for dark mode and extended theme with custom fonts and animations. - Added TypeScript configuration for better type safety and module resolution. - Enhanced Vite configuration with path aliasing for cleaner imports. - Updated Wails backend definitions to include new process detail and GPU process info structures.
This commit is contained in:
@@ -3,6 +3,7 @@ package backend
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -34,15 +35,16 @@ func (s *SysInfo) startEmitter() {
|
||||
return
|
||||
case <-ticker.C:
|
||||
info, _ := s.CollectOnce()
|
||||
// try to attach GPU info if available
|
||||
// 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)
|
||||
}
|
||||
// emit via Wails runtime
|
||||
runtime.EventsEmit(s.ctx, "metrics", info)
|
||||
}
|
||||
}
|
||||
@@ -55,6 +57,15 @@ type ProcessInfo struct {
|
||||
Mem uint64 `json:"mem"`
|
||||
}
|
||||
|
||||
// GPUProcessInfo holds per-process GPU stats from nvidia-smi pmon.
|
||||
type GPUProcessInfo struct {
|
||||
PID int32 `json:"pid"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // "C", "G", or "C+G"
|
||||
VRAM uint64 `json:"vram_mb"` // framebuffer memory in MB
|
||||
RAM uint64 `json:"ram"` // resident set size in bytes
|
||||
}
|
||||
|
||||
type Metrics struct {
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
TotalMem uint64 `json:"total_mem"`
|
||||
@@ -65,6 +76,7 @@ type Metrics struct {
|
||||
GPUTotal uint64 `json:"gpu_total_mem,omitempty"`
|
||||
GPUUsed uint64 `json:"gpu_used_mem,omitempty"`
|
||||
GPUUtil float64 `json:"gpu_util_percent,omitempty"`
|
||||
GPUProcesses []GPUProcessInfo `json:"gpu_processes,omitempty"`
|
||||
}
|
||||
|
||||
// CollectOnce gathers a snapshot of system metrics.
|
||||
@@ -72,13 +84,10 @@ func (s *SysInfo) CollectOnce() (*Metrics, error) {
|
||||
cpuPercents, _ := cpu.PercentWithContext(s.ctx, 0, false)
|
||||
vm, _ := mem.VirtualMemory()
|
||||
|
||||
// collect top processes by CPU (sample few)
|
||||
// Collect ALL processes, then sort and keep top 50
|
||||
procs, _ := ps.Processes()
|
||||
var list []ProcessInfo
|
||||
for i, p := range procs {
|
||||
if i >= 30 {
|
||||
break
|
||||
}
|
||||
for _, p := range procs {
|
||||
name, _ := p.Name()
|
||||
cpuPct, _ := p.CPUPercent()
|
||||
memInfo, _ := p.MemoryInfo()
|
||||
@@ -88,6 +97,13 @@ func (s *SysInfo) CollectOnce() (*Metrics, error) {
|
||||
}
|
||||
list = append(list, ProcessInfo{PID: p.Pid, Name: name, CPU: cpuPct, Mem: memBytes})
|
||||
}
|
||||
// Sort: primary by CPU desc, secondary by Mem desc
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
if list[i].CPU != list[j].CPU {
|
||||
return list[i].CPU > list[j].CPU
|
||||
}
|
||||
return list[i].Mem > list[j].Mem
|
||||
})
|
||||
|
||||
var cpuVal float64
|
||||
if len(cpuPercents) > 0 {
|
||||
@@ -107,6 +123,52 @@ func (s *SysInfo) CollectOnce() (*Metrics, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// queryGPUProcesses lists all processes currently using the GPU via nvidia-smi pmon.
|
||||
func queryGPUProcesses(ctx context.Context) []GPUProcessInfo {
|
||||
cmd := exec.CommandContext(ctx, "nvidia-smi", "pmon", "-s", "m", "-c", "1")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var result []GPUProcessInfo
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
// expected: [gpuIdx, pid, type, fbMB, ccpmMB, name...]
|
||||
if len(fields) < 5 {
|
||||
continue
|
||||
}
|
||||
pid64, err := strconv.ParseInt(fields[1], 10, 32)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
vram, _ := strconv.ParseUint(fields[3], 10, 64)
|
||||
name := ""
|
||||
if len(fields) >= 6 {
|
||||
name = strings.Join(fields[5:], " ")
|
||||
} else {
|
||||
name = fields[len(fields)-1]
|
||||
}
|
||||
gp := GPUProcessInfo{
|
||||
PID: int32(pid64),
|
||||
Name: name,
|
||||
Type: fields[2],
|
||||
VRAM: vram,
|
||||
}
|
||||
// look up RAM (RSS) for this process
|
||||
if p, err := ps.NewProcess(int32(pid64)); err == nil {
|
||||
if mi, err := p.MemoryInfoWithContext(ctx); err == nil && mi != nil {
|
||||
gp.RAM = mi.RSS
|
||||
}
|
||||
}
|
||||
result = append(result, gp)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// try to query GPU info via nvidia-smi; returns name, totalMB, usedMB, utilPercent
|
||||
func queryGPU(ctx context.Context) (string, uint64, uint64, float64) {
|
||||
// Use nvidia-smi if available
|
||||
@@ -138,3 +200,108 @@ func queryGPU(ctx context.Context) (string, uint64, uint64, float64) {
|
||||
func (s *SysInfo) GetMetrics() (*Metrics, error) {
|
||||
return s.CollectOnce()
|
||||
}
|
||||
// ProcessDetail contains all available details about a single process.
|
||||
type ProcessDetail struct {
|
||||
PID int32 `json:"pid"`
|
||||
Name string `json:"name"`
|
||||
Exe string `json:"exe"`
|
||||
Cmdline string `json:"cmdline"`
|
||||
Cwd string `json:"cwd"`
|
||||
Status string `json:"status"`
|
||||
Username string `json:"username"`
|
||||
CreatedAt int64 `json:"created_at"` // unix ms
|
||||
ParentPID int32 `json:"parent_pid"`
|
||||
Nice int32 `json:"nice"`
|
||||
NumThreads int32 `json:"num_threads"`
|
||||
NumFDs int32 `json:"num_fds"`
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
RSS uint64 `json:"rss"`
|
||||
VMS uint64 `json:"vms"`
|
||||
Swap uint64 `json:"swap"`
|
||||
MemPercent float32 `json:"mem_percent"`
|
||||
ReadBytes uint64 `json:"read_bytes"`
|
||||
WriteBytes uint64 `json:"write_bytes"`
|
||||
ReadOps uint64 `json:"read_ops"`
|
||||
WriteOps uint64 `json:"write_ops"`
|
||||
OpenFilesCount int `json:"open_files_count"`
|
||||
ConnCount int `json:"conn_count"`
|
||||
}
|
||||
|
||||
// FetchProcessDetail collects all available details for a given PID.
|
||||
// Fields that can't be read (e.g. permission denied) are left at zero/empty.
|
||||
func FetchProcessDetail(pid int32) (*ProcessDetail, error) {
|
||||
p, err := ps.NewProcess(pid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d := &ProcessDetail{PID: pid}
|
||||
if v, e := p.Name(); e == nil {
|
||||
d.Name = v
|
||||
}
|
||||
if v, e := p.Exe(); e == nil {
|
||||
d.Exe = v
|
||||
}
|
||||
if v, e := p.Cmdline(); e == nil {
|
||||
d.Cmdline = v
|
||||
}
|
||||
if v, e := p.Cwd(); e == nil {
|
||||
d.Cwd = v
|
||||
}
|
||||
if vv, e := p.Status(); e == nil && len(vv) > 0 {
|
||||
d.Status = vv[0]
|
||||
}
|
||||
if v, e := p.Username(); e == nil {
|
||||
d.Username = v
|
||||
}
|
||||
if v, e := p.CreateTime(); e == nil {
|
||||
d.CreatedAt = v
|
||||
}
|
||||
if v, e := p.Ppid(); e == nil {
|
||||
d.ParentPID = v
|
||||
}
|
||||
if v, e := p.Nice(); e == nil {
|
||||
d.Nice = v
|
||||
}
|
||||
if v, e := p.NumThreads(); e == nil {
|
||||
d.NumThreads = v
|
||||
}
|
||||
if v, e := p.NumFDs(); e == nil {
|
||||
d.NumFDs = v
|
||||
}
|
||||
if v, e := p.CPUPercent(); e == nil {
|
||||
d.CPUPercent = v
|
||||
}
|
||||
if v, e := p.MemoryInfo(); e == nil && v != nil {
|
||||
d.RSS = v.RSS
|
||||
d.VMS = v.VMS
|
||||
d.Swap = v.Swap
|
||||
}
|
||||
if v, e := p.MemoryPercent(); e == nil {
|
||||
d.MemPercent = v
|
||||
}
|
||||
if v, e := p.IOCounters(); e == nil && v != nil {
|
||||
d.ReadBytes = v.ReadBytes
|
||||
d.WriteBytes = v.WriteBytes
|
||||
d.ReadOps = v.ReadCount
|
||||
d.WriteOps = v.WriteCount
|
||||
}
|
||||
if v, e := p.OpenFiles(); e == nil {
|
||||
d.OpenFilesCount = len(v)
|
||||
}
|
||||
if v, e := p.Connections(); e == nil {
|
||||
d.ConnCount = len(v)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// KillProcess sends SIGTERM to the process. Use force=true to send SIGKILL.
|
||||
func KillProcess(pid int32, force bool) error {
|
||||
p, err := ps.NewProcess(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if force {
|
||||
return p.Kill()
|
||||
}
|
||||
return p.Terminate()
|
||||
}
|
||||
948
frontend/package-lock.json
generated
948
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,17 +8,22 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.378.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0"
|
||||
"react-dom": "^18.3.0",
|
||||
"tailwind-merge": "^2.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"typescript": "^5.1.0",
|
||||
"vite": "^5.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"autoprefixer": "^10.0.0",
|
||||
"postcss": "^8.0.0",
|
||||
"autoprefixer": "^10.0.0"
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.1.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
f1342afc80cffcb2c13d3ea5fe75c35d
|
||||
2a33c55ea617ea7dc270228763d7ace6
|
||||
@@ -1,5 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
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 } from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ProcessInfo = {
|
||||
pid: number
|
||||
@@ -8,6 +16,14 @@ type ProcessInfo = {
|
||||
mem: number
|
||||
}
|
||||
|
||||
type GPUProcessInfo = {
|
||||
pid: number
|
||||
name: string
|
||||
type: string
|
||||
vram_mb: number
|
||||
ram: number
|
||||
}
|
||||
|
||||
type Metrics = {
|
||||
cpu_percent: number
|
||||
total_mem: number
|
||||
@@ -18,10 +34,396 @@ type Metrics = {
|
||||
gpu_total_mem?: number
|
||||
gpu_used_mem?: number
|
||||
gpu_util_percent?: number
|
||||
gpu_processes?: GPUProcessInfo[]
|
||||
}
|
||||
|
||||
type ProcessDetail = {
|
||||
pid: number
|
||||
name: string
|
||||
exe: string
|
||||
cmdline: string
|
||||
cwd: string
|
||||
status: string
|
||||
username: string
|
||||
created_at: number
|
||||
parent_pid: number
|
||||
nice: number
|
||||
num_threads: number
|
||||
num_fds: number
|
||||
cpu_percent: number
|
||||
rss: number
|
||||
vms: number
|
||||
swap: number
|
||||
mem_percent: number
|
||||
read_bytes: number
|
||||
write_bytes: number
|
||||
read_ops: number
|
||||
write_ops: number
|
||||
open_files_count: number
|
||||
conn_count: number
|
||||
}
|
||||
|
||||
type SortField = 'cpu' | 'mem'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fmtBytes(bytes: number): string {
|
||||
if (bytes <= 0) return '0 B'
|
||||
const gb = bytes / 1024 / 1024 / 1024
|
||||
if (gb >= 1) return `${gb.toFixed(1)} GB`
|
||||
const mb = bytes / 1024 / 1024
|
||||
if (mb >= 1) return `${mb.toFixed(0)} MB`
|
||||
return `${(bytes / 1024).toFixed(0)} KB`
|
||||
}
|
||||
|
||||
function fmtTime(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
|
||||
function fmtRelative(ts: number): string {
|
||||
const secs = Math.floor((Date.now() - ts) / 1000)
|
||||
if (secs < 60) return `${secs}s ago`
|
||||
const mins = Math.floor(secs / 60)
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hrs = Math.floor(mins / 60)
|
||||
const remMins = mins % 60
|
||||
if (hrs < 24) return remMins > 0 ? `${hrs}h ${remMins}m ago` : `${hrs}h ago`
|
||||
const days = Math.floor(hrs / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
function fmtStatus(code: string): string {
|
||||
const map: Record<string, string> = {
|
||||
R: 'Running', S: 'Sleeping', D: 'Disk wait', Z: 'Zombie',
|
||||
T: 'Stopped', t: 'Tracing', X: 'Dead', I: 'Idle',
|
||||
}
|
||||
return map[code] ?? code
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MetricValue({ value, pct }: { value: string; pct: number }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'text-2xl font-bold tabular-nums',
|
||||
pct >= 90 ? 'text-red-400' : pct >= 70 ? 'text-amber-400' : 'text-zinc-100'
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailRow({ label, value, mono = false }: { label: string; value: React.ReactNode; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-zinc-600">{label}</span>
|
||||
<span className={cn('text-xs text-zinc-300 break-all', mono && 'font-mono')}>{value || '—'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-zinc-600 mb-3 mt-4 first:mt-0">
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function StatBlock({ label, value, sub }: { label: string; value: React.ReactNode; sub?: string }) {
|
||||
return (
|
||||
<div className="bg-zinc-800/40 rounded-lg px-3 py-2.5">
|
||||
<p className="text-[10px] text-zinc-600 uppercase tracking-widest mb-1">{label}</p>
|
||||
<p className="text-sm font-semibold text-zinc-100 tabular-nums">{value}</p>
|
||||
{sub && <p className="text-[10px] text-zinc-600 mt-0.5">{sub}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Process detail panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ProcessDetailPanel({
|
||||
pid,
|
||||
onClose,
|
||||
}: {
|
||||
pid: number
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [detail, setDetail] = useState<ProcessDetail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [killing, setKilling] = useState(false)
|
||||
const [killConfirm, setKillConfirm] = useState(false)
|
||||
const [killError, setKillError] = useState<string | null>(null)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
// Use low-level Wails IPC — binding will be regenerated automatically
|
||||
const d = await (window as any)['go']['main']['App']['GetProcessDetail'](pid) as ProcessDetail
|
||||
setDetail(d)
|
||||
} catch {
|
||||
setDetail(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [pid])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
intervalRef.current = setInterval(load, 2000)
|
||||
return () => { if (intervalRef.current) clearInterval(intervalRef.current) }
|
||||
}, [load])
|
||||
|
||||
const killProcess = useCallback(async (force: boolean) => {
|
||||
setKilling(true)
|
||||
setKillError(null)
|
||||
try {
|
||||
await (window as any)['go']['main']['App']['KillProcess'](pid, force)
|
||||
// stop refreshing — process is gone
|
||||
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||
onClose()
|
||||
} catch (e: any) {
|
||||
setKillError(String(e?.message ?? e))
|
||||
setKillConfirm(false)
|
||||
} finally {
|
||||
setKilling(false)
|
||||
}
|
||||
}, [pid, onClose])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 z-40 backdrop-blur-[1px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 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">
|
||||
{/* Panel header */}
|
||||
<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="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-semibold text-zinc-100 truncate">
|
||||
{detail?.name ?? `PID ${pid}`}
|
||||
</span>
|
||||
{detail?.status && (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 text-[10px] font-mono px-1.5 py-0.5 rounded',
|
||||
detail.status === 'R'
|
||||
? 'bg-emerald-900/50 text-emerald-400'
|
||||
: detail.status === 'Z'
|
||||
? 'bg-red-900/50 text-red-400'
|
||||
: 'bg-zinc-800 text-zinc-500'
|
||||
)}
|
||||
>
|
||||
{fmtStatus(detail.status)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-zinc-600">
|
||||
PID {pid}
|
||||
{detail?.username ? ` · ${detail.username}` : ''}
|
||||
{detail?.created_at ? ` · started ${fmtRelative(detail.created_at)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={load}
|
||||
className="p-1.5 rounded-md text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800 transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', loading && 'animate-spin')} />
|
||||
</button>
|
||||
|
||||
{/* Kill button */}
|
||||
{!killConfirm ? (
|
||||
<button
|
||||
onClick={() => setKillConfirm(true)}
|
||||
disabled={killing}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium text-red-500/70 hover:text-red-400 hover:bg-red-900/20 transition-colors"
|
||||
title="Terminate process"
|
||||
>
|
||||
<OctagonX className="w-3.5 h-3.5" />
|
||||
Kill
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-zinc-500 mr-1">Sure?</span>
|
||||
<button
|
||||
onClick={() => killProcess(false)}
|
||||
disabled={killing}
|
||||
className="px-2 py-1 rounded-md text-xs font-semibold text-amber-400 hover:bg-amber-900/20 transition-colors"
|
||||
title="SIGTERM (graceful)"
|
||||
>
|
||||
TERM
|
||||
</button>
|
||||
<button
|
||||
onClick={() => killProcess(true)}
|
||||
disabled={killing}
|
||||
className="px-2 py-1 rounded-md text-xs font-semibold text-red-400 hover:bg-red-900/20 transition-colors"
|
||||
title="SIGKILL (force)"
|
||||
>
|
||||
KILL
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setKillConfirm(false)}
|
||||
className="p-1 rounded-md text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-md text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1">
|
||||
{loading && !detail ? (
|
||||
<p className="text-xs text-zinc-700 py-10 text-center">Loading…</p>
|
||||
) : !detail ? (
|
||||
<p className="text-xs text-red-500/70 py-10 text-center">Process not found or permission denied.</p>
|
||||
) : (
|
||||
<>
|
||||
{killError && (
|
||||
<div className="mb-4 px-3 py-2 rounded-md bg-red-900/20 border border-red-800/40 text-xs text-red-400">
|
||||
Kill failed: {killError}
|
||||
</div>
|
||||
)}
|
||||
{/* Identity */}
|
||||
<SectionTitle>Identity</SectionTitle>
|
||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||
<StatBlock label="Parent PID" value={detail.parent_pid > 0 ? detail.parent_pid : '—'} />
|
||||
<StatBlock label="Nice / Priority" value={detail.nice} />
|
||||
<StatBlock
|
||||
label="Created"
|
||||
value={detail.created_at ? new Date(detail.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '—'}
|
||||
sub={detail.created_at ? fmtRelative(detail.created_at) : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Paths */}
|
||||
<SectionTitle>Executable</SectionTitle>
|
||||
<div className="space-y-3 mb-4">
|
||||
<DetailRow label="Path" value={detail.exe} mono />
|
||||
<DetailRow
|
||||
label="Command"
|
||||
value={detail.cmdline || detail.exe}
|
||||
mono
|
||||
/>
|
||||
{detail.cwd && <DetailRow label="Working directory" value={detail.cwd} mono />}
|
||||
</div>
|
||||
|
||||
{/* CPU & Memory */}
|
||||
<SectionTitle>CPU & Memory</SectionTitle>
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
<StatBlock
|
||||
label="CPU"
|
||||
value={`${detail.cpu_percent.toFixed(1)}%`}
|
||||
/>
|
||||
<StatBlock
|
||||
label="Threads"
|
||||
value={detail.num_threads}
|
||||
sub={`${detail.num_fds} open FDs`}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||
<StatBlock
|
||||
label="RSS (physical)"
|
||||
value={fmtBytes(detail.rss)}
|
||||
/>
|
||||
<StatBlock
|
||||
label="VMS (virtual)"
|
||||
value={fmtBytes(detail.vms)}
|
||||
/>
|
||||
<StatBlock
|
||||
label="Swap"
|
||||
value={fmtBytes(detail.swap)}
|
||||
sub={`${detail.mem_percent.toFixed(1)}% of RAM`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* I/O */}
|
||||
{(detail.read_bytes > 0 || detail.write_bytes > 0) && (
|
||||
<>
|
||||
<SectionTitle>Disk I/O</SectionTitle>
|
||||
<div className="grid grid-cols-2 gap-2 mb-4">
|
||||
<StatBlock
|
||||
label="Read"
|
||||
value={fmtBytes(detail.read_bytes)}
|
||||
sub={`${detail.read_ops.toLocaleString()} ops`}
|
||||
/>
|
||||
<StatBlock
|
||||
label="Write"
|
||||
value={fmtBytes(detail.write_bytes)}
|
||||
sub={`${detail.write_ops.toLocaleString()} ops`}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Files & Network */}
|
||||
<SectionTitle>Files & Network</SectionTitle>
|
||||
<div className="grid grid-cols-2 gap-2 mb-4">
|
||||
<StatBlock
|
||||
label="Open files"
|
||||
value={detail.open_files_count}
|
||||
/>
|
||||
<StatBlock
|
||||
label="Connections"
|
||||
value={detail.conn_count}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-2 border-t border-zinc-800/60 shrink-0">
|
||||
<p className="text-[10px] text-zinc-700">Auto-refreshes every 2s · Press Esc to close</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main App
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function App() {
|
||||
const [metrics, setMetrics] = useState<Metrics | null>(null)
|
||||
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 PAGE_SIZE = 20
|
||||
const GPU_PAGE_SIZE = 4
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (m: Metrics) => setMetrics(m)
|
||||
@@ -29,79 +431,405 @@ export default function App() {
|
||||
return () => EventsOff('metrics')
|
||||
}, [])
|
||||
|
||||
// No simulator: real metrics only
|
||||
const toggleSort = useCallback((field: SortField) => {
|
||||
setSortBy(field)
|
||||
setPage(0)
|
||||
}, [])
|
||||
|
||||
const usedMem = metrics ? metrics.total_mem - metrics.free_mem : 0
|
||||
const memPct = metrics ? (usedMem / metrics.total_mem) * 100 : 0
|
||||
const cpuPct = metrics?.cpu_percent ?? 0
|
||||
const gpuUsedPct =
|
||||
metrics?.gpu_total_mem && metrics?.gpu_used_mem
|
||||
? (metrics.gpu_used_mem / metrics.gpu_total_mem) * 100
|
||||
: 0
|
||||
const gpuUtilPct = metrics?.gpu_util_percent ?? 0
|
||||
|
||||
const gpuProcesses = [...(metrics?.gpu_processes ?? [])].sort((a, b) =>
|
||||
gpuSortBy === 'vram' ? b.vram_mb - a.vram_mb : b.ram - a.ram
|
||||
)
|
||||
const gpuTotalPages = Math.ceil(gpuProcesses.length / GPU_PAGE_SIZE)
|
||||
const safeGpuPage = Math.min(gpuPage, Math.max(0, gpuTotalPages - 1))
|
||||
const pagedGpuProcesses = gpuProcesses.slice(safeGpuPage * GPU_PAGE_SIZE, (safeGpuPage + 1) * GPU_PAGE_SIZE)
|
||||
|
||||
const sortedProcesses = metrics
|
||||
? [...metrics.processes].sort((a, b) => b[sortBy] - a[sortBy])
|
||||
: []
|
||||
const totalPages = Math.ceil(sortedProcesses.length / PAGE_SIZE)
|
||||
const safePage = Math.min(page, Math.max(0, totalPages - 1))
|
||||
const pagedProcesses = sortedProcesses.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE)
|
||||
|
||||
return (
|
||||
<div className="app bg-black min-h-screen text-neon">
|
||||
<header className="p-6 flex items-center justify-between">
|
||||
<h1 className="text-3xl font-extrabold tracking-tight">sysmon</h1>
|
||||
<div className="text-sm opacity-80">Realtime system monitor</div>
|
||||
<div className="min-h-screen bg-zinc-950 text-zinc-100 antialiased select-none">
|
||||
{/* Header */}
|
||||
<header className="px-6 py-3 border-b border-zinc-800/70 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Activity className="w-4 h-4 text-emerald-400" />
|
||||
<span className="text-sm font-semibold tracking-tight">sysmon</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'h-1.5 w-1.5 rounded-full',
|
||||
metrics ? 'bg-emerald-500 animate-pulse' : 'bg-zinc-600'
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-zinc-600">
|
||||
{metrics ? fmtTime(metrics.timestamp) : 'connecting…'}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="p-6 grid grid-cols-3 gap-6">
|
||||
<section className="col-span-1 bg-[#071422] p-4 rounded-lg border border-[#2b2d42]/40">
|
||||
<h2 className="text-lg font-semibold mb-2">Overview</h2>
|
||||
{metrics ? (
|
||||
<div>
|
||||
<div>CPU: {metrics.cpu_percent.toFixed(1)}%</div>
|
||||
<div>Memory: {((metrics.total_mem - metrics.free_mem) / (1024 * 1024)).toFixed(0)} MB used</div>
|
||||
<div>Memory free: {(metrics.free_mem / (1024 * 1024)).toFixed(0)} MB</div>
|
||||
{metrics.processes && metrics.processes.length > 0 && (
|
||||
<div>
|
||||
Top memory: {
|
||||
(() => {
|
||||
const top = [...metrics.processes].sort((a, b) => b.mem - a.mem)[0]
|
||||
return `${top.name} (${(top.mem / 1024 / 1024).toFixed(1)} MB)`
|
||||
})()
|
||||
<main className="p-5 space-y-4 max-w-4xl mx-auto">
|
||||
{/* Processes */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
className="cursor-pointer select-none"
|
||||
onClick={() => setProcOpen(o => !o)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{procOpen
|
||||
? <ChevronDown className="w-3.5 h-3.5 text-zinc-600" />
|
||||
: <ChevronRight className="w-3.5 h-3.5 text-zinc-600" />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
<div>Last: {new Date(metrics.timestamp).toLocaleTimeString()}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>Waiting for metrics…</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="col-span-1 bg-[#071422] p-4 rounded-lg border border-[#2b2d42]/40">
|
||||
<h2 className="text-lg font-semibold mb-2">GPU</h2>
|
||||
{metrics ? (
|
||||
<div>
|
||||
<div>Name: {metrics.gpu_name || 'N/A'}</div>
|
||||
<div>GPU Memory: {metrics.gpu_used_mem ? (metrics.gpu_used_mem / 1024 / 1024).toFixed(0) + ' MB used / ' + (metrics.gpu_total_mem! / 1024 / 1024).toFixed(0) + ' MB' : 'N/A'}</div>
|
||||
<div>Util: {metrics.gpu_util_percent ? metrics.gpu_util_percent.toFixed(1) + '%' : 'N/A'}</div>
|
||||
<CardTitle>Processes</CardTitle>
|
||||
{procOpen && <p className="text-[10px] text-zinc-700 mt-1">Click a row for details</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div>Waiting for GPU…</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4" onClick={e => e.stopPropagation()}>
|
||||
{/* Summary values always visible */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-[10px] text-zinc-600 mb-0.5">CPU</p>
|
||||
<span className={cn('text-sm font-bold tabular-nums', cpuPct >= 90 ? 'text-red-400' : cpuPct >= 70 ? 'text-amber-400' : 'text-zinc-100')}>{cpuPct.toFixed(1)}%</span>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
{procOpen && (
|
||||
<div className="flex gap-1 ml-2">
|
||||
{(['cpu', 'mem'] 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'
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="col-span-2 bg-[#071422] p-4 rounded-lg border border-[#2b2d42]/40">
|
||||
<h2 className="text-lg font-semibold mb-2">Top processes</h2>
|
||||
<div className="overflow-auto max-h-[60vh]">
|
||||
<table className="w-full table-auto">
|
||||
>
|
||||
{field.toUpperCase()}
|
||||
{sortBy === field && <ChevronDown className="w-3 h-3 ml-0.5" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{procOpen && (
|
||||
<CardContent className="pt-1 px-0 pb-0">
|
||||
{/* System resource bars */}
|
||||
<div className="grid grid-cols-2 gap-4 px-5 pb-4 pt-2 border-b border-zinc-800/60">
|
||||
<div>
|
||||
<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={cn('font-mono', cpuPct >= 90 ? 'text-red-400' : cpuPct >= 70 ? 'text-amber-400' : 'text-zinc-400')}>{cpuPct.toFixed(1)}%</span>
|
||||
</div>
|
||||
<Progress value={cpuPct} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-xs text-zinc-600 mb-1.5">
|
||||
<span className="flex items-center gap-1"><Database className="w-3 h-3" /> Memory</span>
|
||||
<span className={cn('font-mono', memPct >= 90 ? 'text-red-400' : memPct >= 70 ? 'text-amber-400' : 'text-zinc-400')}>{fmtBytes(usedMem)} / {metrics ? fmtBytes(metrics.total_mem) : '—'}</span>
|
||||
</div>
|
||||
<Progress value={memPct} />
|
||||
</div>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-sm opacity-80">
|
||||
<th>PID</th>
|
||||
<th>Name</th>
|
||||
<th>CPU</th>
|
||||
<th>Mem</th>
|
||||
<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">PID</th>
|
||||
<th className="py-2 text-left text-xs font-medium text-zinc-600 uppercase tracking-wider">Name</th>
|
||||
<th className="py-2 px-5 text-right text-xs font-medium text-zinc-600 uppercase tracking-wider w-28">CPU</th>
|
||||
<th className="py-2 px-5 text-right text-xs font-medium text-zinc-600 uppercase tracking-wider w-28">Memory</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{metrics?.processes.map((p) => (
|
||||
<tr key={p.pid} className="odd:bg-black/20">
|
||||
<td>{p.pid}</td>
|
||||
<td>{p.name}</td>
|
||||
<td>{p.cpu.toFixed(1)}%</td>
|
||||
<td>{(p.mem / 1024 / 1024).toFixed(1)} MB</td>
|
||||
{pagedProcesses.map((p) => (
|
||||
<tr
|
||||
key={p.pid}
|
||||
onClick={() => setSelectedPid(p.pid)}
|
||||
className={cn(
|
||||
'border-b border-zinc-900 transition-colors cursor-pointer',
|
||||
selectedPid === p.pid
|
||||
? 'bg-zinc-800/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 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>
|
||||
<td className="py-2 px-5 text-right">
|
||||
<span className="tabular-nums font-mono text-xs text-zinc-500">
|
||||
{fmtBytes(p.mem)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{pagedProcesses.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="py-10 text-center text-zinc-700 text-sm">
|
||||
Waiting for metrics…
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-5 py-3 border-t border-zinc-800/60">
|
||||
<span className="text-xs text-zinc-600">
|
||||
{safePage * PAGE_SIZE + 1}–{Math.min((safePage + 1) * PAGE_SIZE, sortedProcesses.length)} of {sortedProcesses.length}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setPage(0)}
|
||||
disabled={safePage === 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={() => setPage(p => Math.max(0, p - 1))}
|
||||
disabled={safePage === 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: totalPages }, (_, i) => i)
|
||||
.filter(i => i === 0 || i === totalPages - 1 || Math.abs(i - safePage) <= 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={`e${idx}`} className="px-1 text-xs text-zinc-700">…</span>
|
||||
) : (
|
||||
<button
|
||||
key={item}
|
||||
onClick={() => setPage(item as number)}
|
||||
className={cn(
|
||||
'w-7 h-7 rounded text-xs font-medium transition-colors',
|
||||
safePage === 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={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={safePage >= totalPages - 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={() => setPage(totalPages - 1)}
|
||||
disabled={safePage >= totalPages - 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>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</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">Name</th>
|
||||
<th className="pb-1.5 text-left text-xs font-medium text-zinc-700 uppercase tracking-wider w-16">Type</th>
|
||||
<th className="pb-1.5 text-right text-xs font-medium text-zinc-700 uppercase tracking-wider w-24">VRAM</th>
|
||||
<th className="pb-1.5 text-right text-xs font-medium text-zinc-700 uppercase tracking-wider w-28">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={() => setSelectedPid(p.pid)}
|
||||
>
|
||||
<td className="py-1.5 text-zinc-300 text-xs truncate max-w-[180px]">{p.name}</td>
|
||||
<td className="py-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block px-1.5 py-0.5 rounded text-xs font-mono font-semibold',
|
||||
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 > 1 && (
|
||||
<div className="flex items-center justify-between pt-2 mt-1 border-t border-zinc-900">
|
||||
<span className="text-xs text-zinc-600">
|
||||
{safeGpuPage * GPU_PAGE_SIZE + 1}–{Math.min((safeGpuPage + 1) * GPU_PAGE_SIZE, gpuProcesses.length)} of {gpuProcesses.length}
|
||||
</span>
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Detail panel */}
|
||||
{selectedPid !== null && (
|
||||
<ProcessDetailPanel
|
||||
pid={selectedPid}
|
||||
onClose={() => setSelectedPid(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
43
frontend/src/components/ui/card.tsx
Normal file
43
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-xl border border-zinc-800 bg-zinc-900/60 text-zinc-100 shadow-sm backdrop-blur-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1 p-5 pb-3', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('text-xs font-semibold uppercase tracking-widest text-zinc-500', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-5 pt-0', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardContent }
|
||||
40
frontend/src/components/ui/progress.tsx
Normal file
40
frontend/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value: number
|
||||
max?: number
|
||||
colorThresholds?: { warn: number; crit: number }
|
||||
}
|
||||
|
||||
function getBarColor(pct: number, warn: number, crit: number): string {
|
||||
if (pct >= crit) return 'bg-red-500'
|
||||
if (pct >= warn) return 'bg-amber-400'
|
||||
return 'bg-emerald-500'
|
||||
}
|
||||
|
||||
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
|
||||
({ value, max = 100, colorThresholds = { warn: 70, crit: 90 }, className, ...props }, ref) => {
|
||||
const pct = Math.min(100, Math.max(0, (value / max) * 100))
|
||||
const { warn, crit } = colorThresholds
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="progressbar"
|
||||
aria-valuenow={pct}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className={cn('relative h-1.5 w-full overflow-hidden rounded-full bg-zinc-800', className)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all duration-500 ease-out', getBarColor(pct, warn, crit))}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Progress.displayName = 'Progress'
|
||||
|
||||
export { Progress }
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
:root{
|
||||
--neon: #39ff14;
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-zinc-950 text-zinc-100 antialiased;
|
||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
body{font-family: Inter, ui-sans-serif, system-ui; background:#000; color:#e6eef0}
|
||||
.text-neon{color:var(--neon)}
|
||||
|
||||
/* Minimal layout for dev without Tailwind */
|
||||
.app{min-height:100vh}
|
||||
header{padding:1.5rem; display:flex; align-items:center; justify-content:space-between}
|
||||
main{padding:1.5rem; display:grid; grid-template-columns:1fr 2fr; gap:1.5rem}
|
||||
section{background:#071422; padding:1rem; border-radius:0.5rem; border:1px solid rgba(43,45,66,0.4)}
|
||||
table{width:100%; border-collapse:collapse}
|
||||
thead tr{opacity:0.85; font-size:0.9rem}
|
||||
tbody tr:nth-child(odd){background:rgba(0,0,0,0.08)}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ['class'],
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
neon: '#39ff14'
|
||||
}
|
||||
}
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
|
||||
},
|
||||
plugins: []
|
||||
animation: {
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
24
frontend/tsconfig.json
Normal file
24
frontend/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig(async () => {
|
||||
const reactPlugin = (await import('@vitejs/plugin-react')).default
|
||||
return {
|
||||
plugins: [reactPlugin()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: '../frontend/dist'
|
||||
}
|
||||
outDir: '../frontend/dist',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
4
frontend/wailsjs/go/main/App.d.ts
vendored
4
frontend/wailsjs/go/main/App.d.ts
vendored
@@ -5,6 +5,10 @@ import {context} from '../models';
|
||||
|
||||
export function GetMetrics():Promise<backend.Metrics>;
|
||||
|
||||
export function GetProcessDetail(arg1:number):Promise<backend.ProcessDetail>;
|
||||
|
||||
export function Greet(arg1:string):Promise<string>;
|
||||
|
||||
export function InitBackend(arg1:context.Context):Promise<void>;
|
||||
|
||||
export function KillProcess(arg1:number,arg2:boolean):Promise<void>;
|
||||
|
||||
@@ -6,6 +6,10 @@ export function GetMetrics() {
|
||||
return window['go']['main']['App']['GetMetrics']();
|
||||
}
|
||||
|
||||
export function GetProcessDetail(arg1) {
|
||||
return window['go']['main']['App']['GetProcessDetail'](arg1);
|
||||
}
|
||||
|
||||
export function Greet(arg1) {
|
||||
return window['go']['main']['App']['Greet'](arg1);
|
||||
}
|
||||
@@ -13,3 +17,7 @@ export function Greet(arg1) {
|
||||
export function InitBackend(arg1) {
|
||||
return window['go']['main']['App']['InitBackend'](arg1);
|
||||
}
|
||||
|
||||
export function KillProcess(arg1, arg2) {
|
||||
return window['go']['main']['App']['KillProcess'](arg1, arg2);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
export namespace backend {
|
||||
|
||||
export class GPUProcessInfo {
|
||||
pid: number;
|
||||
name: string;
|
||||
type: string;
|
||||
vram_mb: number;
|
||||
ram: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new GPUProcessInfo(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.pid = source["pid"];
|
||||
this.name = source["name"];
|
||||
this.type = source["type"];
|
||||
this.vram_mb = source["vram_mb"];
|
||||
this.ram = source["ram"];
|
||||
}
|
||||
}
|
||||
export class ProcessInfo {
|
||||
pid: number;
|
||||
name: string;
|
||||
@@ -28,6 +48,7 @@ export namespace backend {
|
||||
gpu_total_mem?: number;
|
||||
gpu_used_mem?: number;
|
||||
gpu_util_percent?: number;
|
||||
gpu_processes?: GPUProcessInfo[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Metrics(source);
|
||||
@@ -44,6 +65,7 @@ export namespace backend {
|
||||
this.gpu_total_mem = source["gpu_total_mem"];
|
||||
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);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
@@ -64,6 +86,62 @@ export namespace backend {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class ProcessDetail {
|
||||
pid: number;
|
||||
name: string;
|
||||
exe: string;
|
||||
cmdline: string;
|
||||
cwd: string;
|
||||
status: string;
|
||||
username: string;
|
||||
created_at: number;
|
||||
parent_pid: number;
|
||||
nice: number;
|
||||
num_threads: number;
|
||||
num_fds: number;
|
||||
cpu_percent: number;
|
||||
rss: number;
|
||||
vms: number;
|
||||
swap: number;
|
||||
mem_percent: number;
|
||||
read_bytes: number;
|
||||
write_bytes: number;
|
||||
read_ops: number;
|
||||
write_ops: number;
|
||||
open_files_count: number;
|
||||
conn_count: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ProcessDetail(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.pid = source["pid"];
|
||||
this.name = source["name"];
|
||||
this.exe = source["exe"];
|
||||
this.cmdline = source["cmdline"];
|
||||
this.cwd = source["cwd"];
|
||||
this.status = source["status"];
|
||||
this.username = source["username"];
|
||||
this.created_at = source["created_at"];
|
||||
this.parent_pid = source["parent_pid"];
|
||||
this.nice = source["nice"];
|
||||
this.num_threads = source["num_threads"];
|
||||
this.num_fds = source["num_fds"];
|
||||
this.cpu_percent = source["cpu_percent"];
|
||||
this.rss = source["rss"];
|
||||
this.vms = source["vms"];
|
||||
this.swap = source["swap"];
|
||||
this.mem_percent = source["mem_percent"];
|
||||
this.read_bytes = source["read_bytes"];
|
||||
this.write_bytes = source["write_bytes"];
|
||||
this.read_ops = source["read_ops"];
|
||||
this.write_ops = source["write_ops"];
|
||||
this.open_files_count = source["open_files_count"];
|
||||
this.conn_count = source["conn_count"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user