Compare commits

...

11 Commits

Author SHA1 Message Date
Sven van Ginkel
fe30f99695 [Feature] Add detailed CPU metrics (User, System, IOWait, Steal) with per-core monitoring (#1356)
* Add user, system io wait

* add per cpu core

* add total
2025-10-31 17:11:22 -04:00
henrygd
85ac2e5e9a update env var name to EXCLUDE_CONTAINERS #1352 2025-10-30 19:30:01 -04:00
Sven van Ginkel
af6bd4e505 [Feature] Add env var to exclude containers from being monitored (#1352) 2025-10-30 19:02:09 -04:00
Gabay
e54c4b3499 New translations en.po (Hebrew) 2025-10-30 16:50:14 -04:00
henrygd
078c88f825 add hebrew machine translations 2025-10-30 16:45:33 -04:00
henrygd
85169b6c5e improve parsing of edge case smart power on times (#1347) 2025-10-30 16:32:06 -04:00
henrygd
d0ff8ee2c0 fix disk i/o values in longer charts (#1355) 2025-10-30 14:17:56 -04:00
henrygd
e898768997 fix battery nil pointer error #1353 2025-10-30 12:52:33 -04:00
henrygd
0f5b504f23 release 0.15.2 2025-10-29 01:18:15 -04:00
henrygd
365d291393 improve smart device detection (#1345)
also fix virtual device filtering
2025-10-29 01:16:58 -04:00
henrygd
3dbab24c0f improve identification of smart drive types (#1345) 2025-10-28 22:37:47 -04:00
21 changed files with 2376 additions and 133 deletions

View File

@@ -10,8 +10,10 @@ import (
"github.com/distatus/battery" "github.com/distatus/battery"
) )
var systemHasBattery = false var (
var haveCheckedBattery = false systemHasBattery = false
haveCheckedBattery = false
)
// HasReadableBattery checks if the system has a battery and returns true if it does. // HasReadableBattery checks if the system has a battery and returns true if it does.
func HasReadableBattery() bool { func HasReadableBattery() bool {
@@ -21,7 +23,7 @@ func HasReadableBattery() bool {
haveCheckedBattery = true haveCheckedBattery = true
batteries, err := battery.GetAll() batteries, err := battery.GetAll()
for _, bat := range batteries { for _, bat := range batteries {
if bat.Full > 0 { if bat != nil && (bat.Full > 0 || bat.Design > 0) {
systemHasBattery = true systemHasBattery = true
break break
} }

View File

@@ -8,6 +8,7 @@ import (
) )
var lastCpuTimes = make(map[uint16]cpu.TimesStat) var lastCpuTimes = make(map[uint16]cpu.TimesStat)
var lastPerCoreCpuTimes = make(map[uint16][]cpu.TimesStat)
// init initializes the CPU monitoring by storing the initial CPU times // init initializes the CPU monitoring by storing the initial CPU times
// for the default 60-second cache interval. // for the default 60-second cache interval.
@@ -15,6 +16,18 @@ func init() {
if times, err := cpu.Times(false); err == nil { if times, err := cpu.Times(false); err == nil {
lastCpuTimes[60000] = times[0] lastCpuTimes[60000] = times[0]
} }
if perCoreTimes, err := cpu.Times(true); err == nil {
lastPerCoreCpuTimes[60000] = perCoreTimes
}
}
// CpuMetrics contains detailed CPU usage breakdown
type CpuMetrics struct {
Total float64
User float64
System float64
Iowait float64
Steal float64
} }
// getCpuPercent calculates the CPU usage percentage using cached previous measurements. // getCpuPercent calculates the CPU usage percentage using cached previous measurements.
@@ -34,6 +47,92 @@ func getCpuPercent(cacheTimeMs uint16) (float64, error) {
return delta, nil return delta, nil
} }
// getCpuMetrics calculates detailed CPU usage metrics using cached previous measurements.
// It returns percentages for total, user, system, iowait, and steal time.
func getCpuMetrics(cacheTimeMs uint16) (CpuMetrics, error) {
times, err := cpu.Times(false)
if err != nil || len(times) == 0 {
return CpuMetrics{}, err
}
// if cacheTimeMs is not in lastCpuTimes, use 60000 as fallback lastCpuTime
if _, ok := lastCpuTimes[cacheTimeMs]; !ok {
lastCpuTimes[cacheTimeMs] = lastCpuTimes[60000]
}
t1 := lastCpuTimes[cacheTimeMs]
t2 := times[0]
t1All, t1Busy := getAllBusy(t1)
t2All, t2Busy := getAllBusy(t2)
totalDelta := t2All - t1All
if totalDelta <= 0 {
return CpuMetrics{}, nil
}
metrics := CpuMetrics{
Total: clampPercent((t2Busy - t1Busy) / totalDelta * 100),
User: clampPercent((t2.User - t1.User) / totalDelta * 100),
System: clampPercent((t2.System - t1.System) / totalDelta * 100),
Iowait: clampPercent((t2.Iowait - t1.Iowait) / totalDelta * 100),
Steal: clampPercent((t2.Steal - t1.Steal) / totalDelta * 100),
}
lastCpuTimes[cacheTimeMs] = times[0]
return metrics, nil
}
// clampPercent ensures the percentage is between 0 and 100
func clampPercent(value float64) float64 {
return math.Min(100, math.Max(0, value))
}
// getPerCoreCpuMetrics calculates per-core CPU usage metrics.
// Returns a map where the key is "cpu0", "cpu1", etc. and the value is an array of [user, system, iowait, steal] percentages.
func getPerCoreCpuMetrics(cacheTimeMs uint16) (map[string][4]float64, error) {
perCoreTimes, err := cpu.Times(true)
if err != nil || len(perCoreTimes) == 0 {
return nil, err
}
// Initialize cache if needed
if _, ok := lastPerCoreCpuTimes[cacheTimeMs]; !ok {
lastPerCoreCpuTimes[cacheTimeMs] = lastPerCoreCpuTimes[60000]
}
lastTimes := lastPerCoreCpuTimes[cacheTimeMs]
result := make(map[string][4]float64)
// Calculate metrics for each core
for i, currentTime := range perCoreTimes {
if i >= len(lastTimes) {
break
}
t1 := lastTimes[i]
t2 := currentTime
t1All, _ := getAllBusy(t1)
t2All, _ := getAllBusy(t2)
totalDelta := t2All - t1All
if totalDelta <= 0 {
continue
}
// Store as [user, system, iowait, steal]
result[currentTime.CPU] = [4]float64{
clampPercent((t2.User - t1.User) / totalDelta * 100),
clampPercent((t2.System - t1.System) / totalDelta * 100),
clampPercent((t2.Iowait - t1.Iowait) / totalDelta * 100),
clampPercent((t2.Steal - t1.Steal) / totalDelta * 100),
}
}
lastPerCoreCpuTimes[cacheTimeMs] = perCoreTimes
return result, nil
}
// calculateBusy calculates the CPU busy percentage between two time points. // calculateBusy calculates the CPU busy percentage between two time points.
// It computes the ratio of busy time to total time elapsed between t1 and t2, // It computes the ratio of busy time to total time elapsed between t1 and t2,
// returning a percentage clamped between 0 and 100. // returning a percentage clamped between 0 and 100.

View File

@@ -13,6 +13,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -53,6 +54,7 @@ type dockerManager struct {
buf *bytes.Buffer // Buffer to store and read response bodies buf *bytes.Buffer // Buffer to store and read response bodies
decoder *json.Decoder // Reusable JSON decoder that reads from buf decoder *json.Decoder // Reusable JSON decoder that reads from buf
apiStats *container.ApiStats // Reusable API stats object apiStats *container.ApiStats // Reusable API stats object
excludeContainers []string // Patterns to exclude containers by name
// Cache-time-aware tracking for CPU stats (similar to cpu.go) // Cache-time-aware tracking for CPU stats (similar to cpu.go)
// Maps cache time intervals to container-specific CPU usage tracking // Maps cache time intervals to container-specific CPU usage tracking
@@ -94,6 +96,19 @@ func (d *dockerManager) dequeue() {
} }
} }
// shouldExcludeContainer checks if a container name matches any exclusion pattern
func (dm *dockerManager) shouldExcludeContainer(name string) bool {
if len(dm.excludeContainers) == 0 {
return false
}
for _, pattern := range dm.excludeContainers {
if match, _ := path.Match(pattern, name); match {
return true
}
}
return false
}
// Returns stats for all running containers with cache-time-aware delta tracking // Returns stats for all running containers with cache-time-aware delta tracking
func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats, error) { func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats, error) {
resp, err := dm.client.Get("http://localhost/containers/json") resp, err := dm.client.Get("http://localhost/containers/json")
@@ -121,6 +136,13 @@ func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats,
for _, ctr := range dm.apiContainerList { for _, ctr := range dm.apiContainerList {
ctr.IdShort = ctr.Id[:12] ctr.IdShort = ctr.Id[:12]
// Skip this container if it matches the exclusion pattern
if dm.shouldExcludeContainer(ctr.Names[0][1:]) {
slog.Debug("Excluding container", "name", ctr.Names[0][1:])
continue
}
dm.validIds[ctr.IdShort] = struct{}{} dm.validIds[ctr.IdShort] = struct{}{}
// check if container is less than 1 minute old (possible restart) // check if container is less than 1 minute old (possible restart)
// note: can't use Created field because it's not updated on restart // note: can't use Created field because it's not updated on restart
@@ -503,6 +525,19 @@ func newDockerManager(a *Agent) *dockerManager {
userAgent: "Docker-Client/", userAgent: "Docker-Client/",
} }
// Read container exclusion patterns from environment variable
var excludeContainers []string
if excludeStr, set := GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" {
parts := strings.SplitSeq(excludeStr, ",")
for part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
excludeContainers = append(excludeContainers, trimmed)
}
}
slog.Info("EXCLUDE_CONTAINERS", "patterns", excludeContainers)
}
manager := &dockerManager{ manager := &dockerManager{
client: &http.Client{ client: &http.Client{
Timeout: timeout, Timeout: timeout,
@@ -512,6 +547,7 @@ func newDockerManager(a *Agent) *dockerManager {
sem: make(chan struct{}, 5), sem: make(chan struct{}, 5),
apiContainerList: []*container.ApiInfo{}, apiContainerList: []*container.ApiInfo{},
apiStats: &container.ApiStats{}, apiStats: &container.ApiStats{},
excludeContainers: excludeContainers,
// Initialize cache-time-aware tracking structures // Initialize cache-time-aware tracking structures
lastCpuContainer: make(map[uint16]map[string]uint64), lastCpuContainer: make(map[uint16]map[string]uint64),

View File

@@ -1099,3 +1099,107 @@ func TestAllocateBuffer(t *testing.T) {
}) })
} }
} }
func TestShouldExcludeContainer(t *testing.T) {
tests := []struct {
name string
containerName string
patterns []string
expected bool
}{
{
name: "empty patterns excludes nothing",
containerName: "any-container",
patterns: []string{},
expected: false,
},
{
name: "exact match - excluded",
containerName: "test-web",
patterns: []string{"test-web", "test-api"},
expected: true,
},
{
name: "exact match - not excluded",
containerName: "prod-web",
patterns: []string{"test-web", "test-api"},
expected: false,
},
{
name: "wildcard prefix match - excluded",
containerName: "test-web",
patterns: []string{"test-*"},
expected: true,
},
{
name: "wildcard prefix match - not excluded",
containerName: "prod-web",
patterns: []string{"test-*"},
expected: false,
},
{
name: "wildcard suffix match - excluded",
containerName: "myapp-staging",
patterns: []string{"*-staging"},
expected: true,
},
{
name: "wildcard suffix match - not excluded",
containerName: "myapp-prod",
patterns: []string{"*-staging"},
expected: false,
},
{
name: "wildcard both sides match - excluded",
containerName: "test-myapp-staging",
patterns: []string{"*-myapp-*"},
expected: true,
},
{
name: "wildcard both sides match - not excluded",
containerName: "prod-yourapp-live",
patterns: []string{"*-myapp-*"},
expected: false,
},
{
name: "multiple patterns - matches first",
containerName: "test-container",
patterns: []string{"test-*", "*-staging"},
expected: true,
},
{
name: "multiple patterns - matches second",
containerName: "myapp-staging",
patterns: []string{"test-*", "*-staging"},
expected: true,
},
{
name: "multiple patterns - no match",
containerName: "prod-web",
patterns: []string{"test-*", "*-staging"},
expected: false,
},
{
name: "mixed exact and wildcard - exact match",
containerName: "temp-container",
patterns: []string{"temp-container", "test-*"},
expected: true,
},
{
name: "mixed exact and wildcard - wildcard match",
containerName: "test-web",
patterns: []string{"temp-container", "test-*"},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dm := &dockerManager{
excludeContainers: tt.patterns,
}
result := dm.shouldExcludeContainer(tt.containerName)
assert.Equal(t, tt.expected, result)
})
}
}

