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:
@@ -137,6 +137,97 @@ async function readTemps() {
|
|||||||
return { cpuC: cpu?.celsius ?? null, cpuLabel: cpu ? `${cpu.name} · ${cpu.label}` : null, sensors };
|
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) => {
|
systemMonitorRouter.get('/stats', async (ctx) => {
|
||||||
const first = await readCpuSample().catch(() => null);
|
const first = await readCpuSample().catch(() => null);
|
||||||
await new Promise((r) => setTimeout(r, 120));
|
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),
|
readMem().catch(() => null),
|
||||||
readDisks(),
|
readDisks(),
|
||||||
readProcesses(),
|
readProcesses(),
|
||||||
readTemps().catch(() => null),
|
readTemps().catch(() => null),
|
||||||
|
readGpu().catch(() => null),
|
||||||
|
readNet().catch(() => null),
|
||||||
|
readPower().catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const cpus = os.cpus();
|
const cpus = os.cpus();
|
||||||
@@ -175,6 +269,9 @@ systemMonitorRouter.get('/stats', async (ctx) => {
|
|||||||
disks,
|
disks,
|
||||||
processes,
|
processes,
|
||||||
temp,
|
temp,
|
||||||
|
gpu,
|
||||||
|
net,
|
||||||
|
power,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useClient } from 'hooks/useClient';
|
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';
|
import { fmtBytes, fmtDuration } from './shared';
|
||||||
|
|
||||||
type Sensor = { name: string; label: string; celsius: number };
|
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 }[];
|
disks: { mount: string; fsType: string; totalBytes: number; usedBytes: number; usedPct: number }[];
|
||||||
processes: { pid: number; user: string; cpuPct: number; memPct: number; command: string }[];
|
processes: { pid: number; user: string; cpuPct: number; memPct: number; command: string }[];
|
||||||
temp: { cpuC: number | null; cpuLabel: string | null; sensors: Sensor[] } | null;
|
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;
|
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 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 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 }) => (
|
const Bar = ({ pct }: { pct: number }) => (
|
||||||
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||||
@@ -138,7 +142,22 @@ export const BtopView = () => {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Temperature" icon={Thermometer} className="lg:col-start-2">
|
<Card title="GPU" icon={CircuitBoard}>
|
||||||
|
{stats.gpu ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-end justify-between">
|
||||||
|
<span className="text-3xl font-semibold text-foreground">{stats.gpu.busyPct}%</span>
|
||||||
|
<span className="text-xs text-muted-foreground">VRAM {fmtBytes(stats.gpu.vramUsedBytes)} / {fmtBytes(stats.gpu.vramTotalBytes)}</span>
|
||||||
|
</div>
|
||||||
|
<Bar pct={stats.gpu.busyPct} />
|
||||||
|
{stats.gpu.vramTotalBytes > 0 && <Bar pct={(stats.gpu.vramUsedBytes / stats.gpu.vramTotalBytes) * 100} />}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">no gpu</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Temperature" icon={Thermometer}>
|
||||||
{stats.temp?.cpuC != null ? (
|
{stats.temp?.cpuC != null ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-end justify-between">
|
<div className="flex items-end justify-between">
|
||||||
@@ -161,6 +180,45 @@ export const BtopView = () => {
|
|||||||
<p className="text-sm text-muted-foreground">no sensors</p>
|
<p className="text-sm text-muted-foreground">no sensors</p>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Network" icon={Network}>
|
||||||
|
{stats.net ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between text-lg font-semibold text-foreground">
|
||||||
|
<span>↓ {fmtRate(stats.net.rxBytesPerSec)}</span>
|
||||||
|
<span>↑ {fmtRate(stats.net.txBytesPerSec)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 pt-1">
|
||||||
|
{stats.net.interfaces.slice(0, 4).map((i) => (
|
||||||
|
<div key={i.name} className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span className="truncate font-mono">{i.name}</span>
|
||||||
|
<span className="font-mono">↓{fmtRate(i.rxBytesPerSec)} ↑{fmtRate(i.txBytesPerSec)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{stats.net.interfaces.length === 0 && <span className="text-xs text-muted-foreground">idle</span>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">unavailable</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Power" icon={Zap}>
|
||||||
|
{stats.power ? (
|
||||||
|
<div className="flex flex-col gap-2 pt-1">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">CPU package</span>
|
||||||
|
<span className="font-mono text-foreground">{stats.power.cpuWatts != null ? `${stats.power.cpuWatts} W` : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">GPU</span>
|
||||||
|
<span className="font-mono text-foreground">{stats.power.gpuWatts != null ? `${stats.power.gpuWatts} W` : '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">unavailable</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card title="Top processes" icon={Server}>
|
<Card title="Top processes" icon={Server}>
|
||||||
|
|||||||
Reference in New Issue
Block a user