mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-25 15:06:17 +01:00
Compare commits
4 Commits
split-inte
...
4e0ca7c2ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e0ca7c2ba | ||
|
|
a9e7bcd37f | ||
|
|
4635f24fb2 | ||
|
|
3e73399b87 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,4 +20,3 @@ __debug_*
|
|||||||
agent/lhm/obj
|
agent/lhm/obj
|
||||||
agent/lhm/bin
|
agent/lhm/bin
|
||||||
dockerfile_agent_dev
|
dockerfile_agent_dev
|
||||||
.vite
|
|
||||||
@@ -22,23 +22,23 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Agent struct {
|
type Agent struct {
|
||||||
sync.Mutex // Used to lock agent while collecting data
|
sync.Mutex // Used to lock agent while collecting data
|
||||||
debug bool // true if LOG_LEVEL is set to debug
|
debug bool // true if LOG_LEVEL is set to debug
|
||||||
zfs bool // true if system has arcstats
|
zfs bool // true if system has arcstats
|
||||||
memCalc string // Memory calculation formula
|
memCalc string // Memory calculation formula
|
||||||
fsNames []string // List of filesystem device names being monitored
|
fsNames []string // List of filesystem device names being monitored
|
||||||
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
||||||
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
||||||
netIoStats map[string]system.NetIoStats // Keeps track of per-interface bandwidth usage
|
netIoStats system.NetIoStats // Keeps track of bandwidth usage
|
||||||
dockerManager *dockerManager // Manages Docker API requests
|
dockerManager *dockerManager // Manages Docker API requests
|
||||||
sensorConfig *SensorConfig // Sensors config
|
sensorConfig *SensorConfig // Sensors config
|
||||||
systemInfo system.Info // Host system info
|
systemInfo system.Info // Host system info
|
||||||
gpuManager *GPUManager // Manages GPU data
|
gpuManager *GPUManager // Manages GPU data
|
||||||
cache *SessionCache // Cache for system stats based on primary session ID
|
cache *SessionCache // Cache for system stats based on primary session ID
|
||||||
connectionManager *ConnectionManager // Channel to signal connection events
|
connectionManager *ConnectionManager // Channel to signal connection events
|
||||||
server *ssh.Server // SSH server
|
server *ssh.Server // SSH server
|
||||||
dataDir string // Directory for persisting data
|
dataDir string // Directory for persisting data
|
||||||
keys []gossh.PublicKey // SSH public keys
|
keys []gossh.PublicKey // SSH public keys
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgent creates a new agent with the given data directory for persisting data.
|
// NewAgent creates a new agent with the given data directory for persisting data.
|
||||||
|
|||||||
@@ -20,9 +20,8 @@ func HasReadableBattery() bool {
|
|||||||
}
|
}
|
||||||
haveCheckedBattery = true
|
haveCheckedBattery = true
|
||||||
bat, err := battery.Get(0)
|
bat, err := battery.Get(0)
|
||||||
if err == nil && bat != nil {
|
systemHasBattery = err == nil && bat != nil && bat.Design != 0 && bat.Full != 0
|
||||||
systemHasBattery = true
|
if !systemHasBattery {
|
||||||
} else {
|
|
||||||
slog.Debug("No battery found", "err", err)
|
slog.Debug("No battery found", "err", err)
|
||||||
}
|
}
|
||||||
return systemHasBattery
|
return systemHasBattery
|
||||||
|
|||||||
80
agent/deltatracker/deltatracker.go
Normal file
80
agent/deltatracker/deltatracker.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
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,20 +1,86 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"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)
|
||||||
// reset network I/O stats per interface
|
|
||||||
a.netIoStats = make(map[string]system.NetIoStats, 0)
|
|
||||||
|
|
||||||
// map of network interface names passed in via NICS env var
|
// map of network interface names passed in via NICS env var
|
||||||
var nicsMap map[string]struct{}
|
var nicsMap map[string]struct{}
|
||||||
@@ -26,10 +92,13 @@ func (a *Agent) initializeNetIoStats() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reset network I/O stats
|
||||||
|
a.netIoStats.BytesSent = 0
|
||||||
|
a.netIoStats.BytesRecv = 0
|
||||||
|
|
||||||
// get intial network I/O stats
|
// get intial network I/O stats
|
||||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||||
now := time.Now()
|
a.netIoStats.Time = time.Now()
|
||||||
|
|
||||||
for _, v := range netIO {
|
for _, v := range netIO {
|
||||||
switch {
|
switch {
|
||||||
// skip if nics exists and the interface is not in the list
|
// skip if nics exists and the interface is not in the list
|
||||||
@@ -44,15 +113,10 @@ func (a *Agent) initializeNetIoStats() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv)
|
slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv)
|
||||||
|
a.netIoStats.BytesSent += v.BytesSent
|
||||||
|
a.netIoStats.BytesRecv += v.BytesRecv
|
||||||
// store as a valid network interface
|
// store as a valid network interface
|
||||||
a.netInterfaces[v.Name] = struct{}{}
|
a.netInterfaces[v.Name] = struct{}{}
|
||||||
// initialize per-interface stats
|
|
||||||
a.netIoStats[v.Name] = system.NetIoStats{
|
|
||||||
BytesRecv: v.BytesRecv,
|
|
||||||
BytesSent: v.BytesSent,
|
|
||||||
Time: now,
|
|
||||||
Name: v.Name,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
189
agent/system.go
189
agent/system.go
@@ -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,87 +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)
|
|
||||||
a.initializeNetIoStats()
|
|
||||||
}
|
|
||||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
// pre-allocate maps with known capacity
|
|
||||||
interfaceCount := len(a.netInterfaces)
|
|
||||||
if systemStats.NetworkInterfaces == nil || len(systemStats.NetworkInterfaces) != interfaceCount {
|
|
||||||
systemStats.NetworkInterfaces = make(map[string]system.NetworkInterfaceStats, interfaceCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
var totalSent, totalRecv float64
|
|
||||||
|
|
||||||
// single pass through interfaces
|
|
||||||
for _, v := range netIO {
|
|
||||||
// skip if not in valid network interfaces list
|
|
||||||
if _, exists := a.netInterfaces[v.Name]; !exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// get previous stats for this interface
|
|
||||||
prevStats, exists := a.netIoStats[v.Name]
|
|
||||||
var networkSentPs, networkRecvPs float64
|
|
||||||
|
|
||||||
if exists {
|
|
||||||
secondsElapsed := time.Since(prevStats.Time).Seconds()
|
|
||||||
if secondsElapsed > 0 {
|
|
||||||
// direct calculation to MB/s, avoiding intermediate bytes/sec
|
|
||||||
networkSentPs = bytesToMegabytes(float64(v.BytesSent-prevStats.BytesSent) / secondsElapsed)
|
|
||||||
networkRecvPs = bytesToMegabytes(float64(v.BytesRecv-prevStats.BytesRecv) / secondsElapsed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// accumulate totals
|
|
||||||
totalSent += networkSentPs
|
|
||||||
totalRecv += networkRecvPs
|
|
||||||
|
|
||||||
// store per-interface stats
|
|
||||||
systemStats.NetworkInterfaces[v.Name] = system.NetworkInterfaceStats{
|
|
||||||
NetworkSent: networkSentPs,
|
|
||||||
NetworkRecv: networkRecvPs,
|
|
||||||
TotalBytesSent: v.BytesSent,
|
|
||||||
TotalBytesRecv: v.BytesRecv,
|
|
||||||
}
|
|
||||||
|
|
||||||
// update previous stats (reuse existing struct if possible)
|
|
||||||
if prevStats.Name == v.Name {
|
|
||||||
prevStats.BytesRecv = v.BytesRecv
|
|
||||||
prevStats.BytesSent = v.BytesSent
|
|
||||||
prevStats.PacketsSent = v.PacketsSent
|
|
||||||
prevStats.PacketsRecv = v.PacketsRecv
|
|
||||||
prevStats.Time = now
|
|
||||||
a.netIoStats[v.Name] = prevStats
|
|
||||||
} else {
|
|
||||||
a.netIoStats[v.Name] = system.NetIoStats{
|
|
||||||
BytesRecv: v.BytesRecv,
|
|
||||||
BytesSent: v.BytesSent,
|
|
||||||
PacketsSent: v.PacketsSent,
|
|
||||||
PacketsRecv: v.PacketsRecv,
|
|
||||||
Time: now,
|
|
||||||
Name: v.Name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// add check for issue (#150) where sent is a massive number
|
|
||||||
if totalSent > 10_000 || totalRecv > 10_000 {
|
|
||||||
slog.Warn("Invalid net stats. Resetting.", "sent", totalSent, "recv", totalRecv)
|
|
||||||
// reset network I/O stats
|
|
||||||
a.initializeNetIoStats()
|
|
||||||
} else {
|
|
||||||
systemStats.NetworkSent = totalSent
|
|
||||||
systemStats.NetworkRecv = totalRecv
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// connection counts
|
|
||||||
a.updateConnectionCounts(&systemStats)
|
|
||||||
|
|
||||||
// temperatures
|
// temperatures
|
||||||
// TODO: maybe refactor to methods on systemStats
|
// TODO: maybe refactor to methods on systemStats
|
||||||
@@ -302,109 +224,14 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
a.systemInfo.MemPct = systemStats.MemPct
|
a.systemInfo.MemPct = systemStats.MemPct
|
||||||
a.systemInfo.DiskPct = systemStats.DiskPct
|
a.systemInfo.DiskPct = systemStats.DiskPct
|
||||||
a.systemInfo.Uptime, _ = host.Uptime()
|
a.systemInfo.Uptime, _ = host.Uptime()
|
||||||
|
// TODO: in future release, remove MB bandwidth values in favor of bytes
|
||||||
// Sum all per-interface network sent/recv and assign to systemInfo
|
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
|
||||||
var totalSent, totalRecv float64
|
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
|
||||||
for _, iface := range systemStats.NetworkInterfaces {
|
|
||||||
totalSent += iface.NetworkSent
|
|
||||||
totalRecv += iface.NetworkRecv
|
|
||||||
}
|
|
||||||
a.systemInfo.NetworkSent = twoDecimals(totalSent)
|
|
||||||
a.systemInfo.NetworkRecv = twoDecimals(totalRecv)
|
|
||||||
slog.Debug("sysinfo", "data", a.systemInfo)
|
slog.Debug("sysinfo", "data", a.systemInfo)
|
||||||
|
|
||||||
return systemStats
|
return systemStats
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) updateConnectionCounts(systemStats *system.Stats) {
|
|
||||||
// Get IPv4 connections
|
|
||||||
connectionsIPv4, err := psutilNet.Connections("inet")
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug("Failed to get IPv4 connection stats", "err", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get IPv6 connections
|
|
||||||
connectionsIPv6, err := psutilNet.Connections("inet6")
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug("Failed to get IPv6 connection stats", "err", err)
|
|
||||||
// Continue with IPv4 only if IPv6 fails
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize Nets map if needed
|
|
||||||
if systemStats.Nets == nil {
|
|
||||||
systemStats.Nets = make(map[string]float64)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count IPv4 connection states
|
|
||||||
connStatsIPv4 := map[string]int{
|
|
||||||
"established": 0,
|
|
||||||
"listen": 0,
|
|
||||||
"time_wait": 0,
|
|
||||||
"close_wait": 0,
|
|
||||||
"syn_recv": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, conn := range connectionsIPv4 {
|
|
||||||
// Only count TCP connections (Type 1 = SOCK_STREAM)
|
|
||||||
if conn.Type == 1 {
|
|
||||||
switch strings.ToUpper(conn.Status) {
|
|
||||||
case "ESTABLISHED":
|
|
||||||
connStatsIPv4["established"]++
|
|
||||||
case "LISTEN":
|
|
||||||
connStatsIPv4["listen"]++
|
|
||||||
case "TIME_WAIT":
|
|
||||||
connStatsIPv4["time_wait"]++
|
|
||||||
case "CLOSE_WAIT":
|
|
||||||
connStatsIPv4["close_wait"]++
|
|
||||||
case "SYN_RECV":
|
|
||||||
connStatsIPv4["syn_recv"]++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count IPv6 connection states
|
|
||||||
connStatsIPv6 := map[string]int{
|
|
||||||
"established": 0,
|
|
||||||
"listen": 0,
|
|
||||||
"time_wait": 0,
|
|
||||||
"close_wait": 0,
|
|
||||||
"syn_recv": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, conn := range connectionsIPv6 {
|
|
||||||
// Only count TCP connections (Type 1 = SOCK_STREAM)
|
|
||||||
if conn.Type == 1 {
|
|
||||||
switch strings.ToUpper(conn.Status) {
|
|
||||||
case "ESTABLISHED":
|
|
||||||
connStatsIPv6["established"]++
|
|
||||||
case "LISTEN":
|
|
||||||
connStatsIPv6["listen"]++
|
|
||||||
case "TIME_WAIT":
|
|
||||||
connStatsIPv6["time_wait"]++
|
|
||||||
case "CLOSE_WAIT":
|
|
||||||
connStatsIPv6["close_wait"]++
|
|
||||||
case "SYN_RECV":
|
|
||||||
connStatsIPv6["syn_recv"]++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add IPv4 connection counts to Nets
|
|
||||||
systemStats.Nets["conn_established"] = float64(connStatsIPv4["established"])
|
|
||||||
systemStats.Nets["conn_listen"] = float64(connStatsIPv4["listen"])
|
|
||||||
systemStats.Nets["conn_timewait"] = float64(connStatsIPv4["time_wait"])
|
|
||||||
systemStats.Nets["conn_closewait"] = float64(connStatsIPv4["close_wait"])
|
|
||||||
systemStats.Nets["conn_synrecv"] = float64(connStatsIPv4["syn_recv"])
|
|
||||||
|
|
||||||
// Add IPv6 connection counts to Nets
|
|
||||||
systemStats.Nets["conn6_established"] = float64(connStatsIPv6["established"])
|
|
||||||
systemStats.Nets["conn6_listen"] = float64(connStatsIPv6["listen"])
|
|
||||||
systemStats.Nets["conn6_timewait"] = float64(connStatsIPv6["time_wait"])
|
|
||||||
systemStats.Nets["conn6_closewait"] = float64(connStatsIPv6["close_wait"])
|
|
||||||
systemStats.Nets["conn6_synrecv"] = float64(connStatsIPv6["syn_recv"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns the size of the ZFS ARC memory cache in bytes
|
// Returns the size of the ZFS ARC memory cache in bytes
|
||||||
func getARCSize() (uint64, error) {
|
func getARCSize() (uint64, error) {
|
||||||
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
|
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
case "Memory":
|
case "Memory":
|
||||||
val = data.Info.MemPct
|
val = data.Info.MemPct
|
||||||
case "Bandwidth":
|
case "Bandwidth":
|
||||||
val = data.Info.NetworkSent + data.Info.NetworkRecv
|
val = data.Info.Bandwidth
|
||||||
unit = " MB/s"
|
unit = " MB/s"
|
||||||
case "Disk":
|
case "Disk":
|
||||||
maxUsedPct := data.Info.DiskPct
|
maxUsedPct := data.Info.DiskPct
|
||||||
|
|||||||
@@ -8,47 +8,40 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
)
|
)
|
||||||
|
|
||||||
type NetworkInterfaceStats struct {
|
|
||||||
NetworkSent float64 `json:"ns"`
|
|
||||||
NetworkRecv float64 `json:"nr"`
|
|
||||||
MaxNetworkSent float64 `json:"nsm,omitempty"`
|
|
||||||
MaxNetworkRecv float64 `json:"nrm,omitempty"`
|
|
||||||
TotalBytesSent uint64 `json:"tbs,omitempty"` // Total bytes sent since boot
|
|
||||||
TotalBytesRecv uint64 `json:"tbr,omitempty"` // Total bytes received since boot
|
|
||||||
}
|
|
||||||
|
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
Cpu float64 `json:"cpu" cbor:"0,keyasint"`
|
Cpu float64 `json:"cpu" cbor:"0,keyasint"`
|
||||||
MaxCpu float64 `json:"cpum,omitempty" cbor:"1,keyasint,omitempty"`
|
MaxCpu float64 `json:"cpum,omitempty" cbor:"1,keyasint,omitempty"`
|
||||||
Mem float64 `json:"m" cbor:"2,keyasint"`
|
Mem float64 `json:"m" cbor:"2,keyasint"`
|
||||||
MemUsed float64 `json:"mu" cbor:"3,keyasint"`
|
MemUsed float64 `json:"mu" cbor:"3,keyasint"`
|
||||||
MemPct float64 `json:"mp" cbor:"4,keyasint"`
|
MemPct float64 `json:"mp" cbor:"4,keyasint"`
|
||||||
MemBuffCache float64 `json:"mb" cbor:"5,keyasint"`
|
MemBuffCache float64 `json:"mb" cbor:"5,keyasint"`
|
||||||
MemZfsArc float64 `json:"mz,omitempty" cbor:"6,keyasint,omitempty"` // ZFS ARC memory
|
MemZfsArc float64 `json:"mz,omitempty" cbor:"6,keyasint,omitempty"` // ZFS ARC memory
|
||||||
Swap float64 `json:"s,omitempty" cbor:"7,keyasint,omitempty"`
|
Swap float64 `json:"s,omitempty" cbor:"7,keyasint,omitempty"`
|
||||||
SwapUsed float64 `json:"su,omitempty" cbor:"8,keyasint,omitempty"`
|
SwapUsed float64 `json:"su,omitempty" cbor:"8,keyasint,omitempty"`
|
||||||
DiskTotal float64 `json:"d" cbor:"9,keyasint"`
|
DiskTotal float64 `json:"d" cbor:"9,keyasint"`
|
||||||
DiskUsed float64 `json:"du" cbor:"10,keyasint"`
|
DiskUsed float64 `json:"du" cbor:"10,keyasint"`
|
||||||
DiskPct float64 `json:"dp" cbor:"11,keyasint"`
|
DiskPct float64 `json:"dp" cbor:"11,keyasint"`
|
||||||
DiskReadPs float64 `json:"dr" cbor:"12,keyasint"`
|
DiskReadPs float64 `json:"dr" cbor:"12,keyasint"`
|
||||||
DiskWritePs float64 `json:"dw" cbor:"13,keyasint"`
|
DiskWritePs float64 `json:"dw" cbor:"13,keyasint"`
|
||||||
MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"14,keyasint,omitempty"`
|
MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"14,keyasint,omitempty"`
|
||||||
MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"15,keyasint,omitempty"`
|
MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"15,keyasint,omitempty"`
|
||||||
NetworkInterfaces map[string]NetworkInterfaceStats `json:"ni" cbor:"16,omitempty"` // Per-interface network stats
|
NetworkSent float64 `json:"ns" cbor:"16,keyasint"`
|
||||||
NetworkSent float64 `json:"ns" cbor:"17,keyasint"` // Total network sent (MB/s)
|
NetworkRecv float64 `json:"nr" cbor:"17,keyasint"`
|
||||||
NetworkRecv float64 `json:"nr" cbor:"18,keyasint"` // Total network recv (MB/s)
|
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"18,keyasint,omitempty"`
|
||||||
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"19,keyasint,omitempty"`
|
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"19,keyasint,omitempty"`
|
||||||
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"20,keyasint,omitempty"`
|
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
|
||||||
Temperatures map[string]float64 `json:"t,omitempty" cbor:"21,keyasint,omitempty"`
|
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
|
||||||
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"22,keyasint,omitempty"`
|
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
|
||||||
GPUData map[string]GPUData `json:"g,omitempty" cbor:"23,keyasint,omitempty"`
|
LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"`
|
||||||
LoadAvg1 float64 `json:"l1,omitempty" cbor:"24,keyasint,omitempty"`
|
LoadAvg5 float64 `json:"l5,omitempty" cbor:"24,keyasint,omitempty"`
|
||||||
LoadAvg5 float64 `json:"l5,omitempty" cbor:"25,keyasint,omitempty"`
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"25,keyasint,omitempty"`
|
||||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"26,keyasint,omitempty"`
|
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"26,keyasint,omitzero"` // [sent bytes, recv bytes]
|
||||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"27,keyasint"` // [1min, 5min, 15min]
|
MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"27,keyasint,omitzero"` // [sent bytes, recv bytes]
|
||||||
Battery [2]uint8 `json:"bat,omitzero" cbor:"28,keyasint,omitzero"` // [percent, charge state]
|
// TODO: remove other load fields in future release in favor of load avg array
|
||||||
MaxMem float64 `json:"mm,omitempty" cbor:"29,keyasint,omitempty"`
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
|
||||||
Nets map[string]float64 `json:"nets,omitempty" cbor:"30,keyasint,omitempty"` // Network connection statistics
|
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 {
|
type GPUData struct {
|
||||||
@@ -76,12 +69,10 @@ type FsStats struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type NetIoStats struct {
|
type NetIoStats struct {
|
||||||
BytesRecv uint64
|
BytesRecv uint64
|
||||||
BytesSent uint64
|
BytesSent uint64
|
||||||
PacketsSent uint64
|
Time time.Time
|
||||||
PacketsRecv uint64
|
Name string
|
||||||
Time time.Time
|
|
||||||
Name string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Os = uint8
|
type Os = uint8
|
||||||
@@ -94,26 +85,27 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Info struct {
|
type Info struct {
|
||||||
Hostname string `json:"h" cbor:"0,keyasint"`
|
Hostname string `json:"h" cbor:"0,keyasint"`
|
||||||
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
|
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
|
||||||
Cores int `json:"c" cbor:"2,keyasint"`
|
Cores int `json:"c" cbor:"2,keyasint"`
|
||||||
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
|
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
|
||||||
CpuModel string `json:"m" cbor:"4,keyasint"`
|
CpuModel string `json:"m" cbor:"4,keyasint"`
|
||||||
Uptime uint64 `json:"u" cbor:"5,keyasint"`
|
Uptime uint64 `json:"u" cbor:"5,keyasint"`
|
||||||
Cpu float64 `json:"cpu" cbor:"6,keyasint"`
|
Cpu float64 `json:"cpu" cbor:"6,keyasint"`
|
||||||
MemPct float64 `json:"mp" cbor:"7,keyasint"`
|
MemPct float64 `json:"mp" cbor:"7,keyasint"`
|
||||||
DiskPct float64 `json:"dp" cbor:"8,keyasint"`
|
DiskPct float64 `json:"dp" cbor:"8,keyasint"`
|
||||||
NetworkSent float64 `json:"ns" cbor:"9,keyasint"` // Per-interface total (MB/s)
|
Bandwidth float64 `json:"b" cbor:"9,keyasint"`
|
||||||
NetworkRecv float64 `json:"nr" cbor:"10,keyasint"` // Per-interface total (MB/s)
|
AgentVersion string `json:"v" cbor:"10,keyasint"`
|
||||||
AgentVersion string `json:"v" cbor:"11,keyasint"`
|
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"`
|
||||||
Podman bool `json:"p,omitempty" cbor:"12,keyasint,omitempty"`
|
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
|
||||||
GpuPct float64 `json:"g,omitempty" cbor:"13,keyasint,omitempty"`
|
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
|
||||||
DashboardTemp float64 `json:"dt,omitempty" cbor:"14,keyasint,omitempty"`
|
Os Os `json:"os" cbor:"14,keyasint"`
|
||||||
Os Os `json:"os" cbor:"15,keyasint"`
|
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"`
|
||||||
LoadAvg1 float64 `json:"l1,omitempty" cbor:"16,keyasint,omitempty"`
|
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"`
|
||||||
LoadAvg5 float64 `json:"l5,omitempty" cbor:"17,keyasint,omitempty"`
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
||||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"18,keyasint,omitempty"`
|
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
||||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"` // [1min, 5min, 15min]
|
// TODO: remove load fields in future release in favor of load avg array
|
||||||
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final data structure to return to the hub
|
// Final data structure to return to the hub
|
||||||
|
|||||||
@@ -206,51 +206,15 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.DiskPct += stats.DiskPct
|
sum.DiskPct += stats.DiskPct
|
||||||
sum.DiskReadPs += stats.DiskReadPs
|
sum.DiskReadPs += stats.DiskReadPs
|
||||||
sum.DiskWritePs += stats.DiskWritePs
|
sum.DiskWritePs += stats.DiskWritePs
|
||||||
sum.LoadAvg1 += stats.LoadAvg1
|
|
||||||
sum.LoadAvg5 += stats.LoadAvg5
|
|
||||||
sum.LoadAvg15 += stats.LoadAvg15
|
|
||||||
sum.NetworkSent += stats.NetworkSent
|
sum.NetworkSent += stats.NetworkSent
|
||||||
sum.NetworkRecv += stats.NetworkRecv
|
sum.NetworkRecv += stats.NetworkRecv
|
||||||
sum.LoadAvg[0] += stats.LoadAvg[0]
|
sum.LoadAvg[0] += stats.LoadAvg[0]
|
||||||
sum.LoadAvg[1] += stats.LoadAvg[1]
|
sum.LoadAvg[1] += stats.LoadAvg[1]
|
||||||
sum.LoadAvg[2] += stats.LoadAvg[2]
|
sum.LoadAvg[2] += stats.LoadAvg[2]
|
||||||
|
sum.Bandwidth[0] += stats.Bandwidth[0]
|
||||||
|
sum.Bandwidth[1] += stats.Bandwidth[1]
|
||||||
batterySum += int(stats.Battery[0])
|
batterySum += int(stats.Battery[0])
|
||||||
sum.Battery[1] = stats.Battery[1]
|
sum.Battery[1] = stats.Battery[1]
|
||||||
|
|
||||||
if stats.NetworkInterfaces != nil {
|
|
||||||
if sum.NetworkInterfaces == nil {
|
|
||||||
sum.NetworkInterfaces = make(map[string]system.NetworkInterfaceStats, len(stats.NetworkInterfaces))
|
|
||||||
}
|
|
||||||
for key, value := range stats.NetworkInterfaces {
|
|
||||||
if _, ok := sum.NetworkInterfaces[key]; !ok {
|
|
||||||
sum.NetworkInterfaces[key] = system.NetworkInterfaceStats{}
|
|
||||||
}
|
|
||||||
ni := sum.NetworkInterfaces[key]
|
|
||||||
ni.NetworkSent += value.NetworkSent
|
|
||||||
ni.NetworkRecv += value.NetworkRecv
|
|
||||||
ni.MaxNetworkSent += value.MaxNetworkSent
|
|
||||||
ni.MaxNetworkRecv += value.MaxNetworkRecv
|
|
||||||
// For cumulative totals, use the maximum value (most recent)
|
|
||||||
if value.TotalBytesSent > ni.TotalBytesSent {
|
|
||||||
ni.TotalBytesSent = value.TotalBytesSent
|
|
||||||
}
|
|
||||||
if value.TotalBytesRecv > ni.TotalBytesRecv {
|
|
||||||
ni.TotalBytesRecv = value.TotalBytesRecv
|
|
||||||
}
|
|
||||||
sum.NetworkInterfaces[key] = ni
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle network connection stats - use the latest values (most recent sample)
|
|
||||||
if stats.Nets != nil {
|
|
||||||
if sum.Nets == nil {
|
|
||||||
sum.Nets = make(map[string]float64)
|
|
||||||
}
|
|
||||||
for key, value := range stats.Nets {
|
|
||||||
sum.Nets[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set peak values
|
// Set peak values
|
||||||
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
|
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
|
||||||
sum.MaxMem = max(sum.MaxMem, stats.MaxMem, stats.MemUsed)
|
sum.MaxMem = max(sum.MaxMem, stats.MaxMem, stats.MemUsed)
|
||||||
@@ -258,6 +222,21 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.MaxNetworkRecv = max(sum.MaxNetworkRecv, stats.MaxNetworkRecv, stats.NetworkRecv)
|
sum.MaxNetworkRecv = max(sum.MaxNetworkRecv, stats.MaxNetworkRecv, stats.NetworkRecv)
|
||||||
sum.MaxDiskReadPs = max(sum.MaxDiskReadPs, stats.MaxDiskReadPs, stats.DiskReadPs)
|
sum.MaxDiskReadPs = max(sum.MaxDiskReadPs, stats.MaxDiskReadPs, stats.DiskReadPs)
|
||||||
sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs)
|
sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs)
|
||||||
|
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
|
// Accumulate temperatures
|
||||||
if stats.Temperatures != nil {
|
if stats.Temperatures != nil {
|
||||||
@@ -325,26 +304,27 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.DiskPct = twoDecimals(sum.DiskPct / count)
|
sum.DiskPct = twoDecimals(sum.DiskPct / count)
|
||||||
sum.DiskReadPs = twoDecimals(sum.DiskReadPs / count)
|
sum.DiskReadPs = twoDecimals(sum.DiskReadPs / count)
|
||||||
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
|
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
|
||||||
sum.LoadAvg1 = twoDecimals(sum.LoadAvg1 / count)
|
|
||||||
sum.LoadAvg5 = twoDecimals(sum.LoadAvg5 / count)
|
|
||||||
sum.LoadAvg15 = twoDecimals(sum.LoadAvg15 / count)
|
|
||||||
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
|
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
|
||||||
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
|
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
|
||||||
sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count)
|
sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count)
|
||||||
sum.LoadAvg[1] = twoDecimals(sum.LoadAvg[1] / count)
|
sum.LoadAvg[1] = twoDecimals(sum.LoadAvg[1] / count)
|
||||||
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
|
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
|
||||||
|
sum.Bandwidth[0] = sum.Bandwidth[0] / 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 {
|
if sum.NetworkInterfaces != nil {
|
||||||
for key := range sum.NetworkInterfaces {
|
for key := range sum.NetworkInterfaces {
|
||||||
ni := sum.NetworkInterfaces[key]
|
sum.NetworkInterfaces[key] = [4]uint64{
|
||||||
ni.NetworkSent = twoDecimals(ni.NetworkSent / count)
|
sum.NetworkInterfaces[key][0] / uint64(count),
|
||||||
ni.NetworkRecv = twoDecimals(ni.NetworkRecv / count)
|
sum.NetworkInterfaces[key][1] / uint64(count),
|
||||||
ni.MaxNetworkSent = twoDecimals(max(ni.MaxNetworkSent, ni.NetworkSent))
|
sum.NetworkInterfaces[key][2],
|
||||||
ni.MaxNetworkRecv = twoDecimals(max(ni.MaxNetworkRecv, ni.NetworkRecv))
|
sum.NetworkInterfaces[key][3],
|
||||||
sum.NetworkInterfaces[key] = ni
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
||||||
@@ -409,15 +389,19 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
|
|||||||
}
|
}
|
||||||
sums[stat.Name].Cpu += stat.Cpu
|
sums[stat.Name].Cpu += stat.Cpu
|
||||||
sums[stat.Name].Mem += stat.Mem
|
sums[stat.Name].Mem += stat.Mem
|
||||||
|
sums[stat.Name].NetworkSent += stat.NetworkSent
|
||||||
|
sums[stat.Name].NetworkRecv += stat.NetworkRecv
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]container.Stats, 0, len(sums))
|
result := make([]container.Stats, 0, len(sums))
|
||||||
for _, value := range sums {
|
for _, value := range sums {
|
||||||
result = append(result, container.Stats{
|
result = append(result, container.Stats{
|
||||||
Name: value.Name,
|
Name: value.Name,
|
||||||
Cpu: twoDecimals(value.Cpu / count),
|
Cpu: twoDecimals(value.Cpu / count),
|
||||||
Mem: twoDecimals(value.Mem / count),
|
Mem: twoDecimals(value.Mem / count),
|
||||||
|
NetworkSent: twoDecimals(value.NetworkSent / count),
|
||||||
|
NetworkRecv: twoDecimals(value.NetworkRecv / count),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|||||||
843
internal/site/package-lock.json
generated
843
internal/site/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,9 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
|
||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
import { getPagePath } from "@nanostores/router"
|
||||||
|
import { ChevronDownIcon, ExternalLinkIcon, PlusIcon } from "lucide-react"
|
||||||
|
import { memo, useEffect, useRef, useState } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -10,34 +14,30 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
import { isReadOnlyUser, pb } from "@/lib/api"
|
||||||
|
import { SystemStatus } from "@/lib/enums"
|
||||||
import { $publicKey } from "@/lib/stores"
|
import { $publicKey } from "@/lib/stores"
|
||||||
import { cn, generateToken, tokenMap, useBrowserStorage } from "@/lib/utils"
|
import { cn, generateToken, tokenMap, useBrowserStorage } from "@/lib/utils"
|
||||||
import { pb, isReadOnlyUser } from "@/lib/api"
|
import type { SystemRecord } from "@/types"
|
||||||
import { useStore } from "@nanostores/react"
|
|
||||||
import { ChevronDownIcon, ExternalLinkIcon, PlusIcon } from "lucide-react"
|
|
||||||
import { memo, useEffect, useRef, useState } from "react"
|
|
||||||
import { $router, basePath, Link, navigate } from "./router"
|
|
||||||
import { SystemRecord } from "@/types"
|
|
||||||
import { SystemStatus } from "@/lib/enums"
|
|
||||||
import { AppleIcon, DockerIcon, FreeBsdIcon, TuxIcon, WindowsIcon } from "./ui/icons"
|
|
||||||
import { InputCopy } from "./ui/input-copy"
|
|
||||||
import { getPagePath } from "@nanostores/router"
|
|
||||||
import {
|
import {
|
||||||
copyDockerCompose,
|
copyDockerCompose,
|
||||||
copyDockerRun,
|
copyDockerRun,
|
||||||
copyLinuxCommand,
|
copyLinuxCommand,
|
||||||
copyWindowsCommand,
|
copyWindowsCommand,
|
||||||
DropdownItem,
|
type DropdownItem,
|
||||||
InstallDropdown,
|
InstallDropdown,
|
||||||
} from "./install-dropdowns"
|
} from "./install-dropdowns"
|
||||||
|
import { $router, basePath, Link, navigate } from "./router"
|
||||||
import { DropdownMenu, DropdownMenuTrigger } from "./ui/dropdown-menu"
|
import { DropdownMenu, DropdownMenuTrigger } from "./ui/dropdown-menu"
|
||||||
|
import { AppleIcon, DockerIcon, FreeBsdIcon, TuxIcon, WindowsIcon } from "./ui/icons"
|
||||||
|
import { InputCopy } from "./ui/input-copy"
|
||||||
|
|
||||||
export function AddSystemButton({ className }: { className?: string }) {
|
export function AddSystemButton({ className }: { className?: string }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
let opened = useRef(false)
|
const opened = useRef(false)
|
||||||
if (open) {
|
if (open) {
|
||||||
opened.current = true
|
opened.current = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { ColumnDef } from "@tanstack/react-table"
|
|
||||||
import { AlertsHistoryRecord } from "@/types"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { formatShortDate, toFixedFloat, formatDuration, cn } from "@/lib/utils"
|
|
||||||
import { alertInfo } from "@/lib/alerts"
|
|
||||||
import { Trans } from "@lingui/react/macro"
|
|
||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import type { ColumnDef } from "@tanstack/react-table"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { alertInfo } from "@/lib/alerts"
|
||||||
|
import { cn, formatDuration, formatShortDate, toFixedFloat } from "@/lib/utils"
|
||||||
|
import type { AlertsHistoryRecord } from "@/types"
|
||||||
|
|
||||||
export const alertsHistoryColumns: ColumnDef<AlertsHistoryRecord>[] = [
|
export const alertsHistoryColumns: ColumnDef<AlertsHistoryRecord>[] = [
|
||||||
{
|
{
|
||||||
@@ -38,7 +38,7 @@ export const alertsHistoryColumns: ColumnDef<AlertsHistoryRecord>[] = [
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
cell: ({ getValue, row }) => {
|
cell: ({ getValue, row }) => {
|
||||||
let name = getValue() as string
|
const name = getValue() as string
|
||||||
const info = alertInfo[row.original.name]
|
const info = alertInfo[row.original.name]
|
||||||
const Icon = info?.icon
|
const Icon = info?.icon
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { memo, useMemo, useState } from "react"
|
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { $alerts } from "@/lib/stores"
|
|
||||||
import { BellIcon } from "lucide-react"
|
import { BellIcon } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { memo, useMemo, useState } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { SystemRecord } from "@/types"
|
|
||||||
import { AlertDialogContent } from "./alerts-sheet"
|
|
||||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
|
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
|
||||||
|
import { $alerts } from "@/lib/stores"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { SystemRecord } from "@/types"
|
||||||
|
import { AlertDialogContent } from "./alerts-sheet"
|
||||||
|
|
||||||
export default memo(function AlertsButton({ system }: { system: SystemRecord }) {
|
export default memo(function AlertsButton({ system }: { system: SystemRecord }) {
|
||||||
const [opened, setOpened] = useState(false)
|
const [opened, setOpened] = useState(false)
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { Trans, Plural } from "@lingui/react/macro"
|
import { Plural, Trans } from "@lingui/react/macro"
|
||||||
import { $alerts, $systems } from "@/lib/stores"
|
|
||||||
import { cn, debounce } from "@/lib/utils"
|
|
||||||
import { alertInfo } from "@/lib/alerts"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import { AlertInfo, AlertRecord, SystemRecord } from "@/types"
|
|
||||||
import { lazy, memo, Suspense, useMemo, useState } from "react"
|
|
||||||
import { toast } from "@/components/ui/use-toast"
|
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { getPagePath } from "@nanostores/router"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { GlobeIcon, ServerIcon } from "lucide-react"
|
||||||
import { DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
import { lazy, memo, Suspense, useMemo, useState } from "react"
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
|
||||||
import { ServerIcon, GlobeIcon } from "lucide-react"
|
|
||||||
import { $router, Link } from "@/components/router"
|
import { $router, Link } from "@/components/router"
|
||||||
import { DialogHeader } from "@/components/ui/dialog"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
import { toast } from "@/components/ui/use-toast"
|
||||||
|
import { alertInfo } from "@/lib/alerts"
|
||||||
import { pb } from "@/lib/api"
|
import { pb } from "@/lib/api"
|
||||||
|
import { $alerts, $systems } from "@/lib/stores"
|
||||||
|
import { cn, debounce } from "@/lib/utils"
|
||||||
|
import type { AlertInfo, AlertRecord, SystemRecord } from "@/types"
|
||||||
|
|
||||||
const Slider = lazy(() => import("@/components/ui/slider"))
|
const Slider = lazy(() => import("@/components/ui/slider"))
|
||||||
|
|
||||||
@@ -172,7 +171,7 @@ export function AlertContent({
|
|||||||
|
|
||||||
const [checked, setChecked] = useState(global ? false : !!alert)
|
const [checked, setChecked] = useState(global ? false : !!alert)
|
||||||
const [min, setMin] = useState(alert?.min || 10)
|
const [min, setMin] = useState(alert?.min || 10)
|
||||||
const [value, setValue] = useState(alert?.value || (singleDescription ? 0 : alertData.start ?? 80))
|
const [value, setValue] = useState(alert?.value || (singleDescription ? 0 : (alertData.start ?? 80)))
|
||||||
|
|
||||||
const Icon = alertData.icon
|
const Icon = alertData.icon
|
||||||
|
|
||||||
|
|||||||
@@ -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,124 +0,0 @@
|
|||||||
import { memo } from "react"
|
|
||||||
import { useLingui } from "@lingui/react/macro"
|
|
||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
|
||||||
import {
|
|
||||||
ChartContainer,
|
|
||||||
ChartTooltip,
|
|
||||||
ChartTooltipContent,
|
|
||||||
ChartLegend,
|
|
||||||
ChartLegendContent,
|
|
||||||
xAxis,
|
|
||||||
} from "@/components/ui/chart"
|
|
||||||
import { cn, formatShortDate, chartMargin } from "@/lib/utils"
|
|
||||||
import { ChartData } from "@/types"
|
|
||||||
import { useYAxisWidth } from "./hooks"
|
|
||||||
|
|
||||||
export default memo(function ConnectionChart({ chartData }: { chartData: ChartData }) {
|
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
|
||||||
const { t } = useLingui()
|
|
||||||
|
|
||||||
if (chartData.systemStats.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataKeys = [
|
|
||||||
{
|
|
||||||
name: t`IPv4 Established`,
|
|
||||||
dataKey: "stats.nets.conn_established",
|
|
||||||
color: "hsl(220, 70%, 50%)", // Blue
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t`IPv4 Listen`,
|
|
||||||
dataKey: "stats.nets.conn_listen",
|
|
||||||
color: "hsl(142, 70%, 45%)", // Green
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t`IPv4 Time Wait`,
|
|
||||||
dataKey: "stats.nets.conn_timewait",
|
|
||||||
color: "hsl(48, 96%, 53%)", // Yellow
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t`IPv4 Close Wait`,
|
|
||||||
dataKey: "stats.nets.conn_closewait",
|
|
||||||
color: "hsl(271, 81%, 56%)", // Purple
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t`IPv4 Syn Recv`,
|
|
||||||
dataKey: "stats.nets.conn_synrecv",
|
|
||||||
color: "hsl(9, 78%, 56%)", // Red
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t`IPv6 Established`,
|
|
||||||
dataKey: "stats.nets.conn6_established",
|
|
||||||
color: "hsl(220, 70%, 65%)", // Light Blue
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t`IPv6 Listen`,
|
|
||||||
dataKey: "stats.nets.conn6_listen",
|
|
||||||
color: "hsl(142, 70%, 60%)", // Light Green
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t`IPv6 Time Wait`,
|
|
||||||
dataKey: "stats.nets.conn6_timewait",
|
|
||||||
color: "hsl(48, 96%, 68%)", // Light Yellow
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t`IPv6 Close Wait`,
|
|
||||||
dataKey: "stats.nets.conn6_closewait",
|
|
||||||
color: "hsl(271, 81%, 71%)", // Light Purple
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t`IPv6 Syn Recv`,
|
|
||||||
dataKey: "stats.nets.conn6_synrecv",
|
|
||||||
color: "hsl(9, 78%, 71%)", // Light Red
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ChartContainer
|
|
||||||
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
|
||||||
"opacity-100": yAxisWidth,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
|
|
||||||
<CartesianGrid vertical={false} />
|
|
||||||
<YAxis
|
|
||||||
direction="ltr"
|
|
||||||
orientation={chartData.orientation}
|
|
||||||
className="tracking-tighter"
|
|
||||||
width={yAxisWidth}
|
|
||||||
tickFormatter={(value) => updateYAxisWidth(value.toString())}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
/>
|
|
||||||
{xAxis(chartData)}
|
|
||||||
<ChartTooltip
|
|
||||||
animationEasing="ease-out"
|
|
||||||
animationDuration={150}
|
|
||||||
content={
|
|
||||||
<ChartTooltipContent
|
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
|
||||||
contentFormatter={({ value }) => value.toString()}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ChartLegend content={<ChartLegendContent />} />
|
|
||||||
{dataKeys.map((key, i) => (
|
|
||||||
<Area
|
|
||||||
key={i}
|
|
||||||
dataKey={key.dataKey}
|
|
||||||
name={key.name}
|
|
||||||
type="monotoneX"
|
|
||||||
fill={key.color}
|
|
||||||
fillOpacity={0.3}
|
|
||||||
stroke={key.color}
|
|
||||||
strokeOpacity={1}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</AreaChart>
|
|
||||||
</ChartContainer>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -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,164 +0,0 @@
|
|||||||
import { memo, useMemo } from "react"
|
|
||||||
import { useLingui } from "@lingui/react/macro"
|
|
||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
|
||||||
import {
|
|
||||||
ChartContainer,
|
|
||||||
ChartTooltip,
|
|
||||||
ChartTooltipContent,
|
|
||||||
xAxis,
|
|
||||||
ChartLegend,
|
|
||||||
ChartLegendContent,
|
|
||||||
} from "@/components/ui/chart"
|
|
||||||
import { cn, formatShortDate, chartMargin, formatBytes, toFixedFloat, decimalString } from "@/lib/utils"
|
|
||||||
import { ChartData } from "@/types"
|
|
||||||
import { useStore } from "@nanostores/react"
|
|
||||||
import { $networkInterfaceFilter, $userSettings } from "@/lib/stores"
|
|
||||||
import { Unit } from "@/lib/enums"
|
|
||||||
import { useYAxisWidth } from "./hooks"
|
|
||||||
|
|
||||||
const getNestedValue = (path: string, max = false, data: any): number | null => {
|
|
||||||
// path format is like "eth0.ns" or "eth0.nr"
|
|
||||||
// need to access data.stats.ni[interface][property]
|
|
||||||
const parts = path.split(".")
|
|
||||||
if (parts.length !== 2) return null
|
|
||||||
|
|
||||||
const [interfaceName, property] = parts
|
|
||||||
const propertyKey = property + (max ? "m" : "")
|
|
||||||
|
|
||||||
return data?.stats?.ni?.[interfaceName]?.[propertyKey] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(function NetworkInterfaceChart({
|
|
||||||
chartData,
|
|
||||||
maxToggled = false,
|
|
||||||
max,
|
|
||||||
}: {
|
|
||||||
chartData: ChartData
|
|
||||||
maxToggled?: boolean
|
|
||||||
max?: number
|
|
||||||
}) {
|
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
|
||||||
const { i18n } = useLingui()
|
|
||||||
const networkInterfaceFilter = useStore($networkInterfaceFilter)
|
|
||||||
const userSettings = useStore($userSettings)
|
|
||||||
|
|
||||||
const { chartTime } = chartData
|
|
||||||
const showMax = chartTime !== "1h" && maxToggled
|
|
||||||
|
|
||||||
// Get network interface names from the latest stats
|
|
||||||
const networkInterfaces = useMemo(() => {
|
|
||||||
if (chartData.systemStats.length === 0) return []
|
|
||||||
const latestStats = chartData.systemStats[chartData.systemStats.length - 1]
|
|
||||||
const allInterfaces = Object.keys(latestStats.stats.ni || {})
|
|
||||||
|
|
||||||
// Filter interfaces based on filter value
|
|
||||||
if (networkInterfaceFilter) {
|
|
||||||
return allInterfaces.filter((iface) => iface.toLowerCase().includes(networkInterfaceFilter.toLowerCase()))
|
|
||||||
}
|
|
||||||
|
|
||||||
return allInterfaces
|
|
||||||
}, [chartData.systemStats, networkInterfaceFilter])
|
|
||||||
|
|
||||||
const dataKeys = useMemo(() => {
|
|
||||||
// Generate colors for each interface - each interface gets a unique hue
|
|
||||||
// and sent/received use different shades of that hue
|
|
||||||
const interfaceColors = networkInterfaces.map((iface, index) => {
|
|
||||||
const hue = ((index * 360) / Math.max(networkInterfaces.length, 1)) % 360
|
|
||||||
return {
|
|
||||||
interface: iface,
|
|
||||||
sentColor: `hsl(${hue}, 70%, 45%)`, // Darker shade for sent
|
|
||||||
receivedColor: `hsl(${hue}, 70%, 65%)`, // Lighter shade for received
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return interfaceColors.flatMap(({ interface: iface, sentColor, receivedColor }) => [
|
|
||||||
{
|
|
||||||
name: `${iface} Sent`,
|
|
||||||
dataKey: `${iface}.ns`,
|
|
||||||
color: sentColor,
|
|
||||||
type: "sent" as const,
|
|
||||||
interface: iface,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: `${iface} Received`,
|
|
||||||
dataKey: `${iface}.nr`,
|
|
||||||
color: receivedColor,
|
|
||||||
type: "received" as const,
|
|
||||||
interface: iface,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}, [networkInterfaces, i18n.locale])
|
|
||||||
|
|
||||||
const colors = dataKeys.map((key) => key.name)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ChartContainer
|
|
||||||
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
|
||||||
"opacity-100": yAxisWidth,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
|
|
||||||
<CartesianGrid vertical={false} />
|
|
||||||
<YAxis
|
|
||||||
direction="ltr"
|
|
||||||
orientation={chartData.orientation}
|
|
||||||
className="tracking-tighter"
|
|
||||||
width={yAxisWidth}
|
|
||||||
tickFormatter={(value) => {
|
|
||||||
const { value: formattedValue, unit } = formatBytes(value, true, userSettings.unitNet ?? Unit.Bits, true)
|
|
||||||
const rounded = toFixedFloat(formattedValue, formattedValue >= 10 ? 1 : 2)
|
|
||||||
return updateYAxisWidth(`${rounded} ${unit}`)
|
|
||||||
}}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
/>
|
|
||||||
{xAxis(chartData)}
|
|
||||||
<ChartTooltip
|
|
||||||
animationEasing="ease-out"
|
|
||||||
animationDuration={150}
|
|
||||||
content={
|
|
||||||
<ChartTooltipContent
|
|
||||||
labelFormatter={(_: any, data: any) => formatShortDate(data[0].payload.created)}
|
|
||||||
contentFormatter={({ value }: any) => {
|
|
||||||
const { value: formattedValue, unit } = formatBytes(
|
|
||||||
value,
|
|
||||||
true,
|
|
||||||
userSettings.unitNet ?? Unit.Bits,
|
|
||||||
true
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<span className="flex">
|
|
||||||
{decimalString(formattedValue, formattedValue >= 10 ? 1 : 2)} {unit}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{dataKeys.map((key, i) => {
|
|
||||||
const filtered =
|
|
||||||
networkInterfaceFilter && !key.interface.toLowerCase().includes(networkInterfaceFilter.toLowerCase())
|
|
||||||
let fillOpacity = filtered ? 0.05 : 0.4
|
|
||||||
let strokeOpacity = filtered ? 0.1 : 1
|
|
||||||
return (
|
|
||||||
<Area
|
|
||||||
key={i}
|
|
||||||
dataKey={getNestedValue.bind(null, key.dataKey, showMax)}
|
|
||||||
name={key.name}
|
|
||||||
type="monotoneX"
|
|
||||||
fill={key.color}
|
|
||||||
fillOpacity={fillOpacity}
|
|
||||||
stroke={key.color}
|
|
||||||
strokeOpacity={strokeOpacity}
|
|
||||||
activeDot={{ opacity: filtered ? 0 : 1 }}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{colors.length < 12 && <ChartLegend content={<ChartLegendContent />} />}
|
|
||||||
</AreaChart>
|
|
||||||
</ChartContainer>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
import { memo, useMemo } from "react"
|
|
||||||
import { useLingui } from "@lingui/react/macro"
|
|
||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
|
||||||
import {
|
|
||||||
ChartContainer,
|
|
||||||
ChartTooltip,
|
|
||||||
ChartTooltipContent,
|
|
||||||
ChartLegend,
|
|
||||||
ChartLegendContent,
|
|
||||||
xAxis,
|
|
||||||
} from "@/components/ui/chart"
|
|
||||||
import { cn, formatShortDate, chartMargin, formatBytes, toFixedFloat, decimalString } from "@/lib/utils"
|
|
||||||
import { ChartData } from "@/types"
|
|
||||||
import { useStore } from "@nanostores/react"
|
|
||||||
import { $userSettings } from "@/lib/stores"
|
|
||||||
import { Unit } from "@/lib/enums"
|
|
||||||
import { useYAxisWidth } from "./hooks"
|
|
||||||
|
|
||||||
const getPerInterfaceBandwidth = (data: any): Record<string, { sent: number; recv: number }> | null => {
|
|
||||||
const networkInterfaces = data?.stats?.ni
|
|
||||||
if (!networkInterfaces) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const interfaceData: Record<string, { sent: number; recv: number }> = {}
|
|
||||||
let hasData = false
|
|
||||||
|
|
||||||
Object.entries(networkInterfaces).forEach(([name, iface]: [string, any]) => {
|
|
||||||
if (iface.tbs || iface.tbr) {
|
|
||||||
interfaceData[name] = {
|
|
||||||
sent: iface.tbs || 0,
|
|
||||||
recv: iface.tbr || 0,
|
|
||||||
}
|
|
||||||
hasData = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return hasData ? interfaceData : null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(function TotalBandwidthChart({ chartData }: { chartData: ChartData }) {
|
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
|
||||||
const { i18n } = useLingui()
|
|
||||||
const userSettings = useStore($userSettings)
|
|
||||||
|
|
||||||
// Transform data to include per-interface bandwidth
|
|
||||||
const { transformedData, interfaceNames } = useMemo(() => {
|
|
||||||
const allInterfaces = new Set<string>()
|
|
||||||
|
|
||||||
// First pass: collect all interface names
|
|
||||||
chartData.systemStats.forEach((dataPoint) => {
|
|
||||||
const interfaceData = getPerInterfaceBandwidth(dataPoint)
|
|
||||||
if (interfaceData) {
|
|
||||||
Object.keys(interfaceData).forEach((name) => allInterfaces.add(name))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const interfaceNames = Array.from(allInterfaces).sort()
|
|
||||||
|
|
||||||
// Second pass: transform data with per-interface values
|
|
||||||
const transformedData = chartData.systemStats.map((dataPoint) => {
|
|
||||||
const interfaceData = getPerInterfaceBandwidth(dataPoint)
|
|
||||||
const result: any = { ...dataPoint }
|
|
||||||
|
|
||||||
interfaceNames.forEach((interfaceName) => {
|
|
||||||
const data = interfaceData?.[interfaceName]
|
|
||||||
result[`${interfaceName}_sent`] = data?.sent || 0
|
|
||||||
result[`${interfaceName}_recv`] = data?.recv || 0
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
return { transformedData, interfaceNames }
|
|
||||||
}, [chartData.systemStats])
|
|
||||||
|
|
||||||
// Generate dynamic data keys for each interface using same color scheme as NetworkInterfaceChart
|
|
||||||
const dataKeys = useMemo(() => {
|
|
||||||
const keys: Array<{ name: string; dataKey: string; color: string }> = []
|
|
||||||
|
|
||||||
interfaceNames.forEach((interfaceName, index) => {
|
|
||||||
// Use the same color calculation as NetworkInterfaceChart
|
|
||||||
const hue = ((index * 360) / Math.max(interfaceNames.length, 1)) % 360
|
|
||||||
|
|
||||||
keys.push({
|
|
||||||
name: `${interfaceName} Sent`,
|
|
||||||
dataKey: `${interfaceName}_sent`,
|
|
||||||
color: `hsl(${hue}, 70%, 45%)`, // Darker shade for sent (same as NetworkInterfaceChart)
|
|
||||||
})
|
|
||||||
|
|
||||||
keys.push({
|
|
||||||
name: `${interfaceName} Received`,
|
|
||||||
dataKey: `${interfaceName}_recv`,
|
|
||||||
color: `hsl(${hue}, 70%, 65%)`, // Lighter shade for received (same as NetworkInterfaceChart)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return keys
|
|
||||||
}, [interfaceNames])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ChartContainer
|
|
||||||
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
|
||||||
"opacity-100": yAxisWidth,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<AreaChart accessibilityLayer data={transformedData} margin={chartMargin}>
|
|
||||||
<CartesianGrid vertical={false} />
|
|
||||||
<YAxis
|
|
||||||
direction="ltr"
|
|
||||||
orientation={chartData.orientation}
|
|
||||||
className="tracking-tighter"
|
|
||||||
width={yAxisWidth}
|
|
||||||
tickFormatter={(value) => {
|
|
||||||
const { value: formattedValue, unit } = formatBytes(value, false, userSettings.unitNet ?? Unit.Bytes)
|
|
||||||
const rounded = toFixedFloat(formattedValue, formattedValue >= 10 ? 1 : 2)
|
|
||||||
return updateYAxisWidth(`${rounded} ${unit}`)
|
|
||||||
}}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
/>
|
|
||||||
{xAxis(chartData)}
|
|
||||||
<ChartTooltip
|
|
||||||
animationEasing="ease-out"
|
|
||||||
animationDuration={150}
|
|
||||||
content={
|
|
||||||
<ChartTooltipContent
|
|
||||||
labelFormatter={(_: any, data: any) => formatShortDate(data[0].payload.created)}
|
|
||||||
contentFormatter={({ value }: any) => {
|
|
||||||
const { value: formattedValue, unit } = formatBytes(value, false, userSettings.unitNet ?? Unit.Bytes)
|
|
||||||
return (
|
|
||||||
<span className="flex">
|
|
||||||
{decimalString(formattedValue, formattedValue >= 10 ? 1 : 2)} {unit}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ChartLegend content={<ChartLegendContent />} />
|
|
||||||
{dataKeys.map((key, i) => (
|
|
||||||
<Area
|
|
||||||
key={i}
|
|
||||||
dataKey={key.dataKey}
|
|
||||||
name={key.name}
|
|
||||||
type="monotoneX"
|
|
||||||
fill={key.color}
|
|
||||||
fillOpacity={0.3}
|
|
||||||
stroke={key.color}
|
|
||||||
strokeOpacity={1}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</AreaChart>
|
|
||||||
</ChartContainer>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import { getPagePath } from "@nanostores/router"
|
||||||
|
import { DialogDescription } from "@radix-ui/react-dialog"
|
||||||
import {
|
import {
|
||||||
AlertOctagonIcon,
|
AlertOctagonIcon,
|
||||||
BookIcon,
|
BookIcon,
|
||||||
@@ -10,7 +14,7 @@ import {
|
|||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
import { memo, useEffect, useMemo } from "react"
|
||||||
import {
|
import {
|
||||||
CommandDialog,
|
CommandDialog,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@@ -21,15 +25,10 @@ import {
|
|||||||
CommandSeparator,
|
CommandSeparator,
|
||||||
CommandShortcut,
|
CommandShortcut,
|
||||||
} from "@/components/ui/command"
|
} from "@/components/ui/command"
|
||||||
import { memo, useEffect, useMemo } from "react"
|
import { isAdmin } from "@/lib/api"
|
||||||
import { $systems } from "@/lib/stores"
|
import { $systems } from "@/lib/stores"
|
||||||
import { getHostDisplayValue, listen } from "@/lib/utils"
|
import { getHostDisplayValue, listen } from "@/lib/utils"
|
||||||
import { $router, basePath, navigate, prependBasePath } from "./router"
|
import { $router, basePath, navigate, prependBasePath } from "./router"
|
||||||
import { Trans } from "@lingui/react/macro"
|
|
||||||
import { t } from "@lingui/core/macro"
|
|
||||||
import { getPagePath } from "@nanostores/router"
|
|
||||||
import { DialogDescription } from "@radix-ui/react-dialog"
|
|
||||||
import { isAdmin } from "@/lib/api"
|
|
||||||
|
|
||||||
export default memo(function CommandPalette({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) {
|
export default memo(function CommandPalette({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { useEffect, useMemo, useRef } from "react"
|
import { useEffect, useMemo, useRef } from "react"
|
||||||
|
import { $copyContent } from "@/lib/stores"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||||
import { Textarea } from "./ui/textarea"
|
import { Textarea } from "./ui/textarea"
|
||||||
import { $copyContent } from "@/lib/stores"
|
|
||||||
|
|
||||||
export default function CopyToClipboard({ content }: { content: string }) {
|
export default function CopyToClipboard({ content }: { content: string }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo } from "react"
|
|
||||||
import { DropdownMenuContent, DropdownMenuItem } from "./ui/dropdown-menu"
|
|
||||||
import { copyToClipboard, getHubURL } from "@/lib/utils"
|
|
||||||
import { i18n } from "@lingui/core"
|
import { i18n } from "@lingui/core"
|
||||||
|
import { memo } from "react"
|
||||||
|
import { copyToClipboard, getHubURL } from "@/lib/utils"
|
||||||
|
import { DropdownMenuContent, DropdownMenuItem } from "./ui/dropdown-menu"
|
||||||
|
|
||||||
// const isbeta = beszel.hub_version.includes("beta")
|
// const isbeta = beszel.hub_version.includes("beta")
|
||||||
// const imagetag = isbeta ? ":edge" : ""
|
// const imagetag = isbeta ? ":edge" : ""
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro"
|
||||||
import { LanguagesIcon } from "lucide-react"
|
import { LanguagesIcon } from "lucide-react"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||||
|
import { dynamicActivate } from "@/lib/i18n"
|
||||||
import languages from "@/lib/languages"
|
import languages from "@/lib/languages"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useLingui } from "@lingui/react/macro"
|
|
||||||
import { dynamicActivate } from "@/lib/i18n"
|
|
||||||
|
|
||||||
export function LangToggle() {
|
export function LangToggle() {
|
||||||
const { i18n } = useLingui()
|
const { i18n } = useLingui()
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { cn } from "@/lib/utils"
|
import { getPagePath } from "@nanostores/router"
|
||||||
|
import { KeyIcon, LoaderCircle, LockIcon, LogInIcon, MailIcon } from "lucide-react"
|
||||||
|
import type { AuthMethodsList, AuthProviderInfo, OAuth2AuthConfig } from "pocketbase"
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import * as v from "valibot"
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { KeyIcon, LoaderCircle, LockIcon, LogInIcon, MailIcon } from "lucide-react"
|
|
||||||
import { $authenticated } from "@/lib/stores"
|
|
||||||
import * as v from "valibot"
|
|
||||||
import { toast } from "../ui/use-toast"
|
|
||||||
import { Dialog, DialogContent, DialogTrigger, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
|
||||||
import { AuthMethodsList, AuthProviderInfo, OAuth2AuthConfig } from "pocketbase"
|
|
||||||
import { $router, Link, prependBasePath } from "../router"
|
|
||||||
import { getPagePath } from "@nanostores/router"
|
|
||||||
import { pb } from "@/lib/api"
|
import { pb } from "@/lib/api"
|
||||||
|
import { $authenticated } from "@/lib/stores"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { $router, Link, prependBasePath } from "../router"
|
||||||
|
import { toast } from "../ui/use-toast"
|
||||||
import { OtpInputForm } from "./otp-forms"
|
import { OtpInputForm } from "./otp-forms"
|
||||||
|
|
||||||
const honeypot = v.literal("")
|
const honeypot = v.literal("")
|
||||||
@@ -83,9 +83,9 @@ export function UserAuthForm({
|
|||||||
const result = v.safeParse(Schema, data)
|
const result = v.safeParse(Schema, data)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.log(result)
|
console.log(result)
|
||||||
let errors = {}
|
const errors = {}
|
||||||
for (const issue of result.issues) {
|
for (const issue of result.issues) {
|
||||||
// @ts-ignore
|
// @ts-expect-error
|
||||||
errors[issue.path[0].key] = issue.message
|
errors[issue.path[0].key] = issue.message
|
||||||
}
|
}
|
||||||
setErrors(errors)
|
setErrors(errors)
|
||||||
@@ -96,7 +96,7 @@ export function UserAuthForm({
|
|||||||
if (isFirstRun) {
|
if (isFirstRun) {
|
||||||
// check that passwords match
|
// check that passwords match
|
||||||
if (password !== passwordConfirm) {
|
if (password !== passwordConfirm) {
|
||||||
let msg = "Passwords do not match"
|
const msg = "Passwords do not match"
|
||||||
setErrors({ passwordConfirm: msg })
|
setErrors({ passwordConfirm: msg })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
|
||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { LoaderCircle, MailIcon, SendHorizonalIcon } from "lucide-react"
|
import { LoaderCircle, MailIcon, SendHorizonalIcon } from "lucide-react"
|
||||||
|
import { useCallback, useState } from "react"
|
||||||
|
import { pb } from "@/lib/api"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "../ui/button"
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog"
|
||||||
import { Input } from "../ui/input"
|
import { Input } from "../ui/input"
|
||||||
import { Label } from "../ui/label"
|
import { Label } from "../ui/label"
|
||||||
import { useCallback, useState } from "react"
|
|
||||||
import { toast } from "../ui/use-toast"
|
import { toast } from "../ui/use-toast"
|
||||||
import { buttonVariants } from "../ui/button"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Dialog, DialogHeader } from "../ui/dialog"
|
|
||||||
import { DialogContent, DialogTrigger, DialogTitle } from "../ui/dialog"
|
|
||||||
import { pb } from "@/lib/api"
|
|
||||||
|
|
||||||
const showLoginFaliedToast = () => {
|
const showLoginFaliedToast = () => {
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { UserAuthForm } from "@/components/login/auth-form"
|
|
||||||
import { Logo } from "../logo"
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import ForgotPassword from "./forgot-pass-form"
|
import type { AuthMethodsList } from "pocketbase"
|
||||||
import { $router } from "../router"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { AuthMethodsList } from "pocketbase"
|
import { UserAuthForm } from "@/components/login/auth-form"
|
||||||
import { useTheme } from "../theme-provider"
|
|
||||||
import { pb } from "@/lib/api"
|
import { pb } from "@/lib/api"
|
||||||
|
import { Logo } from "../logo"
|
||||||
import { ModeToggle } from "../mode-toggle"
|
import { ModeToggle } from "../mode-toggle"
|
||||||
|
import { $router } from "../router"
|
||||||
|
import { useTheme } from "../theme-provider"
|
||||||
|
import ForgotPassword from "./forgot-pass-form"
|
||||||
import { OtpRequestForm } from "./otp-forms"
|
import { OtpRequestForm } from "./otp-forms"
|
||||||
|
|
||||||
export default function () {
|
export default function () {
|
||||||
@@ -53,7 +53,7 @@ export default function () {
|
|||||||
<div className="min-h-svh grid items-center py-12">
|
<div className="min-h-svh grid items-center py-12">
|
||||||
<div
|
<div
|
||||||
className="grid gap-5 w-full px-4 mx-auto"
|
className="grid gap-5 w-full px-4 mx-auto"
|
||||||
// @ts-ignore
|
// @ts-expect-error
|
||||||
style={{ maxWidth: "21.5em", "--border": theme == "light" ? "hsl(30, 8%, 70%)" : "hsl(220, 3%, 25%)" }}
|
style={{ maxWidth: "21.5em", "--border": theme == "light" ? "hsl(30, 8%, 70%)" : "hsl(220, 3%, 25%)" }}
|
||||||
>
|
>
|
||||||
<div className="absolute top-3 right-3">
|
<div className="absolute top-3 right-3">
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import { LoaderCircle, MailIcon, SendHorizonalIcon } from "lucide-react"
|
||||||
import { useCallback, useState } from "react"
|
import { useCallback, useState } from "react"
|
||||||
|
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/otp"
|
||||||
import { pb } from "@/lib/api"
|
import { pb } from "@/lib/api"
|
||||||
import { $authenticated } from "@/lib/stores"
|
import { $authenticated } from "@/lib/stores"
|
||||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/otp"
|
|
||||||
import { Trans } from "@lingui/react/macro"
|
|
||||||
import { showLoginFaliedToast } from "./auth-form"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { MailIcon, LoaderCircle, SendHorizonalIcon } from "lucide-react"
|
import { $router } from "../router"
|
||||||
import { Label } from "../ui/label"
|
|
||||||
import { buttonVariants } from "../ui/button"
|
import { buttonVariants } from "../ui/button"
|
||||||
import { Input } from "../ui/input"
|
import { Input } from "../ui/input"
|
||||||
import { $router } from "../router"
|
import { Label } from "../ui/label"
|
||||||
|
import { showLoginFaliedToast } from "./auth-form"
|
||||||
|
|
||||||
export function OtpInputForm({ otpId, mfaId }: { otpId: string; mfaId: string }) {
|
export function OtpInputForm({ otpId, mfaId }: { otpId: string; mfaId: string }) {
|
||||||
const [value, setValue] = useState("")
|
const [value, setValue] = useState("")
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { MoonStarIcon, SunIcon } from "lucide-react"
|
import { MoonStarIcon, SunIcon } from "lucide-react"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { useTheme } from "@/components/theme-provider"
|
import { useTheme } from "@/components/theme-provider"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
export function ModeToggle() {
|
export function ModeToggle() {
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { useState, lazy, Suspense } from "react"
|
import { getPagePath } from "@nanostores/router"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
|
||||||
import {
|
import {
|
||||||
DatabaseBackupIcon,
|
DatabaseBackupIcon,
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
@@ -11,23 +10,24 @@ import {
|
|||||||
UserIcon,
|
UserIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { $router, basePath, Link, prependBasePath } from "./router"
|
import { lazy, Suspense, useState } from "react"
|
||||||
import { LangToggle } from "./lang-toggle"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { ModeToggle } from "./mode-toggle"
|
|
||||||
import { Logo } from "./logo"
|
|
||||||
import { cn, runOnce } from "@/lib/utils"
|
|
||||||
import { isReadOnlyUser, isAdmin, logOut, pb } from "@/lib/api"
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuGroup,
|
DropdownMenuGroup,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { isAdmin, isReadOnlyUser, logOut, pb } from "@/lib/api"
|
||||||
|
import { cn, runOnce } from "@/lib/utils"
|
||||||
import { AddSystemButton } from "./add-system"
|
import { AddSystemButton } from "./add-system"
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { LangToggle } from "./lang-toggle"
|
||||||
|
import { Logo } from "./logo"
|
||||||
|
import { ModeToggle } from "./mode-toggle"
|
||||||
|
import { $router, basePath, Link, prependBasePath } from "./router"
|
||||||
|
|
||||||
const CommandPalette = lazy(() => import("./command-palette"))
|
const CommandPalette = lazy(() => import("./command-palette"))
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const prependBasePath = (path: string) => (basePath + path).replaceAll("/
|
|||||||
|
|
||||||
// prepend base path to routes
|
// prepend base path to routes
|
||||||
for (const route in routes) {
|
for (const route in routes) {
|
||||||
// @ts-ignore need as const above to get nanostores to parse types properly
|
// @ts-expect-error need as const above to get nanostores to parse types properly
|
||||||
routes[route] = prependBasePath(routes[route])
|
routes[route] = prependBasePath(routes[route])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { Trans, useLingui } from "@lingui/react/macro"
|
|||||||
import { redirectPage } from "@nanostores/router"
|
import { redirectPage } from "@nanostores/router"
|
||||||
import {
|
import {
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
|
ExternalLinkIcon,
|
||||||
FingerprintIcon,
|
FingerprintIcon,
|
||||||
KeyIcon,
|
KeyIcon,
|
||||||
MoreHorizontalIcon,
|
MoreHorizontalIcon,
|
||||||
RotateCwIcon,
|
RotateCwIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
ExternalLinkIcon,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { memo, useEffect, useMemo, useState } from "react"
|
import { memo, useEffect, useMemo, useState } from "react"
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
$containerFilter,
|
$containerFilter,
|
||||||
$direction,
|
$direction,
|
||||||
$maxValues,
|
$maxValues,
|
||||||
$networkInterfaceFilter,
|
|
||||||
$systems,
|
$systems,
|
||||||
$temperatureFilter,
|
$temperatureFilter,
|
||||||
$userSettings,
|
$userSettings,
|
||||||
@@ -53,9 +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 ConnectionChart from "../charts/connection-chart"
|
import NetworkSheet from "./system/network-sheet"
|
||||||
import NetworkInterfaceChart from "../charts/network-interface-chart"
|
|
||||||
import TotalBandwidthChart from "../charts/total-bandwidth-chart"
|
|
||||||
|
|
||||||
type ChartTimeData = {
|
type ChartTimeData = {
|
||||||
time: number
|
time: number
|
||||||
@@ -151,7 +148,6 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
const netCardRef = useRef<HTMLDivElement>(null)
|
const netCardRef = useRef<HTMLDivElement>(null)
|
||||||
const persistChartTime = useRef(false)
|
const persistChartTime = useRef(false)
|
||||||
const [containerFilterBar, setContainerFilterBar] = useState(null as null | JSX.Element)
|
const [containerFilterBar, setContainerFilterBar] = useState(null as null | JSX.Element)
|
||||||
const [networkInterfaceFilterBar, setNetworkInterfaceFilterBar] = useState(null as null | JSX.Element)
|
|
||||||
const [bottomSpacing, setBottomSpacing] = useState(0)
|
const [bottomSpacing, setBottomSpacing] = useState(0)
|
||||||
const [chartLoading, setChartLoading] = useState(true)
|
const [chartLoading, setChartLoading] = useState(true)
|
||||||
const isLongerChart = chartTime !== "1h"
|
const isLongerChart = chartTime !== "1h"
|
||||||
@@ -168,9 +164,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
setSystemStats([])
|
setSystemStats([])
|
||||||
setContainerData([])
|
setContainerData([])
|
||||||
setContainerFilterBar(null)
|
setContainerFilterBar(null)
|
||||||
setNetworkInterfaceFilterBar(null)
|
|
||||||
$containerFilter.set("")
|
$containerFilter.set("")
|
||||||
$networkInterfaceFilter.set("")
|
|
||||||
}
|
}
|
||||||
}, [name])
|
}, [name])
|
||||||
|
|
||||||
@@ -267,19 +261,6 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
})
|
})
|
||||||
}, [system, chartTime])
|
}, [system, chartTime])
|
||||||
|
|
||||||
// Set up network interface filter bar
|
|
||||||
useEffect(() => {
|
|
||||||
if (systemStats.length > 0) {
|
|
||||||
const latestStats = systemStats[systemStats.length - 1]
|
|
||||||
const networkInterfaces = Object.keys(latestStats.stats.ns || {})
|
|
||||||
if (networkInterfaces.length > 0) {
|
|
||||||
!networkInterfaceFilterBar && setNetworkInterfaceFilterBar(<FilterBar store={$networkInterfaceFilter} />)
|
|
||||||
} else if (networkInterfaceFilterBar) {
|
|
||||||
setNetworkInterfaceFilterBar(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [systemStats, networkInterfaceFilterBar])
|
|
||||||
|
|
||||||
// values for system info bar
|
// values for system info bar
|
||||||
const systemInfo = useMemo(() => {
|
const systemInfo = useMemo(() => {
|
||||||
if (!system.info) {
|
if (!system.info) {
|
||||||
@@ -409,7 +390,6 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
const lastGpuVals = Object.values(systemStats.at(-1)?.stats.g ?? {})
|
const lastGpuVals = Object.values(systemStats.at(-1)?.stats.g ?? {})
|
||||||
const hasGpuData = lastGpuVals.length > 0
|
const hasGpuData = lastGpuVals.length > 0
|
||||||
const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined)
|
const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined)
|
||||||
const latestNetworkStats = systemStats.at(-1)?.stats.ni
|
|
||||||
|
|
||||||
let translatedStatus: string = system.status
|
let translatedStatus: string = system.status
|
||||||
if (system.status === SystemStatus.Up) {
|
if (system.status === SystemStatus.Up) {
|
||||||
@@ -576,7 +556,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
grid={grid}
|
grid={grid}
|
||||||
title={t`Disk I/O`}
|
title={t`Disk I/O`}
|
||||||
description={t`Disk read and write throughput`}
|
description={t`Throughput of root filesystem`}
|
||||||
cornerEl={maxValSelect}
|
cornerEl={maxValSelect}
|
||||||
>
|
>
|
||||||
<AreaChartDefault
|
<AreaChartDefault
|
||||||
@@ -585,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,
|
||||||
},
|
},
|
||||||
@@ -607,44 +587,58 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
{/* Network interface charts */}
|
<ChartCard
|
||||||
{Object.keys(latestNetworkStats ?? {}).length > 0 && (
|
empty={dataEmpty}
|
||||||
<ChartCard
|
grid={grid}
|
||||||
empty={dataEmpty}
|
title={t`Bandwidth`}
|
||||||
grid={grid}
|
cornerEl={
|
||||||
title={t`Network Interfaces`}
|
<div className="flex gap-2">
|
||||||
description={t`Network traffic per interface`}
|
{maxValSelect}
|
||||||
cornerEl={networkInterfaceFilterBar}
|
<NetworkSheet chartData={chartData} dataEmpty={dataEmpty} grid={grid} maxValues={maxValues} />
|
||||||
>
|
</div>
|
||||||
{/* @ts-ignore */}
|
}
|
||||||
<NetworkInterfaceChart chartData={chartData} />
|
description={t`Network traffic of public interfaces`}
|
||||||
</ChartCard>
|
>
|
||||||
)}
|
<AreaChartDefault
|
||||||
|
chartData={chartData}
|
||||||
{/* Per-Interface Cumulative Bandwidth chart */}
|
maxToggled={maxValues}
|
||||||
{Object.keys(latestNetworkStats ?? {}).length > 0 && (
|
dataPoints={[
|
||||||
<ChartCard
|
{
|
||||||
empty={dataEmpty}
|
label: t`Sent`,
|
||||||
grid={grid}
|
// use bytes if available, otherwise multiply old MB (can remove in future)
|
||||||
title={t`Cumulative Bandwidth`}
|
dataKey(data: SystemStatsRecord) {
|
||||||
description={t`Total bytes sent and received per network interface since boot`}
|
if (showMax) {
|
||||||
>
|
return data?.stats?.bm?.[0] ?? (data?.stats?.nsm ?? 0) * 1024 * 1024
|
||||||
{/* @ts-ignore */}
|
}
|
||||||
<TotalBandwidthChart chartData={chartData} />
|
return data?.stats?.b?.[0] ?? data?.stats?.ns * 1024 * 1024
|
||||||
</ChartCard>
|
},
|
||||||
)}
|
color: 5,
|
||||||
|
opacity: 0.2,
|
||||||
{/* TCP Connection States chart */}
|
},
|
||||||
{systemStats.at(-1)?.stats.nets && Object.keys(systemStats.at(-1)?.stats.nets ?? {}).length > 0 && (
|
{
|
||||||
<ChartCard
|
label: t`Received`,
|
||||||
empty={dataEmpty}
|
dataKey(data: SystemStatsRecord) {
|
||||||
grid={grid}
|
if (showMax) {
|
||||||
title={t`TCP Connection States`}
|
return data?.stats?.bm?.[1] ?? (data?.stats?.nrm ?? 0) * 1024 * 1024
|
||||||
description={t`TCP connection states for IPv4 and IPv6`}
|
}
|
||||||
>
|
return data?.stats?.b?.[1] ?? data?.stats?.nr * 1024 * 1024
|
||||||
<ConnectionChart chartData={chartData} />
|
},
|
||||||
</ChartCard>
|
color: 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) => {
|
||||||
|
const { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
|
||||||
|
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||||
|
}}
|
||||||
|
contentFormatter={(data) => {
|
||||||
|
const { value, unit } = formatBytes(data.value, true, userSettings.unitNet, false)
|
||||||
|
return `${decimalString(value, value >= 100 ? 1 : 2)} ${unit}`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
{containerFilterBar && containerData.length > 0 && (
|
{containerFilterBar && containerData.length > 0 && (
|
||||||
<div
|
<div
|
||||||
@@ -688,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>
|
||||||
@@ -701,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>
|
||||||
@@ -893,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"
|
||||||
@@ -919,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>
|
||||||
@@ -935,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
|
||||||
@@ -949,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>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { LoaderCircleIcon } from "lucide-react"
|
import { LoaderCircleIcon } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export default function ({ msg, className }: { msg?: string; className?: string }) {
|
export default function ({ msg, className }: { msg?: string; className?: string }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { SystemRecord } from "@/types"
|
import { t } from "@lingui/core/macro"
|
||||||
import { CellContext, ColumnDef, HeaderContext } from "@tanstack/react-table"
|
import { Trans, useLingui } from "@lingui/react/macro"
|
||||||
import { ClassValue } from "clsx"
|
import { useStore } from "@nanostores/react"
|
||||||
|
import { getPagePath } from "@nanostores/router"
|
||||||
|
import type { CellContext, ColumnDef, HeaderContext } from "@tanstack/react-table"
|
||||||
|
import type { ClassValue } from "clsx"
|
||||||
import {
|
import {
|
||||||
ArrowUpDownIcon,
|
ArrowUpDownIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
@@ -15,7 +18,10 @@ import {
|
|||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
WifiIcon,
|
WifiIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Button } from "../ui/button"
|
import { memo, useMemo, useRef, useState } from "react"
|
||||||
|
import { isReadOnlyUser, pb } from "@/lib/api"
|
||||||
|
import { MeterState, SystemStatus } from "@/lib/enums"
|
||||||
|
import { $longestSystemNameLen, $userSettings } from "@/lib/stores"
|
||||||
import {
|
import {
|
||||||
cn,
|
cn,
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
@@ -25,24 +31,12 @@ import {
|
|||||||
getMeterState,
|
getMeterState,
|
||||||
parseSemVer,
|
parseSemVer,
|
||||||
} from "@/lib/utils"
|
} from "@/lib/utils"
|
||||||
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon } from "../ui/icons"
|
import type { SystemRecord } from "@/types"
|
||||||
import { useStore } from "@nanostores/react"
|
|
||||||
import { $longestSystemNameLen, $userSettings } from "@/lib/stores"
|
|
||||||
import { Trans, useLingui } from "@lingui/react/macro"
|
|
||||||
import { useMemo, useRef, useState } from "react"
|
|
||||||
import { memo } from "react"
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "../ui/dropdown-menu"
|
|
||||||
import AlertButton from "../alerts/alert-button"
|
|
||||||
import { Dialog } from "../ui/dialog"
|
|
||||||
import { SystemDialog } from "../add-system"
|
import { SystemDialog } from "../add-system"
|
||||||
import { AlertDialog } from "../ui/alert-dialog"
|
import AlertButton from "../alerts/alert-button"
|
||||||
|
import { $router, Link } from "../router"
|
||||||
import {
|
import {
|
||||||
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
@@ -51,12 +45,16 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "../ui/alert-dialog"
|
} from "../ui/alert-dialog"
|
||||||
import { buttonVariants } from "../ui/button"
|
import { Button, buttonVariants } from "../ui/button"
|
||||||
import { t } from "@lingui/core/macro"
|
import { Dialog } from "../ui/dialog"
|
||||||
import { MeterState, SystemStatus } from "@/lib/enums"
|
import {
|
||||||
import { $router, Link } from "../router"
|
DropdownMenu,
|
||||||
import { getPagePath } from "@nanostores/router"
|
DropdownMenuContent,
|
||||||
import { isReadOnlyUser, pb } from "@/lib/api"
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "../ui/dropdown-menu"
|
||||||
|
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon } from "../ui/icons"
|
||||||
|
|
||||||
const STATUS_COLORS = {
|
const STATUS_COLORS = {
|
||||||
[SystemStatus.Up]: "bg-green-500",
|
[SystemStatus.Up]: "bg-green-500",
|
||||||
@@ -216,32 +214,22 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorFn: (row) => (row.info.ns || 0) + (row.info.nr || 0),
|
accessorFn: ({ info }) => info.bb || (info.b || 0) * 1024 * 1024,
|
||||||
id: "net",
|
id: "net",
|
||||||
name: () => t`Net`,
|
name: () => t`Net`,
|
||||||
size: 0,
|
size: 0,
|
||||||
Icon: EthernetIcon,
|
Icon: EthernetIcon,
|
||||||
header: sortableHeader,
|
header: sortableHeader,
|
||||||
sortDescFirst: true,
|
|
||||||
sortingFn: (rowA, rowB) => {
|
|
||||||
const a = (rowA.original.info.ns || 0) + (rowA.original.info.nr || 0)
|
|
||||||
const b = (rowB.original.info.ns || 0) + (rowB.original.info.nr || 0)
|
|
||||||
return a - b
|
|
||||||
},
|
|
||||||
cell(info) {
|
cell(info) {
|
||||||
const system = info.row.original
|
const sys = info.row.original
|
||||||
const sent = system.info.ns || 0
|
|
||||||
const received = system.info.nr || 0
|
|
||||||
const userSettings = useStore($userSettings, { keys: ["unitNet"] })
|
const userSettings = useStore($userSettings, { keys: ["unitNet"] })
|
||||||
if (system.status === SystemStatus.Paused) {
|
if (sys.status === SystemStatus.Paused) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const sentFmt = formatBytes(sent, true, userSettings.unitNet, true)
|
const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, false)
|
||||||
const receivedFmt = formatBytes(received, true, userSettings.unitNet, true)
|
|
||||||
return (
|
return (
|
||||||
<span className={cn("tabular-nums whitespace-nowrap", { "ps-1": viewMode === "table" })}>
|
<span className="tabular-nums whitespace-nowrap">
|
||||||
<span className="text-green-600">↑</span> {Math.round(sentFmt.value)} {sentFmt.unit}{" "}
|
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
||||||
<span className="text-blue-600">↓</span> {Math.round(receivedFmt.value)} {receivedFmt.unit}
|
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -300,7 +288,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
// @ts-ignore
|
// @ts-expect-error
|
||||||
name: () => t({ message: "Actions", comment: "Table column" }),
|
name: () => t({ message: "Actions", comment: "Table column" }),
|
||||||
size: 50,
|
size: 50,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
@@ -315,7 +303,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
|
|
||||||
function sortableHeader(context: HeaderContext<SystemRecord, unknown>) {
|
function sortableHeader(context: HeaderContext<SystemRecord, unknown>) {
|
||||||
const { column } = context
|
const { column } = context
|
||||||
// @ts-ignore
|
// @ts-expect-error
|
||||||
const { Icon, hideSort, name }: { Icon: React.ElementType; name: () => string; hideSort: boolean } = column.columnDef
|
const { Icon, hideSort, name }: { Icon: React.ElementType; name: () => string; hideSort: boolean } = column.columnDef
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -363,7 +351,7 @@ export function IndicatorDot({ system, className }: { system: SystemRecord; clas
|
|||||||
export const ActionsButton = memo(({ system }: { system: SystemRecord }) => {
|
export const ActionsButton = memo(({ system }: { system: SystemRecord }) => {
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
const [editOpen, setEditOpen] = useState(false)
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
let editOpened = useRef(false)
|
const editOpened = useRef(false)
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
const { id, status, host, name } = system
|
const { id, status, host, name } = system
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
import { getPagePath } from "@nanostores/router"
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
type ColumnDef,
|
||||||
ColumnFiltersState,
|
type ColumnFiltersState,
|
||||||
getFilteredRowModel,
|
|
||||||
SortingState,
|
|
||||||
getSortedRowModel,
|
|
||||||
flexRender,
|
flexRender,
|
||||||
VisibilityState,
|
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
type Row,
|
||||||
|
type SortingState,
|
||||||
|
type Table as TableType,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
Row,
|
type VisibilityState,
|
||||||
Table as TableType,
|
|
||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
|
||||||
|
import {
|
||||||
|
ArrowDownIcon,
|
||||||
|
ArrowUpDownIcon,
|
||||||
|
ArrowUpIcon,
|
||||||
|
EyeIcon,
|
||||||
|
FilterIcon,
|
||||||
|
LayoutGridIcon,
|
||||||
|
LayoutListIcon,
|
||||||
|
Settings2Icon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -24,30 +38,16 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { SystemRecord } from "@/types"
|
|
||||||
import {
|
|
||||||
ArrowUpDownIcon,
|
|
||||||
LayoutGridIcon,
|
|
||||||
LayoutListIcon,
|
|
||||||
ArrowDownIcon,
|
|
||||||
ArrowUpIcon,
|
|
||||||
Settings2Icon,
|
|
||||||
EyeIcon,
|
|
||||||
FilterIcon,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
|
||||||
import { $pausedSystems, $downSystems, $upSystems, $systems } from "@/lib/stores"
|
|
||||||
import { useStore } from "@nanostores/react"
|
|
||||||
import { cn, runOnce, useBrowserStorage } from "@/lib/utils"
|
|
||||||
import { $router, Link } from "../router"
|
|
||||||
import { useLingui, Trans } from "@lingui/react/macro"
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import SystemsTableColumns, { ActionsButton, IndicatorDot } from "./systems-table-columns"
|
|
||||||
import AlertButton from "../alerts/alert-button"
|
|
||||||
import { SystemStatus } from "@/lib/enums"
|
import { SystemStatus } from "@/lib/enums"
|
||||||
import { useVirtualizer, VirtualItem } from "@tanstack/react-virtual"
|
import { $downSystems, $pausedSystems, $systems, $upSystems } from "@/lib/stores"
|
||||||
|
import { cn, runOnce, useBrowserStorage } from "@/lib/utils"
|
||||||
|
import type { SystemRecord } from "@/types"
|
||||||
|
import AlertButton from "../alerts/alert-button"
|
||||||
|
import { $router, Link } from "../router"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
||||||
|
import SystemsTableColumns, { ActionsButton, IndicatorDot } from "./systems-table-columns"
|
||||||
|
|
||||||
type ViewMode = "table" | "grid"
|
type ViewMode = "table" | "grid"
|
||||||
type StatusFilter = "all" | SystemRecord["status"]
|
type StatusFilter = "all" | SystemRecord["status"]
|
||||||
@@ -309,69 +309,63 @@ export default function SystemsTable() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const AllSystemsTable = memo(function ({
|
const AllSystemsTable = memo(
|
||||||
table,
|
({ table, rows, colLength }: { table: TableType<SystemRecord>; rows: Row<SystemRecord>[]; colLength: number }) => {
|
||||||
rows,
|
// The virtualizer will need a reference to the scrollable container element
|
||||||
colLength,
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
}: {
|
|
||||||
table: TableType<SystemRecord>
|
|
||||||
rows: Row<SystemRecord>[]
|
|
||||||
colLength: number
|
|
||||||
}) {
|
|
||||||
// The virtualizer will need a reference to the scrollable container element
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
||||||
count: rows.length,
|
count: rows.length,
|
||||||
estimateSize: () => (rows.length > 10 ? 56 : 60),
|
estimateSize: () => (rows.length > 10 ? 56 : 60),
|
||||||
getScrollElement: () => scrollRef.current,
|
getScrollElement: () => scrollRef.current,
|
||||||
overscan: 5,
|
overscan: 5,
|
||||||
})
|
})
|
||||||
const virtualRows = virtualizer.getVirtualItems()
|
const virtualRows = virtualizer.getVirtualItems()
|
||||||
|
|
||||||
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
|
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
|
||||||
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
|
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
|
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
|
||||||
// don't set min height if there are less than 2 rows, do set if we need to display the empty state
|
// don't set min height if there are less than 2 rows, do set if we need to display the empty state
|
||||||
(!rows.length || rows.length > 2) && "min-h-50"
|
(!rows.length || rows.length > 2) && "min-h-50"
|
||||||
)}
|
)}
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
>
|
>
|
||||||
{/* add header height to table size */}
|
{/* add header height to table size */}
|
||||||
<div style={{ height: `${virtualizer.getTotalSize() + 50}px`, paddingTop, paddingBottom }}>
|
<div style={{ height: `${virtualizer.getTotalSize() + 50}px`, paddingTop, paddingBottom }}>
|
||||||
<table className="text-sm w-full h-full">
|
<table className="text-sm w-full h-full">
|
||||||
<SystemsTableHead table={table} colLength={colLength} />
|
<SystemsTableHead table={table} colLength={colLength} />
|
||||||
<TableBody onMouseEnter={preloadSystemDetail}>
|
<TableBody onMouseEnter={preloadSystemDetail}>
|
||||||
{rows.length ? (
|
{rows.length ? (
|
||||||
virtualRows.map((virtualRow) => {
|
virtualRows.map((virtualRow) => {
|
||||||
const row = rows[virtualRow.index] as Row<SystemRecord>
|
const row = rows[virtualRow.index] as Row<SystemRecord>
|
||||||
return (
|
return (
|
||||||
<SystemTableRow
|
<SystemTableRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
row={row}
|
row={row}
|
||||||
virtualRow={virtualRow}
|
virtualRow={virtualRow}
|
||||||
length={rows.length}
|
length={rows.length}
|
||||||
colLength={colLength}
|
colLength={colLength}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
||||||
<Trans>No systems found.</Trans>
|
<Trans>No systems found.</Trans>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
function SystemsTableHead({ table, colLength }: { table: TableType<SystemRecord>; colLength: number }) {
|
function SystemsTableHead({ table, colLength }: { table: TableType<SystemRecord>; colLength: number }) {
|
||||||
const { i18n } = useLingui()
|
const { i18n } = useLingui()
|
||||||
@@ -395,42 +389,44 @@ function SystemsTableHead({ table, colLength }: { table: TableType<SystemRecord>
|
|||||||
}, [i18n.locale, colLength])
|
}, [i18n.locale, colLength])
|
||||||
}
|
}
|
||||||
|
|
||||||
const SystemTableRow = memo(function ({
|
const SystemTableRow = memo(
|
||||||
row,
|
({
|
||||||
virtualRow,
|
row,
|
||||||
colLength,
|
virtualRow,
|
||||||
}: {
|
colLength,
|
||||||
row: Row<SystemRecord>
|
}: {
|
||||||
virtualRow: VirtualItem
|
row: Row<SystemRecord>
|
||||||
length: number
|
virtualRow: VirtualItem
|
||||||
colLength: number
|
length: number
|
||||||
}) {
|
colLength: number
|
||||||
const system = row.original
|
}) => {
|
||||||
const { t } = useLingui()
|
const system = row.original
|
||||||
return useMemo(() => {
|
const { t } = useLingui()
|
||||||
return (
|
return useMemo(() => {
|
||||||
<TableRow
|
return (
|
||||||
// data-state={row.getIsSelected() && "selected"}
|
<TableRow
|
||||||
className={cn("cursor-pointer transition-opacity relative safari:transform-3d", {
|
// data-state={row.getIsSelected() && "selected"}
|
||||||
"opacity-50": system.status === SystemStatus.Paused,
|
className={cn("cursor-pointer transition-opacity relative safari:transform-3d", {
|
||||||
})}
|
"opacity-50": system.status === SystemStatus.Paused,
|
||||||
>
|
})}
|
||||||
{row.getVisibleCells().map((cell) => (
|
>
|
||||||
<TableCell
|
{row.getVisibleCells().map((cell) => (
|
||||||
key={cell.id}
|
<TableCell
|
||||||
style={{
|
key={cell.id}
|
||||||
width: cell.column.getSize(),
|
style={{
|
||||||
height: virtualRow.size,
|
width: cell.column.getSize(),
|
||||||
}}
|
height: virtualRow.size,
|
||||||
className="py-0"
|
}}
|
||||||
>
|
className="py-0"
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
>
|
||||||
</TableCell>
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
))}
|
</TableCell>
|
||||||
</TableRow>
|
))}
|
||||||
)
|
</TableRow>
|
||||||
}, [system, system.status, colLength, t])
|
)
|
||||||
})
|
}, [system, system.status, colLength, t])
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const SystemCard = memo(
|
const SystemCard = memo(
|
||||||
({ row, table, colLength }: { row: Row<SystemRecord>; table: TableType<SystemRecord>; colLength: number }) => {
|
({ row, table, colLength }: { row: Row<SystemRecord>; table: TableType<SystemRecord>; colLength: number }) => {
|
||||||
@@ -471,7 +467,7 @@ const SystemCard = memo(
|
|||||||
if (!column.getIsVisible() || column.id === "system" || column.id === "actions") return null
|
if (!column.getIsVisible() || column.id === "system" || column.id === "actions") return null
|
||||||
const cell = row.getAllCells().find((cell) => cell.column.id === column.id)
|
const cell = row.getAllCells().find((cell) => cell.column.id === column.id)
|
||||||
if (!cell) return null
|
if (!cell) return null
|
||||||
// @ts-ignore
|
// @ts-expect-error
|
||||||
const { Icon, name } = column.columnDef as ColumnDef<SystemRecord, unknown>
|
const { Icon, name } = column.columnDef as ColumnDef<SystemRecord, unknown>
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -55,9 +55,6 @@ listenKeys($userSettings, ["chartTime"], ({ chartTime }) => $chartTime.set(chart
|
|||||||
/** Container chart filter */
|
/** Container chart filter */
|
||||||
export const $containerFilter = atom("")
|
export const $containerFilter = atom("")
|
||||||
|
|
||||||
/** Network interface chart filter */
|
|
||||||
export const $networkInterfaceFilter = atom("")
|
|
||||||
|
|
||||||
/** Temperature chart filter */
|
/** Temperature chart filter */
|
||||||
export const $temperatureFilter = atom("")
|
export const $temperatureFilter = atom("")
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { type ClassValue, clsx } from "clsx"
|
import { type ClassValue, clsx } from "clsx"
|
||||||
import { timeDay, timeHour } from "d3-time"
|
import { timeDay, timeHour } from "d3-time"
|
||||||
|
import { listenKeys } from "nanostores"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
import { prependBasePath } from "@/components/router"
|
import { prependBasePath } from "@/components/router"
|
||||||
@@ -8,7 +9,6 @@ import { toast } from "@/components/ui/use-toast"
|
|||||||
import type { ChartTimeData, FingerprintRecord, SemVer, SystemRecord } from "@/types"
|
import type { ChartTimeData, FingerprintRecord, SemVer, SystemRecord } from "@/types"
|
||||||
import { HourFormat, MeterState, Unit } from "./enums"
|
import { HourFormat, MeterState, Unit } from "./enums"
|
||||||
import { $copyContent, $userSettings } from "./stores"
|
import { $copyContent, $userSettings } from "./stores"
|
||||||
import { listenKeys } from "nanostores"
|
|
||||||
|
|
||||||
export const FAVICON_DEFAULT = "favicon.svg"
|
export const FAVICON_DEFAULT = "favicon.svg"
|
||||||
export const FAVICON_GREEN = "favicon-green.svg"
|
export const FAVICON_GREEN = "favicon-green.svg"
|
||||||
@@ -283,22 +283,6 @@ export const getHubURL = () => BESZEL?.HUB_URL || window.location.origin
|
|||||||
/** Map of system IDs to their corresponding tokens (used to avoid fetching in add-system dialog) */
|
/** Map of system IDs to their corresponding tokens (used to avoid fetching in add-system dialog) */
|
||||||
export const tokenMap = new Map<SystemRecord["id"], FingerprintRecord["token"]>()
|
export const tokenMap = new Map<SystemRecord["id"], FingerprintRecord["token"]>()
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a network speed value (in MB/s) to the most readable unit (B/s, KB/s, MB/s, GB/s, TB/s).
|
|
||||||
* @param valueMBps The value in MB/s
|
|
||||||
* @returns A string with the value and the appropriate unit
|
|
||||||
*/
|
|
||||||
export function formatSpeed(valueMBps: number): string {
|
|
||||||
const bitsPerSec = valueMBps * 8_000_000
|
|
||||||
if (bitsPerSec >= 1_000_000_000) {
|
|
||||||
return (bitsPerSec / 1_000_000_000).toFixed(2) + ' Gbit/s'
|
|
||||||
} else if (bitsPerSec >= 1_000_000) {
|
|
||||||
return (bitsPerSec / 1_000_000).toFixed(2) + ' Mbit/s'
|
|
||||||
} else {
|
|
||||||
return (bitsPerSec / 1_000).toFixed(2) + ' kbit/s'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Calculate duration between two dates and format as human-readable string */
|
/** Calculate duration between two dates and format as human-readable string */
|
||||||
export function formatDuration(
|
export function formatDuration(
|
||||||
createdDate: string | null | undefined,
|
createdDate: string | null | undefined,
|
||||||
|
|||||||
27
internal/site/src/types.d.ts
vendored
27
internal/site/src/types.d.ts
vendored
@@ -75,25 +75,6 @@ export interface SystemInfo {
|
|||||||
dt?: number
|
dt?: number
|
||||||
/** operating system */
|
/** operating system */
|
||||||
os?: Os
|
os?: Os
|
||||||
/** network sent (mb) */
|
|
||||||
ns?: number
|
|
||||||
/** network received (mb) */
|
|
||||||
nr?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NetworkInterfaceStats {
|
|
||||||
/** network sent (mb) */
|
|
||||||
ns: number
|
|
||||||
/** network received (mb) */
|
|
||||||
nr: number
|
|
||||||
/** max network sent (mb) */
|
|
||||||
nsm?: number
|
|
||||||
/** max network received (mb) */
|
|
||||||
nrm?: number
|
|
||||||
/** total bytes sent since boot */
|
|
||||||
tbs?: number
|
|
||||||
/** total bytes received since boot */
|
|
||||||
tbr?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemStats {
|
export interface SystemStats {
|
||||||
@@ -150,9 +131,7 @@ export interface SystemStats {
|
|||||||
nsm?: number
|
nsm?: number
|
||||||
/** max network received (mb) */
|
/** max network received (mb) */
|
||||||
nrm?: number
|
nrm?: number
|
||||||
/** per-interface network stats */
|
/** max network sent (bytes) */
|
||||||
ni?: Record<string, NetworkInterfaceStats>
|
|
||||||
/** max bandwidth (bytes) [sent, received] */
|
|
||||||
bm?: [number, number]
|
bm?: [number, number]
|
||||||
/** temperatures */
|
/** temperatures */
|
||||||
t?: Record<string, number>
|
t?: Record<string, number>
|
||||||
@@ -162,8 +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 connection statistics */
|
/** network interfaces [upload bytes, download bytes, total upload bytes, total download bytes] */
|
||||||
nets?: Record<string, number>
|
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