mirror of
https://github.com/henrygd/beszel.git
synced 2026-08-19 08:47:46 +02:00
feat: add multi-battery monitoring
- Report battery data for individual devices - Select a representative battery for legacy fields and alerts - Average named battery data independently - Display multiple batteries in system charts - Add cross-platform coverage and transport tests
This commit is contained in:
@@ -55,6 +55,7 @@ type SystemAlertStats struct {
|
||||
Temperatures map[string]float32 `json:"t"`
|
||||
LoadAvg [3]float64 `json:"la"`
|
||||
Battery [2]uint8 `json:"bat"`
|
||||
Batteries map[string]uint8 `json:"bats"`
|
||||
ExtraFs map[string]SystemAlertFsStats `json:"efs"`
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
||||
case "GPU":
|
||||
val = data.Info.GpuPct
|
||||
case "Battery":
|
||||
if data.Stats.Battery[0] == 0 {
|
||||
if !hasRepresentativeBattery(data.Stats.Battery, data.Stats.Batteries) {
|
||||
continue
|
||||
}
|
||||
val = float64(data.Stats.Battery[0])
|
||||
@@ -167,6 +167,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
||||
stat := systemStats[i]
|
||||
// subtract 10 seconds to give a small time buffer
|
||||
systemStatsCreation := stat.Created.Time().Add(-time.Second * 10)
|
||||
stats = SystemAlertStats{}
|
||||
if err := json.Unmarshal(stat.Stats, &stats); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -235,6 +236,9 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
||||
}
|
||||
alert.val += maxUsage
|
||||
case "Battery":
|
||||
if !hasRepresentativeBattery(stats.Battery, stats.Batteries) {
|
||||
continue
|
||||
}
|
||||
alert.val += float64(stats.Battery[0])
|
||||
default:
|
||||
continue
|
||||
@@ -297,6 +301,10 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasRepresentativeBattery(legacy [2]uint8, batteries map[string]uint8) bool {
|
||||
return legacy != [2]uint8{} || len(batteries) > 0
|
||||
}
|
||||
|
||||
func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
||||
// log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold)
|
||||
systemName := alert.systemRecord.GetString("name")
|
||||
|
||||
@@ -199,7 +199,7 @@ func TestSystemAlertsOneMin(t *testing.T) {
|
||||
testOneMinuteSystemAlert(t, "LoadAvg1", 4, setLoadAvgAlertValue, [3]float64{4.1, 0, 0}, [3]float64{3.9, 0, 0})
|
||||
testOneMinuteSystemAlert(t, "LoadAvg5", 4, setLoadAvgAlertValue, [3]float64{0, 4.1, 0}, [3]float64{0, 3.9, 0})
|
||||
testOneMinuteSystemAlert(t, "LoadAvg15", 4, setLoadAvgAlertValue, [3]float64{0, 0, 4.1}, [3]float64{0, 0, 3.9})
|
||||
testOneMinuteSystemAlert(t, "Battery", 20, setBatteryAlertValue, [2]uint8{19, 0}, [2]uint8{21, 0})
|
||||
testOneMinuteSystemAlert(t, "Battery", 20, setBatteryAlertValue, [2]uint8{0, 1}, [2]uint8{21, 0})
|
||||
}
|
||||
|
||||
func TestSystemAlertsTwoMin(t *testing.T) {
|
||||
|
||||
@@ -34,6 +34,7 @@ type Stats struct {
|
||||
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"`
|
||||
Batteries map[string]uint8 `json:"bats,omitempty" cbor:"37,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"`
|
||||
@@ -43,7 +44,7 @@ type Stats struct {
|
||||
MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"-"` // [sent bytes, recv bytes]
|
||||
// TODO: remove other load fields in future release in favor of load avg array
|
||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
|
||||
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
|
||||
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state]
|
||||
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]
|
||||
|
||||
37
internal/entities/system/system_test.go
Normal file
37
internal/entities/system/system_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStatsBatteryTransport(t *testing.T) {
|
||||
stats := Stats{Battery: [2]uint8{0, 1}, Batteries: map[string]uint8{"Primary": 0, "Mouse": 75}}
|
||||
|
||||
jsonData, err := json.Marshal(stats)
|
||||
require.NoError(t, err)
|
||||
var jsonPayload map[string]any
|
||||
require.NoError(t, json.Unmarshal(jsonData, &jsonPayload))
|
||||
assert.Equal(t, []any{float64(0), float64(1)}, jsonPayload["bat"])
|
||||
assert.Equal(t, map[string]any{"Primary": float64(0), "Mouse": float64(75)}, jsonPayload["bats"])
|
||||
|
||||
cborData, err := cbor.Marshal(stats)
|
||||
require.NoError(t, err)
|
||||
var decoded Stats
|
||||
require.NoError(t, cbor.Unmarshal(cborData, &decoded))
|
||||
assert.Equal(t, stats.Battery, decoded.Battery)
|
||||
assert.Equal(t, stats.Batteries, decoded.Batteries)
|
||||
}
|
||||
|
||||
func TestStatsLegacyBatteryPayload(t *testing.T) {
|
||||
data, err := json.Marshal(Stats{Battery: [2]uint8{50, 4}})
|
||||
require.NoError(t, err)
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(data, &payload))
|
||||
assert.Contains(t, payload, "bat")
|
||||
assert.NotContains(t, payload, "bats")
|
||||
}
|
||||
@@ -186,6 +186,9 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
||||
|
||||
// necessary because uint8 is not big enough for the sum
|
||||
batterySum := 0
|
||||
batteryCount := 0
|
||||
batterySums := make(map[string]uint64)
|
||||
batteryCounts := make(map[string]uint64)
|
||||
// accumulate per-core usage across records
|
||||
var cpuCoresSums []uint64
|
||||
// accumulate cpu breakdown [user, system, iowait, steal, idle]
|
||||
@@ -232,8 +235,15 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
||||
for i := range stats.DiskIoStats {
|
||||
sum.DiskIoStats[i] += stats.DiskIoStats[i]
|
||||
}
|
||||
batterySum += int(stats.Battery[0])
|
||||
sum.Battery[1] = stats.Battery[1]
|
||||
if hasBattery(stats.Battery, stats.Batteries) {
|
||||
batterySum += int(stats.Battery[0])
|
||||
batteryCount++
|
||||
sum.Battery[1] = stats.Battery[1]
|
||||
}
|
||||
for name, percent := range stats.Batteries {
|
||||
batterySums[name] += uint64(percent)
|
||||
batteryCounts[name]++
|
||||
}
|
||||
|
||||
// accumulate per-core usage if present
|
||||
if stats.CpuCoresUsage != nil {
|
||||
@@ -379,7 +389,15 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
||||
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
|
||||
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
|
||||
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
||||
sum.Battery[0] = uint8(batterySum / int(count))
|
||||
if batteryCount > 0 {
|
||||
sum.Battery[0] = uint8(batterySum / batteryCount)
|
||||
}
|
||||
if len(batterySums) > 0 {
|
||||
sum.Batteries = make(map[string]uint8, len(batterySums))
|
||||
for name, total := range batterySums {
|
||||
sum.Batteries[name] = uint8(total / batteryCounts[name])
|
||||
}
|
||||
}
|
||||
|
||||
// Average network interfaces
|
||||
if sum.NetworkInterfaces != nil {
|
||||
@@ -467,6 +485,10 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
||||
return sum
|
||||
}
|
||||
|
||||
func hasBattery(legacy [2]uint8, batteries map[string]uint8) bool {
|
||||
return legacy != [2]uint8{} || len(batteries) > 0
|
||||
}
|
||||
|
||||
// Calculate the average stats of a list of container_stats records
|
||||
func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds) []container.Stats {
|
||||
allStats := make([][]container.Stats, 0, len(records))
|
||||
|
||||
@@ -602,6 +602,28 @@ func TestAverageSystemStatsSlice_BatteryLastChargeState(t *testing.T) {
|
||||
assert.Equal(t, uint8(0), result.Battery[1]) // last record's charge state
|
||||
}
|
||||
|
||||
func TestAverageSystemStatsSlice_BatteriesIndependentSamples(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{Battery: [2]uint8{80, 4}, Batteries: map[string]uint8{"Primary": 80, "Mouse": 0}},
|
||||
{Battery: [2]uint8{60, 3}, Batteries: map[string]uint8{"Primary": 60}},
|
||||
{Battery: [2]uint8{30, 4}, Batteries: map[string]uint8{"Mouse": 40}},
|
||||
{},
|
||||
}
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
assert.Equal(t, map[string]uint8{"Primary": 70, "Mouse": 20}, result.Batteries)
|
||||
assert.Equal(t, uint8(56), result.Battery[0], "representative battery excludes absent samples")
|
||||
assert.Equal(t, uint8(4), result.Battery[1], "representative state comes from its latest sample")
|
||||
}
|
||||
|
||||
func TestAverageSystemStatsSlice_ZeroRepresentativeBattery(t *testing.T) {
|
||||
result := records.AverageSystemStatsSlice([]system.Stats{
|
||||
{Battery: [2]uint8{0, 1}, Batteries: map[string]uint8{"Primary": 0}},
|
||||
{},
|
||||
})
|
||||
assert.Equal(t, [2]uint8{0, 1}, result.Battery)
|
||||
assert.Equal(t, map[string]uint8{"Primary": 0}, result.Batteries)
|
||||
}
|
||||
|
||||
func TestAverageSystemStatsSlice_ThreeRecordsRounding(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{Cpu: 10.0, Mem: 8.0},
|
||||
|
||||
@@ -125,7 +125,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
|
||||
<FanChart {...coreProps} />
|
||||
|
||||
<BatteryChart {...coreProps} />
|
||||
<BatteryChart system={system} {...coreProps} />
|
||||
|
||||
{hasGpuPowerData && <GpuPowerChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />}
|
||||
</div>
|
||||
@@ -191,7 +191,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
<BandwidthChart {...coreProps} systemStats={systemStats} />
|
||||
<TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} />
|
||||
<FanChart {...coreProps} />
|
||||
<BatteryChart {...coreProps} />
|
||||
<BatteryChart system={system} {...coreProps} />
|
||||
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
|
||||
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import AreaChartDefault from "@/components/charts/area-chart"
|
||||
import { batteryStateTranslations } from "@/lib/i18n"
|
||||
import { $fanFilter, $temperatureFilter, $userSettings } from "@/lib/stores"
|
||||
import { cn, decimalString, formatTemperature, toFixedFloat } from "@/lib/utils"
|
||||
import type { ChartData, SystemStatsRecord } from "@/types"
|
||||
import type { ChartData, SystemRecord, SystemStatsRecord } from "@/types"
|
||||
import { ChartCard, FilterBar } from "../chart-card"
|
||||
import LineChartDefault from "@/components/charts/line-chart"
|
||||
import { useStore } from "@nanostores/react"
|
||||
@@ -14,18 +14,60 @@ export function BatteryChart({
|
||||
grid,
|
||||
dataEmpty,
|
||||
maxValues,
|
||||
system,
|
||||
}: {
|
||||
chartData: ChartData
|
||||
grid: boolean
|
||||
dataEmpty: boolean
|
||||
maxValues: boolean
|
||||
system: SystemRecord
|
||||
}) {
|
||||
const showBatteryChart = chartData.systemStats.at(-1)?.stats.bat
|
||||
const batteryNames = useMemo(() => {
|
||||
const names = new Set<string>()
|
||||
for (const record of chartData.systemStats) {
|
||||
for (const name in record.stats?.bats ?? {}) {
|
||||
names.add(name)
|
||||
}
|
||||
}
|
||||
return [...names].sort()
|
||||
}, [chartData.systemStats])
|
||||
const hasNamedBatteries = batteryNames.length > 0
|
||||
const showBatteryChart = hasNamedBatteries || chartData.systemStats.some((record) => record.stats?.bat)
|
||||
|
||||
if (!showBatteryChart) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (hasNamedBatteries) {
|
||||
const dataPoints = batteryNames.map((name, index) => ({
|
||||
label: name,
|
||||
dataKey: ({ stats }: SystemStatsRecord) => stats?.bats?.[name],
|
||||
color: `hsl(${(index * 360 + 226) / batteryNames.length}, 65%, 52%)`,
|
||||
}))
|
||||
return (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`Battery`}
|
||||
description={`${t({
|
||||
message: "Current state",
|
||||
comment: "Context: Battery state",
|
||||
})}: ${batteryStateTranslations[system.info.bat?.[1] ?? 0]()}`}
|
||||
>
|
||||
<LineChartDefault
|
||||
chartData={chartData}
|
||||
maxToggled={maxValues}
|
||||
dataPoints={dataPoints}
|
||||
domain={[0, 100]}
|
||||
legend={true}
|
||||
tickFormatter={(val) => `${val}%`}
|
||||
contentFormatter={({ value }) => `${value}%`}
|
||||
itemSorter={(a, b) => b.value - a.value}
|
||||
/>
|
||||
</ChartCard>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
@@ -34,7 +76,7 @@ export function BatteryChart({
|
||||
description={`${t({
|
||||
message: "Current state",
|
||||
comment: "Context: Battery state",
|
||||
})}: ${batteryStateTranslations[chartData.systemStats.at(-1)?.stats.bat?.[1] ?? 0]()}`}
|
||||
})}: ${batteryStateTranslations[system.info.bat?.[1] ?? 0]()}`}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
@@ -210,15 +252,7 @@ export function TemperatureChart({
|
||||
)
|
||||
}
|
||||
|
||||
export function FanChart({
|
||||
chartData,
|
||||
grid,
|
||||
dataEmpty,
|
||||
}: {
|
||||
chartData: ChartData
|
||||
grid: boolean
|
||||
dataEmpty: boolean
|
||||
}) {
|
||||
export function FanChart({ chartData, grid, dataEmpty }: { chartData: ChartData; grid: boolean; dataEmpty: boolean }) {
|
||||
const showFanChart = chartData.systemStats.at(-1)?.stats.f
|
||||
|
||||
const filter = useStore($fanFilter)
|
||||
|
||||
2
internal/site/src/types.d.ts
vendored
2
internal/site/src/types.d.ts
vendored
@@ -151,6 +151,8 @@ export interface SystemStats {
|
||||
g?: Record<string, GPUData>
|
||||
/** battery percent and state */
|
||||
bat?: [number, BatteryState]
|
||||
/** battery percentages by device name */
|
||||
bats?: Record<string, number>
|
||||
/** network interfaces [upload bytes, download bytes, total upload bytes, total download bytes] */
|
||||
ni?: Record<string, [number, number, number, number]>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user