mirror of
https://github.com/henrygd/beszel.git
synced 2026-08-19 08:47:46 +02:00
feat: fan RPM monitoring (#2032)
Adds fan RPM monitoring as a peer to the existing temperature collection, addressing #1918. --------- Co-authored-by: henrygd <hank@henrygd.me>
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
{
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
<TemperatureChart {...coreProps} />
|
||||
|
||||
<FanChart {...coreProps} />
|
||||
|
||||
<BatteryChart {...coreProps} />
|
||||
|
||||
{hasGpuPowerData && <GpuPowerChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />}
|
||||
@@ -188,6 +190,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
<LoadAverageChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />
|
||||
<BandwidthChart {...coreProps} systemStats={systemStats} />
|
||||
<TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} />
|
||||
<FanChart {...coreProps} />
|
||||
<BatteryChart {...coreProps} />
|
||||
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
|
||||
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<string, number>
|
||||
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<string, string>
|
||||
const dataKeys = {} as Record<string, (d: SystemStatsRecord) => 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 (
|
||||
<div className={cn("odd:last-of-type:col-span-full", { "col-span-full": !grid })}>
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`Fans`}
|
||||
description={t`System fan speeds (RPM)`}
|
||||
cornerEl={<FilterBar store={$fanFilter} />}
|
||||
legend={legend}
|
||||
>
|
||||
<LineChartDefault
|
||||
chartData={chartData}
|
||||
itemSorter={(a, b) => 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}
|
||||
></LineChartDefault>
|
||||
</ChartCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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("")
|
||||
|
||||
|
||||
2
internal/site/src/types.d.ts
vendored
2
internal/site/src/types.d.ts
vendored
@@ -143,6 +143,8 @@ export interface SystemStats {
|
||||
bm?: [number, number]
|
||||
/** temperatures */
|
||||
t?: Record<string, number>
|
||||
/** fan speeds (RPM) — keyed by `<chip>_<label-or-fan-idx>` */
|
||||
f?: Record<string, number>
|
||||
/** extra filesystems */
|
||||
efs?: Record<string, ExtraFsStats>
|
||||
/** GPU data */
|
||||
|
||||
Reference in New Issue
Block a user