mirror of
https://github.com/henrygd/beszel.git
synced 2025-12-17 02:36:17 +01:00
add per-interface and cumulative network traffic charts (#926)
Co-authored-by: Sven van Ginkel <svenvanginkel@icloud.com>
This commit is contained in:
111
agent/deltatracker/deltatracker.go
Normal file
111
agent/deltatracker/deltatracker.go
Normal file
@@ -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("----------------------------------------")
|
||||||
201
agent/deltatracker/deltatracker_test.go
Normal file
201
agent/deltatracker/deltatracker_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,13 +1,83 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
psutilNet "github.com/shirou/gopsutil/v4/net"
|
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() {
|
func (a *Agent) initializeNetIoStats() {
|
||||||
// reset valid network interfaces
|
// reset valid network interfaces
|
||||||
a.netInterfaces = make(map[string]struct{}, 0)
|
a.netInterfaces = make(map[string]struct{}, 0)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/agent/battery"
|
"github.com/henrygd/beszel/agent/battery"
|
||||||
|
"github.com/henrygd/beszel/agent/deltatracker"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/cpu"
|
"github.com/shirou/gopsutil/v4/cpu"
|
||||||
@@ -18,9 +19,10 @@ import (
|
|||||||
"github.com/shirou/gopsutil/v4/host"
|
"github.com/shirou/gopsutil/v4/host"
|
||||||
"github.com/shirou/gopsutil/v4/load"
|
"github.com/shirou/gopsutil/v4/load"
|
||||||
"github.com/shirou/gopsutil/v4/mem"
|
"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
|
// Sets initial / non-changing values about the host system
|
||||||
func (a *Agent) initializeSystemInfo() {
|
func (a *Agent) initializeSystemInfo() {
|
||||||
a.systemInfo.AgentVersion = beszel.Version
|
a.systemInfo.AgentVersion = beszel.Version
|
||||||
@@ -70,7 +72,7 @@ func (a *Agent) initializeSystemInfo() {
|
|||||||
|
|
||||||
// Returns current info, stats about the host system
|
// Returns current info, stats about the host system
|
||||||
func (a *Agent) getSystemStats() system.Stats {
|
func (a *Agent) getSystemStats() system.Stats {
|
||||||
systemStats := system.Stats{}
|
var systemStats system.Stats
|
||||||
|
|
||||||
// battery
|
// battery
|
||||||
if battery.HasReadableBattery() {
|
if battery.HasReadableBattery() {
|
||||||
@@ -173,55 +175,7 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// network stats
|
// network stats
|
||||||
if len(a.netInterfaces) == 0 {
|
a.updateNetworkStats(&systemStats)
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// temperatures
|
// temperatures
|
||||||
// TODO: maybe refactor to methods on systemStats
|
// TODO: maybe refactor to methods on systemStats
|
||||||
|
|||||||
@@ -38,9 +38,10 @@ type Stats struct {
|
|||||||
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"26,keyasint,omitzero"` // [sent bytes, recv bytes]
|
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]
|
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
|
// TODO: remove other load fields in future release in favor of load avg array
|
||||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
|
||||||
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
|
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
|
||||||
MaxMem float64 `json:"mm,omitempty" cbor:"30,keyasint,omitempty"`
|
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 {
|
type GPUData struct {
|
||||||
|
|||||||
@@ -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[0] = max(sum.MaxBandwidth[0], stats.MaxBandwidth[0], stats.Bandwidth[0])
|
||||||
sum.MaxBandwidth[1] = max(sum.MaxBandwidth[1], stats.MaxBandwidth[1], stats.Bandwidth[1])
|
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
|
// Accumulate temperatures
|
||||||
if stats.Temperatures != nil {
|
if stats.Temperatures != nil {
|
||||||
if sum.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[0] = sum.Bandwidth[0] / uint64(count)
|
||||||
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
||||||
sum.Battery[0] = uint8(batterySum / int(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
|
// Average temperatures
|
||||||
if sum.Temperatures != nil && tempCount > 0 {
|
if sum.Temperatures != nil && tempCount > 0 {
|
||||||
for key := range sum.Temperatures {
|
for key := range sum.Temperatures {
|
||||||
|
|||||||
@@ -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 { 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 = {
|
export type DataPoint = {
|
||||||
label: string
|
label: string
|
||||||
@@ -20,6 +27,8 @@ export default function AreaChartDefault({
|
|||||||
contentFormatter,
|
contentFormatter,
|
||||||
dataPoints,
|
dataPoints,
|
||||||
domain,
|
domain,
|
||||||
|
legend,
|
||||||
|
itemSorter,
|
||||||
}: // logRender = false,
|
}: // logRender = false,
|
||||||
{
|
{
|
||||||
chartData: ChartData
|
chartData: ChartData
|
||||||
@@ -29,10 +38,13 @@ export default function AreaChartDefault({
|
|||||||
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
|
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
|
||||||
dataPoints?: DataPoint[]
|
dataPoints?: DataPoint[]
|
||||||
domain?: [number, number]
|
domain?: [number, number]
|
||||||
|
legend?: boolean
|
||||||
|
itemSorter?: (a: any, b: any) => number
|
||||||
// logRender?: boolean
|
// logRender?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: ignore
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (chartData.systemStats.length === 0) {
|
if (chartData.systemStats.length === 0) {
|
||||||
return null
|
return null
|
||||||
@@ -63,6 +75,8 @@ export default function AreaChartDefault({
|
|||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
animationDuration={150}
|
animationDuration={150}
|
||||||
|
// @ts-expect-error
|
||||||
|
itemSorter={itemSorter}
|
||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
@@ -70,11 +84,14 @@ export default function AreaChartDefault({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{dataPoints?.map((dataPoint, i) => {
|
{dataPoints?.map((dataPoint) => {
|
||||||
const color = `var(--chart-${dataPoint.color})`
|
let { color } = dataPoint
|
||||||
|
if (typeof color === "number") {
|
||||||
|
color = `var(--chart-${color})`
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Area
|
<Area
|
||||||
key={i}
|
key={dataPoint.label}
|
||||||
dataKey={dataPoint.dataKey}
|
dataKey={dataPoint.dataKey}
|
||||||
name={dataPoint.label}
|
name={dataPoint.label}
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
@@ -85,7 +102,7 @@ export default function AreaChartDefault({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{/* <ChartLegend content={<ChartLegendContent />} /> */}
|
{legend && <ChartLegend content={<ChartLegendContent />} />}
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { $chartTime } from "@/lib/stores"
|
import { $chartTime } from "@/lib/stores"
|
||||||
import { chartTimeData, cn } from "@/lib/utils"
|
import { chartTimeData, cn } from "@/lib/utils"
|
||||||
import { ChartTimes } from "@/types"
|
import type { ChartTimes } from "@/types"
|
||||||
import { useStore } from "@nanostores/react"
|
|
||||||
import { HistoryIcon } from "lucide-react"
|
|
||||||
|
|
||||||
export default function ChartTimeSelect({ className }: { className?: string }) {
|
export default function ChartTimeSelect({ className }: { className?: string }) {
|
||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
|
|||||||
@@ -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 Spinner from '../spinner'
|
||||||
import { useStore } from "@nanostores/react"
|
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 { $containerFilter, $userSettings } from "@/lib/stores"
|
||||||
|
import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils"
|
||||||
import type { ChartData } from "@/types"
|
import type { ChartData } from "@/types"
|
||||||
import { Separator } from "../ui/separator"
|
import { Separator } from "../ui/separator"
|
||||||
import { ChartType, Unit } from "@/lib/enums"
|
|
||||||
import { useYAxisWidth } from "./hooks"
|
import { useYAxisWidth } from "./hooks"
|
||||||
|
|
||||||
export default memo(function ContainerChart({
|
export default memo(function ContainerChart({
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro"
|
||||||
|
import { memo } from "react"
|
||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
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 { Unit } from "@/lib/enums"
|
||||||
|
import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils"
|
||||||
|
import type { ChartData } from "@/types"
|
||||||
import { useYAxisWidth } from "./hooks"
|
import { useYAxisWidth } from "./hooks"
|
||||||
|
|
||||||
export default memo(function DiskChart({
|
export default memo(function DiskChart({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { memo, useMemo } from "react"
|
||||||
import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
|
import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
ChartLegend,
|
ChartLegend,
|
||||||
@@ -8,9 +8,8 @@ import {
|
|||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
xAxis,
|
xAxis,
|
||||||
} from "@/components/ui/chart"
|
} from "@/components/ui/chart"
|
||||||
import { cn, formatShortDate, toFixedFloat, decimalString, chartMargin } from "@/lib/utils"
|
import { chartMargin, cn, decimalString, formatShortDate, toFixedFloat } from "@/lib/utils"
|
||||||
import { ChartData } from "@/types"
|
import type { ChartData } from "@/types"
|
||||||
import { memo, useMemo } from "react"
|
|
||||||
import { useYAxisWidth } from "./hooks"
|
import { useYAxisWidth } from "./hooks"
|
||||||
|
|
||||||
export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData }) {
|
export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData }) {
|
||||||
@@ -27,10 +26,10 @@ export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData
|
|||||||
colors: Record<string, string>
|
colors: Record<string, string>
|
||||||
}
|
}
|
||||||
const powerSums = {} as Record<string, number>
|
const powerSums = {} as Record<string, number>
|
||||||
for (let data of chartData.systemStats) {
|
for (const data of chartData.systemStats) {
|
||||||
let newData = { created: data.created } as Record<string, number | string>
|
const newData = { created: data.created } as Record<string, number | string>
|
||||||
|
|
||||||
for (let gpu of Object.values(data.stats?.g ?? {})) {
|
for (const gpu of Object.values(data.stats?.g ?? {})) {
|
||||||
if (gpu.p) {
|
if (gpu.p) {
|
||||||
const name = gpu.n
|
const name = gpu.n
|
||||||
newData[name] = gpu.p
|
newData[name] = gpu.p
|
||||||
@@ -40,7 +39,7 @@ export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData
|
|||||||
newChartData.data.push(newData)
|
newChartData.data.push(newData)
|
||||||
}
|
}
|
||||||
const keys = Object.keys(powerSums).sort((a, b) => powerSums[b] - powerSums[a])
|
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%)`
|
newChartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)`
|
||||||
}
|
}
|
||||||
return newChartData
|
return newChartData
|
||||||
@@ -67,7 +66,7 @@ export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData
|
|||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
tickFormatter={(value) => {
|
tickFormatter={(value) => {
|
||||||
const val = toFixedFloat(value, 2)
|
const val = toFixedFloat(value, 2)
|
||||||
return updateYAxisWidth(val + "W")
|
return updateYAxisWidth(`${val}W`)
|
||||||
}}
|
}}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
@@ -76,12 +75,12 @@ export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData
|
|||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
animationDuration={150}
|
animationDuration={150}
|
||||||
// @ts-ignore
|
// @ts-expect-error
|
||||||
itemSorter={(a, b) => b.value - a.value}
|
itemSorter={(a, b) => b.value - a.value}
|
||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
contentFormatter={(item) => decimalString(item.value) + "W"}
|
contentFormatter={(item) => `${decimalString(item.value)}W`}
|
||||||
// indicator="line"
|
// indicator="line"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { ChartConfig } from "@/components/ui/chart"
|
import type { ChartConfig } from "@/components/ui/chart"
|
||||||
import { ChartData } from "@/types"
|
import type { ChartData, SystemStats, SystemStatsRecord } from "@/types"
|
||||||
|
|
||||||
/** Chart configurations for CPU, memory, and network usage charts */
|
/** Chart configurations for CPU, memory, and network usage charts */
|
||||||
export interface ContainerChartConfigs {
|
export interface ContainerChartConfigs {
|
||||||
@@ -105,3 +105,21 @@ export function useYAxisWidth() {
|
|||||||
}
|
}
|
||||||
return { yAxisWidth, updateYAxisWidth }
|
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,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
110
internal/site/src/components/charts/line-chart.tsx
Normal file
110
internal/site/src/components/charts/line-chart.tsx
Normal file
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<ChartContainer
|
||||||
|
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
||||||
|
"opacity-100": yAxisWidth,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<LineChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<YAxis
|
||||||
|
direction="ltr"
|
||||||
|
orientation={chartData.orientation}
|
||||||
|
className="tracking-tighter"
|
||||||
|
width={yAxisWidth}
|
||||||
|
domain={domain ?? [0, max ?? "auto"]}
|
||||||
|
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
{xAxis(chartData)}
|
||||||
|
<ChartTooltip
|
||||||
|
animationEasing="ease-out"
|
||||||
|
animationDuration={150}
|
||||||
|
// @ts-expect-error
|
||||||
|
itemSorter={itemSorter}
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
|
contentFormatter={contentFormatter}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{dataPoints?.map((dataPoint) => {
|
||||||
|
let { color } = dataPoint
|
||||||
|
if (typeof color === "number") {
|
||||||
|
color = `var(--chart-${color})`
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Line
|
||||||
|
key={dataPoint.label}
|
||||||
|
dataKey={dataPoint.dataKey}
|
||||||
|
name={dataPoint.label}
|
||||||
|
type="monotoneX"
|
||||||
|
dot={false}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke={color}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{legend && <ChartLegend content={<ChartLegendContent />} />}
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}, [chartData.systemStats.at(-1), yAxisWidth, maxToggled])
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { memo } from "react"
|
||||||
import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
|
import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
ChartLegend,
|
ChartLegend,
|
||||||
@@ -8,10 +9,8 @@ import {
|
|||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
xAxis,
|
xAxis,
|
||||||
} from "@/components/ui/chart"
|
} from "@/components/ui/chart"
|
||||||
import { cn, formatShortDate, toFixedFloat, decimalString, chartMargin } from "@/lib/utils"
|
import { chartMargin, cn, decimalString, formatShortDate, toFixedFloat } from "@/lib/utils"
|
||||||
import { ChartData, SystemStats } from "@/types"
|
import type { ChartData, SystemStats } from "@/types"
|
||||||
import { memo } from "react"
|
|
||||||
import { t } from "@lingui/core/macro"
|
|
||||||
import { useYAxisWidth } from "./hooks"
|
import { useYAxisWidth } from "./hooks"
|
||||||
|
|
||||||
export default memo(function LoadAverageChart({ chartData }: { chartData: ChartData }) {
|
export default memo(function LoadAverageChart({ chartData }: { chartData: ChartData }) {
|
||||||
@@ -60,7 +59,7 @@ export default memo(function LoadAverageChart({ chartData }: { chartData: ChartD
|
|||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
animationDuration={150}
|
animationDuration={150}
|
||||||
// @ts-ignore
|
// @ts-expect-error
|
||||||
// itemSorter={(a, b) => b.value - a.value}
|
// itemSorter={(a, b) => b.value - a.value}
|
||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro"
|
||||||
|
import { memo } from "react"
|
||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
||||||
import { cn, decimalString, formatShortDate, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils"
|
|
||||||
import { memo } from "react"
|
|
||||||
import { ChartData } from "@/types"
|
|
||||||
import { useLingui } from "@lingui/react/macro"
|
|
||||||
import { Unit } from "@/lib/enums"
|
import { Unit } from "@/lib/enums"
|
||||||
|
import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils"
|
||||||
|
import type { ChartData } from "@/types"
|
||||||
import { useYAxisWidth } from "./hooks"
|
import { useYAxisWidth } from "./hooks"
|
||||||
|
|
||||||
export default memo(function MemChart({ chartData, showMax }: { chartData: ChartData; showMax: boolean }) {
|
export default memo(function MemChart({ chartData, showMax }: { chartData: ChartData; showMax: boolean }) {
|
||||||
@@ -53,7 +53,7 @@ export default memo(function MemChart({ chartData, showMax }: { chartData: Chart
|
|||||||
animationDuration={150}
|
animationDuration={150}
|
||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
// @ts-ignore
|
// @ts-expect-error
|
||||||
itemSorter={(a, b) => a.order - b.order}
|
itemSorter={(a, b) => a.order - b.order}
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
contentFormatter={({ value }) => {
|
contentFormatter={({ value }) => {
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
import { memo } from "react"
|
||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
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 { $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"
|
import { useYAxisWidth } from "./hooks"
|
||||||
|
|
||||||
export default memo(function SwapChart({ chartData }: { chartData: ChartData }) {
|
export default memo(function SwapChart({ chartData }: { chartData: ChartData }) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
import { memo, useMemo } from "react"
|
||||||
import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
|
import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
ChartLegend,
|
ChartLegend,
|
||||||
@@ -8,11 +9,9 @@ import {
|
|||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
xAxis,
|
xAxis,
|
||||||
} from "@/components/ui/chart"
|
} 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 { $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"
|
import { useYAxisWidth } from "./hooks"
|
||||||
|
|
||||||
export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) {
|
export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) {
|
||||||
@@ -31,18 +30,18 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
|
|||||||
colors: Record<string, string>
|
colors: Record<string, string>
|
||||||
}
|
}
|
||||||
const tempSums = {} as Record<string, number>
|
const tempSums = {} as Record<string, number>
|
||||||
for (let data of chartData.systemStats) {
|
for (const data of chartData.systemStats) {
|
||||||
let newData = { created: data.created } as Record<string, number | string>
|
const newData = { created: data.created } as Record<string, number | string>
|
||||||
let keys = Object.keys(data.stats?.t ?? {})
|
const keys = Object.keys(data.stats?.t ?? {})
|
||||||
for (let i = 0; i < keys.length; i++) {
|
for (let i = 0; i < keys.length; i++) {
|
||||||
let key = keys[i]
|
const key = keys[i]
|
||||||
newData[key] = data.stats.t![key]
|
newData[key] = data.stats.t![key]
|
||||||
tempSums[key] = (tempSums[key] ?? 0) + newData[key]
|
tempSums[key] = (tempSums[key] ?? 0) + newData[key]
|
||||||
}
|
}
|
||||||
newChartData.data.push(newData)
|
newChartData.data.push(newData)
|
||||||
}
|
}
|
||||||
const keys = Object.keys(tempSums).sort((a, b) => tempSums[b] - tempSums[a])
|
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%)`
|
newChartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)`
|
||||||
}
|
}
|
||||||
return newChartData
|
return newChartData
|
||||||
@@ -78,7 +77,7 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
|
|||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
animationDuration={150}
|
animationDuration={150}
|
||||||
// @ts-ignore
|
// @ts-expect-error
|
||||||
itemSorter={(a, b) => b.value - a.value}
|
itemSorter={(a, b) => b.value - a.value}
|
||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
@@ -93,7 +92,7 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
|
|||||||
/>
|
/>
|
||||||
{colors.map((key) => {
|
{colors.map((key) => {
|
||||||
const filtered = filter && !key.toLowerCase().includes(filter.toLowerCase())
|
const filtered = filter && !key.toLowerCase().includes(filter.toLowerCase())
|
||||||
let strokeOpacity = filtered ? 0.1 : 1
|
const strokeOpacity = filtered ? 0.1 : 1
|
||||||
return (
|
return (
|
||||||
<Line
|
<Line
|
||||||
key={key}
|
key={key}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import { Input } from "../ui/input"
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
|
||||||
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"
|
||||||
|
|
||||||
type ChartTimeData = {
|
type ChartTimeData = {
|
||||||
time: number
|
time: number
|
||||||
@@ -564,13 +565,13 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
dataPoints={[
|
dataPoints={[
|
||||||
{
|
{
|
||||||
label: t({ message: "Write", comment: "Disk write" }),
|
label: t({ message: "Write", comment: "Disk write" }),
|
||||||
dataKey: ({ stats }) => (showMax ? stats?.dwm : stats?.dw),
|
dataKey: ({ stats }: SystemStatsRecord) => (showMax ? stats?.dwm : stats?.dw),
|
||||||
color: 3,
|
color: 3,
|
||||||
opacity: 0.3,
|
opacity: 0.3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t({ message: "Read", comment: "Disk read" }),
|
label: t({ message: "Read", comment: "Disk read" }),
|
||||||
dataKey: ({ stats }) => (showMax ? stats?.drm : stats?.dr),
|
dataKey: ({ stats }: SystemStatsRecord) => (showMax ? stats?.drm : stats?.dr),
|
||||||
color: 1,
|
color: 1,
|
||||||
opacity: 0.3,
|
opacity: 0.3,
|
||||||
},
|
},
|
||||||
@@ -590,7 +591,12 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
grid={grid}
|
grid={grid}
|
||||||
title={t`Bandwidth`}
|
title={t`Bandwidth`}
|
||||||
cornerEl={maxValSelect}
|
cornerEl={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{maxValSelect}
|
||||||
|
<NetworkSheet chartData={chartData} dataEmpty={dataEmpty} grid={grid} maxValues={maxValues} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
description={t`Network traffic of public interfaces`}
|
description={t`Network traffic of public interfaces`}
|
||||||
>
|
>
|
||||||
<AreaChartDefault
|
<AreaChartDefault
|
||||||
@@ -600,7 +606,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
{
|
{
|
||||||
label: t`Sent`,
|
label: t`Sent`,
|
||||||
// use bytes if available, otherwise multiply old MB (can remove in future)
|
// use bytes if available, otherwise multiply old MB (can remove in future)
|
||||||
dataKey(data) {
|
dataKey(data: SystemStatsRecord) {
|
||||||
if (showMax) {
|
if (showMax) {
|
||||||
return data?.stats?.bm?.[0] ?? (data?.stats?.nsm ?? 0) * 1024 * 1024
|
return data?.stats?.bm?.[0] ?? (data?.stats?.nsm ?? 0) * 1024 * 1024
|
||||||
}
|
}
|
||||||
@@ -611,7 +617,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t`Received`,
|
label: t`Received`,
|
||||||
dataKey(data) {
|
dataKey(data: SystemStatsRecord) {
|
||||||
if (showMax) {
|
if (showMax) {
|
||||||
return data?.stats?.bm?.[1] ?? (data?.stats?.nrm ?? 0) * 1024 * 1024
|
return data?.stats?.bm?.[1] ?? (data?.stats?.nrm ?? 0) * 1024 * 1024
|
||||||
}
|
}
|
||||||
@@ -620,7 +626,9 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
color: 2,
|
color: 2,
|
||||||
opacity: 0.2,
|
opacity: 0.2,
|
||||||
},
|
},
|
||||||
]}
|
]
|
||||||
|
// try to place the lesser number in front for better visibility
|
||||||
|
.sort(() => (systemStats.at(-1)?.stats.b?.[1] ?? 0) - (systemStats.at(-1)?.stats.b?.[0] ?? 0))}
|
||||||
tickFormatter={(val) => {
|
tickFormatter={(val) => {
|
||||||
const { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
|
const { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
|
||||||
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||||
@@ -674,6 +682,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
grid={grid}
|
grid={grid}
|
||||||
title={t`Load Average`}
|
title={t`Load Average`}
|
||||||
description={t`System load averages over time`}
|
description={t`System load averages over time`}
|
||||||
|
legend={true}
|
||||||
>
|
>
|
||||||
<LoadAverageChart chartData={chartData} />
|
<LoadAverageChart chartData={chartData} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
@@ -687,6 +696,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
title={t`Temperature`}
|
title={t`Temperature`}
|
||||||
description={t`Temperatures of system sensors`}
|
description={t`Temperatures of system sensors`}
|
||||||
cornerEl={<FilterBar store={$temperatureFilter} />}
|
cornerEl={<FilterBar store={$temperatureFilter} />}
|
||||||
|
legend={Object.keys(systemStats.at(-1)?.stats.t ?? {}).length < 12}
|
||||||
>
|
>
|
||||||
<TemperatureChart chartData={chartData} />
|
<TemperatureChart chartData={chartData} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
@@ -879,7 +889,7 @@ function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilt
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Input placeholder={t`Filter...`} className="ps-4 pe-8" onChange={handleChange} ref={inputRef} />
|
<Input placeholder={t`Filter...`} className="ps-4 pe-8 w-full sm:w-44" onChange={handleChange} ref={inputRef} />
|
||||||
{containerFilter && (
|
{containerFilter && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -905,7 +915,7 @@ const SelectAvgMax = memo(({ max }: { max: boolean }) => {
|
|||||||
const Icon = max ? ChartMax : ChartAverage
|
const Icon = max ? ChartMax : ChartAverage
|
||||||
return (
|
return (
|
||||||
<Select value={max ? "max" : "avg"} onValueChange={(e) => $maxValues.set(e === "max")}>
|
<Select value={max ? "max" : "avg"} onValueChange={(e) => $maxValues.set(e === "max")}>
|
||||||
<SelectTrigger className="relative ps-10 pe-5">
|
<SelectTrigger className="relative ps-10 pe-5 w-full sm:w-44">
|
||||||
<Icon className="h-4 w-4 absolute start-4 top-1/2 -translate-y-1/2 opacity-85" />
|
<Icon className="h-4 w-4 absolute start-4 top-1/2 -translate-y-1/2 opacity-85" />
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -921,13 +931,15 @@ const SelectAvgMax = memo(({ max }: { max: boolean }) => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
function ChartCard({
|
export function ChartCard({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
children,
|
children,
|
||||||
grid,
|
grid,
|
||||||
empty,
|
empty,
|
||||||
cornerEl,
|
cornerEl,
|
||||||
|
legend,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
@@ -935,17 +947,24 @@ function ChartCard({
|
|||||||
grid?: boolean
|
grid?: boolean
|
||||||
empty?: boolean
|
empty?: boolean
|
||||||
cornerEl?: JSX.Element | null
|
cornerEl?: JSX.Element | null
|
||||||
|
legend?: boolean
|
||||||
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { isIntersecting, ref } = useIntersectionObserver()
|
const { isIntersecting, ref } = useIntersectionObserver()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={cn("pb-2 sm:pb-4 odd:last-of-type:col-span-full", { "col-span-full": !grid })} ref={ref}>
|
<Card
|
||||||
|
className={cn("pb-2 sm:pb-4 odd:last-of-type:col-span-full min-h-full", { "col-span-full": !grid }, className)}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
<CardHeader className="pb-5 pt-4 gap-1 relative max-sm:py-3 max-sm:px-4">
|
<CardHeader className="pb-5 pt-4 gap-1 relative max-sm:py-3 max-sm:px-4">
|
||||||
<CardTitle className="text-xl sm:text-2xl">{title}</CardTitle>
|
<CardTitle className="text-xl sm:text-2xl">{title}</CardTitle>
|
||||||
<CardDescription>{description}</CardDescription>
|
<CardDescription>{description}</CardDescription>
|
||||||
{cornerEl && <div className="relative py-1 block sm:w-44 sm:absolute sm:top-3.5 sm:end-3.5">{cornerEl}</div>}
|
{cornerEl && (
|
||||||
|
<div className="relative py-1 grid sm:justify-end sm:absolute sm:top-3.5 sm:end-3.5">{cornerEl}</div>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<div className="ps-0 w-[calc(100%-1.5em)] h-48 md:h-52 relative group">
|
<div className={cn("ps-0 w-[calc(100%-1.5em)] relative group", legend ? "h-54 md:h-56" : "h-48 md:h-52")}>
|
||||||
{
|
{
|
||||||
<Spinner
|
<Spinner
|
||||||
msg={empty ? t`Waiting for enough records to display` : undefined}
|
msg={empty ? t`Waiting for enough records to display` : undefined}
|
||||||
|
|||||||
149
internal/site/src/components/routes/system/network-sheet.tsx
Normal file
149
internal/site/src/components/routes/system/network-sheet.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
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 { useNetworkInterfaces } from "@/components/charts/hooks"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
|
||||||
|
import { $userSettings } from "@/lib/stores"
|
||||||
|
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||||
|
import type { ChartData } from "@/types"
|
||||||
|
import { ChartCard } from "../system"
|
||||||
|
|
||||||
|
export default memo(function NetworkSheet({
|
||||||
|
chartData,
|
||||||
|
dataEmpty,
|
||||||
|
grid,
|
||||||
|
maxValues,
|
||||||
|
}: {
|
||||||
|
chartData: ChartData
|
||||||
|
dataEmpty: boolean
|
||||||
|
grid: boolean
|
||||||
|
maxValues: boolean
|
||||||
|
}) {
|
||||||
|
const [netInterfacesOpen, setNetInterfacesOpen] = useState(false)
|
||||||
|
const userSettings = useStore($userSettings)
|
||||||
|
const netInterfaces = useNetworkInterfaces(chartData.systemStats.at(-1)?.stats?.ni ?? {})
|
||||||
|
const showNetLegend = netInterfaces.length > 0
|
||||||
|
const hasOpened = useRef(false)
|
||||||
|
|
||||||
|
if (netInterfacesOpen && !hasOpened.current) {
|
||||||
|
hasOpened.current = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!netInterfaces.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={netInterfacesOpen} onOpenChange={setNetInterfacesOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon" className="shrink-0">
|
||||||
|
<MoreHorizontalIcon />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
{hasOpened.current && (
|
||||||
|
<SheetContent className="overflow-auto w-200 !max-w-full p-4 sm:p-6">
|
||||||
|
<ChartTimeSelect className="w-[calc(100%-1.5em)]" />
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={t`Download`}
|
||||||
|
description={t`Network traffic of public interfaces`}
|
||||||
|
legend={showNetLegend}
|
||||||
|
className="min-h-auto"
|
||||||
|
>
|
||||||
|
<AreaChartDefault
|
||||||
|
chartData={chartData}
|
||||||
|
maxToggled={maxValues}
|
||||||
|
itemSorter={(a, b) => b.value - a.value}
|
||||||
|
dataPoints={netInterfaces.data(1)}
|
||||||
|
legend={showNetLegend}
|
||||||
|
tickFormatter={(val) => {
|
||||||
|
const { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
|
||||||
|
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||||
|
}}
|
||||||
|
contentFormatter={({ value }) => {
|
||||||
|
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitNet, false)
|
||||||
|
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={t`Upload`}
|
||||||
|
description={t`Network traffic of public interfaces`}
|
||||||
|
legend={showNetLegend}
|
||||||
|
className="min-h-auto"
|
||||||
|
>
|
||||||
|
<AreaChartDefault
|
||||||
|
chartData={chartData}
|
||||||
|
maxToggled={maxValues}
|
||||||
|
itemSorter={(a, b) => b.value - a.value}
|
||||||
|
legend={showNetLegend}
|
||||||
|
dataPoints={netInterfaces.data(0)}
|
||||||
|
tickFormatter={(val) => {
|
||||||
|
const { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
|
||||||
|
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||||
|
}}
|
||||||
|
contentFormatter={({ value }) => {
|
||||||
|
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitNet, false)
|
||||||
|
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={t`Cumulative Download`}
|
||||||
|
description={t`Total data received for each interface`}
|
||||||
|
legend={showNetLegend}
|
||||||
|
className="min-h-auto"
|
||||||
|
>
|
||||||
|
<AreaChartDefault
|
||||||
|
chartData={chartData}
|
||||||
|
legend={showNetLegend}
|
||||||
|
dataPoints={netInterfaces.data(3)}
|
||||||
|
tickFormatter={(val) => {
|
||||||
|
const { value, unit } = formatBytes(val, false, userSettings.unitNet, false)
|
||||||
|
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||||
|
}}
|
||||||
|
contentFormatter={({ value }) => {
|
||||||
|
const { value: convertedValue, unit } = formatBytes(value, false, userSettings.unitNet, false)
|
||||||
|
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={t`Cumulative Upload`}
|
||||||
|
description={t`Total data sent for each interface`}
|
||||||
|
legend={showNetLegend}
|
||||||
|
className="min-h-auto"
|
||||||
|
>
|
||||||
|
<AreaChartDefault
|
||||||
|
chartData={chartData}
|
||||||
|
legend={showNetLegend}
|
||||||
|
dataPoints={netInterfaces.data(2)}
|
||||||
|
tickFormatter={(val) => {
|
||||||
|
const { value, unit } = formatBytes(val, false, userSettings.unitNet, false)
|
||||||
|
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||||
|
}}
|
||||||
|
contentFormatter={({ value }) => {
|
||||||
|
const { value: convertedValue, unit } = formatBytes(value, false, userSettings.unitNet, false)
|
||||||
|
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
</SheetContent>
|
||||||
|
)}
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
})
|
||||||
2
internal/site/src/types.d.ts
vendored
2
internal/site/src/types.d.ts
vendored
@@ -141,6 +141,8 @@ export interface SystemStats {
|
|||||||
g?: Record<string, GPUData>
|
g?: Record<string, GPUData>
|
||||||
/** battery percent and state */
|
/** battery percent and state */
|
||||||
bat?: [number, BatteryState]
|
bat?: [number, BatteryState]
|
||||||
|
/** network interfaces [upload bytes, download bytes, total upload bytes, total download bytes] */
|
||||||
|
ni?: Record<string, [number, number, number, number]>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GPUData {
|
export interface GPUData {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
## 0.12.8
|
## 0.12.8
|
||||||
|
|
||||||
|
- Add per-interface network traffic charts. (#926)
|
||||||
|
|
||||||
|
- Add cumulative network traffic charts. (#926)
|
||||||
|
|
||||||
- Add setting for time format (12h / 24h). (#424)
|
- Add setting for time format (12h / 24h). (#424)
|
||||||
|
|
||||||
- Add experimental one-time password (OTP) support.
|
- Add experimental one-time password (OTP) support.
|
||||||
|
|||||||
Reference in New Issue
Block a user