View File

@@ -6,7 +6,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"os/exec" "os/exec"
"slices"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -40,6 +39,11 @@ type DeviceInfo struct {
Type string `json:"type"` Type string `json:"type"`
InfoName string `json:"info_name"` InfoName string `json:"info_name"`
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
// typeVerified reports whether we have already parsed SMART data for this device
// with the stored parserType. When true we can skip re-running the detection logic.
typeVerified bool
// parserType holds the parser type (nvme, sat, scsi) that last succeeded.
parserType string
} }
var errNoValidSmartData = fmt.Errorf("no valid SMART data found") // Error for missing data var errNoValidSmartData = fmt.Errorf("no valid SMART data found") // Error for missing data
@@ -136,6 +140,7 @@ func (sm *SmartManager) ScanDevices(force bool) error {
return nil return nil
} }
sm.lastScanTime = time.Now() sm.lastScanTime = time.Now()
currentDevices := sm.devicesSnapshot()
var configuredDevices []*DeviceInfo var configuredDevices []*DeviceInfo
if configuredRaw, ok := GetEnv("SMART_DEVICES"); ok { if configuredRaw, ok := GetEnv("SMART_DEVICES"); ok {
@@ -173,7 +178,7 @@ func (sm *SmartManager) ScanDevices(force bool) error {
} }
} }
finalDevices := mergeDeviceLists(scannedDevices, configuredDevices) finalDevices := mergeDeviceLists(currentDevices, scannedDevices, configuredDevices)
sm.updateSmartDevices(finalDevices) sm.updateSmartDevices(finalDevices)
if len(finalDevices) == 0 { if len(finalDevices) == 0 {
@@ -221,62 +226,140 @@ func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, er
return devices, nil return devices, nil
} }
// detectDeviceType extracts the device type reported in smartctl JSON output. // detectSmartOutputType inspects sections that are unique to each smartctl
func detectDeviceType(output []byte) string { // JSON schema (NVMe, ATA/SATA, SCSI) to determine which parser should be used
var payload struct { // when the reported device type is ambiguous or missing.
Device struct { func detectSmartOutputType(output []byte) string {
Type string `json:"type"` var hints struct {
} `json:"device"` AtaSmartAttributes json.RawMessage `json:"ata_smart_attributes"`
NVMeSmartHealthInformationLog json.RawMessage `json:"nvme_smart_health_information_log"`
ScsiErrorCounterLog json.RawMessage `json:"scsi_error_counter_log"`
} }
if err := json.Unmarshal(output, &payload); err != nil { if err := json.Unmarshal(output, &hints); err != nil {
return "" return ""
} }
return strings.ToLower(payload.Device.Type) switch {
case hasJSONValue(hints.NVMeSmartHealthInformationLog):
return "nvme"
case hasJSONValue(hints.AtaSmartAttributes):
return "sat"
case hasJSONValue(hints.ScsiErrorCounterLog):
return "scsi"
default:
return "sat"
}
}
// hasJSONValue reports whether a JSON payload contains a concrete value. The
// smartctl output often emits "null" for sections that do not apply, so we
// only treat non-null content as a hint.
func hasJSONValue(raw json.RawMessage) bool {
if len(raw) == 0 {
return false
}
trimmed := strings.TrimSpace(string(raw))
return trimmed != "" && trimmed != "null"
}
func normalizeParserType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "nvme", "sntasmedia", "sntrealtek":
return "nvme"
case "sat", "ata":
return "sat"
case "scsi":
return "scsi"
default:
return strings.ToLower(strings.TrimSpace(value))
}
} }
// parseSmartOutput attempts each SMART parser, optionally detecting the type when // parseSmartOutput attempts each SMART parser, optionally detecting the type when
// it is not provided, and updates the device info when a parser succeeds. // it is not provided, and updates the device info when a parser succeeds.
func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte) bool { func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte) bool {
deviceType := strings.ToLower(deviceInfo.Type)
if deviceType == "" {
if detected := detectDeviceType(output); detected != "" {
deviceType = detected
deviceInfo.Type = detected
}
}
parsers := []struct { parsers := []struct {
Type string Type string
Parse func([]byte) (bool, int) Parse func([]byte) (bool, int)
Alias []string
}{ }{
{Type: "nvme", Parse: sm.parseSmartForNvme, Alias: []string{"sntasmedia", "sntrealtek"}}, {Type: "nvme", Parse: sm.parseSmartForNvme},
{Type: "sat", Parse: sm.parseSmartForSata, Alias: []string{"ata"}}, {Type: "sat", Parse: sm.parseSmartForSata},
{Type: "scsi", Parse: sm.parseSmartForScsi}, {Type: "scsi", Parse: sm.parseSmartForScsi},
} }
for _, parser := range parsers { deviceType := normalizeParserType(deviceInfo.parserType)
if deviceType != "" && deviceType != parser.Type { if deviceType == "" {
aliasMatched := slices.Contains(parser.Alias, deviceType) deviceType = normalizeParserType(deviceInfo.Type)
if !aliasMatched { }
continue if deviceInfo.parserType == "" {
} switch deviceType {
} case "nvme", "sat", "scsi":
deviceInfo.parserType = deviceType
hasData, _ := parser.Parse(output)
if hasData {
if deviceInfo.Type == "" {
deviceInfo.Type = parser.Type
}
return true
} else {
slog.Debug("parser failed", "device", deviceInfo.Name, "parser", parser.Type)
} }
} }
// Only run the type detection when we do not yet know which parser works
// or the previous attempt failed.
needsDetection := deviceType == "" || !deviceInfo.typeVerified
if needsDetection {
structureType := detectSmartOutputType(output)
if deviceType != structureType {
deviceType = structureType
deviceInfo.parserType = structureType
deviceInfo.typeVerified = false
}
if deviceInfo.Type == "" || strings.EqualFold(deviceInfo.Type, structureType) {
deviceInfo.Type = structureType
}
}
// Try the most likely parser first, but keep the remaining parsers in reserve
// so an incorrect hint never leaves the device unparsed.
selectedParsers := make([]struct {
Type string
Parse func([]byte) (bool, int)
}, 0, len(parsers))
if deviceType != "" {
for _, parser := range parsers {
if parser.Type == deviceType {
selectedParsers = append(selectedParsers, parser)
break
}
}
}
for _, parser := range parsers {
alreadySelected := false
for _, selected := range selectedParsers {
if selected.Type == parser.Type {
alreadySelected = true
break
}
}
if alreadySelected {
continue
}
selectedParsers = append(selectedParsers, parser)
}
// Try the selected parsers in order until we find one that succeeds.
for _, parser := range selectedParsers {
hasData, _ := parser.Parse(output)
if hasData {
deviceInfo.parserType = parser.Type
if deviceInfo.Type == "" || strings.EqualFold(deviceInfo.Type, parser.Type) {
deviceInfo.Type = parser.Type
}
// Remember that this parser is valid so future refreshes can bypass
// detection entirely.
deviceInfo.typeVerified = true
return true
}
slog.Debug("parser failed", "device", deviceInfo.Name, "parser", parser.Type)
}
// Leave verification false so the next pass will attempt detection again.
deviceInfo.typeVerified = false
slog.Debug("parsing failed", "device", deviceInfo.Name) slog.Debug("parsing failed", "device", deviceInfo.Name)
return false return false
} }
@@ -335,11 +418,15 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool) []string { func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool) []string {
args := make([]string, 0, 7) args := make([]string, 0, 7)
if deviceInfo != nil && deviceInfo.Type != "" { if deviceInfo != nil {
args = append(args, "-d", deviceInfo.Type) deviceType := strings.ToLower(deviceInfo.Type)
// types sometimes misidentified in scan; see github.com/henrygd/beszel/issues/1345
if deviceType != "" && deviceType != "scsi" && deviceType != "ata" {
args = append(args, "-d", deviceInfo.Type)
}
} }
args = append(args, "-aj") args = append(args, "-a", "--json=c")
if includeStandby { if includeStandby {
args = append(args, "-n", "standby") args = append(args, "-n", "standby")
@@ -395,42 +482,84 @@ func (sm *SmartManager) parseScan(output []byte) ([]*DeviceInfo, bool) {
// mergeDeviceLists combines scanned and configured SMART devices, preferring // mergeDeviceLists combines scanned and configured SMART devices, preferring
// configured SMART_DEVICES when both sources reference the same device. // configured SMART_DEVICES when both sources reference the same device.
func mergeDeviceLists(scanned, configured []*DeviceInfo) []*DeviceInfo { func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo {
if len(scanned) == 0 && len(configured) == 0 { if len(scanned) == 0 && len(configured) == 0 {
return nil return existing
}
// preserveVerifiedType copies the verified type/parser metadata from an existing
// device record so that subsequent scans/config updates never downgrade a
// previously verified device.
preserveVerifiedType := func(target, prev *DeviceInfo) {
if prev == nil || !prev.typeVerified {
return
}
target.Type = prev.Type
target.typeVerified = true
target.parserType = prev.parserType
}
existingIndex := make(map[string]*DeviceInfo, len(existing))
for _, dev := range existing {
if dev == nil || dev.Name == "" {
continue
}
existingIndex[dev.Name] = dev
} }
finalDevices := make([]*DeviceInfo, 0, len(scanned)+len(configured)) finalDevices := make([]*DeviceInfo, 0, len(scanned)+len(configured))
deviceIndex := make(map[string]*DeviceInfo, len(scanned)+len(configured)) deviceIndex := make(map[string]*DeviceInfo, len(scanned)+len(configured))
// Start with the newly scanned devices so we always surface fresh metadata,
// but ensure we retain any previously verified parser assignment.
for _, dev := range scanned { for _, dev := range scanned {
if dev == nil || dev.Name == "" { if dev == nil || dev.Name == "" {
continue continue
} }
// Work on a copy so we can safely adjust metadata without mutating the
// input slices that may be reused elsewhere.
copyDev := *dev copyDev := *dev
if prev := existingIndex[copyDev.Name]; prev != nil {
preserveVerifiedType(&copyDev, prev)
}
finalDevices = append(finalDevices, &copyDev) finalDevices = append(finalDevices, &copyDev)
deviceIndex[copyDev.Name] = finalDevices[len(finalDevices)-1] deviceIndex[copyDev.Name] = finalDevices[len(finalDevices)-1]
} }
// Merge configured devices on top so users can override scan results (except
// for verified type information).
for _, dev := range configured { for _, dev := range configured {
if dev == nil || dev.Name == "" { if dev == nil || dev.Name == "" {
continue continue
} }
if existing, ok := deviceIndex[dev.Name]; ok { if existingDev, ok := deviceIndex[dev.Name]; ok {
if dev.Type != "" { // Only update the type if it has not been verified yet; otherwise we
existing.Type = dev.Type // keep the existing verified metadata intact.
if dev.Type != "" && !existingDev.typeVerified {
newType := strings.TrimSpace(dev.Type)
existingDev.Type = newType
existingDev.typeVerified = false
existingDev.parserType = normalizeParserType(newType)
} }
if dev.InfoName != "" { if dev.InfoName != "" {
existing.InfoName = dev.InfoName existingDev.InfoName = dev.InfoName
} }
if dev.Protocol != "" { if dev.Protocol != "" {
existing.Protocol = dev.Protocol existingDev.Protocol = dev.Protocol
} }
continue continue
} }
copyDev := *dev copyDev := *dev
if prev := existingIndex[copyDev.Name]; prev != nil {
preserveVerifiedType(&copyDev, prev)
} else if copyDev.Type != "" {
copyDev.parserType = normalizeParserType(copyDev.Type)
}
finalDevices = append(finalDevices, &copyDev) finalDevices = append(finalDevices, &copyDev)
deviceIndex[copyDev.Name] = finalDevices[len(finalDevices)-1] deviceIndex[copyDev.Name] = finalDevices[len(finalDevices)-1]
} }
@@ -478,21 +607,40 @@ func (sm *SmartManager) isVirtualDevice(data *smart.SmartInfoForSata) bool {
productUpper := strings.ToUpper(data.ScsiProduct) productUpper := strings.ToUpper(data.ScsiProduct)
modelUpper := strings.ToUpper(data.ModelName) modelUpper := strings.ToUpper(data.ModelName)
switch { return sm.isVirtualDeviceFromStrings(vendorUpper, productUpper, modelUpper)
case strings.Contains(vendorUpper, "IET"), // iSCSI Enterprise Target }
strings.Contains(productUpper, "VIRTUAL"),
strings.Contains(productUpper, "QEMU"), // isVirtualDeviceNvme checks if an NVMe device is a virtual disk that should be filtered out
strings.Contains(productUpper, "VBOX"), func (sm *SmartManager) isVirtualDeviceNvme(data *smart.SmartInfoForNvme) bool {
strings.Contains(productUpper, "VMWARE"), modelUpper := strings.ToUpper(data.ModelName)
strings.Contains(vendorUpper, "MSFT"), // Microsoft Hyper-V
strings.Contains(modelUpper, "VIRTUAL"), return sm.isVirtualDeviceFromStrings(modelUpper)
strings.Contains(modelUpper, "QEMU"), }
strings.Contains(modelUpper, "VBOX"),
strings.Contains(modelUpper, "VMWARE"): // isVirtualDeviceScsi checks if a SCSI device is a virtual disk that should be filtered out
return true func (sm *SmartManager) isVirtualDeviceScsi(data *smart.SmartInfoForScsi) bool {
default: vendorUpper := strings.ToUpper(data.ScsiVendor)
return false productUpper := strings.ToUpper(data.ScsiProduct)
modelUpper := strings.ToUpper(data.ScsiModelName)
return sm.isVirtualDeviceFromStrings(vendorUpper, productUpper, modelUpper)
}
// isVirtualDeviceFromStrings checks if any of the provided strings indicate a virtual device
func (sm *SmartManager) isVirtualDeviceFromStrings(fields ...string) bool {
for _, field := range fields {
fieldUpper := strings.ToUpper(field)
switch {
case strings.Contains(fieldUpper, "IET"), // iSCSI Enterprise Target
strings.Contains(fieldUpper, "VIRTUAL"),
strings.Contains(fieldUpper, "QEMU"),
strings.Contains(fieldUpper, "VBOX"),
strings.Contains(fieldUpper, "VMWARE"),
strings.Contains(fieldUpper, "MSFT"): // Microsoft Hyper-V
return true
}
} }
return false
} }
// parseSmartForSata parses the output of smartctl --all -j for SATA/ATA devices and updates the SmartDataMap // parseSmartForSata parses the output of smartctl --all -j for SATA/ATA devices and updates the SmartDataMap
@@ -540,13 +688,17 @@ func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) {
// update SmartAttributes // update SmartAttributes
smartData.Attributes = make([]*smart.SmartAttribute, 0, len(data.AtaSmartAttributes.Table)) smartData.Attributes = make([]*smart.SmartAttribute, 0, len(data.AtaSmartAttributes.Table))
for _, attr := range data.AtaSmartAttributes.Table { for _, attr := range data.AtaSmartAttributes.Table {
rawValue := uint64(attr.Raw.Value)
if parsed, ok := smart.ParseSmartRawValueString(attr.Raw.String); ok {
rawValue = parsed
}
smartAttr := &smart.SmartAttribute{ smartAttr := &smart.SmartAttribute{
ID: attr.ID, ID: attr.ID,
Name: attr.Name, Name: attr.Name,
Value: attr.Value, Value: attr.Value,
Worst: attr.Worst, Worst: attr.Worst,
Threshold: attr.Thresh, Threshold: attr.Thresh,
RawValue: uint64(attr.Raw.Value), RawValue: rawValue,
RawString: attr.Raw.String, RawString: attr.Raw.String,
WhenFailed: attr.WhenFailed, WhenFailed: attr.WhenFailed,
} }
@@ -579,6 +731,12 @@ func (sm *SmartManager) parseSmartForScsi(output []byte) (bool, int) {
return false, data.Smartctl.ExitStatus return false, data.Smartctl.ExitStatus
} }
// Skip virtual devices (e.g., Kubernetes PVCs, QEMU, VirtualBox, etc.)
if sm.isVirtualDeviceScsi(&data) {
slog.Debug("skipping smart", "device", data.Device.Name, "model", data.ScsiModelName)
return false, data.Smartctl.ExitStatus
}
sm.Lock() sm.Lock()
defer sm.Unlock() defer sm.Unlock()
@@ -661,6 +819,12 @@ func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
return false, data.Smartctl.ExitStatus return false, data.Smartctl.ExitStatus
} }
// Skip virtual devices (e.g., Kubernetes PVCs, QEMU, VirtualBox, etc.)
if sm.isVirtualDeviceNvme(data) {
slog.Debug("skipping smart", "device", data.Device.Name, "model", data.ModelName)
return false, data.Smartctl.ExitStatus
}
sm.Lock() sm.Lock()
defer sm.Unlock() defer sm.Unlock()

View File

@@ -89,6 +89,49 @@ func TestParseSmartForSata(t *testing.T) {
} }
} }
func TestParseSmartForSataParentheticalRawValue(t *testing.T) {
jsonPayload := []byte(`{
"smartctl": {"exit_status": 0},
"device": {"name": "/dev/sdz", "type": "sat"},
"model_name": "Example",
"serial_number": "PARENTHESES123",
"firmware_version": "1.0",
"user_capacity": {"bytes": 1024},
"smart_status": {"passed": true},
"temperature": {"current": 25},
"ata_smart_attributes": {
"table": [
{
"id": 9,
"name": "Power_On_Hours",
"value": 93,
"worst": 55,
"thresh": 0,
"when_failed": "",
"raw": {
"value": 57891864217128,
"string": "39925 (212 206 0)"
}
}
]
}
}`)
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
require.True(t, hasData)
assert.Equal(t, 0, exitStatus)
data, ok := sm.SmartDataMap["PARENTHESES123"]
require.True(t, ok)
require.Len(t, data.Attributes, 1)
attr := data.Attributes[0]
assert.Equal(t, uint64(39925), attr.RawValue)
assert.Equal(t, "39925 (212 206 0)", attr.RawString)
}
func TestParseSmartForNvme(t *testing.T) { func TestParseSmartForNvme(t *testing.T) {
fixturePath := filepath.Join("test-data", "smart", "nvme0.json") fixturePath := filepath.Join("test-data", "smart", "nvme0.json")
data, err := os.ReadFile(fixturePath) data, err := os.ReadFile(fixturePath)
@@ -198,7 +241,7 @@ func TestSmartctlArgsWithoutType(t *testing.T) {
sm := &SmartManager{} sm := &SmartManager{}
args := sm.smartctlArgs(device, true) args := sm.smartctlArgs(device, true)
assert.Equal(t, []string{"-aj", "-n", "standby", "/dev/sda"}, args) assert.Equal(t, []string{"-a", "--json=c", "-n", "standby", "/dev/sda"}, args)
} }
func TestSmartctlArgs(t *testing.T) { func TestSmartctlArgs(t *testing.T) {
@@ -206,17 +249,17 @@ func TestSmartctlArgs(t *testing.T) {
sataDevice := &DeviceInfo{Name: "/dev/sda", Type: "sat"} sataDevice := &DeviceInfo{Name: "/dev/sda", Type: "sat"}
assert.Equal(t, assert.Equal(t,
[]string{"-d", "sat", "-aj", "-n", "standby", "/dev/sda"}, []string{"-d", "sat", "-a", "--json=c", "-n", "standby", "/dev/sda"},
sm.smartctlArgs(sataDevice, true), sm.smartctlArgs(sataDevice, true),
) )
assert.Equal(t, assert.Equal(t,
[]string{"-d", "sat", "-aj", "/dev/sda"}, []string{"-d", "sat", "-a", "--json=c", "/dev/sda"},
sm.smartctlArgs(sataDevice, false), sm.smartctlArgs(sataDevice, false),
) )
assert.Equal(t, assert.Equal(t,
[]string{"-aj", "-n", "standby"}, []string{"-a", "--json=c", "-n", "standby"},
sm.smartctlArgs(nil, true), sm.smartctlArgs(nil, true),
) )
} }
@@ -344,7 +387,7 @@ func TestMergeDeviceListsPrefersConfigured(t *testing.T) {
{Name: "/dev/sdb", Type: "sat"}, {Name: "/dev/sdb", Type: "sat"},
} }
merged := mergeDeviceLists(scanned, configured) merged := mergeDeviceLists(nil, scanned, configured)
require.Len(t, merged, 3) require.Len(t, merged, 3)
byName := make(map[string]*DeviceInfo, len(merged)) byName := make(map[string]*DeviceInfo, len(merged))
@@ -363,6 +406,79 @@ func TestMergeDeviceListsPrefersConfigured(t *testing.T) {
assert.Equal(t, "sat", byName["/dev/sdb"].Type) assert.Equal(t, "sat", byName["/dev/sdb"].Type)
} }
func TestMergeDeviceListsPreservesVerification(t *testing.T) {
existing := []*DeviceInfo{
{Name: "/dev/sda", Type: "sat+megaraid", parserType: "sat", typeVerified: true},
}
scanned := []*DeviceInfo{
{Name: "/dev/sda", Type: "nvme"},
}
merged := mergeDeviceLists(existing, scanned, nil)
require.Len(t, merged, 1)
device := merged[0]
assert.True(t, device.typeVerified)
assert.Equal(t, "sat", device.parserType)
assert.Equal(t, "sat+megaraid", device.Type)
}
func TestMergeDeviceListsUpdatesTypeWhenUnverified(t *testing.T) {
existing := []*DeviceInfo{
{Name: "/dev/sda", Type: "sat", parserType: "sat", typeVerified: false},
}
scanned := []*DeviceInfo{
{Name: "/dev/sda", Type: "nvme"},
}
merged := mergeDeviceLists(existing, scanned, nil)
require.Len(t, merged, 1)
device := merged[0]
assert.False(t, device.typeVerified)
assert.Equal(t, "nvme", device.Type)
assert.Equal(t, "", device.parserType)
}
func TestParseSmartOutputMarksVerified(t *testing.T) {
fixturePath := filepath.Join("test-data", "smart", "nvme0.json")
data, err := os.ReadFile(fixturePath)
require.NoError(t, err)
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
device := &DeviceInfo{Name: "/dev/nvme0"}
require.True(t, sm.parseSmartOutput(device, data))
assert.Equal(t, "nvme", device.Type)
assert.Equal(t, "nvme", device.parserType)
assert.True(t, device.typeVerified)
}
func TestParseSmartOutputKeepsCustomType(t *testing.T) {
fixturePath := filepath.Join("test-data", "smart", "sda.json")
data, err := os.ReadFile(fixturePath)
require.NoError(t, err)
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
device := &DeviceInfo{Name: "/dev/sda", Type: "sat+megaraid"}
require.True(t, sm.parseSmartOutput(device, data))
assert.Equal(t, "sat+megaraid", device.Type)
assert.Equal(t, "sat", device.parserType)
assert.True(t, device.typeVerified)
}
func TestParseSmartOutputResetsVerificationOnFailure(t *testing.T) {
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
device := &DeviceInfo{Name: "/dev/sda", Type: "sat", parserType: "sat", typeVerified: true}
assert.False(t, sm.parseSmartOutput(device, []byte("not json")))
assert.False(t, device.typeVerified)
assert.Equal(t, "sat", device.parserType)
}
func assertAttrValue(t *testing.T, attributes []*smart.SmartAttribute, name string, expected uint64) { func assertAttrValue(t *testing.T, attributes []*smart.SmartAttribute, name string, expected uint64) {
t.Helper() t.Helper()
attr := findAttr(attributes, name) attr := findAttr(attributes, name)
@@ -382,3 +498,93 @@ func findAttr(attributes []*smart.SmartAttribute, name string) *smart.SmartAttri
} }
return nil return nil
} }
func TestIsVirtualDevice(t *testing.T) {
sm := &SmartManager{}
tests := []struct {
name string
vendor string
product string
model string
expected bool
}{
{"regular drive", "SEAGATE", "ST1000DM003", "ST1000DM003-1CH162", false},
{"qemu virtual", "QEMU", "QEMU HARDDISK", "QEMU HARDDISK", true},
{"virtualbox virtual", "VBOX", "HARDDISK", "VBOX HARDDISK", true},
{"vmware virtual", "VMWARE", "Virtual disk", "VMWARE Virtual disk", true},
{"virtual in model", "ATA", "VIRTUAL", "VIRTUAL DISK", true},
{"iet virtual", "IET", "VIRTUAL-DISK", "VIRTUAL-DISK", true},
{"hyper-v virtual", "MSFT", "VIRTUAL HD", "VIRTUAL HD", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data := &smart.SmartInfoForSata{
ScsiVendor: tt.vendor,
ScsiProduct: tt.product,
ModelName: tt.model,
}
result := sm.isVirtualDevice(data)
assert.Equal(t, tt.expected, result)
})
}
}
func TestIsVirtualDeviceNvme(t *testing.T) {
sm := &SmartManager{}
tests := []struct {
name string
model string
expected bool
}{
{"regular nvme", "Samsung SSD 970 EVO Plus 1TB", false},
{"qemu virtual", "QEMU NVMe Ctrl", true},
{"virtualbox virtual", "VBOX NVMe", true},
{"vmware virtual", "VMWARE NVMe", true},
{"virtual in model", "Virtual NVMe Device", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data := &smart.SmartInfoForNvme{
ModelName: tt.model,
}
result := sm.isVirtualDeviceNvme(data)
assert.Equal(t, tt.expected, result)
})
}
}
func TestIsVirtualDeviceScsi(t *testing.T) {
sm := &SmartManager{}
tests := []struct {
name string
vendor string
product string
model string
expected bool
}{
{"regular scsi", "SEAGATE", "ST1000DM003", "ST1000DM003-1CH162", false},
{"qemu virtual", "QEMU", "QEMU HARDDISK", "QEMU HARDDISK", true},
{"virtualbox virtual", "VBOX", "HARDDISK", "VBOX HARDDISK", true},
{"vmware virtual", "VMWARE", "Virtual disk", "VMWARE Virtual disk", true},
{"virtual in model", "ATA", "VIRTUAL", "VIRTUAL DISK", true},
{"iet virtual", "IET", "VIRTUAL-DISK", "VIRTUAL-DISK", true},
{"hyper-v virtual", "MSFT", "VIRTUAL HD", "VIRTUAL HD", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data := &smart.SmartInfoForScsi{
ScsiVendor: tt.vendor,
ScsiProduct: tt.product,
ScsiModelName: tt.model,
}
result := sm.isVirtualDeviceScsi(data)
assert.Equal(t, tt.expected, result)
})
}
}

