Compare commits

..

8 Commits

Author SHA1 Message Date
henrygd
330d375997 change to atomic.bool for fetching details / smart 2025-12-18 15:02:59 -05:00
henrygd
8627e3ee97 updates 2025-12-18 12:34:11 -05:00
henrygd
5d04ee5a65 consolidate info bar data 2025-12-17 19:03:31 -05:00
henrygd
d93067ec34 updates 2025-12-17 17:32:59 -05:00
henrygd
82bd953941 add arch 2025-12-16 18:33:32 -05:00
henrygd
996444abeb update 2025-12-16 17:45:26 -05:00
henrygd
aef4baff5e rm index 2025-12-15 18:59:25 -05:00
henrygd
3dea061e93 progress 2025-12-15 18:29:51 -05:00
26 changed files with 803 additions and 769 deletions

View File

@@ -17,19 +17,12 @@ import (
"github.com/gliderlabs/ssh"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/agent/deltatracker"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/host"
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
@@ -45,8 +38,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 (dynamic dashboard data)
staticSystemInfo system.StaticInfo // Static system info (collected at longer intervals)
systemInfo system.Info // Host system info (dynamic)
systemDetails system.Details // Host system details (static, once-per-connection)
gpuManager *GPUManager // Manages GPU data
cache *systemDataCache // Cache for system stats based on cache time
connectionManager *ConnectionManager // Channel to signal connection events
@@ -106,8 +99,11 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
slog.Debug(beszel.Version)
// initialize docker manager
agent.dockerManager = newDockerManager()
// initialize system info
agent.initializeSystemInfo()
agent.refreshStaticInfo()
// initialize connection manager
agent.connectionManager = newConnectionManager(agent)
@@ -121,9 +117,6 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
// initialize net io stats
agent.initializeNetIoStats()
// initialize docker manager
agent.dockerManager = newDockerManager(agent)
agent.systemdManager, err = newSystemdManager()
if err != nil {
slog.Debug("Systemd", "err", err)
@@ -142,7 +135,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
// if debugging, print stats
if agent.debug {
slog.Debug("Stats", "data", agent.gatherStats(0))
slog.Debug("Stats", "data", agent.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000, IncludeDetails: true}))
}
return agent, nil
@@ -157,10 +150,11 @@ func GetEnv(key string) (value string, exists bool) {
return os.LookupEnv(key)
}
func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedData {
a.Lock()
defer a.Unlock()
cacheTimeMs := options.CacheTimeMs
data, isCached := a.cache.Get(cacheTimeMs)
if isCached {
slog.Debug("Cached data", "cacheTimeMs", cacheTimeMs)
@@ -171,16 +165,14 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
Stats: a.getSystemStats(cacheTimeMs),
Info: a.systemInfo,
}
// 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)
// Include static info only when requested
if options.IncludeDetails {
data.Details = &a.systemDetails
}
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
if a.dockerManager != nil {
if containerStats, err := a.dockerManager.getDockerStats(cacheTimeMs); err == nil {
data.Containers = containerStats
@@ -242,11 +234,7 @@ func (a *Agent) getFingerprint() string {
// if no fingerprint is found, generate one
fingerprint, err := host.HostID()
if err != nil || fingerprint == "" {
cpuModel := ""
if len(a.staticSystemInfo.Cpus) > 0 {
cpuModel = a.staticSystemInfo.Cpus[0].Model
}
fingerprint = a.staticSystemInfo.Hostname + cpuModel
fingerprint = a.systemDetails.Hostname + a.systemDetails.CpuModel
}
// hash fingerprint

View File

@@ -22,7 +22,7 @@ func createTestCacheData() *system.CombinedData {
DiskTotal: 100000,
},
Info: system.Info{
Hostname: "test-host",
AgentVersion: "0.12.0",
},
Containers: []*container.Stats{
{
@@ -128,7 +128,7 @@ func TestCacheMultipleIntervals(t *testing.T) {
Mem: 16384,
},
Info: system.Info{
Hostname: "test-host-2",
AgentVersion: "0.12.0",
},
Containers: []*container.Stats{},
}
@@ -171,7 +171,7 @@ func TestCacheOverwrite(t *testing.T) {
Mem: 32768,
},
Info: system.Info{
Hostname: "updated-host",
AgentVersion: "0.12.0",
},
Containers: []*container.Stats{},
}

View File

@@ -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.staticSystemInfo.Hostname
response.Hostname = client.agent.systemDetails.Hostname
serverAddr := client.agent.connectionManager.serverOptions.Addr
_, response.Port, _ = net.SplitHostPort(serverAddr)
}

View File

@@ -60,6 +60,7 @@ type dockerManager struct {
decoder *json.Decoder // Reusable JSON decoder that reads from buf
apiStats *container.ApiStats // Reusable API stats object
excludeContainers []string // Patterns to exclude containers by name
usingPodman bool // Whether the Docker Engine API is running on Podman
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
// Maps cache time intervals to container-specific CPU usage tracking
@@ -478,7 +479,7 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
}
// Creates a new http client for Docker or Podman API
func newDockerManager(a *Agent) *dockerManager {
func newDockerManager() *dockerManager {
dockerHost, exists := GetEnv("DOCKER_HOST")
if exists {
// return nil if set to empty string
@@ -564,7 +565,7 @@ func newDockerManager(a *Agent) *dockerManager {
// If using podman, return client
if strings.Contains(dockerHost, "podman") {
a.staticSystemInfo.Podman = true
manager.usingPodman = true
manager.goodDockerVersion = true
return manager
}
@@ -746,3 +747,23 @@ func decodeDockerLogStream(reader io.Reader, builder *strings.Builder) error {
totalBytesRead += int(n)
}
}
// GetHostInfo fetches the system info from Docker
func (dm *dockerManager) GetHostInfo() (info container.HostInfo, err error) {
resp, err := dm.client.Get("http://localhost/info")
if err != nil {
return info, err
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
slog.Error("Failed to decode Docker version response", "error", err)
return info, err
}
return info, nil
}
func (dm *dockerManager) IsPodman() bool {
return dm.usingPodman
}

View File

@@ -802,6 +802,24 @@ func TestNetworkRateCalculationFormula(t *testing.T) {
}
}
func TestGetHostInfo(t *testing.T) {
data, err := os.ReadFile("test-data/system_info.json")
require.NoError(t, err)
var info container.HostInfo
err = json.Unmarshal(data, &info)
require.NoError(t, err)
assert.Equal(t, "6.8.0-31-generic", info.KernelVersion)
assert.Equal(t, "Ubuntu 24.04 LTS", info.OperatingSystem)
// assert.Equal(t, "24.04", info.OSVersion)
// assert.Equal(t, "linux", info.OSType)
// assert.Equal(t, "x86_64", info.Architecture)
assert.EqualValues(t, 4, info.NCPU)
assert.EqualValues(t, 2095882240, info.MemTotal)
// assert.Equal(t, "27.0.1", info.ServerVersion)
}
func TestDeltaTrackerCacheTimeIsolation(t *testing.T) {
// Test that different cache times have separate DeltaTracker instances
dm := &dockerManager{

View File

@@ -94,7 +94,7 @@ func (h *GetDataHandler) Handle(hctx *HandlerContext) error {
var options common.DataRequestOptions
_ = cbor.Unmarshal(hctx.Request.Data, &options)
sysStats := hctx.Agent.gatherStats(options.CacheTimeMs)
sysStats := hctx.Agent.gatherStats(options)
return hctx.SendResponse(sysStats, hctx.RequestID)
}

View File

@@ -202,7 +202,7 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes
// handleLegacyStats serves the legacy one-shot stats payload for older hubs
func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error {
stats := a.gatherStats(60_000)
stats := a.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000})
return a.writeToSession(w, stats, hubVersion)
}

View File

@@ -513,7 +513,7 @@ func TestWriteToSessionEncoding(t *testing.T) {
err = json.Unmarshal([]byte(encodedData), &decodedJson)
assert.Error(t, err, "Should not be valid JSON data")
assert.Equal(t, testData.Info.Hostname, decodedCbor.Info.Hostname)
assert.Equal(t, testData.Details.Hostname, decodedCbor.Details.Hostname)
assert.Equal(t, testData.Stats.Cpu, decodedCbor.Stats.Cpu)
} else {
// Should be JSON - try to decode as JSON
@@ -526,7 +526,7 @@ func TestWriteToSessionEncoding(t *testing.T) {
assert.Error(t, err, "Should not be valid CBOR data")
// Verify the decoded JSON data matches our test data
assert.Equal(t, testData.Info.Hostname, decodedJson.Info.Hostname)
assert.Equal(t, testData.Details.Hostname, decodedJson.Details.Hostname)
assert.Equal(t, testData.Stats.Cpu, decodedJson.Stats.Cpu)
// Verify it looks like JSON (starts with '{' and contains readable field names)
@@ -551,7 +551,6 @@ func createTestCombinedData() *system.CombinedData {
DiskPct: 50.0,
},
Info: system.Info{
Hostname: "test-host",
Uptime: 3600,
AgentVersion: "0.12.0",
},

View File

@@ -2,6 +2,7 @@ package agent
import (
"bufio"
"errors"
"fmt"
"log/slog"
"os"
@@ -12,10 +13,9 @@ import (
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/agent/battery"
"github.com/henrygd/beszel/internal/entities/container"
"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"
@@ -30,77 +30,80 @@ type prevDisk struct {
}
// 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
func (a *Agent) refreshStaticInfo() {
a.systemInfo.AgentVersion = beszel.Version
platform, family, version, _ := host.PlatformInformation()
// get host info from Docker if available
var hostInfo container.HostInfo
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
if a.dockerManager != nil {
a.systemDetails.Podman = a.dockerManager.IsPodman()
hostInfo, _ = a.dockerManager.GetHostInfo()
}
a.systemDetails.Hostname, _ = os.Hostname()
if arch, err := host.KernelArch(); err == nil {
a.systemDetails.Arch = arch
} else {
osFamily = family
osVersion = version
osKernel = ""
osRelease := readOsRelease()
if pretty, ok := osRelease["PRETTY_NAME"]; ok {
osFamily = pretty
a.systemDetails.Arch = runtime.GOARCH
}
platform, _, version, _ := host.PlatformInformation()
if platform == "darwin" {
a.systemDetails.Os = system.Darwin
a.systemDetails.OsName = fmt.Sprintf("macOS %s", version)
} else if strings.Contains(platform, "indows") {
a.systemDetails.Os = system.Windows
a.systemDetails.OsName = strings.Replace(platform, "Microsoft ", "", 1)
a.systemDetails.Kernel = version
} else if platform == "freebsd" {
a.systemDetails.Os = system.Freebsd
a.systemDetails.Kernel, _ = host.KernelVersion()
if prettyName, err := getOsPrettyName(); err == nil {
a.systemDetails.OsName = prettyName
} else {
a.systemDetails.OsName = "FreeBSD"
}
if name, ok := osRelease["NAME"]; ok {
osFamily = name
} else {
a.systemDetails.Os = system.Linux
a.systemDetails.OsName = hostInfo.OperatingSystem
if a.systemDetails.OsName == "" {
if prettyName, err := getOsPrettyName(); err == nil {
a.systemDetails.OsName = prettyName
} else {
a.systemDetails.OsName = platform
}
}
if versionId, ok := osRelease["VERSION_ID"]; ok {
osVersion = versionId
a.systemDetails.Kernel = hostInfo.KernelVersion
if a.systemDetails.Kernel == "" {
a.systemDetails.Kernel, _ = 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 {
arch := runtime.GOARCH
totalCores := 0
totalThreads := 0
for _, cpuInfo := range info {
totalCores += int(cpuInfo.Cores)
totalThreads++
a.systemDetails.CpuModel = info[0].ModelName
}
// cores / threads
cores, _ := cpu.Counts(false)
threads := hostInfo.NCPU
if threads == 0 {
threads, _ = cpu.Counts(true)
}
// in lxc, logical cores reflects container limits, so use that as cores if lower
if threads > 0 && threads < cores {
cores = threads
}
a.systemDetails.Cores = cores
a.systemDetails.Threads = threads
// total memory
a.systemDetails.MemoryTotal = hostInfo.MemTotal
if a.systemDetails.MemoryTotal == 0 {
if v, err := mem.VirtualMemory(); err == nil {
a.systemDetails.MemoryTotal = v.Total
}
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
@@ -109,41 +112,6 @@ 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
@@ -268,21 +236,16 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
}
}
// update base system info
// update 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]
a.systemInfo.Threads = a.systemDetails.Threads
slog.Debug("sysinfo", "data", a.systemInfo)
return systemStats
@@ -314,135 +277,23 @@ 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 {
// getOsPrettyName attempts to get the pretty OS name from /etc/os-release on Linux systems
func getOsPrettyName() (string, error) {
file, err := os.Open("/etc/os-release")
if err != nil {
return map[string]string{}
return "", err
}
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
if after, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
value := after
value = strings.Trim(value, `"`)
return value, nil
}
}
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,
}}
return "", errors.New("pretty name not found")
}

View File

@@ -0,0 +1,17 @@
{
"ID": "7TRN:IPZB:QYBB:VPBQ:UMPP:KARE:6ZNR:XE6T:7EWV:PKF4:ZOJD:TPYS",
"Containers": 14,
"ContainersRunning": 3,
"ContainersPaused": 1,
"ContainersStopped": 10,
"Images": 508,
"Driver": "overlay2",
"KernelVersion": "6.8.0-31-generic",
"OperatingSystem": "Ubuntu 24.04 LTS",
"OSVersion": "24.04",
"OSType": "linux",
"Architecture": "x86_64",
"NCPU": 4,
"MemTotal": 2095882240,
"ServerVersion": "27.0.1"
}

View File

@@ -6,7 +6,7 @@ import "github.com/blang/semver"
const (
// Version is the current version of the application.
Version = "0.17.0"
Version = "0.18.0-beta.1"
// AppName is the name of the application.
AppName = "beszel"
)

5
go.mod
View File

@@ -9,7 +9,6 @@ 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
@@ -25,7 +24,6 @@ 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
@@ -43,14 +41,11 @@ 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

11
go.sum
View File

@@ -2,8 +2,6 @@ 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=
@@ -43,7 +41,6 @@ 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=
@@ -71,10 +68,6 @@ 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=
@@ -90,8 +83,6 @@ 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=
@@ -100,8 +91,6 @@ 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=

View File

@@ -60,10 +60,10 @@ func TestBatteryAlertLogic(t *testing.T) {
combinedDataHigh := &system.CombinedData{
Stats: statsHigh,
Info: system.Info{
Hostname: "test-host",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
AgentVersion: "0.12.0",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
},
}
@@ -100,10 +100,10 @@ func TestBatteryAlertLogic(t *testing.T) {
combinedDataLow := &system.CombinedData{
Stats: statsLow,
Info: system.Info{
Hostname: "test-host",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
AgentVersion: "0.12.0",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
},
}
@@ -142,10 +142,10 @@ func TestBatteryAlertLogic(t *testing.T) {
combinedDataRecovered := &system.CombinedData{
Stats: statsRecovered,
Info: system.Info{
Hostname: "test-host",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
AgentVersion: "0.12.0",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
},
}
@@ -198,10 +198,10 @@ func TestBatteryAlertNoBattery(t *testing.T) {
combinedData := &system.CombinedData{
Stats: statsNoBattery,
Info: system.Info{
Hostname: "test-host",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
AgentVersion: "0.12.0",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
},
}
@@ -294,10 +294,10 @@ func TestBatteryAlertAveragedSamples(t *testing.T) {
Battery: [2]uint8{15, 1},
},
Info: system.Info{
Hostname: "test-host",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
AgentVersion: "0.12.0",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
},
}
@@ -360,10 +360,10 @@ func TestBatteryAlertAveragedSamples(t *testing.T) {
Battery: [2]uint8{50, 1},
},
Info: system.Info{
Hostname: "test-host",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
AgentVersion: "0.12.0",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
},
}

View File

@@ -58,8 +58,8 @@ type FingerprintResponse struct {
}
type DataRequestOptions struct {
CacheTimeMs uint16 `cbor:"0,keyasint"`
// ResourceType uint8 `cbor:"1,keyasint,omitempty,omitzero"`
CacheTimeMs uint16 `cbor:"0,keyasint"`
IncludeDetails bool `cbor:"1,keyasint"`
}
type ContainerLogsRequest struct {

View File

@@ -34,6 +34,17 @@ type ApiStats struct {
MemoryStats MemoryStats `json:"memory_stats"`
}
// Docker system info from /info
type HostInfo struct {
OperatingSystem string `json:"OperatingSystem"`
KernelVersion string `json:"KernelVersion"`
NCPU int `json:"NCPU"`
MemTotal uint64 `json:"MemTotal"`
// OSVersion string `json:"OSVersion"`
// OSType string `json:"OSType"`
// Architecture string `json:"Architecture"`
}
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer
systemDelta := s.CPUStats.SystemUsage - prevCpuSystem

View File

@@ -115,37 +115,6 @@ 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 (
@@ -154,36 +123,29 @@ 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
// Core system data that is needed in All Systems table
type Info struct {
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
Hostname string `json:"h,omitempty" cbor:"0,keyasint,omitempty"` // deprecated - moved to Details struct
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"` // deprecated - moved to Details struct
Cores int `json:"c,omitzero" cbor:"2,keyasint,omitzero"` // deprecated - moved to Details struct
CpuModel string `json:"m,omitempty" cbor:"4,keyasint,omitempty"` // deprecated - moved to Details struct
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"` // deprecated - moved to Details struct
Os Os `json:"os,omitempty" cbor:"14,keyasint,omitempty"` // deprecated - moved to Details struct
// Threads is needed in Info struct to calculate load average thresholds
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
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"`
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"` // deprecated - use `la` array instead
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"` // deprecated - use `la` array instead
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"` // deprecated - use `la` array instead
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
ExtraFsPct map[string]float64 `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
@@ -191,11 +153,25 @@ type Info struct {
Battery [2]uint8 `json:"bat,omitzero" cbor:"23,keyasint,omitzero"` // [percent, charge state]
}
// Data that does not change during process lifetime and is not needed in All Systems table
type Details struct {
Hostname string `cbor:"0,keyasint"`
Kernel string `cbor:"1,keyasint,omitempty"`
Cores int `cbor:"2,keyasint"`
Threads int `cbor:"3,keyasint"`
CpuModel string `cbor:"4,keyasint"`
Os Os `cbor:"5,keyasint"`
OsName string `cbor:"6,keyasint"`
Arch string `cbor:"7,keyasint"`
Podman bool `cbor:"8,keyasint,omitempty"`
MemoryTotal uint64 `cbor:"9,keyasint"`
}
// Final data structure to return to the hub
type CombinedData struct {
Stats Stats `json:"stats" cbor:"0,keyasint"`
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
Details *Details `cbor:"4,keyasint,omitempty"`
}

View File

@@ -9,7 +9,7 @@ import (
"math/rand"
"net"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/henrygd/beszel/internal/common"
@@ -29,20 +29,21 @@ import (
)
type System struct {
Id string `db:"id"`
Host string `db:"host"`
Port string `db:"port"`
Status string `db:"status"`
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
agentVersion semver.Version // Agent version
updateTicker *time.Ticker // Ticker for updating the system
smartOnce sync.Once // Once for fetching and saving smart devices
Id string `db:"id"`
Host string `db:"host"`
Port string `db:"port"`
Status string `db:"status"`
manager *SystemManager // Manager that this system belongs to
client *ssh.Client // SSH client for fetching data
data *system.CombinedData // system data from agent
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
agentVersion semver.Version // Agent version
updateTicker *time.Ticker // Ticker for updating the system
detailsFetched atomic.Bool // True if static system details have been fetched and saved
smartFetched atomic.Bool // True if SMART devices have been fetched and saved
smartFetching atomic.Bool // True if SMART devices are currently being fetched
}
func (sm *SystemManager) NewSystem(systemId string) *System {
@@ -115,22 +116,15 @@ func (sys *System) update() error {
sys.handlePaused()
return nil
}
// 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
options := common.DataRequestOptions{
CacheTimeMs: uint16(interval),
}
data, err := sys.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: cacheTimeMs})
// fetch system details if not already fetched
if !sys.detailsFetched.Load() {
options.IncludeDetails = true
}
data, err := sys.fetchDataFromAgent(options)
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
@@ -151,23 +145,17 @@ 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
}
hub := sys.manager.hub
err = hub.RunInTransaction(func(txApp core.App) error {
// add system_stats and container_stats records
// add system_stats record
systemStatsCollection, err := txApp.FindCachedCollectionByNameOrId("system_stats")
if err != nil {
return err
}
systemStatsRecord := core.NewRecord(systemStatsCollection)
systemStatsRecord.Set("system", systemRecord.Id)
systemStatsRecord.Set("stats", data.Stats)
@@ -175,14 +163,14 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
if err := txApp.SaveNoValidate(systemStatsRecord); err != nil {
return err
}
// add containers and container_stats records
if len(data.Containers) > 0 {
// add / update containers records
if data.Containers[0].Id != "" {
if err := createContainerRecords(txApp, data.Containers, sys.Id); err != nil {
return err
}
}
// add new container_stats record
containerStatsCollection, err := txApp.FindCachedCollectionByNameOrId("container_stats")
if err != nil {
return err
@@ -203,10 +191,17 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
}
}
// add system details record
if data.Details != nil {
if err := createSystemDetailsRecord(txApp, data.Details, sys.Id); err != nil {
return err
}
sys.detailsFetched.Store(true)
}
// 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", completeInfo)
systemRecord.Set("info", data.Info)
if err := txApp.SaveNoValidate(systemRecord); err != nil {
return err
}
@@ -215,76 +210,42 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
// Fetch and save SMART devices when system first comes online
if err == nil {
sys.smartOnce.Do(func() {
go sys.FetchAndSaveSmartDevices()
})
if !sys.smartFetched.Load() && sys.smartFetching.CompareAndSwap(false, true) {
go func() {
defer sys.smartFetching.Store(false)
if err := sys.FetchAndSaveSmartDevices(); err == nil {
sys.smartFetched.Store(true)
}
}()
}
}
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
func createSystemDetailsRecord(app core.App, data *system.Details, systemId string) error {
collectionName := "system_details"
params := dbx.Params{
"id": systemId,
"system": systemId,
"hostname": data.Hostname,
"kernel": data.Kernel,
"cores": data.Cores,
"threads": data.Threads,
"cpu": data.CpuModel,
"os": data.Os,
"os_name": data.OsName,
"arch": data.Arch,
"memory": data.MemoryTotal,
"podman": data.Podman,
"updated": time.Now().UTC(),
}
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
result, err := app.DB().Update(collectionName, params, dbx.HashExp{"id": systemId}).Execute()
rowsAffected, _ := result.RowsAffected()
if err != nil || rowsAffected == 0 {
_, err = app.DB().Insert(collectionName, params).Execute()
}
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
return err
}
func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId string) error {
@@ -434,7 +395,8 @@ func (sys *System) fetchStringFromAgentViaSSH(action common.WebSocketAction, req
if err := session.Shell(); err != nil {
return false, err
}
req := common.HubRequest[any]{Action: action, Data: requestData}
reqDataBytes, _ := cbor.Marshal(requestData)
req := common.HubRequest[cbor.RawMessage]{Action: action, Data: reqDataBytes}
_ = cbor.NewEncoder(stdin).Encode(req)
_ = stdin.Close()
var resp common.AgentResponse
@@ -498,7 +460,8 @@ func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.Servic
return false, err
}
req := common.HubRequest[any]{Action: common.GetSystemdInfo, Data: common.SystemdInfoRequest{ServiceName: serviceName}}
reqDataBytes, _ := cbor.Marshal(common.SystemdInfoRequest{ServiceName: serviceName})
req := common.HubRequest[cbor.RawMessage]{Action: common.GetSystemdInfo, Data: reqDataBytes}
if err := cbor.NewEncoder(stdin).Encode(req); err != nil {
return false, err
}
@@ -546,7 +509,8 @@ func (sys *System) fetchDataViaSSH(options common.DataRequestOptions) (*system.C
*sys.data = system.CombinedData{}
if sys.agentVersion.GTE(beszel.MinVersionAgentResponse) && stdinErr == nil {
req := common.HubRequest[any]{Action: common.GetData, Data: options}
reqDataBytes, _ := cbor.Marshal(options)
req := common.HubRequest[cbor.RawMessage]{Action: common.GetData, Data: reqDataBytes}
_ = cbor.NewEncoder(stdin).Encode(req)
_ = stdin.Close()
@@ -686,7 +650,6 @@ 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
@@ -696,7 +659,6 @@ 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

View File

@@ -266,18 +266,20 @@ func testOld(t *testing.T, hub *tests.TestHub) {
// Create test system data
testData := &system.CombinedData{
Details: &system.Details{
Hostname: "data-test.example.com",
Kernel: "5.15.0-generic",
Cores: 4,
Threads: 8,
CpuModel: "Test CPU",
},
Info: system.Info{
Hostname: "data-test.example.com",
KernelVersion: "5.15.0-generic",
Cores: 4,
Threads: 8,
CpuModel: "Test CPU",
Uptime: 3600,
Cpu: 25.5,
MemPct: 40.2,
DiskPct: 60.0,
Bandwidth: 100.0,
AgentVersion: "1.0.0",
Uptime: 3600,
Cpu: 25.5,
MemPct: 40.2,
DiskPct: 60.0,
Bandwidth: 100.0,
AgentVersion: "1.0.0",
},
Stats: system.Stats{
Cpu: 25.5,

View File

@@ -1439,6 +1439,184 @@ func init() {
"type": "base",
"updateRule": null,
"viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id"
},
{
"createRule": "",
"deleteRule": "",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "2hz5ncl8tizk5nx",
"hidden": false,
"id": "relation3377271179",
"maxSelect": 1,
"minSelect": 0,
"name": "system",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3847340049",
"max": 0,
"min": 0,
"name": "hostname",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "number1789936913",
"max": null,
"min": null,
"name": "os",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2818598173",
"max": 0,
"min": 0,
"name": "os_name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1574083243",
"max": 0,
"min": 0,
"name": "kernel",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3128971310",
"max": 0,
"min": 0,
"name": "cpu",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text4161937994",
"max": 0,
"min": 0,
"name": "arch",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "number4245036687",
"max": null,
"min": null,
"name": "cores",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number1871592925",
"max": null,
"min": null,
"name": "threads",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number3933025333",
"max": null,
"min": null,
"name": "memory",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "bool2200265312",
"name": "podman",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_3116237454",
"indexes": [],
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
"name": "system_details",
"system": false,
"type": "base",
"updateRule": "",
"viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id"
}
]`

View File

@@ -33,10 +33,7 @@
"noUnusedFunctionParameters": "error",
"noUnusedPrivateClassMembers": "error",
"useExhaustiveDependencies": {
"level": "warn",
"options": {
"reportUnnecessaryDependencies": false
}
"level": "off"
},
"useUniqueElementIds": "off",
"noUnusedVariables": "error"

View File

@@ -1,7 +1,7 @@
{
"name": "beszel",
"private": true,
"version": "0.17.0",
"version": "0.18.0-beta.1",
"type": "module",
"scripts": {
"dev": "vite --host",

View File

@@ -3,17 +3,7 @@ import { Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router"
import { timeTicks } from "d3-time"
import {
ChevronRightSquareIcon,
ClockArrowUp,
CpuIcon,
GlobeIcon,
HardDriveIcon,
LayoutGridIcon,
MonitorIcon,
ServerIcon,
XIcon,
} from "lucide-react"
import { XIcon } from "lucide-react"
import { subscribeKeys } from "nanostores"
import React, { type JSX, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import AreaChartDefault, { type DataPoint } from "@/components/charts/area-chart"
@@ -26,7 +16,7 @@ import MemChart from "@/components/charts/mem-chart"
import SwapChart from "@/components/charts/swap-chart"
import TemperatureChart from "@/components/charts/temperature-chart"
import { getPbTimestamp, pb } from "@/lib/api"
import { ChartType, ConnectionType, connectionTypeLabels, Os, SystemStatus, Unit } from "@/lib/enums"
import { ChartType, Os, SystemStatus, Unit } from "@/lib/enums"
import { batteryStateTranslations } from "@/lib/i18n"
import {
$allSystemsById,
@@ -46,8 +36,6 @@ import {
compareSemVer,
decimalString,
formatBytes,
secondsToString,
getHostDisplayValue,
listen,
parseSemVer,
toFixedFloat,
@@ -63,20 +51,18 @@ import type {
SystemStats,
SystemStatsRecord,
} from "@/types"
import ChartTimeSelect from "../charts/chart-time-select"
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, EthernetIcon, FreeBsdIcon, Rows, TuxIcon, WebSocketIcon, WindowsIcon } from "../ui/icons"
import { ChartAverage, ChartMax } from "../ui/icons"
import { Input } from "../ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
import { Separator } from "../ui/separator"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
import NetworkSheet from "./system/network-sheet"
import CpuCoresSheet from "./system/cpu-sheet"
import LineChartDefault from "../charts/line-chart"
import { pinnedAxisDomain } from "../ui/chart"
import InfoBar from "./system/info-bar"
type ChartTimeData = {
time: number
@@ -156,8 +142,8 @@ async function getStats<T extends SystemStatsRecord | ContainerStatsRecord>(
})
}
function dockerOrPodman(str: string, system: SystemRecord): string {
if (system.info.p) {
function dockerOrPodman(str: string, isPodman: boolean): string {
if (isPodman) {
return str.replace("docker", "podman").replace("Docker", "Podman")
}
return str
@@ -180,6 +166,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
const isLongerChart = !["1m", "1h"].includes(chartTime) // true if chart time is not 1m or 1h
const userSettings = $userSettings.get()
const chartWrapRef = useRef<HTMLDivElement>(null)
const [isPodman, setIsPodman] = useState(system.info?.p ?? false)
useEffect(() => {
return () => {
@@ -219,7 +206,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
// subscribe to realtime metrics if chart time is 1m
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
useEffect(() => {
let unsub = () => { }
let unsub = () => {}
if (!system.id || chartTime !== "1m") {
return
}
@@ -335,105 +322,9 @@ 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) {
return []
}
const osInfo = {
[Os.Linux]: {
Icon: TuxIcon,
value: system.info.k,
label: t({ comment: "Linux kernel", message: "Kernel" }),
},
[Os.Darwin]: {
Icon: AppleIcon,
value: `macOS ${system.info.k}`,
},
[Os.Windows]: {
Icon: WindowsIcon,
value: system.info.k,
},
[Os.FreeBSD]: {
Icon: FreeBsdIcon,
value: system.info.k,
},
}
let uptime: string
if (system.info.u < 3600) {
uptime = secondsToString(system.info.u, "minute")
} else if (system.info.u < 360000) {
uptime = secondsToString(system.info.u, "hour")
} 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 },
{
value: system.info.h,
Icon: MonitorIcon,
label: "Hostname",
// hide if hostname is same as host or name
hide: system.info.h === system.host || system.info.h === system.name,
},
{ value: uptime, Icon: ClockArrowUp, label: t`Uptime`, hide: !system.info.u },
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: !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, formatHardwareInfo])
useEffect(() => {
setIsPodman(system.info?.p ?? false)
}, [system.info?.p])
/** Space for tooltip if more than 10 sensors and no containers table */
useEffect(() => {
@@ -503,113 +394,11 @@ export default memo(function SystemDetail({ id }: { id: string }) {
const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined || gpu.pp !== undefined)
const hasGpuEnginesData = lastGpuVals.some((gpu) => gpu.e !== undefined)
let translatedStatus: string = system.status
if (system.status === SystemStatus.Up) {
translatedStatus = t({ message: "Up", comment: "Context: System is up" })
} else if (system.status === SystemStatus.Down) {
translatedStatus = t({ message: "Down", comment: "Context: System is down" })
}
return (
<>
<div ref={chartWrapRef} className="grid gap-4 mb-14 overflow-x-clip">
{/* system info */}
<Card>
<div className="grid xl:flex gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
<div>
<h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="capitalize flex gap-2 items-center">
<span className={cn("relative flex h-3 w-3")}>
{system.status === SystemStatus.Up && (
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
style={{ animationDuration: "1.5s" }}
></span>
)}
<span
className={cn("relative inline-flex rounded-full h-3 w-3", {
"bg-green-500": system.status === SystemStatus.Up,
"bg-red-500": system.status === SystemStatus.Down,
"bg-primary/40": system.status === SystemStatus.Paused,
"bg-yellow-500": system.status === SystemStatus.Pending,
})}
></span>
</span>
{translatedStatus}
</div>
</TooltipTrigger>
{system.info.ct && (
<TooltipContent>
<div className="flex gap-1 items-center">
{system.info.ct === ConnectionType.WebSocket ? (
<WebSocketIcon className="size-4" />
) : (
<ChevronRightSquareIcon className="size-4" strokeWidth={2} />
)}
{connectionTypeLabels[system.info.ct as ConnectionType]}
</div>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
{systemInfo.map(({ value, label, Icon, hide }) => {
if (hide || !value) {
return null
}
const content = (
<div className="flex gap-1.5 items-center">
<Icon className="h-4 w-4" /> {value}
</div>
)
return (
<div key={value} className="contents">
<Separator orientation="vertical" className="h-4 bg-primary/30" />
{label ? (
<TooltipProvider>
<Tooltip delayDuration={150}>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
content
)}
</div>
)
})}
</div>
</div>
<div className="xl:ms-auto flex items-center gap-2 max-sm:-mb-1">
<ChartTimeSelect className="w-full xl:w-40" agentVersion={chartData.agentVersion} />
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t`Toggle grid`}
variant="outline"
size="icon"
className="hidden xl:flex p-0 text-primary"
onClick={() => setGrid(!grid)}
>
{grid ? (
<LayoutGridIcon className="h-[1.2rem] w-[1.2rem] opacity-75" />
) : (
<Rows className="h-[1.3rem] w-[1.3rem] opacity-75" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{t`Toggle grid`}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</Card>
<InfoBar system={system} chartData={chartData} grid={grid} setGrid={setGrid} setIsPodman={setIsPodman} />
{/* <Tabs defaultValue="overview" className="w-full">
<TabsList className="w-full h-11">
@@ -621,7 +410,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
</TabsContent>
</Tabs> */}
{/* main charts */}
<div className="grid xl:grid-cols-2 gap-4">
<ChartCard
@@ -657,7 +445,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<ChartCard
empty={dataEmpty}
grid={grid}
title={dockerOrPodman(t`Docker CPU Usage`, system)}
title={dockerOrPodman(t`Docker CPU Usage`, isPodman)}
description={t`Average CPU utilization of containers`}
cornerEl={containerFilterBar}
>
@@ -684,8 +472,8 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<ChartCard
empty={dataEmpty}
grid={grid}
title={dockerOrPodman(t`Docker Memory Usage`, system)}
description={dockerOrPodman(t`Memory usage of docker containers`, system)}
title={dockerOrPodman(t`Docker Memory Usage`, isPodman)}
description={dockerOrPodman(t`Memory usage of docker containers`, isPodman)}
cornerEl={containerFilterBar}
>
<ContainerChart
@@ -805,8 +593,8 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<ChartCard
empty={dataEmpty}
grid={grid}
title={dockerOrPodman(t`Docker Network I/O`, system)}
description={dockerOrPodman(t`Network traffic of docker containers`, system)}
title={dockerOrPodman(t`Docker Network I/O`, isPodman)}
description={dockerOrPodman(t`Network traffic of docker containers`, isPodman)}
cornerEl={containerFilterBar}
>
<ContainerChart
@@ -845,10 +633,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
{/* Temperature chart */}
{systemStats.at(-1)?.stats.t && (
<div
ref={temperatureChartRef}
className={cn("odd:last-of-type:col-span-full", { "col-span-full": !grid })}
>
<div ref={temperatureChartRef} className={cn("odd:last-of-type:col-span-full", { "col-span-full": !grid })}>
<ChartCard
empty={dataEmpty}
grid={grid}
@@ -1010,7 +795,9 @@ export default memo(function SystemDetail({ id }: { id: string }) {
label: t`Write`,
dataKey: ({ stats }) => {
if (showMax) {
return stats?.efs?.[extraFsName]?.wbm || (stats?.efs?.[extraFsName]?.wm ?? 0) * 1024 * 1024
return (
stats?.efs?.[extraFsName]?.wbm || (stats?.efs?.[extraFsName]?.wm ?? 0) * 1024 * 1024
)
}
return stats?.efs?.[extraFsName]?.wb || (stats?.efs?.[extraFsName]?.w ?? 0) * 1024 * 1024
},
@@ -1048,9 +835,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
</div>
)}
{compareSemVer(chartData.agentVersion, parseSemVer("0.15.0")) >= 0 && (
<LazySmartTable systemId={system.id} />
)}
{compareSemVer(chartData.agentVersion, parseSemVer("0.15.0")) >= 0 && <LazySmartTable systemId={system.id} />}
{containerData.length > 0 && compareSemVer(chartData.agentVersion, parseSemVer("0.14.0")) >= 0 && (
<LazyContainersTable systemId={system.id} />
@@ -1106,13 +891,10 @@ function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilt
return () => clearTimeout(handle)
}, [inputValue, storeValue, store])
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setInputValue(value)
},
[]
)
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setInputValue(value)
}, [])
const handleClear = useCallback(() => {
setInputValue("")
@@ -1239,4 +1021,4 @@ function LazySystemdTable({ systemId }: { systemId: string }) {
{isIntersecting && <SystemdTable systemId={systemId} />}
</div>
)
}
}

View File

@@ -0,0 +1,259 @@
import { plural } from "@lingui/core/macro"
import { useLingui } from "@lingui/react/macro"
import {
AppleIcon,
ChevronRightSquareIcon,
ClockArrowUp,
CpuIcon,
GlobeIcon,
LayoutGridIcon,
MemoryStickIcon,
MonitorIcon,
Rows,
} from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import ChartTimeSelect from "@/components/charts/chart-time-select"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { FreeBsdIcon, TuxIcon, WebSocketIcon, WindowsIcon } from "@/components/ui/icons"
import { Separator } from "@/components/ui/separator"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { pb } from "@/lib/api"
import { ConnectionType, connectionTypeLabels, Os, SystemStatus } from "@/lib/enums"
import { cn, formatBytes, getHostDisplayValue, secondsToString, toFixedFloat } from "@/lib/utils"
import type { ChartData, SystemDetailsRecord, SystemRecord } from "@/types"
export default function InfoBar({
system,
chartData,
grid,
setGrid,
setIsPodman,
}: {
system: SystemRecord
chartData: ChartData
grid: boolean
setGrid: (grid: boolean) => void
setIsPodman: (isPodman: boolean) => void
}) {
const { t } = useLingui()
const [details, setDetails] = useState<SystemDetailsRecord | null>(null)
// Fetch system_details on mount / when system changes
useEffect(() => {
let active = true
setDetails(null)
// skip fetching system details if agent is older version which includes details in Info struct
if (!system.id || system.info?.m) {
return
}
pb.collection<SystemDetailsRecord>("system_details")
.getOne(system.id, {
fields: "hostname,kernel,cores,threads,cpu,os,os_name,arch,memory,podman",
headers: {
"Cache-Control": "public, max-age=60",
},
})
.then((details) => {
if (active) {
setDetails(details)
setIsPodman(details.podman)
}
})
.catch(() => {})
return () => {
active = false
}
}, [system.id])
// values for system info bar - use details with fallback to system.info
const systemInfo = useMemo(() => {
if (!system.info) {
return []
}
// Use details if available, otherwise fall back to system.info
const hostname = details?.hostname ?? system.info.h
const kernel = details?.kernel ?? system.info.k
const cores = details?.cores ?? system.info.c
const threads = details?.threads ?? system.info.t ?? 0
const cpuModel = details?.cpu ?? system.info.m
const os = details?.os ?? system.info.os ?? Os.Linux
const osName = details?.os_name
const arch = details?.arch
const memory = details?.memory
const osInfo = {
[Os.Linux]: {
Icon: TuxIcon,
// show kernel in tooltip if os name is available, otherwise show the kernel
value: osName || kernel,
label: osName ? kernel : undefined,
},
[Os.Darwin]: {
Icon: AppleIcon,
value: osName || `macOS ${kernel}`,
},
[Os.Windows]: {
Icon: WindowsIcon,
value: osName || kernel,
label: osName ? kernel : undefined,
},
[Os.FreeBSD]: {
Icon: FreeBsdIcon,
value: osName || kernel,
label: osName ? kernel : undefined,
},
}
let uptime: string
if (system.info.u < 3600) {
uptime = secondsToString(system.info.u, "minute")
} else if (system.info.u < 360000) {
uptime = secondsToString(system.info.u, "hour")
} else {
uptime = secondsToString(system.info.u, "day")
}
const info = [
{ value: getHostDisplayValue(system), Icon: GlobeIcon },
{
value: hostname,
Icon: MonitorIcon,
label: "Hostname",
// hide if hostname is same as host or name
hide: hostname === system.host || hostname === system.name,
},
{ value: uptime, Icon: ClockArrowUp, label: t`Uptime`, hide: !system.info.u },
osInfo[os],
{
value: cpuModel,
Icon: CpuIcon,
hide: !cpuModel,
label: `${plural(cores, { one: "# core", other: "# cores" })} / ${plural(threads, { one: "# thread", other: "# threads" })}${arch ? ` / ${arch}` : ""}`,
},
] as {
value: string | number | undefined
label?: string
Icon: React.ElementType
hide?: boolean
}[]
if (memory) {
const memValue = formatBytes(memory, false, undefined, false)
info.push({
value: `${toFixedFloat(memValue.value, memValue.value >= 10 ? 1 : 2)} ${memValue.unit}`,
Icon: MemoryStickIcon,
hide: !memory,
label: t`Memory`,
})
}
return info
}, [system, details, t])
let translatedStatus: string = system.status
if (system.status === SystemStatus.Up) {
translatedStatus = t({ message: "Up", comment: "Context: System is up" })
} else if (system.status === SystemStatus.Down) {
translatedStatus = t({ message: "Down", comment: "Context: System is down" })
}
return (
<Card>
<div className="grid xl:flex gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
<div>
<h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="capitalize flex gap-2 items-center">
<span className={cn("relative flex h-3 w-3")}>
{system.status === SystemStatus.Up && (
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
style={{ animationDuration: "1.5s" }}
></span>
)}
<span
className={cn("relative inline-flex rounded-full h-3 w-3", {
"bg-green-500": system.status === SystemStatus.Up,
"bg-red-500": system.status === SystemStatus.Down,
"bg-primary/40": system.status === SystemStatus.Paused,
"bg-yellow-500": system.status === SystemStatus.Pending,
})}
></span>
</span>
{translatedStatus}
</div>
</TooltipTrigger>
{system.info.ct && (
<TooltipContent>
<div className="flex gap-1 items-center">
{system.info.ct === ConnectionType.WebSocket ? (
<WebSocketIcon className="size-4" />
) : (
<ChevronRightSquareIcon className="size-4" strokeWidth={2} />
)}
{connectionTypeLabels[system.info.ct as ConnectionType]}
</div>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
{systemInfo.map(({ value, label, Icon, hide }) => {
if (hide || !value) {
return null
}
const content = (
<div className="flex gap-1.5 items-center">
<Icon className="h-4 w-4" /> {value}
</div>
)
return (
<div key={value} className="contents">
<Separator orientation="vertical" className="h-4 bg-primary/30" />
{label ? (
<TooltipProvider>
<Tooltip delayDuration={150}>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
content
)}
</div>
)
})}
</div>
</div>
<div className="xl:ms-auto flex items-center gap-2 max-sm:-mb-1">
<ChartTimeSelect className="w-full xl:w-40" agentVersion={chartData.agentVersion} />
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t`Toggle grid`}
variant="outline"
size="icon"
className="hidden xl:flex p-0 text-primary"
onClick={() => setGrid(!grid)}
>
{grid ? (
<LayoutGridIcon className="h-[1.2rem] w-[1.2rem] opacity-75" />
) : (
<Rows className="h-[1.3rem] w-[1.3rem] opacity-75" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{t`Toggle grid`}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</Card>
)
}

View File

@@ -27,32 +27,12 @@ export interface SystemRecord extends RecordModel {
host: string
status: "up" | "down" | "paused" | "pending"
port: string
info: systemInfo
info: SystemInfo
v: string
updated: string
}
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 {
export interface SystemInfo {
/** hostname */
h: string
/** kernel **/
@@ -95,16 +75,6 @@ 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 */
@@ -410,6 +380,19 @@ export interface SmartAttribute {
wf?: string
}
export interface SystemDetailsRecord extends RecordModel {
system: string
hostname: string
kernel: string
cores: number
threads: number
cpu: string
os: Os
os_name: string
memory: number
podman: boolean
}
export interface SmartDeviceRecord extends RecordModel {
id: string
system: string

View File

@@ -1,3 +1,9 @@
## 0.18.0
- Remove `la1`, `la5`, `la15` fields from `Info` struct in favor of `la` array.
- Remove `MB` bandwidth values in favor of bytes.
## 0.17.0
- Add quiet hours to silence alerts during specific time periods. (#265)