diff --git a/agent/cpu.go b/agent/cpu.go index f92b9fcde..773b1a663 100644 --- a/agent/cpu.go +++ b/agent/cpu.go @@ -24,12 +24,15 @@ func init() { // CpuMetrics contains detailed CPU usage breakdown type CpuMetrics struct { - Total float64 - User float64 - System float64 - Iowait float64 - Steal float64 - Idle float64 + Total float64 + User float64 + System float64 + Iowait float64 + Steal float64 + Idle float64 + Irq float64 + Softirq float64 + Nice float64 } // getCpuMetrics calculates detailed CPU usage metrics using cached previous measurements. @@ -56,12 +59,15 @@ func getCpuMetrics(cacheTimeMs uint16) (CpuMetrics, error) { } metrics := CpuMetrics{ - Total: calculateBusy(t1, t2), - User: clampPercent((t2.User - t1.User) / totalDelta * 100), - System: clampPercent((t2.System - t1.System) / totalDelta * 100), - Iowait: clampPercent((t2.Iowait - t1.Iowait) / totalDelta * 100), - Steal: clampPercent((t2.Steal - t1.Steal) / totalDelta * 100), - Idle: clampPercent((t2.Idle - t1.Idle) / totalDelta * 100), + Total: calculateBusy(t1, t2), + User: clampPercent((t2.User - t1.User) / totalDelta * 100), + System: clampPercent((t2.System - t1.System) / totalDelta * 100), + Iowait: clampPercent((t2.Iowait - t1.Iowait) / totalDelta * 100), + Steal: clampPercent((t2.Steal - t1.Steal) / totalDelta * 100), + Idle: clampPercent((t2.Idle - t1.Idle) / totalDelta * 100), + Irq: clampPercent((t2.Irq - t1.Irq) / totalDelta * 100), + Softirq: clampPercent((t2.Softirq - t1.Softirq) / totalDelta * 100), + Nice: clampPercent((t2.Nice - t1.Nice) / totalDelta * 100), } lastCpuTimes[cacheTimeMs] = times[0] diff --git a/agent/cpu_linux.go b/agent/cpu_linux.go new file mode 100644 index 000000000..4da493a21 --- /dev/null +++ b/agent/cpu_linux.go @@ -0,0 +1,65 @@ +//go:build linux + +package agent + +import ( + "os" + "sort" + "strconv" + "strings" + + "github.com/henrygd/beszel/agent/utils" +) + +// getCpuBaseClockMHz reads the max rated CPU clock in MHz from sysfs. +// Returns 0 if unavailable (no cpufreq driver). +func getCpuBaseClockMHz() float64 { + kHz, ok := utils.ReadUintFile("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq") + if !ok || kHz == 0 { + return 0 + } + return utils.TwoDecimals(float64(kHz) / 1000) +} + +// getCpuFrequencies reads per-core current CPU frequency in GHz from sysfs. +// Returns nil if unavailable (no cpufreq driver or sysfs not mounted). +func getCpuFrequencies() []float64 { + entries, err := os.ReadDir("/sys/devices/system/cpu") + if err != nil { + return nil + } + + type cpuFreq struct { + idx int + freq float64 + } + + var freqs []cpuFreq + for _, entry := range entries { + name := entry.Name() + if !strings.HasPrefix(name, "cpu") { + continue + } + idx, err := strconv.Atoi(strings.TrimPrefix(name, "cpu")) + if err != nil { + continue + } + kHz, ok := utils.ReadUintFile("/sys/devices/system/cpu/" + name + "/cpufreq/scaling_cur_freq") + if !ok { + continue + } + freqs = append(freqs, cpuFreq{idx: idx, freq: utils.TwoDecimals(float64(kHz) / 1e6)}) + } + + if len(freqs) == 0 { + return nil + } + + sort.Slice(freqs, func(i, j int) bool { return freqs[i].idx < freqs[j].idx }) + + result := make([]float64, len(freqs)) + for i, f := range freqs { + result[i] = f.freq + } + return result +} diff --git a/agent/cpu_stub.go b/agent/cpu_stub.go new file mode 100644 index 000000000..216ca06b2 --- /dev/null +++ b/agent/cpu_stub.go @@ -0,0 +1,11 @@ +//go:build !linux && !darwin && !windows + +package agent + +func getCpuFrequencies() []float64 { + return nil +} + +func getCpuBaseClockMHz() float64 { + return 0 +} diff --git a/agent/system.go b/agent/system.go index a79819b77..3380e53a9 100644 --- a/agent/system.go +++ b/agent/system.go @@ -74,9 +74,10 @@ func (a *Agent) refreshSystemDetails() { } } - // cpu model + // cpu model and base clock if info, err := cpu.Info(); err == nil && len(info) > 0 { a.systemDetails.CpuModel = info[0].ModelName + a.systemDetails.CpuMHz = getCpuBaseClockMHz() } // cores / threads cores, _ := cpu.Counts(false) @@ -147,6 +148,9 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats { utils.TwoDecimals(cpuMetrics.Iowait), utils.TwoDecimals(cpuMetrics.Steal), utils.TwoDecimals(cpuMetrics.Idle), + utils.TwoDecimals(cpuMetrics.Irq), + utils.TwoDecimals(cpuMetrics.Softirq), + utils.TwoDecimals(cpuMetrics.Nice), } } else { slog.Error("Error getting cpu metrics", "err", err) @@ -157,6 +161,9 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats { systemStats.CpuCoresUsage = perCoreUsage } + // per-core cpu frequencies (Linux sysfs, nil on other platforms) + systemStats.CpuFreqs = getCpuFrequencies() + // load average if avgstat, err := load.Avg(); err == nil { systemStats.LoadAvg[0] = avgstat.Load1 diff --git a/internal/entities/system/system.go b/internal/entities/system/system.go index 81da1fee2..f12658e06 100644 --- a/internal/entities/system/system.go +++ b/internal/entities/system/system.go @@ -46,10 +46,11 @@ type Stats struct { NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download] DiskIO [2]uint64 `json:"dio,omitzero" cbor:"32,keyasint,omitzero"` // [read bytes, write bytes] MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes] - CpuBreakdown []float64 `json:"cpub,omitempty" cbor:"33,keyasint,omitempty"` // [user, system, iowait, steal, idle] + CpuBreakdown []float64 `json:"cpub,omitempty" cbor:"33,keyasint,omitempty"` // [user, system, iowait, steal, idle, irq, softirq, nice] CpuCoresUsage Uint8Slice `json:"cpus,omitempty" cbor:"34,keyasint,omitempty"` // per-core busy usage [CPU0..] DiskIoStats [6]float64 `json:"dios,omitzero" cbor:"35,keyasint,omitzero"` // [read time %, write time %, io utilization %, r_await ms, w_await ms, weighted io %] MaxDiskIoStats [6]float64 `json:"diosm,omitzero" cbor:"-"` // max values for DiskIoStats + CpuFreqs []float64 `json:"cf,omitempty" cbor:"36,keyasint,omitempty"` // per-core CPU frequency (GHz) } // Uint8Slice wraps []uint8 to customize JSON encoding while keeping CBOR efficient. @@ -170,6 +171,7 @@ type Details struct { Podman bool `cbor:"8,keyasint,omitempty"` MemoryTotal uint64 `cbor:"9,keyasint"` SmartInterval time.Duration `cbor:"10,keyasint,omitempty"` + CpuMHz float64 `cbor:"11,keyasint,omitempty"` // base/advertised clock speed } // Final data structure to return to the hub diff --git a/internal/hub/systems/system.go b/internal/hub/systems/system.go index b9a0103f3..0a8783849 100644 --- a/internal/hub/systems/system.go +++ b/internal/hub/systems/system.go @@ -265,6 +265,7 @@ func createSystemDetailsRecord(app core.App, data *system.Details, systemId stri "arch": data.Arch, "memory": data.MemoryTotal, "podman": data.Podman, + "cpu_mhz": data.CpuMHz, "updated": time.Now().UTC(), } result, err := app.DB().Update(collectionName, params, dbx.HashExp{"id": systemId}).Execute() diff --git a/internal/migrations/add_cpu_mhz_to_system_details.go b/internal/migrations/add_cpu_mhz_to_system_details.go new file mode 100644 index 000000000..7b3bc8301 --- /dev/null +++ b/internal/migrations/add_cpu_mhz_to_system_details.go @@ -0,0 +1,23 @@ +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 + } + // skip if field already exists + if collection.Fields.GetByName("cpu_mhz") != nil { + return nil + } + collection.Fields.Add(&core.NumberField{ + Name: "cpu_mhz", + }) + return app.Save(collection) + }, nil) +} diff --git a/internal/site/src/components/routes/system/cpu-sheet.tsx b/internal/site/src/components/routes/system/cpu-sheet.tsx index b4fcb0419..2627854e1 100644 --- a/internal/site/src/components/routes/system/cpu-sheet.tsx +++ b/internal/site/src/components/routes/system/cpu-sheet.tsx @@ -41,6 +41,7 @@ export default memo(function CpuCoresSheet({ const cpus = latest?.cpus ?? [] const numCores = cpus.length const hasBreakdown = (latest?.cpub?.length ?? 0) > 0 + const hasFrequency = (latest?.cf?.length ?? 0) > 0 // make sure all individual core data points have the same y axis domain to make relative comparison easier let highestCpuCorePct = 1 @@ -91,6 +92,27 @@ export default memo(function CpuCoresSheet({ opacity: 0.35, stackId: "a", }, + { + label: "IRQ", + dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[5], + color: "hsl(195, 70%, 50%)", + opacity: 0.35, + stackId: "a", + }, + { + label: t`SoftIRQ`, + dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[6], + color: "hsl(50, 80%, 52%)", + opacity: 0.35, + stackId: "a", + }, + { + label: t`Nice`, + dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[7], + color: "hsl(300, 60%, 55%)", + opacity: 0.35, + stackId: "a", + }, { label: t`Other`, dataKey: ({ stats }: SystemStatsRecord) => { @@ -143,6 +165,35 @@ export default memo(function CpuCoresSheet({ )} + {hasFrequency && ( + + Math.max(dataMax, 0.1)]} + dataPoints={Array.from({ length: latest?.cf?.length ?? 0 }).map((_, i) => ({ + label: `CPU ${i}`, + dataKey: ({ stats }: SystemStatsRecord) => stats?.cf?.[i], + color: `hsl(${226 + (((i * 360) / Math.max(1, latest?.cf?.length ?? 1)) % 360)}, var(--chart-saturation), var(--chart-lightness))`, + opacity: 0.35, + stackId: undefined, + }))} + tickFormatter={(val) => `${decimalString(val, 2)} GHz`} + contentFormatter={({ value }) => `${decimalString(value, 2)} GHz`} + itemSorter={() => 1} + /> + + )} + {numCores > 0 && ( ))} + )} diff --git a/internal/site/src/components/routes/system/info-bar.tsx b/internal/site/src/components/routes/system/info-bar.tsx index 2ef1f0d8e..5a1ae9978 100644 --- a/internal/site/src/components/routes/system/info-bar.tsx +++ b/internal/site/src/components/routes/system/info-bar.tsx @@ -65,6 +65,7 @@ export default function InfoBar({ const osName = details?.os_name const arch = details?.arch const memory = details?.memory + const cpuMHz = details?.cpu_mhz const osInfo = { [Os.Linux]: { @@ -104,7 +105,7 @@ export default function InfoBar({ value: cpuModel, Icon: CpuIcon, hide: !cpuModel, - label: `${plural(cores, { one: "# core", other: "# cores" })} / ${plural(threads, { one: "# thread", other: "# threads" })}${arch ? ` / ${arch}` : ""}`, + label: `${plural(cores, { one: "# core", other: "# cores" })} / ${plural(threads, { one: "# thread", other: "# threads" })}${arch ? ` / ${arch}` : ""}${cpuMHz ? ` / ${toFixedFloat(cpuMHz / 1000, 2)} GHz` : ""}`, }, ] as { value: string | number | undefined 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 c83d2dff3..0d554e10d 100644 --- a/internal/site/src/components/routes/system/use-system-data.ts +++ b/internal/site/src/components/routes/system/use-system-data.ts @@ -97,7 +97,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,cpu,os,os_name,arch,memory,podman,cpu_mhz", headers: { "Cache-Control": "public, max-age=60", }, diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index 74c01a2ad..ec0df5995 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -85,10 +85,12 @@ export interface SystemStats { cpu: number /** peak cpu */ cpum?: number - /** cpu breakdown [user, system, iowait, steal, idle] (0-100 integers) */ + /** cpu breakdown [user, system, iowait, steal, idle, irq, softirq, nice] (0-100 integers) */ cpub?: number[] /** per-core cpu usage [CPU0..] (0-100 integers) */ cpus?: number[] + /** per-core CPU frequency (GHz) */ + cf?: number[] /** load average */ la?: [number, number, number] /** total memory (gb) */ @@ -390,6 +392,7 @@ export interface SystemDetailsRecord extends RecordModel { os_name: string memory: number podman: boolean + cpu_mhz?: number } export interface SmartDeviceRecord extends RecordModel {