feat: add system monitor panel (btop-style) (#455)

* feat: add system monitor panel with live CPU, memory, disk, and GPU metrics

New btop-inspired monitoring page in the OBSERVE sidebar group.
Polls /api/system-monitor every 2s and renders rolling Recharts area
charts for CPU/memory/GPU and progress bars for disk usage.

* feat: add network I/O monitoring to system monitor panel

Add live network throughput (rx/tx bytes/sec) as a dual area chart.
API returns cumulative byte counters per interface (stateless), frontend
computes rates from deltas between 2s polls. Supports Linux (/proc/net/dev)
and macOS (netstat -ib).

* feat: add top processes list to system monitor

Shows top 8 processes by CPU usage in a compact table with CPU%, Mem%,
and RSS columns. Color-codes high usage values. Uses ps on both Linux
and macOS with fallback for platforms without --sort support.

* fix: normalize process CPU% to 0-100 and filter out ps itself

CPU% from ps is per-core (e.g. 200% on 4 cores), now divided by core
count to show total system percentage. Also filters out the ps command
that briefly appears while collecting process data.

---------

Co-authored-by: Dan <github@bookkept.com.au>
This commit is contained in:
danielaustralia1 2026-03-20 02:40:55 +11:00 committed by GitHub
parent 8431992866
commit 465cd96107
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 840 additions and 0 deletions

View File

@ -35,6 +35,7 @@ import { DebugPanel } from '@/components/panels/debug-panel'
import { SecurityAuditPanel } from '@/components/panels/security-audit-panel'
import { NodesPanel } from '@/components/panels/nodes-panel'
import { ExecApprovalPanel } from '@/components/panels/exec-approval-panel'
import { SystemMonitorPanel } from '@/components/panels/system-monitor-panel'
import { ChatPagePanel } from '@/components/panels/chat-page-panel'
import { ChatPanel } from '@/components/chat/chat-panel'
import { getPluginPanel } from '@/lib/plugins'
@ -555,6 +556,8 @@ function ContentRouter({ tab }: { tab: string }) {
return <GitHubSyncPanel />
case 'office':
return <OfficePanel />
case 'monitor':
return <SystemMonitorPanel />
case 'skills':
return <SkillsPanel />
case 'channels':

View File

@ -0,0 +1,398 @@
import { NextRequest, NextResponse } from 'next/server'
import os from 'node:os'
import { runCommand } from '@/lib/command'
import { requireRole } from '@/lib/auth'
import { logger } from '@/lib/logger'
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const [cpu, memory, disk, gpu, network, processes] = await Promise.all([
getCpuSnapshot(),
getMemorySnapshot(),
getDiskSnapshot(),
getGpuSnapshot(),
getNetworkSnapshot(),
getProcessSnapshot(),
])
return NextResponse.json({
timestamp: Date.now(),
cpu,
memory,
disk,
gpu,
network,
processes,
})
} catch (error) {
logger.error({ err: error }, 'System monitor API error')
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
// ── CPU ─────────────────────────────────────────────────────────────────────
/** Sample CPU ticks twice ~100ms apart to compute instantaneous usage % */
async function getCpuSnapshot() {
const cpus = os.cpus()
const model = cpus[0]?.model || 'Unknown'
const cores = cpus.length
const loadAvg = os.loadavg() as [number, number, number]
const sample1 = cpuTotals()
await new Promise(r => setTimeout(r, 100))
const sample2 = cpuTotals()
const idleDelta = sample2.idle - sample1.idle
const totalDelta = sample2.total - sample1.total
const usagePercent = totalDelta > 0 ? Math.round((1 - idleDelta / totalDelta) * 100) : 0
return { usagePercent, cores, model, loadAvg }
}
function cpuTotals() {
let idle = 0
let total = 0
for (const cpu of os.cpus()) {
const t = cpu.times
idle += t.idle
total += t.user + t.nice + t.sys + t.idle + t.irq
}
return { idle, total }
}
// ── Memory ──────────────────────────────────────────────────────────────────
async function getMemorySnapshot() {
const totalBytes = os.totalmem()
let availableBytes = os.freemem()
// More accurate available memory per platform
if (process.platform === 'darwin') {
try {
const { stdout } = await runCommand('vm_stat', [], { timeoutMs: 3000 })
const pageSizeMatch = stdout.match(/page size of (\d+) bytes/i)
const pageSize = parseInt(pageSizeMatch?.[1] || '4096', 10)
const pageLabels = ['Pages free', 'Pages inactive', 'Pages speculative', 'Pages purgeable']
const availablePages = pageLabels.reduce((sum, label) => {
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const match = stdout.match(new RegExp(`${escaped}:\\s+([\\d.]+)`, 'i'))
const pages = parseInt((match?.[1] || '0').replace(/\./g, ''), 10)
return sum + (Number.isFinite(pages) ? pages : 0)
}, 0)
const vmAvailable = availablePages * pageSize
if (vmAvailable > 0) availableBytes = Math.min(vmAvailable, totalBytes)
} catch { /* fallback to os.freemem() */ }
} else {
try {
const { stdout } = await runCommand('free', ['-b'], { timeoutMs: 3000 })
const memLine = stdout.split('\n').find(l => l.startsWith('Mem:'))
if (memLine) {
const parts = memLine.trim().split(/\s+/)
const available = parseInt(parts[6] || parts[3] || '0', 10)
if (Number.isFinite(available) && available > 0) {
availableBytes = Math.min(available, totalBytes)
}
}
} catch { /* fallback */ }
}
const usedBytes = Math.max(0, totalBytes - availableBytes)
const usagePercent = totalBytes > 0 ? Math.round((usedBytes / totalBytes) * 100) : 0
// Swap
let swapTotalBytes = 0
let swapUsedBytes = 0
if (process.platform === 'darwin') {
try {
const { stdout } = await runCommand('sysctl', ['-n', 'vm.swapusage'], { timeoutMs: 3000 })
// Output: "total = 2048.00M used = 1024.00M free = 1024.00M ..."
const totalMatch = stdout.match(/total\s*=\s*([\d.]+)M/i)
const usedMatch = stdout.match(/used\s*=\s*([\d.]+)M/i)
if (totalMatch) swapTotalBytes = parseFloat(totalMatch[1]) * 1024 * 1024
if (usedMatch) swapUsedBytes = parseFloat(usedMatch[1]) * 1024 * 1024
} catch { /* no swap info */ }
} else {
try {
const { stdout } = await runCommand('free', ['-b'], { timeoutMs: 3000 })
const swapLine = stdout.split('\n').find(l => l.startsWith('Swap:'))
if (swapLine) {
const parts = swapLine.trim().split(/\s+/)
swapTotalBytes = parseInt(parts[1] || '0', 10)
swapUsedBytes = parseInt(parts[2] || '0', 10)
}
} catch { /* no swap info */ }
}
return { totalBytes, usedBytes, availableBytes, usagePercent, swapTotalBytes, swapUsedBytes }
}
// ── Disk ────────────────────────────────────────────────────────────────────
async function getDiskSnapshot() {
const disks: Array<{
mountpoint: string
totalBytes: number
usedBytes: number
availableBytes: number
usagePercent: number
}> = []
try {
const { stdout } = await runCommand('df', ['-k'], { timeoutMs: 3000 })
const lines = stdout.trim().split('\n').slice(1) // skip header
for (const line of lines) {
const parts = line.trim().split(/\s+/)
if (parts.length < 6) continue
const mountpoint = parts[parts.length - 1]
// Skip virtual/system filesystems
if (mountpoint.startsWith('/dev') || mountpoint.startsWith('/System') ||
mountpoint.startsWith('/private/var/vm') || mountpoint === '/boot/efi') continue
// Only include real mounts
if (!parts[0].startsWith('/') && !parts[0].includes(':')) continue
const totalKB = parseInt(parts[1], 10)
const usedKB = parseInt(parts[2], 10)
const availableKB = parseInt(parts[3], 10)
if (!Number.isFinite(totalKB) || totalKB <= 0) continue
disks.push({
mountpoint,
totalBytes: totalKB * 1024,
usedBytes: usedKB * 1024,
availableBytes: availableKB * 1024,
usagePercent: Math.round((usedKB / totalKB) * 100),
})
}
} catch (err) {
logger.error({ err }, 'Error reading disk info')
}
return disks
}
// ── GPU ─────────────────────────────────────────────────────────────────────
async function getGpuSnapshot(): Promise<Array<{
name: string
memoryTotalMB: number
memoryUsedMB: number
usagePercent: number
}> | null> {
// Try NVIDIA first (Linux/macOS with discrete GPU)
try {
const { stdout, code } = await runCommand('nvidia-smi', [
'--query-gpu=name,memory.total,memory.used',
'--format=csv,noheader,nounits',
], { timeoutMs: 3000 })
if (code === 0 && stdout.trim()) {
const gpus = stdout.trim().split('\n').map(line => {
const [name, totalStr, usedStr] = line.split(',').map(s => s.trim())
const memoryTotalMB = parseInt(totalStr, 10)
const memoryUsedMB = parseInt(usedStr, 10)
return {
name,
memoryTotalMB,
memoryUsedMB,
usagePercent: memoryTotalMB > 0 ? Math.round((memoryUsedMB / memoryTotalMB) * 100) : 0,
}
})
if (gpus.length > 0) return gpus
}
} catch { /* nvidia-smi not available */ }
// macOS: system_profiler for GPU info (VRAM only, no live usage)
if (process.platform === 'darwin') {
try {
const { stdout } = await runCommand('system_profiler', ['SPDisplaysDataType', '-json'], { timeoutMs: 5000 })
const data = JSON.parse(stdout)
const displays = data?.SPDisplaysDataType
if (Array.isArray(displays)) {
const gpus = displays.map((gpu: any) => {
const name = gpu.sppci_model || 'Unknown GPU'
// VRAM string like "8 GB" or "16384 MB"
const vramStr: string = gpu.spdisplays_vram || gpu.spdisplays_vram_shared || ''
let memoryTotalMB = 0
const gbMatch = vramStr.match(/([\d.]+)\s*GB/i)
const mbMatch = vramStr.match(/([\d.]+)\s*MB/i)
if (gbMatch) memoryTotalMB = parseFloat(gbMatch[1]) * 1024
else if (mbMatch) memoryTotalMB = parseFloat(mbMatch[1])
return {
name,
memoryTotalMB: Math.round(memoryTotalMB),
memoryUsedMB: 0, // macOS doesn't expose live GPU memory usage easily
usagePercent: 0,
}
}).filter((g: any) => g.memoryTotalMB > 0)
if (gpus.length > 0) return gpus
}
} catch { /* system_profiler failed */ }
}
return null
}
// ── Network ──────────────────────────────────────────────────────────────────
/** Return cumulative rx/tx byte counters per interface (stateless — frontend computes rates) */
async function getNetworkSnapshot(): Promise<Array<{
interface: string
rxBytes: number
txBytes: number
}>> {
// Linux: parse /proc/net/dev
if (process.platform === 'linux') {
try {
const fs = await import('node:fs/promises')
const content = await fs.readFile('/proc/net/dev', 'utf-8')
const lines = content.trim().split('\n').slice(2) // skip 2 header lines
const interfaces: Array<{ interface: string; rxBytes: number; txBytes: number }> = []
for (const line of lines) {
const [name, rest] = line.split(':')
if (!name || !rest) continue
const iface = name.trim()
if (iface === 'lo') continue // skip loopback
const cols = rest.trim().split(/\s+/)
const rxBytes = parseInt(cols[0], 10)
const txBytes = parseInt(cols[8], 10)
if (Number.isFinite(rxBytes) && Number.isFinite(txBytes)) {
interfaces.push({ interface: iface, rxBytes, txBytes })
}
}
return interfaces
} catch { /* fallthrough to empty */ }
}
// macOS: parse netstat -ib
if (process.platform === 'darwin') {
try {
const { stdout } = await runCommand('netstat', ['-ib'], { timeoutMs: 3000 })
const lines = stdout.trim().split('\n')
if (lines.length < 2) return []
// Find column indices from header
const header = lines[0]
const cols = header.split(/\s+/)
const nameIdx = 0
const ibytesIdx = cols.indexOf('Ibytes')
const obytesIdx = cols.indexOf('Obytes')
if (ibytesIdx === -1 || obytesIdx === -1) return []
// Deduplicate: keep highest counters per interface (multiple address families)
const ifaceMap = new Map<string, { rxBytes: number; txBytes: number }>()
for (const line of lines.slice(1)) {
const parts = line.split(/\s+/)
const iface = parts[nameIdx]
if (!iface || iface === 'lo0') continue
const rxBytes = parseInt(parts[ibytesIdx], 10)
const txBytes = parseInt(parts[obytesIdx], 10)
if (!Number.isFinite(rxBytes) || !Number.isFinite(txBytes)) continue
const existing = ifaceMap.get(iface)
if (!existing || rxBytes > existing.rxBytes) {
ifaceMap.set(iface, { rxBytes, txBytes })
}
}
return Array.from(ifaceMap.entries()).map(([iface, data]) => ({
interface: iface,
...data,
}))
} catch { /* fallthrough */ }
}
return []
}
// ── Processes ────────────────────────────────────────────────────────────────
const MAX_PROCESSES = 8
/** Return top processes by CPU usage (normalized to 0-100%) */
async function getProcessSnapshot(): Promise<Array<{
pid: number
name: string
cpuPercent: number
memPercent: number
memBytes: number
}>> {
const coreCount = os.cpus().length || 1
function parsePsOutput(stdout: string) {
const lines = stdout.trim().split('\n').slice(1) // skip header
const results: Array<{
pid: number
name: string
cpuPercent: number
memPercent: number
memBytes: number
}> = []
for (const line of lines) {
const parts = line.trim().split(/\s+/, 4)
const rest = line.trim().split(/\s+/).slice(4).join(' ')
if (parts.length < 4 || !rest) continue
const pid = parseInt(parts[0], 10)
const rawCpu = parseFloat(parts[1])
const memPercent = parseFloat(parts[2])
const rssKB = parseInt(parts[3], 10)
if (!Number.isFinite(pid)) continue
// Get just the command name (last path segment)
const name = rest.split('/').pop() || rest
// Filter out the ps command itself
if (name === 'ps') continue
results.push({
pid,
name,
// Normalize: ps reports per-core %, so 200% on 4 cores = 50% total
cpuPercent: Number.isFinite(rawCpu) ? Math.round((rawCpu / coreCount) * 10) / 10 : 0,
memPercent: Number.isFinite(memPercent) ? memPercent : 0,
memBytes: Number.isFinite(rssKB) ? rssKB * 1024 : 0,
})
}
return results
}
try {
// Linux ps supports --sort
const { stdout } = await runCommand('ps', [
'axo', 'pid,pcpu,pmem,rss,comm',
'--sort=-pcpu',
], { timeoutMs: 3000 })
return parsePsOutput(stdout).slice(0, MAX_PROCESSES)
} catch {
// macOS ps doesn't support --sort, sort manually
try {
const { stdout } = await runCommand('ps', [
'axo', 'pid,pcpu,pmem,rss,comm',
], { timeoutMs: 3000 })
const parsed = parsePsOutput(stdout)
parsed.sort((a, b) => b.cpuPercent - a.cpuPercent)
return parsed.slice(0, MAX_PROCESSES)
} catch {
return []
}
}
}

View File

@ -47,6 +47,7 @@ const navGroups: NavGroup[] = [
{ id: 'nodes', label: 'Nodes', icon: <NodesIcon />, priority: false },
{ id: 'exec-approvals', label: 'Approvals', icon: <ApprovalsIcon />, priority: false },
{ id: 'office', label: 'Office', icon: <OfficeIcon />, priority: false },
{ id: 'monitor', label: 'Monitor', icon: <MonitorIcon />, priority: false },
],
},
{
@ -1500,3 +1501,13 @@ function PluginIcon() {
</svg>
)
}
function MonitorIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="1" y="2" width="14" height="10" rx="1.5" />
<polyline points="4,9 6,6 8,8 12,4" />
<path d="M5 14h6" />
</svg>
)
}