View File

@@ -83,12 +83,21 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
systemStats.Battery[1] = batteryState systemStats.Battery[1] = batteryState
} }
// cpu percent // cpu metrics
cpuPercent, err := getCpuPercent(cacheTimeMs) cpuMetrics, err := getCpuMetrics(cacheTimeMs)
if err == nil { if err == nil {
systemStats.Cpu = twoDecimals(cpuPercent) systemStats.Cpu = twoDecimals(cpuMetrics.Total)
systemStats.CpuUser = twoDecimals(cpuMetrics.User)
systemStats.CpuSystem = twoDecimals(cpuMetrics.System)
systemStats.CpuIowait = twoDecimals(cpuMetrics.Iowait)
systemStats.CpuSteal = twoDecimals(cpuMetrics.Steal)
} else { } else {
slog.Error("Error getting cpu percent", "err", err) slog.Error("Error getting cpu metrics", "err", err)
}
// per-core cpu metrics
if perCoreCpuMetrics, err := getPerCoreCpuMetrics(cacheTimeMs); err == nil && len(perCoreCpuMetrics) > 0 {
systemStats.CpuCores = perCoreCpuMetrics
} }
// load average // load average

View File

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

View File

@@ -1,6 +1,7 @@
package smart package smart
import ( import (
"encoding/json"
"strconv" "strconv"
"strings" "strings"
) )
@@ -160,6 +161,33 @@ type RawValue struct {
String string `json:"string"` String string `json:"string"`
} }
func (r *RawValue) UnmarshalJSON(data []byte) error {
var tmp struct {
Value json.RawMessage `json:"value"`
String string `json:"string"`
}
if err := json.Unmarshal(data, &tmp); err != nil {
return err
}
if len(tmp.Value) > 0 {
if err := r.Value.UnmarshalJSON(tmp.Value); err != nil {
return err
}
} else {
r.Value = 0
}
r.String = tmp.String
if parsed, ok := ParseSmartRawValueString(tmp.String); ok {
r.Value = SmartRawValue(parsed)
}
return nil
}
type SmartRawValue uint64 type SmartRawValue uint64
// handles when drives report strings like "0h+0m+0.000s" or "7344 (253d 8h)" for power on hours // handles when drives report strings like "0h+0m+0.000s" or "7344 (253d 8h)" for power on hours
@@ -170,61 +198,73 @@ func (v *SmartRawValue) UnmarshalJSON(data []byte) error {
return nil return nil
} }
if trimmed[0] != '"' { if trimmed[0] == '"' {
parsed, err := strconv.ParseUint(trimmed, 0, 64) valueStr, err := strconv.Unquote(trimmed)
if err != nil { if err != nil {
return err return err
} }
*v = SmartRawValue(parsed) parsed, ok := ParseSmartRawValueString(valueStr)
return nil if ok {
} *v = SmartRawValue(parsed)
return nil
valueStr, err := strconv.Unquote(trimmed) }
if err != nil {
return err
}
if valueStr == "" {
*v = 0 *v = 0
return nil return nil
} }
if parsed, err := strconv.ParseUint(valueStr, 0, 64); err == nil { if parsed, err := strconv.ParseUint(trimmed, 0, 64); err == nil {
*v = SmartRawValue(parsed) *v = SmartRawValue(parsed)
return nil return nil
} }
if idx := strings.IndexRune(valueStr, 'h'); idx >= 0 { if parsed, ok := ParseSmartRawValueString(trimmed); ok {
hoursPart := strings.TrimSpace(valueStr[:idx]) *v = SmartRawValue(parsed)
if hoursPart == "" { return nil
*v = 0
return nil
}
if parsed, err := strconv.ParseFloat(hoursPart, 64); err == nil {
*v = SmartRawValue(uint64(parsed))
return nil
}
}
if digits := leadingDigitPrefix(valueStr); digits != "" {
if parsed, err := strconv.ParseUint(digits, 0, 64); err == nil {
*v = SmartRawValue(parsed)
return nil
}
} }
*v = 0 *v = 0
return nil return nil
} }
func leadingDigitPrefix(value string) string { // ParseSmartRawValueString attempts to extract a numeric value from the raw value
var builder strings.Builder // strings emitted by smartctl, which sometimes include human-friendly annotations
for _, r := range value { // like "7344 (253d 8h)" or "0h+0m+0.000s". It returns the parsed value and a
if r < '0' || r > '9' { // boolean indicating success.
break func ParseSmartRawValueString(value string) (uint64, bool) {
} value = strings.TrimSpace(value)
builder.WriteRune(r) if value == "" {
return 0, false
} }
return builder.String()
if parsed, err := strconv.ParseUint(value, 0, 64); err == nil {
return parsed, true
}
if idx := strings.IndexRune(value, 'h'); idx > 0 {
hoursPart := strings.TrimSpace(value[:idx])
if hoursPart != "" {
if parsed, err := strconv.ParseFloat(hoursPart, 64); err == nil {
return uint64(parsed), true
}
}
}
for i := 0; i < len(value); i++ {
if value[i] < '0' || value[i] > '9' {
continue
}
end := i + 1
for end < len(value) && value[end] >= '0' && value[end] <= '9' {
end++
}
digits := value[i:end]
if parsed, err := strconv.ParseUint(digits, 10, 64); err == nil {
return parsed, true
}
i = end
}
return 0, false
} }
// type PowerOnTimeInfo struct { // type PowerOnTimeInfo struct {

