From 465cd96107da24cfc9d841147ea1538e30b99bec Mon Sep 17 00:00:00 2001 From: danielaustralia1 <40316131+danielaustralia1@users.noreply.github.com> Date: Fri, 20 Mar 2026 02:40:55 +1100 Subject: [PATCH] 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 --- src/app/[[...panel]]/page.tsx | 3 + src/app/api/system-monitor/route.ts | 398 ++++++++++++++++ src/components/layout/nav-rail.tsx | 11 + .../panels/system-monitor-panel.tsx | 428 ++++++++++++++++++ 4 files changed, 840 insertions(+) create mode 100644 src/app/api/system-monitor/route.ts create mode 100644 src/components/panels/system-monitor-panel.tsx diff --git a/src/app/[[...panel]]/page.tsx b/src/app/[[...panel]]/page.tsx index 3c915cc..49309fb 100644 --- a/src/app/[[...panel]]/page.tsx +++ b/src/app/[[...panel]]/page.tsx @@ -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 case 'office': return + case 'monitor': + return case 'skills': return case 'channels': diff --git a/src/app/api/system-monitor/route.ts b/src/app/api/system-monitor/route.ts new file mode 100644 index 0000000..b99adad --- /dev/null +++ b/src/app/api/system-monitor/route.ts @@ -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 | 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> { + // 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() + + 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> { + 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 [] + } + } +} diff --git a/src/components/layout/nav-rail.tsx b/src/components/layout/nav-rail.tsx index 878eb6f..7a118f5 100644 --- a/src/components/layout/nav-rail.tsx +++ b/src/components/layout/nav-rail.tsx @@ -47,6 +47,7 @@ const navGroups: NavGroup[] = [ { id: 'nodes', label: 'Nodes', icon: , priority: false }, { id: 'exec-approvals', label: 'Approvals', icon: , priority: false }, { id: 'office', label: 'Office', icon: , priority: false }, + { id: 'monitor', label: 'Monitor', icon: , priority: false }, ], }, { @@ -1500,3 +1501,13 @@ function PluginIcon() { ) } + +function MonitorIcon() { + return ( + + + + + + ) +} diff --git a/src/components/panels/system-monitor-panel.tsx b/src/components/panels/system-monitor-panel.tsx new file mode 100644 index 0000000..004c65e --- /dev/null +++ b/src/components/panels/system-monitor-panel.tsx @@ -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(null) + const [history, setHistory] = useState([]) + const [error, setError] = useState(null) + const abortRef = useRef(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 ( +
+ {error ? `Error: ${error}` : 'Loading system metrics...'} +
+ ) + } + + return ( +
+
+

System Monitor

+ {error && {error}} +
+ +
+ {/* CPU */} +
+
+

CPU

+ {latest.cpu.usagePercent}% +
+
+ {latest.cpu.cores} cores · Load: {latest.cpu.loadAvg.map(l => l.toFixed(2)).join(', ')} +
+
+ + + + + `${v}%`} /> + [`${v ?? 0}%`, 'CPU']} + /> + + + +
+
+ + {/* Memory */} +
+
+

Memory

+ {latest.memory.usagePercent}% +
+
+ {formatBytes(latest.memory.usedBytes)} / {formatBytes(latest.memory.totalBytes)} + {latest.memory.swapTotalBytes > 0 && ( + <> · Swap: {formatBytes(latest.memory.swapUsedBytes)} / {formatBytes(latest.memory.swapTotalBytes)} + )} +
+
+ + + + + `${v}%`} /> + [`${v ?? 0}%`, 'Memory']} + /> + + + +
+
+ + {/* Disk */} +
+
+

Disk

+
+ {latest.disk.length === 0 ? ( +
No disk data available
+ ) : ( +
+ {latest.disk.map(d => ( +
+
+ {d.mountpoint} + {d.usagePercent}% · {formatBytes(d.usedBytes)} / {formatBytes(d.totalBytes)} +
+
+
= 90 ? 'bg-red-500' : d.usagePercent >= 75 ? 'bg-amber-500' : 'bg-blue-500' + }`} + style={{ width: `${d.usagePercent}%` }} + /> +
+
+ ))} +
+ )} +
+ + {/* GPU */} +
+
+

GPU

+ {latest.gpu && latest.gpu[0] && ( + {latest.gpu[0].usagePercent}% + )} +
+ {!latest.gpu ? ( +
+ No GPU detected +
+ ) : ( + <> +
+ {latest.gpu[0].name} + {latest.gpu[0].memoryTotalMB > 0 && ( + <> · {latest.gpu[0].memoryUsedMB} MB / {latest.gpu[0].memoryTotalMB} MB + )} +
+ {latest.gpu[0].memoryTotalMB > 0 && latest.gpu[0].memoryUsedMB > 0 ? ( +
+ + + + + `${v}%`} /> + [`${v ?? 0}%`, 'GPU Memory']} + /> + + + +
+ ) : ( +
+ GPU detected but live memory usage unavailable +
+ )} + + )} +
+ {/* Processes */} +
+
+

Top Processes

+ {latest.processes.length} shown +
+ {latest.processes.length === 0 ? ( +
+ No process data available +
+ ) : ( +
+ {/* Header */} +
+ Process + CPU + Mem + RSS +
+ {latest.processes.map(p => ( +
+ + {p.name} + + = 50 ? 'text-red-400' : p.cpuPercent >= 10 ? 'text-amber-400' : ''}`}> + {p.cpuPercent.toFixed(1)}% + + = 50 ? 'text-red-400' : p.memPercent >= 10 ? 'text-amber-400' : ''}`}> + {p.memPercent.toFixed(1)}% + + + {formatBytes(p.memBytes)} + +
+ ))} +
+ )} +
+ + {/* Network I/O */} +
+
+

Network I/O

+ {history.length > 0 && ( +
+ + RX {formatRate(history[history.length - 1].netRxRate)} / TX {formatRate(history[history.length - 1].netTxRate)} + +
+ )} +
+ {latest.network.length === 0 ? ( +
+ No network data available +
+ ) : ( + <> +
+ {latest.network.map(n => n.interface).join(', ')} +
+
+ + + + + formatRate(v)} /> + [ + formatRate(v ?? 0), + name === 'netRxRate' ? 'Download' : 'Upload', + ]} + /> + + + + +
+ + )} +
+
+
+ ) +}