mirror of
https://github.com/henrygd/beszel.git
synced 2025-12-27 05:20:25 +00:00
Compare commits
1 Commits
nvml
...
952-collec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d71714cbba |
@@ -17,12 +17,19 @@ 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
|
||||
@@ -38,8 +45,8 @@ type Agent struct {
|
||||
netInterfaceDeltaTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] // Per-cache-time NIC delta trackers
|
||||
dockerManager *dockerManager // Manages Docker API requests
|
||||
sensorConfig *SensorConfig // Sensors config
|
||||
systemInfo system.Info // Host system info (dynamic)
|
||||
systemDetails system.Details // Host system details (static, once-per-connection)
|
||||
systemInfo system.Info // Host system info (dynamic dashboard data)
|
||||
staticSystemInfo system.StaticInfo // Static system info (collected at longer intervals)
|
||||
gpuManager *GPUManager // Manages GPU data
|
||||
cache *systemDataCache // Cache for system stats based on cache time
|
||||
connectionManager *ConnectionManager // Channel to signal connection events
|
||||
@@ -84,7 +91,6 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
||||
slog.Warn("Invalid DISK_USAGE_CACHE", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Set up slog with a log level determined by the LOG_LEVEL env var
|
||||
if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists {
|
||||
switch strings.ToLower(logLevelStr) {
|
||||
@@ -100,21 +106,8 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
||||
|
||||
slog.Debug(beszel.Version)
|
||||
|
||||
// initialize docker manager
|
||||
agent.dockerManager = newDockerManager()
|
||||
|
||||
// initialize system info
|
||||
agent.refreshSystemDetails()
|
||||
|
||||
// SMART_INTERVAL env var to update smart data at this interval
|
||||
if smartIntervalEnv, exists := GetEnv("SMART_INTERVAL"); exists {
|
||||
if duration, err := time.ParseDuration(smartIntervalEnv); err == nil && duration > 0 {
|
||||
agent.systemDetails.SmartInterval = duration
|
||||
slog.Info("SMART_INTERVAL", "duration", duration)
|
||||
} else {
|
||||
slog.Warn("Invalid SMART_INTERVAL", "err", err)
|
||||
}
|
||||
}
|
||||
agent.initializeSystemInfo()
|
||||
|
||||
// initialize connection manager
|
||||
agent.connectionManager = newConnectionManager(agent)
|
||||
@@ -128,6 +121,9 @@ 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)
|
||||
@@ -146,7 +142,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
||||
|
||||
// if debugging, print stats
|
||||
if agent.debug {
|
||||
slog.Debug("Stats", "data", agent.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000, IncludeDetails: true}))
|
||||
slog.Debug("Stats", "data", agent.gatherStats(0))
|
||||
}
|
||||
|
||||
return agent, nil
|
||||
@@ -161,11 +157,10 @@ func GetEnv(key string) (value string, exists bool) {
|
||||
return os.LookupEnv(key)
|
||||
}
|
||||
|
||||
func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedData {
|
||||
func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
|
||||
a.Lock()
|
||||
defer a.Unlock()
|
||||
|
||||
cacheTimeMs := options.CacheTimeMs
|
||||
data, isCached := a.cache.Get(cacheTimeMs)
|
||||
if isCached {
|
||||
slog.Debug("Cached data", "cacheTimeMs", cacheTimeMs)
|
||||
@@ -176,14 +171,16 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
||||
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)
|
||||
|
||||
// Include static info for requests with longer intervals (e.g., 15 min)
|
||||
// This reduces bandwidth by only sending static data occasionally
|
||||
if cacheTimeMs >= StaticInfoIntervalMs {
|
||||
staticInfoCopy := a.staticSystemInfo
|
||||
data.StaticInfo = &staticInfoCopy
|
||||
slog.Debug("Including static info", "cacheTimeMs", cacheTimeMs)
|
||||
}
|
||||
|
||||
if a.dockerManager != nil {
|
||||
if containerStats, err := a.dockerManager.getDockerStats(cacheTimeMs); err == nil {
|
||||
data.Containers = containerStats
|
||||
@@ -244,9 +241,12 @@ func (a *Agent) getFingerprint() string {
|
||||
|
||||
// if no fingerprint is found, generate one
|
||||
fingerprint, err := host.HostID()
|
||||
// we ignore a commonly known "product_uuid" known not to be unique
|
||||
if err != nil || fingerprint == "" || fingerprint == "03000200-0400-0500-0006-000700080009" {
|
||||
fingerprint = a.systemDetails.Hostname + a.systemDetails.CpuModel
|
||||
if err != nil || fingerprint == "" {
|
||||
cpuModel := ""
|
||||
if len(a.staticSystemInfo.Cpus) > 0 {
|
||||
cpuModel = a.staticSystemInfo.Cpus[0].Model
|
||||
}
|
||||
fingerprint = a.staticSystemInfo.Hostname + cpuModel
|
||||
}
|
||||
|
||||
// hash fingerprint
|
||||
|
||||
@@ -22,7 +22,7 @@ func createTestCacheData() *system.CombinedData {
|
||||
DiskTotal: 100000,
|
||||
},
|
||||
Info: system.Info{
|
||||
AgentVersion: "0.12.0",
|
||||
Hostname: "test-host",
|
||||
},
|
||||
Containers: []*container.Stats{
|
||||
{
|
||||
@@ -128,7 +128,7 @@ func TestCacheMultipleIntervals(t *testing.T) {
|
||||
Mem: 16384,
|
||||
},
|
||||
Info: system.Info{
|
||||
AgentVersion: "0.12.0",
|
||||
Hostname: "test-host-2",
|
||||
},
|
||||
Containers: []*container.Stats{},
|
||||
}
|
||||
@@ -171,7 +171,7 @@ func TestCacheOverwrite(t *testing.T) {
|
||||
Mem: 32768,
|
||||
},
|
||||
Info: system.Info{
|
||||
AgentVersion: "0.12.0",
|
||||
Hostname: "updated-host",
|
||||
},
|
||||
Containers: []*container.Stats{},
|
||||
}
|
||||
|
||||
@@ -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.systemDetails.Hostname
|
||||
response.Hostname = client.agent.staticSystemInfo.Hostname
|
||||
serverAddr := client.agent.connectionManager.serverOptions.Addr
|
||||
_, response.Port, _ = net.SplitHostPort(serverAddr)
|
||||
}
|
||||
|
||||
@@ -60,7 +60,6 @@ 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
|
||||
@@ -479,7 +478,7 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
|
||||
}
|
||||
|
||||
// Creates a new http client for Docker or Podman API
|
||||
func newDockerManager() *dockerManager {
|
||||
func newDockerManager(a *Agent) *dockerManager {
|
||||
dockerHost, exists := GetEnv("DOCKER_HOST")
|
||||
if exists {
|
||||
// return nil if set to empty string
|
||||
@@ -565,7 +564,7 @@ func newDockerManager() *dockerManager {
|
||||
|
||||
// If using podman, return client
|
||||
if strings.Contains(dockerHost, "podman") {
|
||||
manager.usingPodman = true
|
||||
a.staticSystemInfo.Podman = true
|
||||
manager.goodDockerVersion = true
|
||||
return manager
|
||||
}
|
||||
@@ -747,22 +746,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -802,24 +802,6 @@ 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{
|
||||
|
||||
25
agent/gpu.go
25
agent/gpu.go
@@ -44,7 +44,6 @@ type GPUManager struct {
|
||||
rocmSmi bool
|
||||
tegrastats bool
|
||||
intelGpuStats bool
|
||||
nvml bool
|
||||
GpuDataMap map[string]*system.GPUData
|
||||
// lastAvgData stores the last calculated averages for each GPU
|
||||
// Used when a collection happens before new data arrives (Count == 0)
|
||||
@@ -298,13 +297,8 @@ func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheK
|
||||
currentCount := uint32(gpu.Count)
|
||||
deltaCount := gm.calculateDeltaCount(currentCount, lastSnapshot)
|
||||
|
||||
// If no new data arrived
|
||||
// If no new data arrived, use last known average
|
||||
if deltaCount == 0 {
|
||||
// If GPU appears suspended (instantaneous values are 0), return zero values
|
||||
// Otherwise return last known average for temporary collection gaps
|
||||
if gpu.Temperature == 0 && gpu.MemoryUsed == 0 {
|
||||
return system.GPUData{Name: gpu.Name}
|
||||
}
|
||||
return gm.lastAvgData[id] // zero value if not found
|
||||
}
|
||||
|
||||
@@ -402,7 +396,7 @@ func (gm *GPUManager) detectGPUs() error {
|
||||
if _, err := exec.LookPath(intelGpuStatsCmd); err == nil {
|
||||
gm.intelGpuStats = true
|
||||
}
|
||||
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats || gm.intelGpuStats || gm.nvml {
|
||||
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats || gm.intelGpuStats {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("no GPU found - install nvidia-smi, rocm-smi, tegrastats, or intel_gpu_top")
|
||||
@@ -473,20 +467,7 @@ func NewGPUManager() (*GPUManager, error) {
|
||||
gm.GpuDataMap = make(map[string]*system.GPUData)
|
||||
|
||||
if gm.nvidiaSmi {
|
||||
if nvml, _ := GetEnv("NVML"); nvml == "true" {
|
||||
gm.nvml = true
|
||||
gm.nvidiaSmi = false
|
||||
collector := &nvmlCollector{gm: &gm}
|
||||
if err := collector.init(); err == nil {
|
||||
go collector.start()
|
||||
} else {
|
||||
slog.Warn("Failed to initialize NVML, falling back to nvidia-smi", "err", err)
|
||||
gm.nvidiaSmi = true
|
||||
gm.startCollector(nvidiaSmiCmd)
|
||||
}
|
||||
} else {
|
||||
gm.startCollector(nvidiaSmiCmd)
|
||||
}
|
||||
gm.startCollector(nvidiaSmiCmd)
|
||||
}
|
||||
if gm.rocmSmi {
|
||||
gm.startCollector(rocmSmiCmd)
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/ebitengine/purego"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
"golang.org/x/exp/slog"
|
||||
)
|
||||
|
||||
// NVML constants and types
|
||||
const (
|
||||
nvmlSuccess int = 0
|
||||
)
|
||||
|
||||
type nvmlDevice uintptr
|
||||
|
||||
type nvmlReturn int
|
||||
|
||||
type nvmlMemoryV1 struct {
|
||||
Total uint64
|
||||
Free uint64
|
||||
Used uint64
|
||||
}
|
||||
|
||||
type nvmlMemoryV2 struct {
|
||||
Version uint32
|
||||
Total uint64
|
||||
Reserved uint64
|
||||
Free uint64
|
||||
Used uint64
|
||||
}
|
||||
|
||||
type nvmlUtilization struct {
|
||||
Gpu uint32
|
||||
Memory uint32
|
||||
}
|
||||
|
||||
type nvmlPciInfo struct {
|
||||
BusId [16]byte
|
||||
Domain uint32
|
||||
Bus uint32
|
||||
Device uint32
|
||||
PciDeviceId uint32
|
||||
PciSubSystemId uint32
|
||||
}
|
||||
|
||||
// NVML function signatures
|
||||
var (
|
||||
nvmlInit func() nvmlReturn
|
||||
nvmlShutdown func() nvmlReturn
|
||||
nvmlDeviceGetCount func(count *uint32) nvmlReturn
|
||||
nvmlDeviceGetHandleByIndex func(index uint32, device *nvmlDevice) nvmlReturn
|
||||
nvmlDeviceGetName func(device nvmlDevice, name *byte, length uint32) nvmlReturn
|
||||
nvmlDeviceGetMemoryInfo func(device nvmlDevice, memory uintptr) nvmlReturn
|
||||
nvmlDeviceGetUtilizationRates func(device nvmlDevice, utilization *nvmlUtilization) nvmlReturn
|
||||
nvmlDeviceGetTemperature func(device nvmlDevice, sensorType int, temp *uint32) nvmlReturn
|
||||
nvmlDeviceGetPowerUsage func(device nvmlDevice, power *uint32) nvmlReturn
|
||||
nvmlDeviceGetPciInfo func(device nvmlDevice, pci *nvmlPciInfo) nvmlReturn
|
||||
nvmlErrorString func(result nvmlReturn) string
|
||||
)
|
||||
|
||||
type nvmlCollector struct {
|
||||
gm *GPUManager
|
||||
lib uintptr
|
||||
devices []nvmlDevice
|
||||
bdfs []string
|
||||
isV2 bool
|
||||
}
|
||||
|
||||
func (c *nvmlCollector) init() error {
|
||||
slog.Info("NVML: Initializing")
|
||||
libPath := getNVMLPath()
|
||||
|
||||
lib, err := openLibrary(libPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load %s: %w", libPath, err)
|
||||
}
|
||||
c.lib = lib
|
||||
|
||||
purego.RegisterLibFunc(&nvmlInit, lib, "nvmlInit")
|
||||
purego.RegisterLibFunc(&nvmlShutdown, lib, "nvmlShutdown")
|
||||
purego.RegisterLibFunc(&nvmlDeviceGetCount, lib, "nvmlDeviceGetCount")
|
||||
purego.RegisterLibFunc(&nvmlDeviceGetHandleByIndex, lib, "nvmlDeviceGetHandleByIndex")
|
||||
purego.RegisterLibFunc(&nvmlDeviceGetName, lib, "nvmlDeviceGetName")
|
||||
// Try to get v2 memory info, fallback to v1 if not available
|
||||
if hasSymbol(lib, "nvmlDeviceGetMemoryInfo_v2") {
|
||||
c.isV2 = true
|
||||
purego.RegisterLibFunc(&nvmlDeviceGetMemoryInfo, lib, "nvmlDeviceGetMemoryInfo_v2")
|
||||
} else {
|
||||
purego.RegisterLibFunc(&nvmlDeviceGetMemoryInfo, lib, "nvmlDeviceGetMemoryInfo")
|
||||
}
|
||||
purego.RegisterLibFunc(&nvmlDeviceGetUtilizationRates, lib, "nvmlDeviceGetUtilizationRates")
|
||||
purego.RegisterLibFunc(&nvmlDeviceGetTemperature, lib, "nvmlDeviceGetTemperature")
|
||||
purego.RegisterLibFunc(&nvmlDeviceGetPowerUsage, lib, "nvmlDeviceGetPowerUsage")
|
||||
purego.RegisterLibFunc(&nvmlDeviceGetPciInfo, lib, "nvmlDeviceGetPciInfo")
|
||||
purego.RegisterLibFunc(&nvmlErrorString, lib, "nvmlErrorString")
|
||||
|
||||
if ret := nvmlInit(); ret != nvmlReturn(nvmlSuccess) {
|
||||
return fmt.Errorf("nvmlInit failed: %v", ret)
|
||||
}
|
||||
|
||||
var count uint32
|
||||
if ret := nvmlDeviceGetCount(&count); ret != nvmlReturn(nvmlSuccess) {
|
||||
return fmt.Errorf("nvmlDeviceGetCount failed: %v", ret)
|
||||
}
|
||||
|
||||
for i := uint32(0); i < count; i++ {
|
||||
var device nvmlDevice
|
||||
if ret := nvmlDeviceGetHandleByIndex(i, &device); ret == nvmlReturn(nvmlSuccess) {
|
||||
c.devices = append(c.devices, device)
|
||||
// Get BDF for power state check
|
||||
var pci nvmlPciInfo
|
||||
if ret := nvmlDeviceGetPciInfo(device, &pci); ret == nvmlReturn(nvmlSuccess) {
|
||||
busID := string(pci.BusId[:])
|
||||
if idx := strings.Index(busID, "\x00"); idx != -1 {
|
||||
busID = busID[:idx]
|
||||
}
|
||||
c.bdfs = append(c.bdfs, strings.ToLower(busID))
|
||||
} else {
|
||||
c.bdfs = append(c.bdfs, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *nvmlCollector) start() {
|
||||
defer nvmlShutdown()
|
||||
ticker := time.Tick(3 * time.Second)
|
||||
|
||||
for range ticker {
|
||||
c.collect()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *nvmlCollector) collect() {
|
||||
c.gm.Lock()
|
||||
defer c.gm.Unlock()
|
||||
|
||||
for i, device := range c.devices {
|
||||
id := fmt.Sprintf("%d", i)
|
||||
bdf := c.bdfs[i]
|
||||
|
||||
// Update GPUDataMap
|
||||
if _, ok := c.gm.GpuDataMap[id]; !ok {
|
||||
var nameBuf [64]byte
|
||||
if ret := nvmlDeviceGetName(device, &nameBuf[0], 64); ret != nvmlReturn(nvmlSuccess) {
|
||||
continue
|
||||
}
|
||||
name := string(nameBuf[:strings.Index(string(nameBuf[:]), "\x00")])
|
||||
name = strings.TrimPrefix(name, "NVIDIA ")
|
||||
c.gm.GpuDataMap[id] = &system.GPUData{Name: strings.TrimSuffix(name, " Laptop GPU")}
|
||||
}
|
||||
gpu := c.gm.GpuDataMap[id]
|
||||
|
||||
if bdf != "" && !c.isGPUActive(bdf) {
|
||||
slog.Info("NVML: GPU is suspended, skipping", "bdf", bdf)
|
||||
gpu.Temperature = 0
|
||||
gpu.MemoryUsed = 0
|
||||
continue
|
||||
}
|
||||
|
||||
// Utilization
|
||||
var utilization nvmlUtilization
|
||||
if ret := nvmlDeviceGetUtilizationRates(device, &utilization); ret != nvmlReturn(nvmlSuccess) {
|
||||
slog.Info("NVML: Utilization failed (GPU likely suspended)", "bdf", bdf, "ret", ret)
|
||||
gpu.Temperature = 0
|
||||
gpu.MemoryUsed = 0
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("NVML: Collecting data for GPU", "bdf", bdf)
|
||||
|
||||
// Temperature
|
||||
var temp uint32
|
||||
nvmlDeviceGetTemperature(device, 0, &temp) // 0 is NVML_TEMPERATURE_GPU
|
||||
|
||||
// Memory
|
||||
var usedMem, totalMem uint64
|
||||
if c.isV2 {
|
||||
var memory nvmlMemoryV2
|
||||
memory.Version = 0x02000028 // (2 << 24) | 40 bytes
|
||||
nvmlDeviceGetMemoryInfo(device, uintptr(unsafe.Pointer(&memory)))
|
||||
usedMem = memory.Used
|
||||
totalMem = memory.Total
|
||||
} else {
|
||||
var memory nvmlMemoryV1
|
||||
nvmlDeviceGetMemoryInfo(device, uintptr(unsafe.Pointer(&memory)))
|
||||
usedMem = memory.Used
|
||||
totalMem = memory.Total
|
||||
}
|
||||
|
||||
// Power
|
||||
var power uint32
|
||||
nvmlDeviceGetPowerUsage(device, &power)
|
||||
|
||||
gpu.Temperature = float64(temp)
|
||||
gpu.MemoryUsed = float64(usedMem) / 1024 / 1024 / mebibytesInAMegabyte
|
||||
gpu.MemoryTotal = float64(totalMem) / 1024 / 1024 / mebibytesInAMegabyte
|
||||
gpu.Usage += float64(utilization.Gpu)
|
||||
gpu.Power += float64(power) / 1000.0
|
||||
gpu.Count++
|
||||
slog.Info("NVML: Collected data", "gpu", gpu)
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/ebitengine/purego"
|
||||
"golang.org/x/exp/slog"
|
||||
)
|
||||
|
||||
func openLibrary(name string) (uintptr, error) {
|
||||
return purego.Dlopen(name, purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
||||
}
|
||||
|
||||
func getNVMLPath() string {
|
||||
return "libnvidia-ml.so.1"
|
||||
}
|
||||
|
||||
func hasSymbol(lib uintptr, symbol string) bool {
|
||||
_, err := purego.Dlsym(lib, symbol)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (c *nvmlCollector) isGPUActive(bdf string) bool {
|
||||
// runtime_status
|
||||
statusPath := filepath.Join("/sys/bus/pci/devices", bdf, "power/runtime_status")
|
||||
status, err := os.ReadFile(statusPath)
|
||||
if err != nil {
|
||||
slog.Info("NVML: Can't read runtime_status", "bdf", bdf, "err", err)
|
||||
return true // Assume active if we can't read status
|
||||
}
|
||||
statusStr := strings.TrimSpace(string(status))
|
||||
if statusStr != "active" && statusStr != "resuming" {
|
||||
slog.Info("NVML: GPU is not active", "bdf", bdf, "status", statusStr)
|
||||
return false
|
||||
}
|
||||
|
||||
// power_state (D0 check)
|
||||
// Find any drm card device power_state
|
||||
pstatePathPattern := filepath.Join("/sys/bus/pci/devices", bdf, "drm/card*/device/power_state")
|
||||
matches, _ := filepath.Glob(pstatePathPattern)
|
||||
if len(matches) > 0 {
|
||||
pstate, err := os.ReadFile(matches[0])
|
||||
if err == nil {
|
||||
pstateStr := strings.TrimSpace(string(pstate))
|
||||
if pstateStr != "D0" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
//go:build !linux && !windows
|
||||
|
||||
package agent
|
||||
|
||||
import "fmt"
|
||||
|
||||
func openLibrary(name string) (uintptr, error) {
|
||||
return 0, fmt.Errorf("nvml not supported on this platform")
|
||||
}
|
||||
|
||||
func getNVMLPath() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func hasSymbol(lib uintptr, symbol string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *nvmlCollector) isGPUActive(bdf string) bool {
|
||||
return true
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func openLibrary(name string) (uintptr, error) {
|
||||
handle, err := windows.LoadLibrary(name)
|
||||
return uintptr(handle), err
|
||||
}
|
||||
|
||||
func getNVMLPath() string {
|
||||
return "nvml.dll"
|
||||
}
|
||||
|
||||
func hasSymbol(lib uintptr, symbol string) bool {
|
||||
_, err := windows.GetProcAddress(windows.Handle(lib), symbol)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (c *nvmlCollector) isGPUActive(bdf string) bool {
|
||||
return true
|
||||
}
|
||||
@@ -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)
|
||||
sysStats := hctx.Agent.gatherStats(options.CacheTimeMs)
|
||||
return hctx.SendResponse(sysStats, hctx.RequestID)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(common.DataRequestOptions{CacheTimeMs: 60_000})
|
||||
stats := a.gatherStats(60_000)
|
||||
return a.writeToSession(w, stats, hubVersion)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.Details.Hostname, decodedCbor.Details.Hostname)
|
||||
assert.Equal(t, testData.Info.Hostname, decodedCbor.Info.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.Details.Hostname, decodedJson.Details.Hostname)
|
||||
assert.Equal(t, testData.Info.Hostname, decodedJson.Info.Hostname)
|
||||
assert.Equal(t, testData.Stats.Cpu, decodedJson.Stats.Cpu)
|
||||
|
||||
// Verify it looks like JSON (starts with '{' and contains readable field names)
|
||||
@@ -550,10 +550,8 @@ func createTestCombinedData() *system.CombinedData {
|
||||
DiskUsed: 549755813888, // 512GB
|
||||
DiskPct: 50.0,
|
||||
},
|
||||
Details: &system.Details{
|
||||
Hostname: "test-host",
|
||||
},
|
||||
Info: system.Info{
|
||||
Hostname: "test-host",
|
||||
Uptime: 3600,
|
||||
AgentVersion: "0.12.0",
|
||||
},
|
||||
|
||||
300
agent/system.go
300
agent/system.go
@@ -2,7 +2,6 @@ package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -13,9 +12,10 @@ 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,80 +30,77 @@ type prevDisk struct {
|
||||
}
|
||||
|
||||
// Sets initial / non-changing values about the host system
|
||||
func (a *Agent) refreshSystemDetails() {
|
||||
a.systemInfo.AgentVersion = beszel.Version
|
||||
func (a *Agent) initializeSystemInfo() {
|
||||
hostname, _ := os.Hostname()
|
||||
a.staticSystemInfo.Hostname = hostname
|
||||
a.staticSystemInfo.AgentVersion = beszel.Version
|
||||
|
||||
// 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()
|
||||
platform, family, version, _ := host.PlatformInformation()
|
||||
|
||||
var osFamily, osVersion, osKernel string
|
||||
var osType system.Os
|
||||
if platform == "darwin" {
|
||||
a.systemDetails.Os = system.Darwin
|
||||
a.systemDetails.OsName = fmt.Sprintf("macOS %s", version)
|
||||
osKernel = version
|
||||
osFamily = "macOS" // macOS is the family name for Darwin
|
||||
osVersion = version
|
||||
} else if strings.Contains(platform, "indows") {
|
||||
a.systemDetails.Os = system.Windows
|
||||
a.systemDetails.OsName = strings.Replace(platform, "Microsoft ", "", 1)
|
||||
a.systemDetails.Kernel = version
|
||||
osKernel = strings.Replace(platform, "Microsoft ", "", 1) + " " + version
|
||||
osFamily = family
|
||||
osVersion = version
|
||||
osType = system.Windows
|
||||
} 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"
|
||||
}
|
||||
osKernel = version
|
||||
osFamily = family
|
||||
osVersion = version
|
||||
} 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
|
||||
}
|
||||
osFamily = family
|
||||
osVersion = version
|
||||
osKernel = ""
|
||||
osRelease := readOsRelease()
|
||||
if pretty, ok := osRelease["PRETTY_NAME"]; ok {
|
||||
osFamily = pretty
|
||||
}
|
||||
a.systemDetails.Kernel = hostInfo.KernelVersion
|
||||
if a.systemDetails.Kernel == "" {
|
||||
a.systemDetails.Kernel, _ = host.KernelVersion()
|
||||
if name, ok := osRelease["NAME"]; ok {
|
||||
osFamily = name
|
||||
}
|
||||
if versionId, ok := osRelease["VERSION_ID"]; ok {
|
||||
osVersion = versionId
|
||||
}
|
||||
}
|
||||
if osKernel == "" {
|
||||
osKernel, _ = host.KernelVersion()
|
||||
}
|
||||
a.staticSystemInfo.KernelVersion = osKernel
|
||||
a.staticSystemInfo.Os = osType
|
||||
a.staticSystemInfo.Oses = []system.OsInfo{{
|
||||
Family: osFamily,
|
||||
Version: osVersion,
|
||||
Kernel: osKernel,
|
||||
}}
|
||||
|
||||
// cpu model
|
||||
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
||||
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
|
||||
arch := runtime.GOARCH
|
||||
totalCores := 0
|
||||
totalThreads := 0
|
||||
for _, cpuInfo := range info {
|
||||
totalCores += int(cpuInfo.Cores)
|
||||
totalThreads++
|
||||
}
|
||||
modelName := info[0].ModelName
|
||||
if idx := strings.Index(modelName, "@"); idx > 0 {
|
||||
modelName = strings.TrimSpace(modelName[:idx])
|
||||
}
|
||||
cpu := system.CpuInfo{
|
||||
Model: modelName,
|
||||
SpeedGHz: fmt.Sprintf("%.2f GHz", info[0].Mhz/1000),
|
||||
Arch: arch,
|
||||
Cores: totalCores,
|
||||
Threads: totalThreads,
|
||||
}
|
||||
a.staticSystemInfo.Cpus = []system.CpuInfo{cpu}
|
||||
a.staticSystemInfo.Threads = totalThreads
|
||||
slog.Debug("CPU info populated", "cpus", a.staticSystemInfo.Cpus)
|
||||
}
|
||||
|
||||
// zfs
|
||||
@@ -112,6 +109,41 @@ func (a *Agent) refreshSystemDetails() {
|
||||
} 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
|
||||
@@ -236,16 +268,22 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
||||
}
|
||||
}
|
||||
|
||||
// update system info
|
||||
// update base system info
|
||||
a.systemInfo.ConnectionType = a.connectionManager.ConnectionType
|
||||
a.systemInfo.Cpu = systemStats.Cpu
|
||||
a.systemInfo.LoadAvg = systemStats.LoadAvg
|
||||
// TODO: remove these in future release in favor of load avg array
|
||||
a.systemInfo.LoadAvg1 = systemStats.LoadAvg[0]
|
||||
a.systemInfo.LoadAvg5 = systemStats.LoadAvg[1]
|
||||
a.systemInfo.LoadAvg15 = systemStats.LoadAvg[2]
|
||||
a.systemInfo.MemPct = systemStats.MemPct
|
||||
a.systemInfo.DiskPct = systemStats.DiskPct
|
||||
a.systemInfo.Battery = systemStats.Battery
|
||||
a.systemInfo.Uptime, _ = host.Uptime()
|
||||
// TODO: in future release, remove MB bandwidth values in favor of bytes
|
||||
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
|
||||
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
|
||||
a.systemInfo.Threads = a.systemDetails.Threads
|
||||
slog.Debug("sysinfo", "data", a.systemInfo)
|
||||
|
||||
return systemStats
|
||||
}
|
||||
@@ -276,23 +314,135 @@ 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) {
|
||||
func getDiskInfo() []system.DiskInfo {
|
||||
blockInfo, err := block.New()
|
||||
if err != nil {
|
||||
slog.Debug("Failed to get block info with ghw", "err", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var disks []system.DiskInfo
|
||||
for _, disk := range blockInfo.Disks {
|
||||
disks = append(disks, system.DiskInfo{
|
||||
Name: disk.Name,
|
||||
Model: disk.Model,
|
||||
Vendor: disk.Vendor,
|
||||
})
|
||||
}
|
||||
return disks
|
||||
}
|
||||
|
||||
func getNetworkInfo() []system.NetworkInfo {
|
||||
netInfo, err := ghwnet.New()
|
||||
if err != nil {
|
||||
slog.Debug("Failed to get network info with ghw", "err", err)
|
||||
return nil
|
||||
}
|
||||
pciInfo, err := ghwpci.New()
|
||||
if err != nil {
|
||||
slog.Debug("Failed to get PCI info with ghw", "err", err)
|
||||
}
|
||||
|
||||
var networks []system.NetworkInfo
|
||||
for _, nic := range netInfo.NICs {
|
||||
if nic.IsVirtual {
|
||||
continue
|
||||
}
|
||||
var vendor, model string
|
||||
if nic.PCIAddress != nil && pciInfo != nil {
|
||||
for _, dev := range pciInfo.Devices {
|
||||
if dev.Address == *nic.PCIAddress {
|
||||
if dev.Vendor != nil {
|
||||
vendor = dev.Vendor.Name
|
||||
}
|
||||
if dev.Product != nil {
|
||||
model = dev.Product.Name
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
networks = append(networks, system.NetworkInfo{
|
||||
Name: nic.Name,
|
||||
Vendor: vendor,
|
||||
Model: model,
|
||||
})
|
||||
}
|
||||
return networks
|
||||
}
|
||||
|
||||
// getInterfaceCapabilitiesFromGhw uses ghw library to get interface capabilities
|
||||
func getInterfaceCapabilitiesFromGhw(nic *ghwnet.NIC) string {
|
||||
// Use the speed information from ghw if available
|
||||
if nic.Speed != "" {
|
||||
return nic.Speed
|
||||
}
|
||||
|
||||
// If no speed info from ghw, try to get interface type from name
|
||||
return getInterfaceTypeFromName(nic.Name)
|
||||
}
|
||||
|
||||
// getInterfaceTypeFromName tries to determine interface type from name
|
||||
func getInterfaceTypeFromName(ifaceName string) string {
|
||||
// Common interface naming patterns
|
||||
switch {
|
||||
case strings.HasPrefix(ifaceName, "eth"):
|
||||
return "Ethernet"
|
||||
case strings.HasPrefix(ifaceName, "en"):
|
||||
return "Ethernet"
|
||||
case strings.HasPrefix(ifaceName, "wlan"):
|
||||
return "WiFi"
|
||||
case strings.HasPrefix(ifaceName, "wl"):
|
||||
return "WiFi"
|
||||
case strings.HasPrefix(ifaceName, "usb"):
|
||||
return "USB"
|
||||
case strings.HasPrefix(ifaceName, "tun"):
|
||||
return "Tunnel"
|
||||
case strings.HasPrefix(ifaceName, "tap"):
|
||||
return "TAP"
|
||||
case strings.HasPrefix(ifaceName, "br"):
|
||||
return "Bridge"
|
||||
case strings.HasPrefix(ifaceName, "bond"):
|
||||
return "Bond"
|
||||
case strings.HasPrefix(ifaceName, "veth"):
|
||||
return "Virtual Ethernet"
|
||||
case strings.HasPrefix(ifaceName, "docker"):
|
||||
return "Docker"
|
||||
case strings.HasPrefix(ifaceName, "lo"):
|
||||
return "Loopback"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func readOsRelease() map[string]string {
|
||||
file, err := os.Open("/etc/os-release")
|
||||
if err != nil {
|
||||
return "", err
|
||||
return map[string]string{}
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
release := make(map[string]string)
|
||||
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
|
||||
if i := strings.Index(line, "="); i > 0 {
|
||||
key := line[:i]
|
||||
val := strings.Trim(line[i+1:], `"`)
|
||||
release[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New("pretty name not found")
|
||||
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,
|
||||
}}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import "github.com/blang/semver"
|
||||
|
||||
const (
|
||||
// Version is the current version of the application.
|
||||
Version = "0.18.0-beta.1"
|
||||
Version = "0.17.0"
|
||||
// AppName is the name of the application.
|
||||
AppName = "beszel"
|
||||
)
|
||||
|
||||
9
go.mod
9
go.mod
@@ -6,10 +6,10 @@ require (
|
||||
github.com/blang/semver v3.5.1+incompatible
|
||||
github.com/coreos/go-systemd/v22 v22.6.0
|
||||
github.com/distatus/battery v0.11.0
|
||||
github.com/ebitengine/purego v0.9.1
|
||||
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
|
||||
@@ -21,11 +21,11 @@ require (
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39
|
||||
golang.org/x/sys v0.38.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
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
|
||||
@@ -33,6 +33,7 @@ require (
|
||||
github.com/dolthub/maphash v0.1.0 // indirect
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.9.1 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
|
||||
@@ -42,11 +43,14 @@ require (
|
||||
github.com/godbus/dbus/v5 v5.2.0 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jaypipes/pcidb v1.0.1 // indirect
|
||||
github.com/klauspost/compress v1.18.1 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
@@ -58,6 +62,7 @@ require (
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/oauth2 v0.33.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/term v0.37.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
howett.net/plist v1.0.1 // indirect
|
||||
|
||||
25
go.sum
25
go.sum
@@ -2,6 +2,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
|
||||
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
||||
@@ -41,6 +43,7 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
@@ -62,10 +65,16 @@ github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 h1:3DsUAV+VNEQa2CUVLx
|
||||
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
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=
|
||||
@@ -81,6 +90,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/nicholas-fedor/shoutrrr v0.12.1 h1:8NjY+I3K7cGHy89ncnaPGUA0ex44XbYK3SAFJX9YMI8=
|
||||
@@ -89,6 +100,8 @@ github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns
|
||||
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -171,18 +184,22 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||
modernc.org/libc v1.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk=
|
||||
modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
|
||||
@@ -60,10 +60,10 @@ func TestBatteryAlertLogic(t *testing.T) {
|
||||
combinedDataHigh := &system.CombinedData{
|
||||
Stats: statsHigh,
|
||||
Info: system.Info{
|
||||
AgentVersion: "0.12.0",
|
||||
Cpu: 10,
|
||||
MemPct: 30,
|
||||
DiskPct: 40,
|
||||
Hostname: "test-host",
|
||||
Cpu: 10,
|
||||
MemPct: 30,
|
||||
DiskPct: 40,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -100,10 +100,10 @@ func TestBatteryAlertLogic(t *testing.T) {
|
||||
combinedDataLow := &system.CombinedData{
|
||||
Stats: statsLow,
|
||||
Info: system.Info{
|
||||
AgentVersion: "0.12.0",
|
||||
Cpu: 10,
|
||||
MemPct: 30,
|
||||
DiskPct: 40,
|
||||
Hostname: "test-host",
|
||||
Cpu: 10,
|
||||
MemPct: 30,
|
||||
DiskPct: 40,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -142,10 +142,10 @@ func TestBatteryAlertLogic(t *testing.T) {
|
||||
combinedDataRecovered := &system.CombinedData{
|
||||
Stats: statsRecovered,
|
||||
Info: system.Info{
|
||||
AgentVersion: "0.12.0",
|
||||
Cpu: 10,
|
||||
MemPct: 30,
|
||||
DiskPct: 40,
|
||||
Hostname: "test-host",
|
||||
Cpu: 10,
|
||||
MemPct: 30,
|
||||
DiskPct: 40,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -198,10 +198,10 @@ func TestBatteryAlertNoBattery(t *testing.T) {
|
||||
combinedData := &system.CombinedData{
|
||||
Stats: statsNoBattery,
|
||||
Info: system.Info{
|
||||
AgentVersion: "0.12.0",
|
||||
Cpu: 10,
|
||||
MemPct: 30,
|
||||
DiskPct: 40,
|
||||
Hostname: "test-host",
|
||||
Cpu: 10,
|
||||
MemPct: 30,
|
||||
DiskPct: 40,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -294,10 +294,10 @@ func TestBatteryAlertAveragedSamples(t *testing.T) {
|
||||
Battery: [2]uint8{15, 1},
|
||||
},
|
||||
Info: system.Info{
|
||||
AgentVersion: "0.12.0",
|
||||
Cpu: 10,
|
||||
MemPct: 30,
|
||||
DiskPct: 40,
|
||||
Hostname: "test-host",
|
||||
Cpu: 10,
|
||||
MemPct: 30,
|
||||
DiskPct: 40,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -360,10 +360,10 @@ func TestBatteryAlertAveragedSamples(t *testing.T) {
|
||||
Battery: [2]uint8{50, 1},
|
||||
},
|
||||
Info: system.Info{
|
||||
AgentVersion: "0.12.0",
|
||||
Cpu: 10,
|
||||
MemPct: 30,
|
||||
DiskPct: 40,
|
||||
Hostname: "test-host",
|
||||
Cpu: 10,
|
||||
MemPct: 30,
|
||||
DiskPct: 40,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -58,8 +58,8 @@ type FingerprintResponse struct {
|
||||
}
|
||||
|
||||
type DataRequestOptions struct {
|
||||
CacheTimeMs uint16 `cbor:"0,keyasint"`
|
||||
IncludeDetails bool `cbor:"1,keyasint"`
|
||||
CacheTimeMs uint16 `cbor:"0,keyasint"`
|
||||
// ResourceType uint8 `cbor:"1,keyasint,omitempty,omitzero"`
|
||||
}
|
||||
|
||||
type ContainerLogsRequest struct {
|
||||
|
||||
@@ -34,14 +34,6 @@ 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
|
||||
|
||||
@@ -115,6 +115,37 @@ const (
|
||||
Freebsd
|
||||
)
|
||||
|
||||
type DiskInfo struct {
|
||||
Name string `json:"n"`
|
||||
Model string `json:"m,omitempty"`
|
||||
Vendor string `json:"v,omitempty"`
|
||||
}
|
||||
|
||||
type NetworkInfo struct {
|
||||
Name string `json:"n"`
|
||||
Vendor string `json:"v,omitempty"`
|
||||
Model string `json:"m,omitempty"`
|
||||
Speed string `json:"s,omitempty"`
|
||||
}
|
||||
|
||||
type MemoryInfo struct {
|
||||
Total string `json:"t,omitempty"`
|
||||
}
|
||||
|
||||
type CpuInfo struct {
|
||||
Model string `json:"m"`
|
||||
SpeedGHz string `json:"s"`
|
||||
Arch string `json:"a"`
|
||||
Cores int `json:"c"`
|
||||
Threads int `json:"t"`
|
||||
}
|
||||
|
||||
type OsInfo struct {
|
||||
Family string `json:"f"`
|
||||
Version string `json:"v"`
|
||||
Kernel string `json:"k"`
|
||||
}
|
||||
|
||||
type ConnectionType = uint8
|
||||
|
||||
const (
|
||||
@@ -123,29 +154,36 @@ const (
|
||||
ConnectionTypeWebSocket
|
||||
)
|
||||
|
||||
// Core system data that is needed in All Systems table
|
||||
type Info struct {
|
||||
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,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"` // 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,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"`
|
||||
// StaticInfo contains system information that rarely or never changes
|
||||
// This is collected at a longer interval (e.g., 10-15 minutes) to reduce bandwidth
|
||||
type StaticInfo struct {
|
||||
Hostname string `json:"h" cbor:"0,keyasint"`
|
||||
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
|
||||
Threads int `json:"t,omitempty" cbor:"2,keyasint,omitempty"`
|
||||
AgentVersion string `json:"v" cbor:"3,keyasint"`
|
||||
Podman bool `json:"p,omitempty" cbor:"4,keyasint,omitempty"`
|
||||
Os Os `json:"os" cbor:"5,keyasint"`
|
||||
Disks []DiskInfo `json:"d,omitempty" cbor:"6,omitempty"`
|
||||
Networks []NetworkInfo `json:"n,omitempty" cbor:"7,omitempty"`
|
||||
Memory []MemoryInfo `json:"m" cbor:"8"`
|
||||
Cpus []CpuInfo `json:"c" cbor:"9"`
|
||||
Oses []OsInfo `json:"o,omitempty" cbor:"10,omitempty"`
|
||||
}
|
||||
|
||||
// Info contains frequently-changing system snapshot data for the dashboard
|
||||
type Info struct {
|
||||
Uptime uint64 `json:"u" cbor:"0,keyasint"`
|
||||
Cpu float64 `json:"cpu" cbor:"1,keyasint"`
|
||||
MemPct float64 `json:"mp" cbor:"2,keyasint"`
|
||||
DiskPct float64 `json:"dp" cbor:"3,keyasint"`
|
||||
Bandwidth float64 `json:"b" cbor:"4,keyasint"`
|
||||
GpuPct float64 `json:"g,omitempty" cbor:"5,keyasint,omitempty"`
|
||||
DashboardTemp float64 `json:"dt,omitempty" cbor:"6,keyasint,omitempty"`
|
||||
LoadAvg1 float64 `json:"l1,omitempty" cbor:"7,keyasint,omitempty"`
|
||||
LoadAvg5 float64 `json:"l5,omitempty" cbor:"8,keyasint,omitempty"`
|
||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"9,keyasint,omitempty"`
|
||||
BandwidthBytes uint64 `json:"bb" cbor:"10,keyasint"`
|
||||
// TODO: remove load fields in future release in favor of load avg array
|
||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
||||
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
|
||||
ExtraFsPct map[string]float64 `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
|
||||
@@ -153,26 +191,11 @@ 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"`
|
||||
SmartInterval time.Duration `cbor:"10,keyasint,omitempty"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
StaticInfo *StaticInfo `json:"static_info,omitempty" cbor:"4,keyasint,omitempty"` // Collected at longer intervals
|
||||
}
|
||||
|
||||
@@ -415,11 +415,7 @@ func TestExpiryMap_RemoveValue_WithExpiration(t *testing.T) {
|
||||
// Wait for first value to expire
|
||||
time.Sleep(time.Millisecond * 20)
|
||||
|
||||
// Trigger lazy cleanup of the expired key
|
||||
_, ok := em.GetOk("key1")
|
||||
assert.False(t, ok)
|
||||
|
||||
// Try to remove the remaining "value1" entry (key3)
|
||||
// Try to remove the expired value - should remove one of the "value1" entries
|
||||
removedValue, ok := em.RemovebyValue("value1")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "value1", removedValue)
|
||||
@@ -427,9 +423,14 @@ func TestExpiryMap_RemoveValue_WithExpiration(t *testing.T) {
|
||||
// Should still have key2 (different value)
|
||||
assert.True(t, em.Has("key2"))
|
||||
|
||||
// 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"))
|
||||
// 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
|
||||
}
|
||||
|
||||
func TestExpiryMap_ValueOperations_Integration(t *testing.T) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"math/rand"
|
||||
"net"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
@@ -29,22 +29,20 @@ 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
|
||||
detailsFetched atomic.Bool // True if static system details have been fetched and saved
|
||||
smartFetching atomic.Bool // True if SMART devices are currently being fetched
|
||||
smartInterval time.Duration // Interval for periodic SMART data updates
|
||||
lastSmartFetch atomic.Int64 // Unix milliseconds of last SMART data fetch
|
||||
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
|
||||
}
|
||||
|
||||
func (sm *SystemManager) NewSystem(systemId string) *System {
|
||||
@@ -117,37 +115,24 @@ func (sys *System) update() error {
|
||||
sys.handlePaused()
|
||||
return nil
|
||||
}
|
||||
options := common.DataRequestOptions{
|
||||
CacheTimeMs: uint16(interval),
|
||||
}
|
||||
// fetch system details if not already fetched
|
||||
if !sys.detailsFetched.Load() {
|
||||
options.IncludeDetails = true
|
||||
|
||||
// Determine which cache time to use based on whether we need static info
|
||||
cacheTimeMs := uint16(interval)
|
||||
if sys.staticInfo == nil {
|
||||
// Request with a cache time that signals the agent to include static info
|
||||
// We use 60001ms (just above the standard interval) since uint16 max is 65535
|
||||
cacheTimeMs = 60_001
|
||||
}
|
||||
|
||||
data, err := sys.fetchDataFromAgent(options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create system records
|
||||
_, err = sys.createRecords(data)
|
||||
|
||||
// Fetch and save SMART devices when system first comes online or at intervals
|
||||
if backgroundSmartFetchEnabled() {
|
||||
if sys.smartInterval <= 0 {
|
||||
sys.smartInterval = time.Hour
|
||||
}
|
||||
lastFetch := sys.lastSmartFetch.Load()
|
||||
if time.Since(time.UnixMilli(lastFetch)) >= sys.smartInterval && sys.smartFetching.CompareAndSwap(false, true) {
|
||||
go func() {
|
||||
defer sys.smartFetching.Store(false)
|
||||
sys.lastSmartFetch.Store(time.Now().UnixMilli())
|
||||
_ = sys.FetchAndSaveSmartDevices()
|
||||
}()
|
||||
data, err := sys.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: cacheTimeMs})
|
||||
if err == nil {
|
||||
// If we received static info, cache it
|
||||
if data.StaticInfo != nil {
|
||||
sys.staticInfo = data.StaticInfo
|
||||
sys.manager.hub.Logger().Debug("Cached static system info", "system", sys.Id)
|
||||
}
|
||||
_, err = sys.createRecords(data)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -166,17 +151,23 @@ 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 record
|
||||
// add system_stats and container_stats records
|
||||
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)
|
||||
@@ -184,14 +175,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
|
||||
@@ -212,53 +203,88 @@ 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 smart interval if it's set on the agent side
|
||||
if data.Details.SmartInterval > 0 {
|
||||
sys.smartInterval = data.Details.SmartInterval
|
||||
}
|
||||
}
|
||||
|
||||
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
|
||||
systemRecord.Set("status", up)
|
||||
systemRecord.Set("info", data.Info)
|
||||
|
||||
systemRecord.Set("info", completeInfo)
|
||||
if err := txApp.SaveNoValidate(systemRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
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(),
|
||||
// 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
|
||||
}
|
||||
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()
|
||||
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
|
||||
}
|
||||
return err
|
||||
if data.Info.DashboardTemp > 0 {
|
||||
info["dt"] = data.Info.DashboardTemp
|
||||
}
|
||||
if data.Info.LoadAvg1 > 0 || data.Info.LoadAvg5 > 0 || data.Info.LoadAvg15 > 0 {
|
||||
info["l1"] = data.Info.LoadAvg1
|
||||
info["l5"] = data.Info.LoadAvg5
|
||||
info["l15"] = data.Info.LoadAvg15
|
||||
info["la"] = data.Info.LoadAvg
|
||||
}
|
||||
if data.Info.ConnectionType > 0 {
|
||||
info["ct"] = data.Info.ConnectionType
|
||||
}
|
||||
|
||||
// Add static fields from cached staticInfo
|
||||
if sys.staticInfo != nil {
|
||||
info["h"] = sys.staticInfo.Hostname
|
||||
if sys.staticInfo.KernelVersion != "" {
|
||||
info["k"] = sys.staticInfo.KernelVersion
|
||||
}
|
||||
if sys.staticInfo.Threads > 0 {
|
||||
info["t"] = sys.staticInfo.Threads
|
||||
}
|
||||
info["v"] = sys.staticInfo.AgentVersion
|
||||
if sys.staticInfo.Podman {
|
||||
info["p"] = true
|
||||
}
|
||||
info["os"] = sys.staticInfo.Os
|
||||
if len(sys.staticInfo.Cpus) > 0 {
|
||||
info["c"] = sys.staticInfo.Cpus
|
||||
}
|
||||
if len(sys.staticInfo.Memory) > 0 {
|
||||
info["m"] = sys.staticInfo.Memory
|
||||
}
|
||||
if len(sys.staticInfo.Disks) > 0 {
|
||||
info["d"] = sys.staticInfo.Disks
|
||||
}
|
||||
if len(sys.staticInfo.Networks) > 0 {
|
||||
info["n"] = sys.staticInfo.Networks
|
||||
}
|
||||
if len(sys.staticInfo.Oses) > 0 {
|
||||
info["o"] = sys.staticInfo.Oses
|
||||
}
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId string) error {
|
||||
@@ -660,6 +686,7 @@ func (sys *System) closeSSHConnection() {
|
||||
sys.client.Close()
|
||||
sys.client = nil
|
||||
}
|
||||
sys.staticInfo = nil
|
||||
}
|
||||
|
||||
// closeWebSocketConnection closes the WebSocket connection but keeps the system in the manager
|
||||
@@ -669,6 +696,7 @@ func (sys *System) closeWebSocketConnection() {
|
||||
if sys.WsConn != nil {
|
||||
sys.WsConn.Close(nil)
|
||||
}
|
||||
sys.staticInfo = nil
|
||||
}
|
||||
|
||||
// extractAgentVersion extracts the beszel version from SSH server version string
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
//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 }
|
||||
@@ -266,20 +266,18 @@ 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{
|
||||
Uptime: 3600,
|
||||
Cpu: 25.5,
|
||||
MemPct: 40.2,
|
||||
DiskPct: 60.0,
|
||||
Bandwidth: 100.0,
|
||||
AgentVersion: "1.0.0",
|
||||
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",
|
||||
},
|
||||
Stats: system.Stats{
|
||||
Cpu: 25.5,
|
||||
|
||||
@@ -10,13 +10,6 @@ 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()
|
||||
|
||||
@@ -1439,184 +1439,6 @@ 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"
|
||||
}
|
||||
]`
|
||||
|
||||
@@ -33,7 +33,10 @@
|
||||
"noUnusedFunctionParameters": "error",
|
||||
"noUnusedPrivateClassMembers": "error",
|
||||
"useExhaustiveDependencies": {
|
||||
"level": "off"
|
||||
"level": "warn",
|
||||
"options": {
|
||||
"reportUnnecessaryDependencies": false
|
||||
}
|
||||
},
|
||||
"useUniqueElementIds": "off",
|
||||
"noUnusedVariables": "error"
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
"lucide-react": "^0.452.0",
|
||||
"nanostores": "^0.11.4",
|
||||
"pocketbase": "^0.26.2",
|
||||
"react": "^19.1.2",
|
||||
"react-dom": "^19.1.2",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"recharts": "^2.15.4",
|
||||
"shiki": "^3.13.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
@@ -811,9 +811,9 @@
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
|
||||
"react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
|
||||
"react-dom": ["react-dom@19.1.1", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.1" } }, "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw=="],
|
||||
|
||||
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
|
||||
@@ -851,7 +851,7 @@
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
@@ -971,6 +971,8 @@
|
||||
|
||||
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"pseudolocale/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
|
||||
|
||||
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
@@ -979,18 +981,28 @@
|
||||
|
||||
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
|
||||
|
||||
"wrap-ansi/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
|
||||
|
||||
"wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.0.1", "", {}, "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA=="],
|
||||
|
||||
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"string-width/strip-ansi/ansi-regex": ["ansi-regex@6.0.1", "", {}, "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA=="],
|
||||
|
||||
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.0.1", "", {}, "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "beszel",
|
||||
"private": true,
|
||||
"version": "0.18.0-beta.1",
|
||||
"version": "0.17.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
|
||||
@@ -3,7 +3,17 @@ import { Trans, useLingui } from "@lingui/react/macro"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { getPagePath } from "@nanostores/router"
|
||||
import { timeTicks } from "d3-time"
|
||||
import { XIcon } from "lucide-react"
|
||||
import {
|
||||
ChevronRightSquareIcon,
|
||||
ClockArrowUp,
|
||||
CpuIcon,
|
||||
GlobeIcon,
|
||||
HardDriveIcon,
|
||||
LayoutGridIcon,
|
||||
MonitorIcon,
|
||||
ServerIcon,
|
||||
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"
|
||||
@@ -16,7 +26,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, Os, SystemStatus, Unit } from "@/lib/enums"
|
||||
import { ChartType, ConnectionType, connectionTypeLabels, Os, SystemStatus, Unit } from "@/lib/enums"
|
||||
import { batteryStateTranslations } from "@/lib/i18n"
|
||||
import {
|
||||
$allSystemsById,
|
||||
@@ -36,6 +46,8 @@ import {
|
||||
compareSemVer,
|
||||
decimalString,
|
||||
formatBytes,
|
||||
secondsToString,
|
||||
getHostDisplayValue,
|
||||
listen,
|
||||
parseSemVer,
|
||||
toFixedFloat,
|
||||
@@ -46,24 +58,25 @@ 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 { ChartAverage, ChartMax } from "../ui/icons"
|
||||
import { AppleIcon, ChartAverage, ChartMax, EthernetIcon, FreeBsdIcon, Rows, TuxIcon, WebSocketIcon, WindowsIcon } from "../ui/icons"
|
||||
import { Input } from "../ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
|
||||
import { Separator } from "../ui/separator"
|
||||
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
|
||||
@@ -143,8 +156,8 @@ async function getStats<T extends SystemStatsRecord | ContainerStatsRecord>(
|
||||
})
|
||||
}
|
||||
|
||||
function dockerOrPodman(str: string, isPodman: boolean): string {
|
||||
if (isPodman) {
|
||||
function dockerOrPodman(str: string, system: SystemRecord): string {
|
||||
if (system.info.p) {
|
||||
return str.replace("docker", "podman").replace("Docker", "Podman")
|
||||
}
|
||||
return str
|
||||
@@ -167,7 +180,6 @@ 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 [details, setDetails] = useState<SystemDetailsRecord>({} as SystemDetailsRecord)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -177,7 +189,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
persistChartTime.current = false
|
||||
setSystemStats([])
|
||||
setContainerData([])
|
||||
setDetails({} as SystemDetailsRecord)
|
||||
$containerFilter.set("")
|
||||
}
|
||||
}, [id])
|
||||
@@ -205,26 +216,10 @@ 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<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(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
|
||||
}
|
||||
@@ -340,6 +335,106 @@ 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])
|
||||
|
||||
/** Space for tooltip if more than 10 sensors and no containers table */
|
||||
useEffect(() => {
|
||||
const sensors = Object.keys(systemStats.at(-1)?.stats.t ?? {})
|
||||
@@ -407,14 +502,114 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
const hasGpuData = lastGpuVals.length > 0
|
||||
const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined || gpu.pp !== undefined)
|
||||
const hasGpuEnginesData = lastGpuVals.some((gpu) => gpu.e !== undefined)
|
||||
const isLinux = (details?.os ?? system.info?.os) === Os.Linux
|
||||
const isPodman = details?.podman ?? system.info?.p ?? false
|
||||
|
||||
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 */}
|
||||
<InfoBar system={system} chartData={chartData} grid={grid} setGrid={setGrid} details={details} />
|
||||
<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>
|
||||
|
||||
|
||||
{/* <Tabs defaultValue="overview" className="w-full">
|
||||
<TabsList className="w-full h-11">
|
||||
@@ -426,6 +621,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
</TabsContent>
|
||||
</Tabs> */}
|
||||
|
||||
|
||||
{/* main charts */}
|
||||
<div className="grid xl:grid-cols-2 gap-4">
|
||||
<ChartCard
|
||||
@@ -461,7 +657,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={dockerOrPodman(t`Docker CPU Usage`, isPodman)}
|
||||
title={dockerOrPodman(t`Docker CPU Usage`, system)}
|
||||
description={t`Average CPU utilization of containers`}
|
||||
cornerEl={containerFilterBar}
|
||||
>
|
||||
@@ -488,8 +684,8 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={dockerOrPodman(t`Docker Memory Usage`, isPodman)}
|
||||
description={dockerOrPodman(t`Memory usage of docker containers`, isPodman)}
|
||||
title={dockerOrPodman(t`Docker Memory Usage`, system)}
|
||||
description={dockerOrPodman(t`Memory usage of docker containers`, system)}
|
||||
cornerEl={containerFilterBar}
|
||||
>
|
||||
<ContainerChart
|
||||
@@ -609,8 +805,8 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={dockerOrPodman(t`Docker Network I/O`, isPodman)}
|
||||
description={dockerOrPodman(t`Network traffic of docker containers`, isPodman)}
|
||||
title={dockerOrPodman(t`Docker Network I/O`, system)}
|
||||
description={dockerOrPodman(t`Network traffic of docker containers`, system)}
|
||||
cornerEl={containerFilterBar}
|
||||
>
|
||||
<ContainerChart
|
||||
@@ -649,7 +845,10 @@ 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}
|
||||
@@ -811,9 +1010,7 @@ 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
|
||||
},
|
||||
@@ -851,13 +1048,15 @@ 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} />
|
||||
)}
|
||||
|
||||
{isLinux && compareSemVer(chartData.agentVersion, parseSemVer("0.16.0")) >= 0 && (
|
||||
{system.info?.os === Os.Linux && compareSemVer(chartData.agentVersion, parseSemVer("0.16.0")) >= 0 && (
|
||||
<LazySystemdTable systemId={system.id} />
|
||||
)}
|
||||
</div>
|
||||
@@ -907,10 +1106,13 @@ 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("")
|
||||
@@ -1037,4 +1239,4 @@ function LazySystemdTable({ systemId }: { systemId: string }) {
|
||||
{isIntersecting && <SystemdTable systemId={systemId} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -93,15 +93,51 @@ export const smartColumns: ColumnDef<SmartAttribute>[] = [
|
||||
},
|
||||
]
|
||||
|
||||
export type DiskInfo = {
|
||||
id: string
|
||||
system: string
|
||||
device: string
|
||||
model: string
|
||||
capacity: string
|
||||
status: string
|
||||
temperature: number
|
||||
deviceType: string
|
||||
powerOnHours?: number
|
||||
powerCycles?: number
|
||||
attributes?: SmartAttribute[]
|
||||
updated: string
|
||||
}
|
||||
|
||||
// Function to format capacity display
|
||||
function formatCapacity(bytes: number): string {
|
||||
const { value, unit } = formatBytes(bytes)
|
||||
return `${toFixedFloat(value, value >= 10 ? 1 : 2)} ${unit}`
|
||||
}
|
||||
|
||||
// Function to convert SmartDeviceRecord to DiskInfo
|
||||
function convertSmartDeviceRecordToDiskInfo(records: SmartDeviceRecord[]): DiskInfo[] {
|
||||
const unknown = "Unknown"
|
||||
return records.map((record) => ({
|
||||
id: record.id,
|
||||
system: record.system,
|
||||
device: record.name || unknown,
|
||||
model: record.model || unknown,
|
||||
serialNumber: record.serial || unknown,
|
||||
firmwareVersion: record.firmware || unknown,
|
||||
capacity: record.capacity ? formatCapacity(record.capacity) : unknown,
|
||||
status: record.state || unknown,
|
||||
temperature: record.temp || 0,
|
||||
deviceType: record.type || unknown,
|
||||
attributes: record.attributes,
|
||||
updated: record.updated,
|
||||
powerOnHours: record.hours,
|
||||
powerCycles: record.cycles,
|
||||
}))
|
||||
}
|
||||
|
||||
const SMART_DEVICE_FIELDS = "id,system,name,model,state,capacity,temp,type,hours,cycles,updated"
|
||||
|
||||
export const columns: ColumnDef<SmartDeviceRecord>[] = [
|
||||
export const columns: ColumnDef<DiskInfo>[] = [
|
||||
{
|
||||
id: "system",
|
||||
accessorFn: (record) => record.system,
|
||||
@@ -118,12 +154,12 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
||||
accessorKey: "device",
|
||||
sortingFn: (a, b) => a.original.device.localeCompare(b.original.device),
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Device`} Icon={HardDrive} />,
|
||||
cell: ({ getValue }) => (
|
||||
<div className="font-medium max-w-40 truncate ms-1.5" title={getValue() as string}>
|
||||
{getValue() as string}
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium max-w-40 truncate ms-1.5" title={row.getValue("device")}>
|
||||
{row.getValue("device")}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -131,20 +167,19 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
|
||||
accessorKey: "model",
|
||||
sortingFn: (a, b) => a.original.model.localeCompare(b.original.model),
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Model`} Icon={Box} />,
|
||||
cell: ({ getValue }) => (
|
||||
<div className="max-w-48 truncate ms-1.5" title={getValue() as string}>
|
||||
{getValue() as string}
|
||||
cell: ({ row }) => (
|
||||
<div className="max-w-48 truncate ms-1.5" title={row.getValue("model")}>
|
||||
{row.getValue("model")}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "capacity",
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Capacity`} Icon={BinaryIcon} />,
|
||||
cell: ({ getValue }) => <span className="ms-1.5">{formatCapacity(getValue() as number)}</span>,
|
||||
cell: ({ getValue }) => <span className="ms-1.5">{getValue() as string}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "state",
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Status`} Icon={Activity} />,
|
||||
cell: ({ getValue }) => {
|
||||
const status = getValue() as string
|
||||
@@ -156,8 +191,8 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
sortingFn: (a, b) => a.original.type.localeCompare(b.original.type),
|
||||
accessorKey: "deviceType",
|
||||
sortingFn: (a, b) => a.original.deviceType.localeCompare(b.original.deviceType),
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Type`} Icon={ArrowLeftRightIcon} />,
|
||||
cell: ({ getValue }) => (
|
||||
<div className="ms-1.5">
|
||||
@@ -168,7 +203,7 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "hours",
|
||||
accessorKey: "powerOnHours",
|
||||
invertSorting: true,
|
||||
header: ({ column }) => (
|
||||
<HeaderButton column={column} name={t({ message: "Power On", comment: "Power On Time" })} Icon={Clock} />
|
||||
@@ -188,7 +223,7 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "cycles",
|
||||
accessorKey: "powerCycles",
|
||||
invertSorting: true,
|
||||
header: ({ column }) => (
|
||||
<HeaderButton column={column} name={t({ message: "Cycles", comment: "Power Cycles" })} Icon={RotateCwIcon} />
|
||||
@@ -202,7 +237,7 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "temp",
|
||||
accessorKey: "temperature",
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Temp`} Icon={ThermometerIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
@@ -211,14 +246,14 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
|
||||
},
|
||||
},
|
||||
// {
|
||||
// accessorKey: "serial",
|
||||
// sortingFn: (a, b) => a.original.serial.localeCompare(b.original.serial),
|
||||
// accessorKey: "serialNumber",
|
||||
// sortingFn: (a, b) => a.original.serialNumber.localeCompare(b.original.serialNumber),
|
||||
// header: ({ column }) => <HeaderButton column={column} name={t`Serial Number`} Icon={HashIcon} />,
|
||||
// cell: ({ getValue }) => <span className="ms-1.5">{getValue() as string}</span>,
|
||||
// },
|
||||
// {
|
||||
// accessorKey: "firmware",
|
||||
// sortingFn: (a, b) => a.original.firmware.localeCompare(b.original.firmware),
|
||||
// accessorKey: "firmwareVersion",
|
||||
// sortingFn: (a, b) => a.original.firmwareVersion.localeCompare(b.original.firmwareVersion),
|
||||
// header: ({ column }) => <HeaderButton column={column} name={t`Firmware`} Icon={CpuIcon} />,
|
||||
// cell: ({ getValue }) => <span className="ms-1.5">{getValue() as string}</span>,
|
||||
// },
|
||||
@@ -237,15 +272,7 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
|
||||
},
|
||||
]
|
||||
|
||||
function HeaderButton({
|
||||
column,
|
||||
name,
|
||||
Icon,
|
||||
}: {
|
||||
column: Column<SmartDeviceRecord>
|
||||
name: string
|
||||
Icon: React.ElementType
|
||||
}) {
|
||||
function HeaderButton({ column, name, Icon }: { column: Column<DiskInfo>; name: string; Icon: React.ElementType }) {
|
||||
const isSorted = column.getIsSorted()
|
||||
return (
|
||||
<Button
|
||||
@@ -263,7 +290,7 @@ function HeaderButton({
|
||||
}
|
||||
|
||||
export default function DisksTable({ systemId }: { systemId?: string }) {
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: systemId ? "name" : "system", desc: false }])
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: systemId ? "device" : "system", desc: false }])
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [smartDevices, setSmartDevices] = useState<SmartDeviceRecord[] | undefined>(undefined)
|
||||
@@ -272,95 +299,96 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
|
||||
const [rowActionState, setRowActionState] = useState<{ type: "refresh" | "delete"; id: string } | null>(null)
|
||||
const [globalFilter, setGlobalFilter] = useState("")
|
||||
|
||||
const openSheet = (disk: SmartDeviceRecord) => {
|
||||
const openSheet = (disk: DiskInfo) => {
|
||||
setActiveDiskId(disk.id)
|
||||
setSheetOpen(true)
|
||||
}
|
||||
|
||||
// Fetch smart devices
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
// Fetch smart devices from collection (without attributes to save bandwidth)
|
||||
const fetchSmartDevices = useCallback(() => {
|
||||
pb.collection<SmartDeviceRecord>("smart_devices")
|
||||
.getFullList({
|
||||
filter: systemId ? pb.filter("system = {:system}", { system: systemId }) : undefined,
|
||||
fields: SMART_DEVICE_FIELDS,
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(setSmartDevices)
|
||||
.catch((err) => {
|
||||
if (!err.isAbort) {
|
||||
setSmartDevices([])
|
||||
}
|
||||
.then((records) => {
|
||||
setSmartDevices(records)
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
.catch(() => setSmartDevices([]))
|
||||
}, [systemId])
|
||||
|
||||
// Subscribe to updates
|
||||
// Fetch smart devices when component mounts or systemId changes
|
||||
useEffect(() => {
|
||||
fetchSmartDevices()
|
||||
}, [fetchSmartDevices])
|
||||
|
||||
// Subscribe to live updates so rows add/remove without manual refresh/filtering
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | undefined
|
||||
const pbOptions = systemId
|
||||
? { fields: SMART_DEVICE_FIELDS, filter: pb.filter("system = {:system}", { system: systemId }) }
|
||||
: { fields: SMART_DEVICE_FIELDS }
|
||||
|
||||
; (async () => {
|
||||
try {
|
||||
unsubscribe = await pb.collection("smart_devices").subscribe(
|
||||
"*",
|
||||
(event) => {
|
||||
const record = event.record as SmartDeviceRecord
|
||||
setSmartDevices((currentDevices) => {
|
||||
const devices = currentDevices ?? []
|
||||
const matchesSystemScope = !systemId || record.system === systemId
|
||||
;(async () => {
|
||||
try {
|
||||
unsubscribe = await pb.collection("smart_devices").subscribe(
|
||||
"*",
|
||||
(event) => {
|
||||
const record = event.record as SmartDeviceRecord
|
||||
setSmartDevices((currentDevices) => {
|
||||
const devices = currentDevices ?? []
|
||||
const matchesSystemScope = !systemId || record.system === systemId
|
||||
|
||||
if (event.action === "delete") {
|
||||
return devices.filter((device) => device.id !== record.id)
|
||||
}
|
||||
if (event.action === "delete") {
|
||||
return devices.filter((device) => device.id !== record.id)
|
||||
}
|
||||
|
||||
if (!matchesSystemScope) {
|
||||
// Record moved out of scope; ensure it disappears locally.
|
||||
return devices.filter((device) => device.id !== record.id)
|
||||
}
|
||||
if (!matchesSystemScope) {
|
||||
// Record moved out of scope; ensure it disappears locally.
|
||||
return devices.filter((device) => device.id !== record.id)
|
||||
}
|
||||
|
||||
const existingIndex = devices.findIndex((device) => device.id === record.id)
|
||||
if (existingIndex === -1) {
|
||||
return [record, ...devices]
|
||||
}
|
||||
const existingIndex = devices.findIndex((device) => device.id === record.id)
|
||||
if (existingIndex === -1) {
|
||||
return [record, ...devices]
|
||||
}
|
||||
|
||||
const next = [...devices]
|
||||
next[existingIndex] = record
|
||||
return next
|
||||
})
|
||||
},
|
||||
pbOptions
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Failed to subscribe to SMART device updates:", error)
|
||||
}
|
||||
})()
|
||||
const next = [...devices]
|
||||
next[existingIndex] = record
|
||||
return next
|
||||
})
|
||||
},
|
||||
pbOptions
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Failed to subscribe to SMART device updates:", error)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
unsubscribe?.()
|
||||
}
|
||||
}, [systemId])
|
||||
|
||||
const handleRowRefresh = useCallback(async (disk: SmartDeviceRecord) => {
|
||||
if (!disk.system) return
|
||||
setRowActionState({ type: "refresh", id: disk.id })
|
||||
try {
|
||||
await pb.send("/api/beszel/smart/refresh", {
|
||||
method: "POST",
|
||||
query: { system: disk.system },
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh SMART device:", error)
|
||||
} finally {
|
||||
setRowActionState((state) => (state?.id === disk.id ? null : state))
|
||||
}
|
||||
}, [])
|
||||
const handleRowRefresh = useCallback(
|
||||
async (disk: DiskInfo) => {
|
||||
if (!disk.system) return
|
||||
setRowActionState({ type: "refresh", id: disk.id })
|
||||
try {
|
||||
await pb.send("/api/beszel/smart/refresh", {
|
||||
method: "POST",
|
||||
query: { system: disk.system },
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh SMART device:", error)
|
||||
} finally {
|
||||
setRowActionState((state) => (state?.id === disk.id ? null : state))
|
||||
}
|
||||
},
|
||||
[fetchSmartDevices]
|
||||
)
|
||||
|
||||
const handleDeleteDevice = useCallback(async (disk: SmartDeviceRecord) => {
|
||||
const handleDeleteDevice = useCallback(async (disk: DiskInfo) => {
|
||||
setRowActionState({ type: "delete", id: disk.id })
|
||||
try {
|
||||
await pb.collection("smart_devices").delete(disk.id)
|
||||
@@ -372,7 +400,7 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const actionColumn = useMemo<ColumnDef<SmartDeviceRecord>>(
|
||||
const actionColumn = useMemo<ColumnDef<DiskInfo>>(
|
||||
() => ({
|
||||
id: "actions",
|
||||
enableSorting: false,
|
||||
@@ -440,8 +468,13 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
|
||||
return [...baseColumns, actionColumn]
|
||||
}, [systemId, actionColumn])
|
||||
|
||||
// Convert SmartDeviceRecord to DiskInfo
|
||||
const diskData = useMemo(() => {
|
||||
return smartDevices ? convertSmartDeviceRecordToDiskInfo(smartDevices) : []
|
||||
}, [smartDevices])
|
||||
|
||||
const table = useReactTable({
|
||||
data: smartDevices || ([] as SmartDeviceRecord[]),
|
||||
data: diskData,
|
||||
columns: tableColumns,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
@@ -459,10 +492,10 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
|
||||
globalFilterFn: (row, _columnId, filterValue) => {
|
||||
const disk = row.original
|
||||
const systemName = $allSystemsById.get()[disk.system]?.name ?? ""
|
||||
const device = disk.name ?? ""
|
||||
const device = disk.device ?? ""
|
||||
const model = disk.model ?? ""
|
||||
const status = disk.state ?? ""
|
||||
const type = disk.type ?? ""
|
||||
const status = disk.status ?? ""
|
||||
const type = disk.deviceType ?? ""
|
||||
const searchString = `${systemName} ${device} ${model} ${status} ${type}`.toLowerCase()
|
||||
return (filterValue as string)
|
||||
.toLowerCase()
|
||||
@@ -472,7 +505,7 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
|
||||
})
|
||||
|
||||
// Hide the table on system pages if there's no data, but always show on global page
|
||||
if (systemId && !smartDevices?.length && !columnFilters.length) {
|
||||
if (systemId && !diskData.length && !columnFilters.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
47
internal/site/src/types.d.ts
vendored
47
internal/site/src/types.d.ts
vendored
@@ -27,12 +27,32 @@ export interface SystemRecord extends RecordModel {
|
||||
host: string
|
||||
status: "up" | "down" | "paused" | "pending"
|
||||
port: string
|
||||
info: SystemInfo
|
||||
info: systemInfo
|
||||
v: string
|
||||
updated: string
|
||||
}
|
||||
|
||||
export interface SystemInfo {
|
||||
export interface CpuInfo {
|
||||
m: string
|
||||
s: string
|
||||
a: string
|
||||
c: number
|
||||
t: number
|
||||
}
|
||||
|
||||
export interface OsInfo {
|
||||
f: string
|
||||
v: string
|
||||
k: string
|
||||
}
|
||||
|
||||
export interface NetworkLocationInfo {
|
||||
ip?: string
|
||||
isp?: string
|
||||
asn?: string
|
||||
}
|
||||
|
||||
export interface systemInfo {
|
||||
/** hostname */
|
||||
h: string
|
||||
/** kernel **/
|
||||
@@ -75,6 +95,16 @@ export interface SystemInfo {
|
||||
g?: number
|
||||
/** dashboard display temperature */
|
||||
dt?: number
|
||||
/** disks info (array of block devices with model/vendor/serial) */
|
||||
d?: { n: string; m?: string; v?: string; serial?: string }[]
|
||||
/** networks info (array of network interfaces with vendor/model/capabilities) */
|
||||
n?: { n: string; v?: string; m?: string; s?: string }[]
|
||||
/** memory info (array with total property) */
|
||||
m?: { t: string }[]
|
||||
/** cpu info (array of cpu objects) */
|
||||
c?: CpuInfo[]
|
||||
/** os info (array of os objects) */
|
||||
o?: OsInfo[]
|
||||
/** operating system */
|
||||
os?: Os
|
||||
/** connection type */
|
||||
@@ -380,19 +410,6 @@ 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
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
## 0.18.0
|
||||
|
||||
- Collect S.M.A.R.T. data in the background every hour.
|
||||
|
||||
- Add `SMART_INTERVAL` environment variable to customize S.M.A.R.T. data collection interval.
|
||||
|
||||
- Collect system distribution and architecture.
|
||||
|
||||
- Add `system_details` collection to store infrequently updated system information.
|
||||
|
||||
- Skip known non-unique product UUID when generating fingerprints. (#1556)
|
||||
|
||||
## 0.17.0
|
||||
|
||||
- Add quiet hours to silence alerts during specific time periods. (#265)
|
||||
|
||||
Reference in New Issue
Block a user