View File

@@ -3,28 +3,60 @@ package smart
import ( import (
"encoding/json" "encoding/json"
"testing" "testing"
"github.com/stretchr/testify/assert"
) )
func TestSmartRawValueUnmarshalDuration(t *testing.T) { func TestSmartRawValueUnmarshalDuration(t *testing.T) {
input := []byte(`{"value":"62312h+33m+50.907s","string":"62312h+33m+50.907s"}`) input := []byte(`{"value":"62312h+33m+50.907s","string":"62312h+33m+50.907s"}`)
var raw RawValue var raw RawValue
if err := json.Unmarshal(input, &raw); err != nil { err := json.Unmarshal(input, &raw)
t.Fatalf("unexpected error unmarshalling raw value: %v", err) assert.NoError(t, err)
}
if uint64(raw.Value) != 62312 { assert.EqualValues(t, 62312, raw.Value)
t.Fatalf("expected hours to be 62312, got %d", raw.Value)
}
} }
func TestSmartRawValueUnmarshalNumericString(t *testing.T) { func TestSmartRawValueUnmarshalNumericString(t *testing.T) {
input := []byte(`{"value":"7344","string":"7344"}`) input := []byte(`{"value":"7344","string":"7344"}`)
var raw RawValue var raw RawValue
if err := json.Unmarshal(input, &raw); err != nil { err := json.Unmarshal(input, &raw)
t.Fatalf("unexpected error unmarshalling numeric string: %v", err) assert.NoError(t, err)
}
if uint64(raw.Value) != 7344 { assert.EqualValues(t, 7344, raw.Value)
t.Fatalf("expected hours to be 7344, got %d", raw.Value) }
}
func TestSmartRawValueUnmarshalParenthetical(t *testing.T) {
input := []byte(`{"value":"39925 (212 206 0)","string":"39925 (212 206 0)"}`)
var raw RawValue
err := json.Unmarshal(input, &raw)
assert.NoError(t, err)
assert.EqualValues(t, 39925, raw.Value)
}
func TestSmartRawValueUnmarshalDurationWithFractions(t *testing.T) {
input := []byte(`{"value":"2748h+31m+49.560s","string":"2748h+31m+49.560s"}`)
var raw RawValue
err := json.Unmarshal(input, &raw)
assert.NoError(t, err)
assert.EqualValues(t, 2748, raw.Value)
}
func TestSmartRawValueUnmarshalParentheticalRawValue(t *testing.T) {
input := []byte(`{"value":57891864217128,"string":"39925 (212 206 0)"}`)
var raw RawValue
err := json.Unmarshal(input, &raw)
assert.NoError(t, err)
assert.EqualValues(t, 39925, raw.Value)
}
func TestSmartRawValueUnmarshalDurationRawValue(t *testing.T) {
input := []byte(`{"value":57891864217128,"string":"2748h+31m+49.560s"}`)
var raw RawValue
err := json.Unmarshal(input, &raw)
assert.NoError(t, err)
assert.EqualValues(t, 2748, raw.Value)
} }

