From 1e2dcacb2c92cf4888a0b6ce7fb71463367700a8 Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Tue, 10 Nov 2026 00:04:47 -0800 Subject: [PATCH] Two-socket machines: Hardware and Top show each CPU, not one with every core Hardware counts CPUs, cores and threads per CPU. Top groups threads under a row per socket, reads each core's temperature from its own socket's coretemp chip, and the CPU temperature is the hotter package. Co-Authored-By: Claude Opus 5.5 --- internal/metrics/cputemp_test.go | 16 ++++++++ internal/metrics/metrics.go | 69 +++++++++++++++++++++++++++++--- web/src/Hardware.tsx | 17 ++++++-- web/src/Top.tsx | 52 +++++++++++++++++------- web/src/api.ts | 4 +- web/src/styles.css | 2 + 6 files changed, 133 insertions(+), 27 deletions(-) create mode 100644 internal/metrics/cputemp_test.go diff --git a/internal/metrics/cputemp_test.go b/internal/metrics/cputemp_test.go new file mode 100644 index 0000000..c7ffb2a --- /dev/null +++ b/internal/metrics/cputemp_test.go @@ -0,0 +1,16 @@ +package metrics + +import "testing" + +// a two-socket board: two coretemp chips, and the CPU's temperature is the hotter package +func TestCPUTempHottestPackage(t *testing.T) { + temps := []Temp{ + {Chip: "coretemp", Label: "Core 0", C: 31}, + {Chip: "coretemp", Label: "Core 0", C: 44, Pkg: 1}, + {Chip: "coretemp", Label: "Package id 0", C: 35}, + {Chip: "coretemp", Label: "Package id 1", C: 41, Pkg: 1}, + } + if got := cpuTemp(temps); got != 41 { + t.Fatalf("cpuTemp = %v, want 41", got) + } +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 872d2df..22725c8 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -60,6 +60,7 @@ type Temp struct { C float64 `json:"c"` Max float64 `json:"max,omitempty"` Crit float64 `json:"crit,omitempty"` + Pkg int `json:"pkg,omitempty"` // coretemp: the CPU package the chip belongs to — a two-socket board has two chips, both with a "Core 0" } type Fan struct { @@ -544,6 +545,19 @@ func hwmonDir(d string) (chip string, temps []Temp, fans []Fan) { fans = append(fans, Fan{Chip: chip, Label: label, RPM: int(raw)}) } } + // coretemp is one chip per CPU package, and only its "Package id N" says which + if chip == "coretemp" { + for _, t := range temps { + if n, ok := strings.CutPrefix(t.Label, "Package id "); ok { + if pkg, err := strconv.Atoi(n); err == nil { + for i := range temps { + temps[i].Pkg = pkg + } + } + break + } + } + } return } @@ -559,7 +573,10 @@ func hwmon() ([]Temp, []Fan) { if temps[i].Chip != temps[j].Chip { return temps[i].Chip < temps[j].Chip } - return temps[i].Label < temps[j].Label + if temps[i].Label != temps[j].Label { + return temps[i].Label < temps[j].Label + } + return temps[i].Pkg < temps[j].Pkg }) return temps, fans } @@ -1112,6 +1129,38 @@ func coreIDs(n int) []int { return coreIDsVal[:n] } +// corePkgs maps logical CPUs to the CPU package (socket) each is on, and is nil on a +// one-socket machine: there is nothing to tell apart. +var ( + corePkgsOnce sync.Once + corePkgsVal []int +) + +func corePkgs(n int) []int { + corePkgsOnce.Do(func() { + seen := map[int]bool{} + var out []int + for i := 0; ; i++ { + b, err := os.ReadFile("/sys/devices/system/cpu/cpu" + strconv.Itoa(i) + "/topology/physical_package_id") + if err != nil { + break + } + id, err := strconv.Atoi(strings.TrimSpace(string(b))) + if err != nil { + break + } + out, seen[id] = append(out, id), true + } + if len(seen) > 1 { + corePkgsVal = out + } + }) + if len(corePkgsVal) < n { + return nil + } + return corePkgsVal[:n] +} + // ProcTable is the Processes tab payload. type ProcTable struct { T int64 `json:"t"` @@ -1123,7 +1172,8 @@ type ProcTable struct { CPUMaxMHz float64 `json:"cpu_max_mhz,omitempty"` CPUPowerW float64 `json:"cpu_power_w,omitempty"` CPUTemp float64 `json:"cpu_temp,omitempty"` - CoreIDs []int `json:"core_ids,omitempty"` // for each logical CPU, the physical core it is a thread of (what the temperature sensors are named by) + CoreIDs []int `json:"core_ids,omitempty"` // for each logical CPU, the physical core it is a thread of (what the temperature sensors are named by) + CorePkgs []int `json:"core_pkgs,omitempty"` // for each logical CPU, the socket it is on; only on a machine with more than one Load [3]float64 `json:"load"` Mem Mem `json:"mem"` Swap Mem `json:"swap"` @@ -1182,18 +1232,24 @@ func cpuMHz() float64 { // cpuTemp picks the sensor that best represents the CPU package. func cpuTemp(temps []Temp) float64 { - best, max := 0.0, 0.0 + // the hottest package: a two-socket board has two, and the hotter one is the one to watch + pkg, max := 0.0, 0.0 for _, t := range temps { l := strings.ToLower(t.Label) if t.Chip == "coretemp" && strings.HasPrefix(l, "package") || t.Chip == "k10temp" && (l == "tctl" || l == "tdie") || t.Chip == "thermal" && strings.Contains(l, "cpu") || t.Chip == "cpu_thermal" || t.Chip == "soc_thermal" { - return t.C + if t.C > pkg { + pkg = t.C + } } if (t.Chip == "coretemp" || t.Chip == "k10temp") && t.C > max { - max, best = t.C, t.C + max = t.C } } - return best + if pkg > 0 { + return pkg + } + return max } var bootTime = func() int64 { @@ -1323,6 +1379,7 @@ func (c *Collector) Processes() ProcTable { last := c.ring[n-1] t.CPU, t.Cores, t.Net, t.CPUTemp, t.CPUPowerW = last.CPU, last.Cores, last.Net, cpuTemp(last.Temps), last.CPUPower t.CoreIDs = coreIDs(len(last.Cores)) + t.CorePkgs = corePkgs(len(last.Cores)) from := n - 90 if from < 0 { from = 0 diff --git a/web/src/Hardware.tsx b/web/src/Hardware.tsx index beb003f..220b80c 100644 --- a/web/src/Hardware.tsx +++ b/web/src/Hardware.tsx @@ -158,6 +158,10 @@ export default function Hardware() { const capacity = drives.reduce((n, d) => n + d.size, 0) const threads = cpu.threads ?? 0 const cores = cpu.cores ?? 0 + // a two-socket board is two processors, not one with twice the cores: count and name each + const sockets = Math.max(1, cpu.sockets ?? 1) + const multi = sockets > 1 + const per = (n: number) => Math.round(n / sockets) return (
@@ -171,8 +175,11 @@ export default function Hardware() {
Processor
-
20 ? { fontSize: 16 } : undefined}>{cpu.model}
-
{[cores ? `${cores} core${cores === 1 ? '' : 's'}` : '', threads && threads !== cores ? `${threads} threads` : '', cpu.max_mhz ? `up to ${ghz(cpu.max_mhz)}` : ''].filter(Boolean).join(' · ')}
+
(multi ? 16 : 20) ? { fontSize: 16 } : undefined}>{multi ? `${sockets} × ${cpu.model}` : cpu.model}
+
{(multi + ? [`${sockets} CPUs`, cores ? `${per(cores)} cores${threads && threads !== cores ? ` / ${per(threads)} threads` : ''} each` : '', cpu.max_mhz ? `up to ${ghz(cpu.max_mhz)}` : ''] + : [cores ? `${cores} core${cores === 1 ? '' : 's'}` : '', threads && threads !== cores ? `${threads} threads` : '', cpu.max_mhz ? `up to ${ghz(cpu.max_mhz)}` : ''] + ).filter(Boolean).join(' · ')}
Memory
@@ -199,9 +206,11 @@ export default function Hardware() {

-

1 ? ` · ${cpu.threads_per_core} threads each` : ''}` : ''} /> + {multi &&

} +

1 ? ` · ${cpu.threads_per_core} threads each` : ''}` : ''} /> + {multi && threads > 0 &&

}

