Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 18 additions & 12 deletions agent/cpu.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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]
Expand Down
65 changes: 65 additions & 0 deletions agent/cpu_linux.go
Original file line number Diff line number Diff line change
@@ -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
}
11 changes: 11 additions & 0 deletions agent/cpu_stub.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//go:build !linux && !darwin && !windows

package agent

func getCpuFrequencies() []float64 {
return nil
}

func getCpuBaseClockMHz() float64 {
return 0
}
9 changes: 8 additions & 1 deletion agent/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion internal/entities/system/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/hub/systems/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
23 changes: 23 additions & 0 deletions internal/migrations/add_cpu_mhz_to_system_details.go
Original file line number Diff line number Diff line change
@@ -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)
}
52 changes: 52 additions & 0 deletions internal/site/src/components/routes/system/cpu-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -143,6 +165,35 @@ export default memo(function CpuCoresSheet({
</ChartCard>
)}

{hasFrequency && (
<ChartCard
key="cpu-frequency"
empty={dataEmpty}
grid={grid}
title={t`CPU Frequency`}
description={t`Per-core frequency`}
legend={numCores < 10}
className="min-h-auto"
>
<AreaChartDefault
chartData={chartData}
maxToggled={maxValues}
legend={numCores < 10}
domain={[0, (dataMax: number) => 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}
/>
</ChartCard>
)}

{numCores > 0 && (
<ChartCard
key="cpu-cores-all"
Expand Down Expand Up @@ -200,6 +251,7 @@ export default memo(function CpuCoresSheet({
/>
</ChartCard>
))}

</SheetContent>
)}
</Sheet>
Expand Down
3 changes: 2 additions & 1 deletion internal/site/src/components/routes/system/info-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]: {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,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,cpu,os,os_name,arch,memory,podman,cpu_mhz",
headers: {
"Cache-Control": "public, max-age=60",
},
Expand Down
5 changes: 4 additions & 1 deletion internal/site/src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down Expand Up @@ -390,6 +392,7 @@ export interface SystemDetailsRecord extends RecordModel {
os_name: string
memory: number
podman: boolean
cpu_mhz?: number
}

export interface SmartDeviceRecord extends RecordModel {
Expand Down
Loading