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:
@@ -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 && (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
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)}
|
||||
|
||||
Reference in New Issue
Block a user