-

+

diff --git a/web/src/Top.tsx b/web/src/Top.tsx index 54f010b..470db38 100644 --- a/web/src/Top.tsx +++ b/web/src/Top.tsx @@ -3,7 +3,7 @@ // as they climb. The numbers are Ham Lab's own — the sensor stream for everything measured and // /api/procs for the table — so this works on any machine the console can see, whether or not // btop is installed on it. -import { createContext, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { Fragment, createContext, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { api, GPU, ProcTable, Proc, Sample } from './api' import { Catalog, MONITOR_SHAPE, Values, mergeValues, sampleOf, stale } from './sampleAdapter' import { fetchCatalog, fetchHistories, useSensorStream } from './sensors' @@ -360,7 +360,13 @@ export default function Top() { const model = (pt?.cpu_model ?? '').replace(/\(R\)|\(TM\)|CPU|Processor/g, '').replace(/@.*$/, '').replace(/^\d+th Gen\s*/i, '').replace(/\s+/g, ' ').trim() // the hottest thing that looks like a CPU sensor, and one per core where the chip names them; // a second thread of a core (CPU 8 on an 8-core chip with hyper-threading) wears its core's - const coreTemp = (i: number) => { const id = pt?.core_ids?.[i] ?? i; return s?.temps.find((t) => new RegExp(`core ${id}\\b`, 'i').test(t.label))?.c } + // On a two-socket board each CPU has its own sensor chip, both counting from "Core 0", so a + // thread's core is looked up on its own socket's chip. + const pkgs = pt?.core_pkgs?.length === cores.length ? pt.core_pkgs : undefined + const sockets = pkgs ? [...new Set(pkgs)].sort((a, b) => a - b) : [] + const coreTemp = (i: number) => { const id = pt?.core_ids?.[i] ?? i, pk = pkgs?.[i]; return s?.temps.find((t) => new RegExp(`core ${id}\\b`, 'i').test(t.label) && (pk === undefined || (t.pkg ?? 0) === pk))?.c } + const sockTemp = (pk: number) => s?.temps.find((t) => t.chip === 'coretemp' && new RegExp(`^package id ${pk}$`, 'i').test(t.label))?.c + const sockUse = (pk: number) => { const v = cores.filter((_, i) => pkgs?.[i] === pk); return v.length ? v.reduce((a, b) => a + b, 0) / v.length : 0 } const cpuTemp = pt?.cpu_temp ?? s?.temps.find((t) => /package|tctl|tdie|cpu/i.test(t.label))?.c ?? coreTemp(0) // the temperature columns are there for every row once any row has one, so the % column lines up const anyTemp = cpuTemp !== undefined || cores.some((_, i) => coreTemp(i) !== undefined) @@ -403,8 +409,8 @@ export default function Top() {

