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:
parent
8431992866
commit
465cd96107
|
|
@ -35,6 +35,7 @@ import { DebugPanel } from '@/components/panels/debug-panel'
|
||||||
import { SecurityAuditPanel } from '@/components/panels/security-audit-panel'
|
import { SecurityAuditPanel } from '@/components/panels/security-audit-panel'
|
||||||
import { NodesPanel } from '@/components/panels/nodes-panel'
|
import { NodesPanel } from '@/components/panels/nodes-panel'
|
||||||
import { ExecApprovalPanel } from '@/components/panels/exec-approval-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 { ChatPagePanel } from '@/components/panels/chat-page-panel'
|
||||||
import { ChatPanel } from '@/components/chat/chat-panel'
|
import { ChatPanel } from '@/components/chat/chat-panel'
|
||||||
import { getPluginPanel } from '@/lib/plugins'
|
import { getPluginPanel } from '@/lib/plugins'
|
||||||
|
|
@ -555,6 +556,8 @@ function ContentRouter({ tab }: { tab: string }) {
|
||||||
return <GitHubSyncPanel />
|
return <GitHubSyncPanel />
|
||||||
case 'office':
|
case 'office':
|
||||||
return <OfficePanel />
|
return <OfficePanel />
|
||||||
|
case 'monitor':
|
||||||
|
return <SystemMonitorPanel />
|
||||||
case 'skills':
|
case 'skills':
|
||||||
return <SkillsPanel />
|
return <SkillsPanel />
|
||||||
case 'channels':
|
case 'channels':
|
||||||
|
|
|
||||||
|
|
@ -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 []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -47,6 +47,7 @@ const navGroups: NavGroup[] = [
|
||||||
{ id: 'nodes', label: 'Nodes', icon: <NodesIcon />, priority: false },
|
{ id: 'nodes', label: 'Nodes', icon: <NodesIcon />, priority: false },
|
||||||
{ id: 'exec-approvals', label: 'Approvals', icon: <ApprovalsIcon />, priority: false },
|
{ id: 'exec-approvals', label: 'Approvals', icon: <ApprovalsIcon />, priority: false },
|
||||||
{ id: 'office', label: 'Office', icon: <OfficeIcon />, 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>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 · 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 && (
|
||||||
|
<> · 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}% · {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 && (
|
||||||
|
<> · {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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue