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:
Jonathan Atta
2026-03-11 17:09:25 +01:00
parent 17beab746e
commit 9ba9b80b8b
16 changed files with 2156 additions and 107 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1 +1 @@
f1342afc80cffcb2c13d3ea5fe75c35d
2a33c55ea617ea7dc270228763d7ace6

View File

@@ -1,5 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -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 &amp; 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 &amp; 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 && (
<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>
Top memory: {
(() => {
const top = [...metrics.processes].sort((a, b) => b.mem - a.mem)[0]
return `${top.name} (${(top.mem / 1024 / 1024).toFixed(1)} MB)`
})()
}
<CardTitle>Processes</CardTitle>
{procOpen && <p className="text-[10px] text-zinc-700 mt-1">Click a row for details</p>}
</div>
)}
<div>Last: {new Date(metrics.timestamp).toLocaleTimeString()}</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'
)}
>
{field.toUpperCase()}
{sortBy === field && <ChevronDown className="w-3 h-3 ml-0.5" />}
</button>
))}
</div>
)}
</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>
</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>
) : (
<div>Waiting for GPU</div>
)}
</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">
<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>
</div>
</section>
{/* 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>
</div>
)}
</div>
)}
</CardContent>
)}
</Card>
)}
</main>
{/* Detail panel */}
{selectedPid !== null && (
<ProcessDetailPanel
pid={selectedPid}
onClose={() => setSelectedPid(null)}
/>
)}
</div>
)
}

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

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

View 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))
}

View File

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

View File

@@ -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'],
},
animation: {
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
},
},
plugins: []
plugins: [],
}

24
frontend/tsconfig.json Normal file
View 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"]
}

View File

@@ -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',
},
}
})

View File

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

View File

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

View File

@@ -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"];
}
}
}