up {upfor(uptime)}
-
-
{model || 'cpu'}{pt?.cpu_mhz ? `${Math.round(pt.cpu_mhz)} MHz` : ''} +
1 ? ' multi' : '')}> +
{sockets.length > 1 ? `${sockets.length} × ` : ''}{model || 'cpu'}{pt?.cpu_mhz ? `${Math.round(pt.cpu_mhz)} MHz` : ''} {pt?.cpu_power_w ? {pt.cpu_mhz ? ' · ' : ''}{pt.cpu_power_w < 10 ? pt.cpu_power_w.toFixed(1) : Math.round(pt.cpu_power_w)} W : null}
CPU @@ -412,17 +418,33 @@ export default function Top() { {Math.round(cpu)}% {anyTemp && <>{cpuTemp !== undefined && }{cpuTemp !== undefined ? `${Math.round(cpuTemp)}°C` : ''}}
- {cores.map((v, i) => { - const t = coreTemp(i) - return ( -
- C{i} - {braille(recent.get(`cpu.core.${i}.usage`) ?? [], 9)} - {Math.round(v)}% - {anyTemp && <>{t !== undefined && }{t !== undefined ? `${Math.round(t)}°C` : ''}} -
- ) - })} + {(sockets.length > 1 ? sockets : [undefined]).map((pk) => ( + + {pk !== undefined && (() => { + const u = sockUse(pk), t = sockTemp(pk) + return ( +
+ CPU{pk + 1} + + {Math.round(u)}% + {anyTemp && <>{t !== undefined && }{t !== undefined ? `${Math.round(t)}°C` : ''}} +
+ ) + })()} + {cores.map((v, i) => { + if (pk !== undefined && pkgs?.[i] !== pk) return null + const t = coreTemp(i) + return ( +
+ C{i} + {braille(recent.get(`cpu.core.${i}.usage`) ?? [], 9)} + {Math.round(v)}% + {anyTemp && <>{t !== undefined && }{t !== undefined ? `${Math.round(t)}°C` : ''}} +
+ ) + })} +
+ ))}
Load AVG:{load.map((x) => x.toFixed(2)).join(' ')}
diff --git a/web/src/api.ts b/web/src/api.ts index fe27aea..077fcd1 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -71,7 +71,7 @@ export const titleOf = (name: string | null, machines: MachineStatus[], hubHost: export interface MachineStatus { name: string; url: string; fingerprint?: string; added: string; online: boolean; host?: string; version?: string; load?: string; uptime?: number; reboot_needed?: boolean; last_seen?: string; error?: string } export interface Term { id: string; name: string; running: boolean; exit_code: number; created: string; clients: number } -export interface Temp { chip: string; label: string; c: number; max?: number; crit?: number } +export interface Temp { chip: string; label: string; c: number; max?: number; crit?: number; pkg?: number } export interface Fan { chip: string; label: string; rpm: number } export interface NetIf { iface: string; up: boolean; rx_bps: number; tx_bps: number; rx_total: number; tx_total: number } export interface Disk { name: string; model?: string; size: number; read_bps: number; write_bps: number; util_pct: number; temp?: number } @@ -90,7 +90,7 @@ export interface GPU { mhz?: number; req_mhz?: number; max_mhz?: number; mem_used?: number; mem_total?: number clients: GPUClient[]; clients_ok: boolean; stalled?: boolean } -export interface ProcTable { t: number; cpu: number; cores: number[]; cpu_hist: number[]; cpu_model?: string; cpu_mhz?: number; cpu_max_mhz?: number; cpu_power_w?: number; cpu_temp?: number; core_ids?: number[]; load: [number, number, number]; mem: MemInfo; swap: MemInfo; net: NetIf[]; gpus?: GPU[]; uptime: number; procs: Proc[]; total: number; running: number } +export interface ProcTable { t: number; cpu: number; cores: number[]; cpu_hist: number[]; cpu_model?: string; cpu_mhz?: number; cpu_max_mhz?: number; cpu_power_w?: number; cpu_temp?: number; core_ids?: number[]; core_pkgs?: number[]; load: [number, number, number]; mem: MemInfo; swap: MemInfo; net: NetIf[]; gpus?: GPU[]; uptime: number; procs: Proc[]; total: number; running: number } export interface Sample { t: number; interval_ms: number; cpu: number; cores: number[]; load: [number, number, number] mem: { total: number; used: number; avail: number }; swap: { total: number; used: number; avail: number } diff --git a/web/src/styles.css b/web/src/styles.css index 6f258ba..95d57f3 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -794,6 +794,8 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%, .t-cores-head b { color: var(--fg); font-weight: 400; overflow: hidden; text-overflow: ellipsis; } .t-core { display: flex; align-items: center; gap: 6px; } .t-core-n { width: 30px; } .t-core-p { width: 34px; text-align: right; } +.t-cores.multi .t-core-n { width: 38px; } /* "CPU2" — a two-socket board has a row per socket above its threads */ +.t-sock { color: var(--fg); margin-top: 2px; } .t-core-g { flex: 1; color: var(--ok); letter-spacing: 0; overflow: hidden; white-space: nowrap; } .t-core .tm { flex: 1; } .t-core-t { flex: 0 0 62px; display: flex; } .t-core-c { width: 42px; text-align: right; color: var(--t-blue); } .t-load { display: flex; margin-top: 2px; }