system-monitor: CPU temperature in bTop (Temperature card under Memory)
- Backend: readTemps() scans /sys/class/hwmon for all temp sensors and picks the CPU one (k10temp/coretemp/zenpower Tctl/Tdie/Package); added to /stats.temp. - BtopView: a Temperature card at lg:col-start-2 (under Memory, second row) — big CPU °C (color-graded), its sensor label, and the other sensors (GPU, NVMe, wifi…) listed beneath. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import os from 'node:os';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { createRouter } from '../../create-router';
|
||||
@@ -102,6 +102,41 @@ async function readProcesses() {
|
||||
}
|
||||
|
||||
// GET /stats — one snapshot. CPU% comes from two /proc/stat samples ~120ms apart.
|
||||
// Read every hwmon temperature sensor (name/label/°C) and pick the CPU one (k10temp/coretemp/…).
|
||||
async function readTemps() {
|
||||
const sensors: { name: string; label: string; celsius: number }[] = [];
|
||||
let dirs: string[];
|
||||
try {
|
||||
dirs = await readdir('/sys/class/hwmon');
|
||||
} catch {
|
||||
return { cpuC: null as number | null, cpuLabel: null as string | null, sensors };
|
||||
}
|
||||
for (const d of dirs) {
|
||||
const base = `/sys/class/hwmon/${d}`;
|
||||
const name = (await readFile(`${base}/name`, 'utf8').catch(() => '')).trim();
|
||||
let files: string[];
|
||||
try {
|
||||
files = await readdir(base);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const f of files) {
|
||||
const m = f.match(/^temp(\d+)_input$/);
|
||||
if (!m) continue;
|
||||
const milli = Number.parseInt((await readFile(`${base}/${f}`, 'utf8').catch(() => '')).trim(), 10);
|
||||
if (!Number.isFinite(milli)) continue;
|
||||
const label = (await readFile(`${base}/temp${m[1]}_label`, 'utf8').catch(() => '')).trim();
|
||||
sensors.push({ name, label: label || `temp${m[1]}`, celsius: Math.round(milli / 100) / 10 });
|
||||
}
|
||||
}
|
||||
const CPU_DRIVERS = ['k10temp', 'zenpower', 'coretemp', 'k8temp', 'cpu_thermal'];
|
||||
const cpu =
|
||||
sensors.find((s) => CPU_DRIVERS.includes(s.name.toLowerCase()) && /tctl|tdie|package|composite|core 0/i.test(s.label)) ??
|
||||
sensors.find((s) => CPU_DRIVERS.includes(s.name.toLowerCase())) ??
|
||||
null;
|
||||
return { cpuC: cpu?.celsius ?? null, cpuLabel: cpu ? `${cpu.name} · ${cpu.label}` : null, sensors };
|
||||
}
|
||||
|
||||
systemMonitorRouter.get('/stats', async (ctx) => {
|
||||
const first = await readCpuSample().catch(() => null);
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
@@ -117,10 +152,11 @@ systemMonitorRouter.get('/stats', async (ctx) => {
|
||||
})
|
||||
: [];
|
||||
|
||||
const [mem, disks, processes] = await Promise.all([
|
||||
const [mem, disks, processes, temp] = await Promise.all([
|
||||
readMem().catch(() => null),
|
||||
readDisks(),
|
||||
readProcesses(),
|
||||
readTemps().catch(() => null),
|
||||
]);
|
||||
|
||||
const cpus = os.cpus();
|
||||
@@ -138,6 +174,7 @@ systemMonitorRouter.get('/stats', async (ctx) => {
|
||||
mem,
|
||||
disks,
|
||||
processes,
|
||||
temp,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Cpu, MemoryStick, HardDrive, Server } from 'lucide-react';
|
||||
import { Cpu, MemoryStick, HardDrive, Server, Thermometer } from 'lucide-react';
|
||||
import { fmtBytes, fmtDuration } from './shared';
|
||||
|
||||
type Sensor = { name: string; label: string; celsius: number };
|
||||
type Stats = {
|
||||
hostname: string;
|
||||
platform: string;
|
||||
@@ -12,12 +13,14 @@ type Stats = {
|
||||
mem: { totalBytes: number; usedBytes: number; freeBytes: number; usedPct: number; swapTotalBytes: number; swapUsedBytes: number } | null;
|
||||
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;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
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 Bar = ({ pct }: { pct: number }) => (
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
@@ -25,8 +28,8 @@ const Bar = ({ pct }: { pct: number }) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
const Card = ({ title, icon: Icon, children }: { title: string; icon: typeof Cpu; children: React.ReactNode }) => (
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card p-4">
|
||||
const Card = ({ title, icon: Icon, children, className }: { title: string; icon: typeof Cpu; children: React.ReactNode; className?: string }) => (
|
||||
<div className={`flex flex-col gap-3 rounded-xl border border-border bg-card p-4 ${className ?? ''}`}>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Icon size={16} className="text-primary" />
|
||||
{title}
|
||||
@@ -134,6 +137,30 @@ export const BtopView = () => {
|
||||
<p className="text-sm text-muted-foreground">no disks</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="Temperature" icon={Thermometer} className="lg:col-start-2">
|
||||
{stats.temp?.cpuC != null ? (
|
||||
<>
|
||||
<div className="flex items-end justify-between">
|
||||
<span className={`text-3xl font-semibold ${tempColor(stats.temp.cpuC)}`}>{stats.temp.cpuC.toFixed(0)}°C</span>
|
||||
<span className="truncate pl-2 text-xs text-muted-foreground">{stats.temp.cpuLabel}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pt-1">
|
||||
{stats.temp.sensors
|
||||
.filter((s) => `${s.name} · ${s.label}` !== stats.temp!.cpuLabel)
|
||||
.slice(0, 6)
|
||||
.map((s, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-xs">
|
||||
<span className="truncate text-muted-foreground">{s.name} · {s.label}</span>
|
||||
<span className={`font-mono ${tempColor(s.celsius)}`}>{s.celsius.toFixed(0)}°C</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">no sensors</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title="Top processes" icon={Server}>
|
||||
|
||||
Reference in New Issue
Block a user