Compare commits

..

8 Commits

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

View File

@@ -84,7 +84,6 @@ 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) {
@@ -104,17 +103,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
agent.dockerManager = newDockerManager() agent.dockerManager = newDockerManager()
// initialize system info // initialize system info
agent.refreshSystemDetails() agent.refreshStaticInfo()
// 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)
@@ -177,7 +166,7 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
Info: a.systemInfo, Info: a.systemInfo,
} }
// Include static system details only when requested // Include static info only when requested
if options.IncludeDetails { if options.IncludeDetails {
data.Details = &a.systemDetails data.Details = &a.systemDetails
} }
@@ -244,8 +233,7 @@ 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()
// we ignore a commonly known "product_uuid" known not to be unique if err != nil || fingerprint == "" {
if err != nil || fingerprint == "" || fingerprint == "03000200-0400-0500-0006-000700080009" {
fingerprint = a.systemDetails.Hostname + a.systemDetails.CpuModel fingerprint = a.systemDetails.Hostname + a.systemDetails.CpuModel
} }

View File

@@ -757,6 +757,7 @@ 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
} }

View File

@@ -550,9 +550,6 @@ 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",

View File

@@ -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) refreshSystemDetails() { func (a *Agent) refreshStaticInfo() {
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,6 +246,7 @@ 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
} }

View File

@@ -34,12 +34,15 @@ type ApiStats struct {
MemoryStats MemoryStats `json:"memory_stats"` MemoryStats MemoryStats `json:"memory_stats"`
} }
// Docker system info from /info API endpoint // Docker system info from /info
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 {

View File

@@ -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,17 +155,16 @@ 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

View File

@@ -415,11 +415,7 @@ 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)
// Trigger lazy cleanup of the expired key // Try to remove the expired value - should remove one of the "value1" entries
_, 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)
@@ -427,9 +423,14 @@ 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"))
// key1 should be gone due to expiration and key3 should be removed by value. // Should have removed one of the "value1" entries (either key1 or key3)
assert.False(t, em.Has("key1")) // But we can't predict which one due to map iteration order
assert.False(t, em.Has("key3")) 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) { func TestExpiryMap_ValueOperations_Integration(t *testing.T) {

View File

@@ -42,9 +42,8 @@ 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 {
@@ -124,30 +123,10 @@ 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 {
return err _, err = sys.createRecords(data)
} }
// 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
} }
@@ -218,10 +197,6 @@ 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)
@@ -233,6 +208,18 @@ 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
} }
@@ -408,7 +395,8 @@ 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
} }
req := common.HubRequest[any]{Action: action, Data: requestData} reqDataBytes, _ := cbor.Marshal(requestData)
req := common.HubRequest[cbor.RawMessage]{Action: action, Data: reqDataBytes}
_ = cbor.NewEncoder(stdin).Encode(req) _ = cbor.NewEncoder(stdin).Encode(req)
_ = stdin.Close() _ = stdin.Close()
var resp common.AgentResponse var resp common.AgentResponse
@@ -472,7 +460,8 @@ func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.Servic
return false, err return false, err
} }
req := common.HubRequest[any]{Action: common.GetSystemdInfo, Data: common.SystemdInfoRequest{ServiceName: serviceName}} reqDataBytes, _ := cbor.Marshal(common.SystemdInfoRequest{ServiceName: serviceName})
req := common.HubRequest[cbor.RawMessage]{Action: common.GetSystemdInfo, Data: reqDataBytes}
if err := cbor.NewEncoder(stdin).Encode(req); err != nil { if err := cbor.NewEncoder(stdin).Encode(req); err != nil {
return false, err return false, err
} }
@@ -520,7 +509,8 @@ 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 {
req := common.HubRequest[any]{Action: common.GetData, Data: options} reqDataBytes, _ := cbor.Marshal(options)
req := common.HubRequest[cbor.RawMessage]{Action: common.GetData, Data: reqDataBytes}
_ = cbor.NewEncoder(stdin).Encode(req) _ = cbor.NewEncoder(stdin).Encode(req)
_ = stdin.Close() _ = stdin.Close()

View File

@@ -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 }

View File

@@ -10,13 +10,6 @@ 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()

View File

