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 {