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 {