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

View File

@@ -3,6 +3,7 @@ package backend
import ( import (
"context" "context"
"os/exec" "os/exec"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -34,15 +35,16 @@ func (s *SysInfo) startEmitter() {
return return
case <-ticker.C: case <-ticker.C:
info, _ := s.CollectOnce() info, _ := s.CollectOnce()
// try to attach GPU info if available // attach GPU global stats
name, tot, used, util := queryGPU(s.ctx) name, tot, used, util := queryGPU(s.ctx)
if name != "" { if name != "" {
info.GPUName = name info.GPUName = name
info.GPUTotal = tot info.GPUTotal = tot
info.GPUUsed = used info.GPUUsed = used
info.GPUUtil = util info.GPUUtil = util
// attach per-process GPU stats
info.GPUProcesses = queryGPUProcesses(s.ctx)
} }
// emit via Wails runtime
runtime.EventsEmit(s.ctx, "metrics", info) runtime.EventsEmit(s.ctx, "metrics", info)
} }
} }
@@ -55,6 +57,15 @@ type ProcessInfo struct {
Mem uint64 `json:"mem"` 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 { type Metrics struct {
CPUPercent float64 `json:"cpu_percent"` CPUPercent float64 `json:"cpu_percent"`
TotalMem uint64 `json:"total_mem"` TotalMem uint64 `json:"total_mem"`
@@ -65,6 +76,7 @@ type Metrics struct {
GPUTotal uint64 `json:"gpu_total_mem,omitempty"` GPUTotal uint64 `json:"gpu_total_mem,omitempty"`
GPUUsed uint64 `json:"gpu_used_mem,omitempty"` GPUUsed uint64 `json:"gpu_used_mem,omitempty"`
GPUUtil float64 `json:"gpu_util_percent,omitempty"` GPUUtil float64 `json:"gpu_util_percent,omitempty"`
GPUProcesses []GPUProcessInfo `json:"gpu_processes,omitempty"`
} }
// CollectOnce gathers a snapshot of system metrics. // CollectOnce gathers a snapshot of system metrics.
@@ -72,13 +84,10 @@ func (s *SysInfo) CollectOnce() (*Metrics, error) {
cpuPercents, _ := cpu.PercentWithContext(s.ctx, 0, false) cpuPercents, _ := cpu.PercentWithContext(s.ctx, 0, false)
vm, _ := mem.VirtualMemory() vm, _ := mem.VirtualMemory()
// collect top processes by CPU (sample few) // Collect ALL processes, then sort and keep top 50
procs, _ := ps.Processes() procs, _ := ps.Processes()
var list []ProcessInfo var list []ProcessInfo
for i, p := range procs { for _, p := range procs {
if i >= 30 {
break
}
name, _ := p.Name() name, _ := p.Name()
cpuPct, _ := p.CPUPercent() cpuPct, _ := p.CPUPercent()
memInfo, _ := p.MemoryInfo() 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}) 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 var cpuVal float64
if len(cpuPercents) > 0 { if len(cpuPercents) > 0 {
@@ -107,6 +123,52 @@ func (s *SysInfo) CollectOnce() (*Metrics, error) {
}, nil }, 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 // try to query GPU info via nvidia-smi; returns name, totalMB, usedMB, utilPercent
func queryGPU(ctx context.Context) (string, uint64, uint64, float64) { func queryGPU(ctx context.Context) (string, uint64, uint64, float64) {
// Use nvidia-smi if available // Use nvidia-smi if available
@@ -138,3 +200,108 @@ func queryGPU(ctx context.Context) (string, uint64, uint64, float64) {
func (s *SysInfo) GetMetrics() (*Metrics, error) { func (s *SysInfo) GetMetrics() (*Metrics, error) {
return s.CollectOnce() 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()
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,17 +8,22 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.378.0",
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "^18.3.0" "react-dom": "^18.3.0",
"tailwind-merge": "^2.6.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.0.0",
"@types/react": "^18.0.0", "@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0", "@types/react-dom": "^18.0.0",
"typescript": "^5.1.0",
"vite": "^5.0.0",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"tailwindcss": "^4.0.0", "autoprefixer": "^10.0.0",
"postcss": "^8.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 = { module.exports = {
plugins: { plugins: {
tailwindcss: {},
autoprefixer: {}, 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 { 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 = { type ProcessInfo = {
pid: number pid: number
@@ -8,6 +16,14 @@ type ProcessInfo = {
mem: number mem: number
} }
type GPUProcessInfo = {
pid: number
name: string
type: string
vram_mb: number
ram: number
}
type Metrics = { type Metrics = {
cpu_percent: number cpu_percent: number
total_mem: number total_mem: number
@@ -18,10 +34,396 @@ type Metrics = {
gpu_total_mem?: number gpu_total_mem?: number
gpu_used_mem?: number gpu_used_mem?: number
gpu_util_percent?: 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() { export default function App() {
const [metrics, setMetrics] = useState<Metrics | null>(null) const [metrics, setMetrics] = useState<Metrics | null>(null)
const [sortBy, setSortBy] = useState<SortField>('cpu')
const [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(() => { useEffect(() => {
const handler = (m: Metrics) => setMetrics(m) const handler = (m: Metrics) => setMetrics(m)
@@ -29,79 +431,405 @@ export default function App() {
return () => EventsOff('metrics') 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 ( return (
<div className="app bg-black min-h-screen text-neon"> <div className="min-h-screen bg-zinc-950 text-zinc-100 antialiased select-none">
<header className="p-6 flex items-center justify-between"> {/* Header */}
<h1 className="text-3xl font-extrabold tracking-tight">sysmon</h1> <header className="px-6 py-3 border-b border-zinc-800/70 flex items-center justify-between">
<div className="text-sm opacity-80">Realtime system monitor</div> <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> </header>
<main className="p-6 grid grid-cols-3 gap-6"> <main className="p-5 space-y-4 max-w-4xl mx-auto">
<section className="col-span-1 bg-[#071422] p-4 rounded-lg border border-[#2b2d42]/40"> {/* Processes */}
<h2 className="text-lg font-semibold mb-2">Overview</h2> <Card>
{metrics ? ( <CardHeader
<div> className="cursor-pointer select-none"
<div>CPU: {metrics.cpu_percent.toFixed(1)}%</div> onClick={() => setProcOpen(o => !o)}
<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> <div className="flex items-center justify-between">
{metrics.processes && metrics.processes.length > 0 && ( <div className="flex items-center gap-2">
<div> {procOpen
Top memory: { ? <ChevronDown className="w-3.5 h-3.5 text-zinc-600" />
(() => { : <ChevronRight className="w-3.5 h-3.5 text-zinc-600" />
const top = [...metrics.processes].sort((a, b) => b.mem - a.mem)[0]
return `${top.name} (${(top.mem / 1024 / 1024).toFixed(1)} MB)`
})()
} }
</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>
<div>Name: {metrics.gpu_name || 'N/A'}</div> <CardTitle>Processes</CardTitle>
<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> {procOpen && <p className="text-[10px] text-zinc-700 mt-1">Click a row for details</p>}
<div>Util: {metrics.gpu_util_percent ? metrics.gpu_util_percent.toFixed(1) + '%' : 'N/A'}</div>
</div> </div>
) : ( </div>
<div>Waiting for GPU</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> >
{field.toUpperCase()}
<section className="col-span-2 bg-[#071422] p-4 rounded-lg border border-[#2b2d42]/40"> {sortBy === field && <ChevronDown className="w-3 h-3 ml-0.5" />}
<h2 className="text-lg font-semibold mb-2">Top processes</h2> </button>
<div className="overflow-auto max-h-[60vh]"> ))}
<table className="w-full table-auto"> </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> <thead>
<tr className="text-left text-sm opacity-80"> <tr className="border-b border-zinc-800/80">
<th>PID</th> <th className="py-2 px-5 text-left text-xs font-medium text-zinc-600 uppercase tracking-wider w-16">PID</th>
<th>Name</th> <th className="py-2 text-left text-xs font-medium text-zinc-600 uppercase tracking-wider">Name</th>
<th>CPU</th> <th className="py-2 px-5 text-right text-xs font-medium text-zinc-600 uppercase tracking-wider w-28">CPU</th>
<th>Mem</th> <th className="py-2 px-5 text-right text-xs font-medium text-zinc-600 uppercase tracking-wider w-28">Memory</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{metrics?.processes.map((p) => ( {pagedProcesses.map((p) => (
<tr key={p.pid} className="odd:bg-black/20"> <tr
<td>{p.pid}</td> key={p.pid}
<td>{p.name}</td> onClick={() => setSelectedPid(p.pid)}
<td>{p.cpu.toFixed(1)}%</td> className={cn(
<td>{(p.mem / 1024 / 1024).toFixed(1)} MB</td> '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> </tr>
))} ))}
</tbody> </tbody>
</table> </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>
</section> </div>
)}
</div>
)}
</CardContent>
)}
</Card>
)}
</main> </main>
{/* Detail panel */}
{selectedPid !== null && (
<ProcessDetailPanel
pid={selectedPid}
onClose={() => setSelectedPid(null)}
/>
)}
</div> </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{ @tailwind base;
--neon: #39ff14; @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 = { module.exports = {
darkMode: ['class'],
content: ['./index.html', './src/**/*.{ts,tsx}'], content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: { theme: {
extend: { extend: {
colors: { fontFamily: {
neon: '#39ff14' 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
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 { defineConfig } from 'vite'
import path from 'path'
export default defineConfig(async () => { export default defineConfig(async () => {
const reactPlugin = (await import('@vitejs/plugin-react')).default const reactPlugin = (await import('@vitejs/plugin-react')).default
return { return {
plugins: [reactPlugin()], plugins: [reactPlugin()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: { 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 GetMetrics():Promise<backend.Metrics>;
export function GetProcessDetail(arg1:number):Promise<backend.ProcessDetail>;
export function Greet(arg1:string):Promise<string>; export function Greet(arg1:string):Promise<string>;
export function InitBackend(arg1:context.Context):Promise<void>; 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'](); return window['go']['main']['App']['GetMetrics']();
} }
export function GetProcessDetail(arg1) {
return window['go']['main']['App']['GetProcessDetail'](arg1);
}
export function Greet(arg1) { export function Greet(arg1) {
return window['go']['main']['App']['Greet'](arg1); return window['go']['main']['App']['Greet'](arg1);
} }
@@ -13,3 +17,7 @@ export function Greet(arg1) {
export function InitBackend(arg1) { export function InitBackend(arg1) {
return window['go']['main']['App']['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 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 { export class ProcessInfo {
pid: number; pid: number;
name: string; name: string;
@@ -28,6 +48,7 @@ export namespace backend {
gpu_total_mem?: number; gpu_total_mem?: number;
gpu_used_mem?: number; gpu_used_mem?: number;
gpu_util_percent?: number; gpu_util_percent?: number;
gpu_processes?: GPUProcessInfo[];
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new Metrics(source); return new Metrics(source);
@@ -44,6 +65,7 @@ export namespace backend {
this.gpu_total_mem = source["gpu_total_mem"]; this.gpu_total_mem = source["gpu_total_mem"];
this.gpu_used_mem = source["gpu_used_mem"]; this.gpu_used_mem = source["gpu_used_mem"];
this.gpu_util_percent = source["gpu_util_percent"]; 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 { convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -64,6 +86,62 @@ export namespace backend {
return a; 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"];
}
}
} }