mirror of
https://github.com/henrygd/beszel.git
synced 2026-08-16 23:37:48 +02:00
Compare commits
1 Commits
main
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94f546feb2 |
28
.github/workflows/docker-images.yml
vendored
28
.github/workflows/docker-images.yml
vendored
@@ -52,19 +52,6 @@ jobs:
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||
|
||||
# henrygd/beszel-agent-nvidia:slim
|
||||
- image: henrygd/beszel-agent-nvidia
|
||||
dockerfile: ./internal/dockerfile_agent_nvidia_slim
|
||||
platforms: linux/amd64,linux/arm64
|
||||
registry: docker.io
|
||||
username_secret: DOCKERHUB_USERNAME
|
||||
password_secret: DOCKERHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=slim
|
||||
type=semver,pattern={{version}}-slim
|
||||
type=semver,pattern={{major}}.{{minor}}-slim
|
||||
type=semver,pattern={{major}}-slim
|
||||
|
||||
# henrygd/beszel-agent-intel
|
||||
- image: henrygd/beszel-agent-intel
|
||||
dockerfile: ./internal/dockerfile_agent_intel
|
||||
@@ -120,19 +107,6 @@ jobs:
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||
|
||||
# ghcr.io/henrygd/beszel-agent-nvidia:slim
|
||||
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
||||
dockerfile: ./internal/dockerfile_agent_nvidia_slim
|
||||
platforms: linux/amd64,linux/arm64
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password_secret: GITHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=slim
|
||||
type=semver,pattern={{version}}-slim
|
||||
type=semver,pattern={{major}}.{{minor}}-slim
|
||||
type=semver,pattern={{major}}-slim
|
||||
|
||||
# ghcr.io/henrygd/beszel-agent-intel
|
||||
- image: ghcr.io/${{ github.repository }}/beszel-agent-intel
|
||||
dockerfile: ./internal/dockerfile_agent_intel
|
||||
@@ -220,7 +194,7 @@ jobs:
|
||||
with:
|
||||
context: ./
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7' }}
|
||||
platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v7' }}
|
||||
push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }}
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
|
||||
@@ -22,9 +22,6 @@ builds:
|
||||
- amd64
|
||||
- arm64
|
||||
- arm
|
||||
goarm:
|
||||
- "6"
|
||||
- "7"
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
@@ -34,8 +31,6 @@ builds:
|
||||
goarch: arm64
|
||||
- goos: freebsd
|
||||
goarch: arm
|
||||
- goos: darwin
|
||||
goarch: arm
|
||||
|
||||
- id: beszel-agent
|
||||
binary: beszel-agent
|
||||
@@ -57,10 +52,6 @@ builds:
|
||||
- mipsle
|
||||
- mips
|
||||
- ppc64le
|
||||
goarm:
|
||||
- "5"
|
||||
- "6"
|
||||
- "7"
|
||||
gomips:
|
||||
- hardfloat
|
||||
- softfloat
|
||||
@@ -80,8 +71,6 @@ builds:
|
||||
gomips: hardfloat
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
- goos: darwin
|
||||
goarch: arm
|
||||
- goos: darwin
|
||||
goarch: riscv64
|
||||
- goos: windows
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
// Package battery provides battery information for the host and connected devices.
|
||||
// Package battery provides functions to check if the system has a battery and return the charge state and percentage.
|
||||
package battery
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
stateUnknown uint8 = iota
|
||||
stateEmpty
|
||||
@@ -16,55 +9,3 @@ const (
|
||||
stateDischarging
|
||||
stateIdle
|
||||
)
|
||||
|
||||
// Battery is a readable battery reported by the operating system.
|
||||
type Battery struct {
|
||||
Name string
|
||||
Percent uint8
|
||||
State uint8
|
||||
FullChargeCapacity uint64
|
||||
HasFullChargeCapacity bool
|
||||
System bool
|
||||
}
|
||||
|
||||
var errNoBatteries = errors.New("no readable batteries")
|
||||
|
||||
// normalizeBatteries supplies stable fallback names and disambiguates duplicates.
|
||||
func normalizeBatteries(batteries []Battery) []Battery {
|
||||
nameCounts := make(map[string]int, len(batteries))
|
||||
for i := range batteries {
|
||||
name := strings.TrimSpace(batteries[i].Name)
|
||||
if name == "" {
|
||||
name = "Battery " + strconv.Itoa(i+1)
|
||||
}
|
||||
nameCounts[name]++
|
||||
if nameCounts[name] > 1 {
|
||||
name += " (" + strconv.Itoa(nameCounts[name]) + ")"
|
||||
}
|
||||
batteries[i].Name = name
|
||||
}
|
||||
return batteries
|
||||
}
|
||||
|
||||
// Primary returns the representative battery. Reported full-charge capacity wins,
|
||||
// then system-scoped devices, then name for deterministic ties.
|
||||
func Primary(batteries []Battery) (Battery, bool) {
|
||||
if len(batteries) == 0 {
|
||||
return Battery{}, false
|
||||
}
|
||||
ordered := append([]Battery(nil), batteries...)
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
a, b := ordered[i], ordered[j]
|
||||
if a.HasFullChargeCapacity != b.HasFullChargeCapacity {
|
||||
return a.HasFullChargeCapacity
|
||||
}
|
||||
if a.HasFullChargeCapacity && a.FullChargeCapacity != b.FullChargeCapacity {
|
||||
return a.FullChargeCapacity > b.FullChargeCapacity
|
||||
}
|
||||
if a.System != b.System {
|
||||
return a.System
|
||||
}
|
||||
return a.Name < b.Name
|
||||
})
|
||||
return ordered[0], true
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
package battery
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"math"
|
||||
"os/exec"
|
||||
"sync"
|
||||
|
||||
"howett.net/plist"
|
||||
)
|
||||
@@ -31,46 +35,62 @@ func readMacBatteries() ([]macBattery, error) {
|
||||
return batteries, nil
|
||||
}
|
||||
|
||||
func HasReadableBattery() bool {
|
||||
batteries, _ := GetBatteryStats()
|
||||
return len(batteries) > 0
|
||||
}
|
||||
|
||||
// GetBatteryStats returns every readable battery reported by macOS.
|
||||
func GetBatteryStats() ([]Battery, error) {
|
||||
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||
var HasReadableBattery = sync.OnceValue(func() bool {
|
||||
systemHasBattery := false
|
||||
batteries, err := readMacBatteries()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(batteries) == 0 {
|
||||
return nil, errNoBatteries
|
||||
}
|
||||
result := make([]Battery, 0, len(batteries))
|
||||
slog.Debug("Batteries", "batteries", batteries, "err", err)
|
||||
for _, bat := range batteries {
|
||||
if bat.MaxCapacity <= 0 {
|
||||
if bat.MaxCapacity > 0 {
|
||||
systemHasBattery = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return systemHasBattery
|
||||
})
|
||||
|
||||
// GetBatteryStats returns the current battery percent and charge state.
|
||||
// Uses CurrentCapacity/MaxCapacity to match the value macOS displays.
|
||||
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
||||
if !HasReadableBattery() {
|
||||
return batteryPercent, batteryState, errors.ErrUnsupported
|
||||
}
|
||||
batteries, err := readMacBatteries()
|
||||
if len(batteries) == 0 {
|
||||
return batteryPercent, batteryState, errors.New("no batteries")
|
||||
}
|
||||
|
||||
totalCapacity := 0
|
||||
totalCharge := 0
|
||||
batteryState = math.MaxUint8
|
||||
|
||||
for _, bat := range batteries {
|
||||
if bat.MaxCapacity == 0 {
|
||||
// skip ghost batteries with 0 capacity
|
||||
// https://github.com/distatus/battery/issues/34
|
||||
continue
|
||||
}
|
||||
percent := min(max(float64(bat.CurrentCapacity)/float64(bat.MaxCapacity)*100, 0), 100)
|
||||
state := stateUnknown
|
||||
totalCapacity += bat.MaxCapacity
|
||||
totalCharge += min(bat.CurrentCapacity, bat.MaxCapacity)
|
||||
|
||||
switch {
|
||||
case !bat.ExternalConnected:
|
||||
state = stateDischarging
|
||||
batteryState = stateDischarging
|
||||
case bat.IsCharging:
|
||||
state = stateCharging
|
||||
batteryState = stateCharging
|
||||
case bat.CurrentCapacity == 0:
|
||||
state = stateEmpty
|
||||
batteryState = stateEmpty
|
||||
case !bat.FullyCharged:
|
||||
state = stateIdle
|
||||
batteryState = stateIdle
|
||||
default:
|
||||
state = stateFull
|
||||
batteryState = stateFull
|
||||
}
|
||||
result = append(result, Battery{Name: "Primary", Percent: uint8(percent), State: state,
|
||||
FullChargeCapacity: uint64(bat.MaxCapacity), HasFullChargeCapacity: true, System: true})
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, errNoBatteries
|
||||
|
||||
if totalCapacity == 0 || batteryState == math.MaxUint8 {
|
||||
return batteryPercent, batteryState, errors.New("no battery capacity")
|
||||
}
|
||||
return normalizeBatteries(result), nil
|
||||
|
||||
batteryPercent = uint8(float64(totalCharge) / float64(totalCapacity) * 100)
|
||||
return batteryPercent, batteryState, nil
|
||||
}
|
||||
|
||||
@@ -3,19 +3,58 @@
|
||||
package battery
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
)
|
||||
|
||||
var batteryRoot = "/sys/class/power_supply"
|
||||
// getBatteryPaths returns the paths of all batteries in /sys/class/power_supply
|
||||
var getBatteryPaths func() ([]string, error)
|
||||
|
||||
// HasReadableBattery reports whether collection currently finds a readable battery.
|
||||
func HasReadableBattery() bool {
|
||||
batteries, _ := GetBatteryStats()
|
||||
return len(batteries) > 0
|
||||
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||
var HasReadableBattery func() bool
|
||||
|
||||
func init() {
|
||||
resetBatteryState("/sys/class/power_supply")
|
||||
}
|
||||
|
||||
// resetBatteryState resets the sync.Once functions to a fresh state.
|
||||
// Tests call this after swapping sysfsPowerSupply so the new path is picked up.
|
||||
func resetBatteryState(sysfsPowerSupplyPath string) {
|
||||
getBatteryPaths = sync.OnceValues(func() ([]string, error) {
|
||||
entries, err := os.ReadDir(sysfsPowerSupplyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var paths []string
|
||||
for _, e := range entries {
|
||||
path := filepath.Join(sysfsPowerSupplyPath, e.Name())
|
||||
if utils.ReadStringFile(filepath.Join(path, "type")) == "Battery" {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
return paths, nil
|
||||
})
|
||||
HasReadableBattery = sync.OnceValue(func() bool {
|
||||
systemHasBattery := false
|
||||
paths, err := getBatteryPaths()
|
||||
for _, path := range paths {
|
||||
if _, ok := utils.ReadStringFileOK(filepath.Join(path, "capacity")); ok {
|
||||
systemHasBattery = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !systemHasBattery {
|
||||
slog.Debug("No battery found", "err", err)
|
||||
}
|
||||
return systemHasBattery
|
||||
})
|
||||
}
|
||||
|
||||
func parseSysfsState(status string) uint8 {
|
||||
@@ -35,18 +74,26 @@ func parseSysfsState(status string) uint8 {
|
||||
}
|
||||
}
|
||||
|
||||
// GetBatteryStats re-enumerates power supplies and returns every readable battery.
|
||||
func GetBatteryStats() ([]Battery, error) {
|
||||
entries, err := os.ReadDir(batteryRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// GetBatteryStats returns the current battery percent and charge state.
|
||||
// Reads /sys/class/power_supply/*/capacity directly so the kernel-reported
|
||||
// value is used, which is always 0-100 and matches what the OS displays.
|
||||
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
||||
if !HasReadableBattery() {
|
||||
return batteryPercent, batteryState, errors.ErrUnsupported
|
||||
}
|
||||
batteries := make([]Battery, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
path := filepath.Join(batteryRoot, entry.Name())
|
||||
if utils.ReadStringFile(filepath.Join(path, "type")) != "Battery" {
|
||||
continue
|
||||
}
|
||||
paths, err := getBatteryPaths()
|
||||
if err != nil {
|
||||
return batteryPercent, batteryState, err
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return batteryPercent, batteryState, errors.New("no batteries")
|
||||
}
|
||||
|
||||
batteryState = math.MaxUint8
|
||||
totalPercent := 0
|
||||
count := 0
|
||||
|
||||
for _, path := range paths {
|
||||
capStr, ok := utils.ReadStringFileOK(filepath.Join(path, "capacity"))
|
||||
if !ok {
|
||||
continue
|
||||
@@ -56,30 +103,19 @@ func GetBatteryStats() ([]Battery, error) {
|
||||
continue
|
||||
}
|
||||
cap = min(max(cap, 0), 100)
|
||||
name := utils.ReadStringFile(filepath.Join(path, "model_name"))
|
||||
if name == "" {
|
||||
name = utils.ReadStringFile(filepath.Join(path, "model"))
|
||||
totalPercent += cap
|
||||
count++
|
||||
|
||||
state := parseSysfsState(utils.ReadStringFile(filepath.Join(path, "status")))
|
||||
if state != stateUnknown {
|
||||
batteryState = state
|
||||
}
|
||||
if name == "" {
|
||||
name = entry.Name()
|
||||
}
|
||||
battery := Battery{
|
||||
Name: name,
|
||||
Percent: uint8(cap),
|
||||
State: parseSysfsState(utils.ReadStringFile(filepath.Join(path, "status"))),
|
||||
System: utils.ReadStringFile(filepath.Join(path, "scope")) != "Device",
|
||||
}
|
||||
for _, fullName := range []string{"charge_full", "energy_full"} {
|
||||
if parsed, ok := utils.ReadUintFile(filepath.Join(path, fullName)); ok && parsed > 0 {
|
||||
battery.FullChargeCapacity = parsed
|
||||
battery.HasFullChargeCapacity = true
|
||||
break
|
||||
}
|
||||
}
|
||||
batteries = append(batteries, battery)
|
||||
}
|
||||
if len(batteries) == 0 {
|
||||
return nil, errNoBatteries
|
||||
|
||||
if count == 0 || batteryState == math.MaxUint8 {
|
||||
return batteryPercent, batteryState, errors.New("no battery capacity")
|
||||
}
|
||||
return normalizeBatteries(batteries), nil
|
||||
|
||||
batteryPercent = uint8(totalPercent / count)
|
||||
return batteryPercent, batteryState, nil
|
||||
}
|
||||
|
||||
@@ -8,102 +8,204 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeBattery struct{ id, name, capacity, status, full, scope string }
|
||||
|
||||
func setupFakeSysfs(t *testing.T) (string, func(fakeBattery)) {
|
||||
// setupFakeSysfs creates a temporary sysfs-like tree under t.TempDir(),
|
||||
// swaps sysfsPowerSupply, resets the sync.Once caches, and restores
|
||||
// everything on cleanup. Returns a helper to create battery directories.
|
||||
func setupFakeSysfs(t *testing.T) (tmpDir string, addBattery func(name, capacity, status string)) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
previousRoot := batteryRoot
|
||||
batteryRoot = root
|
||||
t.Cleanup(func() { batteryRoot = previousRoot })
|
||||
write := func(path, value string) {
|
||||
|
||||
tmp := t.TempDir()
|
||||
resetBatteryState(tmp)
|
||||
|
||||
write := func(path, content string) {
|
||||
t.Helper()
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||
require.NoError(t, os.WriteFile(path, []byte(value), 0o644))
|
||||
}
|
||||
add := func(b fakeBattery) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, b.id)
|
||||
write(filepath.Join(dir, "type"), "Battery")
|
||||
if b.capacity != "" {
|
||||
write(filepath.Join(dir, "capacity"), b.capacity)
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write(filepath.Join(dir, "status"), b.status)
|
||||
if b.name != "" {
|
||||
write(filepath.Join(dir, "model_name"), b.name)
|
||||
}
|
||||
if b.full != "" {
|
||||
write(filepath.Join(dir, "energy_full"), b.full)
|
||||
}
|
||||
if b.scope != "" {
|
||||
write(filepath.Join(dir, "scope"), b.scope)
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return root, add
|
||||
|
||||
addBattery = func(name, capacity, status string) {
|
||||
t.Helper()
|
||||
batDir := filepath.Join(tmp, name)
|
||||
write(filepath.Join(batDir, "type"), "Battery")
|
||||
write(filepath.Join(batDir, "capacity"), capacity)
|
||||
write(filepath.Join(batDir, "status"), status)
|
||||
}
|
||||
|
||||
return tmp, addBattery
|
||||
}
|
||||
|
||||
func TestParseSysfsState(t *testing.T) {
|
||||
assert.Equal(t, stateEmpty, parseSysfsState("Empty"))
|
||||
assert.Equal(t, stateFull, parseSysfsState("Full"))
|
||||
assert.Equal(t, stateCharging, parseSysfsState("Charging"))
|
||||
assert.Equal(t, stateDischarging, parseSysfsState("Discharging"))
|
||||
assert.Equal(t, stateIdle, parseSysfsState("Not charging"))
|
||||
assert.Equal(t, stateUnknown, parseSysfsState("SomethingElse"))
|
||||
tests := []struct {
|
||||
input string
|
||||
want uint8
|
||||
}{
|
||||
{"Empty", stateEmpty},
|
||||
{"Full", stateFull},
|
||||
{"Charging", stateCharging},
|
||||
{"Discharging", stateDischarging},
|
||||
{"Not charging", stateIdle},
|
||||
{"", stateUnknown},
|
||||
{"SomethingElse", stateUnknown},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
assert.Equal(t, tt.want, parseSysfsState(tt.input), "parseSysfsState(%q)", tt.input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBatteryStatsMultipleNamedAndPrimary(t *testing.T) {
|
||||
_, add := setupFakeSysfs(t)
|
||||
add(fakeBattery{id: "BAT0", name: "Primary", capacity: "105", status: "Charging", full: "5000", scope: "System"})
|
||||
add(fakeBattery{id: "hidpp_battery_0", name: "MX Keys S", capacity: "55", status: "Unknown", full: "900", scope: "Device"})
|
||||
batteries, err := GetBatteryStats()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, batteries, 2)
|
||||
assert.Equal(t, "Primary", batteries[0].Name)
|
||||
assert.Equal(t, uint8(100), batteries[0].Percent)
|
||||
assert.Equal(t, stateUnknown, batteries[1].State)
|
||||
primary, ok := Primary(batteries)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "Primary", primary.Name)
|
||||
func TestGetBatteryStats_SingleBattery(t *testing.T) {
|
||||
_, addBattery := setupFakeSysfs(t)
|
||||
addBattery("BAT0", "72", "Discharging")
|
||||
|
||||
pct, state, err := GetBatteryStats()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, uint8(72), pct)
|
||||
assert.Equal(t, stateDischarging, state)
|
||||
}
|
||||
|
||||
func TestGetBatteryStatsFallbackDuplicatesAndUnreadable(t *testing.T) {
|
||||
root, add := setupFakeSysfs(t)
|
||||
add(fakeBattery{id: "BAT0", name: "Keyboard", capacity: "80", status: "Discharging"})
|
||||
add(fakeBattery{id: "BAT1", name: "Keyboard", capacity: "-4", status: "SomethingWeird"})
|
||||
add(fakeBattery{id: "BAT2", capacity: "not-a-number", status: "Charging"})
|
||||
add(fakeBattery{id: "BAT3", capacity: "42", status: "Full"})
|
||||
ac := filepath.Join(root, "AC0")
|
||||
require.NoError(t, os.MkdirAll(ac, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(ac, "type"), []byte("Mains"), 0o644))
|
||||
batteries, err := GetBatteryStats()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, batteries, 3)
|
||||
assert.Equal(t, "Keyboard", batteries[0].Name)
|
||||
assert.Equal(t, "Keyboard (2)", batteries[1].Name)
|
||||
assert.Equal(t, uint8(0), batteries[1].Percent)
|
||||
assert.Equal(t, "BAT3", batteries[2].Name)
|
||||
func TestGetBatteryStats_MultipleBatteries(t *testing.T) {
|
||||
_, addBattery := setupFakeSysfs(t)
|
||||
addBattery("BAT0", "80", "Charging")
|
||||
addBattery("BAT1", "40", "Charging")
|
||||
|
||||
pct, state, err := GetBatteryStats()
|
||||
assert.NoError(t, err)
|
||||
// average of 80 and 40 = 60
|
||||
assert.EqualValues(t, 60, pct)
|
||||
assert.Equal(t, stateCharging, state)
|
||||
}
|
||||
|
||||
func TestGetBatteryStatsHotPlugReenumerates(t *testing.T) {
|
||||
_, add := setupFakeSysfs(t)
|
||||
_, err := GetBatteryStats()
|
||||
func TestGetBatteryStats_FullBattery(t *testing.T) {
|
||||
_, addBattery := setupFakeSysfs(t)
|
||||
addBattery("BAT0", "100", "Full")
|
||||
|
||||
pct, state, err := GetBatteryStats()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, uint8(100), pct)
|
||||
assert.Equal(t, stateFull, state)
|
||||
}
|
||||
|
||||
func TestGetBatteryStats_CapacityClamped(t *testing.T) {
|
||||
_, addBattery := setupFakeSysfs(t)
|
||||
addBattery("BAT0", "105", "Charging")
|
||||
|
||||
pct, state, err := GetBatteryStats()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, uint8(100), pct)
|
||||
assert.Equal(t, stateCharging, state)
|
||||
}
|
||||
|
||||
func TestGetBatteryStats_EmptyBattery(t *testing.T) {
|
||||
_, addBattery := setupFakeSysfs(t)
|
||||
addBattery("BAT0", "0", "Empty")
|
||||
|
||||
pct, state, err := GetBatteryStats()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, uint8(0), pct)
|
||||
assert.Equal(t, stateEmpty, state)
|
||||
}
|
||||
|
||||
func TestGetBatteryStats_NotCharging(t *testing.T) {
|
||||
_, addBattery := setupFakeSysfs(t)
|
||||
addBattery("BAT0", "80", "Not charging")
|
||||
|
||||
pct, state, err := GetBatteryStats()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, uint8(80), pct)
|
||||
assert.Equal(t, stateIdle, state)
|
||||
}
|
||||
|
||||
func TestGetBatteryStats_NoBatteries(t *testing.T) {
|
||||
setupFakeSysfs(t) // empty directory, no batteries
|
||||
|
||||
_, _, err := GetBatteryStats()
|
||||
assert.Error(t, err)
|
||||
assert.False(t, HasReadableBattery())
|
||||
add(fakeBattery{id: "BAT0", capacity: "64", status: "Discharging"})
|
||||
batteries, err := GetBatteryStats()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestGetBatteryStats_NonBatterySupplyIgnored(t *testing.T) {
|
||||
tmp, addBattery := setupFakeSysfs(t)
|
||||
|
||||
// Add a real battery
|
||||
addBattery("BAT0", "55", "Charging")
|
||||
|
||||
// Add an AC adapter (type != Battery) - should be ignored
|
||||
acDir := filepath.Join(tmp, "AC0")
|
||||
if err := os.MkdirAll(acDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(acDir, "type"), []byte("Mains"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pct, state, err := GetBatteryStats()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, uint8(55), pct)
|
||||
assert.Equal(t, stateCharging, state)
|
||||
}
|
||||
|
||||
func TestGetBatteryStats_InvalidCapacitySkipped(t *testing.T) {
|
||||
tmp, addBattery := setupFakeSysfs(t)
|
||||
|
||||
// One battery with valid capacity
|
||||
addBattery("BAT0", "90", "Discharging")
|
||||
|
||||
// Another with invalid capacity text
|
||||
badDir := filepath.Join(tmp, "BAT1")
|
||||
if err := os.MkdirAll(badDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(badDir, "type"), []byte("Battery"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(badDir, "capacity"), []byte("not-a-number"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(badDir, "status"), []byte("Discharging"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pct, _, err := GetBatteryStats()
|
||||
assert.NoError(t, err)
|
||||
// Only BAT0 counted
|
||||
assert.Equal(t, uint8(90), pct)
|
||||
}
|
||||
|
||||
func TestGetBatteryStats_UnknownStatusOnly(t *testing.T) {
|
||||
_, addBattery := setupFakeSysfs(t)
|
||||
addBattery("BAT0", "50", "SomethingWeird")
|
||||
|
||||
_, _, err := GetBatteryStats()
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestHasReadableBattery_True(t *testing.T) {
|
||||
_, addBattery := setupFakeSysfs(t)
|
||||
addBattery("BAT0", "50", "Charging")
|
||||
|
||||
assert.True(t, HasReadableBattery())
|
||||
require.Len(t, batteries, 1)
|
||||
assert.Equal(t, uint8(64), batteries[0].Percent)
|
||||
}
|
||||
|
||||
func TestGetBatteryStatsNoReadableCapacity(t *testing.T) {
|
||||
_, add := setupFakeSysfs(t)
|
||||
add(fakeBattery{id: "BAT0", status: "Charging"})
|
||||
_, err := GetBatteryStats()
|
||||
assert.Error(t, err)
|
||||
func TestHasReadableBattery_False(t *testing.T) {
|
||||
setupFakeSysfs(t) // no batteries
|
||||
|
||||
assert.False(t, HasReadableBattery())
|
||||
}
|
||||
|
||||
func TestHasReadableBattery_NoCapacityFile(t *testing.T) {
|
||||
tmp, _ := setupFakeSysfs(t)
|
||||
|
||||
// Battery dir with type file but no capacity file
|
||||
batDir := filepath.Join(tmp, "BAT0")
|
||||
err := os.MkdirAll(batDir, 0o755)
|
||||
assert.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(batDir, "type"), []byte("Battery"), 0o644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.False(t, HasReadableBattery())
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@ func HasReadableBattery() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func GetBatteryStats() ([]Battery, error) {
|
||||
return nil, errors.ErrUnsupported
|
||||
func GetBatteryStats() (uint8, uint8, error) {
|
||||
return 0, 0, errors.ErrUnsupported
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package battery
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPrimarySelection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bats []Battery
|
||||
want string
|
||||
}{
|
||||
{"largest reported capacity", []Battery{{Name: "Small", FullChargeCapacity: 20, HasFullChargeCapacity: true, System: true}, {Name: "Large", FullChargeCapacity: 80, HasFullChargeCapacity: true}}, "Large"},
|
||||
{"reported ranks over missing", []Battery{{Name: "Unknown", System: true}, {Name: "Known", FullChargeCapacity: 1, HasFullChargeCapacity: true}}, "Known"},
|
||||
{"system wins capacity tie", []Battery{{Name: "Peripheral", FullChargeCapacity: 50, HasFullChargeCapacity: true}, {Name: "System", FullChargeCapacity: 50, HasFullChargeCapacity: true, System: true}}, "System"},
|
||||
{"name resolves final tie", []Battery{{Name: "Zed"}, {Name: "Alpha"}}, "Alpha"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := Primary(tt.bats)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, tt.want, got.Name)
|
||||
})
|
||||
}
|
||||
_, ok := Primary(nil)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestNormalizeBatteriesFallbackNames(t *testing.T) {
|
||||
bats := normalizeBatteries([]Battery{{}, {}, {Name: "Mouse"}, {Name: "Mouse"}})
|
||||
assert.Equal(t, []string{"Battery 1", "Battery 2", "Mouse", "Mouse (2)"}, []string{bats[0].Name, bats[1].Name, bats[2].Name, bats[3].Name})
|
||||
}
|
||||
@@ -7,6 +7,9 @@ package battery
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"math"
|
||||
"sync"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
@@ -76,7 +79,7 @@ var (
|
||||
setupDiDestroyDeviceInfoList = setupapi.NewProc("SetupDiDestroyDeviceInfoList")
|
||||
)
|
||||
|
||||
// winBatteryGet reads one battery by index.
|
||||
// winBatteryGet reads one battery by index. Returns (fullCapacity, currentCapacity, state, error).
|
||||
// Returns error == errNotFound when there are no more batteries.
|
||||
var errNotFound = errors.New("no more batteries")
|
||||
|
||||
@@ -119,7 +122,7 @@ func readWinBatteryState(powerState uint32) uint8 {
|
||||
}
|
||||
}
|
||||
|
||||
func winBatteryGet(idx int) (Battery, error) {
|
||||
func winBatteryGet(idx int) (full, current uint32, state uint8, err error) {
|
||||
hdev, err := setupDiSetup(
|
||||
setupDiGetClassDevsW,
|
||||
4,
|
||||
@@ -129,7 +132,7 @@ func winBatteryGet(idx int) (Battery, error) {
|
||||
0, 0,
|
||||
)
|
||||
if err != nil {
|
||||
return Battery{}, err
|
||||
return 0, 0, stateUnknown, err
|
||||
}
|
||||
defer syscall.SyscallN(setupDiDestroyDeviceInfoList.Addr(), hdev)
|
||||
|
||||
@@ -145,10 +148,10 @@ func winBatteryGet(idx int) (Battery, error) {
|
||||
0,
|
||||
)
|
||||
if errno == 259 { // ERROR_NO_MORE_ITEMS
|
||||
return Battery{}, errNotFound
|
||||
return 0, 0, stateUnknown, errNotFound
|
||||
}
|
||||
if errno != 0 {
|
||||
return Battery{}, errno
|
||||
return 0, 0, stateUnknown, errno
|
||||
}
|
||||
|
||||
var cbRequired uint32
|
||||
@@ -162,7 +165,7 @@ func winBatteryGet(idx int) (Battery, error) {
|
||||
0,
|
||||
)
|
||||
if errno != 0 && errno != 122 { // ERROR_INSUFFICIENT_BUFFER
|
||||
return Battery{}, errno
|
||||
return 0, 0, stateUnknown, errno
|
||||
}
|
||||
didd := make([]uint16, cbRequired/2)
|
||||
cbSize := (*uint32)(unsafe.Pointer(&didd[0]))
|
||||
@@ -182,7 +185,7 @@ func winBatteryGet(idx int) (Battery, error) {
|
||||
0,
|
||||
)
|
||||
if errno != 0 {
|
||||
return Battery{}, errno
|
||||
return 0, 0, stateUnknown, errno
|
||||
}
|
||||
devicePath := &didd[2:][0]
|
||||
|
||||
@@ -196,7 +199,7 @@ func winBatteryGet(idx int) (Battery, error) {
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return Battery{}, err
|
||||
return 0, 0, stateUnknown, err
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
|
||||
@@ -213,7 +216,7 @@ func winBatteryGet(idx int) (Battery, error) {
|
||||
&dwOut, nil,
|
||||
)
|
||||
if err != nil || bqi.BatteryTag == 0 {
|
||||
return Battery{}, errors.New("battery tag not returned")
|
||||
return 0, 0, stateUnknown, errors.New("battery tag not returned")
|
||||
}
|
||||
|
||||
var bi batteryInformation
|
||||
@@ -226,21 +229,7 @@ func winBatteryGet(idx int) (Battery, error) {
|
||||
uint32(unsafe.Sizeof(bi)),
|
||||
&dwOut, nil,
|
||||
); err != nil {
|
||||
return Battery{}, err
|
||||
}
|
||||
|
||||
// BatteryDeviceName is optional, so retain the deterministic fallback on error.
|
||||
name := ""
|
||||
nameQuery := bqi
|
||||
nameQuery.InformationLevel = 4 // BatteryDeviceName
|
||||
nameBuffer := make([]uint16, 128)
|
||||
if err := windows.DeviceIoControl(
|
||||
handle, 2703428,
|
||||
(*byte)(unsafe.Pointer(&nameQuery)), uint32(unsafe.Sizeof(nameQuery)),
|
||||
(*byte)(unsafe.Pointer(&nameBuffer[0])), uint32(len(nameBuffer)*2),
|
||||
&dwOut, nil,
|
||||
); err == nil {
|
||||
name = windows.UTF16ToString(nameBuffer)
|
||||
return 0, 0, stateUnknown, err
|
||||
}
|
||||
|
||||
bws := batteryWaitStatus{BatteryTag: bqi.BatteryTag}
|
||||
@@ -254,38 +243,56 @@ func winBatteryGet(idx int) (Battery, error) {
|
||||
uint32(unsafe.Sizeof(bs)),
|
||||
&dwOut, nil,
|
||||
); err != nil {
|
||||
return Battery{}, err
|
||||
return 0, 0, stateUnknown, err
|
||||
}
|
||||
|
||||
if bs.Capacity == 0xffffffff || bi.FullChargedCapacity == 0 || bi.FullChargedCapacity == 0xffffffff {
|
||||
return Battery{}, errors.New("battery capacity unknown")
|
||||
if bs.Capacity == 0xffffffff { // BATTERY_UNKNOWN_CAPACITY
|
||||
return 0, 0, stateUnknown, errors.New("battery capacity unknown")
|
||||
}
|
||||
percent := min(float64(bs.Capacity)/float64(bi.FullChargedCapacity)*100, 100)
|
||||
return Battery{Name: name, Percent: uint8(percent), State: readWinBatteryState(bs.PowerState),
|
||||
FullChargeCapacity: uint64(bi.FullChargedCapacity), HasFullChargeCapacity: true, System: true}, nil
|
||||
|
||||
return bi.FullChargedCapacity, bs.Capacity, readWinBatteryState(bs.PowerState), nil
|
||||
}
|
||||
|
||||
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||
func HasReadableBattery() bool {
|
||||
batteries, _ := GetBatteryStats()
|
||||
return len(batteries) > 0
|
||||
}
|
||||
var HasReadableBattery = sync.OnceValue(func() bool {
|
||||
systemHasBattery := false
|
||||
full, _, _, err := winBatteryGet(0)
|
||||
if err == nil && full > 0 {
|
||||
systemHasBattery = true
|
||||
}
|
||||
if !systemHasBattery {
|
||||
slog.Debug("No battery found", "err", err)
|
||||
}
|
||||
return systemHasBattery
|
||||
})
|
||||
|
||||
// GetBatteryStats returns the current battery percent and charge state.
|
||||
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
||||
if !HasReadableBattery() {
|
||||
return batteryPercent, batteryState, errors.ErrUnsupported
|
||||
}
|
||||
|
||||
totalFull := uint32(0)
|
||||
totalCurrent := uint32(0)
|
||||
batteryState = math.MaxUint8
|
||||
|
||||
// GetBatteryStats returns every readable battery reported by Windows.
|
||||
func GetBatteryStats() ([]Battery, error) {
|
||||
batteries := make([]Battery, 0, 2)
|
||||
for i := 0; ; i++ {
|
||||
battery, bErr := winBatteryGet(i)
|
||||
full, current, state, bErr := winBatteryGet(i)
|
||||
if errors.Is(bErr, errNotFound) {
|
||||
break
|
||||
}
|
||||
if bErr != nil {
|
||||
if bErr != nil || full == 0 {
|
||||
continue
|
||||
}
|
||||
batteries = append(batteries, battery)
|
||||
totalFull += full
|
||||
totalCurrent += min(current, full)
|
||||
batteryState = state
|
||||
}
|
||||
if len(batteries) == 0 {
|
||||
return nil, errNoBatteries
|
||||
|
||||
if totalFull == 0 || batteryState == math.MaxUint8 {
|
||||
return batteryPercent, batteryState, errors.New("no battery capacity")
|
||||
}
|
||||
return normalizeBatteries(batteries), nil
|
||||
|
||||
batteryPercent = uint8(float64(totalCurrent) / float64(totalFull) * 100)
|
||||
return batteryPercent, batteryState, nil
|
||||
}
|
||||
|
||||
25
agent/gpu.go
25
agent/gpu.go
@@ -48,8 +48,6 @@ type GPUManager struct {
|
||||
// Per-cache-key tracking for delta calculations
|
||||
// cacheKey -> gpuId -> snapshot of last count/usage/power values
|
||||
lastSnapshots map[uint16]map[string]*gpuSnapshot
|
||||
// Per-card energy snapshots for Intel sysfs power calculation.
|
||||
intelSysfsEnergySnapshots map[string]intelSysfsEnergySnapshot
|
||||
}
|
||||
|
||||
// gpuSnapshot stores the last observed incremental values for delta tracking
|
||||
@@ -92,7 +90,6 @@ const (
|
||||
collectorSourceNVML collectorSource = "nvml"
|
||||
collectorSourceNvidiaSMI collectorSource = collectorSource(nvidiaSmiCmd)
|
||||
collectorSourceIntelGpuTop collectorSource = collectorSource(intelGpuStatsCmd)
|
||||
collectorSourceIntelSysfs collectorSource = "intel_sysfs"
|
||||
collectorSourceAmdSysfs collectorSource = "amd_sysfs"
|
||||
collectorSourceRocmSMI collectorSource = collectorSource(rocmSmiCmd)
|
||||
collectorSourceMacmon collectorSource = collectorSource(macmonCmd)
|
||||
@@ -109,7 +106,6 @@ func isValidCollectorSource(source collectorSource) bool {
|
||||
collectorSourceNVML,
|
||||
collectorSourceNvidiaSMI,
|
||||
collectorSourceIntelGpuTop,
|
||||
collectorSourceIntelSysfs,
|
||||
collectorSourceAmdSysfs,
|
||||
collectorSourceRocmSMI,
|
||||
collectorSourceMacmon,
|
||||
@@ -126,8 +122,6 @@ type gpuCapabilities struct {
|
||||
hasAmdSysfs bool
|
||||
hasTegrastats bool
|
||||
hasIntelGpuTop bool
|
||||
hasXe bool
|
||||
hasIntelSysfs bool
|
||||
hasNvtop bool
|
||||
hasMacmon bool
|
||||
hasPowermetrics bool
|
||||
@@ -375,13 +369,12 @@ func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheK
|
||||
|
||||
gpuAvg.Power = utils.TwoDecimals(deltaPower / float64(deltaCount))
|
||||
|
||||
gpuAvg.PowerPkg = utils.TwoDecimals(deltaPowerPkg / float64(deltaCount))
|
||||
|
||||
if gpu.Engines != nil {
|
||||
// make fresh map for averaged engine metrics to avoid mutating
|
||||
// the accumulator map stored in gm.GpuDataMap
|
||||
gpuAvg.Engines = make(map[string]float64, len(gpu.Engines))
|
||||
gpuAvg.Usage = gm.calculateIntelGPUUsage(&gpuAvg, gpu, lastSnapshot, deltaCount)
|
||||
gpuAvg.PowerPkg = utils.TwoDecimals(deltaPowerPkg / float64(deltaCount))
|
||||
} else {
|
||||
gpuAvg.Usage = utils.TwoDecimals(deltaUsage / float64(deltaCount))
|
||||
}
|
||||
@@ -451,8 +444,6 @@ func (gm *GPUManager) storeSnapshot(id string, gpu *system.GPUData, cacheKey uin
|
||||
func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
|
||||
caps := gpuCapabilities{
|
||||
hasAmdSysfs: gm.hasAmdSysfs(),
|
||||
hasXe: gm.hasXe(),
|
||||
hasIntelSysfs: gm.hasIntelSysfs(),
|
||||
}
|
||||
if _, err := exec.LookPath(nvidiaSmiCmd); err == nil {
|
||||
caps.hasNvidiaSmi = true
|
||||
@@ -481,7 +472,7 @@ func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
|
||||
}
|
||||
|
||||
func hasAnyGpuCollector(caps gpuCapabilities) bool {
|
||||
return caps.hasNvidiaSmi || caps.hasRocmSmi || caps.hasAmdSysfs || caps.hasTegrastats || caps.hasIntelGpuTop || caps.hasIntelSysfs || caps.hasNvtop || caps.hasMacmon || caps.hasPowermetrics
|
||||
return caps.hasNvidiaSmi || caps.hasRocmSmi || caps.hasAmdSysfs || caps.hasTegrastats || caps.hasIntelGpuTop || caps.hasNvtop || caps.hasMacmon || caps.hasPowermetrics
|
||||
}
|
||||
|
||||
func (gm *GPUManager) startIntelCollector() {
|
||||
@@ -572,13 +563,6 @@ func (gm *GPUManager) collectorDefinitions(caps gpuCapabilities) map[collectorSo
|
||||
return true
|
||||
},
|
||||
},
|
||||
collectorSourceIntelSysfs: {
|
||||
group: collectorGroupIntel,
|
||||
available: caps.hasIntelSysfs,
|
||||
start: func(_ func()) bool {
|
||||
return gm.startIntelSysfsCollector()
|
||||
},
|
||||
},
|
||||
collectorSourceAmdSysfs: {
|
||||
group: collectorGroupAmd,
|
||||
available: caps.hasAmdSysfs,
|
||||
@@ -721,12 +705,9 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
|
||||
priorities = append(priorities, collectorSourceAmdSysfs)
|
||||
}
|
||||
|
||||
if caps.hasIntelGpuTop && !caps.hasXe {
|
||||
if caps.hasIntelGpuTop {
|
||||
priorities = append(priorities, collectorSourceIntelGpuTop)
|
||||
}
|
||||
if caps.hasIntelSysfs {
|
||||
priorities = append(priorities, collectorSourceIntelSysfs)
|
||||
}
|
||||
|
||||
// Apple collectors are currently opt-in only for testing.
|
||||
// Enable them with GPU_COLLECTOR=macmon or GPU_COLLECTOR=powermetrics.
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
)
|
||||
|
||||
var (
|
||||
drmSysfsRoot = "/sys/class/drm"
|
||||
intelSysfsNow = time.Now
|
||||
)
|
||||
|
||||
type intelSysfsEnergySnapshot struct {
|
||||
microjoules uint64
|
||||
timestamp time.Time
|
||||
}
|
||||
|
||||
type intelSysfsCard struct {
|
||||
cardPath string
|
||||
hwmonDir string
|
||||
}
|
||||
|
||||
// hasIntelSysfs returns true if any Intel DRM card exposes an hwmon energy counter.
|
||||
func (gm *GPUManager) hasIntelSysfs() bool {
|
||||
cards, err := discoverIntelSysfsCards()
|
||||
return err == nil && len(cards) > 0
|
||||
}
|
||||
|
||||
// startIntelSysfsCollector starts Intel GPU collection via sysfs.
|
||||
func (gm *GPUManager) startIntelSysfsCollector() bool {
|
||||
go func() {
|
||||
if err := gm.collectIntelSysfsStats(); err != nil {
|
||||
slog.Warn("Error collecting Intel GPU data via sysfs", "err", err)
|
||||
}
|
||||
}()
|
||||
return true
|
||||
}
|
||||
|
||||
// collectIntelSysfsStats collects Intel GPU metrics directly from DRM sysfs / hwmon.
|
||||
func (gm *GPUManager) collectIntelSysfsStats() error {
|
||||
sysfsPollInterval := 3000 * time.Millisecond
|
||||
cards, err := discoverIntelSysfsCards()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(cards) == 0 {
|
||||
return errNoValidData
|
||||
}
|
||||
|
||||
slog.Debug("Using sysfs for Intel GPU data collection", "cards", len(cards))
|
||||
for _, card := range cards {
|
||||
slog.Debug("Intel sysfs card detected", "card", filepath.Base(card.cardPath), "hwmon", card.hwmonDir)
|
||||
}
|
||||
|
||||
failures := 0
|
||||
for {
|
||||
hasData := false
|
||||
for _, card := range cards {
|
||||
if gm.updateIntelSysfsGpuData(card.cardPath, card.hwmonDir) {
|
||||
hasData = true
|
||||
}
|
||||
}
|
||||
if !hasData {
|
||||
failures++
|
||||
if failures > maxFailureRetries {
|
||||
return errNoValidData
|
||||
}
|
||||
slog.Warn("No Intel GPU data from sysfs", "failures", failures)
|
||||
time.Sleep(retryWaitTime)
|
||||
continue
|
||||
}
|
||||
failures = 0
|
||||
time.Sleep(sysfsPollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func discoverIntelSysfsCards() ([]intelSysfsCard, error) {
|
||||
paths, err := filepath.Glob(filepath.Join(drmSysfsRoot, "card*"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cards []intelSysfsCard
|
||||
for _, cardPath := range paths {
|
||||
if strings.Contains(filepath.Base(cardPath), "-") || !isIntelGpu(cardPath) {
|
||||
continue
|
||||
}
|
||||
hwmonDir := findIntelEnergyHwmon(filepath.Join(cardPath, "device"))
|
||||
if hwmonDir == "" {
|
||||
continue
|
||||
}
|
||||
cards = append(cards, intelSysfsCard{cardPath: cardPath, hwmonDir: hwmonDir})
|
||||
}
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
func isIntelGpu(cardPath string) bool {
|
||||
vendor, err := utils.ReadStringFileLimited(filepath.Join(cardPath, "device/vendor"), 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(vendor), "0x8086")
|
||||
}
|
||||
|
||||
func findIntelEnergyHwmon(devicePath string) string {
|
||||
hwmons, _ := filepath.Glob(filepath.Join(devicePath, "hwmon/hwmon*"))
|
||||
var fallback string
|
||||
for _, hwmonDir := range hwmons {
|
||||
if !sysfsFileExists(filepath.Join(hwmonDir, "energy1_input")) {
|
||||
continue
|
||||
}
|
||||
if name, err := utils.ReadStringFileLimited(filepath.Join(hwmonDir, "name"), 64); err == nil && strings.EqualFold(strings.TrimSpace(name), "xe") {
|
||||
return hwmonDir
|
||||
}
|
||||
if fallback == "" {
|
||||
fallback = hwmonDir
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func sysfsFileExists(path string) bool {
|
||||
_, err := utils.ReadStringFileLimited(path, 1)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// updateIntelSysfsGpuData reads GPU metrics from sysfs and updates the GPU data map.
|
||||
// Returns true if the required energy counter was read successfully.
|
||||
func (gm *GPUManager) updateIntelSysfsGpuData(cardPath, hwmonDir string) bool {
|
||||
devicePath := filepath.Join(cardPath, "device")
|
||||
id := filepath.Base(cardPath)
|
||||
|
||||
energy, err := readSysfsUint(filepath.Join(hwmonDir, "energy1_input"))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
now := intelSysfsNow()
|
||||
power, hasPower := gm.calculateIntelSysfsPower(id, energy, now)
|
||||
powerPkg, hasPowerPkg := gm.readIntelSysfsPowerPkg(id, hwmonDir, now)
|
||||
temp := readIntelSysfsTemperature(hwmonDir)
|
||||
usage, usageErr := readOptionalSysfsFloat(filepath.Join(devicePath, "gpu_busy_percent"))
|
||||
memUsed, memUsedErr := readFirstOptionalSysfsFloat(
|
||||
filepath.Join(devicePath, "mem_info_vram_used"),
|
||||
filepath.Join(devicePath, "mem_info_lmem_used"),
|
||||
filepath.Join(devicePath, "mem_info_local_mem_used"),
|
||||
)
|
||||
memTotal, memTotalErr := readFirstOptionalSysfsFloat(
|
||||
filepath.Join(devicePath, "mem_info_vram_total"),
|
||||
filepath.Join(devicePath, "mem_info_lmem_total"),
|
||||
filepath.Join(devicePath, "mem_info_local_mem_total"),
|
||||
)
|
||||
|
||||
gm.Lock()
|
||||
defer gm.Unlock()
|
||||
|
||||
gpu, ok := gm.GpuDataMap[id]
|
||||
if !ok {
|
||||
gpu = &system.GPUData{Name: getIntelSysfsGpuName(cardPath)}
|
||||
gm.GpuDataMap[id] = gpu
|
||||
}
|
||||
|
||||
if usageErr == nil {
|
||||
gpu.Usage += usage
|
||||
}
|
||||
if memUsedErr == nil {
|
||||
gpu.MemoryUsed = utils.BytesToMegabytes(memUsed)
|
||||
}
|
||||
if memTotalErr == nil {
|
||||
gpu.MemoryTotal = utils.BytesToMegabytes(memTotal)
|
||||
}
|
||||
if temp > 0 {
|
||||
gpu.Temperature = temp
|
||||
}
|
||||
if hasPower {
|
||||
gpu.Power += power
|
||||
slog.Debug("Computed Intel sysfs GPU power", "card", id, "watts", power)
|
||||
}
|
||||
if hasPowerPkg {
|
||||
gpu.PowerPkg += powerPkg
|
||||
}
|
||||
gpu.Count++
|
||||
return true
|
||||
}
|
||||
|
||||
func (gm *GPUManager) calculateIntelSysfsPower(cardID string, microjoules uint64, timestamp time.Time) (float64, bool) {
|
||||
if gm.intelSysfsEnergySnapshots == nil {
|
||||
gm.intelSysfsEnergySnapshots = make(map[string]intelSysfsEnergySnapshot)
|
||||
}
|
||||
|
||||
last, ok := gm.intelSysfsEnergySnapshots[cardID]
|
||||
gm.intelSysfsEnergySnapshots[cardID] = intelSysfsEnergySnapshot{microjoules: microjoules, timestamp: timestamp}
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
if microjoules < last.microjoules {
|
||||
slog.Debug("Intel sysfs energy counter reset", "card", cardID)
|
||||
return 0, false
|
||||
}
|
||||
elapsed := timestamp.Sub(last.timestamp).Seconds()
|
||||
if elapsed <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
delta := microjoules - last.microjoules
|
||||
return float64(delta) / 1_000_000.0 / elapsed, true
|
||||
}
|
||||
|
||||
func (gm *GPUManager) readIntelSysfsPowerPkg(cardID, hwmonDir string, timestamp time.Time) (float64, bool) {
|
||||
energyPaths, _ := filepath.Glob(filepath.Join(hwmonDir, "energy*_input"))
|
||||
for _, path := range energyPaths {
|
||||
if filepath.Base(path) == "energy1_input" {
|
||||
continue
|
||||
}
|
||||
energy, err := readSysfsUint(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
return gm.calculateIntelSysfsPower(cardID+":"+filepath.Base(path), energy, timestamp)
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func readIntelSysfsTemperature(hwmonDir string) float64 {
|
||||
tempPaths, _ := filepath.Glob(filepath.Join(hwmonDir, "temp*_input"))
|
||||
for _, path := range tempPaths {
|
||||
temp, err := readSysfsFloat(path)
|
||||
if err == nil && temp > 0 {
|
||||
return temp / 1000.0
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func readSysfsUint(path string) (uint64, error) {
|
||||
val, err := utils.ReadStringFileLimited(path, 64)
|
||||
if err != nil {
|
||||
slog.Debug("Failed to read sysfs value", "path", path, "error", err)
|
||||
return 0, err
|
||||
}
|
||||
return strconv.ParseUint(strings.TrimSpace(val), 10, 64)
|
||||
}
|
||||
|
||||
func readOptionalSysfsFloat(path string) (float64, error) {
|
||||
val, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return strconv.ParseFloat(strings.TrimSpace(string(val)), 64)
|
||||
}
|
||||
|
||||
func readFirstOptionalSysfsFloat(paths ...string) (float64, error) {
|
||||
for _, path := range paths {
|
||||
val, err := readOptionalSysfsFloat(path)
|
||||
if err == nil {
|
||||
return val, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("no sysfs values found")
|
||||
}
|
||||
|
||||
func getIntelSysfsGpuName(cardPath string) string {
|
||||
devicePath := filepath.Join(cardPath, "device")
|
||||
if product, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "product_name"), 128); err == nil && strings.TrimSpace(product) != "" {
|
||||
return strings.TrimSpace(product)
|
||||
}
|
||||
if name, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "name"), 128); err == nil && strings.TrimSpace(name) != "" {
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
return fmt.Sprintf("Intel GPU %s", filepath.Base(cardPath))
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setupIntelSysfsTest(t *testing.T) (root, cardPath, hwmonPath string) {
|
||||
t.Helper()
|
||||
root = t.TempDir()
|
||||
oldRoot := drmSysfsRoot
|
||||
drmSysfsRoot = root
|
||||
t.Cleanup(func() {
|
||||
drmSysfsRoot = oldRoot
|
||||
})
|
||||
|
||||
cardPath = filepath.Join(root, "card0")
|
||||
devicePath := filepath.Join(cardPath, "device")
|
||||
hwmonPath = filepath.Join(devicePath, "hwmon", "hwmon0")
|
||||
require.NoError(t, os.MkdirAll(hwmonPath, 0o755))
|
||||
return root, cardPath, hwmonPath
|
||||
}
|
||||
|
||||
func writeIntelSysfsFile(t *testing.T, basePath, name, content string) {
|
||||
t.Helper()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(basePath, name), []byte(content), 0o644))
|
||||
}
|
||||
|
||||
func setIntelSysfsTime(t *testing.T, now time.Time) {
|
||||
t.Helper()
|
||||
oldNow := intelSysfsNow
|
||||
intelSysfsNow = func() time.Time { return now }
|
||||
t.Cleanup(func() {
|
||||
intelSysfsNow = oldNow
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntelSysfsDetectsIntelCardWithEnergy(t *testing.T) {
|
||||
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
|
||||
devicePath := filepath.Join(cardPath, "device")
|
||||
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
|
||||
writeIntelSysfsFile(t, hwmonPath, "name", "xe\n")
|
||||
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
|
||||
|
||||
gm := &GPUManager{}
|
||||
assert.True(t, gm.hasIntelSysfs())
|
||||
|
||||
cards, err := discoverIntelSysfsCards()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, cards, 1)
|
||||
assert.Equal(t, cardPath, cards[0].cardPath)
|
||||
assert.Equal(t, hwmonPath, cards[0].hwmonDir)
|
||||
}
|
||||
|
||||
func TestIntelSysfsRejectsNonIntelCard(t *testing.T) {
|
||||
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
|
||||
devicePath := filepath.Join(cardPath, "device")
|
||||
writeIntelSysfsFile(t, devicePath, "vendor", "0x1002\n")
|
||||
writeIntelSysfsFile(t, hwmonPath, "name", "xe\n")
|
||||
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
|
||||
|
||||
gm := &GPUManager{}
|
||||
assert.False(t, gm.hasIntelSysfs())
|
||||
}
|
||||
|
||||
func TestIntelSysfsRequiresEnergyInput(t *testing.T) {
|
||||
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
|
||||
devicePath := filepath.Join(cardPath, "device")
|
||||
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
|
||||
writeIntelSysfsFile(t, hwmonPath, "name", "xe\n")
|
||||
|
||||
gm := &GPUManager{}
|
||||
assert.False(t, gm.hasIntelSysfs())
|
||||
}
|
||||
|
||||
func TestIntelSysfsFirstSampleInitializesWithoutBogusPower(t *testing.T) {
|
||||
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
|
||||
devicePath := filepath.Join(cardPath, "device")
|
||||
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
|
||||
writeIntelSysfsFile(t, hwmonPath, "name", "xe\n")
|
||||
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
|
||||
setIntelSysfsTime(t, time.Unix(100, 0))
|
||||
|
||||
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
|
||||
ok := gm.updateIntelSysfsGpuData(cardPath, hwmonPath)
|
||||
require.True(t, ok)
|
||||
|
||||
gpu := gm.GpuDataMap["card0"]
|
||||
require.NotNil(t, gpu)
|
||||
assert.Equal(t, "Intel GPU card0", gpu.Name)
|
||||
assert.Equal(t, 0.0, gpu.Power)
|
||||
assert.Equal(t, 1.0, gpu.Count)
|
||||
}
|
||||
|
||||
func TestIntelSysfsSecondSampleComputesWatts(t *testing.T) {
|
||||
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
|
||||
devicePath := filepath.Join(cardPath, "device")
|
||||
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
|
||||
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
|
||||
|
||||
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
|
||||
oldNow := intelSysfsNow
|
||||
intelSysfsNow = func() time.Time { return time.Unix(100, 0) }
|
||||
t.Cleanup(func() { intelSysfsNow = oldNow })
|
||||
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
|
||||
|
||||
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "6000000\n")
|
||||
intelSysfsNow = func() time.Time { return time.Unix(102, 0) }
|
||||
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
|
||||
|
||||
gpu := gm.GpuDataMap["card0"]
|
||||
require.NotNil(t, gpu)
|
||||
assert.Equal(t, 2.5, gpu.Power)
|
||||
assert.Equal(t, 2.0, gpu.Count)
|
||||
}
|
||||
|
||||
func TestIntelSysfsSecondEnergyCounterMapsToPowerPkg(t *testing.T) {
|
||||
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
|
||||
devicePath := filepath.Join(cardPath, "device")
|
||||
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
|
||||
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
|
||||
writeIntelSysfsFile(t, hwmonPath, "energy2_input", "2000000\n")
|
||||
|
||||
oldNow := intelSysfsNow
|
||||
t.Cleanup(func() { intelSysfsNow = oldNow })
|
||||
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
|
||||
intelSysfsNow = func() time.Time { return time.Unix(100, 0) }
|
||||
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
|
||||
|
||||
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "2000000\n")
|
||||
writeIntelSysfsFile(t, hwmonPath, "energy2_input", "8000000\n")
|
||||
intelSysfsNow = func() time.Time { return time.Unix(102, 0) }
|
||||
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
|
||||
|
||||
gpu := gm.GpuDataMap["card0"]
|
||||
require.NotNil(t, gpu)
|
||||
assert.Equal(t, 0.5, gpu.Power)
|
||||
assert.Equal(t, 3.0, gpu.PowerPkg)
|
||||
}
|
||||
|
||||
func TestIntelSysfsCounterResetSkipsOneSample(t *testing.T) {
|
||||
gm := &GPUManager{}
|
||||
power, ok := gm.calculateIntelSysfsPower("card0", 5000000, time.Unix(100, 0))
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, 0.0, power)
|
||||
|
||||
power, ok = gm.calculateIntelSysfsPower("card0", 1000000, time.Unix(101, 0))
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, 0.0, power)
|
||||
|
||||
power, ok = gm.calculateIntelSysfsPower("card0", 3000000, time.Unix(103, 0))
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 1.0, power)
|
||||
}
|
||||
|
||||
func TestIntelSysfsTempInputMapsToCelsius(t *testing.T) {
|
||||
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
|
||||
devicePath := filepath.Join(cardPath, "device")
|
||||
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
|
||||
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
|
||||
writeIntelSysfsFile(t, hwmonPath, "temp1_input", "43500\n")
|
||||
setIntelSysfsTime(t, time.Unix(100, 0))
|
||||
|
||||
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
|
||||
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
|
||||
|
||||
gpu := gm.GpuDataMap["card0"]
|
||||
require.NotNil(t, gpu)
|
||||
assert.Equal(t, 43.5, gpu.Temperature)
|
||||
}
|
||||
|
||||
func TestIntelSysfsMissingOptionalFilesDoNotFail(t *testing.T) {
|
||||
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
|
||||
devicePath := filepath.Join(cardPath, "device")
|
||||
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
|
||||
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
|
||||
setIntelSysfsTime(t, time.Unix(100, 0))
|
||||
|
||||
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
|
||||
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
|
||||
|
||||
gpu := gm.GpuDataMap["card0"]
|
||||
require.NotNil(t, gpu)
|
||||
assert.Equal(t, 0.0, gpu.Usage)
|
||||
assert.Equal(t, 0.0, gpu.MemoryUsed)
|
||||
assert.Equal(t, 0.0, gpu.MemoryTotal)
|
||||
assert.Equal(t, 0.0, gpu.Temperature)
|
||||
}
|
||||
|
||||
func TestIntelSysfsMapsOpportunisticMemoryAndUsage(t *testing.T) {
|
||||
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
|
||||
devicePath := filepath.Join(cardPath, "device")
|
||||
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
|
||||
writeIntelSysfsFile(t, devicePath, "gpu_busy_percent", "37\n")
|
||||
writeIntelSysfsFile(t, devicePath, "mem_info_lmem_used", "1073741824\n")
|
||||
writeIntelSysfsFile(t, devicePath, "mem_info_lmem_total", "2147483648\n")
|
||||
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
|
||||
setIntelSysfsTime(t, time.Unix(100, 0))
|
||||
|
||||
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
|
||||
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
|
||||
|
||||
gpu := gm.GpuDataMap["card0"]
|
||||
require.NotNil(t, gpu)
|
||||
assert.Equal(t, 37.0, gpu.Usage)
|
||||
assert.Equal(t, utils.BytesToMegabytes(1073741824), gpu.MemoryUsed)
|
||||
assert.Equal(t, utils.BytesToMegabytes(2147483648), gpu.MemoryTotal)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
//go:build !linux
|
||||
|
||||
package agent
|
||||
|
||||
type intelSysfsEnergySnapshot struct{}
|
||||
|
||||
func (gm *GPUManager) hasIntelSysfs() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (gm *GPUManager) startIntelSysfsCollector() bool {
|
||||
return false
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -49,14 +48,9 @@ func (gm *GPUManager) updateNvtopSnapshots(snapshots []nvtopSnapshot) bool {
|
||||
|
||||
valid := false
|
||||
usedIDs := make(map[string]struct{}, len(snapshots))
|
||||
var xeName string
|
||||
for i, sample := range snapshots {
|
||||
// nvtop leaves device_name unset on xe devices.
|
||||
if sample.DeviceName == "" {
|
||||
if xeName == "" {
|
||||
xeName = xeGpuName()
|
||||
}
|
||||
sample.DeviceName = xeName
|
||||
continue
|
||||
}
|
||||
indexID := "n" + strconv.Itoa(i)
|
||||
id := indexID
|
||||
@@ -164,38 +158,3 @@ func (gm *GPUManager) startNvtopCollector(interval string, onFailure func()) {
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// xeDevicePath returns the sysfs device path of the first xe GPU, or "".
|
||||
func xeDevicePath() string {
|
||||
cards, err := filepath.Glob("/sys/class/drm/card*")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, card := range cards {
|
||||
if strings.Contains(filepath.Base(card), "-") {
|
||||
continue
|
||||
}
|
||||
if uevent, err := utils.ReadStringFileLimited(filepath.Join(card, "device", "uevent"), 4096); err == nil && strings.Contains(uevent, "DRIVER=xe") {
|
||||
return filepath.Join(card, "device")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (gm *GPUManager) hasXe() bool {
|
||||
return xeDevicePath() != ""
|
||||
}
|
||||
|
||||
// xeGpuName names an xe GPU from its PCI device id; nvtop leaves device_name unset on xe.
|
||||
func xeGpuName() string {
|
||||
devicePath := xeDevicePath()
|
||||
if devicePath == "" {
|
||||
return "GPU"
|
||||
}
|
||||
id, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "device"), 64)
|
||||
if err != nil {
|
||||
return "GPU"
|
||||
}
|
||||
id = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(id, "0x")))
|
||||
return "Intel GPU (" + id + ")"
|
||||
}
|
||||
|
||||
@@ -332,12 +332,11 @@ func TestUpdateNvtopSnapshotsKeepsDeviceAssociationWhenOrderChanges(t *testing.T
|
||||
}
|
||||
|
||||
func TestParseCollectorPriority(t *testing.T) {
|
||||
got := parseCollectorPriority(" nvml, nvidia-smi, intel_gpu_top, intel_sysfs, amd_sysfs, nvtop, rocm-smi, bad ")
|
||||
got := parseCollectorPriority(" nvml, nvidia-smi, intel_gpu_top, amd_sysfs, nvtop, rocm-smi, bad ")
|
||||
want := []collectorSource{
|
||||
collectorSourceNVML,
|
||||
collectorSourceNvidiaSMI,
|
||||
collectorSourceIntelGpuTop,
|
||||
collectorSourceIntelSysfs,
|
||||
collectorSourceAmdSysfs,
|
||||
collectorSourceNVTop,
|
||||
collectorSourceRocmSMI,
|
||||
|
||||
@@ -132,14 +132,9 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
||||
var systemStats system.Stats
|
||||
|
||||
// battery
|
||||
if batteries, err := battery.GetBatteryStats(); err == nil {
|
||||
systemStats.Batteries = make(map[string]uint8, len(batteries))
|
||||
for _, device := range batteries {
|
||||
systemStats.Batteries[device.Name] = device.Percent
|
||||
}
|
||||
if primary, ok := battery.Primary(batteries); ok {
|
||||
systemStats.Battery = [2]uint8{primary.Percent, primary.State}
|
||||
}
|
||||
if batteryPercent, batteryState, err := battery.GetBatteryStats(); err == nil {
|
||||
systemStats.Battery[0] = batteryPercent
|
||||
systemStats.Battery[1] = batteryState
|
||||
}
|
||||
|
||||
// cpu metrics
|
||||
|
||||
2
go.mod
2
go.mod
@@ -20,7 +20,7 @@ require (
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/exp v0.0.0-20260811152304-ee035b5b010f
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/sys v0.47.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
howett.net/plist v1.0.1
|
||||
|
||||
4
go.sum
4
go.sum
@@ -146,8 +146,8 @@ golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
|
||||
@@ -55,7 +55,6 @@ 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 !hasRepresentativeBattery(data.Stats.Battery, data.Stats.Batteries) {
|
||||
if data.Stats.Battery[0] == 0 {
|
||||
continue
|
||||
}
|
||||
val = float64(data.Stats.Battery[0])
|
||||
@@ -167,7 +167,6 @@ 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
|
||||
}
|
||||
@@ -236,9 +235,6 @@ 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
|
||||
@@ -301,10 +297,6 @@ 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{0, 1}, [2]uint8{21, 0})
|
||||
testOneMinuteSystemAlert(t, "Battery", 20, setBatteryAlertValue, [2]uint8{19, 0}, [2]uint8{21, 0})
|
||||
}
|
||||
|
||||
func TestSystemAlertsTwoMin(t *testing.T) {
|
||||
|
||||
@@ -20,7 +20,7 @@ FROM alpine:3.23
|
||||
|
||||
COPY --from=builder /agent /agent
|
||||
|
||||
RUN apk add --no-cache -X https://dl-cdn.alpinelinux.org/alpine/edge/testing igt-gpu-tools nvtop smartmontools
|
||||
RUN apk add --no-cache -X https://dl-cdn.alpinelinux.org/alpine/edge/testing igt-gpu-tools smartmontools
|
||||
|
||||
# Ensure data persistence across container recreations
|
||||
VOLUME ["/var/lib/beszel-agent"]
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
FROM --platform=$BUILDPLATFORM golang:bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ../go.mod ../go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source files
|
||||
COPY . ./
|
||||
|
||||
# Build
|
||||
ARG TARGETOS=linux
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
|
||||
RUN set -eux; \
|
||||
if [ "$TARGETARCH" = "arm" ] && [ -n "$TARGETVARIANT" ]; then \
|
||||
export GOARM="${TARGETVARIANT#v}"; \
|
||||
fi; \
|
||||
CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH \
|
||||
go build -tags glibc -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
||||
|
||||
# --------------------------
|
||||
# Smartmontools builder stage
|
||||
# --------------------------
|
||||
FROM --platform=$TARGETPLATFORM debian:bookworm-slim AS smartmontools-builder
|
||||
|
||||
# Keep smartmontools 7.5 built from source to match the current NVIDIA agent image behavior.
|
||||
# A simpler Debian package based approach is also possible:
|
||||
#
|
||||
# RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# smartmontools \
|
||||
# && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget \
|
||||
ca-certificates \
|
||||
build-essential \
|
||||
make \
|
||||
g++ \
|
||||
&& wget https://downloads.sourceforge.net/project/smartmontools/smartmontools/7.5/smartmontools-7.5.tar.gz \
|
||||
&& tar zxvf smartmontools-7.5.tar.gz \
|
||||
&& cd smartmontools-7.5 \
|
||||
&& ./configure --prefix=/usr --sysconfdir=/etc \
|
||||
&& make \
|
||||
&& make install \
|
||||
&& rm -rf /smartmontools-7.5* \
|
||||
&& apt-get remove -y wget build-essential \
|
||||
&& apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy smartmontools binary, data files, and required runtime libraries
|
||||
RUN set -eux; \
|
||||
mkdir -p /out/rootfs/usr/share /out/rootfs/lib /out/rootfs/lib64 /out/rootfs/usr/lib; \
|
||||
if [ -d /usr/share/smartmontools ]; then \
|
||||
cp -a /usr/share/smartmontools /out/rootfs/usr/share/; \
|
||||
fi; \
|
||||
ldd /usr/sbin/smartctl \
|
||||
| awk '{print $3}' \
|
||||
| grep '^/' \
|
||||
| xargs -r -I '{}' sh -c 'mkdir -p "/out/rootfs$(dirname "{}")"; cp -v "{}" "/out/rootfs{}"'; \
|
||||
interp="$(ldd /usr/sbin/smartctl | awk "/ld-linux/ {print \$1}")"; \
|
||||
if [ -n "$interp" ] && [ -e "$interp" ]; then \
|
||||
mkdir -p "/out/rootfs$(dirname "$interp")"; \
|
||||
cp -v "$interp" "/out/rootfs$interp"; \
|
||||
fi
|
||||
|
||||
# --------------------------
|
||||
# Final image: lightweight multi-arch NVIDIA agent (slim)
|
||||
# --------------------------
|
||||
FROM --platform=$TARGETPLATFORM gcr.io/distroless/base-debian12
|
||||
COPY --from=builder /agent /agent
|
||||
|
||||
# AMD GPU name lookup (used by agent on hybrid laptops when /usr/share/libdrm/amdgpu.ids is read)
|
||||
COPY --from=builder /app/agent/test-data/amdgpu.ids /usr/share/libdrm/amdgpu.ids
|
||||
|
||||
# Copy smartmontools binaries and config files
|
||||
COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl
|
||||
COPY --from=smartmontools-builder /out/rootfs/ /
|
||||
|
||||
# nvidia-smi is intentionally not bundled.
|
||||
# Mount the host binary instead, for example:
|
||||
# - /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro
|
||||
|
||||
# Ensure data persistence across container recreations
|
||||
VOLUME ["/var/lib/beszel-agent"]
|
||||
|
||||
WORKDIR /var/lib/beszel-agent
|
||||
|
||||
ENTRYPOINT ["/agent"]
|
||||
@@ -34,7 +34,6 @@ 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"`
|
||||
@@ -44,7 +43,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]
|
||||
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
|
||||
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]
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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")
|
||||
}
|
||||
@@ -348,9 +348,6 @@ func (sys *System) getRecord(app core.App) (*core.Record, error) {
|
||||
record, err := app.FindRecordById("systems", sys.Id)
|
||||
if err != nil || record == nil {
|
||||
_ = sys.manager.RemoveSystem(sys.Id)
|
||||
if err == nil {
|
||||
err = fmt.Errorf("system record %s not found", sys.Id)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return record, nil
|
||||
@@ -380,16 +377,10 @@ func (sys *System) HasUser(app core.App, user *core.Record) bool {
|
||||
// setDown marks a system as down in the database.
|
||||
// It takes the original error that caused the system to go down and returns any error
|
||||
// encountered during the process of updating the system status.
|
||||
// It is a no-op if the system's context has been cancelled.
|
||||
func (sys *System) setDown(originalError error) error {
|
||||
if sys.Status == down || sys.Status == paused {
|
||||
return nil
|
||||
}
|
||||
// the updater can race shutdown, and the app may already be disposed by the
|
||||
// time we get here, so don't touch the database once the context is cancelled
|
||||
if sys.ctx != nil && sys.ctx.Err() != nil {
|
||||
return sys.ctx.Err()
|
||||
}
|
||||
record, err := sys.getRecord(sys.manager.hub)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
package systems
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
@@ -158,17 +157,3 @@ func TestCombinedData_MigrateDeprecatedFields(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetDownAfterContextCancelled(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
// manager is nil on purpose: setDown must bail out before touching the app
|
||||
sys := &System{Status: up, ctx: ctx}
|
||||
|
||||
if err := sys.setDown(nil); err != context.Canceled {
|
||||
t.Fatalf("expected context.Canceled, got %v", err)
|
||||
}
|
||||
if sys.Status != up {
|
||||
t.Fatalf("status should be untouched, got %q", sys.Status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,9 +186,6 @@ 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]
|
||||
@@ -235,15 +232,8 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
||||
for i := range stats.DiskIoStats {
|
||||
sum.DiskIoStats[i] += stats.DiskIoStats[i]
|
||||
}
|
||||
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]++
|
||||
}
|
||||
batterySum += int(stats.Battery[0])
|
||||
sum.Battery[1] = stats.Battery[1]
|
||||
|
||||
// accumulate per-core usage if present
|
||||
if stats.CpuCoresUsage != nil {
|
||||
@@ -389,15 +379,7 @@ 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)
|
||||
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])
|
||||
}
|
||||
}
|
||||
sum.Battery[0] = uint8(batterySum / int(count))
|
||||
|
||||
// Average network interfaces
|
||||
if sum.NetworkInterfaces != nil {
|
||||
@@ -485,10 +467,6 @@ 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,28 +602,6 @@ 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},
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
"preview": "vite preview",
|
||||
"sync": "lingui extract --overwrite && lingui compile",
|
||||
"sync_no_compile": "lingui extract --overwrite --clean",
|
||||
"sync_and_purge": "lingui extract --overwrite --clean && lingui compile"
|
||||
"sync_and_purge": "lingui extract --overwrite --clean && lingui compile",
|
||||
"format": "biome format --write .",
|
||||
"lint": "biome lint .",
|
||||
"check": "biome check .",
|
||||
"check:fix": "biome check --fix ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@henrygd/queue": "^1.0.7",
|
||||
|
||||
@@ -125,7 +125,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
|
||||
<FanChart {...coreProps} />
|
||||
|
||||
<BatteryChart system={system} {...coreProps} />
|
||||
<BatteryChart {...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 system={system} {...coreProps} />
|
||||
<BatteryChart {...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, SystemRecord, SystemStatsRecord } from "@/types"
|
||||
import type { ChartData, SystemStatsRecord } from "@/types"
|
||||
import { ChartCard, FilterBar } from "../chart-card"
|
||||
import LineChartDefault from "@/components/charts/line-chart"
|
||||
import { useStore } from "@nanostores/react"
|
||||
@@ -14,60 +14,18 @@ export function BatteryChart({
|
||||
grid,
|
||||
dataEmpty,
|
||||
maxValues,
|
||||
system,
|
||||
}: {
|
||||
chartData: ChartData
|
||||
grid: boolean
|
||||
dataEmpty: boolean
|
||||
maxValues: boolean
|
||||
system: SystemRecord
|
||||
}) {
|
||||
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)
|
||||
const showBatteryChart = chartData.systemStats.at(-1)?.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}
|
||||
@@ -76,7 +34,7 @@ export function BatteryChart({
|
||||
description={`${t({
|
||||
message: "Current state",
|
||||
comment: "Context: Battery state",
|
||||
})}: ${batteryStateTranslations[system.info.bat?.[1] ?? 0]()}`}
|
||||
})}: ${batteryStateTranslations[chartData.systemStats.at(-1)?.stats.bat?.[1] ?? 0]()}`}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
@@ -252,7 +210,15 @@ 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,8 +151,6 @@ 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