@@ -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.2", "react": "^19.1.1",
"react-dom": "^19.1.2", "react-dom": "^19.1.1",
"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.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=="], "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.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=="], "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=="], "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=="],
@@ -979,18 +981,28 @@
"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=="],
} }
} }

View File

@@ -46,7 +46,6 @@ import type {
ChartTimes, ChartTimes,
ContainerStatsRecord, ContainerStatsRecord,
GPUData, GPUData,
SystemDetailsRecord,
SystemInfo, SystemInfo,
SystemRecord, SystemRecord,
SystemStats, SystemStats,
@@ -167,7 +166,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
const isLongerChart = !["1m", "1h"].includes(chartTime) // true if chart time is not 1m or 1h const 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 [details, setDetails] = useState<SystemDetailsRecord>({} as SystemDetailsRecord) const [isPodman, setIsPodman] = useState(system.info?.p ?? false)
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -177,7 +176,6 @@ 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])
@@ -205,22 +203,6 @@ 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(() => {
@@ -340,6 +322,10 @@ 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 ?? {})
@@ -407,14 +393,12 @@ 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} details={details} /> <InfoBar system={system} chartData={chartData} grid={grid} setGrid={setGrid} setIsPodman={setIsPodman} />
{/* <Tabs defaultValue="overview" className="w-full"> {/* <Tabs defaultValue="overview" className="w-full">
<TabsList className="w-full h-11"> <TabsList className="w-full h-11">
@@ -857,7 +841,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<LazyContainersTable systemId={system.id} /> <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} /> <LazySystemdTable systemId={system.id} />
)} )}
</div> </div>

View File

@@ -11,13 +11,14 @@ import {
MonitorIcon, MonitorIcon,
Rows, Rows,
} from "lucide-react" } from "lucide-react"
import { useMemo } from "react" import { useEffect, useMemo, useState } 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"
@@ -27,15 +28,44 @@ export default function InfoBar({
chartData, chartData,
grid, grid,
setGrid, setGrid,
details, setIsPodman,
}: { }: {
system: SystemRecord system: SystemRecord
chartData: ChartData chartData: ChartData
grid: boolean grid: boolean
setGrid: (grid: boolean) => void setGrid: (grid: boolean) => void
details: SystemDetailsRecord | null setIsPodman: (isPodman: boolean) => void
}) { }) {
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(() => {

View File

@@ -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 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<SmartDeviceRecord>[] = [ export const columns: ColumnDef<DiskInfo>[] = [
{ {
id: "system", id: "system",
accessorFn: (record) => record.system, accessorFn: (record) => record.system,
@@ -118,12 +154,12 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
}, },
}, },
{ {
accessorKey: "name", accessorKey: "device",
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name), sortingFn: (a, b) => a.original.device.localeCompare(b.original.device),
header: ({ column }) => <HeaderButton column={column} name={t`Device`} Icon={HardDrive} />, header: ({ column }) => <HeaderButton column={column} name={t`Device`} Icon={HardDrive} />,
cell: ({ getValue }) => ( cell: ({ row }) => (
<div className="font-medium max-w-40 truncate ms-1.5" title={getValue() as string}> <div className="font-medium max-w-40 truncate ms-1.5" title={row.getValue("device")}>
{getValue() as string} {row.getValue("device")}
</div> </div>
), ),
}, },
@@ -131,20 +167,19 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
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: ({ getValue }) => ( cell: ({ row }) => (
<div className="max-w-48 truncate ms-1.5" title={getValue() as string}> <div className="max-w-48 truncate ms-1.5" title={row.getValue("model")}>
{getValue() as string} {row.getValue("model")}
</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">{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} />, header: ({ column }) => <HeaderButton column={column} name={t`Status`} Icon={Activity} />,
cell: ({ getValue }) => { cell: ({ getValue }) => {
const status = getValue() as string const status = getValue() as string
@@ -156,8 +191,8 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
}, },
}, },
{ {
accessorKey: "type", accessorKey: "deviceType",
sortingFn: (a, b) => a.original.type.localeCompare(b.original.type), sortingFn: (a, b) => a.original.deviceType.localeCompare(b.original.deviceType),
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">
@@ -168,7 +203,7 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
), ),
}, },
{ {
accessorKey: "hours", accessorKey: "powerOnHours",
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} />
@@ -188,7 +223,7 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
}, },
}, },
{ {
accessorKey: "cycles", accessorKey: "powerCycles",
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} />
@@ -202,7 +237,7 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
}, },
}, },
{ {
accessorKey: "temp", accessorKey: "temperature",
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 }) => {
@@ -211,14 +246,14 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
}, },
}, },
// { // {
// accessorKey: "serial", // accessorKey: "serialNumber",
// sortingFn: (a, b) => a.original.serial.localeCompare(b.original.serial), // sortingFn: (a, b) => a.original.serialNumber.localeCompare(b.original.serialNumber),
// 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: "firmware", // accessorKey: "firmwareVersion",
// sortingFn: (a, b) => a.original.firmware.localeCompare(b.original.firmware), // sortingFn: (a, b) => a.original.firmwareVersion.localeCompare(b.original.firmwareVersion),
// 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>,
// }, // },
@@ -237,15 +272,7 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
}, },
] ]
function HeaderButton({ function HeaderButton({ column, name, Icon }: { column: Column<DiskInfo>; name: string; Icon: React.ElementType }) {
column,
name,
Icon,
}: {
column: Column<SmartDeviceRecord>
name: string
Icon: React.ElementType
}) {
const isSorted = column.getIsSorted() const isSorted = column.getIsSorted()
return ( return (
<Button <Button
@@ -263,7 +290,7 @@ function HeaderButton({
} }
export default function DisksTable({ systemId }: { systemId?: string }) { 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 [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)
@@ -272,95 +299,96 @@ 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: SmartDeviceRecord) => { const openSheet = (disk: DiskInfo) => {
setActiveDiskId(disk.id) setActiveDiskId(disk.id)
setSheetOpen(true) setSheetOpen(true)
} }
// Fetch smart devices // Fetch smart devices from collection (without attributes to save bandwidth)
useEffect(() => { const fetchSmartDevices = useCallback(() => {
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(setSmartDevices) .then((records) => {
.catch((err) => { setSmartDevices(records)
if (!err.isAbort) {
setSmartDevices([])
}
}) })
.catch(() => setSmartDevices([]))
return () => controller.abort()
}, [systemId]) }, [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(() => { 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(async (disk: SmartDeviceRecord) => { const handleRowRefresh = useCallback(
if (!disk.system) return async (disk: DiskInfo) => {
setRowActionState({ type: "refresh", id: disk.id }) if (!disk.system) return
try { setRowActionState({ type: "refresh", id: disk.id })
await pb.send("/api/beszel/smart/refresh", { try {
method: "POST", await pb.send("/api/beszel/smart/refresh", {
query: { system: disk.system }, method: "POST",
}) query: { system: disk.system },
} catch (error) { })
console.error("Failed to refresh SMART device:", error) } catch (error) {
} finally { console.error("Failed to refresh SMART device:", error)
setRowActionState((state) => (state?.id === disk.id ? null : state)) } 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 }) setRowActionState({ type: "delete", id: disk.id })
try { try {
await pb.collection("smart_devices").delete(disk.id) 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", id: "actions",
enableSorting: false, enableSorting: false,
@@ -440,8 +468,13 @@ 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: smartDevices || ([] as SmartDeviceRecord[]), data: diskData,
columns: tableColumns, columns: tableColumns,
onSortingChange: setSorting, onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters, onColumnFiltersChange: setColumnFilters,
@@ -459,10 +492,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.name ?? "" const device = disk.device ?? ""
const model = disk.model ?? "" const model = disk.model ?? ""
const status = disk.state ?? "" const status = disk.status ?? ""
const type = disk.type ?? "" const type = disk.deviceType ?? ""
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()
@@ -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 // 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 return null
} }

View File

@@ -1,14 +1,8 @@
## 0.18.0 ## 0.18.0
- Collect S.M.A.R.T. data in the background every hour. - Remove `la1`, `la5`, `la15` fields from `Info` struct in favor of `la` array.
- Add `SMART_INTERVAL` environment variable to customize S.M.A.R.T. data collection interval. - Remove `MB` bandwidth values in favor of bytes.
- 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