From 827157906e9b04e236049f5fddee3b811df90105 Mon Sep 17 00:00:00 2001 From: Vincent Franstyo Date: Tue, 20 Oct 2026 23:59:20 +0700 Subject: [PATCH] feat: show CPU socket count on the system page (#2646) --- agent/system.go | 20 ++++++++++++++++ agent/system_test.go | 17 +++++++++++++ internal/entities/system/system.go | 1 + internal/hub/systems/system.go | 1 + internal/hub/systems/system_test.go | 9 +++++++ .../1792309602_system_details_sockets.go | 24 +++++++++++++++++++ .../src/components/routes/system/info-bar.tsx | 4 +++- .../routes/system/use-system-data.ts | 2 +- internal/site/src/types.d.ts | 2 ++ 9 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 internal/migrations/1792309602_system_details_sockets.go diff --git a/agent/system.go b/agent/system.go index 2aa302770..793da4647 100644 --- a/agent/system.go +++ b/agent/system.go @@ -84,6 +84,7 @@ func (a *Agent) refreshSystemDetails() { // cpu model if info, err := cpu.Info(); err == nil && len(info) > 0 { a.systemDetails.CpuModel = info[0].ModelName + a.systemDetails.Sockets = cpuSockets(info) } // gopsutil doesn't parse the "cpu model" field from /proc/cpuinfo, which // is the only source of the CPU model name on MIPS. Fall back to reading @@ -277,6 +278,25 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats { // on various embedded architectures. var cpuModelFallbackKeys = []string{"cpu model", "system type"} +// cpuSockets returns the number of physical CPU packages, or 0 if unknown. +// PhysicalID is only a socket id on x86 Linux; on Windows gopsutil returns +// one entry per package. +func cpuSockets(info []cpu.InfoStat) int { + switch { + case runtime.GOOS == "windows": + return len(info) + case runtime.GOOS == "linux" && (runtime.GOARCH == "amd64" || runtime.GOARCH == "386"): + ids := make(map[string]struct{}) + for _, c := range info { + if c.PhysicalID != "" { + ids[c.PhysicalID] = struct{}{} + } + } + return len(ids) + } + return 0 +} + // getCpuModelFromCpuinfo reads /proc/cpuinfo and returns a CPU model string. // This is a fallback for architectures where gopsutil's cpu.Info() does not // populate ModelName, most notably MIPS. diff --git a/agent/system_test.go b/agent/system_test.go index b73a0904d..fd2c31629 100644 --- a/agent/system_test.go +++ b/agent/system_test.go @@ -1,11 +1,13 @@ package agent import ( + "runtime" "strings" "testing" "github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/entities/system" + "github.com/shirou/gopsutil/v4/cpu" "github.com/shirou/gopsutil/v4/mem" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -192,3 +194,18 @@ BogoMIPS : 300.00`, }) } } + +func TestCpuSockets(t *testing.T) { + // two packages with several logical CPUs each, as gopsutil reports on x86 Linux + dual := []cpu.InfoStat{{PhysicalID: "0"}, {PhysicalID: "0"}, {PhysicalID: "1"}, {PhysicalID: "1"}} + got := cpuSockets(dual) + switch { + case runtime.GOOS == "windows": + assert.Equal(t, 4, got) + case runtime.GOOS == "linux" && (runtime.GOARCH == "amd64" || runtime.GOARCH == "386"): + assert.Equal(t, 2, got) + assert.Equal(t, 0, cpuSockets([]cpu.InfoStat{{}, {}}), "empty PhysicalID is unknown") + default: + assert.Equal(t, 0, got) + } +} diff --git a/internal/entities/system/system.go b/internal/entities/system/system.go index c3ec5e404..f6ab3620c 100644 --- a/internal/entities/system/system.go +++ b/internal/entities/system/system.go @@ -201,6 +201,7 @@ type Details struct { MemoryTotal uint64 `cbor:"9,keyasint"` SmartInterval time.Duration `cbor:"10,keyasint,omitempty"` ZfsInterval time.Duration `cbor:"11,keyasint,omitempty"` // interval for ZFS detail refresh + Sockets int `cbor:"12,keyasint,omitempty"` // physical CPU packages, 0 if unknown } // Final data structure to return to the hub diff --git a/internal/hub/systems/system.go b/internal/hub/systems/system.go index 7df928f02..75917e096 100644 --- a/internal/hub/systems/system.go +++ b/internal/hub/systems/system.go @@ -381,6 +381,7 @@ func createSystemDetailsRecord(app core.App, data *system.Details, systemId stri "kernel": data.Kernel, "cores": data.Cores, "threads": data.Threads, + "sockets": data.Sockets, "cpu": data.CpuModel, "os": data.Os, "os_name": data.OsName, diff --git a/internal/hub/systems/system_test.go b/internal/hub/systems/system_test.go index b05ca427d..c8c207b0f 100644 --- a/internal/hub/systems/system_test.go +++ b/internal/hub/systems/system_test.go @@ -259,3 +259,12 @@ func TestSSHFallbackDialsAgentUnlessDisabled(t *testing.T) { }) } } + +func TestCreateRecordsSavesCpuSockets(t *testing.T) { + sys, app := newTestSystemWithHub(t) + _, err := sys.createRecords(&system.CombinedData{Details: &system.Details{Hostname: "host", Sockets: 2}}) + require.NoError(t, err) + record, err := app.FindRecordById("system_details", sys.Id) + require.NoError(t, err) + require.Equal(t, 2, record.GetInt("sockets")) +} diff --git a/internal/migrations/1792309602_system_details_sockets.go b/internal/migrations/1792309602_system_details_sockets.go new file mode 100644 index 000000000..a73c02d72 --- /dev/null +++ b/internal/migrations/1792309602_system_details_sockets.go @@ -0,0 +1,24 @@ +package migrations + +import ( + "github.com/pocketbase/pocketbase/core" + m "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + m.Register(func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("system_details") + if err != nil { + return err + } + collection.Fields.Add(&core.NumberField{Id: "system_details_sockets", Name: "sockets", OnlyInt: true}) + return app.Save(collection) + }, func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("system_details") + if err != nil { + return err + } + collection.Fields.RemoveByName("sockets") + return app.Save(collection) + }) +} diff --git a/internal/site/src/components/routes/system/info-bar.tsx b/internal/site/src/components/routes/system/info-bar.tsx index ee9c1339b..8367d1e90 100644 --- a/internal/site/src/components/routes/system/info-bar.tsx +++ b/internal/site/src/components/routes/system/info-bar.tsx @@ -61,6 +61,7 @@ export default function InfoBar({ const cores = details?.cores ?? system.info.c const threads = details?.threads ?? system.info.t ?? 0 const cpuModel = details?.cpu ?? system.info.m + const sockets = details?.sockets ?? 0 const os = details?.os ?? system.info.os ?? Os.Linux const osName = details?.os_name const arch = details?.arch @@ -101,7 +102,8 @@ export default function InfoBar({ { value: secondsToUptimeString(system.info.u), Icon: ClockArrowUp, label: t`Uptime`, hide: !system.info.u }, osInfo[os], { - value: cpuModel, + // 0 means the agent did not report a socket count + value: sockets > 1 ? `${sockets}x ${cpuModel}` : cpuModel, Icon: CpuIcon, hide: !cpuModel, label: `${plural(cores, { one: "# core", other: "# cores" })} / ${plural(threads, { one: "# thread", other: "# threads" })}${arch ? ` / ${arch}` : ""}`, diff --git a/internal/site/src/components/routes/system/use-system-data.ts b/internal/site/src/components/routes/system/use-system-data.ts index f724cecbf..a39ee3d0d 100644 --- a/internal/site/src/components/routes/system/use-system-data.ts +++ b/internal/site/src/components/routes/system/use-system-data.ts @@ -132,7 +132,7 @@ export function useSystemData(id: string) { } pb.collection("system_details") .getOne(system.id, { - fields: "hostname,kernel,cores,threads,cpu,os,os_name,arch,memory,podman", + fields: "hostname,kernel,cores,threads,sockets,cpu,os,os_name,arch,memory,podman", headers: { "Cache-Control": "public, max-age=60", }, diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index cde1f24dc..fa44adcb9 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -511,6 +511,8 @@ export interface SystemDetailsRecord extends RecordModel { kernel: string cores: number threads: number + /** physical CPU packages, 0 if unknown */ + sockets?: number cpu: string os: Os os_name: string