system-monitor: GPU, Network, and Power cards in bTop

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 10:59:27 +00:00
co-authored by Claude Opus 4.8
parent 75a978d2e2
commit f9752d2868
2 changed files with 158 additions and 3 deletions
@@ -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<string, { rx: number; tx: number }>; 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<string, { rx: number; tx: number }> = {};
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(),
});
});