diff --git a/agent/deltatracker/deltatracker.go b/agent/deltatracker/deltatracker.go new file mode 100644 index 00000000..99098d32 --- /dev/null +++ b/agent/deltatracker/deltatracker.go @@ -0,0 +1,111 @@ +package deltatracker + +import ( + "sync" + + "golang.org/x/exp/constraints" +) + +// Numeric is a constraint that permits any integer or floating-point type. +type Numeric interface { + constraints.Integer | constraints.Float +} + +// DeltaTracker is a generic, thread-safe tracker for calculating differences +// in numeric values over time. +// K is the key type (e.g., int, string). +// V is the value type (e.g., int, int64, float32, float64). +type DeltaTracker[K comparable, V Numeric] struct { + mu sync.RWMutex + current map[K]V + previous map[K]V +} + +// NewDeltaTracker creates a new generic tracker. +func NewDeltaTracker[K comparable, V Numeric]() *DeltaTracker[K, V] { + return &DeltaTracker[K, V]{ + current: make(map[K]V), + previous: make(map[K]V), + } +} + +// Set records the current value for a given ID. +func (t *DeltaTracker[K, V]) Set(id K, value V) { + t.mu.Lock() + defer t.mu.Unlock() + t.current[id] = value +} + +// Deltas returns a map of all calculated deltas for the current interval. +func (t *DeltaTracker[K, V]) Deltas() map[K]V { + t.mu.RLock() + defer t.mu.RUnlock() + + deltas := make(map[K]V) + for id, currentVal := range t.current { + if previousVal, ok := t.previous[id]; ok { + deltas[id] = currentVal - previousVal + } else { + deltas[id] = 0 + } + } + return deltas +} + +// Delta returns the delta for a single key. +// Returns 0 if the key doesn't exist or has no previous value. +func (t *DeltaTracker[K, V]) Delta(id K) V { + t.mu.RLock() + defer t.mu.RUnlock() + + currentVal, currentOk := t.current[id] + if !currentOk { + return 0 + } + + previousVal, previousOk := t.previous[id] + if !previousOk { + return 0 + } + + return currentVal - previousVal +} + +// Cycle prepares the tracker for the next interval. +func (t *DeltaTracker[K, V]) Cycle() { + t.mu.Lock() + defer t.mu.Unlock() + t.previous = t.current + t.current = make(map[K]V) +} + +// // --- Example 1: Integer values (unchanged) --- +// fmt.Println("--- 🚀 Example with int64 values (PIDs) ---") +// pidTracker := NewDeltaTracker[int, int64]() +// pidTracker.Set(101, 20000) +// pidTracker.Cycle() +// pidTracker.Set(101, 22500) +// fmt.Println("PID Deltas:", pidTracker.Deltas()) +// fmt.Println("----------------------------------------") + +// // --- Example 2: Float values (New!) --- +// fmt.Println("\n--- 🚀 Example with float64 values (CPU Load) ---") +// // Track the 1-minute load average for different servers. +// loadTracker := NewDeltaTracker[string, float64]() + +// // Minute 1 +// loadTracker.Set("server-alpha", 0.74) +// loadTracker.Set("server-beta", 1.15) +// fmt.Println("Minute 1 Loads:", loadTracker.Deltas()) +// loadTracker.Cycle() + +// // Minute 2 +// loadTracker.Set("server-alpha", 0.68) // Load decreased +// loadTracker.Set("server-beta", 1.55) // Load increased +// loadTracker.Set("server-gamma", 0.25) // New server + +// minute2Deltas := loadTracker.Deltas() +// fmt.Println("Minute 2 Load Deltas:", minute2Deltas) +// fmt.Printf("Change in alpha's load: %.2f\n", minute2Deltas["server-alpha"]) +// fmt.Printf("Change in beta's load: %.2f\n", minute2Deltas["server-beta"]) +// fmt.Println("----------------------------------------") diff --git a/agent/deltatracker/deltatracker_test.go b/agent/deltatracker/deltatracker_test.go new file mode 100644 index 00000000..5bc51061 --- /dev/null +++ b/agent/deltatracker/deltatracker_test.go @@ -0,0 +1,201 @@ +package deltatracker + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewDeltaTracker(t *testing.T) { + tracker := NewDeltaTracker[string, int]() + assert.NotNil(t, tracker) + assert.Empty(t, tracker.current) + assert.Empty(t, tracker.previous) +} + +func TestSet(t *testing.T) { + tracker := NewDeltaTracker[string, int]() + tracker.Set("key1", 10) + + tracker.mu.RLock() + defer tracker.mu.RUnlock() + + assert.Equal(t, 10, tracker.current["key1"]) +} + +func TestDeltas(t *testing.T) { + tracker := NewDeltaTracker[string, int]() + + // Test with no previous values + tracker.Set("key1", 10) + tracker.Set("key2", 20) + + deltas := tracker.Deltas() + assert.Equal(t, 0, deltas["key1"]) + assert.Equal(t, 0, deltas["key2"]) + + // Cycle to move current to previous + tracker.Cycle() + + // Set new values and check deltas + tracker.Set("key1", 15) // Delta should be 5 (15-10) + tracker.Set("key2", 25) // Delta should be 5 (25-20) + tracker.Set("key3", 30) // New key, delta should be 0 + + deltas = tracker.Deltas() + assert.Equal(t, 5, deltas["key1"]) + assert.Equal(t, 5, deltas["key2"]) + assert.Equal(t, 0, deltas["key3"]) +} + +func TestCycle(t *testing.T) { + tracker := NewDeltaTracker[string, int]() + + tracker.Set("key1", 10) + tracker.Set("key2", 20) + + // Verify current has values + tracker.mu.RLock() + assert.Equal(t, 10, tracker.current["key1"]) + assert.Equal(t, 20, tracker.current["key2"]) + assert.Empty(t, tracker.previous) + tracker.mu.RUnlock() + + tracker.Cycle() + + // After cycle, previous should have the old current values + // and current should be empty + tracker.mu.RLock() + assert.Empty(t, tracker.current) + assert.Equal(t, 10, tracker.previous["key1"]) + assert.Equal(t, 20, tracker.previous["key2"]) + tracker.mu.RUnlock() +} + +func TestCompleteWorkflow(t *testing.T) { + tracker := NewDeltaTracker[string, int]() + + // First interval + tracker.Set("server1", 100) + tracker.Set("server2", 200) + + // Get deltas for first interval (should be zero) + firstDeltas := tracker.Deltas() + assert.Equal(t, 0, firstDeltas["server1"]) + assert.Equal(t, 0, firstDeltas["server2"]) + + // Cycle to next interval + tracker.Cycle() + + // Second interval + tracker.Set("server1", 150) // Delta: 50 + tracker.Set("server2", 180) // Delta: -20 + tracker.Set("server3", 300) // New server, delta: 300 + + secondDeltas := tracker.Deltas() + assert.Equal(t, 50, secondDeltas["server1"]) + assert.Equal(t, -20, secondDeltas["server2"]) + assert.Equal(t, 0, secondDeltas["server3"]) +} + +func TestDeltaTrackerWithDifferentTypes(t *testing.T) { + // Test with int64 + intTracker := NewDeltaTracker[string, int64]() + intTracker.Set("pid1", 1000) + intTracker.Cycle() + intTracker.Set("pid1", 1200) + intDeltas := intTracker.Deltas() + assert.Equal(t, int64(200), intDeltas["pid1"]) + + // Test with float64 + floatTracker := NewDeltaTracker[string, float64]() + floatTracker.Set("cpu1", 1.5) + floatTracker.Cycle() + floatTracker.Set("cpu1", 2.7) + floatDeltas := floatTracker.Deltas() + assert.InDelta(t, 1.2, floatDeltas["cpu1"], 0.0001) + + // Test with int keys + pidTracker := NewDeltaTracker[int, int64]() + pidTracker.Set(101, 20000) + pidTracker.Cycle() + pidTracker.Set(101, 22500) + pidDeltas := pidTracker.Deltas() + assert.Equal(t, int64(2500), pidDeltas[101]) +} + +func TestDelta(t *testing.T) { + tracker := NewDeltaTracker[string, int]() + + // Test getting delta for non-existent key + result := tracker.Delta("nonexistent") + assert.Equal(t, 0, result) + + // Test getting delta for key with no previous value + tracker.Set("key1", 10) + result = tracker.Delta("key1") + assert.Equal(t, 0, result) + + // Cycle to move current to previous + tracker.Cycle() + + // Test getting delta for key with previous value + tracker.Set("key1", 15) + result = tracker.Delta("key1") + assert.Equal(t, 5, result) + + // Test getting delta for key that exists in previous but not current + result = tracker.Delta("key1") + assert.Equal(t, 5, result) // Should still return 5 + + // Test getting delta for key that exists in current but not previous + tracker.Set("key2", 20) + result = tracker.Delta("key2") + assert.Equal(t, 0, result) +} + +func TestDeltaWithDifferentTypes(t *testing.T) { + // Test with int64 + intTracker := NewDeltaTracker[string, int64]() + intTracker.Set("pid1", 1000) + intTracker.Cycle() + intTracker.Set("pid1", 1200) + result := intTracker.Delta("pid1") + assert.Equal(t, int64(200), result) + + // Test with float64 + floatTracker := NewDeltaTracker[string, float64]() + floatTracker.Set("cpu1", 1.5) + floatTracker.Cycle() + floatTracker.Set("cpu1", 2.7) + floatResult := floatTracker.Delta("cpu1") + assert.InDelta(t, 1.2, floatResult, 0.0001) + + // Test with int keys + pidTracker := NewDeltaTracker[int, int64]() + pidTracker.Set(101, 20000) + pidTracker.Cycle() + pidTracker.Set(101, 22500) + pidResult := pidTracker.Delta(101) + assert.Equal(t, int64(2500), pidResult) +} + +func TestDeltaConcurrentAccess(t *testing.T) { + tracker := NewDeltaTracker[string, int]() + + // Set initial values + tracker.Set("key1", 10) + tracker.Set("key2", 20) + tracker.Cycle() + + // Set new values + tracker.Set("key1", 15) + tracker.Set("key2", 25) + + // Test concurrent access safety + result1 := tracker.Delta("key1") + result2 := tracker.Delta("key2") + + assert.Equal(t, 5, result1) + assert.Equal(t, 5, result2) +} diff --git a/agent/network.go b/agent/network.go index fb5097af..0901d9c9 100644 --- a/agent/network.go +++ b/agent/network.go @@ -1,13 +1,83 @@ package agent import ( + "fmt" "log/slog" "strings" "time" + "github.com/henrygd/beszel/internal/entities/system" psutilNet "github.com/shirou/gopsutil/v4/net" ) +func (a *Agent) updateNetworkStats(systemStats *system.Stats) { + // network stats + if len(a.netInterfaces) == 0 { + // if no network interfaces, initialize again + // this is a fix if agent started before network is online (#466) + // maybe refactor this in the future to not cache interface names at all so we + // don't miss an interface that's been added after agent started in any circumstance + a.initializeNetIoStats() + } + + if systemStats.NetworkInterfaces == nil { + systemStats.NetworkInterfaces = make(map[string][4]uint64, 0) + } + + if netIO, err := psutilNet.IOCounters(true); err == nil { + msElapsed := uint64(time.Since(a.netIoStats.Time).Milliseconds()) + a.netIoStats.Time = time.Now() + totalBytesSent := uint64(0) + totalBytesRecv := uint64(0) + netInterfaceDeltaTracker.Cycle() + // sum all bytes sent and received + for _, v := range netIO { + // skip if not in valid network interfaces list + if _, exists := a.netInterfaces[v.Name]; !exists { + continue + } + totalBytesSent += v.BytesSent + totalBytesRecv += v.BytesRecv + + // track deltas for each network interface + netInterfaceDeltaTracker.Set(fmt.Sprintf("%sdown", v.Name), v.BytesRecv) + netInterfaceDeltaTracker.Set(fmt.Sprintf("%sup", v.Name), v.BytesSent) + upDelta := netInterfaceDeltaTracker.Delta(fmt.Sprintf("%sup", v.Name)) * 1000 / msElapsed + downDelta := netInterfaceDeltaTracker.Delta(fmt.Sprintf("%sdown", v.Name)) * 1000 / msElapsed + // add interface to systemStats + systemStats.NetworkInterfaces[v.Name] = [4]uint64{upDelta, downDelta, v.BytesSent, v.BytesRecv} + } + + // add to systemStats + var bytesSentPerSecond, bytesRecvPerSecond uint64 + if msElapsed > 0 { + bytesSentPerSecond = (totalBytesSent - a.netIoStats.BytesSent) * 1000 / msElapsed + bytesRecvPerSecond = (totalBytesRecv - a.netIoStats.BytesRecv) * 1000 / msElapsed + } + networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond)) + networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond)) + // add check for issue (#150) where sent is a massive number + if networkSentPs > 10_000 || networkRecvPs > 10_000 { + slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs) + for _, v := range netIO { + if _, exists := a.netInterfaces[v.Name]; !exists { + continue + } + slog.Info(v.Name, "recv", v.BytesRecv, "sent", v.BytesSent) + } + // reset network I/O stats + a.initializeNetIoStats() + } else { + systemStats.NetworkSent = networkSentPs + systemStats.NetworkRecv = networkRecvPs + systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond + // update netIoStats + a.netIoStats.BytesSent = totalBytesSent + a.netIoStats.BytesRecv = totalBytesRecv + } + } +} + func (a *Agent) initializeNetIoStats() { // reset valid network interfaces a.netInterfaces = make(map[string]struct{}, 0) diff --git a/agent/system.go b/agent/system.go index d3a76107..06d85ef0 100644 --- a/agent/system.go +++ b/agent/system.go @@ -11,6 +11,7 @@ import ( "github.com/henrygd/beszel" "github.com/henrygd/beszel/agent/battery" + "github.com/henrygd/beszel/agent/deltatracker" "github.com/henrygd/beszel/internal/entities/system" "github.com/shirou/gopsutil/v4/cpu" @@ -18,9 +19,10 @@ import ( "github.com/shirou/gopsutil/v4/host" "github.com/shirou/gopsutil/v4/load" "github.com/shirou/gopsutil/v4/mem" - psutilNet "github.com/shirou/gopsutil/v4/net" ) +var netInterfaceDeltaTracker = deltatracker.NewDeltaTracker[string, uint64]() + // Sets initial / non-changing values about the host system func (a *Agent) initializeSystemInfo() { a.systemInfo.AgentVersion = beszel.Version @@ -70,7 +72,7 @@ func (a *Agent) initializeSystemInfo() { // Returns current info, stats about the host system func (a *Agent) getSystemStats() system.Stats { - systemStats := system.Stats{} + var systemStats system.Stats // battery if battery.HasReadableBattery() { @@ -173,55 +175,7 @@ func (a *Agent) getSystemStats() system.Stats { } // network stats - if len(a.netInterfaces) == 0 { - // if no network interfaces, initialize again - // this is a fix if agent started before network is online (#466) - // maybe refactor this in the future to not cache interface names at all so we - // don't miss an interface that's been added after agent started in any circumstance - a.initializeNetIoStats() - } - if netIO, err := psutilNet.IOCounters(true); err == nil { - msElapsed := uint64(time.Since(a.netIoStats.Time).Milliseconds()) - a.netIoStats.Time = time.Now() - totalBytesSent := uint64(0) - totalBytesRecv := uint64(0) - // sum all bytes sent and received - for _, v := range netIO { - // skip if not in valid network interfaces list - if _, exists := a.netInterfaces[v.Name]; !exists { - continue - } - totalBytesSent += v.BytesSent - totalBytesRecv += v.BytesRecv - } - // add to systemStats - var bytesSentPerSecond, bytesRecvPerSecond uint64 - if msElapsed > 0 { - bytesSentPerSecond = (totalBytesSent - a.netIoStats.BytesSent) * 1000 / msElapsed - bytesRecvPerSecond = (totalBytesRecv - a.netIoStats.BytesRecv) * 1000 / msElapsed - } - networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond)) - networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond)) - // add check for issue (#150) where sent is a massive number - if networkSentPs > 10_000 || networkRecvPs > 10_000 { - slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs) - for _, v := range netIO { - if _, exists := a.netInterfaces[v.Name]; !exists { - continue - } - slog.Info(v.Name, "recv", v.BytesRecv, "sent", v.BytesSent) - } - // reset network I/O stats - a.initializeNetIoStats() - } else { - systemStats.NetworkSent = networkSentPs - systemStats.NetworkRecv = networkRecvPs - systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond - // update netIoStats - a.netIoStats.BytesSent = totalBytesSent - a.netIoStats.BytesRecv = totalBytesRecv - } - } + a.updateNetworkStats(&systemStats) // temperatures // TODO: maybe refactor to methods on systemStats diff --git a/internal/entities/system/system.go b/internal/entities/system/system.go index 3e81e62b..ce43ebd7 100644 --- a/internal/entities/system/system.go +++ b/internal/entities/system/system.go @@ -38,9 +38,10 @@ type Stats struct { Bandwidth [2]uint64 `json:"b,omitzero" cbor:"26,keyasint,omitzero"` // [sent bytes, recv bytes] MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"27,keyasint,omitzero"` // [sent bytes, recv bytes] // TODO: remove other load fields in future release in favor of load avg array - LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"` - Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current] - MaxMem float64 `json:"mm,omitempty" cbor:"30,keyasint,omitempty"` + LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"` + Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current] + MaxMem float64 `json:"mm,omitempty" cbor:"30,keyasint,omitempty"` + NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download] } type GPUData struct { diff --git a/internal/records/records.go b/internal/records/records.go index 03f14914..428bba0a 100644 --- a/internal/records/records.go +++ b/internal/records/records.go @@ -225,6 +225,19 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) * sum.MaxBandwidth[0] = max(sum.MaxBandwidth[0], stats.MaxBandwidth[0], stats.Bandwidth[0]) sum.MaxBandwidth[1] = max(sum.MaxBandwidth[1], stats.MaxBandwidth[1], stats.Bandwidth[1]) + // Accumulate network interfaces + if sum.NetworkInterfaces == nil { + sum.NetworkInterfaces = make(map[string][4]uint64, len(stats.NetworkInterfaces)) + } + for key, value := range stats.NetworkInterfaces { + sum.NetworkInterfaces[key] = [4]uint64{ + sum.NetworkInterfaces[key][0] + value[0], + sum.NetworkInterfaces[key][1] + value[1], + max(sum.NetworkInterfaces[key][2], value[2]), + max(sum.NetworkInterfaces[key][3], value[3]), + } + } + // Accumulate temperatures if stats.Temperatures != nil { if sum.Temperatures == nil { @@ -299,6 +312,19 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) * sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count) sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count) sum.Battery[0] = uint8(batterySum / int(count)) + + // Average network interfaces + if sum.NetworkInterfaces != nil { + for key := range sum.NetworkInterfaces { + sum.NetworkInterfaces[key] = [4]uint64{ + sum.NetworkInterfaces[key][0] / uint64(count), + sum.NetworkInterfaces[key][1] / uint64(count), + sum.NetworkInterfaces[key][2], + sum.NetworkInterfaces[key][3], + } + } + } + // Average temperatures if sum.Temperatures != nil && tempCount > 0 { for key := range sum.Temperatures { diff --git a/internal/site/src/components/charts/area-chart.tsx b/internal/site/src/components/charts/area-chart.tsx index 12b51141..c6f00875 100644 --- a/internal/site/src/components/charts/area-chart.tsx +++ b/internal/site/src/components/charts/area-chart.tsx @@ -1,9 +1,16 @@ -import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" -import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" -import { cn, formatShortDate, chartMargin } from "@/lib/utils" -import { useYAxisWidth } from "./hooks" -import { ChartData, SystemStatsRecord } from "@/types" import { useMemo } from "react" +import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + xAxis, +} from "@/components/ui/chart" +import { chartMargin, cn, formatShortDate } from "@/lib/utils" +import type { ChartData, SystemStatsRecord } from "@/types" +import { useYAxisWidth } from "./hooks" export type DataPoint = { label: string @@ -20,6 +27,8 @@ export default function AreaChartDefault({ contentFormatter, dataPoints, domain, + legend, + itemSorter, }: // logRender = false, { chartData: ChartData @@ -29,10 +38,13 @@ export default function AreaChartDefault({ contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string dataPoints?: DataPoint[] domain?: [number, number] + legend?: boolean + itemSorter?: (a: any, b: any) => number // logRender?: boolean }) { const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() + // biome-ignore lint/correctness/useExhaustiveDependencies: ignore return useMemo(() => { if (chartData.systemStats.length === 0) { return null @@ -63,6 +75,8 @@ export default function AreaChartDefault({ formatShortDate(data[0].payload.created)} @@ -70,11 +84,14 @@ export default function AreaChartDefault({ /> } /> - {dataPoints?.map((dataPoint, i) => { - const color = `var(--chart-${dataPoint.color})` + {dataPoints?.map((dataPoint) => { + let { color } = dataPoint + if (typeof color === "number") { + color = `var(--chart-${color})` + } return ( ) })} - {/* } /> */} + {legend && } />} diff --git a/internal/site/src/components/charts/chart-time-select.tsx b/internal/site/src/components/charts/chart-time-select.tsx index b46a4739..0b9896ad 100644 --- a/internal/site/src/components/charts/chart-time-select.tsx +++ b/internal/site/src/components/charts/chart-time-select.tsx @@ -1,9 +1,9 @@ +import { useStore } from "@nanostores/react" +import { HistoryIcon } from "lucide-react" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { $chartTime } from "@/lib/stores" import { chartTimeData, cn } from "@/lib/utils" -import { ChartTimes } from "@/types" -import { useStore } from "@nanostores/react" -import { HistoryIcon } from "lucide-react" +import type { ChartTimes } from "@/types" export default function ChartTimeSelect({ className }: { className?: string }) { const chartTime = useStore($chartTime) diff --git a/internal/site/src/components/charts/container-chart.tsx b/internal/site/src/components/charts/container-chart.tsx index ff5d650b..e330529f 100644 --- a/internal/site/src/components/charts/container-chart.tsx +++ b/internal/site/src/components/charts/container-chart.tsx @@ -1,13 +1,13 @@ -import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" -import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" -import { memo, useMemo } from "react" -import { cn, formatShortDate, chartMargin, toFixedFloat, formatBytes, decimalString } from "@/lib/utils" // import Spinner from '../spinner' import { useStore } from "@nanostores/react" +import { memo, useMemo } from "react" +import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" +import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" +import { ChartType, Unit } from "@/lib/enums" import { $containerFilter, $userSettings } from "@/lib/stores" +import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils" import type { ChartData } from "@/types" import { Separator } from "../ui/separator" -import { ChartType, Unit } from "@/lib/enums" import { useYAxisWidth } from "./hooks" export default memo(function ContainerChart({ diff --git a/internal/site/src/components/charts/disk-chart.tsx b/internal/site/src/components/charts/disk-chart.tsx index d520428e..bc7fe26c 100644 --- a/internal/site/src/components/charts/disk-chart.tsx +++ b/internal/site/src/components/charts/disk-chart.tsx @@ -1,10 +1,10 @@ +import { useLingui } from "@lingui/react/macro" +import { memo } from "react" import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" -import { cn, formatShortDate, decimalString, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils" -import { ChartData } from "@/types" -import { memo } from "react" -import { useLingui } from "@lingui/react/macro" import { Unit } from "@/lib/enums" +import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils" +import type { ChartData } from "@/types" import { useYAxisWidth } from "./hooks" export default memo(function DiskChart({ diff --git a/internal/site/src/components/charts/gpu-power-chart.tsx b/internal/site/src/components/charts/gpu-power-chart.tsx index a4d8e104..a9a20248 100644 --- a/internal/site/src/components/charts/gpu-power-chart.tsx +++ b/internal/site/src/components/charts/gpu-power-chart.tsx @@ -1,5 +1,5 @@ +import { memo, useMemo } from "react" import { CartesianGrid, Line, LineChart, YAxis } from "recharts" - import { ChartContainer, ChartLegend, @@ -8,9 +8,8 @@ import { ChartTooltipContent, xAxis, } from "@/components/ui/chart" -import { cn, formatShortDate, toFixedFloat, decimalString, chartMargin } from "@/lib/utils" -import { ChartData } from "@/types" -import { memo, useMemo } from "react" +import { chartMargin, cn, decimalString, formatShortDate, toFixedFloat } from "@/lib/utils" +import type { ChartData } from "@/types" import { useYAxisWidth } from "./hooks" export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData }) { @@ -27,10 +26,10 @@ export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData colors: Record } const powerSums = {} as Record - for (let data of chartData.systemStats) { - let newData = { created: data.created } as Record + for (const data of chartData.systemStats) { + const newData = { created: data.created } as Record - for (let gpu of Object.values(data.stats?.g ?? {})) { + for (const gpu of Object.values(data.stats?.g ?? {})) { if (gpu.p) { const name = gpu.n newData[name] = gpu.p @@ -40,7 +39,7 @@ export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData newChartData.data.push(newData) } const keys = Object.keys(powerSums).sort((a, b) => powerSums[b] - powerSums[a]) - for (let key of keys) { + for (const key of keys) { newChartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)` } return newChartData @@ -67,7 +66,7 @@ export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData width={yAxisWidth} tickFormatter={(value) => { const val = toFixedFloat(value, 2) - return updateYAxisWidth(val + "W") + return updateYAxisWidth(`${val}W`) }} tickLine={false} axisLine={false} @@ -76,12 +75,12 @@ export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData b.value - a.value} content={ formatShortDate(data[0].payload.created)} - contentFormatter={(item) => decimalString(item.value) + "W"} + contentFormatter={(item) => `${decimalString(item.value)}W`} // indicator="line" /> } diff --git a/internal/site/src/components/charts/hooks.ts b/internal/site/src/components/charts/hooks.ts index 117b7e1a..a5946026 100644 --- a/internal/site/src/components/charts/hooks.ts +++ b/internal/site/src/components/charts/hooks.ts @@ -1,6 +1,6 @@ import { useMemo, useState } from "react" -import { ChartConfig } from "@/components/ui/chart" -import { ChartData } from "@/types" +import type { ChartConfig } from "@/components/ui/chart" +import type { ChartData, SystemStats, SystemStatsRecord } from "@/types" /** Chart configurations for CPU, memory, and network usage charts */ export interface ContainerChartConfigs { @@ -105,3 +105,21 @@ export function useYAxisWidth() { } return { yAxisWidth, updateYAxisWidth } } + +// Assures consistent colors for network interfaces +export function useNetworkInterfaces(interfaces: SystemStats["ni"]) { + const keys = Object.keys(interfaces ?? {}) + const sortedKeys = keys.sort((a, b) => (interfaces?.[b]?.[3] ?? 0) - (interfaces?.[a]?.[3] ?? 0)) + return { + length: sortedKeys.length, + data: (index = 3) => { + return sortedKeys.map((key) => ({ + label: key, + dataKey: (stats: SystemStatsRecord) => stats.stats?.ni?.[key]?.[index], + color: `hsl(${220 + (((sortedKeys.indexOf(key) * 360) / sortedKeys.length) % 360)}, 70%, 50%)`, + + opacity: 0.3, + })) + }, + } +} diff --git a/internal/site/src/components/charts/line-chart.tsx b/internal/site/src/components/charts/line-chart.tsx new file mode 100644 index 00000000..45fcc996 --- /dev/null +++ b/internal/site/src/components/charts/line-chart.tsx @@ -0,0 +1,110 @@ +import { useMemo } from "react" +import { CartesianGrid, Line, LineChart, YAxis } from "recharts" +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + xAxis, +} from "@/components/ui/chart" +import { chartMargin, cn, formatShortDate } from "@/lib/utils" +import type { ChartData, SystemStatsRecord } from "@/types" +import { useYAxisWidth } from "./hooks" + +export type DataPoint = { + label: string + dataKey: (data: SystemStatsRecord) => number | undefined + color: number | string +} + +export default function LineChartDefault({ + chartData, + max, + maxToggled, + tickFormatter, + contentFormatter, + dataPoints, + domain, + legend, + itemSorter, +}: // logRender = false, +{ + chartData: ChartData + max?: number + maxToggled?: boolean + tickFormatter: (value: number, index: number) => string + contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string + dataPoints?: DataPoint[] + domain?: [number, number] + legend?: boolean + itemSorter?: (a: any, b: any) => number + // logRender?: boolean +}) { + const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() + + // biome-ignore lint/correctness/useExhaustiveDependencies: ignore + return useMemo(() => { + if (chartData.systemStats.length === 0) { + return null + } + // if (logRender) { + // console.log("Rendered at", new Date()) + // } + return ( +
+ + + + updateYAxisWidth(tickFormatter(value, index))} + tickLine={false} + axisLine={false} + /> + {xAxis(chartData)} + formatShortDate(data[0].payload.created)} + contentFormatter={contentFormatter} + /> + } + /> + {dataPoints?.map((dataPoint) => { + let { color } = dataPoint + if (typeof color === "number") { + color = `var(--chart-${color})` + } + return ( + + ) + })} + {legend && } />} + + +
+ ) + }, [chartData.systemStats.at(-1), yAxisWidth, maxToggled]) +} diff --git a/internal/site/src/components/charts/load-average-chart.tsx b/internal/site/src/components/charts/load-average-chart.tsx index 5c3cecd9..8ea7300c 100644 --- a/internal/site/src/components/charts/load-average-chart.tsx +++ b/internal/site/src/components/charts/load-average-chart.tsx @@ -1,5 +1,6 @@ +import { t } from "@lingui/core/macro" +import { memo } from "react" import { CartesianGrid, Line, LineChart, YAxis } from "recharts" - import { ChartContainer, ChartLegend, @@ -8,10 +9,8 @@ import { ChartTooltipContent, xAxis, } from "@/components/ui/chart" -import { cn, formatShortDate, toFixedFloat, decimalString, chartMargin } from "@/lib/utils" -import { ChartData, SystemStats } from "@/types" -import { memo } from "react" -import { t } from "@lingui/core/macro" +import { chartMargin, cn, decimalString, formatShortDate, toFixedFloat } from "@/lib/utils" +import type { ChartData, SystemStats } from "@/types" import { useYAxisWidth } from "./hooks" export default memo(function LoadAverageChart({ chartData }: { chartData: ChartData }) { @@ -60,7 +59,7 @@ export default memo(function LoadAverageChart({ chartData }: { chartData: ChartD b.value - a.value} content={ a.order - b.order} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} contentFormatter={({ value }) => { diff --git a/internal/site/src/components/charts/swap-chart.tsx b/internal/site/src/components/charts/swap-chart.tsx index f2cd2430..a45c1e9e 100644 --- a/internal/site/src/components/charts/swap-chart.tsx +++ b/internal/site/src/components/charts/swap-chart.tsx @@ -1,12 +1,11 @@ import { t } from "@lingui/core/macro" - +import { useStore } from "@nanostores/react" +import { memo } from "react" import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" -import { cn, formatShortDate, decimalString, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils" -import { ChartData } from "@/types" -import { memo } from "react" import { $userSettings } from "@/lib/stores" -import { useStore } from "@nanostores/react" +import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils" +import type { ChartData } from "@/types" import { useYAxisWidth } from "./hooks" export default memo(function SwapChart({ chartData }: { chartData: ChartData }) { diff --git a/internal/site/src/components/charts/temperature-chart.tsx b/internal/site/src/components/charts/temperature-chart.tsx index 61248a47..a5a03705 100644 --- a/internal/site/src/components/charts/temperature-chart.tsx +++ b/internal/site/src/components/charts/temperature-chart.tsx @@ -1,5 +1,6 @@ +import { useStore } from "@nanostores/react" +import { memo, useMemo } from "react" import { CartesianGrid, Line, LineChart, YAxis } from "recharts" - import { ChartContainer, ChartLegend, @@ -8,11 +9,9 @@ import { ChartTooltipContent, xAxis, } from "@/components/ui/chart" -import { cn, formatShortDate, toFixedFloat, chartMargin, formatTemperature, decimalString } from "@/lib/utils" -import { ChartData } from "@/types" -import { memo, useMemo } from "react" import { $temperatureFilter, $userSettings } from "@/lib/stores" -import { useStore } from "@nanostores/react" +import { chartMargin, cn, decimalString, formatShortDate, formatTemperature, toFixedFloat } from "@/lib/utils" +import type { ChartData } from "@/types" import { useYAxisWidth } from "./hooks" export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) { @@ -31,18 +30,18 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD colors: Record } const tempSums = {} as Record - for (let data of chartData.systemStats) { - let newData = { created: data.created } as Record - let keys = Object.keys(data.stats?.t ?? {}) + for (const data of chartData.systemStats) { + const newData = { created: data.created } as Record + const keys = Object.keys(data.stats?.t ?? {}) for (let i = 0; i < keys.length; i++) { - let key = keys[i] + const key = keys[i] newData[key] = data.stats.t![key] tempSums[key] = (tempSums[key] ?? 0) + newData[key] } newChartData.data.push(newData) } const keys = Object.keys(tempSums).sort((a, b) => tempSums[b] - tempSums[a]) - for (let key of keys) { + for (const key of keys) { newChartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)` } return newChartData @@ -78,7 +77,7 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD b.value - a.value} content={ {colors.map((key) => { const filtered = filter && !key.toLowerCase().includes(filter.toLowerCase()) - let strokeOpacity = filtered ? 0.1 : 1 + const strokeOpacity = filtered ? 0.1 : 1 return ( (showMax ? stats?.dwm : stats?.dw), + dataKey: ({ stats }: SystemStatsRecord) => (showMax ? stats?.dwm : stats?.dw), color: 3, opacity: 0.3, }, { label: t({ message: "Read", comment: "Disk read" }), - dataKey: ({ stats }) => (showMax ? stats?.drm : stats?.dr), + dataKey: ({ stats }: SystemStatsRecord) => (showMax ? stats?.drm : stats?.dr), color: 1, opacity: 0.3, }, @@ -590,7 +591,12 @@ export default memo(function SystemDetail({ name }: { name: string }) { empty={dataEmpty} grid={grid} title={t`Bandwidth`} - cornerEl={maxValSelect} + cornerEl={ +
+ {maxValSelect} + +
+ } description={t`Network traffic of public interfaces`} > (systemStats.at(-1)?.stats.b?.[1] ?? 0) - (systemStats.at(-1)?.stats.b?.[0] ?? 0))} tickFormatter={(val) => { const { value, unit } = formatBytes(val, true, userSettings.unitNet, false) return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}` @@ -674,6 +682,7 @@ export default memo(function SystemDetail({ name }: { name: string }) { grid={grid} title={t`Load Average`} description={t`System load averages over time`} + legend={true} > @@ -687,6 +696,7 @@ export default memo(function SystemDetail({ name }: { name: string }) { title={t`Temperature`} description={t`Temperatures of system sensors`} cornerEl={} + legend={Object.keys(systemStats.at(-1)?.stats.t ?? {}).length < 12} > @@ -879,7 +889,7 @@ function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilt return ( <> - + {containerFilter && (