diff --git a/beszel/internal/agent/agent.go b/beszel/internal/agent/agent.go index 2e5f639..00b3d1c 100644 --- a/beszel/internal/agent/agent.go +++ b/beszel/internal/agent/agent.go @@ -36,6 +36,7 @@ type Agent struct { server *ssh.Server // SSH server dataDir string // Directory for persisting data keys []gossh.PublicKey // SSH public keys + hasBattery bool // true if agent has access to battery stats } // NewAgent creates a new agent with the given data directory for persisting data. diff --git a/beszel/internal/agent/battery.go b/beszel/internal/agent/battery.go new file mode 100644 index 0000000..da5433c --- /dev/null +++ b/beszel/internal/agent/battery.go @@ -0,0 +1,18 @@ +package agent + +import "github.com/distatus/battery" + +// getBatteryStats returns the current battery percent and charge state +func getBatteryStats() (batteryPercent uint8, batteryState uint8, err error) { + bat, err := battery.Get(0) + if err != nil { + return batteryPercent, batteryState, err + } + full := bat.Design + if full == 0 { + full = bat.Full + } + batteryPercent = uint8(bat.Current / full * 100) + batteryState = uint8(bat.State.Raw) + return batteryPercent, batteryState, nil +} diff --git a/beszel/internal/agent/system.go b/beszel/internal/agent/system.go index 215b504..1926f5e 100644 --- a/beszel/internal/agent/system.go +++ b/beszel/internal/agent/system.go @@ -59,10 +59,17 @@ func (a *Agent) initializeSystemInfo() { } // zfs - if _, err := getARCSize(); err == nil { - a.zfs = true - } else { + if _, err := getARCSize(); err != nil { slog.Debug("Not monitoring ZFS ARC", "err", err) + } else { + a.zfs = true + } + + // battery + if _, _, err := getBatteryStats(); err != nil { + slog.Debug("No battery detected", "err", err) + } else { + a.hasBattery = true } } @@ -70,6 +77,11 @@ func (a *Agent) initializeSystemInfo() { func (a *Agent) getSystemStats() system.Stats { systemStats := system.Stats{} + // battery + if a.hasBattery { + systemStats.Battery[0], systemStats.Battery[1], _ = getBatteryStats() + } + // cpu percent cpuPct, err := cpu.Percent(0, false) if err != nil { @@ -80,7 +92,6 @@ func (a *Agent) getSystemStats() system.Stats { // load average if avgstat, err := load.Avg(); err == nil { - // TODO: remove these in future release in favor of load avg array systemStats.LoadAvg[0] = avgstat.Load1 systemStats.LoadAvg[1] = avgstat.Load5 systemStats.LoadAvg[2] = avgstat.Load15 diff --git a/beszel/internal/entities/system/system.go b/beszel/internal/entities/system/system.go index 9efde5c..e8a2715 100644 --- a/beszel/internal/entities/system/system.go +++ b/beszel/internal/entities/system/system.go @@ -36,8 +36,9 @@ type Stats struct { LoadAvg15 float64 `json:"l15,omitempty" cbor:"25,keyasint,omitempty"` Bandwidth [2]uint64 `json:"b,omitzero" cbor:"26,keyasint,omitzero"` // [sent bytes, recv bytes] MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"27,keyasint,omitzero"` // [sent bytes, recv bytes] - LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"` // 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] } type GPUData struct { @@ -81,27 +82,27 @@ const ( ) type Info struct { - Hostname string `json:"h" cbor:"0,keyasint"` - KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"` - Cores int `json:"c" cbor:"2,keyasint"` - Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"` - CpuModel string `json:"m" cbor:"4,keyasint"` - Uptime uint64 `json:"u" cbor:"5,keyasint"` - Cpu float64 `json:"cpu" cbor:"6,keyasint"` - MemPct float64 `json:"mp" cbor:"7,keyasint"` - DiskPct float64 `json:"dp" cbor:"8,keyasint"` - Bandwidth float64 `json:"b" cbor:"9,keyasint"` - AgentVersion string `json:"v" cbor:"10,keyasint"` - Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"` - GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"` - DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"` - Os Os `json:"os" cbor:"14,keyasint"` - LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"` - LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"` - LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"` - BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"` - LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"` + Hostname string `json:"h" cbor:"0,keyasint"` + KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"` + Cores int `json:"c" cbor:"2,keyasint"` + Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"` + CpuModel string `json:"m" cbor:"4,keyasint"` + Uptime uint64 `json:"u" cbor:"5,keyasint"` + Cpu float64 `json:"cpu" cbor:"6,keyasint"` + MemPct float64 `json:"mp" cbor:"7,keyasint"` + DiskPct float64 `json:"dp" cbor:"8,keyasint"` + Bandwidth float64 `json:"b" cbor:"9,keyasint"` + AgentVersion string `json:"v" cbor:"10,keyasint"` + Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"` + GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"` + DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"` + Os Os `json:"os" cbor:"14,keyasint"` + LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"` + LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"` + LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"` + BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"` // TODO: remove load fields in future release in favor of load avg array + LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"` } // Final data structure to return to the hub diff --git a/beszel/internal/records/records.go b/beszel/internal/records/records.go index 7dd782b..c1b17d8 100644 --- a/beszel/internal/records/records.go +++ b/beszel/internal/records/records.go @@ -172,6 +172,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) * tempStats = system.Stats{} sum := &sumStats stats := &tempStats + // necessary because uint8 is not big enough for the sum + batterySum := 0 count := float64(len(records)) tempCount := float64(0) @@ -208,6 +210,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) * sum.LoadAvg[2] += stats.LoadAvg[2] sum.Bandwidth[0] += stats.Bandwidth[0] sum.Bandwidth[1] += stats.Bandwidth[1] + batterySum += int(stats.Battery[0]) + sum.Battery[1] = stats.Battery[1] // Set peak values sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu) sum.MaxNetworkSent = max(sum.MaxNetworkSent, stats.MaxNetworkSent, stats.NetworkSent) @@ -290,6 +294,7 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) * 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)) // Average temperatures if sum.Temperatures != nil && tempCount > 0 { for key := range sum.Temperatures { diff --git a/beszel/site/src/components/charts/area-chart.tsx b/beszel/site/src/components/charts/area-chart.tsx index eee7035..1aefec1 100644 --- a/beszel/site/src/components/charts/area-chart.tsx +++ b/beszel/site/src/components/charts/area-chart.tsx @@ -18,6 +18,7 @@ export default function AreaChartDefault({ tickFormatter, contentFormatter, dataPoints, + domain, }: // logRender = false, { chartData: ChartData @@ -26,6 +27,7 @@ export default function AreaChartDefault({ tickFormatter: (value: number, index: number) => string contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string dataPoints?: DataPoint[] + domain?: [number, number] // logRender?: boolean }) { const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() @@ -51,7 +53,7 @@ export default function AreaChartDefault({ orientation={chartData.orientation} className="tracking-tighter" width={yAxisWidth} - domain={[0, max ?? "auto"]} + domain={domain ?? [0, max ?? "auto"]} tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))} tickLine={false} axisLine={false} diff --git a/beszel/site/src/components/routes/system.tsx b/beszel/site/src/components/routes/system.tsx index d3635fe..335b5d8 100644 --- a/beszel/site/src/components/routes/system.tsx +++ b/beszel/site/src/components/routes/system.tsx @@ -41,6 +41,7 @@ import { timeTicks } from "d3-time" import { useLingui } from "@lingui/react/macro" import { $router, navigate } from "../router" import { getPagePath } from "@nanostores/router" +import { batteryStateTranslations } from "@/lib/i18n" const AreaChartDefault = lazy(() => import("../charts/area-chart")) const ContainerChart = lazy(() => import("../charts/container-chart")) @@ -668,6 +669,35 @@ export default function SystemDetail({ name }: { name: string }) { )} + {/* Battery chart */} + {systemStats.at(-1)?.stats.bat && ( + + stats?.bat?.[0], + color: "1", + opacity: 0.35, + }, + ]} + domain={[0, 100]} + tickFormatter={(val) => `${val}%`} + contentFormatter={({ value }) => `${value}%`} + /> + + )} + {/* GPU power draw chart */} {hasGpuPowerData && ( - // -
+ // +
- {/* */} - {children} -
- ); + {/* */} + {children} +
+ ) }) ChartContainer.displayName = "Chart" @@ -228,7 +228,7 @@ const ChartTooltipContent = React.forwardRef< {itemConfig?.label || item.name} {item.value !== undefined && ( - + {content && typeof content === "function" ? content(item, key) : item.value.toLocaleString() + (unit ? unit : "")} diff --git a/beszel/site/src/index.css b/beszel/site/src/index.css index 2aa0670..fc99028 100644 --- a/beszel/site/src/index.css +++ b/beszel/site/src/index.css @@ -101,6 +101,7 @@ .recharts-tooltip-wrapper { z-index: 1; + @apply tabular-nums; } .recharts-yAxis { diff --git a/beszel/site/src/lib/enums.ts b/beszel/site/src/lib/enums.ts index 198558e..7fd219b 100644 --- a/beszel/site/src/lib/enums.ts +++ b/beszel/site/src/lib/enums.ts @@ -36,3 +36,13 @@ export enum SystemStatus { Pending = "pending", Paused = "paused", } + +/** Battery state */ +export enum BatteryState { + Unknown, + Empty, + Full, + Charging, + Discharging, + Idle, +} diff --git a/beszel/site/src/lib/i18n.ts b/beszel/site/src/lib/i18n.ts index cd4afc3..ac8a275 100644 --- a/beszel/site/src/lib/i18n.ts +++ b/beszel/site/src/lib/i18n.ts @@ -4,6 +4,8 @@ import type { Messages } from "@lingui/core" import languages from "@/lib/languages" import { detect, fromStorage, fromNavigator } from "@lingui/detect-locale" import { messages as enMessages } from "@/locales/en/en" +import { BatteryState } from "./enums" +import { t } from "@lingui/core/macro" // activates locale function activateLocale(locale: string, messages: Messages = enMessages) { @@ -54,3 +56,14 @@ export function getLocale() { } return locale } + +//////////////////////////////////////////////////////// + +export const batteryStateTranslations = { + [BatteryState.Unknown]: () => t({ message: "Unknown", comment: "Context: Battery state" }), + [BatteryState.Empty]: () => t({ message: "Empty", comment: "Context: Battery state" }), + [BatteryState.Full]: () => t({ message: "Full", comment: "Context: Battery state" }), + [BatteryState.Charging]: () => t({ message: "Charging", comment: "Context: Battery state" }), + [BatteryState.Discharging]: () => t({ message: "Discharging", comment: "Context: Battery state" }), + [BatteryState.Idle]: () => t({ message: "Idle", comment: "Context: Battery state" }), +} as const diff --git a/beszel/site/src/types.d.ts b/beszel/site/src/types.d.ts index d9ec2eb..6e01e56 100644 --- a/beszel/site/src/types.d.ts +++ b/beszel/site/src/types.d.ts @@ -1,5 +1,5 @@ import { RecordModel } from "pocketbase" -import { Unit, Os } from "./lib/enums" +import { Unit, Os, BatteryState } from "./lib/enums" // global window properties declare global { @@ -136,6 +136,8 @@ export interface SystemStats { efs?: Record /** GPU data */ g?: Record + /** battery percent and state */ + bat?: [number, BatteryState] } export interface GPUData {