mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-22 13:36:16 +01:00
Compare commits
10 Commits
split-syst
...
nvml
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f74438bd7 | ||
|
|
ea354ec030 | ||
|
|
f6ab5f2af1 | ||
|
|
7d943633a3 | ||
|
|
7fff3c999a | ||
|
|
a9068a11a9 | ||
|
|
d3d102516c | ||
|
|
32131439f9 | ||
|
|
d17685c540 | ||
|
|
e59f8eee36 |
@@ -84,6 +84,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
slog.Warn("Invalid DISK_USAGE_CACHE", "err", err)
|
slog.Warn("Invalid DISK_USAGE_CACHE", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up slog with a log level determined by the LOG_LEVEL env var
|
// Set up slog with a log level determined by the LOG_LEVEL env var
|
||||||
if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists {
|
if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists {
|
||||||
switch strings.ToLower(logLevelStr) {
|
switch strings.ToLower(logLevelStr) {
|
||||||
@@ -103,7 +104,17 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
agent.dockerManager = newDockerManager()
|
agent.dockerManager = newDockerManager()
|
||||||
|
|
||||||
// initialize system info
|
// initialize system info
|
||||||
agent.refreshStaticInfo()
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// initialize connection manager
|
// initialize connection manager
|
||||||
agent.connectionManager = newConnectionManager(agent)
|
agent.connectionManager = newConnectionManager(agent)
|
||||||
@@ -166,7 +177,7 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
Info: a.systemInfo,
|
Info: a.systemInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include static info only when requested
|
// Include static system details only when requested
|
||||||
if options.IncludeDetails {
|
if options.IncludeDetails {
|
||||||
data.Details = &a.systemDetails
|
data.Details = &a.systemDetails
|
||||||
}
|
}
|
||||||
@@ -233,7 +244,8 @@ func (a *Agent) getFingerprint() string {
|
|||||||
|
|
||||||
// if no fingerprint is found, generate one
|
// if no fingerprint is found, generate one
|
||||||
fingerprint, err := host.HostID()
|
fingerprint, err := host.HostID()
|
||||||
if err != nil || fingerprint == "" {
|
// 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
|
fingerprint = a.systemDetails.Hostname + a.systemDetails.CpuModel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -757,7 +757,6 @@ func (dm *dockerManager) GetHostInfo() (info container.HostInfo, err error) {
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||||
slog.Error("Failed to decode Docker version response", "error", err)
|
|
||||||
return info, err
|
return info, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
25
agent/gpu.go
25
agent/gpu.go
@@ -44,6 +44,7 @@ type GPUManager struct {
|
|||||||
rocmSmi bool
|
rocmSmi bool
|
||||||
tegrastats bool
|
tegrastats bool
|
||||||
intelGpuStats bool
|
intelGpuStats bool
|
||||||
|
nvml bool
|
||||||
GpuDataMap map[string]*system.GPUData
|
GpuDataMap map[string]*system.GPUData
|
||||||
// lastAvgData stores the last calculated averages for each GPU
|
// lastAvgData stores the last calculated averages for each GPU
|
||||||
// Used when a collection happens before new data arrives (Count == 0)
|
// Used when a collection happens before new data arrives (Count == 0)
|
||||||
@@ -297,8 +298,13 @@ func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheK
|
|||||||
currentCount := uint32(gpu.Count)
|
currentCount := uint32(gpu.Count)
|
||||||
deltaCount := gm.calculateDeltaCount(currentCount, lastSnapshot)
|
deltaCount := gm.calculateDeltaCount(currentCount, lastSnapshot)
|
||||||
|
|
||||||
// If no new data arrived, use last known average
|
// If no new data arrived
|
||||||
if deltaCount == 0 {
|
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
|
return gm.lastAvgData[id] // zero value if not found
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,7 +402,7 @@ func (gm *GPUManager) detectGPUs() error {
|
|||||||
if _, err := exec.LookPath(intelGpuStatsCmd); err == nil {
|
if _, err := exec.LookPath(intelGpuStatsCmd); err == nil {
|
||||||
gm.intelGpuStats = true
|
gm.intelGpuStats = true
|
||||||
}
|
}
|
||||||
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats || gm.intelGpuStats {
|
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats || gm.intelGpuStats || gm.nvml {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("no GPU found - install nvidia-smi, rocm-smi, tegrastats, or intel_gpu_top")
|
return fmt.Errorf("no GPU found - install nvidia-smi, rocm-smi, tegrastats, or intel_gpu_top")
|
||||||
@@ -467,7 +473,20 @@ func NewGPUManager() (*GPUManager, error) {
|
|||||||
gm.GpuDataMap = make(map[string]*system.GPUData)
|
gm.GpuDataMap = make(map[string]*system.GPUData)
|
||||||
|
|
||||||
if gm.nvidiaSmi {
|
if gm.nvidiaSmi {
|
||||||
gm.startCollector(nvidiaSmiCmd)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if gm.rocmSmi {
|
if gm.rocmSmi {
|
||||||
gm.startCollector(rocmSmiCmd)
|
gm.startCollector(rocmSmiCmd)
|
||||||
|
|||||||
210
agent/gpu_nvml.go
Normal file
210
agent/gpu_nvml.go
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
56
agent/gpu_nvml_linux.go
Normal file
56
agent/gpu_nvml_linux.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
//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
|
||||||
|
}
|
||||||
21
agent/gpu_nvml_unsupported.go
Normal file
21
agent/gpu_nvml_unsupported.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
//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
|
||||||
|
}
|
||||||
25
agent/gpu_nvml_windows.go
Normal file
25
agent/gpu_nvml_windows.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
//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
|
||||||
|
}
|
||||||
@@ -550,6 +550,9 @@ func createTestCombinedData() *system.CombinedData {
|
|||||||
DiskUsed: 549755813888, // 512GB
|
DiskUsed: 549755813888, // 512GB
|
||||||
DiskPct: 50.0,
|
DiskPct: 50.0,
|
||||||
},
|
},
|
||||||
|
Details: &system.Details{
|
||||||
|
Hostname: "test-host",
|
||||||
|
},
|
||||||
Info: system.Info{
|
Info: system.Info{
|
||||||
Uptime: 3600,
|
Uptime: 3600,
|
||||||
AgentVersion: "0.12.0",
|
AgentVersion: "0.12.0",
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ type prevDisk struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sets initial / non-changing values about the host system
|
// Sets initial / non-changing values about the host system
|
||||||
func (a *Agent) refreshStaticInfo() {
|
func (a *Agent) refreshSystemDetails() {
|
||||||
a.systemInfo.AgentVersion = beszel.Version
|
a.systemInfo.AgentVersion = beszel.Version
|
||||||
|
|
||||||
// get host info from Docker if available
|
// get host info from Docker if available
|
||||||
@@ -246,7 +246,6 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
a.systemInfo.Uptime, _ = host.Uptime()
|
a.systemInfo.Uptime, _ = host.Uptime()
|
||||||
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
|
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
|
||||||
a.systemInfo.Threads = a.systemDetails.Threads
|
a.systemInfo.Threads = a.systemDetails.Threads
|
||||||
slog.Debug("sysinfo", "data", a.systemInfo)
|
|
||||||
|
|
||||||
return systemStats
|
return systemStats
|
||||||
}
|
}
|
||||||
|
|||||||
4
go.mod
4
go.mod
@@ -6,6 +6,7 @@ require (
|
|||||||
github.com/blang/semver v3.5.1+incompatible
|
github.com/blang/semver v3.5.1+incompatible
|
||||||
github.com/coreos/go-systemd/v22 v22.6.0
|
github.com/coreos/go-systemd/v22 v22.6.0
|
||||||
github.com/distatus/battery v0.11.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/fxamacker/cbor/v2 v2.9.0
|
||||||
github.com/gliderlabs/ssh v0.3.8
|
github.com/gliderlabs/ssh v0.3.8
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
@@ -20,6 +21,7 @@ require (
|
|||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/crypto v0.45.0
|
golang.org/x/crypto v0.45.0
|
||||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39
|
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39
|
||||||
|
golang.org/x/sys v0.38.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,7 +33,6 @@ require (
|
|||||||
github.com/dolthub/maphash v0.1.0 // indirect
|
github.com/dolthub/maphash v0.1.0 // indirect
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // 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/fatih/color v1.18.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||||
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
|
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
|
||||||
@@ -57,7 +58,6 @@ require (
|
|||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.47.0 // indirect
|
||||||
golang.org/x/oauth2 v0.33.0 // indirect
|
golang.org/x/oauth2 v0.33.0 // indirect
|
||||||
golang.org/x/sync v0.18.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/term v0.37.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/text v0.31.0 // indirect
|
||||||
howett.net/plist v1.0.1 // indirect
|
howett.net/plist v1.0.1 // indirect
|
||||||
|
|||||||
14
go.sum
14
go.sum
@@ -62,8 +62,6 @@ 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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
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 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
|
||||||
@@ -173,22 +171,18 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
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 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
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 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
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 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
|||||||
@@ -34,15 +34,12 @@ type ApiStats struct {
|
|||||||
MemoryStats MemoryStats `json:"memory_stats"`
|
MemoryStats MemoryStats `json:"memory_stats"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Docker system info from /info
|
// Docker system info from /info API endpoint
|
||||||
type HostInfo struct {
|
type HostInfo struct {
|
||||||
OperatingSystem string `json:"OperatingSystem"`
|
OperatingSystem string `json:"OperatingSystem"`
|
||||||
KernelVersion string `json:"KernelVersion"`
|
KernelVersion string `json:"KernelVersion"`
|
||||||
NCPU int `json:"NCPU"`
|
NCPU int `json:"NCPU"`
|
||||||
MemTotal uint64 `json:"MemTotal"`
|
MemTotal uint64 `json:"MemTotal"`
|
||||||
// OSVersion string `json:"OSVersion"`
|
|
||||||
// OSType string `json:"OSType"`
|
|
||||||
// Architecture string `json:"Architecture"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {
|
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {
|
||||||
|
|||||||
@@ -125,22 +125,22 @@ const (
|
|||||||
|
|
||||||
// Core system data that is needed in All Systems table
|
// Core system data that is needed in All Systems table
|
||||||
type Info struct {
|
type Info struct {
|
||||||
Hostname string `json:"h,omitempty" cbor:"0,keyasint,omitempty"` // deprecated - moved to Details 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
|
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
|
Cores int `json:"c,omitzero" cbor:"2,keyasint,omitzero"` // deprecated - moved to Details struct
|
||||||
CpuModel string `json:"m,omitempty" cbor:"4,keyasint,omitempty"` // deprecated - moved to Details struct
|
|
||||||
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"` // deprecated - moved to Details struct
|
|
||||||
Os Os `json:"os,omitempty" cbor:"14,keyasint,omitempty"` // deprecated - moved to Details struct
|
|
||||||
// Threads is needed in Info struct to calculate load average thresholds
|
// Threads is needed in Info struct to calculate load average thresholds
|
||||||
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
|
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"`
|
Uptime uint64 `json:"u" cbor:"5,keyasint"`
|
||||||
Cpu float64 `json:"cpu" cbor:"6,keyasint"`
|
Cpu float64 `json:"cpu" cbor:"6,keyasint"`
|
||||||
MemPct float64 `json:"mp" cbor:"7,keyasint"`
|
MemPct float64 `json:"mp" cbor:"7,keyasint"`
|
||||||
DiskPct float64 `json:"dp" cbor:"8,keyasint"`
|
DiskPct float64 `json:"dp" cbor:"8,keyasint"`
|
||||||
Bandwidth float64 `json:"b" cbor:"9,keyasint"`
|
Bandwidth float64 `json:"b" cbor:"9,keyasint"`
|
||||||
AgentVersion string `json:"v" cbor:"10,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"`
|
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
|
||||||
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,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
|
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
|
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
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"` // deprecated - use `la` array instead
|
||||||
@@ -155,16 +155,17 @@ type Info struct {
|
|||||||
|
|
||||||
// Data that does not change during process lifetime and is not needed in All Systems table
|
// Data that does not change during process lifetime and is not needed in All Systems table
|
||||||
type Details struct {
|
type Details struct {
|
||||||
Hostname string `cbor:"0,keyasint"`
|
Hostname string `cbor:"0,keyasint"`
|
||||||
Kernel string `cbor:"1,keyasint,omitempty"`
|
Kernel string `cbor:"1,keyasint,omitempty"`
|
||||||
Cores int `cbor:"2,keyasint"`
|
Cores int `cbor:"2,keyasint"`
|
||||||
Threads int `cbor:"3,keyasint"`
|
Threads int `cbor:"3,keyasint"`
|
||||||
CpuModel string `cbor:"4,keyasint"`
|
CpuModel string `cbor:"4,keyasint"`
|
||||||
Os Os `cbor:"5,keyasint"`
|
Os Os `cbor:"5,keyasint"`
|
||||||
OsName string `cbor:"6,keyasint"`
|
OsName string `cbor:"6,keyasint"`
|
||||||
Arch string `cbor:"7,keyasint"`
|
Arch string `cbor:"7,keyasint"`
|
||||||
Podman bool `cbor:"8,keyasint,omitempty"`
|
Podman bool `cbor:"8,keyasint,omitempty"`
|
||||||
MemoryTotal uint64 `cbor:"9,keyasint"`
|
MemoryTotal uint64 `cbor:"9,keyasint"`
|
||||||
|
SmartInterval time.Duration `cbor:"10,keyasint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final data structure to return to the hub
|
// Final data structure to return to the hub
|
||||||
|
|||||||
@@ -415,7 +415,11 @@ func TestExpiryMap_RemoveValue_WithExpiration(t *testing.T) {
|
|||||||
// Wait for first value to expire
|
// Wait for first value to expire
|
||||||
time.Sleep(time.Millisecond * 20)
|
time.Sleep(time.Millisecond * 20)
|
||||||
|
|
||||||
// Try to remove the expired value - should remove one of the "value1" entries
|
// Trigger lazy cleanup of the expired key
|
||||||
|
_, ok := em.GetOk("key1")
|
||||||
|
assert.False(t, ok)
|
||||||
|
|
||||||
|
// Try to remove the remaining "value1" entry (key3)
|
||||||
removedValue, ok := em.RemovebyValue("value1")
|
removedValue, ok := em.RemovebyValue("value1")
|
||||||
assert.True(t, ok)
|
assert.True(t, ok)
|
||||||
assert.Equal(t, "value1", removedValue)
|
assert.Equal(t, "value1", removedValue)
|
||||||
@@ -423,14 +427,9 @@ func TestExpiryMap_RemoveValue_WithExpiration(t *testing.T) {
|
|||||||
// Should still have key2 (different value)
|
// Should still have key2 (different value)
|
||||||
assert.True(t, em.Has("key2"))
|
assert.True(t, em.Has("key2"))
|
||||||
|
|
||||||
// Should have removed one of the "value1" entries (either key1 or key3)
|
// key1 should be gone due to expiration and key3 should be removed by value.
|
||||||
// But we can't predict which one due to map iteration order
|
assert.False(t, em.Has("key1"))
|
||||||
key1Exists := em.Has("key1")
|
assert.False(t, em.Has("key3"))
|
||||||
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) {
|
func TestExpiryMap_ValueOperations_Integration(t *testing.T) {
|
||||||
|
|||||||
@@ -42,8 +42,9 @@ type System struct {
|
|||||||
agentVersion semver.Version // Agent version
|
agentVersion semver.Version // Agent version
|
||||||
updateTicker *time.Ticker // Ticker for updating the system
|
updateTicker *time.Ticker // Ticker for updating the system
|
||||||
detailsFetched atomic.Bool // True if static system details have been fetched and saved
|
detailsFetched atomic.Bool // True if static system details have been fetched and saved
|
||||||
smartFetched atomic.Bool // True if SMART devices have been fetched and saved
|
|
||||||
smartFetching atomic.Bool // True if SMART devices are currently being fetched
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SystemManager) NewSystem(systemId string) *System {
|
func (sm *SystemManager) NewSystem(systemId string) *System {
|
||||||
@@ -123,10 +124,30 @@ func (sys *System) update() error {
|
|||||||
if !sys.detailsFetched.Load() {
|
if !sys.detailsFetched.Load() {
|
||||||
options.IncludeDetails = true
|
options.IncludeDetails = true
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := sys.fetchDataFromAgent(options)
|
data, err := sys.fetchDataFromAgent(options)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
_, err = sys.createRecords(data)
|
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()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +218,10 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
sys.detailsFetched.Store(true)
|
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)
|
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
|
||||||
@@ -208,18 +233,6 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch and save SMART devices when system first comes online
|
|
||||||
if err == nil {
|
|
||||||
if !sys.smartFetched.Load() && sys.smartFetching.CompareAndSwap(false, true) {
|
|
||||||
go func() {
|
|
||||||
defer sys.smartFetching.Store(false)
|
|
||||||
if err := sys.FetchAndSaveSmartDevices(); err == nil {
|
|
||||||
sys.smartFetched.Store(true)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return systemRecord, err
|
return systemRecord, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,8 +408,7 @@ func (sys *System) fetchStringFromAgentViaSSH(action common.WebSocketAction, req
|
|||||||
if err := session.Shell(); err != nil {
|
if err := session.Shell(); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
reqDataBytes, _ := cbor.Marshal(requestData)
|
req := common.HubRequest[any]{Action: action, Data: requestData}
|
||||||
req := common.HubRequest[cbor.RawMessage]{Action: action, Data: reqDataBytes}
|
|
||||||
_ = cbor.NewEncoder(stdin).Encode(req)
|
_ = cbor.NewEncoder(stdin).Encode(req)
|
||||||
_ = stdin.Close()
|
_ = stdin.Close()
|
||||||
var resp common.AgentResponse
|
var resp common.AgentResponse
|
||||||
@@ -460,8 +472,7 @@ func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.Servic
|
|||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
reqDataBytes, _ := cbor.Marshal(common.SystemdInfoRequest{ServiceName: serviceName})
|
req := common.HubRequest[any]{Action: common.GetSystemdInfo, Data: common.SystemdInfoRequest{ServiceName: serviceName}}
|
||||||
req := common.HubRequest[cbor.RawMessage]{Action: common.GetSystemdInfo, Data: reqDataBytes}
|
|
||||||
if err := cbor.NewEncoder(stdin).Encode(req); err != nil {
|
if err := cbor.NewEncoder(stdin).Encode(req); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -509,8 +520,7 @@ func (sys *System) fetchDataViaSSH(options common.DataRequestOptions) (*system.C
|
|||||||
*sys.data = system.CombinedData{}
|
*sys.data = system.CombinedData{}
|
||||||
|
|
||||||
if sys.agentVersion.GTE(beszel.MinVersionAgentResponse) && stdinErr == nil {
|
if sys.agentVersion.GTE(beszel.MinVersionAgentResponse) && stdinErr == nil {
|
||||||
reqDataBytes, _ := cbor.Marshal(options)
|
req := common.HubRequest[any]{Action: common.GetData, Data: options}
|
||||||
req := common.HubRequest[cbor.RawMessage]{Action: common.GetData, Data: reqDataBytes}
|
|
||||||
_ = cbor.NewEncoder(stdin).Encode(req)
|
_ = cbor.NewEncoder(stdin).Encode(req)
|
||||||
_ = stdin.Close()
|
_ = stdin.Close()
|
||||||
|
|
||||||
|
|||||||
10
internal/hub/systems/systems_production.go
Normal file
10
internal/hub/systems/systems_production.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
//go:build !testing
|
||||||
|
// +build !testing
|
||||||
|
|
||||||
|
package systems
|
||||||
|
|
||||||
|
// Background SMART fetching is enabled in production but disabled for tests (systems_test_helpers.go).
|
||||||
|
//
|
||||||
|
// The hub integration tests create/replace systems and clean up the test apps quickly.
|
||||||
|
// Background SMART fetching can outlive teardown and crash in PocketBase internals (nil DB).
|
||||||
|
func backgroundSmartFetchEnabled() bool { return true }
|
||||||
@@ -10,6 +10,13 @@ import (
|
|||||||
entities "github.com/henrygd/beszel/internal/entities/system"
|
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
|
// TESTING ONLY: GetSystemCount returns the number of systems in the store
|
||||||
func (sm *SystemManager) GetSystemCount() int {
|
func (sm *SystemManager) GetSystemCount() int {
|
||||||
return sm.systems.Length()
|
return sm.systems.Length()
|
||||||
|
|||||||
@@ -36,8 +36,8 @@
|
|||||||
"lucide-react": "^0.452.0",
|
"lucide-react": "^0.452.0",
|
||||||
"nanostores": "^0.11.4",
|
"nanostores": "^0.11.4",
|
||||||
"pocketbase": "^0.26.2",
|
"pocketbase": "^0.26.2",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.2",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.2",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"shiki": "^3.13.0",
|
"shiki": "^3.13.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
@@ -811,9 +811,9 @@
|
|||||||
|
|
||||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
|
|
||||||
"react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="],
|
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
|
||||||
|
|
||||||
"react-dom": ["react-dom@19.1.1", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.1" } }, "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw=="],
|
"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-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
"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=="],
|
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||||
|
|
||||||
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
"semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
@@ -971,8 +971,6 @@
|
|||||||
|
|
||||||
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
"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=="],
|
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
"restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
"restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||||
@@ -981,28 +979,18 @@
|
|||||||
|
|
||||||
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
"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/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/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/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=="],
|
"@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=="],
|
"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/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=="],
|
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.0.1", "", {}, "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import type {
|
|||||||
ChartTimes,
|
ChartTimes,
|
||||||
ContainerStatsRecord,
|
ContainerStatsRecord,
|
||||||
GPUData,
|
GPUData,
|
||||||
|
SystemDetailsRecord,
|
||||||
SystemInfo,
|
SystemInfo,
|
||||||
SystemRecord,
|
SystemRecord,
|
||||||
SystemStats,
|
SystemStats,
|
||||||
@@ -166,7 +167,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
const isLongerChart = !["1m", "1h"].includes(chartTime) // true if chart time is not 1m or 1h
|
const isLongerChart = !["1m", "1h"].includes(chartTime) // true if chart time is not 1m or 1h
|
||||||
const userSettings = $userSettings.get()
|
const userSettings = $userSettings.get()
|
||||||
const chartWrapRef = useRef<HTMLDivElement>(null)
|
const chartWrapRef = useRef<HTMLDivElement>(null)
|
||||||
const [isPodman, setIsPodman] = useState(system.info?.p ?? false)
|
const [details, setDetails] = useState<SystemDetailsRecord>({} as SystemDetailsRecord)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -176,6 +177,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
persistChartTime.current = false
|
persistChartTime.current = false
|
||||||
setSystemStats([])
|
setSystemStats([])
|
||||||
setContainerData([])
|
setContainerData([])
|
||||||
|
setDetails({} as SystemDetailsRecord)
|
||||||
$containerFilter.set("")
|
$containerFilter.set("")
|
||||||
}
|
}
|
||||||
}, [id])
|
}, [id])
|
||||||
@@ -203,6 +205,22 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
}
|
}
|
||||||
}, [system?.info?.v])
|
}, [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
|
// subscribe to realtime metrics if chart time is 1m
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
|
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -322,10 +340,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
})
|
})
|
||||||
}, [system, chartTime])
|
}, [system, chartTime])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsPodman(system.info?.p ?? false)
|
|
||||||
}, [system.info?.p])
|
|
||||||
|
|
||||||
/** Space for tooltip if more than 10 sensors and no containers table */
|
/** Space for tooltip if more than 10 sensors and no containers table */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sensors = Object.keys(systemStats.at(-1)?.stats.t ?? {})
|
const sensors = Object.keys(systemStats.at(-1)?.stats.t ?? {})
|
||||||
@@ -393,12 +407,14 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
const hasGpuData = lastGpuVals.length > 0
|
const hasGpuData = lastGpuVals.length > 0
|
||||||
const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined || gpu.pp !== undefined)
|
const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined || gpu.pp !== undefined)
|
||||||
const hasGpuEnginesData = lastGpuVals.some((gpu) => gpu.e !== 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
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div ref={chartWrapRef} className="grid gap-4 mb-14 overflow-x-clip">
|
<div ref={chartWrapRef} className="grid gap-4 mb-14 overflow-x-clip">
|
||||||
{/* system info */}
|
{/* system info */}
|
||||||
<InfoBar system={system} chartData={chartData} grid={grid} setGrid={setGrid} setIsPodman={setIsPodman} />
|
<InfoBar system={system} chartData={chartData} grid={grid} setGrid={setGrid} details={details} />
|
||||||
|
|
||||||
{/* <Tabs defaultValue="overview" className="w-full">
|
{/* <Tabs defaultValue="overview" className="w-full">
|
||||||
<TabsList className="w-full h-11">
|
<TabsList className="w-full h-11">
|
||||||
@@ -841,7 +857,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
<LazyContainersTable systemId={system.id} />
|
<LazyContainersTable systemId={system.id} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{system.info?.os === Os.Linux && compareSemVer(chartData.agentVersion, parseSemVer("0.16.0")) >= 0 && (
|
{isLinux && compareSemVer(chartData.agentVersion, parseSemVer("0.16.0")) >= 0 && (
|
||||||
<LazySystemdTable systemId={system.id} />
|
<LazySystemdTable systemId={system.id} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,14 +11,13 @@ import {
|
|||||||
MonitorIcon,
|
MonitorIcon,
|
||||||
Rows,
|
Rows,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useMemo } from "react"
|
||||||
import ChartTimeSelect from "@/components/charts/chart-time-select"
|
import ChartTimeSelect from "@/components/charts/chart-time-select"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card"
|
||||||
import { FreeBsdIcon, TuxIcon, WebSocketIcon, WindowsIcon } from "@/components/ui/icons"
|
import { FreeBsdIcon, TuxIcon, WebSocketIcon, WindowsIcon } from "@/components/ui/icons"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
import { pb } from "@/lib/api"
|
|
||||||
import { ConnectionType, connectionTypeLabels, Os, SystemStatus } from "@/lib/enums"
|
import { ConnectionType, connectionTypeLabels, Os, SystemStatus } from "@/lib/enums"
|
||||||
import { cn, formatBytes, getHostDisplayValue, secondsToString, toFixedFloat } from "@/lib/utils"
|
import { cn, formatBytes, getHostDisplayValue, secondsToString, toFixedFloat } from "@/lib/utils"
|
||||||
import type { ChartData, SystemDetailsRecord, SystemRecord } from "@/types"
|
import type { ChartData, SystemDetailsRecord, SystemRecord } from "@/types"
|
||||||
@@ -28,44 +27,15 @@ export default function InfoBar({
|
|||||||
chartData,
|
chartData,
|
||||||
grid,
|
grid,
|
||||||
setGrid,
|
setGrid,
|
||||||
setIsPodman,
|
details,
|
||||||
}: {
|
}: {
|
||||||
system: SystemRecord
|
system: SystemRecord
|
||||||
chartData: ChartData
|
chartData: ChartData
|
||||||
grid: boolean
|
grid: boolean
|
||||||
setGrid: (grid: boolean) => void
|
setGrid: (grid: boolean) => void
|
||||||
setIsPodman: (isPodman: boolean) => void
|
details: SystemDetailsRecord | null
|
||||||
}) {
|
}) {
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
const [details, setDetails] = useState<SystemDetailsRecord | null>(null)
|
|
||||||
|
|
||||||
// Fetch system_details on mount / when system changes
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true
|
|
||||||
setDetails(null)
|
|
||||||
// skip fetching system details if agent is older version which includes details in Info struct
|
|
||||||
if (!system.id || system.info?.m) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pb.collection<SystemDetailsRecord>("system_details")
|
|
||||||
.getOne(system.id, {
|
|
||||||
fields: "hostname,kernel,cores,threads,cpu,os,os_name,arch,memory,podman",
|
|
||||||
headers: {
|
|
||||||
"Cache-Control": "public, max-age=60",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then((details) => {
|
|
||||||
if (active) {
|
|
||||||
setDetails(details)
|
|
||||||
setIsPodman(details.podman)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false
|
|
||||||
}
|
|
||||||
}, [system.id])
|
|
||||||
|
|
||||||
// values for system info bar - use details with fallback to system.info
|
// values for system info bar - use details with fallback to system.info
|
||||||
const systemInfo = useMemo(() => {
|
const systemInfo = useMemo(() => {
|
||||||
|
|||||||
@@ -93,51 +93,15 @@ 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 to format capacity display
|
||||||
function formatCapacity(bytes: number): string {
|
function formatCapacity(bytes: number): string {
|
||||||
const { value, unit } = formatBytes(bytes)
|
const { value, unit } = formatBytes(bytes)
|
||||||
return `${toFixedFloat(value, value >= 10 ? 1 : 2)} ${unit}`
|
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"
|
const SMART_DEVICE_FIELDS = "id,system,name,model,state,capacity,temp,type,hours,cycles,updated"
|
||||||
|
|
||||||
export const columns: ColumnDef<DiskInfo>[] = [
|
export const columns: ColumnDef<SmartDeviceRecord>[] = [
|
||||||
{
|
{
|
||||||
id: "system",
|
id: "system",
|
||||||
accessorFn: (record) => record.system,
|
accessorFn: (record) => record.system,
|
||||||
@@ -154,12 +118,12 @@ export const columns: ColumnDef<DiskInfo>[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "device",
|
accessorKey: "name",
|
||||||
sortingFn: (a, b) => a.original.device.localeCompare(b.original.device),
|
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Device`} Icon={HardDrive} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Device`} Icon={HardDrive} />,
|
||||||
cell: ({ row }) => (
|
cell: ({ getValue }) => (
|
||||||
<div className="font-medium max-w-40 truncate ms-1.5" title={row.getValue("device")}>
|
<div className="font-medium max-w-40 truncate ms-1.5" title={getValue() as string}>
|
||||||
{row.getValue("device")}
|
{getValue() as string}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -167,19 +131,20 @@ export const columns: ColumnDef<DiskInfo>[] = [
|
|||||||
accessorKey: "model",
|
accessorKey: "model",
|
||||||
sortingFn: (a, b) => a.original.model.localeCompare(b.original.model),
|
sortingFn: (a, b) => a.original.model.localeCompare(b.original.model),
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Model`} Icon={Box} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Model`} Icon={Box} />,
|
||||||
cell: ({ row }) => (
|
cell: ({ getValue }) => (
|
||||||
<div className="max-w-48 truncate ms-1.5" title={row.getValue("model")}>
|
<div className="max-w-48 truncate ms-1.5" title={getValue() as string}>
|
||||||
{row.getValue("model")}
|
{getValue() as string}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "capacity",
|
accessorKey: "capacity",
|
||||||
|
invertSorting: true,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Capacity`} Icon={BinaryIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Capacity`} Icon={BinaryIcon} />,
|
||||||
cell: ({ getValue }) => <span className="ms-1.5">{getValue() as string}</span>,
|
cell: ({ getValue }) => <span className="ms-1.5">{formatCapacity(getValue() as number)}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "status",
|
accessorKey: "state",
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Status`} Icon={Activity} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Status`} Icon={Activity} />,
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const status = getValue() as string
|
const status = getValue() as string
|
||||||
@@ -191,8 +156,8 @@ export const columns: ColumnDef<DiskInfo>[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "deviceType",
|
accessorKey: "type",
|
||||||
sortingFn: (a, b) => a.original.deviceType.localeCompare(b.original.deviceType),
|
sortingFn: (a, b) => a.original.type.localeCompare(b.original.type),
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Type`} Icon={ArrowLeftRightIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Type`} Icon={ArrowLeftRightIcon} />,
|
||||||
cell: ({ getValue }) => (
|
cell: ({ getValue }) => (
|
||||||
<div className="ms-1.5">
|
<div className="ms-1.5">
|
||||||
@@ -203,7 +168,7 @@ export const columns: ColumnDef<DiskInfo>[] = [
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "powerOnHours",
|
accessorKey: "hours",
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<HeaderButton column={column} name={t({ message: "Power On", comment: "Power On Time" })} Icon={Clock} />
|
<HeaderButton column={column} name={t({ message: "Power On", comment: "Power On Time" })} Icon={Clock} />
|
||||||
@@ -223,7 +188,7 @@ export const columns: ColumnDef<DiskInfo>[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "powerCycles",
|
accessorKey: "cycles",
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<HeaderButton column={column} name={t({ message: "Cycles", comment: "Power Cycles" })} Icon={RotateCwIcon} />
|
<HeaderButton column={column} name={t({ message: "Cycles", comment: "Power Cycles" })} Icon={RotateCwIcon} />
|
||||||
@@ -237,7 +202,7 @@ export const columns: ColumnDef<DiskInfo>[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "temperature",
|
accessorKey: "temp",
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Temp`} Icon={ThermometerIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Temp`} Icon={ThermometerIcon} />,
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
@@ -246,14 +211,14 @@ export const columns: ColumnDef<DiskInfo>[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
// accessorKey: "serialNumber",
|
// accessorKey: "serial",
|
||||||
// sortingFn: (a, b) => a.original.serialNumber.localeCompare(b.original.serialNumber),
|
// sortingFn: (a, b) => a.original.serial.localeCompare(b.original.serial),
|
||||||
// header: ({ column }) => <HeaderButton column={column} name={t`Serial Number`} Icon={HashIcon} />,
|
// header: ({ column }) => <HeaderButton column={column} name={t`Serial Number`} Icon={HashIcon} />,
|
||||||
// cell: ({ getValue }) => <span className="ms-1.5">{getValue() as string}</span>,
|
// cell: ({ getValue }) => <span className="ms-1.5">{getValue() as string}</span>,
|
||||||
// },
|
// },
|
||||||
// {
|
// {
|
||||||
// accessorKey: "firmwareVersion",
|
// accessorKey: "firmware",
|
||||||
// sortingFn: (a, b) => a.original.firmwareVersion.localeCompare(b.original.firmwareVersion),
|
// sortingFn: (a, b) => a.original.firmware.localeCompare(b.original.firmware),
|
||||||
// header: ({ column }) => <HeaderButton column={column} name={t`Firmware`} Icon={CpuIcon} />,
|
// header: ({ column }) => <HeaderButton column={column} name={t`Firmware`} Icon={CpuIcon} />,
|
||||||
// cell: ({ getValue }) => <span className="ms-1.5">{getValue() as string}</span>,
|
// cell: ({ getValue }) => <span className="ms-1.5">{getValue() as string}</span>,
|
||||||
// },
|
// },
|
||||||
@@ -272,7 +237,15 @@ export const columns: ColumnDef<DiskInfo>[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function HeaderButton({ column, name, Icon }: { column: Column<DiskInfo>; name: string; Icon: React.ElementType }) {
|
function HeaderButton({
|
||||||
|
column,
|
||||||
|
name,
|
||||||
|
Icon,
|
||||||
|
}: {
|
||||||
|
column: Column<SmartDeviceRecord>
|
||||||
|
name: string
|
||||||
|
Icon: React.ElementType
|
||||||
|
}) {
|
||||||
const isSorted = column.getIsSorted()
|
const isSorted = column.getIsSorted()
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -290,7 +263,7 @@ function HeaderButton({ column, name, Icon }: { column: Column<DiskInfo>; name:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DisksTable({ systemId }: { systemId?: string }) {
|
export default function DisksTable({ systemId }: { systemId?: string }) {
|
||||||
const [sorting, setSorting] = useState<SortingState>([{ id: systemId ? "device" : "system", desc: false }])
|
const [sorting, setSorting] = useState<SortingState>([{ id: systemId ? "name" : "system", desc: false }])
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||||
const [rowSelection, setRowSelection] = useState({})
|
const [rowSelection, setRowSelection] = useState({})
|
||||||
const [smartDevices, setSmartDevices] = useState<SmartDeviceRecord[] | undefined>(undefined)
|
const [smartDevices, setSmartDevices] = useState<SmartDeviceRecord[] | undefined>(undefined)
|
||||||
@@ -299,96 +272,95 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
|
|||||||
const [rowActionState, setRowActionState] = useState<{ type: "refresh" | "delete"; id: string } | null>(null)
|
const [rowActionState, setRowActionState] = useState<{ type: "refresh" | "delete"; id: string } | null>(null)
|
||||||
const [globalFilter, setGlobalFilter] = useState("")
|
const [globalFilter, setGlobalFilter] = useState("")
|
||||||
|
|
||||||
const openSheet = (disk: DiskInfo) => {
|
const openSheet = (disk: SmartDeviceRecord) => {
|
||||||
setActiveDiskId(disk.id)
|
setActiveDiskId(disk.id)
|
||||||
setSheetOpen(true)
|
setSheetOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch smart devices from collection (without attributes to save bandwidth)
|
// Fetch smart devices
|
||||||
const fetchSmartDevices = useCallback(() => {
|
useEffect(() => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
|
||||||
pb.collection<SmartDeviceRecord>("smart_devices")
|
pb.collection<SmartDeviceRecord>("smart_devices")
|
||||||
.getFullList({
|
.getFullList({
|
||||||
filter: systemId ? pb.filter("system = {:system}", { system: systemId }) : undefined,
|
filter: systemId ? pb.filter("system = {:system}", { system: systemId }) : undefined,
|
||||||
fields: SMART_DEVICE_FIELDS,
|
fields: SMART_DEVICE_FIELDS,
|
||||||
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
.then((records) => {
|
.then(setSmartDevices)
|
||||||
setSmartDevices(records)
|
.catch((err) => {
|
||||||
|
if (!err.isAbort) {
|
||||||
|
setSmartDevices([])
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => setSmartDevices([]))
|
|
||||||
|
return () => controller.abort()
|
||||||
}, [systemId])
|
}, [systemId])
|
||||||
|
|
||||||
// Fetch smart devices when component mounts or systemId changes
|
// Subscribe to updates
|
||||||
useEffect(() => {
|
|
||||||
fetchSmartDevices()
|
|
||||||
}, [fetchSmartDevices])
|
|
||||||
|
|
||||||
// Subscribe to live updates so rows add/remove without manual refresh/filtering
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsubscribe: (() => void) | undefined
|
let unsubscribe: (() => void) | undefined
|
||||||
const pbOptions = systemId
|
const pbOptions = systemId
|
||||||
? { fields: SMART_DEVICE_FIELDS, filter: pb.filter("system = {:system}", { system: systemId }) }
|
? { fields: SMART_DEVICE_FIELDS, filter: pb.filter("system = {:system}", { system: systemId }) }
|
||||||
: { fields: SMART_DEVICE_FIELDS }
|
: { fields: SMART_DEVICE_FIELDS }
|
||||||
|
|
||||||
;(async () => {
|
; (async () => {
|
||||||
try {
|
try {
|
||||||
unsubscribe = await pb.collection("smart_devices").subscribe(
|
unsubscribe = await pb.collection("smart_devices").subscribe(
|
||||||
"*",
|
"*",
|
||||||
(event) => {
|
(event) => {
|
||||||
const record = event.record as SmartDeviceRecord
|
const record = event.record as SmartDeviceRecord
|
||||||
setSmartDevices((currentDevices) => {
|
setSmartDevices((currentDevices) => {
|
||||||
const devices = currentDevices ?? []
|
const devices = currentDevices ?? []
|
||||||
const matchesSystemScope = !systemId || record.system === systemId
|
const matchesSystemScope = !systemId || record.system === systemId
|
||||||
|
|
||||||
if (event.action === "delete") {
|
if (event.action === "delete") {
|
||||||
return devices.filter((device) => device.id !== record.id)
|
return devices.filter((device) => device.id !== record.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!matchesSystemScope) {
|
if (!matchesSystemScope) {
|
||||||
// Record moved out of scope; ensure it disappears locally.
|
// Record moved out of scope; ensure it disappears locally.
|
||||||
return devices.filter((device) => device.id !== record.id)
|
return devices.filter((device) => device.id !== record.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingIndex = devices.findIndex((device) => device.id === record.id)
|
const existingIndex = devices.findIndex((device) => device.id === record.id)
|
||||||
if (existingIndex === -1) {
|
if (existingIndex === -1) {
|
||||||
return [record, ...devices]
|
return [record, ...devices]
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = [...devices]
|
const next = [...devices]
|
||||||
next[existingIndex] = record
|
next[existingIndex] = record
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
pbOptions
|
pbOptions
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to subscribe to SMART device updates:", error)
|
console.error("Failed to subscribe to SMART device updates:", error)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe?.()
|
unsubscribe?.()
|
||||||
}
|
}
|
||||||
}, [systemId])
|
}, [systemId])
|
||||||
|
|
||||||
const handleRowRefresh = useCallback(
|
const handleRowRefresh = useCallback(async (disk: SmartDeviceRecord) => {
|
||||||
async (disk: DiskInfo) => {
|
if (!disk.system) return
|
||||||
if (!disk.system) return
|
setRowActionState({ type: "refresh", id: disk.id })
|
||||||
setRowActionState({ type: "refresh", id: disk.id })
|
try {
|
||||||
try {
|
await pb.send("/api/beszel/smart/refresh", {
|
||||||
await pb.send("/api/beszel/smart/refresh", {
|
method: "POST",
|
||||||
method: "POST",
|
query: { system: disk.system },
|
||||||
query: { system: disk.system },
|
})
|
||||||
})
|
} catch (error) {
|
||||||
} catch (error) {
|
console.error("Failed to refresh SMART device:", error)
|
||||||
console.error("Failed to refresh SMART device:", error)
|
} finally {
|
||||||
} finally {
|
setRowActionState((state) => (state?.id === disk.id ? null : state))
|
||||||
setRowActionState((state) => (state?.id === disk.id ? null : state))
|
}
|
||||||
}
|
}, [])
|
||||||
},
|
|
||||||
[fetchSmartDevices]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDeleteDevice = useCallback(async (disk: DiskInfo) => {
|
const handleDeleteDevice = useCallback(async (disk: SmartDeviceRecord) => {
|
||||||
setRowActionState({ type: "delete", id: disk.id })
|
setRowActionState({ type: "delete", id: disk.id })
|
||||||
try {
|
try {
|
||||||
await pb.collection("smart_devices").delete(disk.id)
|
await pb.collection("smart_devices").delete(disk.id)
|
||||||
@@ -400,7 +372,7 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const actionColumn = useMemo<ColumnDef<DiskInfo>>(
|
const actionColumn = useMemo<ColumnDef<SmartDeviceRecord>>(
|
||||||
() => ({
|
() => ({
|
||||||
id: "actions",
|
id: "actions",
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
@@ -468,13 +440,8 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
|
|||||||
return [...baseColumns, actionColumn]
|
return [...baseColumns, actionColumn]
|
||||||
}, [systemId, actionColumn])
|
}, [systemId, actionColumn])
|
||||||
|
|
||||||
// Convert SmartDeviceRecord to DiskInfo
|
|
||||||
const diskData = useMemo(() => {
|
|
||||||
return smartDevices ? convertSmartDeviceRecordToDiskInfo(smartDevices) : []
|
|
||||||
}, [smartDevices])
|
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: diskData,
|
data: smartDevices || ([] as SmartDeviceRecord[]),
|
||||||
columns: tableColumns,
|
columns: tableColumns,
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
@@ -492,10 +459,10 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
|
|||||||
globalFilterFn: (row, _columnId, filterValue) => {
|
globalFilterFn: (row, _columnId, filterValue) => {
|
||||||
const disk = row.original
|
const disk = row.original
|
||||||
const systemName = $allSystemsById.get()[disk.system]?.name ?? ""
|
const systemName = $allSystemsById.get()[disk.system]?.name ?? ""
|
||||||
const device = disk.device ?? ""
|
const device = disk.name ?? ""
|
||||||
const model = disk.model ?? ""
|
const model = disk.model ?? ""
|
||||||
const status = disk.status ?? ""
|
const status = disk.state ?? ""
|
||||||
const type = disk.deviceType ?? ""
|
const type = disk.type ?? ""
|
||||||
const searchString = `${systemName} ${device} ${model} ${status} ${type}`.toLowerCase()
|
const searchString = `${systemName} ${device} ${model} ${status} ${type}`.toLowerCase()
|
||||||
return (filterValue as string)
|
return (filterValue as string)
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -505,7 +472,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
|
// Hide the table on system pages if there's no data, but always show on global page
|
||||||
if (systemId && !diskData.length && !columnFilters.length) {
|
if (systemId && !smartDevices?.length && !columnFilters.length) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
## 0.18.0
|
## 0.18.0
|
||||||
|
|
||||||
- Remove `la1`, `la5`, `la15` fields from `Info` struct in favor of `la` array.
|
- Collect S.M.A.R.T. data in the background every hour.
|
||||||
|
|
||||||
- Remove `MB` bandwidth values in favor of bytes.
|
- 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
|
## 0.17.0
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user