From 0c4d2edd453a93ef045ce9426160faa7bcc6ede6 Mon Sep 17 00:00:00 2001 From: henrygd Date: Sat, 7 Mar 2026 13:50:49 -0500 Subject: [PATCH] refactor(agent): add utils package; rm utils.go and fs_utils.go --- agent/agent.go | 3 +- agent/disk.go | 15 +++-- agent/docker.go | 9 +-- agent/docker_test.go | 61 +++-------------- agent/emmc_linux.go | 15 +++-- agent/fs_utils.go | 41 ------------ agent/gpu.go | 21 +++--- agent/gpu_amd_linux.go | 5 +- agent/gpu_amd_linux_test.go | 9 +-- agent/gpu_nvtop.go | 5 +- agent/gpu_test.go | 9 +-- agent/mdraid_linux.go | 23 ++++--- agent/network.go | 5 +- agent/sensors.go | 3 +- agent/system.go | 27 ++++---- agent/utils.go | 15 ----- agent/utils/utils.go | 62 +++++++++++++++++ agent/utils/utils_test.go | 130 ++++++++++++++++++++++++++++++++++++ 18 files changed, 283 insertions(+), 175 deletions(-) delete mode 100644 agent/fs_utils.go delete mode 100644 agent/utils.go create mode 100644 agent/utils/utils.go create mode 100644 agent/utils/utils_test.go diff --git a/agent/agent.go b/agent/agent.go index 7c30d926..c717844d 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -14,6 +14,7 @@ import ( "github.com/gliderlabs/ssh" "github.com/henrygd/beszel" "github.com/henrygd/beszel/agent/deltatracker" + "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/entities/system" gossh "golang.org/x/crypto/ssh" @@ -213,7 +214,7 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD data.Stats.ExtraFs[key] = stats // Add percentages to Info struct for dashboard if stats.DiskTotal > 0 { - pct := twoDecimals((stats.DiskUsed / stats.DiskTotal) * 100) + pct := utils.TwoDecimals((stats.DiskUsed / stats.DiskTotal) * 100) data.Info.ExtraFsPct[key] = pct } } diff --git a/agent/disk.go b/agent/disk.go index 917fbc90..940b11d8 100644 --- a/agent/disk.go +++ b/agent/disk.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/system" "github.com/shirou/gopsutil/v4/disk" @@ -412,12 +413,12 @@ func (a *Agent) updateDiskUsage(systemStats *system.Stats) { continue } if d, err := disk.Usage(stats.Mountpoint); err == nil { - stats.DiskTotal = bytesToGigabytes(d.Total) - stats.DiskUsed = bytesToGigabytes(d.Used) + stats.DiskTotal = utils.BytesToGigabytes(d.Total) + stats.DiskUsed = utils.BytesToGigabytes(d.Used) if stats.Root { - systemStats.DiskTotal = bytesToGigabytes(d.Total) - systemStats.DiskUsed = bytesToGigabytes(d.Used) - systemStats.DiskPct = twoDecimals(d.UsedPercent) + systemStats.DiskTotal = utils.BytesToGigabytes(d.Total) + systemStats.DiskUsed = utils.BytesToGigabytes(d.Used) + systemStats.DiskPct = utils.TwoDecimals(d.UsedPercent) } } else { // reset stats if error (likely unmounted) @@ -470,8 +471,8 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) { diskIORead := (d.ReadBytes - prev.readBytes) * 1000 / msElapsed diskIOWrite := (d.WriteBytes - prev.writeBytes) * 1000 / msElapsed - readMbPerSecond := bytesToMegabytes(float64(diskIORead)) - writeMbPerSecond := bytesToMegabytes(float64(diskIOWrite)) + readMbPerSecond := utils.BytesToMegabytes(float64(diskIORead)) + writeMbPerSecond := utils.BytesToMegabytes(float64(diskIOWrite)) // validate values if readMbPerSecond > 50_000 || writeMbPerSecond > 50_000 { diff --git a/agent/docker.go b/agent/docker.go index a5880f92..adb3baf9 100644 --- a/agent/docker.go +++ b/agent/docker.go @@ -21,6 +21,7 @@ import ( "time" "github.com/henrygd/beszel/agent/deltatracker" + "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/container" "github.com/blang/semver" @@ -336,12 +337,12 @@ func validateCpuPercentage(cpuPct float64, containerName string) error { // updateContainerStatsValues updates the final stats values func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemory uint64, sent_delta, recv_delta uint64, readTime time.Time) { - stats.Cpu = twoDecimals(cpuPct) - stats.Mem = bytesToMegabytes(float64(usedMemory)) + stats.Cpu = utils.TwoDecimals(cpuPct) + stats.Mem = utils.BytesToMegabytes(float64(usedMemory)) stats.Bandwidth = [2]uint64{sent_delta, recv_delta} // TODO(0.19+): stop populating NetworkSent/NetworkRecv (deprecated in 0.18.3) - stats.NetworkSent = bytesToMegabytes(float64(sent_delta)) - stats.NetworkRecv = bytesToMegabytes(float64(recv_delta)) + stats.NetworkSent = utils.BytesToMegabytes(float64(sent_delta)) + stats.NetworkRecv = utils.BytesToMegabytes(float64(recv_delta)) stats.PrevReadTime = readTime } diff --git a/agent/docker_test.go b/agent/docker_test.go index f38ca92a..75973a60 100644 --- a/agent/docker_test.go +++ b/agent/docker_test.go @@ -18,6 +18,7 @@ import ( "time" "github.com/henrygd/beszel/agent/deltatracker" + "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/container" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -298,48 +299,6 @@ func TestUpdateContainerStatsValues(t *testing.T) { assert.Equal(t, testTime, stats.PrevReadTime) } -func TestTwoDecimals(t *testing.T) { - tests := []struct { - name string - input float64 - expected float64 - }{ - {"round down", 1.234, 1.23}, - {"round half up", 1.235, 1.24}, // math.Round rounds half up - {"no rounding needed", 1.23, 1.23}, - {"negative number", -1.235, -1.24}, // math.Round rounds half up (more negative) - {"zero", 0.0, 0.0}, - {"large number", 123.456, 123.46}, // rounds 5 up - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := twoDecimals(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestBytesToMegabytes(t *testing.T) { - tests := []struct { - name string - input float64 - expected float64 - }{ - {"1 MB", 1048576, 1.0}, - {"512 KB", 524288, 0.5}, - {"zero", 0, 0}, - {"large value", 1073741824, 1024}, // 1 GB = 1024 MB - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := bytesToMegabytes(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} - func TestInitializeCpuTracking(t *testing.T) { dm := &dockerManager{ lastCpuContainer: make(map[uint16]map[string]uint64), @@ -905,11 +864,11 @@ func TestContainerStatsEndToEndWithRealData(t *testing.T) { updateContainerStatsValues(testStats, cpuPct, usedMemory, 1000000, 500000, testTime) assert.Equal(t, cpuPct, testStats.Cpu) - assert.Equal(t, bytesToMegabytes(float64(usedMemory)), testStats.Mem) + assert.Equal(t, utils.BytesToMegabytes(float64(usedMemory)), testStats.Mem) assert.Equal(t, [2]uint64{1000000, 500000}, testStats.Bandwidth) // Deprecated fields still populated for backward compatibility with older hubs - assert.Equal(t, bytesToMegabytes(1000000), testStats.NetworkSent) - assert.Equal(t, bytesToMegabytes(500000), testStats.NetworkRecv) + assert.Equal(t, utils.BytesToMegabytes(1000000), testStats.NetworkSent) + assert.Equal(t, utils.BytesToMegabytes(500000), testStats.NetworkRecv) assert.Equal(t, testTime, testStats.PrevReadTime) } @@ -1190,13 +1149,13 @@ func TestConstantsAndUtilityFunctions(t *testing.T) { assert.Equal(t, 5*1024*1024, maxTotalLogSize) // 5MB // Test utility functions - assert.Equal(t, 1.5, twoDecimals(1.499)) - assert.Equal(t, 1.5, twoDecimals(1.5)) - assert.Equal(t, 1.5, twoDecimals(1.501)) + assert.Equal(t, 1.5, utils.TwoDecimals(1.499)) + assert.Equal(t, 1.5, utils.TwoDecimals(1.5)) + assert.Equal(t, 1.5, utils.TwoDecimals(1.501)) - assert.Equal(t, 1.0, bytesToMegabytes(1048576)) // 1 MB - assert.Equal(t, 0.5, bytesToMegabytes(524288)) // 512 KB - assert.Equal(t, 0.0, bytesToMegabytes(0)) + assert.Equal(t, 1.0, utils.BytesToMegabytes(1048576)) // 1 MB + assert.Equal(t, 0.5, utils.BytesToMegabytes(524288)) // 512 KB + assert.Equal(t, 0.0, utils.BytesToMegabytes(0)) } func TestDecodeDockerLogStream(t *testing.T) { diff --git a/agent/emmc_linux.go b/agent/emmc_linux.go index 0100b1f1..14763027 100644 --- a/agent/emmc_linux.go +++ b/agent/emmc_linux.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" + "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/smart" ) @@ -141,9 +142,9 @@ func readEmmcHealth(blockName string) (emmcHealth, bool) { out.lifeA = lifeA out.lifeB = lifeB - out.model = readStringFile(filepath.Join(deviceDir, "name")) - out.serial = readStringFile(filepath.Join(deviceDir, "serial")) - out.revision = readStringFile(filepath.Join(deviceDir, "prv")) + out.model = utils.ReadStringFile(filepath.Join(deviceDir, "name")) + out.serial = utils.ReadStringFile(filepath.Join(deviceDir, "serial")) + out.revision = utils.ReadStringFile(filepath.Join(deviceDir, "prv")) if capBytes, ok := readBlockCapacityBytes(blockName); ok { out.capacity = capBytes @@ -153,7 +154,7 @@ func readEmmcHealth(blockName string) (emmcHealth, bool) { } func readLifeTime(deviceDir string) (uint8, uint8, bool) { - if content, ok := readStringFileOK(filepath.Join(deviceDir, "life_time")); ok { + if content, ok := utils.ReadStringFileOK(filepath.Join(deviceDir, "life_time")); ok { a, b, ok := parseHexBytePair(content) return a, b, ok } @@ -170,7 +171,7 @@ func readBlockCapacityBytes(blockName string) (uint64, bool) { sizePath := filepath.Join(emmcSysfsRoot, "class", "block", blockName, "size") lbsPath := filepath.Join(emmcSysfsRoot, "class", "block", blockName, "queue", "logical_block_size") - sizeStr, ok := readStringFileOK(sizePath) + sizeStr, ok := utils.ReadStringFileOK(sizePath) if !ok { return 0, false } @@ -179,7 +180,7 @@ func readBlockCapacityBytes(blockName string) (uint64, bool) { return 0, false } - lbsStr, ok := readStringFileOK(lbsPath) + lbsStr, ok := utils.ReadStringFileOK(lbsPath) logicalBlockSize := uint64(512) if ok { if parsed, err := strconv.ParseUint(lbsStr, 10, 64); err == nil && parsed > 0 { @@ -191,7 +192,7 @@ func readBlockCapacityBytes(blockName string) (uint64, bool) { } func readHexByteFile(path string) (uint8, bool) { - content, ok := readStringFileOK(path) + content, ok := utils.ReadStringFileOK(path) if !ok { return 0, false } diff --git a/agent/fs_utils.go b/agent/fs_utils.go deleted file mode 100644 index 4b954fc6..00000000 --- a/agent/fs_utils.go +++ /dev/null @@ -1,41 +0,0 @@ -package agent - -import ( - "os" - "strconv" - "strings" -) - -// readStringFile returns trimmed file contents or empty string on error. -func readStringFile(path string) string { - content, _ := readStringFileOK(path) - return content -} - -// readStringFileOK returns trimmed file contents and read success. -func readStringFileOK(path string) (string, bool) { - b, err := os.ReadFile(path) - if err != nil { - return "", false - } - return strings.TrimSpace(string(b)), true -} - -// fileExists reports whether the given path exists. -func fileExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - -// readUintFile parses a decimal uint64 value from a file. -func readUintFile(path string) (uint64, bool) { - raw, ok := readStringFileOK(path) - if !ok { - return 0, false - } - parsed, err := strconv.ParseUint(raw, 10, 64) - if err != nil { - return 0, false - } - return parsed, true -} diff --git a/agent/gpu.go b/agent/gpu.go index 6938d6f7..84b997da 100644 --- a/agent/gpu.go +++ b/agent/gpu.go @@ -15,6 +15,7 @@ import ( "sync" "time" + "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/system" ) @@ -291,8 +292,8 @@ func (gm *GPUManager) parseAmdData(output []byte) bool { } gpu := gm.GpuDataMap[id] gpu.Temperature, _ = strconv.ParseFloat(v.Temperature, 64) - gpu.MemoryUsed = bytesToMegabytes(memoryUsage) - gpu.MemoryTotal = bytesToMegabytes(totalMemory) + gpu.MemoryUsed = utils.BytesToMegabytes(memoryUsage) + gpu.MemoryTotal = utils.BytesToMegabytes(totalMemory) gpu.Usage += usage gpu.Power += power gpu.Count++ @@ -366,16 +367,16 @@ func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheK gpuAvg := *gpu deltaUsage, deltaPower, deltaPowerPkg := gm.calculateDeltas(gpu, lastSnapshot) - gpuAvg.Power = twoDecimals(deltaPower / float64(deltaCount)) + gpuAvg.Power = utils.TwoDecimals(deltaPower / 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 = twoDecimals(deltaPowerPkg / float64(deltaCount)) + gpuAvg.PowerPkg = utils.TwoDecimals(deltaPowerPkg / float64(deltaCount)) } else { - gpuAvg.Usage = twoDecimals(deltaUsage / float64(deltaCount)) + gpuAvg.Usage = utils.TwoDecimals(deltaUsage / float64(deltaCount)) } gm.lastAvgData[id] = gpuAvg @@ -410,17 +411,17 @@ func (gm *GPUManager) calculateIntelGPUUsage(gpuAvg, gpu *system.GPUData, lastSn } else { deltaEngine = engine } - gpuAvg.Engines[name] = twoDecimals(deltaEngine / float64(deltaCount)) + gpuAvg.Engines[name] = utils.TwoDecimals(deltaEngine / float64(deltaCount)) maxEngineUsage = max(maxEngineUsage, deltaEngine/float64(deltaCount)) } - return twoDecimals(maxEngineUsage) + return utils.TwoDecimals(maxEngineUsage) } // updateInstantaneousValues updates values that should reflect current state, not averages func (gm *GPUManager) updateInstantaneousValues(gpuAvg *system.GPUData, gpu *system.GPUData) { - gpuAvg.Temperature = twoDecimals(gpu.Temperature) - gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed) - gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal) + gpuAvg.Temperature = utils.TwoDecimals(gpu.Temperature) + gpuAvg.MemoryUsed = utils.TwoDecimals(gpu.MemoryUsed) + gpuAvg.MemoryTotal = utils.TwoDecimals(gpu.MemoryTotal) } // storeSnapshot saves the current GPU state for this cache key diff --git a/agent/gpu_amd_linux.go b/agent/gpu_amd_linux.go index 9eccb221..ab809e90 100644 --- a/agent/gpu_amd_linux.go +++ b/agent/gpu_amd_linux.go @@ -13,6 +13,7 @@ import ( "sync" "time" + "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/system" ) @@ -144,8 +145,8 @@ func (gm *GPUManager) updateAmdGpuData(cardPath string) bool { if usageErr == nil { gpu.Usage += usage } - gpu.MemoryUsed = bytesToMegabytes(memUsed) - gpu.MemoryTotal = bytesToMegabytes(memTotal) + gpu.MemoryUsed = utils.BytesToMegabytes(memUsed) + gpu.MemoryTotal = utils.BytesToMegabytes(memTotal) gpu.Temperature = temp gpu.Power += power gpu.Count++ diff --git a/agent/gpu_amd_linux_test.go b/agent/gpu_amd_linux_test.go index a52d8e45..76b286cc 100644 --- a/agent/gpu_amd_linux_test.go +++ b/agent/gpu_amd_linux_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "testing" + "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/system" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -128,14 +129,14 @@ func TestUpdateAmdGpuDataWithFakeSysfs(t *testing.T) { { name: "sums vram and gtt when gtt is present", writeGTT: true, - wantMemoryUsed: bytesToMegabytes(1073741824 + 536870912), - wantMemoryTotal: bytesToMegabytes(2147483648 + 4294967296), + wantMemoryUsed: utils.BytesToMegabytes(1073741824 + 536870912), + wantMemoryTotal: utils.BytesToMegabytes(2147483648 + 4294967296), }, { name: "falls back to vram when gtt is missing", writeGTT: false, - wantMemoryUsed: bytesToMegabytes(1073741824), - wantMemoryTotal: bytesToMegabytes(2147483648), + wantMemoryUsed: utils.BytesToMegabytes(1073741824), + wantMemoryTotal: utils.BytesToMegabytes(2147483648), }, } diff --git a/agent/gpu_nvtop.go b/agent/gpu_nvtop.go index 36efb42e..ce32f008 100644 --- a/agent/gpu_nvtop.go +++ b/agent/gpu_nvtop.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/system" ) @@ -80,10 +81,10 @@ func (gm *GPUManager) updateNvtopSnapshots(snapshots []nvtopSnapshot) bool { gpu.Temperature = parseNvtopNumber(*sample.Temp) } if sample.MemUsed != nil { - gpu.MemoryUsed = bytesToMegabytes(parseNvtopNumber(*sample.MemUsed)) + gpu.MemoryUsed = utils.BytesToMegabytes(parseNvtopNumber(*sample.MemUsed)) } if sample.MemTotal != nil { - gpu.MemoryTotal = bytesToMegabytes(parseNvtopNumber(*sample.MemTotal)) + gpu.MemoryTotal = utils.BytesToMegabytes(parseNvtopNumber(*sample.MemTotal)) } if sample.GpuUtil != nil { gpu.Usage += parseNvtopNumber(*sample.GpuUtil) diff --git a/agent/gpu_test.go b/agent/gpu_test.go index 3562b5d9..4cf0c1a5 100644 --- a/agent/gpu_test.go +++ b/agent/gpu_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/system" "github.com/stretchr/testify/assert" @@ -265,8 +266,8 @@ func TestParseNvtopData(t *testing.T) { assert.Equal(t, 48.0, g0.Temperature) assert.Equal(t, 5.0, g0.Usage) assert.Equal(t, 13.0, g0.Power) - assert.Equal(t, bytesToMegabytes(349372416), g0.MemoryUsed) - assert.Equal(t, bytesToMegabytes(4294967296), g0.MemoryTotal) + assert.Equal(t, utils.BytesToMegabytes(349372416), g0.MemoryUsed) + assert.Equal(t, utils.BytesToMegabytes(4294967296), g0.MemoryTotal) assert.Equal(t, 1.0, g0.Count) g1, ok := gm.GpuDataMap["n1"] @@ -275,8 +276,8 @@ func TestParseNvtopData(t *testing.T) { assert.Equal(t, 48.0, g1.Temperature) assert.Equal(t, 12.0, g1.Usage) assert.Equal(t, 9.0, g1.Power) - assert.Equal(t, bytesToMegabytes(1213784064), g1.MemoryUsed) - assert.Equal(t, bytesToMegabytes(16929173504), g1.MemoryTotal) + assert.Equal(t, utils.BytesToMegabytes(1213784064), g1.MemoryUsed) + assert.Equal(t, utils.BytesToMegabytes(16929173504), g1.MemoryTotal) assert.Equal(t, 1.0, g1.Count) } diff --git a/agent/mdraid_linux.go b/agent/mdraid_linux.go index 184d1e77..7e827470 100644 --- a/agent/mdraid_linux.go +++ b/agent/mdraid_linux.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" + "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/smart" ) @@ -42,7 +43,7 @@ func scanMdraidDevices() []*DeviceInfo { continue } mdDir := filepath.Join(blockDir, name, "md") - if !fileExists(filepath.Join(mdDir, "array_state")) { + if !utils.FileExists(filepath.Join(mdDir, "array_state")) { continue } @@ -134,24 +135,24 @@ func readMdraidHealth(blockName string) (mdraidHealth, bool) { } mdDir := filepath.Join(mdraidSysfsRoot, "block", blockName, "md") - arrayState, okState := readStringFileOK(filepath.Join(mdDir, "array_state")) + arrayState, okState := utils.ReadStringFileOK(filepath.Join(mdDir, "array_state")) if !okState { return out, false } out.arrayState = arrayState - out.level = readStringFile(filepath.Join(mdDir, "level")) - out.syncAction = readStringFile(filepath.Join(mdDir, "sync_action")) - out.syncCompleted = readStringFile(filepath.Join(mdDir, "sync_completed")) - out.syncSpeed = readStringFile(filepath.Join(mdDir, "sync_speed")) + out.level = utils.ReadStringFile(filepath.Join(mdDir, "level")) + out.syncAction = utils.ReadStringFile(filepath.Join(mdDir, "sync_action")) + out.syncCompleted = utils.ReadStringFile(filepath.Join(mdDir, "sync_completed")) + out.syncSpeed = utils.ReadStringFile(filepath.Join(mdDir, "sync_speed")) - if val, ok := readUintFile(filepath.Join(mdDir, "raid_disks")); ok { + if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "raid_disks")); ok { out.raidDisks = val } - if val, ok := readUintFile(filepath.Join(mdDir, "degraded")); ok { + if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "degraded")); ok { out.degraded = val } - if val, ok := readUintFile(filepath.Join(mdDir, "mismatch_cnt")); ok { + if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "mismatch_cnt")); ok { out.mismatchCnt = val } @@ -205,7 +206,7 @@ func readMdraidBlockCapacityBytes(blockName, root string) (uint64, bool) { sizePath := filepath.Join(root, "block", blockName, "size") lbsPath := filepath.Join(root, "block", blockName, "queue", "logical_block_size") - sizeStr, ok := readStringFileOK(sizePath) + sizeStr, ok := utils.ReadStringFileOK(sizePath) if !ok { return 0, false } @@ -215,7 +216,7 @@ func readMdraidBlockCapacityBytes(blockName, root string) (uint64, bool) { } logicalBlockSize := uint64(512) - if lbsStr, ok := readStringFileOK(lbsPath); ok { + if lbsStr, ok := utils.ReadStringFileOK(lbsPath); ok { if parsed, err := strconv.ParseUint(lbsStr, 10, 64); err == nil && parsed > 0 { logicalBlockSize = parsed } diff --git a/agent/network.go b/agent/network.go index 9b58489a..2eb00bc4 100644 --- a/agent/network.go +++ b/agent/network.go @@ -8,6 +8,7 @@ import ( "time" "github.com/henrygd/beszel/agent/deltatracker" + "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/system" psutilNet "github.com/shirou/gopsutil/v4/net" ) @@ -215,8 +216,8 @@ func (a *Agent) applyNetworkTotals( totalBytesSent, totalBytesRecv uint64, bytesSentPerSecond, bytesRecvPerSecond uint64, ) { - networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond)) - networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond)) + networkSentPs := utils.BytesToMegabytes(float64(bytesSentPerSecond)) + networkRecvPs := utils.BytesToMegabytes(float64(bytesRecvPerSecond)) if networkSentPs > 10_000 || networkRecvPs > 10_000 { slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs) for _, v := range netIO { diff --git a/agent/sensors.go b/agent/sensors.go index 22b8e5a3..9cdc4c45 100644 --- a/agent/sensors.go +++ b/agent/sensors.go @@ -10,6 +10,7 @@ import ( "strings" "unicode/utf8" + "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/system" "github.com/shirou/gopsutil/v4/common" @@ -135,7 +136,7 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) { case sensorName: a.systemInfo.DashboardTemp = sensor.Temperature } - systemStats.Temperatures[sensorName] = twoDecimals(sensor.Temperature) + systemStats.Temperatures[sensorName] = utils.TwoDecimals(sensor.Temperature) } } diff --git a/agent/system.go b/agent/system.go index 26f204d9..d15245a2 100644 --- a/agent/system.go +++ b/agent/system.go @@ -12,6 +12,7 @@ import ( "github.com/henrygd/beszel" "github.com/henrygd/beszel/agent/battery" + "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/agent/zfs" "github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/system" @@ -127,13 +128,13 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats { // cpu metrics cpuMetrics, err := getCpuMetrics(cacheTimeMs) if err == nil { - systemStats.Cpu = twoDecimals(cpuMetrics.Total) + systemStats.Cpu = utils.TwoDecimals(cpuMetrics.Total) systemStats.CpuBreakdown = []float64{ - twoDecimals(cpuMetrics.User), - twoDecimals(cpuMetrics.System), - twoDecimals(cpuMetrics.Iowait), - twoDecimals(cpuMetrics.Steal), - twoDecimals(cpuMetrics.Idle), + utils.TwoDecimals(cpuMetrics.User), + utils.TwoDecimals(cpuMetrics.System), + utils.TwoDecimals(cpuMetrics.Iowait), + utils.TwoDecimals(cpuMetrics.Steal), + utils.TwoDecimals(cpuMetrics.Idle), } } else { slog.Error("Error getting cpu metrics", "err", err) @@ -157,8 +158,8 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats { // memory if v, err := mem.VirtualMemory(); err == nil { // swap - systemStats.Swap = bytesToGigabytes(v.SwapTotal) - systemStats.SwapUsed = bytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached) + systemStats.Swap = utils.BytesToGigabytes(v.SwapTotal) + systemStats.SwapUsed = utils.BytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached) // cache + buffers value for default mem calculation // note: gopsutil automatically adds SReclaimable to v.Cached cacheBuff := v.Cached + v.Buffers - v.Shared @@ -181,13 +182,13 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats { if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < v.Used { v.Used = v.Used - arcSize v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0 - systemStats.MemZfsArc = bytesToGigabytes(arcSize) + systemStats.MemZfsArc = utils.BytesToGigabytes(arcSize) } } - systemStats.Mem = bytesToGigabytes(v.Total) - systemStats.MemBuffCache = bytesToGigabytes(cacheBuff) - systemStats.MemUsed = bytesToGigabytes(v.Used) - systemStats.MemPct = twoDecimals(v.UsedPercent) + systemStats.Mem = utils.BytesToGigabytes(v.Total) + systemStats.MemBuffCache = utils.BytesToGigabytes(cacheBuff) + systemStats.MemUsed = utils.BytesToGigabytes(v.Used) + systemStats.MemPct = utils.TwoDecimals(v.UsedPercent) } // disk usage diff --git a/agent/utils.go b/agent/utils.go deleted file mode 100644 index d2688b1a..00000000 --- a/agent/utils.go +++ /dev/null @@ -1,15 +0,0 @@ -package agent - -import "math" - -func bytesToMegabytes(b float64) float64 { - return twoDecimals(b / 1048576) -} - -func bytesToGigabytes(b uint64) float64 { - return twoDecimals(float64(b) / 1073741824) -} - -func twoDecimals(value float64) float64 { - return math.Round(value*100) / 100 -} diff --git a/agent/utils/utils.go b/agent/utils/utils.go new file mode 100644 index 00000000..32cfcd8f --- /dev/null +++ b/agent/utils/utils.go @@ -0,0 +1,62 @@ +package utils + +import ( + "math" + "os" + "strconv" + "strings" +) + +// BytesToMegabytes converts bytes to megabytes and rounds to two decimal places. +func BytesToMegabytes(b float64) float64 { + return TwoDecimals(b / 1048576) +} + +// BytesToGigabytes converts bytes to gigabytes and rounds to two decimal places. +func BytesToGigabytes(b uint64) float64 { + return TwoDecimals(float64(b) / 1073741824) +} + +// TwoDecimals rounds a float64 value to two decimal places. +func TwoDecimals(value float64) float64 { + return math.Round(value*100) / 100 +} + +// func RoundFloat(val float64, precision uint) float64 { +// ratio := math.Pow(10, float64(precision)) +// return math.Round(val*ratio) / ratio +// } + +// ReadStringFile returns trimmed file contents or empty string on error. +func ReadStringFile(path string) string { + content, _ := ReadStringFileOK(path) + return content +} + +// ReadStringFileOK returns trimmed file contents and read success. +func ReadStringFileOK(path string) (string, bool) { + b, err := os.ReadFile(path) + if err != nil { + return "", false + } + return strings.TrimSpace(string(b)), true +} + +// FileExists reports whether the given path exists. +func FileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// ReadUintFile parses a decimal uint64 value from a file. +func ReadUintFile(path string) (uint64, bool) { + raw, ok := ReadStringFileOK(path) + if !ok { + return 0, false + } + parsed, err := strconv.ParseUint(raw, 10, 64) + if err != nil { + return 0, false + } + return parsed, true +} diff --git a/agent/utils/utils_test.go b/agent/utils/utils_test.go new file mode 100644 index 00000000..eff27240 --- /dev/null +++ b/agent/utils/utils_test.go @@ -0,0 +1,130 @@ +package utils + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTwoDecimals(t *testing.T) { + tests := []struct { + name string + input float64 + expected float64 + }{ + {"round down", 1.234, 1.23}, + {"round half up", 1.235, 1.24}, // math.Round rounds half up + {"no rounding needed", 1.23, 1.23}, + {"negative number", -1.235, -1.24}, // math.Round rounds half up (more negative) + {"zero", 0.0, 0.0}, + {"large number", 123.456, 123.46}, // rounds 5 up + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := TwoDecimals(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestBytesToMegabytes(t *testing.T) { + tests := []struct { + name string + input float64 + expected float64 + }{ + {"1 MB", 1048576, 1.0}, + {"512 KB", 524288, 0.5}, + {"zero", 0, 0}, + {"large value", 1073741824, 1024}, // 1 GB = 1024 MB + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := BytesToMegabytes(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestBytesToGigabytes(t *testing.T) { + tests := []struct { + name string + input uint64 + expected float64 + }{ + {"1 GB", 1073741824, 1.0}, + {"512 MB", 536870912, 0.5}, + {"0 GB", 0, 0}, + {"2 GB", 2147483648, 2.0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := BytesToGigabytes(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFileFunctions(t *testing.T) { + tmpDir := t.TempDir() + testFilePath := filepath.Join(tmpDir, "test.txt") + testContent := "hello world" + + // Test FileExists (false) + assert.False(t, FileExists(testFilePath)) + + // Test ReadStringFileOK (false) + content, ok := ReadStringFileOK(testFilePath) + assert.False(t, ok) + assert.Empty(t, content) + + // Test ReadStringFile (empty) + assert.Empty(t, ReadStringFile(testFilePath)) + + // Write file + err := os.WriteFile(testFilePath, []byte(testContent+"\n "), 0644) + assert.NoError(t, err) + + // Test FileExists (true) + assert.True(t, FileExists(testFilePath)) + + // Test ReadStringFileOK (true) + content, ok = ReadStringFileOK(testFilePath) + assert.True(t, ok) + assert.Equal(t, testContent, content) + + // Test ReadStringFile (content) + assert.Equal(t, testContent, ReadStringFile(testFilePath)) +} + +func TestReadUintFile(t *testing.T) { + tmpDir := t.TempDir() + + t.Run("valid uint", func(t *testing.T) { + path := filepath.Join(tmpDir, "uint.txt") + os.WriteFile(path, []byte(" 12345\n"), 0644) + val, ok := ReadUintFile(path) + assert.True(t, ok) + assert.Equal(t, uint64(12345), val) + }) + + t.Run("invalid uint", func(t *testing.T) { + path := filepath.Join(tmpDir, "invalid.txt") + os.WriteFile(path, []byte("abc"), 0644) + val, ok := ReadUintFile(path) + assert.False(t, ok) + assert.Equal(t, uint64(0), val) + }) + + t.Run("missing file", func(t *testing.T) { + path := filepath.Join(tmpDir, "missing.txt") + val, ok := ReadUintFile(path) + assert.False(t, ok) + assert.Equal(t, uint64(0), val) + }) +}