mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-22 20:51:50 +02:00
Compare commits
3 Commits
261f7fb76c
...
8a2bee11d4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a2bee11d4 | ||
|
|
485f7d16ff | ||
|
|
46fdc94cb8 |
391
beszel/internal/agent/client_test.go
Normal file
391
beszel/internal/agent/client_test.go
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/internal/common"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNewWebSocketClient tests WebSocket client creation
|
||||||
|
func TestNewWebSocketClient(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
hubURL string
|
||||||
|
token string
|
||||||
|
expectError bool
|
||||||
|
errorMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid configuration",
|
||||||
|
hubURL: "http://localhost:8080",
|
||||||
|
token: "test-token-123",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid https URL",
|
||||||
|
hubURL: "https://hub.example.com",
|
||||||
|
token: "secure-token",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing hub URL",
|
||||||
|
hubURL: "",
|
||||||
|
token: "test-token",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "HUB_URL environment variable not set",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid URL",
|
||||||
|
hubURL: "ht\ttp://invalid",
|
||||||
|
token: "test-token",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "invalid hub URL",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing token",
|
||||||
|
hubURL: "http://localhost:8080",
|
||||||
|
token: "",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "TOKEN environment variable not set",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Set up environment
|
||||||
|
if tc.hubURL != "" {
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", tc.hubURL)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
}
|
||||||
|
if tc.token != "" {
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", tc.token)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
|
||||||
|
if tc.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
if err != nil && tc.errorMsg != "" {
|
||||||
|
assert.Contains(t, err.Error(), tc.errorMsg)
|
||||||
|
}
|
||||||
|
assert.Nil(t, client)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, client)
|
||||||
|
assert.Equal(t, agent, client.agent)
|
||||||
|
assert.Equal(t, tc.token, client.token)
|
||||||
|
assert.Equal(t, tc.hubURL, client.hubURL.String())
|
||||||
|
assert.NotEmpty(t, client.fingerprint)
|
||||||
|
assert.NotNil(t, client.hubRequest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSocketClient_GetOptions tests WebSocket client options configuration
|
||||||
|
func TestWebSocketClient_GetOptions(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
inputURL string
|
||||||
|
expectedScheme string
|
||||||
|
expectedPath string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "http to ws conversion",
|
||||||
|
inputURL: "http://localhost:8080",
|
||||||
|
expectedScheme: "ws",
|
||||||
|
expectedPath: "/api/beszel/agent-connect",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "https to wss conversion",
|
||||||
|
inputURL: "https://hub.example.com",
|
||||||
|
expectedScheme: "wss",
|
||||||
|
expectedPath: "/api/beszel/agent-connect",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "existing path preservation",
|
||||||
|
inputURL: "http://localhost:8080/custom/path",
|
||||||
|
expectedScheme: "ws",
|
||||||
|
expectedPath: "/custom/path/api/beszel/agent-connect",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Set up environment
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", tc.inputURL)
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
options := client.getOptions()
|
||||||
|
|
||||||
|
// Parse the WebSocket URL
|
||||||
|
wsURL, err := url.Parse(options.Addr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, tc.expectedScheme, wsURL.Scheme)
|
||||||
|
assert.Equal(t, tc.expectedPath, wsURL.Path)
|
||||||
|
|
||||||
|
// Check headers
|
||||||
|
assert.Equal(t, "test-token", options.RequestHeader.Get("X-Token"))
|
||||||
|
assert.Equal(t, beszel.Version, options.RequestHeader.Get("X-Beszel"))
|
||||||
|
assert.Contains(t, options.RequestHeader.Get("User-Agent"), "Mozilla/5.0")
|
||||||
|
|
||||||
|
// Test options caching
|
||||||
|
options2 := client.getOptions()
|
||||||
|
assert.Same(t, options, options2, "Options should be cached")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSocketClient_VerifySignature tests signature verification
|
||||||
|
func TestWebSocketClient_VerifySignature(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
|
// Generate test key pairs
|
||||||
|
_, goodPrivKey, err := ed25519.GenerateKey(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
goodPubKey, err := ssh.NewPublicKey(goodPrivKey.Public().(ed25519.PublicKey))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, badPrivKey, err := ed25519.GenerateKey(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
badPubKey, err := ssh.NewPublicKey(badPrivKey.Public().(ed25519.PublicKey))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set up environment
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
keys []ssh.PublicKey
|
||||||
|
token string
|
||||||
|
signWith ed25519.PrivateKey
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid signature with correct key",
|
||||||
|
keys: []ssh.PublicKey{goodPubKey},
|
||||||
|
token: "test-token",
|
||||||
|
signWith: goodPrivKey,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid signature with wrong key",
|
||||||
|
keys: []ssh.PublicKey{goodPubKey},
|
||||||
|
token: "test-token",
|
||||||
|
signWith: badPrivKey,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid signature with multiple keys",
|
||||||
|
keys: []ssh.PublicKey{badPubKey, goodPubKey},
|
||||||
|
token: "test-token",
|
||||||
|
signWith: goodPrivKey,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no valid keys",
|
||||||
|
keys: []ssh.PublicKey{badPubKey},
|
||||||
|
token: "test-token",
|
||||||
|
signWith: goodPrivKey,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Set up agent with test keys
|
||||||
|
agent.keys = tc.keys
|
||||||
|
client.token = tc.token
|
||||||
|
|
||||||
|
// Create signature
|
||||||
|
signature := ed25519.Sign(tc.signWith, []byte(tc.token))
|
||||||
|
|
||||||
|
err := client.verifySignature(signature)
|
||||||
|
|
||||||
|
if tc.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "invalid signature")
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSocketClient_HandleHubRequest tests hub request routing (basic verification logic)
|
||||||
|
func TestWebSocketClient_HandleHubRequest(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
|
// Set up environment
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
action common.WebSocketAction
|
||||||
|
hubVerified bool
|
||||||
|
expectError bool
|
||||||
|
errorMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "CheckFingerprint without verification",
|
||||||
|
action: common.CheckFingerprint,
|
||||||
|
hubVerified: false,
|
||||||
|
expectError: false, // CheckFingerprint is allowed without verification
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GetData without verification",
|
||||||
|
action: common.GetData,
|
||||||
|
hubVerified: false,
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "hub not verified",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
client.hubVerified = tc.hubVerified
|
||||||
|
|
||||||
|
// Create minimal request
|
||||||
|
hubRequest := &common.HubRequest[cbor.RawMessage]{
|
||||||
|
Action: tc.action,
|
||||||
|
Data: cbor.RawMessage{},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := client.handleHubRequest(hubRequest)
|
||||||
|
|
||||||
|
if tc.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
if tc.errorMsg != "" {
|
||||||
|
assert.Contains(t, err.Error(), tc.errorMsg)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For CheckFingerprint, we expect a decode error since we're not providing valid data,
|
||||||
|
// but it shouldn't be the "hub not verified" error
|
||||||
|
if err != nil && tc.errorMsg != "" {
|
||||||
|
assert.NotContains(t, err.Error(), tc.errorMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSocketClient_GetUserAgent tests user agent generation
|
||||||
|
func TestGetUserAgent(t *testing.T) {
|
||||||
|
// Run multiple times to check both variants
|
||||||
|
userAgents := make(map[string]bool)
|
||||||
|
|
||||||
|
for range 20 {
|
||||||
|
ua := getUserAgent()
|
||||||
|
userAgents[ua] = true
|
||||||
|
|
||||||
|
// Check that it's a valid Mozilla user agent
|
||||||
|
assert.Contains(t, ua, "Mozilla/5.0")
|
||||||
|
assert.Contains(t, ua, "AppleWebKit/537.36")
|
||||||
|
assert.Contains(t, ua, "Chrome/124.0.0.0")
|
||||||
|
assert.Contains(t, ua, "Safari/537.36")
|
||||||
|
|
||||||
|
// Should contain either Windows or Mac
|
||||||
|
isWindows := strings.Contains(ua, "Windows NT 11.0")
|
||||||
|
isMac := strings.Contains(ua, "Macintosh; Intel Mac OS X 14_0_0")
|
||||||
|
assert.True(t, isWindows || isMac, "User agent should contain either Windows or Mac identifier")
|
||||||
|
}
|
||||||
|
|
||||||
|
// With enough iterations, we should see both variants
|
||||||
|
// though this might occasionally fail
|
||||||
|
if len(userAgents) == 1 {
|
||||||
|
t.Log("Note: Only one user agent variant was generated in this test run")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSocketClient_Close tests connection closing
|
||||||
|
func TestWebSocketClient_Close(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
|
// Set up environment
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Test closing with nil connection (should not panic)
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
client.Close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSocketClient_ConnectRateLimit tests connection rate limiting
|
||||||
|
func TestWebSocketClient_ConnectRateLimit(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
|
// Set up environment
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set recent connection attempt
|
||||||
|
client.lastConnectAttempt = time.Now()
|
||||||
|
|
||||||
|
// Test that connection fails quickly due to rate limiting
|
||||||
|
// This won't actually connect but should fail fast
|
||||||
|
err = client.Connect()
|
||||||
|
assert.Error(t, err, "Connection should fail but not hang")
|
||||||
|
}
|
||||||
@@ -174,24 +174,27 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
a.initializeNetIoStats()
|
a.initializeNetIoStats()
|
||||||
}
|
}
|
||||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||||
secondsElapsed := time.Since(a.netIoStats.Time).Seconds()
|
msElapsed := uint64(time.Since(a.netIoStats.Time).Milliseconds())
|
||||||
a.netIoStats.Time = time.Now()
|
a.netIoStats.Time = time.Now()
|
||||||
bytesSent := uint64(0)
|
totalBytesSent := uint64(0)
|
||||||
bytesRecv := uint64(0)
|
totalBytesRecv := uint64(0)
|
||||||
// sum all bytes sent and received
|
// sum all bytes sent and received
|
||||||
for _, v := range netIO {
|
for _, v := range netIO {
|
||||||
// skip if not in valid network interfaces list
|
// skip if not in valid network interfaces list
|
||||||
if _, exists := a.netInterfaces[v.Name]; !exists {
|
if _, exists := a.netInterfaces[v.Name]; !exists {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
bytesSent += v.BytesSent
|
totalBytesSent += v.BytesSent
|
||||||
bytesRecv += v.BytesRecv
|
totalBytesRecv += v.BytesRecv
|
||||||
}
|
}
|
||||||
// add to systemStats
|
// add to systemStats
|
||||||
sentPerSecond := float64(bytesSent-a.netIoStats.BytesSent) / secondsElapsed
|
var bytesSentPerSecond, bytesRecvPerSecond uint64
|
||||||
recvPerSecond := float64(bytesRecv-a.netIoStats.BytesRecv) / secondsElapsed
|
if msElapsed > 0 {
|
||||||
networkSentPs := bytesToMegabytes(sentPerSecond)
|
bytesSentPerSecond = (totalBytesSent - a.netIoStats.BytesSent) * 1000 / msElapsed
|
||||||
networkRecvPs := bytesToMegabytes(recvPerSecond)
|
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
|
// add check for issue (#150) where sent is a massive number
|
||||||
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
|
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
|
||||||
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
|
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
|
||||||
@@ -206,9 +209,10 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
} else {
|
} else {
|
||||||
systemStats.NetworkSent = networkSentPs
|
systemStats.NetworkSent = networkSentPs
|
||||||
systemStats.NetworkRecv = networkRecvPs
|
systemStats.NetworkRecv = networkRecvPs
|
||||||
|
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
|
||||||
// update netIoStats
|
// update netIoStats
|
||||||
a.netIoStats.BytesSent = bytesSent
|
a.netIoStats.BytesSent = totalBytesSent
|
||||||
a.netIoStats.BytesRecv = bytesRecv
|
a.netIoStats.BytesRecv = totalBytesRecv
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +261,9 @@ 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
|
||||||
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
|
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
|
||||||
|
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
|
||||||
slog.Debug("sysinfo", "data", a.systemInfo)
|
slog.Debug("sysinfo", "data", a.systemInfo)
|
||||||
|
|
||||||
return systemStats
|
return systemStats
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ type Stats struct {
|
|||||||
LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty,omitzero"`
|
LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty,omitzero"`
|
||||||
LoadAvg5 float64 `json:"l5,omitempty" cbor:"24,keyasint,omitempty,omitzero"`
|
LoadAvg5 float64 `json:"l5,omitempty" cbor:"24,keyasint,omitempty,omitzero"`
|
||||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"25,keyasint,omitempty,omitzero"`
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"25,keyasint,omitempty,omitzero"`
|
||||||
|
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]
|
||||||
}
|
}
|
||||||
|
|
||||||
type GPUData struct {
|
type GPUData struct {
|
||||||
@@ -95,6 +97,7 @@ type Info struct {
|
|||||||
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"`
|
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"`
|
||||||
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"`
|
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"`
|
||||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
||||||
|
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final data structure to return to the hub
|
// Final data structure to return to the hub
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ func (ws *WsConn) Ping() error {
|
|||||||
|
|
||||||
// sendMessage encodes data to CBOR and sends it as a binary message to the agent.
|
// sendMessage encodes data to CBOR and sends it as a binary message to the agent.
|
||||||
func (ws *WsConn) sendMessage(data common.HubRequest[any]) error {
|
func (ws *WsConn) sendMessage(data common.HubRequest[any]) error {
|
||||||
|
if ws.conn == nil {
|
||||||
|
return gws.ErrConnClosed
|
||||||
|
}
|
||||||
bytes, err := cbor.Marshal(data)
|
bytes, err := cbor.Marshal(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
221
beszel/internal/hub/ws/ws_test.go
Normal file
221
beszel/internal/hub/ws/ws_test.go
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/common"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGetUpgrader tests the singleton upgrader
|
||||||
|
func TestGetUpgrader(t *testing.T) {
|
||||||
|
// Reset the global upgrader to test singleton behavior
|
||||||
|
upgrader = nil
|
||||||
|
|
||||||
|
// First call should create the upgrader
|
||||||
|
upgrader1 := GetUpgrader()
|
||||||
|
assert.NotNil(t, upgrader1, "Upgrader should not be nil")
|
||||||
|
|
||||||
|
// Second call should return the same instance
|
||||||
|
upgrader2 := GetUpgrader()
|
||||||
|
assert.Same(t, upgrader1, upgrader2, "Should return the same upgrader instance")
|
||||||
|
|
||||||
|
// Verify it's properly configured
|
||||||
|
assert.NotNil(t, upgrader1, "Upgrader should be configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewWsConnection tests WebSocket connection creation
|
||||||
|
func TestNewWsConnection(t *testing.T) {
|
||||||
|
// We can't easily mock gws.Conn, so we'll pass nil and test the structure
|
||||||
|
wsConn := NewWsConnection(nil)
|
||||||
|
|
||||||
|
assert.NotNil(t, wsConn, "WebSocket connection should not be nil")
|
||||||
|
assert.Nil(t, wsConn.conn, "Connection should be nil as passed")
|
||||||
|
assert.NotNil(t, wsConn.responseChan, "Response channel should be initialized")
|
||||||
|
assert.NotNil(t, wsConn.DownChan, "Down channel should be initialized")
|
||||||
|
assert.Equal(t, 1, cap(wsConn.responseChan), "Response channel should have capacity of 1")
|
||||||
|
assert.Equal(t, 1, cap(wsConn.DownChan), "Down channel should have capacity of 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWsConn_IsConnected tests the connection status check
|
||||||
|
func TestWsConn_IsConnected(t *testing.T) {
|
||||||
|
// Test with nil connection
|
||||||
|
wsConn := NewWsConnection(nil)
|
||||||
|
assert.False(t, wsConn.IsConnected(), "Should not be connected when conn is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWsConn_Close tests the connection closing with nil connection
|
||||||
|
func TestWsConn_Close(t *testing.T) {
|
||||||
|
wsConn := NewWsConnection(nil)
|
||||||
|
|
||||||
|
// Should handle nil connection gracefully
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
wsConn.Close([]byte("test message"))
|
||||||
|
}, "Should not panic when closing nil connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWsConn_SendMessage_CBOR tests CBOR encoding in sendMessage
|
||||||
|
func TestWsConn_SendMessage_CBOR(t *testing.T) {
|
||||||
|
wsConn := NewWsConnection(nil)
|
||||||
|
|
||||||
|
testData := common.HubRequest[any]{
|
||||||
|
Action: common.GetData,
|
||||||
|
Data: "test data",
|
||||||
|
}
|
||||||
|
|
||||||
|
// This will fail because conn is nil, but we can test the CBOR encoding logic
|
||||||
|
// by checking that the function properly encodes to CBOR before failing
|
||||||
|
err := wsConn.sendMessage(testData)
|
||||||
|
assert.Error(t, err, "Should error with nil connection")
|
||||||
|
|
||||||
|
// Test CBOR encoding separately
|
||||||
|
bytes, err := cbor.Marshal(testData)
|
||||||
|
assert.NoError(t, err, "Should encode to CBOR successfully")
|
||||||
|
|
||||||
|
// Verify we can decode it back
|
||||||
|
var decodedData common.HubRequest[any]
|
||||||
|
err = cbor.Unmarshal(bytes, &decodedData)
|
||||||
|
assert.NoError(t, err, "Should decode from CBOR successfully")
|
||||||
|
assert.Equal(t, testData.Action, decodedData.Action, "Action should match")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWsConn_GetFingerprint_SignatureGeneration tests signature creation logic
|
||||||
|
func TestWsConn_GetFingerprint_SignatureGeneration(t *testing.T) {
|
||||||
|
// Generate test key pair
|
||||||
|
_, privKey, err := ed25519.GenerateKey(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
signer, err := ssh.NewSignerFromKey(privKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
token := "test-token"
|
||||||
|
|
||||||
|
// This will timeout since conn is nil, but we can verify the signature logic
|
||||||
|
// We can't test the full flow, but we can test that the signature is created properly
|
||||||
|
challenge := []byte(token)
|
||||||
|
signature, err := signer.Sign(nil, challenge)
|
||||||
|
assert.NoError(t, err, "Should create signature successfully")
|
||||||
|
assert.NotEmpty(t, signature.Blob, "Signature blob should not be empty")
|
||||||
|
assert.Equal(t, signer.PublicKey().Type(), signature.Format, "Signature format should match key type")
|
||||||
|
|
||||||
|
// Test the fingerprint request structure
|
||||||
|
fpRequest := common.FingerprintRequest{
|
||||||
|
Signature: signature.Blob,
|
||||||
|
NeedSysInfo: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test CBOR encoding of fingerprint request
|
||||||
|
fpData, err := cbor.Marshal(fpRequest)
|
||||||
|
assert.NoError(t, err, "Should encode fingerprint request to CBOR")
|
||||||
|
|
||||||
|
var decodedFpRequest common.FingerprintRequest
|
||||||
|
err = cbor.Unmarshal(fpData, &decodedFpRequest)
|
||||||
|
assert.NoError(t, err, "Should decode fingerprint request from CBOR")
|
||||||
|
assert.Equal(t, fpRequest.Signature, decodedFpRequest.Signature, "Signature should match")
|
||||||
|
assert.Equal(t, fpRequest.NeedSysInfo, decodedFpRequest.NeedSysInfo, "NeedSysInfo should match")
|
||||||
|
|
||||||
|
// Test the full hub request structure
|
||||||
|
hubRequest := common.HubRequest[any]{
|
||||||
|
Action: common.CheckFingerprint,
|
||||||
|
Data: fpRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
hubData, err := cbor.Marshal(hubRequest)
|
||||||
|
assert.NoError(t, err, "Should encode hub request to CBOR")
|
||||||
|
|
||||||
|
var decodedHubRequest common.HubRequest[cbor.RawMessage]
|
||||||
|
err = cbor.Unmarshal(hubData, &decodedHubRequest)
|
||||||
|
assert.NoError(t, err, "Should decode hub request from CBOR")
|
||||||
|
assert.Equal(t, common.CheckFingerprint, decodedHubRequest.Action, "Action should be CheckFingerprint")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWsConn_RequestSystemData_RequestFormat tests system data request format
|
||||||
|
func TestWsConn_RequestSystemData_RequestFormat(t *testing.T) {
|
||||||
|
// Test the request format that would be sent
|
||||||
|
request := common.HubRequest[any]{
|
||||||
|
Action: common.GetData,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test CBOR encoding
|
||||||
|
data, err := cbor.Marshal(request)
|
||||||
|
assert.NoError(t, err, "Should encode request to CBOR")
|
||||||
|
|
||||||
|
// Test decoding
|
||||||
|
var decodedRequest common.HubRequest[any]
|
||||||
|
err = cbor.Unmarshal(data, &decodedRequest)
|
||||||
|
assert.NoError(t, err, "Should decode request from CBOR")
|
||||||
|
assert.Equal(t, common.GetData, decodedRequest.Action, "Should have GetData action")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFingerprintRecord tests the FingerprintRecord struct
|
||||||
|
func TestFingerprintRecord(t *testing.T) {
|
||||||
|
record := FingerprintRecord{
|
||||||
|
Id: "test-id",
|
||||||
|
SystemId: "system-123",
|
||||||
|
Fingerprint: "test-fingerprint",
|
||||||
|
Token: "test-token",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "test-id", record.Id)
|
||||||
|
assert.Equal(t, "system-123", record.SystemId)
|
||||||
|
assert.Equal(t, "test-fingerprint", record.Fingerprint)
|
||||||
|
assert.Equal(t, "test-token", record.Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeadlineConstant tests that the deadline constant is reasonable
|
||||||
|
func TestDeadlineConstant(t *testing.T) {
|
||||||
|
assert.Equal(t, 70*time.Second, deadline, "Deadline should be 70 seconds")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCommonActions tests that the common actions are properly defined
|
||||||
|
func TestCommonActions(t *testing.T) {
|
||||||
|
// Test that the actions we use exist and have expected values
|
||||||
|
assert.Equal(t, common.WebSocketAction(0), common.GetData, "GetData should be action 0")
|
||||||
|
assert.Equal(t, common.WebSocketAction(1), common.CheckFingerprint, "CheckFingerprint should be action 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandler tests that we can create a Handler
|
||||||
|
func TestHandler(t *testing.T) {
|
||||||
|
handler := &Handler{}
|
||||||
|
assert.NotNil(t, handler, "Handler should be created successfully")
|
||||||
|
|
||||||
|
// The Handler embeds gws.BuiltinEventHandler, so it should have the embedded type
|
||||||
|
assert.NotNil(t, handler.BuiltinEventHandler, "Should have embedded BuiltinEventHandler")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWsConnChannelBehavior tests channel behavior without WebSocket connections
|
||||||
|
func TestWsConnChannelBehavior(t *testing.T) {
|
||||||
|
wsConn := NewWsConnection(nil)
|
||||||
|
|
||||||
|
// Test that channels are properly initialized and can be used
|
||||||
|
select {
|
||||||
|
case wsConn.DownChan <- struct{}{}:
|
||||||
|
// Should be able to write to channel
|
||||||
|
default:
|
||||||
|
t.Error("Should be able to write to DownChan")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test reading from DownChan
|
||||||
|
select {
|
||||||
|
case <-wsConn.DownChan:
|
||||||
|
// Should be able to read from channel
|
||||||
|
case <-time.After(10 * time.Millisecond):
|
||||||
|
t.Error("Should be able to read from DownChan")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response channel should be empty initially
|
||||||
|
select {
|
||||||
|
case <-wsConn.responseChan:
|
||||||
|
t.Error("Response channel should be empty initially")
|
||||||
|
default:
|
||||||
|
// Expected - channel should be empty
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -206,12 +206,16 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.LoadAvg1 += stats.LoadAvg1
|
sum.LoadAvg1 += stats.LoadAvg1
|
||||||
sum.LoadAvg5 += stats.LoadAvg5
|
sum.LoadAvg5 += stats.LoadAvg5
|
||||||
sum.LoadAvg15 += stats.LoadAvg15
|
sum.LoadAvg15 += stats.LoadAvg15
|
||||||
|
sum.Bandwidth[0] += stats.Bandwidth[0]
|
||||||
|
sum.Bandwidth[1] += stats.Bandwidth[1]
|
||||||
// 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.MaxNetworkSent = max(sum.MaxNetworkSent, stats.MaxNetworkSent, stats.NetworkSent)
|
sum.MaxNetworkSent = max(sum.MaxNetworkSent, stats.MaxNetworkSent, stats.NetworkSent)
|
||||||
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 temperatures
|
// Accumulate temperatures
|
||||||
if stats.Temperatures != nil {
|
if stats.Temperatures != nil {
|
||||||
@@ -284,6 +288,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.LoadAvg1 = twoDecimals(sum.LoadAvg1 / count)
|
sum.LoadAvg1 = twoDecimals(sum.LoadAvg1 / count)
|
||||||
sum.LoadAvg5 = twoDecimals(sum.LoadAvg5 / count)
|
sum.LoadAvg5 = twoDecimals(sum.LoadAvg5 / count)
|
||||||
sum.LoadAvg15 = twoDecimals(sum.LoadAvg15 / count)
|
sum.LoadAvg15 = twoDecimals(sum.LoadAvg15 / count)
|
||||||
|
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
|
||||||
|
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
||||||
// 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 {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="manifest" href="./static/manifest.json" />
|
<link rel="manifest" href="./static/manifest.json" />
|
||||||
<link rel="icon" type="image/svg+xml" href="./static/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="./static/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0,maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
<title>Beszel</title>
|
<title>Beszel</title>
|
||||||
<script>
|
<script>
|
||||||
globalThis.BESZEL = {
|
globalThis.BESZEL = {
|
||||||
|
|||||||
@@ -1,79 +1,42 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
|
||||||
|
|
||||||
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 { useYAxisWidth, cn, formatShortDate, chartMargin } from "@/lib/utils"
|
import { useYAxisWidth, cn, formatShortDate, chartMargin } from "@/lib/utils"
|
||||||
// import Spinner from '../spinner'
|
import { ChartData, SystemStatsRecord } from "@/types"
|
||||||
import { ChartData } from "@/types"
|
import { useMemo } from "react"
|
||||||
import { memo, useMemo } from "react"
|
|
||||||
import { useLingui } from "@lingui/react/macro"
|
|
||||||
|
|
||||||
/** [label, key, color, opacity] */
|
export type DataPoint = {
|
||||||
type DataKeys = [string, string, number, number]
|
label: string
|
||||||
|
dataKey: (data: SystemStatsRecord) => number | undefined
|
||||||
const getNestedValue = (path: string, max = false, data: any): number | null => {
|
color: string
|
||||||
// fallback value (obj?.stats?.cpum ? 0 : null) should only come into play when viewing
|
opacity: number
|
||||||
// a max value which doesn't exist, or the value was zero and omitted from the stats object.
|
|
||||||
// so we check if cpum is present. if so, return 0 to make sure the zero value is displayed.
|
|
||||||
// if not, return null - there is no max data so do not display anything.
|
|
||||||
return `stats.${path}${max ? "m" : ""}`
|
|
||||||
.split(".")
|
|
||||||
.reduce((acc: any, key: string) => acc?.[key] ?? (data.stats?.cpum ? 0 : null), data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(function AreaChartDefault({
|
export default function AreaChartDefault({
|
||||||
maxToggled = false,
|
|
||||||
chartName,
|
|
||||||
chartData,
|
chartData,
|
||||||
max,
|
max,
|
||||||
|
maxToggled,
|
||||||
tickFormatter,
|
tickFormatter,
|
||||||
contentFormatter,
|
contentFormatter,
|
||||||
}: {
|
dataPoints,
|
||||||
maxToggled?: boolean
|
}: // logRender = false,
|
||||||
chartName: string
|
{
|
||||||
chartData: ChartData
|
chartData: ChartData
|
||||||
max?: number
|
max?: number
|
||||||
tickFormatter: (value: number) => string
|
maxToggled?: boolean
|
||||||
contentFormatter: ({ value }: { value: number }) => string
|
tickFormatter: (value: number, index: number) => string
|
||||||
|
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
|
||||||
|
dataPoints?: DataPoint[]
|
||||||
|
// logRender?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const { i18n } = useLingui()
|
|
||||||
|
|
||||||
const { chartTime } = chartData
|
|
||||||
|
|
||||||
const showMax = chartTime !== "1h" && maxToggled
|
|
||||||
|
|
||||||
const dataKeys: DataKeys[] = useMemo(() => {
|
|
||||||
// [label, key, color, opacity]
|
|
||||||
if (chartName === "CPU Usage") {
|
|
||||||
return [[t`CPU Usage`, "cpu", 1, 0.4]]
|
|
||||||
} else if (chartName === "dio") {
|
|
||||||
return [
|
|
||||||
[t({ message: "Write", comment: "Disk write" }), "dw", 3, 0.3],
|
|
||||||
[t({ message: "Read", comment: "Disk read" }), "dr", 1, 0.3],
|
|
||||||
]
|
|
||||||
} else if (chartName === "bw") {
|
|
||||||
return [
|
|
||||||
[t({ message: "Sent", comment: "Network bytes sent (upload)" }), "ns", 5, 0.2],
|
|
||||||
[t({ message: "Received", comment: "Network bytes received (download)" }), "nr", 2, 0.2],
|
|
||||||
]
|
|
||||||
} else if (chartName.startsWith("efs")) {
|
|
||||||
return [
|
|
||||||
[t`Write`, `${chartName}.w`, 3, 0.3],
|
|
||||||
[t`Read`, `${chartName}.r`, 1, 0.3],
|
|
||||||
]
|
|
||||||
} else if (chartName.startsWith("g.")) {
|
|
||||||
return [chartName.includes("mu") ? [t`Used`, chartName, 2, 0.25] : [t`Usage`, chartName, 1, 0.4]]
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}, [chartName, i18n.locale])
|
|
||||||
|
|
||||||
// console.log('Rendered at', new Date())
|
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
if (chartData.systemStats.length === 0) {
|
if (chartData.systemStats.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
// if (logRender) {
|
||||||
|
// console.log("Rendered at", new Date())
|
||||||
|
// }
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
@@ -89,7 +52,7 @@ export default memo(function AreaChartDefault({
|
|||||||
className="tracking-tighter"
|
className="tracking-tighter"
|
||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
domain={[0, max ?? "auto"]}
|
domain={[0, max ?? "auto"]}
|
||||||
tickFormatter={(value) => updateYAxisWidth(tickFormatter(value))}
|
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
/>
|
/>
|
||||||
@@ -101,20 +64,19 @@ export default memo(function AreaChartDefault({
|
|||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
contentFormatter={contentFormatter}
|
contentFormatter={contentFormatter}
|
||||||
// indicator="line"
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{dataKeys.map((key, i) => {
|
{dataPoints?.map((dataPoint, i) => {
|
||||||
const color = `hsl(var(--chart-${key[2]}))`
|
const color = `hsl(var(--chart-${dataPoint.color}))`
|
||||||
return (
|
return (
|
||||||
<Area
|
<Area
|
||||||
key={i}
|
key={i}
|
||||||
dataKey={getNestedValue.bind(null, key[1], showMax)}
|
dataKey={dataPoint.dataKey}
|
||||||
name={key[0]}
|
name={dataPoint.label}
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
fill={color}
|
fill={color}
|
||||||
fillOpacity={key[3]}
|
fillOpacity={dataPoint.opacity}
|
||||||
stroke={color}
|
stroke={color}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
@@ -125,4 +87,5 @@ export default memo(function AreaChartDefault({
|
|||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
}, [chartData.systemStats.length, yAxisWidth, maxToggled])
|
||||||
|
}
|
||||||
|
|||||||
@@ -371,6 +371,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
|
|
||||||
// select field for switching between avg and max values
|
// select field for switching between avg and max values
|
||||||
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
|
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
|
||||||
|
const showMax = chartTime !== "1h" && maxValues
|
||||||
|
|
||||||
// if no data, show empty message
|
// if no data, show empty message
|
||||||
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
|
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
|
||||||
@@ -477,8 +478,15 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
>
|
>
|
||||||
<AreaChartDefault
|
<AreaChartDefault
|
||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
chartName="CPU Usage"
|
|
||||||
maxToggled={maxValues}
|
maxToggled={maxValues}
|
||||||
|
dataPoints={[
|
||||||
|
{
|
||||||
|
label: t`CPU Usage`,
|
||||||
|
dataKey: ({ stats }) => (showMax ? stats?.cpum : stats?.cpu),
|
||||||
|
color: "1",
|
||||||
|
opacity: 0.4,
|
||||||
|
},
|
||||||
|
]}
|
||||||
tickFormatter={(val) => toFixedFloat(val, 2) + "%"}
|
tickFormatter={(val) => toFixedFloat(val, 2) + "%"}
|
||||||
contentFormatter={({ value }) => decimalString(value) + "%"}
|
contentFormatter={({ value }) => decimalString(value) + "%"}
|
||||||
/>
|
/>
|
||||||
@@ -530,8 +538,21 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
>
|
>
|
||||||
<AreaChartDefault
|
<AreaChartDefault
|
||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
chartName="dio"
|
|
||||||
maxToggled={maxValues}
|
maxToggled={maxValues}
|
||||||
|
dataPoints={[
|
||||||
|
{
|
||||||
|
label: t({ message: "Write", comment: "Disk write" }),
|
||||||
|
dataKey: ({ stats }) => (showMax ? stats?.dwm : stats?.dw),
|
||||||
|
color: "3",
|
||||||
|
opacity: 0.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t({ message: "Read", comment: "Disk read" }),
|
||||||
|
dataKey: ({ stats }) => (showMax ? stats?.drm : stats?.dr),
|
||||||
|
color: "1",
|
||||||
|
opacity: 0.3,
|
||||||
|
},
|
||||||
|
]}
|
||||||
tickFormatter={(val) => {
|
tickFormatter={(val) => {
|
||||||
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, true)
|
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, true)
|
||||||
return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit
|
return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit
|
||||||
@@ -552,15 +573,39 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
>
|
>
|
||||||
<AreaChartDefault
|
<AreaChartDefault
|
||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
chartName="bw"
|
|
||||||
maxToggled={maxValues}
|
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) => {
|
tickFormatter={(val) => {
|
||||||
let { value, unit } = formatBytes(val, true, userSettings.unitNet, true)
|
let { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
|
||||||
return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit
|
return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit
|
||||||
}}
|
}}
|
||||||
contentFormatter={({ value }) => {
|
contentFormatter={(data) => {
|
||||||
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitNet, true)
|
const { value, unit } = formatBytes(data.value, true, userSettings.unitNet, false)
|
||||||
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
|
return decimalString(value, value >= 100 ? 1 : 2) + " " + unit
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
@@ -649,7 +694,14 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
>
|
>
|
||||||
<AreaChartDefault
|
<AreaChartDefault
|
||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
chartName={`g.${id}.u`}
|
dataPoints={[
|
||||||
|
{
|
||||||
|
label: t`Usage`,
|
||||||
|
dataKey: ({ stats }) => stats?.g?.[id]?.u ?? 0,
|
||||||
|
color: "1",
|
||||||
|
opacity: 0.35,
|
||||||
|
},
|
||||||
|
]}
|
||||||
tickFormatter={(val) => toFixedFloat(val, 2) + "%"}
|
tickFormatter={(val) => toFixedFloat(val, 2) + "%"}
|
||||||
contentFormatter={({ value }) => decimalString(value) + "%"}
|
contentFormatter={({ value }) => decimalString(value) + "%"}
|
||||||
/>
|
/>
|
||||||
@@ -662,7 +714,14 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
>
|
>
|
||||||
<AreaChartDefault
|
<AreaChartDefault
|
||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
chartName={`g.${id}.mu`}
|
dataPoints={[
|
||||||
|
{
|
||||||
|
label: t`Usage`,
|
||||||
|
dataKey: ({ stats }) => stats?.g?.[id]?.mu ?? 0,
|
||||||
|
color: "2",
|
||||||
|
opacity: 0.25,
|
||||||
|
},
|
||||||
|
]}
|
||||||
max={gpu.mt}
|
max={gpu.mt}
|
||||||
tickFormatter={(val) => {
|
tickFormatter={(val) => {
|
||||||
const { value, unit } = formatBytes(val, false, Unit.Bytes, true)
|
const { value, unit } = formatBytes(val, false, Unit.Bytes, true)
|
||||||
@@ -707,7 +766,20 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
>
|
>
|
||||||
<AreaChartDefault
|
<AreaChartDefault
|
||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
chartName={`efs.${extraFsName}`}
|
dataPoints={[
|
||||||
|
{
|
||||||
|
label: t`Write`,
|
||||||
|
dataKey: ({ stats }) => stats?.efs?.[extraFsName]?.[showMax ? "wm" : "w"] ?? 0,
|
||||||
|
color: "3",
|
||||||
|
opacity: 0.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t`Read`,
|
||||||
|
dataKey: ({ stats }) => stats?.efs?.[extraFsName]?.[showMax ? "rm" : "r"] ?? 0,
|
||||||
|
color: "1",
|
||||||
|
opacity: 0.3,
|
||||||
|
},
|
||||||
|
]}
|
||||||
maxToggled={maxValues}
|
maxToggled={maxValues}
|
||||||
tickFormatter={(val) => {
|
tickFormatter={(val) => {
|
||||||
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, true)
|
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, true)
|
||||||
|
|||||||
@@ -260,19 +260,19 @@ export default function SystemsTable() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorFn: (originalRow) => originalRow.info.b || 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,
|
||||||
cell(info) {
|
cell(info) {
|
||||||
if (info.row.original.status === "paused") {
|
const sys = info.row.original
|
||||||
|
if (sys.status === "paused") {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const val = info.getValue() as number
|
|
||||||
const userSettings = useStore($userSettings)
|
const userSettings = useStore($userSettings)
|
||||||
const { value, unit } = formatBytes(val, true, userSettings.unitNet, true)
|
const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, false)
|
||||||
return (
|
return (
|
||||||
<span className="tabular-nums whitespace-nowrap">
|
<span className="tabular-nums whitespace-nowrap">
|
||||||
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
||||||
|
|||||||
6
beszel/site/src/types.d.ts
vendored
6
beszel/site/src/types.d.ts
vendored
@@ -60,6 +60,8 @@ export interface SystemInfo {
|
|||||||
dp: number
|
dp: number
|
||||||
/** bandwidth (mb) */
|
/** bandwidth (mb) */
|
||||||
b: number
|
b: number
|
||||||
|
/** bandwidth bytes */
|
||||||
|
bb?: number
|
||||||
/** agent version */
|
/** agent version */
|
||||||
v: string
|
v: string
|
||||||
/** system is using podman */
|
/** system is using podman */
|
||||||
@@ -115,10 +117,14 @@ export interface SystemStats {
|
|||||||
ns: number
|
ns: number
|
||||||
/** network received (mb) */
|
/** network received (mb) */
|
||||||
nr: number
|
nr: number
|
||||||
|
/** bandwidth bytes [sent, recv] */
|
||||||
|
b?: [number, number]
|
||||||
/** max network sent (mb) */
|
/** max network sent (mb) */
|
||||||
nsm?: number
|
nsm?: number
|
||||||
/** max network received (mb) */
|
/** max network received (mb) */
|
||||||
nrm?: number
|
nrm?: number
|
||||||
|
/** max network sent (bytes) */
|
||||||
|
bm?: [number, number]
|
||||||
/** temperatures */
|
/** temperatures */
|
||||||
t?: Record<string, number>
|
t?: Record<string, number>
|
||||||
/** extra filesystems */
|
/** extra filesystems */
|
||||||
|
|||||||
Reference in New Issue
Block a user