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 os from 'node:os';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile, readdir } from 'node:fs/promises';
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { createRouter } from '../../create-router';
|
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.
|
// 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) => {
|
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));
|
||||||
@@ -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),
|
readMem().catch(() => null),
|
||||||
readDisks(),
|
readDisks(),
|
||||||
readProcesses(),
|
readProcesses(),
|
||||||
|
readTemps().catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const cpus = os.cpus();
|
const cpus = os.cpus();
|
||||||
@@ -138,6 +174,7 @@ systemMonitorRouter.get('/stats', async (ctx) => {
|
|||||||
mem,
|
mem,
|
||||||
disks,
|
disks,
|
||||||
processes,
|
processes,
|
||||||
|
temp,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
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 } from 'lucide-react';
|
import { Cpu, MemoryStick, HardDrive, Server, Thermometer } from 'lucide-react';
|
||||||
import { fmtBytes, fmtDuration } from './shared';
|
import { fmtBytes, fmtDuration } from './shared';
|
||||||
|
|
||||||
|
type Sensor = { name: string; label: string; celsius: number };
|
||||||
type Stats = {
|
type Stats = {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
@@ -12,12 +13,14 @@ type Stats = {
|
|||||||
mem: { totalBytes: number; usedBytes: number; freeBytes: number; usedPct: number; swapTotalBytes: number; swapUsedBytes: number } | null;
|
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 }[];
|
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;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const POLL_MS = 2000;
|
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 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">
|
||||||
@@ -25,8 +28,8 @@ const Bar = ({ pct }: { pct: number }) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const Card = ({ title, icon: Icon, children }: { title: string; icon: typeof Cpu; children: React.ReactNode }) => (
|
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">
|
<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">
|
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||||
<Icon size={16} className="text-primary" />
|
<Icon size={16} className="text-primary" />
|
||||||
{title}
|
{title}
|
||||||
@@ -134,6 +137,30 @@ export const BtopView = () => {
|
|||||||
<p className="text-sm text-muted-foreground">no disks</p>
|
<p className="text-sm text-muted-foreground">no disks</p>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Card title="Top processes" icon={Server}>
|
<Card title="Top processes" icon={Server}>
|
||||||
|
|||||||
Reference in New Issue
Block a user