From d71714cbba0c4a4753459bf30f76e43b6c9b0ec9 Mon Sep 17 00:00:00 2001 From: Sven van Ginkel Date: Sat, 13 Dec 2025 22:11:31 +0100 Subject: [PATCH] [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 --- agent/agent.go | 25 +- agent/client.go | 2 +- agent/docker.go | 2 +- agent/server_test.go | 3 - agent/system.go | 254 ++++++++++++++++-- go.mod | 5 + go.sum | 11 + internal/entities/system/system.go | 79 ++++-- internal/hub/systems/system.go | 90 ++++++- .../site/src/components/routes/system.tsx | 61 ++++- internal/site/src/types.d.ts | 34 ++- 11 files changed, 504 insertions(+), 62 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index 5dcd3b60..dbf69f5a 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -22,6 +22,14 @@ import ( gossh "golang.org/x/crypto/ssh" ) +const ( + // StaticInfoIntervalMs defines the cache time threshold for including static system info + // Requests with cache time >= this value will include static info (reduces bandwidth) + // Note: uint16 max is 65535, so we can't use 15 minutes directly. The hub will make + // periodic requests at this interval. + StaticInfoIntervalMs uint16 = 60_001 // Just above the standard 60s interval +) + type Agent struct { sync.Mutex // Used to lock agent while collecting data debug bool // true if LOG_LEVEL is set to debug @@ -37,7 +45,8 @@ type Agent struct { netInterfaceDeltaTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] // Per-cache-time NIC delta trackers dockerManager *dockerManager // Manages Docker API requests sensorConfig *SensorConfig // Sensors config - systemInfo system.Info // Host system info + systemInfo system.Info // Host system info (dynamic dashboard data) + staticSystemInfo system.StaticInfo // Static system info (collected at longer intervals) gpuManager *GPUManager // Manages GPU data cache *systemDataCache // Cache for system stats based on cache time connectionManager *ConnectionManager // Channel to signal connection events @@ -164,6 +173,14 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData { } // slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs) + // Include static info for requests with longer intervals (e.g., 15 min) + // This reduces bandwidth by only sending static data occasionally + if cacheTimeMs >= StaticInfoIntervalMs { + staticInfoCopy := a.staticSystemInfo + data.StaticInfo = &staticInfoCopy + slog.Debug("Including static info", "cacheTimeMs", cacheTimeMs) + } + if a.dockerManager != nil { if containerStats, err := a.dockerManager.getDockerStats(cacheTimeMs); err == nil { data.Containers = containerStats @@ -225,7 +242,11 @@ func (a *Agent) getFingerprint() string { // if no fingerprint is found, generate one fingerprint, err := host.HostID() if err != nil || fingerprint == "" { - fingerprint = a.systemInfo.Hostname + a.systemInfo.CpuModel + cpuModel := "" + if len(a.staticSystemInfo.Cpus) > 0 { + cpuModel = a.staticSystemInfo.Cpus[0].Model + } + fingerprint = a.staticSystemInfo.Hostname + cpuModel } // hash fingerprint diff --git a/agent/client.go b/agent/client.go index 48a965b9..f75a8e22 100644 --- a/agent/client.go +++ b/agent/client.go @@ -201,7 +201,7 @@ func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.R if authRequest.NeedSysInfo { response.Name, _ = GetEnv("SYSTEM_NAME") - response.Hostname = client.agent.systemInfo.Hostname + response.Hostname = client.agent.staticSystemInfo.Hostname serverAddr := client.agent.connectionManager.serverOptions.Addr _, response.Port, _ = net.SplitHostPort(serverAddr) } diff --git a/agent/docker.go b/agent/docker.go index fb306b0d..77ec4062 100644 --- a/agent/docker.go +++ b/agent/docker.go @@ -564,7 +564,7 @@ func newDockerManager(a *Agent) *dockerManager { // If using podman, return client if strings.Contains(dockerHost, "podman") { - a.systemInfo.Podman = true + a.staticSystemInfo.Podman = true manager.goodDockerVersion = true return manager } diff --git a/agent/server_test.go b/agent/server_test.go index bfee84e5..b092a84b 100644 --- a/agent/server_test.go +++ b/agent/server_test.go @@ -552,11 +552,8 @@ func createTestCombinedData() *system.CombinedData { }, Info: system.Info{ Hostname: "test-host", - Cores: 8, - CpuModel: "Test CPU Model", Uptime: 3600, AgentVersion: "0.12.0", - Os: system.Linux, }, Containers: []*container.Stats{ { diff --git a/agent/system.go b/agent/system.go index f845b956..f54a3d71 100644 --- a/agent/system.go +++ b/agent/system.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "os" + "runtime" "strconv" "strings" "time" @@ -12,7 +13,9 @@ import ( "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" @@ -28,41 +31,76 @@ type prevDisk struct { // Sets initial / non-changing values about the host system func (a *Agent) initializeSystemInfo() { - a.systemInfo.AgentVersion = beszel.Version - a.systemInfo.Hostname, _ = os.Hostname() + hostname, _ := os.Hostname() + a.staticSystemInfo.Hostname = hostname + a.staticSystemInfo.AgentVersion = beszel.Version - platform, _, version, _ := host.PlatformInformation() + platform, family, version, _ := host.PlatformInformation() + var osFamily, osVersion, osKernel string + var osType system.Os if platform == "darwin" { - a.systemInfo.KernelVersion = version - a.systemInfo.Os = system.Darwin + osKernel = version + osFamily = "macOS" // macOS is the family name for Darwin + osVersion = version } else if strings.Contains(platform, "indows") { - a.systemInfo.KernelVersion = fmt.Sprintf("%s %s", strings.Replace(platform, "Microsoft ", "", 1), version) - a.systemInfo.Os = system.Windows + osKernel = strings.Replace(platform, "Microsoft ", "", 1) + " " + version + osFamily = family + osVersion = version + osType = system.Windows } else if platform == "freebsd" { - a.systemInfo.Os = system.Freebsd - a.systemInfo.KernelVersion = version + osKernel = version + osFamily = family + osVersion = version } else { - a.systemInfo.Os = system.Linux + 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 a.systemInfo.KernelVersion == "" { - a.systemInfo.KernelVersion, _ = host.KernelVersion() + 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 { - a.systemInfo.CpuModel = info[0].ModelName - } - // cores / threads - a.systemInfo.Cores, _ = cpu.Counts(false) - if threads, err := cpu.Counts(true); err == nil { - if threads > 0 && threads < a.systemInfo.Cores { - // in lxc logical cores reflects container limits, so use that as cores if lower - a.systemInfo.Cores = threads - } else { - a.systemInfo.Threads = threads + 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 @@ -71,6 +109,41 @@ func (a *Agent) initializeSystemInfo() { } 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 @@ -240,3 +313,136 @@ func getARCSize() (uint64, error) { 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, + }} +} + diff --git a/go.mod b/go.mod index 2b38fda3..eee9cbfb 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/fxamacker/cbor/v2 v2.9.0 github.com/gliderlabs/ssh v0.3.8 github.com/google/uuid v1.6.0 + github.com/jaypipes/ghw v0.17.0 github.com/lxzan/gws v1.8.9 github.com/nicholas-fedor/shoutrrr v0.12.1 github.com/pocketbase/dbx v1.11.0 @@ -24,6 +25,7 @@ require ( ) require ( + github.com/StackExchange/wmi v1.2.1 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -41,11 +43,14 @@ require ( github.com/godbus/dbus/v5 v5.2.0 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jaypipes/pcidb v1.0.1 // indirect github.com/klauspost/compress v1.18.1 // indirect github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect diff --git a/go.sum b/go.sum index a7697a0b..48cbf46f 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= +github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= @@ -41,6 +43,7 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= @@ -68,6 +71,10 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A= github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= +github.com/jaypipes/ghw v0.17.0 h1:EVLJeNcy5z6GK/Lqby0EhBpynZo+ayl8iJWY0kbEUJA= +github.com/jaypipes/ghw v0.17.0/go.mod h1:In8SsaDqlb1oTyrbmTC14uy+fbBMvp+xdqX51MidlD8= +github.com/jaypipes/pcidb v1.0.1 h1:WB2zh27T3nwg8AE8ei81sNRb9yWBii3JGNJtT7K9Oic= +github.com/jaypipes/pcidb v1.0.1/go.mod h1:6xYUz/yYEyOkIkUt2t2J2folIuZ4Yg6uByCGFXMCeE4= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= @@ -83,6 +90,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nicholas-fedor/shoutrrr v0.12.1 h1:8NjY+I3K7cGHy89ncnaPGUA0ex44XbYK3SAFJX9YMI8= @@ -91,6 +100,8 @@ github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/internal/entities/system/system.go b/internal/entities/system/system.go index 0649666f..fe72eea8 100644 --- a/internal/entities/system/system.go +++ b/internal/entities/system/system.go @@ -115,6 +115,37 @@ const ( Freebsd ) +type DiskInfo struct { + Name string `json:"n"` + Model string `json:"m,omitempty"` + Vendor string `json:"v,omitempty"` +} + +type NetworkInfo struct { + Name string `json:"n"` + Vendor string `json:"v,omitempty"` + Model string `json:"m,omitempty"` + Speed string `json:"s,omitempty"` +} + +type MemoryInfo struct { + Total string `json:"t,omitempty"` +} + +type CpuInfo struct { + Model string `json:"m"` + SpeedGHz string `json:"s"` + Arch string `json:"a"` + Cores int `json:"c"` + Threads int `json:"t"` +} + +type OsInfo struct { + Family string `json:"f"` + Version string `json:"v"` + Kernel string `json:"k"` +} + type ConnectionType = uint8 const ( @@ -123,26 +154,35 @@ const ( ConnectionTypeWebSocket ) +// StaticInfo contains system information that rarely or never changes +// This is collected at a longer interval (e.g., 10-15 minutes) to reduce bandwidth +type StaticInfo struct { + Hostname string `json:"h" cbor:"0,keyasint"` + KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"` + Threads int `json:"t,omitempty" cbor:"2,keyasint,omitempty"` + AgentVersion string `json:"v" cbor:"3,keyasint"` + Podman bool `json:"p,omitempty" cbor:"4,keyasint,omitempty"` + Os Os `json:"os" cbor:"5,keyasint"` + Disks []DiskInfo `json:"d,omitempty" cbor:"6,omitempty"` + Networks []NetworkInfo `json:"n,omitempty" cbor:"7,omitempty"` + Memory []MemoryInfo `json:"m" cbor:"8"` + Cpus []CpuInfo `json:"c" cbor:"9"` + Oses []OsInfo `json:"o,omitempty" cbor:"10,omitempty"` +} + +// Info contains frequently-changing system snapshot data for the dashboard type Info struct { - Hostname string `json:"h" cbor:"0,keyasint"` - KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"` - Cores int `json:"c" cbor:"2,keyasint"` - Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"` - CpuModel string `json:"m" cbor:"4,keyasint"` - Uptime uint64 `json:"u" cbor:"5,keyasint"` - Cpu float64 `json:"cpu" cbor:"6,keyasint"` - MemPct float64 `json:"mp" cbor:"7,keyasint"` - DiskPct float64 `json:"dp" cbor:"8,keyasint"` - Bandwidth float64 `json:"b" cbor:"9,keyasint"` - AgentVersion string `json:"v" cbor:"10,keyasint"` - Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"` - GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"` - DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"` - Os Os `json:"os" cbor:"14,keyasint"` - LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"` - LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"` - LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"` - BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"` + Uptime uint64 `json:"u" cbor:"0,keyasint"` + Cpu float64 `json:"cpu" cbor:"1,keyasint"` + MemPct float64 `json:"mp" cbor:"2,keyasint"` + DiskPct float64 `json:"dp" cbor:"3,keyasint"` + Bandwidth float64 `json:"b" cbor:"4,keyasint"` + GpuPct float64 `json:"g,omitempty" cbor:"5,keyasint,omitempty"` + DashboardTemp float64 `json:"dt,omitempty" cbor:"6,keyasint,omitempty"` + LoadAvg1 float64 `json:"l1,omitempty" cbor:"7,keyasint,omitempty"` + LoadAvg5 float64 `json:"l5,omitempty" cbor:"8,keyasint,omitempty"` + LoadAvg15 float64 `json:"l15,omitempty" cbor:"9,keyasint,omitempty"` + BandwidthBytes uint64 `json:"bb" cbor:"10,keyasint"` // TODO: remove load fields in future release in favor of load avg array LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"` ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"` @@ -157,4 +197,5 @@ type CombinedData struct { Info Info `json:"info" cbor:"1,keyasint"` Containers []*container.Stats `json:"container" cbor:"2,keyasint"` SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"` + StaticInfo *StaticInfo `json:"static_info,omitempty" cbor:"4,keyasint,omitempty"` // Collected at longer intervals } diff --git a/internal/hub/systems/system.go b/internal/hub/systems/system.go index f18704aa..c0218b92 100644 --- a/internal/hub/systems/system.go +++ b/internal/hub/systems/system.go @@ -36,6 +36,7 @@ type System struct { manager *SystemManager // Manager that this system belongs to client *ssh.Client // SSH client for fetching data data *system.CombinedData // system data from agent + staticInfo *system.StaticInfo // cached static system info, fetched once per connection ctx context.Context // Context for stopping the updater cancel context.CancelFunc // Stops and removes system from updater WsConn *ws.WsConn // Handler for agent WebSocket connection @@ -114,8 +115,22 @@ func (sys *System) update() error { sys.handlePaused() return nil } - data, err := sys.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: uint16(interval)}) + + // Determine which cache time to use based on whether we need static info + cacheTimeMs := uint16(interval) + if sys.staticInfo == nil { + // Request with a cache time that signals the agent to include static info + // We use 60001ms (just above the standard interval) since uint16 max is 65535 + cacheTimeMs = 60_001 + } + + data, err := sys.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: cacheTimeMs}) if err == nil { + // If we received static info, cache it + if data.StaticInfo != nil { + sys.staticInfo = data.StaticInfo + sys.manager.hub.Logger().Debug("Cached static system info", "system", sys.Id) + } _, err = sys.createRecords(data) } return err @@ -136,6 +151,11 @@ func (sys *System) handlePaused() { // createRecords updates the system record and adds system_stats and container_stats records func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error) { + // Build complete info combining dynamic and static data + completeInfo := sys.buildCompleteInfo(data) + + sys.manager.hub.Logger().Debug("Creating records - complete info", "info", completeInfo) + systemRecord, err := sys.getRecord() if err != nil { return nil, err @@ -186,7 +206,7 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error // update system record (do this last because it triggers alerts and we need above records to be inserted first) systemRecord.Set("status", up) - systemRecord.Set("info", data.Info) + systemRecord.Set("info", completeInfo) if err := txApp.SaveNoValidate(systemRecord); err != nil { return err } @@ -203,6 +223,70 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error return systemRecord, err } +// buildCompleteInfo combines the dynamic Info with cached StaticInfo to create a complete system info structure +// This is needed because we've split the original Info structure for bandwidth optimization +func (sys *System) buildCompleteInfo(data *system.CombinedData) map[string]interface{} { + info := make(map[string]interface{}) + + // Add dynamic fields from data.Info + if data.Info.Uptime > 0 { + info["u"] = data.Info.Uptime + } + info["cpu"] = data.Info.Cpu + info["mp"] = data.Info.MemPct + info["dp"] = data.Info.DiskPct + info["b"] = data.Info.Bandwidth + info["bb"] = data.Info.BandwidthBytes + if data.Info.GpuPct > 0 { + info["g"] = data.Info.GpuPct + } + if data.Info.DashboardTemp > 0 { + info["dt"] = data.Info.DashboardTemp + } + if data.Info.LoadAvg1 > 0 || data.Info.LoadAvg5 > 0 || data.Info.LoadAvg15 > 0 { + info["l1"] = data.Info.LoadAvg1 + info["l5"] = data.Info.LoadAvg5 + info["l15"] = data.Info.LoadAvg15 + info["la"] = data.Info.LoadAvg + } + if data.Info.ConnectionType > 0 { + info["ct"] = data.Info.ConnectionType + } + + // Add static fields from cached staticInfo + if sys.staticInfo != nil { + info["h"] = sys.staticInfo.Hostname + if sys.staticInfo.KernelVersion != "" { + info["k"] = sys.staticInfo.KernelVersion + } + if sys.staticInfo.Threads > 0 { + info["t"] = sys.staticInfo.Threads + } + info["v"] = sys.staticInfo.AgentVersion + if sys.staticInfo.Podman { + info["p"] = true + } + info["os"] = sys.staticInfo.Os + if len(sys.staticInfo.Cpus) > 0 { + info["c"] = sys.staticInfo.Cpus + } + if len(sys.staticInfo.Memory) > 0 { + info["m"] = sys.staticInfo.Memory + } + if len(sys.staticInfo.Disks) > 0 { + info["d"] = sys.staticInfo.Disks + } + if len(sys.staticInfo.Networks) > 0 { + info["n"] = sys.staticInfo.Networks + } + if len(sys.staticInfo.Oses) > 0 { + info["o"] = sys.staticInfo.Oses + } + } + + return info +} + func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId string) error { if len(data) == 0 { return nil @@ -602,6 +686,7 @@ func (sys *System) closeSSHConnection() { sys.client.Close() sys.client = nil } + sys.staticInfo = nil } // closeWebSocketConnection closes the WebSocket connection but keeps the system in the manager @@ -611,6 +696,7 @@ func (sys *System) closeWebSocketConnection() { if sys.WsConn != nil { sys.WsConn.Close(nil) } + sys.staticInfo = nil } // extractAgentVersion extracts the beszel version from SSH server version string diff --git a/internal/site/src/components/routes/system.tsx b/internal/site/src/components/routes/system.tsx index 8feec020..86b5ed3f 100644 --- a/internal/site/src/components/routes/system.tsx +++ b/internal/site/src/components/routes/system.tsx @@ -8,8 +8,10 @@ import { ClockArrowUp, CpuIcon, GlobeIcon, + HardDriveIcon, LayoutGridIcon, MonitorIcon, + ServerIcon, XIcon, } from "lucide-react" import { subscribeKeys } from "nanostores" @@ -66,7 +68,7 @@ import { $router, navigate } from "../router" import Spinner from "../spinner" import { Button } from "../ui/button" import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card" -import { AppleIcon, ChartAverage, ChartMax, FreeBsdIcon, Rows, TuxIcon, WebSocketIcon, WindowsIcon } from "../ui/icons" +import { AppleIcon, ChartAverage, ChartMax, EthernetIcon, FreeBsdIcon, Rows, TuxIcon, WebSocketIcon, WindowsIcon } from "../ui/icons" import { Input } from "../ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select" import { Separator } from "../ui/separator" @@ -333,6 +335,20 @@ export default memo(function SystemDetail({ id }: { id: string }) { }) }, [system, chartTime]) + // Helper to format hardware info (disk/nic) with vendor and model + const formatHardwareInfo = useCallback((item: { n: string; v?: string; m?: string }) => { + const vendor = item.v && item.v.toLowerCase() !== 'unknown' ? item.v : null + const model = item.m && item.m.toLowerCase() !== 'unknown' ? item.m : null + if (vendor && model) { + return `${item.n}: ${vendor} ${model}` + } else if (model) { + return `${item.n}: ${model}` + } else if (vendor) { + return `${item.n}: ${vendor}` + } + return item.n + }, []) + // values for system info bar const systemInfo = useMemo(() => { if (!system.info) { @@ -366,6 +382,11 @@ export default memo(function SystemDetail({ id }: { id: string }) { } else { uptime = secondsToString(system.info.u, "day") } + // Extract CPU and Memory info from arrays + const cpuInfo = system.info.c && system.info.c.length > 0 ? system.info.c[0] : undefined + const memoryInfo = system.info.m && system.info.m.length > 0 ? system.info.m[0] : undefined + const osData = system.info.o && system.info.o.length > 0 ? system.info.o[0] : undefined + return [ { value: getHostDisplayValue(system), Icon: GlobeIcon }, { @@ -376,19 +397,43 @@ export default memo(function SystemDetail({ id }: { id: string }) { hide: system.info.h === system.host || system.info.h === system.name, }, { value: uptime, Icon: ClockArrowUp, label: t`Uptime`, hide: !system.info.u }, - osInfo[system.info.os ?? Os.Linux], - { - value: `${system.info.m} (${system.info.c}c${system.info.t ? `/${system.info.t}t` : ""})`, + osData ? { + value: `${osData.f} ${osData.v}`.trim(), + Icon: osInfo[system.info.os ?? Os.Linux]?.Icon ?? TuxIcon, + label: osData.k ? `Kernel: ${osData.k}` : undefined, + } : osInfo[system.info.os ?? Os.Linux], + cpuInfo ? { + value: cpuInfo.m, Icon: CpuIcon, - hide: !system.info.m, - }, - ] as { + hide: !cpuInfo.m, + label: [ + (cpuInfo.c || cpuInfo.t) ? `Cores / Threads: ${cpuInfo.c || '?'} / ${cpuInfo.t || cpuInfo.c || '?'}` : null, + cpuInfo.a ? `Arch: ${cpuInfo.a}` : null, + cpuInfo.s ? `Speed: ${cpuInfo.s}` : null, + ].filter(Boolean).join('\n'), + } : undefined, + memoryInfo ? { + value: memoryInfo.t, + Icon: ServerIcon, + label: "Total Memory", + } : undefined, + system.info.d && system.info.d.length > 0 ? { + value: `${system.info.d.length} ${system.info.d.length === 1 ? t`Disk` : t`Disks`}`, + Icon: HardDriveIcon, + label: system.info.d.map(formatHardwareInfo).join('\n'), + } : undefined, + system.info.n && system.info.n.length > 0 ? { + value: `${system.info.n.length} ${system.info.n.length === 1 ? t`NIC` : t`NICs`}`, + Icon: EthernetIcon, + label: system.info.n.map(formatHardwareInfo).join('\n'), + } : undefined, + ].filter(Boolean) as { value: string | number | undefined label?: string Icon: React.ElementType hide?: boolean }[] - }, [system, t]) + }, [system, t, formatHardwareInfo]) /** Space for tooltip if more than 10 sensors and no containers table */ useEffect(() => { diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index 682e6960..48704905 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -27,12 +27,32 @@ export interface SystemRecord extends RecordModel { host: string status: "up" | "down" | "paused" | "pending" port: string - info: SystemInfo + info: systemInfo v: string updated: string } -export interface SystemInfo { +export interface CpuInfo { + m: string + s: string + a: string + c: number + t: number +} + +export interface OsInfo { + f: string + v: string + k: string +} + +export interface NetworkLocationInfo { + ip?: string + isp?: string + asn?: string +} + +export interface systemInfo { /** hostname */ h: string /** kernel **/ @@ -75,6 +95,16 @@ export interface SystemInfo { g?: number /** dashboard display temperature */ dt?: number + /** disks info (array of block devices with model/vendor/serial) */ + d?: { n: string; m?: string; v?: string; serial?: string }[] + /** networks info (array of network interfaces with vendor/model/capabilities) */ + n?: { n: string; v?: string; m?: string; s?: string }[] + /** memory info (array with total property) */ + m?: { t: string }[] + /** cpu info (array of cpu objects) */ + c?: CpuInfo[] + /** os info (array of os objects) */ + o?: OsInfo[] /** operating system */ os?: Os /** connection type */