mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-23 22:16:18 +01:00
Compare commits
8 Commits
v0.15.2
...
sven-cpu-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe30f99695 | ||
|
|
85ac2e5e9a | ||
|
|
af6bd4e505 | ||
|
|
e54c4b3499 | ||
|
|
078c88f825 | ||
|
|
85169b6c5e | ||
|
|
d0ff8ee2c0 | ||
|
|
e898768997 |
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
99
agent/cpu.go
99
agent/cpu.go
@@ -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.
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -426,7 +426,7 @@ func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
args = append(args, "-aj")
|
args = append(args, "-a", "--json=c")
|
||||||
|
|
||||||
if includeStandby {
|
if includeStandby {
|
||||||
args = append(args, "-n", "standby")
|
args = append(args, "-n", "standby")
|
||||||
@@ -688,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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
|||||||
"es",
|
"es",
|
||||||
"fa",
|
"fa",
|
||||||
"fr",
|
"fr",
|
||||||
|
"he",
|
||||||
"hr",
|
"hr",
|
||||||
"hu",
|
"hu",
|
||||||
"it",
|
"it",
|
||||||
|
|||||||
@@ -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,
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
119
internal/site/src/components/routes/system/cpu-cores-sheet.tsx
Normal file
119
internal/site/src/components/routes/system/cpu-cores-sheet.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
})
|
||||||
1348
internal/site/src/locales/he/he.po
Normal file
1348
internal/site/src/locales/he/he.po
Normal file
File diff suppressed because it is too large
Load Diff
10
internal/site/src/types.d.ts
vendored
10
internal/site/src/types.d.ts
vendored
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user