Compare commits

...

8 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
18 changed files with 1987 additions and 67 deletions

View File

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

View File

@@ -8,6 +8,7 @@ import (
)
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
// for the default 60-second cache interval.
@@ -15,6 +16,18 @@ func init() {
if times, err := cpu.Times(false); err == nil {
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.
@@ -34,6 +47,92 @@ func getCpuPercent(cacheTimeMs uint16) (float64, error) {
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.
// It computes the ratio of busy time to total time elapsed between t1 and t2,
// returning a percentage clamped between 0 and 100.

View File

@@ -13,6 +13,7 @@ import (
"net/http"
"net/url"
"os"
"path"
"strings"
"sync"
"time"
@@ -53,6 +54,7 @@ type dockerManager struct {
buf *bytes.Buffer // Buffer to store and read response bodies
decoder *json.Decoder // Reusable JSON decoder that reads from buf
apiStats *container.ApiStats // Reusable API stats object
excludeContainers []string // Patterns to exclude containers by name
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
// 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
func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats, error) {
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 {
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{}{}
// check if container is less than 1 minute old (possible 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/",
}
// 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{
client: &http.Client{
Timeout: timeout,
@@ -512,6 +547,7 @@ func newDockerManager(a *Agent) *dockerManager {
sem: make(chan struct{}, 5),
apiContainerList: []*container.ApiInfo{},
apiStats: &container.ApiStats{},
excludeContainers: excludeContainers,
// Initialize cache-time-aware tracking structures
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

@@ -426,7 +426,7 @@ func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool
}
}
args = append(args, "-aj")
args = append(args, "-a", "--json=c")
if includeStandby {
args = append(args, "-n", "standby")
@@ -688,13 +688,17 @@ func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) {
// update SmartAttributes
smartData.Attributes = make([]*smart.SmartAttribute, 0, len(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{
ID: attr.ID,
Name: attr.Name,
Value: attr.Value,
Worst: attr.Worst,
Threshold: attr.Thresh,
RawValue: uint64(attr.Raw.Value),
RawValue: rawValue,
RawString: attr.Raw.String,
WhenFailed: attr.WhenFailed,
}

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) {
fixturePath := filepath.Join("test-data", "smart", "nvme0.json")
data, err := os.ReadFile(fixturePath)
@@ -198,7 +241,7 @@ func TestSmartctlArgsWithoutType(t *testing.T) {
sm := &SmartManager{}
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) {
@@ -206,17 +249,17 @@ func TestSmartctlArgs(t *testing.T) {
sataDevice := &DeviceInfo{Name: "/dev/sda", Type: "sat"}
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),
)
assert.Equal(t,
[]string{"-d", "sat", "-aj", "/dev/sda"},
[]string{"-d", "sat", "-a", "--json=c", "/dev/sda"},
sm.smartctlArgs(sataDevice, false),
)
assert.Equal(t,
[]string{"-aj", "-n", "standby"},
[]string{"-a", "--json=c", "-n", "standby"},
sm.smartctlArgs(nil, true),
)
}

View File

@@ -83,12 +83,21 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
systemStats.Battery[1] = batteryState
}
// cpu percent
cpuPercent, err := getCpuPercent(cacheTimeMs)
// cpu metrics
cpuMetrics, err := getCpuMetrics(cacheTimeMs)
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 {
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

View File

@@ -1,6 +1,7 @@
package smart
import (
"encoding/json"
"strconv"
"strings"
)
@@ -160,6 +161,33 @@ type RawValue struct {
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
// 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
}
if trimmed[0] != '"' {
parsed, err := strconv.ParseUint(trimmed, 0, 64)
if trimmed[0] == '"' {
valueStr, err := strconv.Unquote(trimmed)
if err != nil {
return err
}
*v = SmartRawValue(parsed)
return nil
}
valueStr, err := strconv.Unquote(trimmed)
if err != nil {
return err
}
if valueStr == "" {
parsed, ok := ParseSmartRawValueString(valueStr)
if ok {
*v = SmartRawValue(parsed)
return nil
}
*v = 0
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)
return nil
}
if idx := strings.IndexRune(valueStr, 'h'); idx >= 0 {
hoursPart := strings.TrimSpace(valueStr[:idx])
if hoursPart == "" {
*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
}
if parsed, ok := ParseSmartRawValueString(trimmed); ok {
*v = SmartRawValue(parsed)
return nil
}
*v = 0
return nil
}
func leadingDigitPrefix(value string) string {
var builder strings.Builder
for _, r := range value {
if r < '0' || r > '9' {
break
}
builder.WriteRune(r)
// ParseSmartRawValueString attempts to extract a numeric value from the raw value
// strings emitted by smartctl, which sometimes include human-friendly annotations
// like "7344 (253d 8h)" or "0h+0m+0.000s". It returns the parsed value and a
// boolean indicating success.
func ParseSmartRawValueString(value string) (uint64, bool) {
value = strings.TrimSpace(value)
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 {

View File

@@ -3,28 +3,60 @@ package smart
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
)
func TestSmartRawValueUnmarshalDuration(t *testing.T) {
input := []byte(`{"value":"62312h+33m+50.907s","string":"62312h+33m+50.907s"}`)
var raw RawValue
if err := json.Unmarshal(input, &raw); err != nil {
t.Fatalf("unexpected error unmarshalling raw value: %v", err)
}
err := json.Unmarshal(input, &raw)
assert.NoError(t, err)
if uint64(raw.Value) != 62312 {
t.Fatalf("expected hours to be 62312, got %d", raw.Value)
}
assert.EqualValues(t, 62312, raw.Value)
}
func TestSmartRawValueUnmarshalNumericString(t *testing.T) {
input := []byte(`{"value":"7344","string":"7344"}`)
var raw RawValue
if err := json.Unmarshal(input, &raw); err != nil {
t.Fatalf("unexpected error unmarshalling numeric string: %v", err)
}
err := json.Unmarshal(input, &raw)
assert.NoError(t, err)
if uint64(raw.Value) != 7344 {
t.Fatalf("expected hours to be 7344, got %d", raw.Value)
}
assert.EqualValues(t, 7344, 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 {
Cpu float64 `json:"cpu" cbor:"0,keyasint"`
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"`
MemPct float64 `json:"mp" cbor:"4,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.MaxDiskReadPS = max(fs.MaxDiskReadPS, value.MaxDiskReadPS, value.DiskReadPs)
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.DiskWritePs = twoDecimals(fs.DiskWritePs / 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",
"fa",
"fr",
"he",
"hr",
"hu",
"it",

View File

@@ -118,6 +118,28 @@ export function useNetworkInterfaces(interfaces: SystemStats["ni"]) {
dataKey: ({ stats }: SystemStatsRecord) => stats?.ni?.[key]?.[index],
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,
}))
},

View File

@@ -73,6 +73,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
import { Separator } from "../ui/separator"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
import NetworkSheet from "./system/network-sheet"
import CpuCoresSheet from "./system/cpu-cores-sheet"
import LineChartDefault from "../charts/line-chart"
@@ -585,18 +586,49 @@ export default memo(function SystemDetail({ id }: { id: string }) {
grid={grid}
title={t`CPU Usage`}
description={t`Average system-wide CPU utilization`}
cornerEl={maxValSelect}
cornerEl={
<>
{maxValSelect}
<CpuCoresSheet chartData={chartData} dataEmpty={dataEmpty} grid={grid} maxValues={maxValues} />
</>
}
legend={true}
>
<AreaChartDefault
chartData={chartData}
maxToggled={maxValues}
legend={true}
dataPoints={[
{
label: t`CPU Usage`,
label: t`Total`,
dataKey: ({ stats }) => (showMax ? stats?.cpum : stats?.cpu),
color: 1,
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)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
@@ -958,9 +990,9 @@ export default memo(function SystemDetail({ id }: { id: string }) {
label: t`Write`,
dataKey: ({ stats }) => {
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,
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
/** peak cpu */
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
/** load average 1 minute */
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
- Add `SMART_DEVICES` environment variable to specify devices and types. (#373, #1335)