diff --git a/agent/fans.go b/agent/fans.go new file mode 100644 index 00000000..0e3b2688 --- /dev/null +++ b/agent/fans.go @@ -0,0 +1,101 @@ +package agent + +import ( + "log/slog" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/henrygd/beszel/agent/utils" + "github.com/henrygd/beszel/internal/entities/system" +) + +type fanSensor struct { + key, path string +} + +var getFanSensors = newFanSensorCache(hwmonRoot) + +func newFanSensorCache(root string) func() ([]fanSensor, error) { + return sync.OnceValues(func() ([]fanSensor, error) { + return discoverHwmonFans(root) + }) +} + +// updateFans populates systemStats.Fans from the host's hwmon sysfs tree. +// No-op on platforms where hwmon isn't available (see fans_other.go). +func (a *Agent) updateFans(systemStats *system.Stats) { + if hwmonRoot == "" { + return + } + sensors, err := getFanSensors() + if err != nil { + slog.Debug("Error reading fans", "err", err) + return + } + fans := readFanSensors(sensors) + if len(fans) == 0 { + return + } + systemStats.Fans = fans + // Note: Commented out because we don't currently use this value in the UI. + // Compute the single "dashboard" value used by the FanSpeed alert. + // Per-sensor RPMs live in Stats.Fans and drive the multi-line FanChart + // in the UI; the alert path only needs one number to compare against + // the user's threshold, so we use the highest RPM across all fans + // a.systemInfo.DashboardFan = 0 + // for _, rpm := range fans { + // if rpm > a.systemInfo.DashboardFan { + // a.systemInfo.DashboardFan = rpm + // } + // } +} + +// readHwmonFans walks the given hwmon root (typically /sys/class/hwmon) and +// returns a map of "_" → RPM for every fan*_input +// file it finds. Zero RPM is retained because it can represent a real fan that +// has stopped; negative and malformed readings are ignored. +func readHwmonFans(root string) (map[string]uint16, error) { + sensors, err := discoverHwmonFans(root) + if err != nil { + return nil, err + } + return readFanSensors(sensors), nil +} + +func discoverHwmonFans(root string) ([]fanSensor, error) { + entries, err := os.ReadDir(root) + if err != nil { + return nil, err + } + var sensors []fanSensor + for _, entry := range entries { + chipDir := filepath.Join(root, entry.Name()) + chipName := utils.ReadStringFile(filepath.Join(chipDir, "name")) + if chipName == "" { + chipName = entry.Name() + } + inputs, _ := filepath.Glob(filepath.Join(chipDir, "fan*_input")) + for _, inputPath := range inputs { + base := strings.TrimSuffix(filepath.Base(inputPath), "_input") + label := utils.ReadStringFile(filepath.Join(chipDir, base+"_label")) + key := chipName + "_" + base + if label != "" { + key = chipName + "_" + label + } + sensors = append(sensors, fanSensor{key, inputPath}) + } + } + return sensors, nil +} + +func readFanSensors(sensors []fanSensor) map[string]uint16 { + fans := make(map[string]uint16, len(sensors)) + for _, sensor := range sensors { + if rpm, ok := utils.ReadUintFile(sensor.path); ok { + fans[sensor.key] = uint16(rpm) + } + } + return fans +} diff --git a/agent/fans_linux.go b/agent/fans_linux.go new file mode 100644 index 00000000..08d30f32 --- /dev/null +++ b/agent/fans_linux.go @@ -0,0 +1,8 @@ +//go:build linux + +package agent + +// hwmonRoot is the sysfs entry point for hardware monitor chips. Each +// subdirectory (hwmon0, hwmon1, …) is one chip; fan*_input files inside it +// expose RPM readings. +const hwmonRoot = "/sys/class/hwmon" diff --git a/agent/fans_other.go b/agent/fans_other.go new file mode 100644 index 00000000..b8bfca5d --- /dev/null +++ b/agent/fans_other.go @@ -0,0 +1,7 @@ +//go:build !linux + +package agent + +// hwmonRoot is empty on non-Linux platforms — fan RPM reporting via sysfs +// hwmon is Linux-specific. updateFans() short-circuits when this is empty. +const hwmonRoot = "" diff --git a/agent/fans_test.go b/agent/fans_test.go new file mode 100644 index 00000000..ab374334 --- /dev/null +++ b/agent/fans_test.go @@ -0,0 +1,87 @@ +//go:build testing + +package agent + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writeFile creates path with parents and writes contents. +func writeFile(t *testing.T, path, contents string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(contents), 0o644)) +} + +// TestReadHwmonFans verifies the /sys/class/hwmon walker: +// - picks up fan*_input from every chip, +// - keys entries by chip name + sensor label (or fan idx if no label), +// - retains 0 RPM for stopped fans, +// - tolerates chips with no fan files at all. +func TestReadHwmonFans(t *testing.T) { + root := t.TempDir() + + // hwmon0: Raspberry Pi 5 active cooler — one fan, no label. + writeFile(t, filepath.Join(root, "hwmon0", "name"), "pwmfan\n") + writeFile(t, filepath.Join(root, "hwmon0", "fan1_input"), "6500\n") + + // hwmon1: a thermal-only chip, no fan files. Must not error. + writeFile(t, filepath.Join(root, "hwmon1", "name"), "cpu_thermal\n") + writeFile(t, filepath.Join(root, "hwmon1", "temp1_input"), "55000\n") + + // hwmon2: two fans — one stopped (0 RPM) and one labeled "chassis". + writeFile(t, filepath.Join(root, "hwmon2", "name"), "nct6798\n") + writeFile(t, filepath.Join(root, "hwmon2", "fan1_input"), "0\n") + writeFile(t, filepath.Join(root, "hwmon2", "fan2_input"), "1200\n") + writeFile(t, filepath.Join(root, "hwmon2", "fan2_label"), "chassis\n") + + fans, err := readHwmonFans(root) + require.NoError(t, err) + + assert.Equal(t, map[string]uint16{ + "pwmfan_fan1": 6500, + "nct6798_fan1": 0, + "nct6798_chassis": 1200, + }, fans) +} + +// TestReadHwmonFansMissingRoot returns an error rather than panicking when the +// hwmon root doesn't exist (e.g. running on a kernel without hwmon support). +func TestReadHwmonFansMissingRoot(t *testing.T) { + _, err := readHwmonFans(filepath.Join(t.TempDir(), "does-not-exist")) + assert.Error(t, err) +} + +// TestReadHwmonFansEmpty returns an empty map (not nil error) when the root +// exists but contains no chips at all. +func TestReadHwmonFansEmpty(t *testing.T) { + root := t.TempDir() + fans, err := readHwmonFans(root) + require.NoError(t, err) + assert.Empty(t, fans) +} + +func TestFanDiscoveryCache(t *testing.T) { + root := t.TempDir() + input := filepath.Join(root, "hwmon0", "fan1_input") + writeFile(t, filepath.Join(root, "hwmon0", "name"), "chip\n") + writeFile(t, input, "1000\n") + + getSensors := newFanSensorCache(root) + sensors, err := getSensors() + require.NoError(t, err) + fans := readFanSensors(sensors) + assert.Equal(t, uint16(1000), fans["chip_fan1"]) + + writeFile(t, input, "1200\n") + writeFile(t, filepath.Join(root, "hwmon0", "fan1_label"), "case\n") + sensors, err = getSensors() + require.NoError(t, err) + fans = readFanSensors(sensors) + assert.Equal(t, map[string]uint16{"chip_fan1": 1200}, fans) +} diff --git a/agent/system.go b/agent/system.go index eeb55cf1..da348b46 100644 --- a/agent/system.go +++ b/agent/system.go @@ -210,6 +210,9 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats { // TODO: maybe refactor to methods on systemStats a.updateTemperatures(&systemStats) + // fan speeds (Linux-only; sysfs hwmon) + a.updateFans(&systemStats) + // GPU data if a.gpuManager != nil { // reset high gpu percent diff --git a/internal/entities/system/system.go b/internal/entities/system/system.go index 81da1fee..b8e6cfe8 100644 --- a/internal/entities/system/system.go +++ b/internal/entities/system/system.go @@ -33,6 +33,7 @@ type Stats struct { MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"-"` MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"-"` Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"` + Fans map[string]uint16 `json:"f,omitempty" cbor:"36,keyasint,omitempty"` ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"` GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"` // LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"` diff --git a/internal/records/records.go b/internal/records/records.go index 5162fce7..ae986893 100644 --- a/internal/records/records.go +++ b/internal/records/records.go @@ -191,6 +191,8 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats { // accumulate cpu breakdown [user, system, iowait, steal, idle] var cpuBreakdownSums []float64 tempCount := float64(0) + var fanSums map[string]uint64 + fanCount := uint64(0) // Accumulate totals for i := range records { @@ -282,6 +284,17 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats { } } + // Accumulate fan speeds + if stats.Fans != nil { + if fanSums == nil { + fanSums = make(map[string]uint64, len(stats.Fans)) + } + fanCount++ + for key, value := range stats.Fans { + fanSums[key] += uint64(value) + } + } + // Accumulate extra filesystem stats if stats.ExtraFs != nil { if sum.ExtraFs == nil { @@ -387,6 +400,14 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats { } } + // Average fan speeds + if fanSums != nil && fanCount > 0 { + sum.Fans = make(map[string]uint16, len(fanSums)) + for key, value := range fanSums { + sum.Fans[key] = uint16(value / fanCount) + } + } + // Average extra filesystem stats if sum.ExtraFs != nil { for key := range sum.ExtraFs { diff --git a/internal/records/records_averaging_test.go b/internal/records/records_averaging_test.go index 92ab99f7..2ce77de8 100644 --- a/internal/records/records_averaging_test.go +++ b/internal/records/records_averaging_test.go @@ -291,6 +291,28 @@ func TestAverageSystemStatsSlice_Temperatures(t *testing.T) { assert.Equal(t, 80.0, result.Temperatures["gpu"]) } +// Tests that fan speeds are averaged and records without fan data are excluded. +func TestAverageSystemStatsSlice_Fans(t *testing.T) { + input := []system.Stats{ + { + Fans: map[string]uint16{"cpu": 60_000, "case": 1_000}, + }, + { + Fans: map[string]uint16{"cpu": 50_000, "case": 2_000}, + }, + { + // No fan data - should not affect fan averaging + Cpu: 30.0, + }, + } + + result := records.AverageSystemStatsSlice(input) + + require.NotNil(t, result.Fans) + assert.Equal(t, uint16(55_000), result.Fans["cpu"]) + assert.Equal(t, uint16(1_500), result.Fans["case"]) +} + func TestAverageSystemStatsSlice_NetworkInterfaces(t *testing.T) { input := []system.Stats{ { diff --git a/internal/site/src/components/routes/system.tsx b/internal/site/src/components/routes/system.tsx index 4d4b96da..accd0233 100644 --- a/internal/site/src/components/routes/system.tsx +++ b/internal/site/src/components/routes/system.tsx @@ -9,7 +9,7 @@ import { CpuChart, ContainerCpuChart } from "./system/charts/cpu-charts" import { MemoryChart, ContainerMemoryChart, SwapChart } from "./system/charts/memory-charts" import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts" import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts" -import { TemperatureChart, BatteryChart } from "./system/charts/sensor-charts" +import { TemperatureChart, FanChart, BatteryChart } from "./system/charts/sensor-charts" import { GpuPowerChart, GpuDetailCharts } from "./system/charts/gpu-charts" import { LazyContainersTable, LazySmartTable, LazySystemdTable } from "./system/lazy-tables" import { LoadAverageChart } from "./system/charts/load-average-chart" @@ -123,6 +123,8 @@ export default memo(function SystemDetail({ id }: { id: string }) { + + {hasGpuPowerData && } @@ -188,6 +190,7 @@ export default memo(function SystemDetail({ id }: { id: string }) { + {pageBottomExtraMargin > 0 &&
} diff --git a/internal/site/src/components/routes/system/charts/sensor-charts.tsx b/internal/site/src/components/routes/system/charts/sensor-charts.tsx index ec6ad281..e9a8e999 100644 --- a/internal/site/src/components/routes/system/charts/sensor-charts.tsx +++ b/internal/site/src/components/routes/system/charts/sensor-charts.tsx @@ -1,7 +1,7 @@ import { t } from "@lingui/core/macro" import AreaChartDefault from "@/components/charts/area-chart" import { batteryStateTranslations } from "@/lib/i18n" -import { $temperatureFilter, $userSettings } from "@/lib/stores" +import { $fanFilter, $temperatureFilter, $userSettings } from "@/lib/stores" import { cn, decimalString, formatTemperature, toFixedFloat } from "@/lib/utils" import type { ChartData, SystemStatsRecord } from "@/types" import { ChartCard, FilterBar } from "../chart-card" @@ -209,3 +209,102 @@ export function TemperatureChart({ ) } + +export function FanChart({ + chartData, + grid, + dataEmpty, +}: { + chartData: ChartData + grid: boolean + dataEmpty: boolean +}) { + const showFanChart = chartData.systemStats.at(-1)?.stats.f + + const filter = useStore($fanFilter) + + const statsRef = useRef(chartData.systemStats) + statsRef.current = chartData.systemStats + + // Stable key derived from current sensor names (sorted) so the memo only + // recomputes when the set of fans changes — not on every data point. + let sensorNamesKey = "" + for (let i = chartData.systemStats.length - 1; i >= 0; i--) { + const f = chartData.systemStats[i].stats?.f + if (f) { + sensorNamesKey = Object.keys(f).sort().join("\0") + break + } + } + + const { colorMap, dataKeys, sortedKeys } = useMemo(() => { + const stats = statsRef.current + const sums = {} as Record + for (const data of stats) { + const f = data.stats?.f + if (!f) continue + for (const key of Object.keys(f)) { + sums[key] = (sums[key] ?? 0) + f[key] + } + } + const sorted = Object.keys(sums).sort((a, b) => sums[b] - sums[a]) + const colorMap = {} as Record + const dataKeys = {} as Record number | undefined> + for (let i = 0; i < sorted.length; i++) { + const key = sorted[i] + colorMap[key] = `hsl(${((i * 360) / sorted.length) % 360}, 60%, 55%)` + dataKeys[key] = (d: SystemStatsRecord) => d.stats?.f?.[key] + } + return { colorMap, dataKeys, sortedKeys: sorted } + }, [sensorNamesKey]) + + const dataPoints = useMemo(() => { + return sortedKeys.map((key) => { + const filterTerms = filter + ? filter + .toLowerCase() + .split(" ") + .filter((term) => term.length > 0) + : [] + const filtered = filterTerms.length > 0 && !filterTerms.some((term) => key.toLowerCase().includes(term)) + const strokeOpacity = filtered ? 0.1 : 1 + return { + label: key, + dataKey: dataKeys[key], + color: colorMap[key], + strokeOpacity, + activeDot: !filtered, + } + }) + }, [sortedKeys, filter, dataKeys, colorMap]) + + if (!showFanChart) { + return null + } + + const legend = dataPoints.length < 12 + + return ( +
+ } + legend={legend} + > + b.value - a.value} + domain={["auto", "auto"]} + legend={legend} + tickFormatter={(val) => `${toFixedFloat(val, 0)}`} + contentFormatter={(item) => `${decimalString(item.value, 0)} RPM`} + dataPoints={dataPoints} + filter={filter} + > + +
+ ) +} diff --git a/internal/site/src/lib/stores.ts b/internal/site/src/lib/stores.ts index fd77788a..0778c1a3 100644 --- a/internal/site/src/lib/stores.ts +++ b/internal/site/src/lib/stores.ts @@ -64,6 +64,9 @@ export const $containerFilter = atom("") /** Temperature chart filter */ export const $temperatureFilter = atom("") +/** Fan-speed chart filter */ +export const $fanFilter = atom("") + /** Fallback copy to clipboard dialog content */ export const $copyContent = atom("") diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index b8690104..c922f134 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -143,6 +143,8 @@ export interface SystemStats { bm?: [number, number] /** temperatures */ t?: Record + /** fan speeds (RPM) — keyed by `_` */ + f?: Record /** extra filesystems */ efs?: Record /** GPU data */ diff --git a/readme.md b/readme.md index 2495eaf7..8685a8a3 100644 --- a/readme.md +++ b/readme.md @@ -16,7 +16,7 @@ It has a friendly web interface, simple configuration, and is ready to use out o - **Lightweight**: Smaller and less resource-intensive than leading solutions. - **Simple**: Easy setup with little manual configuration required. - **Docker stats**: Tracks CPU, memory, and network usage history for each container. -- **Alerts**: Configurable alerts for CPU, memory, disk, bandwidth, temperature, load average, and status. +- **Alerts**: Configurable alerts for CPU, memory, disk, bandwidth, temperature, fan speed, load average, and status. - **Multi-user**: Users manage their own systems. Admins can share systems across users. - **OAuth / OIDC**: Supports many OAuth2 providers. Password auth can be disabled. - **Automatic backups**: Save to and restore from disk or S3-compatible storage. @@ -48,6 +48,7 @@ The [quick start guide](https://beszel.dev/guide/getting-started) and other docu - **Network usage** - Host system and containers. - **Load average** - Host system. - **Temperature** - Host system sensors. +- **Fan speed** - Host system sensors (Linux, via `/sys/class/hwmon`). - **GPU usage / power draw** - Nvidia, AMD, and Intel. - **Battery** - Host system battery charge. - **Containers** - Status and metrics of all running Docker / Podman containers.