View File

@ -0,0 +1,428 @@
'use client'
import { useState, useCallback, useRef } from 'react'
import { useSmartPoll } from '@/lib/use-smart-poll'
import { AreaChart, Area, BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, CartesianGrid } from 'recharts'
interface CpuData {
usagePercent: number
cores: number
model: string
loadAvg: [number, number, number]
}
interface MemoryData {
totalBytes: number
usedBytes: number
availableBytes: number
usagePercent: number
swapTotalBytes: number
swapUsedBytes: number
}
interface DiskData {
mountpoint: string
totalBytes: number
usedBytes: number
availableBytes: number
usagePercent: number
}
interface GpuData {
name: string
memoryTotalMB: number
memoryUsedMB: number
usagePercent: number
}
interface NetworkData {
interface: string
rxBytes: number
txBytes: number
}
interface ProcessData {
pid: number
name: string
cpuPercent: number
memPercent: number
memBytes: number
}
interface Snapshot {
timestamp: number
cpu: CpuData
memory: MemoryData
disk: DiskData[]
gpu: GpuData[] | null
network: NetworkData[]
processes: ProcessData[]
}
interface TimePoint {
time: string
cpuPercent: number
memUsedGB: number
memTotalGB: number
memPercent: number
gpuPercent: number
gpuUsedMB: number
gpuTotalMB: number
netRxRate: number
netTxRate: number
}
const MAX_POINTS = 60
function formatBytes(bytes: number): string {
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(0)} MB`
return `${(bytes / 1024).toFixed(0)} KB`
}
function formatRate(bps: number): string {
if (bps >= 1024 ** 3) return `${(bps / 1024 ** 3).toFixed(1)} GB/s`
if (bps >= 1024 ** 2) return `${(bps / 1024 ** 2).toFixed(1)} MB/s`
if (bps >= 1024) return `${(bps / 1024).toFixed(1)} KB/s`
return `${Math.round(bps)} B/s`
}
function formatTime(ts: number): string {
const d = new Date(ts)
return `${d.getMinutes().toString().padStart(2, '0')}:${d.getSeconds().toString().padStart(2, '0')}`
}
export function SystemMonitorPanel() {
const [latest, setLatest] = useState<Snapshot | null>(null)
const [history, setHistory] = useState<TimePoint[]>([])
const [error, setError] = useState<string | null>(null)
const abortRef = useRef<AbortController | null>(null)
const prevNetRef = useRef<{ timestamp: number; network: NetworkData[] } | null>(null)
const fetchData = useCallback(async () => {
// Abort any in-flight request
if (abortRef.current) abortRef.current.abort()
const controller = new AbortController()
abortRef.current = controller
try {
const res = await fetch('/api/system-monitor', { signal: controller.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: Snapshot = await res.json()
setLatest(data)
setError(null)
// Compute network rates from cumulative counters
let netRxRate = 0
let netTxRate = 0
const prev = prevNetRef.current
if (prev && data.network.length > 0) {
const deltaSec = (data.timestamp - prev.timestamp) / 1000
if (deltaSec > 0) {
let totalRxDelta = 0
let totalTxDelta = 0
for (const iface of data.network) {
const prevIface = prev.network.find(p => p.interface === iface.interface)
if (prevIface) {
const rxDelta = iface.rxBytes - prevIface.rxBytes
const txDelta = iface.txBytes - prevIface.txBytes
// Guard against counter resets
if (rxDelta >= 0) totalRxDelta += rxDelta
if (txDelta >= 0) totalTxDelta += txDelta
}
}
netRxRate = totalRxDelta / deltaSec
netTxRate = totalTxDelta / deltaSec
}
}
prevNetRef.current = { timestamp: data.timestamp, network: data.network }
setHistory(prevHistory => {
const point: TimePoint = {
time: formatTime(data.timestamp),
cpuPercent: data.cpu.usagePercent,
memUsedGB: data.memory.usedBytes / 1024 ** 3,
memTotalGB: data.memory.totalBytes / 1024 ** 3,
memPercent: data.memory.usagePercent,
gpuPercent: data.gpu?.[0]?.usagePercent ?? 0,
gpuUsedMB: data.gpu?.[0]?.memoryUsedMB ?? 0,
gpuTotalMB: data.gpu?.[0]?.memoryTotalMB ?? 0,
netRxRate,
netTxRate,
}
const next = [...prevHistory, point]
return next.length > MAX_POINTS ? next.slice(-MAX_POINTS) : next
})
} catch (err: any) {
if (err.name !== 'AbortError') setError(err.message)
}
}, [])
useSmartPoll(fetchData, 2000)
if (!latest) {
return (
<div className="p-5 flex items-center justify-center h-64 text-muted-foreground">
{error ? `Error: ${error}` : 'Loading system metrics...'}
</div>
)
}
return (
<div className="p-5 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">System Monitor</h2>
{error && <span className="text-xs text-red-500">{error}</span>}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* CPU */}
<section className="rounded-xl border border-border bg-card p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium">CPU</h3>
<span className="text-2xl font-mono font-bold tabular-nums">{latest.cpu.usagePercent}%</span>
</div>
<div className="text-xs text-muted-foreground mb-2">
{latest.cpu.cores} cores &middot; Load: {latest.cpu.loadAvg.map(l => l.toFixed(2)).join(', ')}
</div>
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={history} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
<XAxis dataKey="time" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
<YAxis domain={[0, 100]} tick={{ fontSize: 10 }} width={30} tickFormatter={v => `${v}%`} />
<Tooltip
contentStyle={{ fontSize: 12, background: 'var(--color-card)', border: '1px solid var(--color-border)' }}
formatter={(v: number | undefined) => [`${v ?? 0}%`, 'CPU']}
/>
<Area
type="monotone"
dataKey="cpuPercent"
stroke="hsl(var(--chart-1, 221 83% 53%))"
fill="hsl(var(--chart-1, 221 83% 53%))"
fillOpacity={0.15}
strokeWidth={1.5}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</section>
{/* Memory */}
<section className="rounded-xl border border-border bg-card p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium">Memory</h3>
<span className="text-2xl font-mono font-bold tabular-nums">{latest.memory.usagePercent}%</span>
</div>
<div className="text-xs text-muted-foreground mb-2">
{formatBytes(latest.memory.usedBytes)} / {formatBytes(latest.memory.totalBytes)}
{latest.memory.swapTotalBytes > 0 && (
<> &middot; Swap: {formatBytes(latest.memory.swapUsedBytes)} / {formatBytes(latest.memory.swapTotalBytes)}</>
)}
</div>
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={history} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
<XAxis dataKey="time" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
<YAxis domain={[0, 100]} tick={{ fontSize: 10 }} width={30} tickFormatter={v => `${v}%`} />
<Tooltip
contentStyle={{ fontSize: 12, background: 'var(--color-card)', border: '1px solid var(--color-border)' }}
formatter={(v: number | undefined) => [`${v ?? 0}%`, 'Memory']}
/>
<Area
type="monotone"
dataKey="memPercent"
stroke="hsl(var(--chart-2, 142 71% 45%))"
fill="hsl(var(--chart-2, 142 71% 45%))"
fillOpacity={0.15}
strokeWidth={1.5}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</section>
{/* Disk */}
<section className="rounded-xl border border-border bg-card p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium">Disk</h3>
</div>
{latest.disk.length === 0 ? (
<div className="text-xs text-muted-foreground">No disk data available</div>
) : (
<div className="space-y-3">
{latest.disk.map(d => (
<div key={d.mountpoint}>
<div className="flex items-center justify-between text-xs mb-1">
<span className="font-mono text-muted-foreground truncate max-w-[60%]">{d.mountpoint}</span>
<span className="tabular-nums">{d.usagePercent}% &middot; {formatBytes(d.usedBytes)} / {formatBytes(d.totalBytes)}</span>
</div>
<div className="h-2.5 bg-muted rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
d.usagePercent >= 90 ? 'bg-red-500' : d.usagePercent >= 75 ? 'bg-amber-500' : 'bg-blue-500'
}`}
style={{ width: `${d.usagePercent}%` }}
/>
</div>
</div>
))}
</div>
)}
</section>
{/* GPU */}
<section className="rounded-xl border border-border bg-card p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium">GPU</h3>
{latest.gpu && latest.gpu[0] && (
<span className="text-2xl font-mono font-bold tabular-nums">{latest.gpu[0].usagePercent}%</span>
)}
</div>
{!latest.gpu ? (
<div className="flex items-center justify-center h-40 text-xs text-muted-foreground">
No GPU detected
</div>
) : (
<>
<div className="text-xs text-muted-foreground mb-2">
{latest.gpu[0].name}
{latest.gpu[0].memoryTotalMB > 0 && (
<> &middot; {latest.gpu[0].memoryUsedMB} MB / {latest.gpu[0].memoryTotalMB} MB</>
)}
</div>
{latest.gpu[0].memoryTotalMB > 0 && latest.gpu[0].memoryUsedMB > 0 ? (
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={history} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
<XAxis dataKey="time" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
<YAxis domain={[0, 100]} tick={{ fontSize: 10 }} width={30} tickFormatter={v => `${v}%`} />
<Tooltip
contentStyle={{ fontSize: 12, background: 'var(--color-card)', border: '1px solid var(--color-border)' }}
formatter={(v: number | undefined) => [`${v ?? 0}%`, 'GPU Memory']}
/>
<Area
type="monotone"
dataKey="gpuPercent"
stroke="hsl(var(--chart-4, 280 65% 60%))"
fill="hsl(var(--chart-4, 280 65% 60%))"
fillOpacity={0.15}
strokeWidth={1.5}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
) : (
<div className="flex items-center justify-center h-40 text-xs text-muted-foreground">
GPU detected but live memory usage unavailable
</div>
)}
</>
)}
</section>
{/* Processes */}
<section className="rounded-xl border border-border bg-card p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium">Top Processes</h3>
<span className="text-xs text-muted-foreground">{latest.processes.length} shown</span>
</div>
{latest.processes.length === 0 ? (
<div className="flex items-center justify-center h-40 text-xs text-muted-foreground">
No process data available
</div>
) : (
<div className="space-y-0">
{/* Header */}
<div className="flex items-center text-[10px] text-muted-foreground uppercase tracking-wider pb-1.5 border-b border-border mb-1">
<span className="flex-1">Process</span>
<span className="w-14 text-right">CPU</span>
<span className="w-14 text-right">Mem</span>
<span className="w-16 text-right">RSS</span>
</div>
{latest.processes.map(p => (
<div key={p.pid} className="flex items-center text-xs py-1 border-b border-border/50 last:border-0">
<span className="flex-1 truncate font-mono text-muted-foreground" title={`${p.name} (PID ${p.pid})`}>
{p.name}
</span>
<span className={`w-14 text-right tabular-nums font-mono ${p.cpuPercent >= 50 ? 'text-red-400' : p.cpuPercent >= 10 ? 'text-amber-400' : ''}`}>
{p.cpuPercent.toFixed(1)}%
</span>
<span className={`w-14 text-right tabular-nums font-mono ${p.memPercent >= 50 ? 'text-red-400' : p.memPercent >= 10 ? 'text-amber-400' : ''}`}>
{p.memPercent.toFixed(1)}%
</span>
<span className="w-16 text-right tabular-nums font-mono text-muted-foreground">
{formatBytes(p.memBytes)}
</span>
</div>
))}
</div>
)}
</section>
{/* Network I/O */}
<section className="rounded-xl border border-border bg-card p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium">Network I/O</h3>
{history.length > 0 && (
<div className="text-right">
<span className="text-xs text-muted-foreground">
RX {formatRate(history[history.length - 1].netRxRate)} / TX {formatRate(history[history.length - 1].netTxRate)}
</span>
</div>
)}
</div>
{latest.network.length === 0 ? (
<div className="flex items-center justify-center h-40 text-xs text-muted-foreground">
No network data available
</div>
) : (
<>
<div className="text-xs text-muted-foreground mb-2">
{latest.network.map(n => n.interface).join(', ')}
</div>
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={history} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
<XAxis dataKey="time" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10 }} width={50} tickFormatter={v => formatRate(v)} />
<Tooltip
contentStyle={{ fontSize: 12, background: 'var(--color-card)', border: '1px solid var(--color-border)' }}
formatter={(v: number | undefined, name?: string) => [
formatRate(v ?? 0),
name === 'netRxRate' ? 'Download' : 'Upload',
]}
/>
<Area
type="monotone"
dataKey="netRxRate"
stroke="hsl(var(--chart-5, 25 95% 53%))"
fill="hsl(var(--chart-5, 25 95% 53%))"
fillOpacity={0.15}
strokeWidth={1.5}
isAnimationActive={false}
/>
<Area
type="monotone"
dataKey="netTxRate"
stroke="hsl(var(--chart-3, 173 58% 39%))"
fill="hsl(var(--chart-3, 173 58% 39%))"
fillOpacity={0.15}
strokeWidth={1.5}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</>
)}
</section>
</div>
</div>
)
}