mirror of
https://github.com/henrygd/beszel.git
synced 2025-11-05 21:14:27 +00:00
Compare commits
1 Commits
top-proces
...
split-inte
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb26877720 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,3 +20,4 @@ __debug_*
|
||||
agent/lhm/obj
|
||||
agent/lhm/bin
|
||||
dockerfile_agent_dev
|
||||
.vite
|
||||
2
Makefile
2
Makefile
@@ -77,7 +77,7 @@ dev-hub: export ENV=dev
|
||||
dev-hub:
|
||||
mkdir -p ./internal/site/dist && touch ./internal/site/dist/index.html
|
||||
@if command -v entr >/dev/null 2>&1; then \
|
||||
find ./internal/cmd/hub/*.go ./internal/{alerts,hub,records,users}/*.go | entr -r -s "cd ./internal/cmd/hub && go run -tags development . serve --http 0.0.0.0:8090"; \
|
||||
find ./internal -type f -name '*.go' | entr -r -s "cd ./internal/cmd/hub && go run -tags development . serve --http 0.0.0.0:8090"; \
|
||||
else \
|
||||
cd ./internal/cmd/hub && go run -tags development . serve --http 0.0.0.0:8090; \
|
||||
fi
|
||||
|
||||
@@ -22,23 +22,23 @@ import (
|
||||
)
|
||||
|
||||
type Agent struct {
|
||||
sync.Mutex // Used to lock agent while collecting data
|
||||
debug bool // true if LOG_LEVEL is set to debug
|
||||
zfs bool // true if system has arcstats
|
||||
memCalc string // Memory calculation formula
|
||||
fsNames []string // List of filesystem device names being monitored
|
||||
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
||||
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
||||
netIoStats system.NetIoStats // Keeps track of bandwidth usage
|
||||
dockerManager *dockerManager // Manages Docker API requests
|
||||
sensorConfig *SensorConfig // Sensors config
|
||||
systemInfo system.Info // Host system info
|
||||
gpuManager *GPUManager // Manages GPU data
|
||||
cache *SessionCache // Cache for system stats based on primary session ID
|
||||
connectionManager *ConnectionManager // Channel to signal connection events
|
||||
server *ssh.Server // SSH server
|
||||
dataDir string // Directory for persisting data
|
||||
keys []gossh.PublicKey // SSH public keys
|
||||
sync.Mutex // Used to lock agent while collecting data
|
||||
debug bool // true if LOG_LEVEL is set to debug
|
||||
zfs bool // true if system has arcstats
|
||||
memCalc string // Memory calculation formula
|
||||
fsNames []string // List of filesystem device names being monitored
|
||||
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
||||
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
||||
netIoStats map[string]system.NetIoStats // Keeps track of per-interface bandwidth usage
|
||||
dockerManager *dockerManager // Manages Docker API requests
|
||||
sensorConfig *SensorConfig // Sensors config
|
||||
systemInfo system.Info // Host system info
|
||||
gpuManager *GPUManager // Manages GPU data
|
||||
cache *SessionCache // Cache for system stats based on primary session ID
|
||||
connectionManager *ConnectionManager // Channel to signal connection events
|
||||
server *ssh.Server // SSH server
|
||||
dataDir string // Directory for persisting data
|
||||
keys []gossh.PublicKey // SSH public keys
|
||||
}
|
||||
|
||||
// NewAgent creates a new agent with the given data directory for persisting data.
|
||||
|
||||
@@ -5,12 +5,16 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
psutilNet "github.com/shirou/gopsutil/v4/net"
|
||||
)
|
||||
|
||||
func (a *Agent) initializeNetIoStats() {
|
||||
// reset valid network interfaces
|
||||
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
|
||||
var nicsMap map[string]struct{}
|
||||
@@ -22,13 +26,10 @@ func (a *Agent) initializeNetIoStats() {
|
||||
}
|
||||
}
|
||||
|
||||
// reset network I/O stats
|
||||
a.netIoStats.BytesSent = 0
|
||||
a.netIoStats.BytesRecv = 0
|
||||
|
||||
// get intial network I/O stats
|
||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||
a.netIoStats.Time = time.Now()
|
||||
now := time.Now()
|
||||
|
||||
for _, v := range netIO {
|
||||
switch {
|
||||
// skip if nics exists and the interface is not in the list
|
||||
@@ -43,10 +44,15 @@ func (a *Agent) initializeNetIoStats() {
|
||||
}
|
||||
}
|
||||
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
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
195
agent/system.go
195
agent/system.go
@@ -176,53 +176,85 @@ func (a *Agent) getSystemStats() system.Stats {
|
||||
if len(a.netInterfaces) == 0 {
|
||||
// if no network interfaces, initialize again
|
||||
// this is a fix if agent started before network is online (#466)
|
||||
// maybe refactor this in the future to not cache interface names at all so we
|
||||
// don't miss an interface that's been added after agent started in any circumstance
|
||||
a.initializeNetIoStats()
|
||||
}
|
||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||
msElapsed := uint64(time.Since(a.netIoStats.Time).Milliseconds())
|
||||
a.netIoStats.Time = time.Now()
|
||||
totalBytesSent := uint64(0)
|
||||
totalBytesRecv := uint64(0)
|
||||
// sum all bytes sent and received
|
||||
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
|
||||
}
|
||||
totalBytesSent += v.BytesSent
|
||||
totalBytesRecv += v.BytesRecv
|
||||
}
|
||||
// add to systemStats
|
||||
var bytesSentPerSecond, bytesRecvPerSecond uint64
|
||||
if msElapsed > 0 {
|
||||
bytesSentPerSecond = (totalBytesSent - a.netIoStats.BytesSent) * 1000 / msElapsed
|
||||
bytesRecvPerSecond = (totalBytesRecv - a.netIoStats.BytesRecv) * 1000 / msElapsed
|
||||
}
|
||||
networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond))
|
||||
networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond))
|
||||
// add check for issue (#150) where sent is a massive number
|
||||
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
|
||||
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
|
||||
for _, v := range netIO {
|
||||
if _, exists := a.netInterfaces[v.Name]; !exists {
|
||||
continue
|
||||
|
||||
// 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)
|
||||
}
|
||||
slog.Info(v.Name, "recv", v.BytesRecv, "sent", v.BytesSent)
|
||||
}
|
||||
|
||||
// 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 = networkSentPs
|
||||
systemStats.NetworkRecv = networkRecvPs
|
||||
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
|
||||
// update netIoStats
|
||||
a.netIoStats.BytesSent = totalBytesSent
|
||||
a.netIoStats.BytesRecv = totalBytesRecv
|
||||
systemStats.NetworkSent = totalSent
|
||||
systemStats.NetworkRecv = totalRecv
|
||||
}
|
||||
}
|
||||
|
||||
// connection counts
|
||||
a.updateConnectionCounts(&systemStats)
|
||||
|
||||
// temperatures
|
||||
// TODO: maybe refactor to methods on systemStats
|
||||
a.updateTemperatures(&systemStats)
|
||||
@@ -270,14 +302,109 @@ func (a *Agent) getSystemStats() system.Stats {
|
||||
a.systemInfo.MemPct = systemStats.MemPct
|
||||
a.systemInfo.DiskPct = systemStats.DiskPct
|
||||
a.systemInfo.Uptime, _ = host.Uptime()
|
||||
// TODO: in future release, remove MB bandwidth values in favor of bytes
|
||||
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
|
||||
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
|
||||
|
||||
// Sum all per-interface network sent/recv and assign to systemInfo
|
||||
var totalSent, totalRecv float64
|
||||
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)
|
||||
|
||||
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
|
||||
func getARCSize() (uint64, error) {
|
||||
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
|
||||
|
||||
@@ -38,7 +38,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
||||
case "Memory":
|
||||
val = data.Info.MemPct
|
||||
case "Bandwidth":
|
||||
val = data.Info.Bandwidth
|
||||
val = data.Info.NetworkSent + data.Info.NetworkRecv
|
||||
unit = " MB/s"
|
||||
case "Disk":
|
||||
maxUsedPct := data.Info.DiskPct
|
||||
|
||||
@@ -8,39 +8,47 @@ import (
|
||||
"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 {
|
||||
Cpu float64 `json:"cpu" cbor:"0,keyasint"`
|
||||
MaxCpu float64 `json:"cpum,omitempty" cbor:"1,keyasint,omitempty"`
|
||||
Mem float64 `json:"m" cbor:"2,keyasint"`
|
||||
MemUsed float64 `json:"mu" cbor:"3,keyasint"`
|
||||
MemPct float64 `json:"mp" cbor:"4,keyasint"`
|
||||
MemBuffCache float64 `json:"mb" cbor:"5,keyasint"`
|
||||
MemZfsArc float64 `json:"mz,omitempty" cbor:"6,keyasint,omitempty"` // ZFS ARC memory
|
||||
Swap float64 `json:"s,omitempty" cbor:"7,keyasint,omitempty"`
|
||||
SwapUsed float64 `json:"su,omitempty" cbor:"8,keyasint,omitempty"`
|
||||
DiskTotal float64 `json:"d" cbor:"9,keyasint"`
|
||||
DiskUsed float64 `json:"du" cbor:"10,keyasint"`
|
||||
DiskPct float64 `json:"dp" cbor:"11,keyasint"`
|
||||
DiskReadPs float64 `json:"dr" cbor:"12,keyasint"`
|
||||
DiskWritePs float64 `json:"dw" cbor:"13,keyasint"`
|
||||
MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"14,keyasint,omitempty"`
|
||||
MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"15,keyasint,omitempty"`
|
||||
NetworkSent float64 `json:"ns" cbor:"16,keyasint"`
|
||||
NetworkRecv float64 `json:"nr" cbor:"17,keyasint"`
|
||||
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"18,keyasint,omitempty"`
|
||||
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"19,keyasint,omitempty"`
|
||||
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
|
||||
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
|
||||
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
|
||||
LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"`
|
||||
LoadAvg5 float64 `json:"l5,omitempty" cbor:"24,keyasint,omitempty"`
|
||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"25,keyasint,omitempty"`
|
||||
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"26,keyasint,omitzero"` // [sent bytes, recv bytes]
|
||||
MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"27,keyasint,omitzero"` // [sent bytes, recv bytes]
|
||||
// TODO: remove other load fields in future release in favor of load avg array
|
||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
|
||||
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
|
||||
MaxMem float64 `json:"mm,omitempty" cbor:"30,keyasint,omitempty"`
|
||||
Cpu float64 `json:"cpu" cbor:"0,keyasint"`
|
||||
MaxCpu float64 `json:"cpum,omitempty" cbor:"1,keyasint,omitempty"`
|
||||
Mem float64 `json:"m" cbor:"2,keyasint"`
|
||||
MemUsed float64 `json:"mu" cbor:"3,keyasint"`
|
||||
MemPct float64 `json:"mp" cbor:"4,keyasint"`
|
||||
MemBuffCache float64 `json:"mb" cbor:"5,keyasint"`
|
||||
MemZfsArc float64 `json:"mz,omitempty" cbor:"6,keyasint,omitempty"` // ZFS ARC memory
|
||||
Swap float64 `json:"s,omitempty" cbor:"7,keyasint,omitempty"`
|
||||
SwapUsed float64 `json:"su,omitempty" cbor:"8,keyasint,omitempty"`
|
||||
DiskTotal float64 `json:"d" cbor:"9,keyasint"`
|
||||
DiskUsed float64 `json:"du" cbor:"10,keyasint"`
|
||||
DiskPct float64 `json:"dp" cbor:"11,keyasint"`
|
||||
DiskReadPs float64 `json:"dr" cbor:"12,keyasint"`
|
||||
DiskWritePs float64 `json:"dw" cbor:"13,keyasint"`
|
||||
MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"14,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:"17,keyasint"` // Total network sent (MB/s)
|
||||
NetworkRecv float64 `json:"nr" cbor:"18,keyasint"` // Total network recv (MB/s)
|
||||
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"19,keyasint,omitempty"`
|
||||
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"20,keyasint,omitempty"`
|
||||
Temperatures map[string]float64 `json:"t,omitempty" cbor:"21,keyasint,omitempty"`
|
||||
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"22,keyasint,omitempty"`
|
||||
GPUData map[string]GPUData `json:"g,omitempty" cbor:"23,keyasint,omitempty"`
|
||||
LoadAvg1 float64 `json:"l1,omitempty" cbor:"24,keyasint,omitempty"`
|
||||
LoadAvg5 float64 `json:"l5,omitempty" cbor:"25,keyasint,omitempty"`
|
||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"26,keyasint,omitempty"`
|
||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"27,keyasint"` // [1min, 5min, 15min]
|
||||
Battery [2]uint8 `json:"bat,omitzero" cbor:"28,keyasint,omitzero"` // [percent, charge state]
|
||||
MaxMem float64 `json:"mm,omitempty" cbor:"29,keyasint,omitempty"`
|
||||
Nets map[string]float64 `json:"nets,omitempty" cbor:"30,keyasint,omitempty"` // Network connection statistics
|
||||
}
|
||||
|
||||
type GPUData struct {
|
||||
@@ -68,10 +76,12 @@ type FsStats struct {
|
||||
}
|
||||
|
||||
type NetIoStats struct {
|
||||
BytesRecv uint64
|
||||
BytesSent uint64
|
||||
Time time.Time
|
||||
Name string
|
||||
BytesRecv uint64
|
||||
BytesSent uint64
|
||||
PacketsSent uint64
|
||||
PacketsRecv uint64
|
||||
Time time.Time
|
||||
Name string
|
||||
}
|
||||
|
||||
type Os = uint8
|
||||
@@ -84,27 +94,26 @@ const (
|
||||
)
|
||||
|
||||
type Info struct {
|
||||
Hostname string `json:"h" cbor:"0,keyasint"`
|
||||
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
|
||||
Cores int `json:"c" cbor:"2,keyasint"`
|
||||
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
|
||||
CpuModel string `json:"m" cbor:"4,keyasint"`
|
||||
Uptime uint64 `json:"u" cbor:"5,keyasint"`
|
||||
Cpu float64 `json:"cpu" cbor:"6,keyasint"`
|
||||
MemPct float64 `json:"mp" cbor:"7,keyasint"`
|
||||
DiskPct float64 `json:"dp" cbor:"8,keyasint"`
|
||||
Bandwidth float64 `json:"b" cbor:"9,keyasint"`
|
||||
AgentVersion string `json:"v" cbor:"10,keyasint"`
|
||||
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"`
|
||||
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
|
||||
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
|
||||
Os Os `json:"os" cbor:"14,keyasint"`
|
||||
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"`
|
||||
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"`
|
||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
||||
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
||||
// TODO: remove load fields in future release in favor of load avg array
|
||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
||||
Hostname string `json:"h" cbor:"0,keyasint"`
|
||||
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
|
||||
Cores int `json:"c" cbor:"2,keyasint"`
|
||||
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
|
||||
CpuModel string `json:"m" cbor:"4,keyasint"`
|
||||
Uptime uint64 `json:"u" cbor:"5,keyasint"`
|
||||
Cpu float64 `json:"cpu" cbor:"6,keyasint"`
|
||||
MemPct float64 `json:"mp" cbor:"7,keyasint"`
|
||||
DiskPct float64 `json:"dp" cbor:"8,keyasint"`
|
||||
NetworkSent float64 `json:"ns" cbor:"9,keyasint"` // Per-interface total (MB/s)
|
||||
NetworkRecv float64 `json:"nr" cbor:"10,keyasint"` // Per-interface total (MB/s)
|
||||
AgentVersion string `json:"v" cbor:"11,keyasint"`
|
||||
Podman bool `json:"p,omitempty" cbor:"12,keyasint,omitempty"`
|
||||
GpuPct float64 `json:"g,omitempty" cbor:"13,keyasint,omitempty"`
|
||||
DashboardTemp float64 `json:"dt,omitempty" cbor:"14,keyasint,omitempty"`
|
||||
Os Os `json:"os" cbor:"15,keyasint"`
|
||||
LoadAvg1 float64 `json:"l1,omitempty" cbor:"16,keyasint,omitempty"`
|
||||
LoadAvg5 float64 `json:"l5,omitempty" cbor:"17,keyasint,omitempty"`
|
||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"18,keyasint,omitempty"`
|
||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"` // [1min, 5min, 15min]
|
||||
}
|
||||
|
||||
// Final data structure to return to the hub
|
||||
|
||||
@@ -206,15 +206,51 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
||||
sum.DiskPct += stats.DiskPct
|
||||
sum.DiskReadPs += stats.DiskReadPs
|
||||
sum.DiskWritePs += stats.DiskWritePs
|
||||
sum.LoadAvg1 += stats.LoadAvg1
|
||||
sum.LoadAvg5 += stats.LoadAvg5
|
||||
sum.LoadAvg15 += stats.LoadAvg15
|
||||
sum.NetworkSent += stats.NetworkSent
|
||||
sum.NetworkRecv += stats.NetworkRecv
|
||||
sum.LoadAvg[0] += stats.LoadAvg[0]
|
||||
sum.LoadAvg[1] += stats.LoadAvg[1]
|
||||
sum.LoadAvg[2] += stats.LoadAvg[2]
|
||||
sum.Bandwidth[0] += stats.Bandwidth[0]
|
||||
sum.Bandwidth[1] += stats.Bandwidth[1]
|
||||
batterySum += int(stats.Battery[0])
|
||||
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
|
||||
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
|
||||
sum.MaxMem = max(sum.MaxMem, stats.MaxMem, stats.MemUsed)
|
||||
@@ -222,8 +258,6 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
||||
sum.MaxNetworkRecv = max(sum.MaxNetworkRecv, stats.MaxNetworkRecv, stats.NetworkRecv)
|
||||
sum.MaxDiskReadPs = max(sum.MaxDiskReadPs, stats.MaxDiskReadPs, stats.DiskReadPs)
|
||||
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 temperatures
|
||||
if stats.Temperatures != nil {
|
||||
@@ -291,14 +325,26 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
||||
sum.DiskPct = twoDecimals(sum.DiskPct / count)
|
||||
sum.DiskReadPs = twoDecimals(sum.DiskReadPs / 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.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
|
||||
sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count)
|
||||
sum.LoadAvg[1] = twoDecimals(sum.LoadAvg[1] / 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))
|
||||
|
||||
if sum.NetworkInterfaces != nil {
|
||||
for key := range sum.NetworkInterfaces {
|
||||
ni := sum.NetworkInterfaces[key]
|
||||
ni.NetworkSent = twoDecimals(ni.NetworkSent / count)
|
||||
ni.NetworkRecv = twoDecimals(ni.NetworkRecv / count)
|
||||
ni.MaxNetworkSent = twoDecimals(max(ni.MaxNetworkSent, ni.NetworkSent))
|
||||
ni.MaxNetworkRecv = twoDecimals(max(ni.MaxNetworkRecv, ni.NetworkRecv))
|
||||
sum.NetworkInterfaces[key] = ni
|
||||
}
|
||||
}
|
||||
// Average temperatures
|
||||
if sum.Temperatures != nil && tempCount > 0 {
|
||||
for key := range sum.Temperatures {
|
||||
@@ -363,19 +409,15 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
|
||||
}
|
||||
sums[stat.Name].Cpu += stat.Cpu
|
||||
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))
|
||||
for _, value := range sums {
|
||||
result = append(result, container.Stats{
|
||||
Name: value.Name,
|
||||
Cpu: twoDecimals(value.Cpu / count),
|
||||
Mem: twoDecimals(value.Mem / count),
|
||||
NetworkSent: twoDecimals(value.NetworkSent / count),
|
||||
NetworkRecv: twoDecimals(value.NetworkRecv / count),
|
||||
Name: value.Name,
|
||||
Cpu: twoDecimals(value.Cpu / count),
|
||||
Mem: twoDecimals(value.Mem / count),
|
||||
})
|
||||
}
|
||||
return result
|
||||
|
||||
863
internal/site/package-lock.json
generated
863
internal/site/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
124
internal/site/src/components/charts/connection-chart.tsx
Normal file
124
internal/site/src/components/charts/connection-chart.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
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>
|
||||
)
|
||||
})
|
||||
164
internal/site/src/components/charts/network-interface-chart.tsx
Normal file
164
internal/site/src/components/charts/network-interface-chart.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
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>
|
||||
)
|
||||
})
|
||||
159
internal/site/src/components/charts/total-bandwidth-chart.tsx
Normal file
159
internal/site/src/components/charts/total-bandwidth-chart.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
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>
|
||||
)
|
||||
})
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
$containerFilter,
|
||||
$direction,
|
||||
$maxValues,
|
||||
$networkInterfaceFilter,
|
||||
$systems,
|
||||
$temperatureFilter,
|
||||
$userSettings,
|
||||
@@ -52,6 +53,9 @@ import { Input } from "../ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
|
||||
import { Separator } from "../ui/separator"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
|
||||
import ConnectionChart from "../charts/connection-chart"
|
||||
import NetworkInterfaceChart from "../charts/network-interface-chart"
|
||||
import TotalBandwidthChart from "../charts/total-bandwidth-chart"
|
||||
|
||||
type ChartTimeData = {
|
||||
time: number
|
||||
@@ -147,6 +151,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
||||
const netCardRef = useRef<HTMLDivElement>(null)
|
||||
const persistChartTime = useRef(false)
|
||||
const [containerFilterBar, setContainerFilterBar] = useState(null as null | JSX.Element)
|
||||
const [networkInterfaceFilterBar, setNetworkInterfaceFilterBar] = useState(null as null | JSX.Element)
|
||||
const [bottomSpacing, setBottomSpacing] = useState(0)
|
||||
const [chartLoading, setChartLoading] = useState(true)
|
||||
const isLongerChart = chartTime !== "1h"
|
||||
@@ -163,7 +168,9 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
||||
setSystemStats([])
|
||||
setContainerData([])
|
||||
setContainerFilterBar(null)
|
||||
setNetworkInterfaceFilterBar(null)
|
||||
$containerFilter.set("")
|
||||
$networkInterfaceFilter.set("")
|
||||
}
|
||||
}, [name])
|
||||
|
||||
@@ -260,6 +267,19 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
||||
})
|
||||
}, [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
|
||||
const systemInfo = useMemo(() => {
|
||||
if (!system.info) {
|
||||
@@ -389,6 +409,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
||||
const lastGpuVals = Object.values(systemStats.at(-1)?.stats.g ?? {})
|
||||
const hasGpuData = lastGpuVals.length > 0
|
||||
const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined)
|
||||
const latestNetworkStats = systemStats.at(-1)?.stats.ni
|
||||
|
||||
let translatedStatus: string = system.status
|
||||
if (system.status === SystemStatus.Up) {
|
||||
@@ -555,7 +576,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`Disk I/O`}
|
||||
description={t`Throughput of root filesystem`}
|
||||
description={t`Disk read and write throughput`}
|
||||
cornerEl={maxValSelect}
|
||||
>
|
||||
<AreaChartDefault
|
||||
@@ -586,51 +607,44 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
||||
/>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`Bandwidth`}
|
||||
cornerEl={maxValSelect}
|
||||
description={t`Network traffic of public interfaces`}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
maxToggled={maxValues}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`Sent`,
|
||||
// use bytes if available, otherwise multiply old MB (can remove in future)
|
||||
dataKey(data) {
|
||||
if (showMax) {
|
||||
return data?.stats?.bm?.[0] ?? (data?.stats?.nsm ?? 0) * 1024 * 1024
|
||||
}
|
||||
return data?.stats?.b?.[0] ?? data?.stats?.ns * 1024 * 1024
|
||||
},
|
||||
color: 5,
|
||||
opacity: 0.2,
|
||||
},
|
||||
{
|
||||
label: t`Received`,
|
||||
dataKey(data) {
|
||||
if (showMax) {
|
||||
return data?.stats?.bm?.[1] ?? (data?.stats?.nrm ?? 0) * 1024 * 1024
|
||||
}
|
||||
return data?.stats?.b?.[1] ?? data?.stats?.nr * 1024 * 1024
|
||||
},
|
||||
color: 2,
|
||||
opacity: 0.2,
|
||||
},
|
||||
]}
|
||||
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>
|
||||
{/* Network interface charts */}
|
||||
{Object.keys(latestNetworkStats ?? {}).length > 0 && (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`Network Interfaces`}
|
||||
description={t`Network traffic per interface`}
|
||||
cornerEl={networkInterfaceFilterBar}
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
<NetworkInterfaceChart chartData={chartData} />
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
{/* Per-Interface Cumulative Bandwidth chart */}
|
||||
{Object.keys(latestNetworkStats ?? {}).length > 0 && (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`Cumulative Bandwidth`}
|
||||
description={t`Total bytes sent and received per network interface since boot`}
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
<TotalBandwidthChart chartData={chartData} />
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
{/* TCP Connection States chart */}
|
||||
{systemStats.at(-1)?.stats.nets && Object.keys(systemStats.at(-1)?.stats.nets ?? {}).length > 0 && (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`TCP Connection States`}
|
||||
description={t`TCP connection states for IPv4 and IPv6`}
|
||||
>
|
||||
<ConnectionChart chartData={chartData} />
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
{containerFilterBar && containerData.length > 0 && (
|
||||
<div
|
||||
|
||||
@@ -216,22 +216,32 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: ({ info }) => info.bb || (info.b || 0) * 1024 * 1024,
|
||||
accessorFn: (row) => (row.info.ns || 0) + (row.info.nr || 0),
|
||||
id: "net",
|
||||
name: () => t`Net`,
|
||||
size: 0,
|
||||
Icon: EthernetIcon,
|
||||
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) {
|
||||
const sys = info.row.original
|
||||
const system = info.row.original
|
||||
const sent = system.info.ns || 0
|
||||
const received = system.info.nr || 0
|
||||
const userSettings = useStore($userSettings, { keys: ["unitNet"] })
|
||||
if (sys.status === SystemStatus.Paused) {
|
||||
if (system.status === SystemStatus.Paused) {
|
||||
return null
|
||||
}
|
||||
const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, false)
|
||||
const sentFmt = formatBytes(sent, true, userSettings.unitNet, true)
|
||||
const receivedFmt = formatBytes(received, true, userSettings.unitNet, true)
|
||||
return (
|
||||
<span className="tabular-nums whitespace-nowrap">
|
||||
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
||||
<span className={cn("tabular-nums whitespace-nowrap", { "ps-1": viewMode === "table" })}>
|
||||
<span className="text-green-600">↑</span> {Math.round(sentFmt.value)} {sentFmt.unit}{" "}
|
||||
<span className="text-blue-600">↓</span> {Math.round(receivedFmt.value)} {receivedFmt.unit}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -55,6 +55,9 @@ listenKeys($userSettings, ["chartTime"], ({ chartTime }) => $chartTime.set(chart
|
||||
/** Container chart filter */
|
||||
export const $containerFilter = atom("")
|
||||
|
||||
/** Network interface chart filter */
|
||||
export const $networkInterfaceFilter = atom("")
|
||||
|
||||
/** Temperature chart filter */
|
||||
export const $temperatureFilter = atom("")
|
||||
|
||||
|
||||
@@ -283,6 +283,22 @@ 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) */
|
||||
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 */
|
||||
export function formatDuration(
|
||||
createdDate: string | null | undefined,
|
||||
|
||||
25
internal/site/src/types.d.ts
vendored
25
internal/site/src/types.d.ts
vendored
@@ -75,6 +75,25 @@ export interface SystemInfo {
|
||||
dt?: number
|
||||
/** operating system */
|
||||
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 {
|
||||
@@ -131,7 +150,9 @@ export interface SystemStats {
|
||||
nsm?: number
|
||||
/** max network received (mb) */
|
||||
nrm?: number
|
||||
/** max network sent (bytes) */
|
||||
/** per-interface network stats */
|
||||
ni?: Record<string, NetworkInterfaceStats>
|
||||
/** max bandwidth (bytes) [sent, received] */
|
||||
bm?: [number, number]
|
||||
/** temperatures */
|
||||
t?: Record<string, number>
|
||||
@@ -141,6 +162,8 @@ export interface SystemStats {
|
||||
g?: Record<string, GPUData>
|
||||
/** battery percent and state */
|
||||
bat?: [number, BatteryState]
|
||||
/** network connection statistics */
|
||||
nets?: Record<string, number>
|
||||
}
|
||||
|
||||
export interface GPUData {
|
||||
|
||||
Reference in New Issue
Block a user