View File

@@ -11,7 +11,12 @@ import (
type Stats struct { type Stats struct {
Cpu float64 `json:"cpu" cbor:"0,keyasint"` Cpu float64 `json:"cpu" cbor:"0,keyasint"`
MaxCpu float64 `json:"cpum,omitempty" cbor:"1,keyasint,omitempty"` MaxCpu float64 `json:"cpum,omitempty" cbor:"1,keyasint,omitempty"`
Mem float64 `json:"m" cbor:"2,keyasint"` CpuUser float64 `json:"cpuu,omitempty" cbor:"33,keyasint,omitempty"`
CpuSystem float64 `json:"cpus,omitempty" cbor:"34,keyasint,omitempty"`
CpuIowait float64 `json:"cpui,omitempty" cbor:"35,keyasint,omitempty"`
CpuSteal float64 `json:"cpust,omitempty" cbor:"36,keyasint,omitempty"`
CpuCores map[string][4]float64 `json:"cpuc,omitempty" cbor:"37,keyasint,omitempty"` // [user, system, iowait, steal] per core
Mem float64 `json:"m" cbor:"2,keyasint"`
MemUsed float64 `json:"mu" cbor:"3,keyasint"` MemUsed float64 `json:"mu" cbor:"3,keyasint"`
MemPct float64 `json:"mp" cbor:"4,keyasint"` MemPct float64 `json:"mp" cbor:"4,keyasint"`
MemBuffCache float64 `json:"mb" cbor:"5,keyasint"` MemBuffCache float64 `json:"mb" cbor:"5,keyasint"`

View File

@@ -269,6 +269,10 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
fs.DiskReadPs += value.DiskReadPs fs.DiskReadPs += value.DiskReadPs
fs.MaxDiskReadPS = max(fs.MaxDiskReadPS, value.MaxDiskReadPS, value.DiskReadPs) fs.MaxDiskReadPS = max(fs.MaxDiskReadPS, value.MaxDiskReadPS, value.DiskReadPs)
fs.MaxDiskWritePS = max(fs.MaxDiskWritePS, value.MaxDiskWritePS, value.DiskWritePs) fs.MaxDiskWritePS = max(fs.MaxDiskWritePS, value.MaxDiskWritePS, value.DiskWritePs)
fs.DiskReadBytes += value.DiskReadBytes
fs.DiskWriteBytes += value.DiskWriteBytes
fs.MaxDiskReadBytes = max(fs.MaxDiskReadBytes, value.MaxDiskReadBytes, value.DiskReadBytes)
fs.MaxDiskWriteBytes = max(fs.MaxDiskWriteBytes, value.MaxDiskWriteBytes, value.DiskWriteBytes)
} }
} }
@@ -356,6 +360,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
fs.DiskUsed = twoDecimals(fs.DiskUsed / count) fs.DiskUsed = twoDecimals(fs.DiskUsed / count)
fs.DiskWritePs = twoDecimals(fs.DiskWritePs / count) fs.DiskWritePs = twoDecimals(fs.DiskWritePs / count)
fs.DiskReadPs = twoDecimals(fs.DiskReadPs / count) fs.DiskReadPs = twoDecimals(fs.DiskReadPs / count)
fs.DiskReadBytes = fs.DiskReadBytes / uint64(count)
fs.DiskWriteBytes = fs.DiskWriteBytes / uint64(count)
} }
} }

