From e59f8eee36e219aff78cb3a6820dd2026f778390 Mon Sep 17 00:00:00 2001 From: henrygd Date: Thu, 18 Dec 2025 17:22:59 -0500 Subject: [PATCH] add system_details collection for infrequently updated data - add includedetails flag to data requests for better efficiency - integrate docker host info api for better os detection - pull more OS details as well as cpu arch - separate info bar component and refactor for new info --- agent/agent.go | 25 +- agent/agent_cache_test.go | 6 +- agent/client.go | 2 +- agent/docker.go | 24 +- agent/docker_test.go | 18 ++ agent/handlers.go | 2 +- agent/server.go | 2 +- agent/server_test.go | 11 +- agent/system.go | 114 +++++--- agent/test-data/system_info.json | 17 ++ beszel.go | 2 +- internal/alerts/alerts_battery_test.go | 48 ++-- internal/common/common-ws.go | 4 +- internal/entities/container/container.go | 8 + internal/entities/system/system.go | 37 ++- internal/hub/expirymap/expirymap_test.go | 17 +- internal/hub/systems/system.go | 106 +++++--- internal/hub/systems/systems_production.go | 10 + internal/hub/systems/systems_test.go | 24 +- internal/hub/systems/systems_test_helpers.go | 7 + ...=> 0_collections_snapshot_0_18_0_dev_1.go} | 178 +++++++++++++ internal/site/biome.json | 5 +- internal/site/package.json | 2 +- .../site/src/components/routes/system.tsx | 244 +++--------------- .../src/components/routes/system/info-bar.tsx | 229 ++++++++++++++++ internal/site/src/types.d.ts | 13 + 26 files changed, 812 insertions(+), 343 deletions(-) create mode 100644 agent/test-data/system_info.json create mode 100644 internal/hub/systems/systems_production.go rename internal/migrations/{0_collections_snapshot_0_17_1_dev_0.go => 0_collections_snapshot_0_18_0_dev_1.go} (90%) create mode 100644 internal/site/src/components/routes/system/info-bar.tsx diff --git a/agent/agent.go b/agent/agent.go index 5dcd3b60..95ad85de 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -17,6 +17,7 @@ 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" @@ -37,7 +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 + 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 @@ -97,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.refreshSystemDetails() // initialize connection manager agent.connectionManager = newConnectionManager(agent) @@ -112,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) @@ -133,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 @@ -148,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) @@ -162,6 +165,12 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData { Stats: a.getSystemStats(cacheTimeMs), Info: a.systemInfo, } + + // Include static system details only when requested + if options.IncludeDetails { + data.Details = &a.systemDetails + } + // slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs) if a.dockerManager != nil { @@ -225,7 +234,7 @@ 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 + fingerprint = a.systemDetails.Hostname + a.systemDetails.CpuModel } // hash fingerprint diff --git a/agent/agent_cache_test.go b/agent/agent_cache_test.go index 2930563f..db7d5d78 100644 --- a/agent/agent_cache_test.go +++ b/agent/agent_cache_test.go @@ -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{}, } diff --git a/agent/client.go b/agent/client.go index 48a965b9..3523117a 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.systemDetails.Hostname serverAddr := client.agent.connectionManager.serverOptions.Addr _, response.Port, _ = net.SplitHostPort(serverAddr) } diff --git a/agent/docker.go b/agent/docker.go index fb306b0d..3b83ed59 100644 --- a/agent/docker.go +++ b/agent/docker.go @@ -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.systemInfo.Podman = true + manager.usingPodman = true manager.goodDockerVersion = true return manager } @@ -746,3 +747,22 @@ 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 { + return info, err + } + + return info, nil +} + +func (dm *dockerManager) IsPodman() bool { + return dm.usingPodman +} diff --git a/agent/docker_test.go b/agent/docker_test.go index bd0123c4..601e6b39 100644 --- a/agent/docker_test.go +++ b/agent/docker_test.go @@ -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{ diff --git a/agent/handlers.go b/agent/handlers.go index 931c4dfe..c6f8f9c3 100644 --- a/agent/handlers.go +++ b/agent/handlers.go @@ -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) } diff --git a/agent/server.go b/agent/server.go index c826d67f..b1c15abf 100644 --- a/agent/server.go +++ b/agent/server.go @@ -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) } diff --git a/agent/server_test.go b/agent/server_test.go index bfee84e5..27486eba 100644 --- a/agent/server_test.go +++ b/agent/server_test.go @@ -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) @@ -550,13 +550,12 @@ func createTestCombinedData() *system.CombinedData { DiskUsed: 549755813888, // 512GB DiskPct: 50.0, }, + Details: &system.Details{ + Hostname: "test-host", + }, 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..ecf1f884 100644 --- a/agent/system.go +++ b/agent/system.go @@ -2,15 +2,18 @@ package agent import ( "bufio" + "errors" "fmt" "log/slog" "os" + "runtime" "strconv" "strings" "time" "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/shirou/gopsutil/v4/cpu" @@ -27,41 +30,79 @@ type prevDisk struct { } // Sets initial / non-changing values about the host system -func (a *Agent) initializeSystemInfo() { +func (a *Agent) refreshSystemDetails() { a.systemInfo.AgentVersion = beszel.Version - a.systemInfo.Hostname, _ = os.Hostname() + + // get host info from Docker if available + var hostInfo container.HostInfo + + 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 { + a.systemDetails.Arch = runtime.GOARCH + } platform, _, version, _ := host.PlatformInformation() if platform == "darwin" { - a.systemInfo.KernelVersion = version - a.systemInfo.Os = system.Darwin + a.systemDetails.Os = system.Darwin + a.systemDetails.OsName = fmt.Sprintf("macOS %s", 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 + a.systemDetails.Os = system.Windows + a.systemDetails.OsName = strings.Replace(platform, "Microsoft ", "", 1) + a.systemDetails.Kernel = version } else if platform == "freebsd" { - a.systemInfo.Os = system.Freebsd - a.systemInfo.KernelVersion = version + 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" + } } else { - a.systemInfo.Os = system.Linux - } - - if a.systemInfo.KernelVersion == "" { - a.systemInfo.KernelVersion, _ = host.KernelVersion() + 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 + } + } + a.systemDetails.Kernel = hostInfo.KernelVersion + if a.systemDetails.Kernel == "" { + a.systemDetails.Kernel, _ = host.KernelVersion() + } } // cpu model if info, err := cpu.Info(); err == nil && len(info) > 0 { - a.systemInfo.CpuModel = info[0].ModelName + a.systemDetails.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 + 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 } } @@ -195,22 +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] - slog.Debug("sysinfo", "data", a.systemInfo) + a.systemInfo.Threads = a.systemDetails.Threads return systemStats } @@ -240,3 +275,24 @@ func getARCSize() (uint64, error) { return 0, fmt.Errorf("failed to parse size field") } + +// 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 "", err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if after, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok { + value := after + value = strings.Trim(value, `"`) + return value, nil + } + } + + return "", errors.New("pretty name not found") +} diff --git a/agent/test-data/system_info.json b/agent/test-data/system_info.json new file mode 100644 index 00000000..57a62bb7 --- /dev/null +++ b/agent/test-data/system_info.json @@ -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" +} diff --git a/beszel.go b/beszel.go index c19750d7..d71abac4 100644 --- a/beszel.go +++ b/beszel.go @@ -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" ) diff --git a/internal/alerts/alerts_battery_test.go b/internal/alerts/alerts_battery_test.go index e3824d4d..3baeb417 100644 --- a/internal/alerts/alerts_battery_test.go +++ b/internal/alerts/alerts_battery_test.go @@ -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, }, } diff --git a/internal/common/common-ws.go b/internal/common/common-ws.go index 290e0dbb..08237362 100644 --- a/internal/common/common-ws.go +++ b/internal/common/common-ws.go @@ -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 { diff --git a/internal/entities/container/container.go b/internal/entities/container/container.go index ae9df4e6..051f0dff 100644 --- a/internal/entities/container/container.go +++ b/internal/entities/container/container.go @@ -34,6 +34,14 @@ type ApiStats struct { MemoryStats MemoryStats `json:"memory_stats"` } +// Docker system info from /info API endpoint +type HostInfo struct { + OperatingSystem string `json:"OperatingSystem"` + KernelVersion string `json:"KernelVersion"` + NCPU int `json:"NCPU"` + MemTotal uint64 `json:"MemTotal"` +} + func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 { cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer systemDelta := s.CPUStats.SystemUsage - prevCpuSystem diff --git a/internal/entities/system/system.go b/internal/entities/system/system.go index 0649666f..1c7b9679 100644 --- a/internal/entities/system/system.go +++ b/internal/entities/system/system.go @@ -123,27 +123,29 @@ const ( ConnectionTypeWebSocket ) +// Core system data that is needed in All Systems table 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"` + 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 + // Threads is needed in Info struct to calculate load average thresholds Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"` - CpuModel string `json:"m" cbor:"4,keyasint"` + CpuModel string `json:"m,omitempty" cbor:"4,keyasint,omitempty"` // deprecated - moved to Details struct 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"` + Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"` // deprecated - moved to Details struct 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"` + Os Os `json:"os,omitempty" cbor:"14,keyasint,omitempty"` // deprecated - moved to Details struct + 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"` - // 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"` ExtraFsPct map[string]float64 `json:"efs,omitempty" cbor:"21,keyasint,omitempty"` @@ -151,10 +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"` + Details *Details `cbor:"4,keyasint,omitempty"` } diff --git a/internal/hub/expirymap/expirymap_test.go b/internal/hub/expirymap/expirymap_test.go index 22658ed9..81272edf 100644 --- a/internal/hub/expirymap/expirymap_test.go +++ b/internal/hub/expirymap/expirymap_test.go @@ -415,7 +415,11 @@ func TestExpiryMap_RemoveValue_WithExpiration(t *testing.T) { // Wait for first value to expire time.Sleep(time.Millisecond * 20) - // Try to remove the expired value - should remove one of the "value1" entries + // Trigger lazy cleanup of the expired key + _, ok := em.GetOk("key1") + assert.False(t, ok) + + // Try to remove the remaining "value1" entry (key3) removedValue, ok := em.RemovebyValue("value1") assert.True(t, ok) assert.Equal(t, "value1", removedValue) @@ -423,14 +427,9 @@ func TestExpiryMap_RemoveValue_WithExpiration(t *testing.T) { // Should still have key2 (different value) assert.True(t, em.Has("key2")) - // Should have removed one of the "value1" entries (either key1 or key3) - // But we can't predict which one due to map iteration order - key1Exists := em.Has("key1") - key3Exists := em.Has("key3") - - // Exactly one of key1 or key3 should be gone - assert.False(t, key1Exists && key3Exists) // Both shouldn't exist - assert.True(t, key1Exists || key3Exists) // At least one should still exist + // key1 should be gone due to expiration and key3 should be removed by value. + assert.False(t, em.Has("key1")) + assert.False(t, em.Has("key3")) } func TestExpiryMap_ValueOperations_Integration(t *testing.T) { diff --git a/internal/hub/systems/system.go b/internal/hub/systems/system.go index f18704aa..265286de 100644 --- a/internal/hub/systems/system.go +++ b/internal/hub/systems/system.go @@ -9,7 +9,7 @@ import ( "math/rand" "net" "strings" - "sync" + "sync/atomic" "time" "github.com/henrygd/beszel/internal/common" @@ -29,19 +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 - 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 { @@ -114,10 +116,32 @@ func (sys *System) update() error { sys.handlePaused() return nil } - data, err := sys.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: uint16(interval)}) - if err == nil { - _, err = sys.createRecords(data) + options := common.DataRequestOptions{ + CacheTimeMs: uint16(interval), } + // fetch system details if not already fetched + if !sys.detailsFetched.Load() { + options.IncludeDetails = true + } + + data, err := sys.fetchDataFromAgent(options) + if err != nil { + return err + } + + // create system records + _, err = sys.createRecords(data) + + // Fetch and save SMART devices when system first comes online + if backgroundSmartFetchEnabled() && !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 err } @@ -142,12 +166,11 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error } 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) @@ -155,14 +178,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 @@ -183,9 +206,16 @@ 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", data.Info) if err := txApp.SaveNoValidate(systemRecord); err != nil { return err @@ -193,16 +223,34 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error return nil }) - // Fetch and save SMART devices when system first comes online - if err == nil { - sys.smartOnce.Do(func() { - go sys.FetchAndSaveSmartDevices() - }) - } - return systemRecord, err } +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(), + } + 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() + } + return err +} + func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId string) error { if len(data) == 0 { return nil diff --git a/internal/hub/systems/systems_production.go b/internal/hub/systems/systems_production.go new file mode 100644 index 00000000..bca88dc6 --- /dev/null +++ b/internal/hub/systems/systems_production.go @@ -0,0 +1,10 @@ +//go:build !testing +// +build !testing + +package systems + +// Background SMART fetching is enabled in production but disabled for tests (systems_test_helpers.go). +// +// The hub integration tests create/replace systems and clean up the test apps quickly. +// Background SMART fetching can outlive teardown and crash in PocketBase internals (nil DB). +func backgroundSmartFetchEnabled() bool { return true } diff --git a/internal/hub/systems/systems_test.go b/internal/hub/systems/systems_test.go index 5f362b23..db7e6bb3 100644 --- a/internal/hub/systems/systems_test.go +++ b/internal/hub/systems/systems_test.go @@ -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, diff --git a/internal/hub/systems/systems_test_helpers.go b/internal/hub/systems/systems_test_helpers.go index b49d8369..204ed486 100644 --- a/internal/hub/systems/systems_test_helpers.go +++ b/internal/hub/systems/systems_test_helpers.go @@ -10,6 +10,13 @@ import ( entities "github.com/henrygd/beszel/internal/entities/system" ) +// The hub integration tests create/replace systems and cleanup the test apps quickly. +// Background SMART fetching can outlive teardown and crash in PocketBase internals (nil DB). +// +// We keep the explicit SMART refresh endpoint / method available, but disable +// the automatic background fetch during tests. +func backgroundSmartFetchEnabled() bool { return false } + // TESTING ONLY: GetSystemCount returns the number of systems in the store func (sm *SystemManager) GetSystemCount() int { return sm.systems.Length() diff --git a/internal/migrations/0_collections_snapshot_0_17_1_dev_0.go b/internal/migrations/0_collections_snapshot_0_18_0_dev_1.go similarity index 90% rename from internal/migrations/0_collections_snapshot_0_17_1_dev_0.go rename to internal/migrations/0_collections_snapshot_0_18_0_dev_1.go index 69fcb86c..320b4eed 100644 --- a/internal/migrations/0_collections_snapshot_0_17_1_dev_0.go +++ b/internal/migrations/0_collections_snapshot_0_18_0_dev_1.go @@ -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" } ]` diff --git a/internal/site/biome.json b/internal/site/biome.json index 6d8387e9..d42a4ac7 100644 --- a/internal/site/biome.json +++ b/internal/site/biome.json @@ -33,10 +33,7 @@ "noUnusedFunctionParameters": "error", "noUnusedPrivateClassMembers": "error", "useExhaustiveDependencies": { - "level": "warn", - "options": { - "reportUnnecessaryDependencies": false - } + "level": "off" }, "useUniqueElementIds": "off", "noUnusedVariables": "error" diff --git a/internal/site/package.json b/internal/site/package.json index 6acdde7d..4a0f5aae 100644 --- a/internal/site/package.json +++ b/internal/site/package.json @@ -1,7 +1,7 @@ { "name": "beszel", "private": true, - "version": "0.17.0", + "version": "0.18.0-beta.1", "type": "module", "scripts": { "dev": "vite --host", diff --git a/internal/site/src/components/routes/system.tsx b/internal/site/src/components/routes/system.tsx index 8feec020..f2553352 100644 --- a/internal/site/src/components/routes/system.tsx +++ b/internal/site/src/components/routes/system.tsx @@ -3,15 +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, - LayoutGridIcon, - MonitorIcon, - 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" @@ -24,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, @@ -44,8 +36,6 @@ import { compareSemVer, decimalString, formatBytes, - secondsToString, - getHostDisplayValue, listen, parseSemVer, toFixedFloat, @@ -56,25 +46,24 @@ import type { ChartTimes, ContainerStatsRecord, GPUData, + SystemDetailsRecord, SystemInfo, SystemRecord, 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, 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 @@ -154,8 +143,8 @@ async function getStats( }) } -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 @@ -178,6 +167,8 @@ 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(null) + const [details, setDetails] = useState(null) + const isPodman = useMemo(() => details?.podman ?? system.info?.p ?? false, [details, system.info?.p]) useEffect(() => { return () => { @@ -187,6 +178,7 @@ export default memo(function SystemDetail({ id }: { id: string }) { persistChartTime.current = false setSystemStats([]) setContainerData([]) + setDetails(null) $containerFilter.set("") } }, [id]) @@ -214,10 +206,26 @@ export default memo(function SystemDetail({ id }: { id: string }) { } }, [system?.info?.v]) + // fetch system details + useEffect(() => { + // if system.info.m exists, agent is old version without system details + if (!system.id || system.info?.m) { + return + } + pb.collection("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(setDetails) + }, [system.id]) + // 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 } @@ -333,63 +341,6 @@ export default memo(function SystemDetail({ id }: { id: string }) { }) }, [system, chartTime]) - // 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") - } - 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 }, - osInfo[system.info.os ?? Os.Linux], - { - value: `${system.info.m} (${system.info.c}c${system.info.t ? `/${system.info.t}t` : ""})`, - Icon: CpuIcon, - hide: !system.info.m, - }, - ] as { - value: string | number | undefined - label?: string - Icon: React.ElementType - hide?: boolean - }[] - }, [system, t]) - /** Space for tooltip if more than 10 sensors and no containers table */ useEffect(() => { const sensors = Object.keys(systemStats.at(-1)?.stats.t ?? {}) @@ -458,113 +409,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 ( <>
{/* system info */} - -
-
-

