feat: show CPU socket count on the system page (#2646)

This commit is contained in:
Vincent Franstyo
2026-10-20 12:59:20 -04:00
committed by GitHub
parent c84f517ab9
commit 827157906e
9 changed files with 78 additions and 2 deletions
+20
View File
@@ -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.
+17
View File
@@ -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)
}
}
+1
View File
@@ -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
+1
View File
@@ -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,
+9
View File
@@ -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"))
}
@@ -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)
})
}
@@ -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}` : ""}`,
@@ -132,7 +132,7 @@ export function useSystemData(id: string) {
}
pb.collection<SystemDetailsRecord>("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",
},
+2
View File
@@ -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