Files
beszel-ipv6/agent/system.go
Sven van Ginkel d71714cbba [Feature] Expand system info bar to include memory, disk, CPU, and OS details (#952)
* collect OS info

* Fix systeminfo

* Fix it

* optimize it

* Add disk info

* add ethernet info

* add ethernet

* remove speed from ethernet

* add cpu info

* chore cleanup data

* chore fix podman

* restruct systeminfo

* use short cpu name

* debug memory

* collect and show memory

* remove os from the table

* truncate nic name

* chore: shorter names in json

* collect memory info

* add debug

* undo memory

* revert package.json

* fix conflicts

* fix conflixts

* Fix MacOs os family

* add ISP data for remote systems

* reorder the system page bar information

* remove OS from the system table

* Update with main

* Fix vulcheck

* Fix systembar

* fix system bar

* fix vulcheck

* update struct with static info

* Adjust collection method to upon agent connection
2025-12-13 16:11:31 -05:00

449 lines
12 KiB
Go

package agent
import (
"bufio"
"fmt"
"log/slog"
"os"
"runtime"
"strconv"
"strings"
"time"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/agent/battery"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/jaypipes/ghw/pkg/block"
ghwnet "github.com/jaypipes/ghw/pkg/net"
ghwpci "github.com/jaypipes/ghw/pkg/pci"
"github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/host"
"github.com/shirou/gopsutil/v4/load"
"github.com/shirou/gopsutil/v4/mem"
)
// prevDisk stores previous per-device disk counters for a given cache interval
type prevDisk struct {
readBytes uint64
writeBytes uint64
at time.Time
}
// Sets initial / non-changing values about the host system
func (a *Agent) initializeSystemInfo() {
hostname, _ := os.Hostname()
a.staticSystemInfo.Hostname = hostname
a.staticSystemInfo.AgentVersion = beszel.Version
platform, family, version, _ := host.PlatformInformation()
var osFamily, osVersion, osKernel string
var osType system.Os
if platform == "darwin" {
osKernel = version
osFamily = "macOS" // macOS is the family name for Darwin
osVersion = version
} else if strings.Contains(platform, "indows") {
osKernel = strings.Replace(platform, "Microsoft ", "", 1) + " " + version
osFamily = family
osVersion = version
osType = system.Windows
} else if platform == "freebsd" {
osKernel = version
osFamily = family
osVersion = version
} else {
osFamily = family
osVersion = version
osKernel = ""
osRelease := readOsRelease()
if pretty, ok := osRelease["PRETTY_NAME"]; ok {
osFamily = pretty
}
if name, ok := osRelease["NAME"]; ok {
osFamily = name
}
if versionId, ok := osRelease["VERSION_ID"]; ok {
osVersion = versionId
}
}
if osKernel == "" {
osKernel, _ = host.KernelVersion()
}
a.staticSystemInfo.KernelVersion = osKernel
a.staticSystemInfo.Os = osType
a.staticSystemInfo.Oses = []system.OsInfo{{
Family: osFamily,
Version: osVersion,
Kernel: osKernel,
}}
// cpu model
if info, err := cpu.Info(); err == nil && len(info) > 0 {
arch := runtime.GOARCH
totalCores := 0
totalThreads := 0
for _, cpuInfo := range info {
totalCores += int(cpuInfo.Cores)
totalThreads++
}
modelName := info[0].ModelName
if idx := strings.Index(modelName, "@"); idx > 0 {
modelName = strings.TrimSpace(modelName[:idx])
}
cpu := system.CpuInfo{
Model: modelName,
SpeedGHz: fmt.Sprintf("%.2f GHz", info[0].Mhz/1000),
Arch: arch,
Cores: totalCores,
Threads: totalThreads,
}
a.staticSystemInfo.Cpus = []system.CpuInfo{cpu}
a.staticSystemInfo.Threads = totalThreads
slog.Debug("CPU info populated", "cpus", a.staticSystemInfo.Cpus)
}
// zfs
if _, err := getARCSize(); err != nil {
slog.Debug("Not monitoring ZFS ARC", "err", err)
} else {
a.zfs = true
}
// Collect disk info (model/vendor)
a.staticSystemInfo.Disks = getDiskInfo()
// Collect network interface info
a.staticSystemInfo.Networks = getNetworkInfo()
// Collect total memory and store in staticSystemInfo.Memory
if v, err := mem.VirtualMemory(); err == nil {
total := fmt.Sprintf("%d GB", int((float64(v.Total)/(1024*1024*1024))+0.5))
a.staticSystemInfo.Memory = []system.MemoryInfo{{Total: total}}
slog.Debug("Memory info populated", "memory", a.staticSystemInfo.Memory)
}
}
// readPrettyName reads the PRETTY_NAME from /etc/os-release
func readPrettyName() string {
file, err := os.Open("/etc/os-release")
if err != nil {
return ""
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "PRETTY_NAME=") {
// Remove the prefix and any surrounding quotes
prettyName := strings.TrimPrefix(line, "PRETTY_NAME=")
prettyName = strings.Trim(prettyName, `"`)
return prettyName
}
}
return ""
}
// Returns current info, stats about the host system
func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
var systemStats system.Stats
// battery
if batteryPercent, batteryState, err := battery.GetBatteryStats(); err == nil {
systemStats.Battery[0] = batteryPercent
systemStats.Battery[1] = batteryState
}
// cpu metrics
cpuMetrics, err := getCpuMetrics(cacheTimeMs)
if err == nil {
systemStats.Cpu = twoDecimals(cpuMetrics.Total)
systemStats.CpuBreakdown = []float64{
twoDecimals(cpuMetrics.User),
twoDecimals(cpuMetrics.System),
twoDecimals(cpuMetrics.Iowait),
twoDecimals(cpuMetrics.Steal),
twoDecimals(cpuMetrics.Idle),
}
} else {
slog.Error("Error getting cpu metrics", "err", err)
}
// per-core cpu usage
if perCoreUsage, err := getPerCoreCpuUsage(cacheTimeMs); err == nil {
systemStats.CpuCoresUsage = perCoreUsage
}
// load average
if avgstat, err := load.Avg(); err == nil {
systemStats.LoadAvg[0] = avgstat.Load1
systemStats.LoadAvg[1] = avgstat.Load5
systemStats.LoadAvg[2] = avgstat.Load15
slog.Debug("Load average", "5m", avgstat.Load5, "15m", avgstat.Load15)
} else {
slog.Error("Error getting load average", "err", err)
}
// memory
if v, err := mem.VirtualMemory(); err == nil {
// swap
systemStats.Swap = bytesToGigabytes(v.SwapTotal)
systemStats.SwapUsed = 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
if cacheBuff <= 0 {
cacheBuff = max(v.Total-v.Free-v.Used, 0)
}
// htop memory calculation overrides (likely outdated as of mid 2025)
if a.memCalc == "htop" {
// cacheBuff = v.Cached + v.Buffers - v.Shared
v.Used = v.Total - (v.Free + cacheBuff)
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
}
// if a.memCalc == "legacy" {
// v.Used = v.Total - v.Free - v.Buffers - v.Cached
// cacheBuff = v.Total - v.Free - v.Used
// v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
// }
// subtract ZFS ARC size from used memory and add as its own category
if a.zfs {
if arcSize, _ := getARCSize(); 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.Mem = bytesToGigabytes(v.Total)
systemStats.MemBuffCache = bytesToGigabytes(cacheBuff)
systemStats.MemUsed = bytesToGigabytes(v.Used)
systemStats.MemPct = twoDecimals(v.UsedPercent)
}
// disk usage
a.updateDiskUsage(&systemStats)
// disk i/o (cache-aware per interval)
a.updateDiskIo(cacheTimeMs, &systemStats)
// network stats (per cache interval)
a.updateNetworkStats(cacheTimeMs, &systemStats)
// temperatures
// TODO: maybe refactor to methods on systemStats
a.updateTemperatures(&systemStats)
// GPU data
if a.gpuManager != nil {
// reset high gpu percent
a.systemInfo.GpuPct = 0
// get current GPU data
if gpuData := a.gpuManager.GetCurrentData(cacheTimeMs); len(gpuData) > 0 {
systemStats.GPUData = gpuData
// add temperatures
if systemStats.Temperatures == nil {
systemStats.Temperatures = make(map[string]float64, len(gpuData))
}
highestTemp := 0.0
for _, gpu := range gpuData {
if gpu.Temperature > 0 {
systemStats.Temperatures[gpu.Name] = gpu.Temperature
if a.sensorConfig.primarySensor == gpu.Name {
a.systemInfo.DashboardTemp = gpu.Temperature
}
if gpu.Temperature > highestTemp {
highestTemp = gpu.Temperature
}
}
// update high gpu percent for dashboard
a.systemInfo.GpuPct = max(a.systemInfo.GpuPct, gpu.Usage)
}
// use highest temp for dashboard temp if dashboard temp is unset
if a.systemInfo.DashboardTemp == 0 {
a.systemInfo.DashboardTemp = highestTemp
}
}
}
// update base system info
a.systemInfo.ConnectionType = a.connectionManager.ConnectionType
a.systemInfo.Cpu = systemStats.Cpu
a.systemInfo.LoadAvg = systemStats.LoadAvg
// TODO: remove these in future release in favor of load avg array
a.systemInfo.LoadAvg1 = systemStats.LoadAvg[0]
a.systemInfo.LoadAvg5 = systemStats.LoadAvg[1]
a.systemInfo.LoadAvg15 = systemStats.LoadAvg[2]
a.systemInfo.MemPct = systemStats.MemPct
a.systemInfo.DiskPct = systemStats.DiskPct
a.systemInfo.Battery = systemStats.Battery
a.systemInfo.Uptime, _ = host.Uptime()
// TODO: in future release, remove MB bandwidth values in favor of bytes
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
slog.Debug("sysinfo", "data", a.systemInfo)
return systemStats
}
// Returns the size of the ZFS ARC memory cache in bytes
func getARCSize() (uint64, error) {
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
if err != nil {
return 0, err
}
defer file.Close()
// Scan the lines
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "size") {
// Example line: size 4 15032385536
fields := strings.Fields(line)
if len(fields) < 3 {
return 0, err
}
// Return the size as uint64
return strconv.ParseUint(fields[2], 10, 64)
}
}
return 0, fmt.Errorf("failed to parse size field")
}
func getDiskInfo() []system.DiskInfo {
blockInfo, err := block.New()
if err != nil {
slog.Debug("Failed to get block info with ghw", "err", err)
return nil
}
var disks []system.DiskInfo
for _, disk := range blockInfo.Disks {
disks = append(disks, system.DiskInfo{
Name: disk.Name,
Model: disk.Model,
Vendor: disk.Vendor,
})
}
return disks
}
func getNetworkInfo() []system.NetworkInfo {
netInfo, err := ghwnet.New()
if err != nil {
slog.Debug("Failed to get network info with ghw", "err", err)
return nil
}
pciInfo, err := ghwpci.New()
if err != nil {
slog.Debug("Failed to get PCI info with ghw", "err", err)
}
var networks []system.NetworkInfo
for _, nic := range netInfo.NICs {
if nic.IsVirtual {
continue
}
var vendor, model string
if nic.PCIAddress != nil && pciInfo != nil {
for _, dev := range pciInfo.Devices {
if dev.Address == *nic.PCIAddress {
if dev.Vendor != nil {
vendor = dev.Vendor.Name
}
if dev.Product != nil {
model = dev.Product.Name
}
break
}
}
}
networks = append(networks, system.NetworkInfo{
Name: nic.Name,
Vendor: vendor,
Model: model,
})
}
return networks
}
// getInterfaceCapabilitiesFromGhw uses ghw library to get interface capabilities
func getInterfaceCapabilitiesFromGhw(nic *ghwnet.NIC) string {
// Use the speed information from ghw if available
if nic.Speed != "" {
return nic.Speed
}
// If no speed info from ghw, try to get interface type from name
return getInterfaceTypeFromName(nic.Name)
}
// getInterfaceTypeFromName tries to determine interface type from name
func getInterfaceTypeFromName(ifaceName string) string {
// Common interface naming patterns
switch {
case strings.HasPrefix(ifaceName, "eth"):
return "Ethernet"
case strings.HasPrefix(ifaceName, "en"):
return "Ethernet"
case strings.HasPrefix(ifaceName, "wlan"):
return "WiFi"
case strings.HasPrefix(ifaceName, "wl"):
return "WiFi"
case strings.HasPrefix(ifaceName, "usb"):
return "USB"
case strings.HasPrefix(ifaceName, "tun"):
return "Tunnel"
case strings.HasPrefix(ifaceName, "tap"):
return "TAP"
case strings.HasPrefix(ifaceName, "br"):
return "Bridge"
case strings.HasPrefix(ifaceName, "bond"):
return "Bond"
case strings.HasPrefix(ifaceName, "veth"):
return "Virtual Ethernet"
case strings.HasPrefix(ifaceName, "docker"):
return "Docker"
case strings.HasPrefix(ifaceName, "lo"):
return "Loopback"
default:
return ""
}
}
func readOsRelease() map[string]string {
file, err := os.Open("/etc/os-release")
if err != nil {
return map[string]string{}
}
defer file.Close()
release := make(map[string]string)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if i := strings.Index(line, "="); i > 0 {
key := line[:i]
val := strings.Trim(line[i+1:], `"`)
release[key] = val
}
}
return release
}
func getMemoryInfo() []system.MemoryInfo {
var total string
if v, err := mem.VirtualMemory(); err == nil {
total = fmt.Sprintf("%d GB", int((float64(v.Total)/(1024*1024*1024))+0.5))
}
return []system.MemoryInfo{{
Total: total,
}}
}