mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-21 17:07:47 +02:00
Compare commits
3 Commits
65a6f60304
...
68a3f8962a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68a3f8962a | ||
|
|
0eb3426619 | ||
|
|
96beadc8c9 |
@@ -72,14 +72,30 @@ func discoverHwmonFans(root string) ([]fanSensor, error) {
|
||||
var sensors []fanSensor
|
||||
for _, entry := range entries {
|
||||
chipDir := filepath.Join(root, entry.Name())
|
||||
chipName := utils.ReadStringFile(filepath.Join(chipDir, "name"))
|
||||
sensorDir := chipDir
|
||||
inputs, _ := filepath.Glob(filepath.Join(sensorDir, "fan*_input"))
|
||||
|
||||
// Some legacy hwmon drivers (notably applesmc) register a hwmon class
|
||||
// device but create fan attributes on the parent platform device. In
|
||||
// sysfs that parent is exposed through hwmonN/device.
|
||||
if len(inputs) == 0 {
|
||||
deviceDir := filepath.Join(chipDir, "device")
|
||||
if deviceInputs, _ := filepath.Glob(filepath.Join(deviceDir, "fan*_input")); len(deviceInputs) > 0 {
|
||||
sensorDir = deviceDir
|
||||
inputs = deviceInputs
|
||||
}
|
||||
}
|
||||
|
||||
chipName := utils.ReadStringFile(filepath.Join(sensorDir, "name"))
|
||||
if chipName == "" {
|
||||
chipName = utils.ReadStringFile(filepath.Join(chipDir, "name"))
|
||||
}
|
||||
if chipName == "" {
|
||||
chipName = entry.Name()
|
||||
}
|
||||
inputs, _ := filepath.Glob(filepath.Join(chipDir, "fan*_input"))
|
||||
for _, inputPath := range inputs {
|
||||
base := strings.TrimSuffix(filepath.Base(inputPath), "_input")
|
||||
label := utils.ReadStringFile(filepath.Join(chipDir, base+"_label"))
|
||||
label := utils.ReadStringFile(filepath.Join(sensorDir, base+"_label"))
|
||||
key := chipName + "_" + base
|
||||
if label != "" {
|
||||
key = chipName + "_" + label
|
||||
|
||||
@@ -50,6 +50,24 @@ func TestReadHwmonFans(t *testing.T) {
|
||||
}, fans)
|
||||
}
|
||||
|
||||
// TestReadHwmonFansLegacyParent verifies legacy hwmon layouts such as applesmc,
|
||||
// where the hwmon class node exists but fan attributes live on hwmonN/device.
|
||||
func TestReadHwmonFansLegacyParent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
deviceDir := filepath.Join(root, "devices", "applesmc.768")
|
||||
writeFile(t, filepath.Join(deviceDir, "name"), "applesmc\n")
|
||||
writeFile(t, filepath.Join(deviceDir, "fan1_input"), "1202\n")
|
||||
writeFile(t, filepath.Join(deviceDir, "fan1_label"), "Exhaust\n")
|
||||
|
||||
chipDir := filepath.Join(root, "hwmon1")
|
||||
require.NoError(t, os.MkdirAll(chipDir, 0o755))
|
||||
require.NoError(t, os.Symlink(deviceDir, filepath.Join(chipDir, "device")))
|
||||
|
||||
fans, err := readHwmonFans(root)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]uint16{"applesmc_Exhaust": 1202}, fans)
|
||||
}
|
||||
|
||||
// TestReadHwmonFansMissingRoot returns an error rather than panicking when the
|
||||
// hwmon root doesn't exist (e.g. running on a kernel without hwmon support).
|
||||
func TestReadHwmonFansMissingRoot(t *testing.T) {
|
||||
|
||||
@@ -50,6 +50,9 @@ func generateFingerprint(hostname, cpuModel string) string {
|
||||
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
||||
cpuModel = info[0].ModelName
|
||||
}
|
||||
if cpuModel == "" {
|
||||
cpuModel = getCpuModelFromCpuinfo()
|
||||
}
|
||||
}
|
||||
fingerprint = hostname + cpuModel
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"runtime"
|
||||
@@ -78,6 +79,12 @@ func (a *Agent) refreshSystemDetails() {
|
||||
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
||||
a.systemDetails.CpuModel = info[0].ModelName
|
||||
}
|
||||
// gopsutil doesn't parse the "cpu model" field from /proc/cpuinfo, which
|
||||
// is the only source of the CPU model name on MIPS. Fall back to reading
|
||||
// it directly when ModelName is empty.
|
||||
if a.systemDetails.CpuModel == "" {
|
||||
a.systemDetails.CpuModel = getCpuModelFromCpuinfo()
|
||||
}
|
||||
// cores / threads
|
||||
cores, _ := cpu.Counts(false)
|
||||
threads := hostInfo.NCPU
|
||||
@@ -258,13 +265,66 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
||||
a.systemInfo.MemPct = systemStats.MemPct
|
||||
a.systemInfo.DiskPct = systemStats.DiskPct
|
||||
a.systemInfo.Battery = systemStats.Battery
|
||||
a.systemInfo.Uptime, _ = host.Uptime()
|
||||
a.systemInfo.Uptime, _ = getUptime()
|
||||
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
|
||||
a.systemInfo.Threads = a.systemDetails.Threads
|
||||
|
||||
return systemStats
|
||||
}
|
||||
|
||||
// cpuModelFallbackKeys are the field names to look for in /proc/cpuinfo when
|
||||
// gopsutil fails to return a ModelName. The "cpu model" key is used on MIPS
|
||||
// (e.g. "MIPS 1004Kc V2.15"), while "system type" provides SoC information
|
||||
// on various embedded architectures.
|
||||
var cpuModelFallbackKeys = []string{"cpu model", "system type"}
|
||||
|
||||
// getCpuModelFromCpuinfo reads /proc/cpuinfo and returns a CPU model string.
|
||||
// This is a fallback for architectures where gopsutil's cpu.Info() does not
|
||||
// populate ModelName, most notably MIPS.
|
||||
func getCpuModelFromCpuinfo() string {
|
||||
file, err := os.Open("/proc/cpuinfo")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer file.Close()
|
||||
return parseCpuModel(file)
|
||||
}
|
||||
|
||||
// parseCpuModel scans r (expected to be /proc/cpuinfo content) and returns
|
||||
// a combined CPU model string. It collects values from all matching keys
|
||||
// and joins them with " / " when multiple are found.
|
||||
func parseCpuModel(r io.Reader) string {
|
||||
lines := readLines(r)
|
||||
var parts []string
|
||||
for _, key := range cpuModelFallbackKeys {
|
||||
for _, line := range lines {
|
||||
after, found := strings.CutPrefix(line, key)
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
after = strings.TrimSpace(after)
|
||||
if len(after) < 2 || after[0] != ':' {
|
||||
continue
|
||||
}
|
||||
if value := strings.TrimSpace(after[1:]); value != "" {
|
||||
parts = append(parts, value)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " / ")
|
||||
}
|
||||
|
||||
// readLines reads all lines from r into a slice.
|
||||
func readLines(r io.Reader) []string {
|
||||
scanner := bufio.NewScanner(r)
|
||||
var lines []string
|
||||
for scanner.Scan() {
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// calculateHostMemoryUsage derives counters defensively because /proc/meminfo may
|
||||
// change while gopsutil reads it. Invalid unsigned subtractions saturate at zero.
|
||||
func calculateHostMemoryUsage(v *mem.VirtualMemoryStat, htop bool) (used, cacheBuff, swapUsed uint64) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
@@ -113,3 +114,81 @@ func TestUpdateSystemDetailsMarksDetailsDirty(t *testing.T) {
|
||||
assert.False(t, agent.detailsDirty)
|
||||
assert.Nil(t, original.Details)
|
||||
}
|
||||
|
||||
func TestParseCpuModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "MIPS with both cpu model and system type",
|
||||
input: `system type : MediaTek MT7621 ver:1 eco:3
|
||||
machine : ASUS RT-AX53U
|
||||
processor : 0
|
||||
cpu model : MIPS 1004Kc V2.15
|
||||
BogoMIPS : 586.13
|
||||
wait instruction : yes`,
|
||||
expected: "MIPS 1004Kc V2.15 / MediaTek MT7621 ver:1 eco:3",
|
||||
},
|
||||
{
|
||||
name: "MIPS with different SoC",
|
||||
input: `system type : Atheros AR7161 rev 2
|
||||
machine : NETGEAR WNDR3700
|
||||
processor : 0
|
||||
cpu model : MIPS 24Kc V7.4
|
||||
BogoMIPS : 452.19`,
|
||||
expected: "MIPS 24Kc V7.4 / Atheros AR7161 rev 2",
|
||||
},
|
||||
{
|
||||
name: "only system type when cpu model missing",
|
||||
input: `system type : Broadcom BCM47xx
|
||||
processor : 0
|
||||
BogoMIPS : 296.11`,
|
||||
expected: "Broadcom BCM47xx",
|
||||
},
|
||||
{
|
||||
name: "only cpu model when system type missing",
|
||||
input: `processor : 0
|
||||
cpu model : MIPS 34Kc V2.15
|
||||
BogoMIPS : 300.00`,
|
||||
expected: "MIPS 34Kc V2.15",
|
||||
},
|
||||
{
|
||||
name: "x86 cpuinfo returns empty",
|
||||
input: `processor : 0
|
||||
vendor_id : GenuineIntel
|
||||
cpu family : 6
|
||||
model : 142
|
||||
model name : Intel(R) Core(TM) i5-8250U CPU @ 1.60GHz
|
||||
stepping : 10`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "cpu model with extra whitespace",
|
||||
input: `processor : 0
|
||||
cpu model : MIPS 34Kc V2.15
|
||||
BogoMIPS : 300.00`,
|
||||
expected: "MIPS 34Kc V2.15",
|
||||
},
|
||||
{
|
||||
name: "cpu model without value",
|
||||
input: `processor : 0
|
||||
cpu model :
|
||||
BogoMIPS : 300.00`,
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := parseCpuModel(strings.NewReader(tt.input))
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
44
agent/uptime_linux.go
Normal file
44
agent/uptime_linux.go
Normal file
@@ -0,0 +1,44 @@
|
||||
//go:build linux
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/host"
|
||||
)
|
||||
|
||||
// uptimeFilePath is a variable so tests can point it at a fixture.
|
||||
var uptimeFilePath = "/proc/uptime"
|
||||
|
||||
// getUptime returns the system uptime in seconds.
|
||||
//
|
||||
// This reads /proc/uptime instead of using host.Uptime(), which calls the
|
||||
// sysinfo(2) syscall. Inside an LXC container lxcfs virtualizes /proc/uptime
|
||||
// but cannot intercept a syscall, so sysinfo(2) reports the host's uptime
|
||||
// rather than the container's.
|
||||
//
|
||||
// Falls back to host.Uptime() if /proc/uptime is missing or unparseable, so
|
||||
// behavior is unchanged anywhere the file isn't available.
|
||||
func getUptime() (uint64, error) {
|
||||
data, err := os.ReadFile(uptimeFilePath)
|
||||
if err != nil {
|
||||
return host.Uptime()
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) == 0 {
|
||||
return host.Uptime()
|
||||
}
|
||||
seconds, err := strconv.ParseFloat(fields[0], 64)
|
||||
if err != nil ||
|
||||
math.IsNaN(seconds) ||
|
||||
math.IsInf(seconds, 0) ||
|
||||
seconds < 0 ||
|
||||
seconds >= 1<<64 {
|
||||
return host.Uptime()
|
||||
}
|
||||
return uint64(seconds), nil
|
||||
}
|
||||
101
agent/uptime_linux_test.go
Normal file
101
agent/uptime_linux_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
//go:build linux
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetUptimeFromProc(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
contents string
|
||||
want uint64
|
||||
}{
|
||||
{"typical", "12345.67 98765.43\n", 12345},
|
||||
{"zero", "0.00 0.00\n", 0},
|
||||
{"no trailing newline", "42.99 7.00", 42},
|
||||
{"single field", "600.5", 600},
|
||||
{"large value", "266030.12 1000000.00\n", 266030},
|
||||
}
|
||||
|
||||
prev := uptimeFilePath
|
||||
t.Cleanup(func() { uptimeFilePath = prev })
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "uptime")
|
||||
if err := os.WriteFile(path, []byte(tt.contents), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uptimeFilePath = path
|
||||
|
||||
got, err := getUptime()
|
||||
if err != nil {
|
||||
t.Fatalf("getUptime() returned error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("getUptime() = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func writeUptime(contents string) func(t *testing.T) string {
|
||||
return func(t *testing.T) string {
|
||||
path := filepath.Join(t.TempDir(), "uptime")
|
||||
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
// Malformed, missing, or out-of-range input must fall back to host.Uptime()
|
||||
// rather than returning a bogus value, so the agent still reports something sane.
|
||||
func TestGetUptimeFallsBack(t *testing.T) {
|
||||
prev := uptimeFilePath
|
||||
t.Cleanup(func() { uptimeFilePath = prev })
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
prepare func(t *testing.T) string
|
||||
}{
|
||||
{"missing file", func(t *testing.T) string {
|
||||
return filepath.Join(t.TempDir(), "does-not-exist")
|
||||
}},
|
||||
{"empty file", func(t *testing.T) string {
|
||||
path := filepath.Join(t.TempDir(), "uptime")
|
||||
if err := os.WriteFile(path, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}},
|
||||
{"unparseable", func(t *testing.T) string {
|
||||
path := filepath.Join(t.TempDir(), "uptime")
|
||||
if err := os.WriteFile(path, []byte("not-a-number 1.0\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}},
|
||||
{"NaN", writeUptime("NaN 1.0\n")},
|
||||
{"positive infinity", writeUptime("+Inf 1.0\n")},
|
||||
{"negative infinity", writeUptime("-Inf 1.0\n")},
|
||||
{"negative", writeUptime("-42.5 1.0\n")},
|
||||
{"exceeds uint64 range", writeUptime("1e20 1.0\n")},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
uptimeFilePath = tt.prepare(t)
|
||||
|
||||
got, err := getUptime()
|
||||
if err != nil {
|
||||
t.Fatalf("getUptime() returned error: %v", err)
|
||||
}
|
||||
if got == 0 {
|
||||
t.Error("getUptime() = 0, expected fallback to host.Uptime()")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
10
agent/uptime_stub.go
Normal file
10
agent/uptime_stub.go
Normal file
@@ -0,0 +1,10 @@
|
||||
//go:build !linux
|
||||
|
||||
package agent
|
||||
|
||||
import "github.com/shirou/gopsutil/v4/host"
|
||||
|
||||
// getUptime returns the system uptime in seconds.
|
||||
func getUptime() (uint64, error) {
|
||||
return host.Uptime()
|
||||
}
|
||||
Reference in New Issue
Block a user