From f9752d286837c7e3f3f7b4d5c5f897f8bac308b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 27 Jul 2026 10:59:27 +0000 Subject: [PATCH] system-monitor: GPU, Network, and Power cards in bTop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more same-level grid cards (bTop now 7 cards; Temperature stays under Memory via natural 3-col flow): - GPU: gpu_busy_percent + VRAM used/total from /sys/class/drm (instant). - Network: ↓/↑ throughput (bytes/sec) from /proc/net/dev deltas between /stats calls, aggregate + top interfaces. - Power: CPU package watts via RAPL energy delta + GPU watts (amdgpu hwmon). Note: RAPL energy_uj is root-only by default (Spectre-era lockdown), so CPU package power shows "—" unless made readable (a udev rule); GPU watts work. Co-Authored-By: Claude Opus 4.8 --- .../api/system-monitor/system-monitor.ts | 99 ++++++++++++++++++- .../src/apps/SystemMonitor/BtopView.tsx | 62 +++++++++++- 2 files changed, 158 insertions(+), 3 deletions(-) diff --git a/src/servers/api/system-monitor/system-monitor.ts b/src/servers/api/system-monitor/system-monitor.ts index 3387d158..05db5155 100644 --- a/src/servers/api/system-monitor/system-monitor.ts +++ b/src/servers/api/system-monitor/system-monitor.ts @@ -137,6 +137,97 @@ async function readTemps() { return { cpuC: cpu?.celsius ?? null, cpuLabel: cpu ? `${cpu.name} · ${cpu.label}` : null, sensors }; } +// GPU (amdgpu/intel): busy% + VRAM, read straight from /sys/class/drm (instant, no sampling). +async function readGpu() { + let cards: string[]; + try { + cards = await readdir('/sys/class/drm'); + } catch { + return null; + } + for (const c of cards.sort()) { + if (!/^card\d+$/.test(c)) continue; + const dev = `/sys/class/drm/${c}/device`; + const busyRaw = await readFile(`${dev}/gpu_busy_percent`, 'utf8').catch(() => null); + if (busyRaw == null) continue; + const busyPct = Number.parseInt(busyRaw.trim(), 10); + const vramUsed = Number.parseInt((await readFile(`${dev}/mem_info_vram_used`, 'utf8').catch(() => '0')).trim(), 10) || 0; + const vramTotal = Number.parseInt((await readFile(`${dev}/mem_info_vram_total`, 'utf8').catch(() => '0')).trim(), 10) || 0; + return { busyPct: Number.isFinite(busyPct) ? busyPct : 0, vramUsedBytes: vramUsed, vramTotalBytes: vramTotal }; + } + return null; +} + +// Network throughput — bytes/sec computed from the delta since the previous /stats call (~2s window). +let lastNet: { total: { rx: number; tx: number }; per: Record; ts: number } | null = null; +async function readNet() { + const now = Date.now(); + let data: string; + try { + data = await readFile('/proc/net/dev', 'utf8'); + } catch { + return null; + } + const per: Record = {}; + let totRx = 0; + let totTx = 0; + for (const line of data.split('\n')) { + const m = line.match(/^\s*([^:]+):\s*(.+)$/); + if (!m) continue; + const iface = m[1]!.trim(); + if (iface === 'lo') continue; + const cols = m[2]!.trim().split(/\s+/).map(Number); + const rx = cols[0] ?? 0; + const tx = cols[8] ?? 0; + per[iface] = { rx, tx }; + totRx += rx; + totTx += tx; + } + const prev = lastNet; + lastNet = { total: { rx: totRx, tx: totTx }, per, ts: now }; + if (!prev || now <= prev.ts) return { rxBytesPerSec: 0, txBytesPerSec: 0, interfaces: [] as { name: string; rxBytesPerSec: number; txBytesPerSec: number }[] }; + const dt = (now - prev.ts) / 1000; + const rate = (cur: number, old: number) => Math.max(0, Math.round((cur - old) / dt)); + const interfaces = Object.entries(per) + .map(([name, v]) => ({ name, rxBytesPerSec: rate(v.rx, prev.per[name]?.rx ?? v.rx), txBytesPerSec: rate(v.tx, prev.per[name]?.tx ?? v.tx) })) + .filter((i) => i.rxBytesPerSec > 0 || i.txBytesPerSec > 0) + .sort((a, b) => b.rxBytesPerSec + b.txBytesPerSec - (a.rxBytesPerSec + a.txBytesPerSec)); + return { rxBytesPerSec: rate(totRx, prev.total.rx), txBytesPerSec: rate(totTx, prev.total.tx), interfaces }; +} + +// Power draw — CPU package watts from the RAPL energy counter (delta since last call) + GPU watts +// (amdgpu hwmon, instant). +let lastRapl: { uj: number; ts: number } | null = null; +async function readPower() { + const now = Date.now(); + let cpuWatts: number | null = null; + const ujRaw = await readFile('/sys/class/powercap/intel-rapl:0/energy_uj', 'utf8').catch(() => null); + if (ujRaw != null) { + const uj = Number.parseInt(ujRaw.trim(), 10); + const prev = lastRapl; + lastRapl = { uj, ts: now }; + if (prev && now > prev.ts && uj >= prev.uj) { + cpuWatts = Math.round(((uj - prev.uj) / 1e6 / ((now - prev.ts) / 1000)) * 10) / 10; + } + } + let gpuWatts: number | null = null; + try { + for (const d of await readdir('/sys/class/hwmon')) { + const base = `/sys/class/hwmon/${d}`; + if ((await readFile(`${base}/name`, 'utf8').catch(() => '')).trim() !== 'amdgpu') continue; + const p = (await readFile(`${base}/power1_average`, 'utf8').catch(() => null)) ?? (await readFile(`${base}/power1_input`, 'utf8').catch(() => null)); + if (p != null) { + const uw = Number.parseInt(p.trim(), 10); + if (Number.isFinite(uw)) gpuWatts = Math.round((uw / 1e6) * 10) / 10; + } + break; + } + } catch { + /* no amdgpu */ + } + return { cpuWatts, gpuWatts }; +} + systemMonitorRouter.get('/stats', async (ctx) => { const first = await readCpuSample().catch(() => null); await new Promise((r) => setTimeout(r, 120)); @@ -152,11 +243,14 @@ systemMonitorRouter.get('/stats', async (ctx) => { }) : []; - const [mem, disks, processes, temp] = await Promise.all([ + const [mem, disks, processes, temp, gpu, net, power] = await Promise.all([ readMem().catch(() => null), readDisks(), readProcesses(), readTemps().catch(() => null), + readGpu().catch(() => null), + readNet().catch(() => null), + readPower().catch(() => null), ]); const cpus = os.cpus(); @@ -175,6 +269,9 @@ systemMonitorRouter.get('/stats', async (ctx) => { disks, processes, temp, + gpu, + net, + power, timestamp: Date.now(), }); }); diff --git a/src/workspaces/officerdev/src/apps/SystemMonitor/BtopView.tsx b/src/workspaces/officerdev/src/apps/SystemMonitor/BtopView.tsx index aac24ddd..416e5727 100644 --- a/src/workspaces/officerdev/src/apps/SystemMonitor/BtopView.tsx +++ b/src/workspaces/officerdev/src/apps/SystemMonitor/BtopView.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { useClient } from 'hooks/useClient'; -import { Cpu, MemoryStick, HardDrive, Server, Thermometer } from 'lucide-react'; +import { Cpu, MemoryStick, HardDrive, Server, Thermometer, CircuitBoard, Network, Zap } from 'lucide-react'; import { fmtBytes, fmtDuration } from './shared'; type Sensor = { name: string; label: string; celsius: number }; @@ -14,6 +14,9 @@ type Stats = { disks: { mount: string; fsType: string; totalBytes: number; usedBytes: number; usedPct: number }[]; processes: { pid: number; user: string; cpuPct: number; memPct: number; command: string }[]; temp: { cpuC: number | null; cpuLabel: string | null; sensors: Sensor[] } | null; + gpu: { busyPct: number; vramUsedBytes: number; vramTotalBytes: number } | null; + net: { rxBytesPerSec: number; txBytesPerSec: number; interfaces: { name: string; rxBytesPerSec: number; txBytesPerSec: number }[] } | null; + power: { cpuWatts: number | null; gpuWatts: number | null } | null; timestamp: number; }; @@ -21,6 +24,7 @@ const POLL_MS = 2000; const barColor = (pct: number) => (pct >= 85 ? 'bg-red-500' : pct >= 60 ? 'bg-amber-500' : 'bg-emerald-500'); const tempColor = (c: number) => (c >= 80 ? 'text-red-500' : c >= 65 ? 'text-amber-500' : 'text-emerald-500'); +const fmtRate = (bps: number) => `${fmtBytes(bps)}/s`; const Bar = ({ pct }: { pct: number }) => (
@@ -138,7 +142,22 @@ export const BtopView = () => { )} - + + {stats.gpu ? ( + <> +
+ {stats.gpu.busyPct}% + VRAM {fmtBytes(stats.gpu.vramUsedBytes)} / {fmtBytes(stats.gpu.vramTotalBytes)} +
+ + {stats.gpu.vramTotalBytes > 0 && } + + ) : ( +

no gpu

+ )} +
+ + {stats.temp?.cpuC != null ? ( <>
@@ -161,6 +180,45 @@ export const BtopView = () => {

no sensors

)} + + + {stats.net ? ( + <> +
+ ↓ {fmtRate(stats.net.rxBytesPerSec)} + ↑ {fmtRate(stats.net.txBytesPerSec)} +
+
+ {stats.net.interfaces.slice(0, 4).map((i) => ( +
+ {i.name} + ↓{fmtRate(i.rxBytesPerSec)} ↑{fmtRate(i.txBytesPerSec)} +
+ ))} + {stats.net.interfaces.length === 0 && idle} +
+ + ) : ( +

unavailable

+ )} +
+ + + {stats.power ? ( +
+
+ CPU package + {stats.power.cpuWatts != null ? `${stats.power.cpuWatts} W` : '—'} +
+
+ GPU + {stats.power.gpuWatts != null ? `${stats.power.gpuWatts} W` : '—'} +
+
+ ) : ( +

unavailable

+ )} +