View File

@@ -11,6 +11,7 @@ export default defineConfig({
"es", "es",
"fa", "fa",
"fr", "fr",
"he",
"hr", "hr",
"hu", "hu",
"it", "it",

View File

@@ -1,12 +1,12 @@
{ {
"name": "beszel", "name": "beszel",
"version": "0.15.1", "version": "0.15.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "beszel", "name": "beszel",
"version": "0.15.1", "version": "0.15.2",
"dependencies": { "dependencies": {
"@henrygd/queue": "^1.0.7", "@henrygd/queue": "^1.0.7",
"@henrygd/semaphore": "^0.0.2", "@henrygd/semaphore": "^0.0.2",

View File

@@ -1,7 +1,7 @@
{ {
"name": "beszel", "name": "beszel",
"private": true, "private": true,
"version": "0.15.1", "version": "0.15.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite --host",

View File

@@ -118,6 +118,28 @@ export function useNetworkInterfaces(interfaces: SystemStats["ni"]) {
dataKey: ({ stats }: SystemStatsRecord) => stats?.ni?.[key]?.[index], dataKey: ({ stats }: SystemStatsRecord) => stats?.ni?.[key]?.[index],
color: `hsl(${220 + (((sortedKeys.indexOf(key) * 360) / sortedKeys.length) % 360)}, 70%, 50%)`, color: `hsl(${220 + (((sortedKeys.indexOf(key) * 360) / sortedKeys.length) % 360)}, 70%, 50%)`,
opacity: 0.3,
}))
},
}
}
// Assures consistent colors for CPU cores
export function useCpuCores(cores: SystemStats["cpuc"]) {
const keys = Object.keys(cores ?? {})
// Sort cores by name (cpu0, cpu1, cpu2, etc.)
const sortedKeys = keys.sort((a, b) => {
const numA = Number.parseInt(a.replace("cpu", ""))
const numB = Number.parseInt(b.replace("cpu", ""))
return numA - numB
})
return {
length: sortedKeys.length,
data: (index = 0) => {
return sortedKeys.map((key) => ({
label: key,
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpuc?.[key]?.[index],
color: `hsl(${(((sortedKeys.indexOf(key) * 360) / sortedKeys.length) % 360)}, 70%, 50%)`,
opacity: 0.3, opacity: 0.3,
})) }))
}, },

View File

@@ -73,6 +73,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
import { Separator } from "../ui/separator" import { Separator } from "../ui/separator"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
import NetworkSheet from "./system/network-sheet" import NetworkSheet from "./system/network-sheet"
import CpuCoresSheet from "./system/cpu-cores-sheet"
import LineChartDefault from "../charts/line-chart" import LineChartDefault from "../charts/line-chart"
@@ -585,18 +586,49 @@ export default memo(function SystemDetail({ id }: { id: string }) {
grid={grid} grid={grid}
title={t`CPU Usage`} title={t`CPU Usage`}
description={t`Average system-wide CPU utilization`} description={t`Average system-wide CPU utilization`}
cornerEl={maxValSelect} cornerEl={
<>
{maxValSelect}
<CpuCoresSheet chartData={chartData} dataEmpty={dataEmpty} grid={grid} maxValues={maxValues} />
</>
}
legend={true}
> >
<AreaChartDefault <AreaChartDefault
chartData={chartData} chartData={chartData}
maxToggled={maxValues} maxToggled={maxValues}
legend={true}
dataPoints={[ dataPoints={[
{ {
label: t`CPU Usage`, label: t`Total`,
dataKey: ({ stats }) => (showMax ? stats?.cpum : stats?.cpu), dataKey: ({ stats }) => (showMax ? stats?.cpum : stats?.cpu),
color: 1, color: 1,
opacity: 0.4, opacity: 0.4,
}, },
{
label: t`User`,
dataKey: ({ stats }) => stats?.cpuu,
color: 2,
opacity: 0.3,
},
{
label: t`System`,
dataKey: ({ stats }) => stats?.cpus,
color: 3,
opacity: 0.3,
},
{
label: t`IOWait`,
dataKey: ({ stats }) => stats?.cpui,
color: 4,
opacity: 0.3,
},
{
label: t`Steal`,
dataKey: ({ stats }) => stats?.cpust,
color: 5,
opacity: 0.3,
},
]} ]}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`} tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`} contentFormatter={({ value }) => `${decimalString(value)}%`}
@@ -958,9 +990,9 @@ export default memo(function SystemDetail({ id }: { id: string }) {
label: t`Write`, label: t`Write`,
dataKey: ({ stats }) => { dataKey: ({ stats }) => {
if (showMax) { if (showMax) {
return stats?.efs?.[extraFsName]?.wb ?? (stats?.efs?.[extraFsName]?.wm ?? 0) * 1024 * 1024 return stats?.efs?.[extraFsName]?.wbm || (stats?.efs?.[extraFsName]?.wm ?? 0) * 1024 * 1024
} }
return stats?.efs?.[extraFsName]?.wb ?? (stats?.efs?.[extraFsName]?.w ?? 0) * 1024 * 1024 return stats?.efs?.[extraFsName]?.wb || (stats?.efs?.[extraFsName]?.w ?? 0) * 1024 * 1024
}, },
color: 3, color: 3,
opacity: 0.3, opacity: 0.3,

View File

@@ -0,0 +1,119 @@
import { t } from "@lingui/core/macro"
import { MoreHorizontalIcon } from "lucide-react"
import { memo, useRef, useState } from "react"
import AreaChartDefault from "@/components/charts/area-chart"
import ChartTimeSelect from "@/components/charts/chart-time-select"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
import { DialogTitle } from "@/components/ui/dialog"
import { decimalString, toFixedFloat } from "@/lib/utils"
import type { ChartData, SystemStatsRecord } from "@/types"
import { ChartCard } from "../system"
export default memo(function CpuCoresSheet({
chartData,
dataEmpty,
grid,
maxValues,
}: {
chartData: ChartData
dataEmpty: boolean
grid: boolean
maxValues: boolean
}) {
const [cpuCoresOpen, setCpuCoresOpen] = useState(false)
const hasOpened = useRef(false)
if (cpuCoresOpen && !hasOpened.current) {
hasOpened.current = true
}
// Get list of CPU cores from the latest stats
const cpuCoresData = chartData.systemStats.at(-1)?.stats?.cpuc ?? {}
const coreNames = Object.keys(cpuCoresData).sort((a, b) => {
const numA = Number.parseInt(a.replace("cpu", ""))
const numB = Number.parseInt(b.replace("cpu", ""))
return numA - numB
})
if (coreNames.length === 0) {
return null
}
return (
<Sheet open={cpuCoresOpen} onOpenChange={setCpuCoresOpen}>
<DialogTitle className="sr-only">{t`Per-core CPU usage`}</DialogTitle>
<SheetTrigger asChild>
<Button
title={t`View per-core CPU`}
variant="outline"
size="icon"
className="shrink-0 max-sm:absolute max-sm:top-3 max-sm:end-3"
>
<MoreHorizontalIcon />
</Button>
</SheetTrigger>
{hasOpened.current && (
<SheetContent aria-describedby={undefined} className="overflow-auto w-200 !max-w-full p-4 sm:p-6">
<ChartTimeSelect className="w-[calc(100%-2em)]" agentVersion={chartData.agentVersion} />
{coreNames.map((coreName) => (
<ChartCard
key={coreName}
empty={dataEmpty}
grid={grid}
title={coreName.toUpperCase()}
description={t`CPU usage breakdown for ${coreName}`}
legend={true}
className="min-h-auto"
>
<AreaChartDefault
chartData={chartData}
maxToggled={maxValues}
legend={true}
dataPoints={[
{
label: t`Total`,
dataKey: ({ stats }: SystemStatsRecord) => {
const core = stats?.cpuc?.[coreName]
if (!core) return undefined
// Sum all metrics: user + system + iowait + steal
return core[0] + core[1] + core[2] + core[3]
},
color: 1,
opacity: 0.4,
},
{
label: t`User`,
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpuc?.[coreName]?.[0],
color: 2,
opacity: 0.3,
},
{
label: t`System`,
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpuc?.[coreName]?.[1],
color: 3,
opacity: 0.3,
},
{
label: t`IOWait`,
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpuc?.[coreName]?.[2],
color: 4,
opacity: 0.3,
},
{
label: t`Steal`,
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpuc?.[coreName]?.[3],
color: 5,
opacity: 0.3,
},
]}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
/>
</ChartCard>
))}
</SheetContent>
)}
</Sheet>
)
})

File diff suppressed because it is too large Load Diff

View File

@@ -84,6 +84,16 @@ export interface SystemStats {
cpu: number cpu: number
/** peak cpu */ /** peak cpu */
cpum?: number cpum?: number
/** cpu user percent */
cpuu?: number
/** cpu system percent */
cpus?: number
/** cpu iowait percent */
cpui?: number
/** cpu steal percent */
cpust?: number
/** per-core cpu metrics [user, system, iowait, steal] */
cpuc?: Record<string, [number, number, number, number]>
// TODO: remove these in future release in favor of la // TODO: remove these in future release in favor of la
/** load average 1 minute */ /** load average 1 minute */
l1?: number l1?: number

View File

@@ -1,3 +1,11 @@
## 0.15.3
- Improve parsing of edge case S.M.A.R.T. power on times. (#1347)
## 0.15.2
- Improve S.M.A.R.T. device detection logic (fix regression in 0.15.1) (#1345)
## 0.15.1 ## 0.15.1
- Add `SMART_DEVICES` environment variable to specify devices and types. (#373, #1335) - Add `SMART_DEVICES` environment variable to specify devices and types. (#373, #1335)