{system.name}

-
- - - -
- - {system.status === SystemStatus.Up && ( - - )} - - - {translatedStatus} -
-
- {system.info.ct && ( - -
- {system.info.ct === ConnectionType.WebSocket ? ( - - ) : ( - - )} - {connectionTypeLabels[system.info.ct as ConnectionType]} -
-
- )} -
-
- - {systemInfo.map(({ value, label, Icon, hide }) => { - if (hide || !value) { - return null - } - const content = ( -
- {value} -
- ) - return ( -
- - {label ? ( - - - {content} - {label} - - - ) : ( - content - )} -
- ) - })} -
-
-
- - - - - - - {t`Toggle grid`} - - -
-
-
- + {/* @@ -576,7 +425,6 @@ export default memo(function SystemDetail({ id }: { id: string }) { */} - {/* main charts */}
@@ -639,8 +487,8 @@ export default memo(function SystemDetail({ id }: { id: string }) { +
{ 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 }, @@ -1003,9 +850,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
)} - {compareSemVer(chartData.agentVersion, parseSemVer("0.15.0")) >= 0 && ( - - )} + {compareSemVer(chartData.agentVersion, parseSemVer("0.15.0")) >= 0 && } {containerData.length > 0 && compareSemVer(chartData.agentVersion, parseSemVer("0.14.0")) >= 0 && ( @@ -1061,13 +906,10 @@ function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilt return () => clearTimeout(handle) }, [inputValue, storeValue, store]) - const handleChange = useCallback( - (e: React.ChangeEvent) => { - const value = e.target.value - setInputValue(value) - }, - [] - ) + const handleChange = useCallback((e: React.ChangeEvent) => { + const value = e.target.value + setInputValue(value) + }, []) const handleClear = useCallback(() => { setInputValue("") @@ -1194,4 +1036,4 @@ function LazySystemdTable({ systemId }: { systemId: string }) { {isIntersecting && }
) -} \ No newline at end of file +} diff --git a/internal/site/src/components/routes/system/info-bar.tsx b/internal/site/src/components/routes/system/info-bar.tsx new file mode 100644 index 00000000..f30106e7 --- /dev/null +++ b/internal/site/src/components/routes/system/info-bar.tsx @@ -0,0 +1,229 @@ +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 { useMemo } 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 { 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, + details, +}: { + system: SystemRecord + chartData: ChartData + grid: boolean + setGrid: (grid: boolean) => void + details: SystemDetailsRecord | null +}) { + const { t } = useLingui() + + // 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 ( + +
+
+

{system.name}

+
+ + + +
+ + {system.status === SystemStatus.Up && ( + + )} + + + {translatedStatus} +
+
+ {system.info.ct && ( + +
+ {system.info.ct === ConnectionType.WebSocket ? ( + + ) : ( + + )} + {connectionTypeLabels[system.info.ct as ConnectionType]} +
+
+ )} +
+
+ + {systemInfo.map(({ value, label, Icon, hide }) => { + if (hide || !value) { + return null + } + const content = ( +
+ {value} +
+ ) + return ( +
+ + {label ? ( + + + {content} + {label} + + + ) : ( + content + )} +
+ ) + })} +
+
+
+ + + + + + + {t`Toggle grid`} + + +
+
+
+ ) +} diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index 682e6960..20363a29 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -380,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