mirror of
https://github.com/fankes/beszel.git
synced 2025-10-19 17:59:28 +08:00
add battery charge monitoring
This commit is contained in:
@@ -36,6 +36,7 @@ type Agent struct {
|
|||||||
server *ssh.Server // SSH server
|
server *ssh.Server // SSH server
|
||||||
dataDir string // Directory for persisting data
|
dataDir string // Directory for persisting data
|
||||||
keys []gossh.PublicKey // SSH public keys
|
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.
|
// NewAgent creates a new agent with the given data directory for persisting data.
|
||||||
|
18
beszel/internal/agent/battery.go
Normal file
18
beszel/internal/agent/battery.go
Normal file
@@ -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
|
||||||
|
}
|
@@ -59,10 +59,17 @@ func (a *Agent) initializeSystemInfo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// zfs
|
// zfs
|
||||||
if _, err := getARCSize(); err == nil {
|
if _, err := getARCSize(); err != nil {
|
||||||
a.zfs = true
|
|
||||||
} else {
|
|
||||||
slog.Debug("Not monitoring ZFS ARC", "err", err)
|
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 {
|
func (a *Agent) getSystemStats() system.Stats {
|
||||||
systemStats := system.Stats{}
|
systemStats := system.Stats{}
|
||||||
|
|
||||||
|
// battery
|
||||||
|
if a.hasBattery {
|
||||||
|
systemStats.Battery[0], systemStats.Battery[1], _ = getBatteryStats()
|
||||||
|
}
|
||||||
|
|
||||||
// cpu percent
|
// cpu percent
|
||||||
cpuPct, err := cpu.Percent(0, false)
|
cpuPct, err := cpu.Percent(0, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -80,7 +92,6 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
|
|
||||||
// load average
|
// load average
|
||||||
if avgstat, err := load.Avg(); err == nil {
|
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[0] = avgstat.Load1
|
||||||
systemStats.LoadAvg[1] = avgstat.Load5
|
systemStats.LoadAvg[1] = avgstat.Load5
|
||||||
systemStats.LoadAvg[2] = avgstat.Load15
|
systemStats.LoadAvg[2] = avgstat.Load15
|
||||||
|
@@ -36,8 +36,9 @@ type Stats struct {
|
|||||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"25,keyasint,omitempty"`
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"25,keyasint,omitempty"`
|
||||||
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"26,keyasint,omitzero"` // [sent bytes, recv bytes]
|
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]
|
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
|
// 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 {
|
type GPUData struct {
|
||||||
@@ -100,8 +101,8 @@ type Info struct {
|
|||||||
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"`
|
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"`
|
||||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
||||||
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
||||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
|
||||||
// TODO: remove load fields in future release in favor of load avg array
|
// 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
|
// Final data structure to return to the hub
|
||||||
|
@@ -172,6 +172,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
tempStats = system.Stats{}
|
tempStats = system.Stats{}
|
||||||
sum := &sumStats
|
sum := &sumStats
|
||||||
stats := &tempStats
|
stats := &tempStats
|
||||||
|
// necessary because uint8 is not big enough for the sum
|
||||||
|
batterySum := 0
|
||||||
|
|
||||||
count := float64(len(records))
|
count := float64(len(records))
|
||||||
tempCount := float64(0)
|
tempCount := float64(0)
|
||||||
@@ -208,6 +210,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.LoadAvg[2] += stats.LoadAvg[2]
|
sum.LoadAvg[2] += stats.LoadAvg[2]
|
||||||
sum.Bandwidth[0] += stats.Bandwidth[0]
|
sum.Bandwidth[0] += stats.Bandwidth[0]
|
||||||
sum.Bandwidth[1] += stats.Bandwidth[1]
|
sum.Bandwidth[1] += stats.Bandwidth[1]
|
||||||
|
batterySum += int(stats.Battery[0])
|
||||||
|
sum.Battery[1] = stats.Battery[1]
|
||||||
// Set peak values
|
// Set peak values
|
||||||
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
|
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
|
||||||
sum.MaxNetworkSent = max(sum.MaxNetworkSent, stats.MaxNetworkSent, stats.NetworkSent)
|
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.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
|
||||||
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
|
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
|
||||||
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
||||||
|
sum.Battery[0] = uint8(batterySum / int(count))
|
||||||
// Average temperatures
|
// Average temperatures
|
||||||
if sum.Temperatures != nil && tempCount > 0 {
|
if sum.Temperatures != nil && tempCount > 0 {
|
||||||
for key := range sum.Temperatures {
|
for key := range sum.Temperatures {
|
||||||
|
@@ -18,6 +18,7 @@ export default function AreaChartDefault({
|
|||||||
tickFormatter,
|
tickFormatter,
|
||||||
contentFormatter,
|
contentFormatter,
|
||||||
dataPoints,
|
dataPoints,
|
||||||
|
domain,
|
||||||
}: // logRender = false,
|
}: // logRender = false,
|
||||||
{
|
{
|
||||||
chartData: ChartData
|
chartData: ChartData
|
||||||
@@ -26,6 +27,7 @@ export default function AreaChartDefault({
|
|||||||
tickFormatter: (value: number, index: number) => string
|
tickFormatter: (value: number, index: number) => string
|
||||||
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
|
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
|
||||||
dataPoints?: DataPoint[]
|
dataPoints?: DataPoint[]
|
||||||
|
domain?: [number, number]
|
||||||
// logRender?: boolean
|
// logRender?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
@@ -51,7 +53,7 @@ export default function AreaChartDefault({
|
|||||||
orientation={chartData.orientation}
|
orientation={chartData.orientation}
|
||||||
className="tracking-tighter"
|
className="tracking-tighter"
|
||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
domain={[0, max ?? "auto"]}
|
domain={domain ?? [0, max ?? "auto"]}
|
||||||
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
|
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
|
@@ -41,6 +41,7 @@ import { timeTicks } from "d3-time"
|
|||||||
import { useLingui } from "@lingui/react/macro"
|
import { useLingui } from "@lingui/react/macro"
|
||||||
import { $router, navigate } from "../router"
|
import { $router, navigate } from "../router"
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { getPagePath } from "@nanostores/router"
|
||||||
|
import { batteryStateTranslations } from "@/lib/i18n"
|
||||||
|
|
||||||
const AreaChartDefault = lazy(() => import("../charts/area-chart"))
|
const AreaChartDefault = lazy(() => import("../charts/area-chart"))
|
||||||
const ContainerChart = lazy(() => import("../charts/container-chart"))
|
const ContainerChart = lazy(() => import("../charts/container-chart"))
|
||||||
@@ -668,6 +669,35 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
</ChartCard>
|
</ChartCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Battery chart */}
|
||||||
|
{systemStats.at(-1)?.stats.bat && (
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={t`Battery`}
|
||||||
|
description={`${t({
|
||||||
|
message: "Current state",
|
||||||
|
comment: "Context: Battery state",
|
||||||
|
})}: ${batteryStateTranslations[systemStats.at(-1)?.stats.bat![1] ?? 0]()}`}
|
||||||
|
>
|
||||||
|
<AreaChartDefault
|
||||||
|
chartData={chartData}
|
||||||
|
maxToggled={maxValues}
|
||||||
|
dataPoints={[
|
||||||
|
{
|
||||||
|
label: t`Charge`,
|
||||||
|
dataKey: ({ stats }) => stats?.bat?.[0],
|
||||||
|
color: "1",
|
||||||
|
opacity: 0.35,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
domain={[0, 100]}
|
||||||
|
tickFormatter={(val) => `${val}%`}
|
||||||
|
contentFormatter={({ value }) => `${value}%`}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* GPU power draw chart */}
|
{/* GPU power draw chart */}
|
||||||
{hasGpuPowerData && (
|
{hasGpuPowerData && (
|
||||||
<ChartCard
|
<ChartCard
|
||||||
|
@@ -4,7 +4,7 @@ import * as RechartsPrimitive from "recharts"
|
|||||||
import { chartTimeData, cn } from "@/lib/utils"
|
import { chartTimeData, cn } from "@/lib/utils"
|
||||||
import { ChartData } from "@/types"
|
import { ChartData } from "@/types"
|
||||||
|
|
||||||
import type { JSX } from "react";
|
import type { JSX } from "react"
|
||||||
|
|
||||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
const THEMES = { light: "", dark: ".dark" } as const
|
const THEMES = { light: "", dark: ".dark" } as const
|
||||||
@@ -57,7 +57,7 @@ const ChartContainer = React.forwardRef<
|
|||||||
{/* <ChartStyle id={chartId} config={config} /> */}
|
{/* <ChartStyle id={chartId} config={config} /> */}
|
||||||
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
|
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
})
|
})
|
||||||
ChartContainer.displayName = "Chart"
|
ChartContainer.displayName = "Chart"
|
||||||
|
|
||||||
@@ -228,7 +228,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
{itemConfig?.label || item.name}
|
{itemConfig?.label || item.name}
|
||||||
</span>
|
</span>
|
||||||
{item.value !== undefined && (
|
{item.value !== undefined && (
|
||||||
<span className="font-medium tabular-nums text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
{content && typeof content === "function"
|
{content && typeof content === "function"
|
||||||
? content(item, key)
|
? content(item, key)
|
||||||
: item.value.toLocaleString() + (unit ? unit : "")}
|
: item.value.toLocaleString() + (unit ? unit : "")}
|
||||||
|
@@ -101,6 +101,7 @@
|
|||||||
|
|
||||||
.recharts-tooltip-wrapper {
|
.recharts-tooltip-wrapper {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
@apply tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recharts-yAxis {
|
.recharts-yAxis {
|
||||||
|
@@ -36,3 +36,13 @@ export enum SystemStatus {
|
|||||||
Pending = "pending",
|
Pending = "pending",
|
||||||
Paused = "paused",
|
Paused = "paused",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Battery state */
|
||||||
|
export enum BatteryState {
|
||||||
|
Unknown,
|
||||||
|
Empty,
|
||||||
|
Full,
|
||||||
|
Charging,
|
||||||
|
Discharging,
|
||||||
|
Idle,
|
||||||
|
}
|
||||||
|
@@ -4,6 +4,8 @@ import type { Messages } from "@lingui/core"
|
|||||||
import languages from "@/lib/languages"
|
import languages from "@/lib/languages"
|
||||||
import { detect, fromStorage, fromNavigator } from "@lingui/detect-locale"
|
import { detect, fromStorage, fromNavigator } from "@lingui/detect-locale"
|
||||||
import { messages as enMessages } from "@/locales/en/en"
|
import { messages as enMessages } from "@/locales/en/en"
|
||||||
|
import { BatteryState } from "./enums"
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
|
||||||
// activates locale
|
// activates locale
|
||||||
function activateLocale(locale: string, messages: Messages = enMessages) {
|
function activateLocale(locale: string, messages: Messages = enMessages) {
|
||||||
@@ -54,3 +56,14 @@ export function getLocale() {
|
|||||||
}
|
}
|
||||||
return locale
|
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
|
||||||
|
4
beszel/site/src/types.d.ts
vendored
4
beszel/site/src/types.d.ts
vendored
@@ -1,5 +1,5 @@
|
|||||||
import { RecordModel } from "pocketbase"
|
import { RecordModel } from "pocketbase"
|
||||||
import { Unit, Os } from "./lib/enums"
|
import { Unit, Os, BatteryState } from "./lib/enums"
|
||||||
|
|
||||||
// global window properties
|
// global window properties
|
||||||
declare global {
|
declare global {
|
||||||
@@ -136,6 +136,8 @@ export interface SystemStats {
|
|||||||
efs?: Record<string, ExtraFsStats>
|
efs?: Record<string, ExtraFsStats>
|
||||||
/** GPU data */
|
/** GPU data */
|
||||||
g?: Record<string, GPUData>
|
g?: Record<string, GPUData>
|
||||||
|
/** battery percent and state */
|
||||||
|
bat?: [number, BatteryState]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GPUData {
|
export interface GPUData {
|
||||||
|
Reference in New Issue
Block a user