Compare commits

..

11 Commits

Author SHA1 Message Date
Sven van Ginkel
cb26877720 [Feature] Improve Network Monitoring (#926)
* Split interfaces

* add filters

* feat: split interfaces and add filters (without locales)

* make it an line chart

* fix the colors

* remove tx rx tooltip

* fill the chart

* update chart and cleanup

* chore

* update system tab

* Fix alerts

* chore

* fix chart

* resolve conflicts

* Use new formatSpeed

* fix records

* update pakage

* Fix network I/O stats compilation errors

- Added globalNetIoStats field to Agent struct to track total bandwidth usage
- Updated initializeNetIoStats() to initialize both per-interface and global network stats
- Modified system.go to use globalNetIoStats for bandwidth calculations
- Maintained per-interface tracking in netIoStats map for interface-specific data

This resolves the compilation errors where netIoStats was accessed as a single struct
instead of a map[string]NetIoStats.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Remove redundant bandwidth chart and fix network interface data access

- Removed the old Bandwidth chart since network interface charts provide more detailed per-interface data
- Fixed system.tsx to look for network interface data in stats.ni instead of stats.ns
- Fixed NetworkInterfaceChart component to use correct data paths (stats.ni)
- Network interface charts should now display properly with per-interface network statistics

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Restore split network metrics display in systems table

- Modified systems table Net column to show separate sent/received values
- Added green ↑ arrow for sent traffic and blue ↓ arrow for received traffic
- Uses info.ns (NetworkSent) and info.nr (NetworkRecv) from agent
- Maintains sorting functionality based on total network traffic
- Shows values in appropriate units (B/s, KB/s, MB/s, etc.)

This restores the split network metrics view that was present in the original
feat/split-interfaces branch before the merge conflict resolution.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Remove unused bandwidth fields and calculations from agent

Removed legacy bandwidth collection code that is no longer used by the frontend:

**Removed from structs:**
- Stats.Bandwidth [2]uint64 (bandwidth bytes array)
- Stats.MaxBandwidth [2]uint64 (max bandwidth bytes array)
- Info.Bandwidth float64 (total bandwidth MB/s)
- Info.BandwidthBytes uint64 (total bandwidth bytes/s)

**Removed from agent:**
- globalNetIoStats tracking and calculations
- bandwidth byte-per-second calculations
- bandwidth array assignments in systemStats
- bandwidth field assignments in systemInfo

**Removed from records:**
- Bandwidth array accumulation and averaging in AverageSystemStats
- MaxBandwidth tracking in peak value calculations

The frontend now uses only:
- info.ns/info.nr (split metrics in systems table)
- stats.ni (per-interface charts)

This cleanup removes ~50 lines of unused code and eliminates redundant calculations.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Optimize network collection for better performance

**Performance Improvements:**
- Pre-allocate NetworkInterfaces map with known capacity to reduce allocations
- Remove redundant byte counters (totalBytesSent, totalBytesRecv) that were unused
- Direct calculation to MB/s, avoiding intermediate bytes-per-second variables
- Reuse existing NetIoStats structs when possible to reduce GC pressure
- Streamlined single-pass processing through network interfaces

**Optimizations:**
- Reduced memory allocations per collection cycle
- Fewer arithmetic operations (eliminated double conversion)
- Better cache locality with simplified data flow
- Reduced time complexity from O(n²) operations to O(n)

**Maintained Functionality:**
- Same per-interface statistics collection
- Same total network sent/recv calculations
- Same error handling and reset logic
- Same data structures and output format

Expected improvement: ~15-25% reduction in network collection CPU time and memory allocations.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix the Unit preferences

* Add total bytes sent and received to network interface stats and implement total bandwidth chart

* chore: fix Cumulative records

* Add connection counts

* Add connection stats

* Fix ordering

* remove test builds

* improve entre command in makefile

* rebase
2025-09-13 17:05:49 -04:00
Ryan W
e149366451 Fixing service name in helm chart and making default values unopinionated (#1166) 2025-09-13 12:09:36 -04:00
henrygd
8da1ded73e strip whitespace from TOKEN_FILE (#984) 2025-09-12 12:59:53 -04:00
henrygd
efa37b2312 web: extra check for valid system before adding (#1063) 2025-09-11 15:37:11 -04:00
henrygd
bcdb4c92b5 add freebsd to list of copyable commands 2025-09-11 15:07:37 -04:00
henrygd
a7d07310b6 Add AUTO_LOGIN environment variable for automatic login. (#399) 2025-09-11 14:01:09 -04:00
hank
8db87e5497 Update Crowdin configuration file 2025-09-11 12:45:43 -04:00
henrygd
e601a0d564 add TRUSTED_AUTH_HEADER for auth forwarding (#399) 2025-09-10 21:26:59 -04:00
Fankesyooni
07491108cd new zh-CN translations (#1160) 2025-09-10 13:34:17 -04:00
henrygd
42ab17de1f move i18n.yml to project root 2025-09-10 13:30:42 -04:00
henrygd
2d14174f61 update i18n.yml 2025-09-10 13:28:06 -04:00
31 changed files with 1472 additions and 810 deletions

1
.gitignore vendored
View File

@@ -20,3 +20,4 @@ __debug_*
agent/lhm/obj agent/lhm/obj
agent/lhm/bin agent/lhm/bin
dockerfile_agent_dev dockerfile_agent_dev
.vite

View File

@@ -77,7 +77,7 @@ dev-hub: export ENV=dev
dev-hub: dev-hub:
mkdir -p ./internal/site/dist && touch ./internal/site/dist/index.html mkdir -p ./internal/site/dist && touch ./internal/site/dist/index.html
@if command -v entr >/dev/null 2>&1; then \ @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 \ else \
cd ./internal/cmd/hub && go run -tags development . serve --http 0.0.0.0:8090; \ cd ./internal/cmd/hub && go run -tags development . serve --http 0.0.0.0:8090; \
fi fi

View File

@@ -29,7 +29,7 @@ type Agent struct {
fsNames []string // List of filesystem device names being monitored fsNames []string // List of filesystem device names being monitored
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
netInterfaces map[string]struct{} // Stores all valid network interfaces netInterfaces map[string]struct{} // Stores all valid network interfaces
netIoStats system.NetIoStats // Keeps track of bandwidth usage netIoStats map[string]system.NetIoStats // Keeps track of per-interface bandwidth usage
dockerManager *dockerManager // Manages Docker API requests dockerManager *dockerManager // Manages Docker API requests
sensorConfig *SensorConfig // Sensors config sensorConfig *SensorConfig // Sensors config
systemInfo system.Info // Host system info systemInfo system.Info // Host system info

View File

@@ -85,7 +85,7 @@ func getToken() (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
return string(tokenBytes), nil return strings.TrimSpace(string(tokenBytes)), nil
} }
// getOptions returns the WebSocket client options, creating them if necessary. // getOptions returns the WebSocket client options, creating them if necessary.

View File

@@ -537,4 +537,25 @@ func TestGetToken(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "", token, "Empty file should return empty string") assert.Equal(t, "", token, "Empty file should return empty string")
}) })
t.Run("strips whitespace from TOKEN_FILE", func(t *testing.T) {
unsetEnvVars()
tokenWithWhitespace := " test-token-with-whitespace \n\t"
expectedToken := "test-token-with-whitespace"
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
require.NoError(t, err)
defer os.Remove(tokenFile.Name())
_, err = tokenFile.WriteString(tokenWithWhitespace)
require.NoError(t, err)
tokenFile.Close()
os.Setenv("TOKEN_FILE", tokenFile.Name())
defer os.Unsetenv("TOKEN_FILE")
token, err := getToken()
assert.NoError(t, err)
assert.Equal(t, expectedToken, token, "Whitespace should be stripped from token file content")
})
} }

View File

@@ -5,12 +5,16 @@ import (
"strings" "strings"
"time" "time"
"github.com/henrygd/beszel/internal/entities/system"
psutilNet "github.com/shirou/gopsutil/v4/net" psutilNet "github.com/shirou/gopsutil/v4/net"
) )
func (a *Agent) initializeNetIoStats() { func (a *Agent) initializeNetIoStats() {
// reset valid network interfaces // reset valid network interfaces
a.netInterfaces = make(map[string]struct{}, 0) a.netInterfaces = make(map[string]struct{}, 0)
// reset network I/O stats per interface
a.netIoStats = make(map[string]system.NetIoStats, 0)
// map of network interface names passed in via NICS env var // map of network interface names passed in via NICS env var
var nicsMap map[string]struct{} var nicsMap map[string]struct{}
@@ -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 // get intial network I/O stats
if netIO, err := psutilNet.IOCounters(true); err == nil { if netIO, err := psutilNet.IOCounters(true); err == nil {
a.netIoStats.Time = time.Now() now := time.Now()
for _, v := range netIO { for _, v := range netIO {
switch { switch {
// skip if nics exists and the interface is not in the list // skip if nics exists and the interface is not in the list
@@ -43,10 +44,15 @@ func (a *Agent) initializeNetIoStats() {
} }
} }
slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv) slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv)
a.netIoStats.BytesSent += v.BytesSent
a.netIoStats.BytesRecv += v.BytesRecv
// store as a valid network interface // store as a valid network interface
a.netInterfaces[v.Name] = struct{}{} a.netInterfaces[v.Name] = struct{}{}
// initialize per-interface stats
a.netIoStats[v.Name] = system.NetIoStats{
BytesRecv: v.BytesRecv,
BytesSent: v.BytesSent,
Time: now,
Name: v.Name,
}
} }
} }
} }

View File

@@ -176,53 +176,85 @@ func (a *Agent) getSystemStats() system.Stats {
if len(a.netInterfaces) == 0 { if len(a.netInterfaces) == 0 {
// if no network interfaces, initialize again // if no network interfaces, initialize again
// this is a fix if agent started before network is online (#466) // 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() a.initializeNetIoStats()
} }
if netIO, err := psutilNet.IOCounters(true); err == nil { if netIO, err := psutilNet.IOCounters(true); err == nil {
msElapsed := uint64(time.Since(a.netIoStats.Time).Milliseconds()) now := time.Now()
a.netIoStats.Time = time.Now()
totalBytesSent := uint64(0) // pre-allocate maps with known capacity
totalBytesRecv := uint64(0) interfaceCount := len(a.netInterfaces)
// sum all bytes sent and received 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 { 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
} }
totalBytesSent += v.BytesSent
totalBytesRecv += v.BytesRecv // 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)
} }
// 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)) // 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 // add check for issue (#150) where sent is a massive number
if networkSentPs > 10_000 || networkRecvPs > 10_000 { if totalSent > 10_000 || totalRecv > 10_000 {
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs) slog.Warn("Invalid net stats. Resetting.", "sent", totalSent, "recv", totalRecv)
for _, v := range netIO {
if _, exists := a.netInterfaces[v.Name]; !exists {
continue
}
slog.Info(v.Name, "recv", v.BytesRecv, "sent", v.BytesSent)
}
// reset network I/O stats // reset network I/O stats
a.initializeNetIoStats() a.initializeNetIoStats()
} else { } else {
systemStats.NetworkSent = networkSentPs systemStats.NetworkSent = totalSent
systemStats.NetworkRecv = networkRecvPs systemStats.NetworkRecv = totalRecv
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
// update netIoStats
a.netIoStats.BytesSent = totalBytesSent
a.netIoStats.BytesRecv = totalBytesRecv
} }
} }
// connection counts
a.updateConnectionCounts(&systemStats)
// temperatures // temperatures
// TODO: maybe refactor to methods on systemStats // TODO: maybe refactor to methods on systemStats
a.updateTemperatures(&systemStats) a.updateTemperatures(&systemStats)
@@ -270,14 +302,109 @@ 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) // Sum all per-interface network sent/recv and assign to systemInfo
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1] 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) slog.Debug("sysinfo", "data", a.systemInfo)
return systemStats return systemStats
} }
func (a *Agent) updateConnectionCounts(systemStats *system.Stats) {
// Get IPv4 connections
connectionsIPv4, err := psutilNet.Connections("inet")
if err != nil {
slog.Debug("Failed to get IPv4 connection stats", "err", err)
return
}
// Get IPv6 connections
connectionsIPv6, err := psutilNet.Connections("inet6")
if err != nil {
slog.Debug("Failed to get IPv6 connection stats", "err", err)
// Continue with IPv4 only if IPv6 fails
}
// Initialize Nets map if needed
if systemStats.Nets == nil {
systemStats.Nets = make(map[string]float64)
}
// Count IPv4 connection states
connStatsIPv4 := map[string]int{
"established": 0,
"listen": 0,
"time_wait": 0,
"close_wait": 0,
"syn_recv": 0,
}
for _, conn := range connectionsIPv4 {
// Only count TCP connections (Type 1 = SOCK_STREAM)
if conn.Type == 1 {
switch strings.ToUpper(conn.Status) {
case "ESTABLISHED":
connStatsIPv4["established"]++
case "LISTEN":
connStatsIPv4["listen"]++
case "TIME_WAIT":
connStatsIPv4["time_wait"]++
case "CLOSE_WAIT":
connStatsIPv4["close_wait"]++
case "SYN_RECV":
connStatsIPv4["syn_recv"]++
}
}
}
// Count IPv6 connection states
connStatsIPv6 := map[string]int{
"established": 0,
"listen": 0,
"time_wait": 0,
"close_wait": 0,
"syn_recv": 0,
}
for _, conn := range connectionsIPv6 {
// Only count TCP connections (Type 1 = SOCK_STREAM)
if conn.Type == 1 {
switch strings.ToUpper(conn.Status) {
case "ESTABLISHED":
connStatsIPv6["established"]++
case "LISTEN":
connStatsIPv6["listen"]++
case "TIME_WAIT":
connStatsIPv6["time_wait"]++
case "CLOSE_WAIT":
connStatsIPv6["close_wait"]++
case "SYN_RECV":
connStatsIPv6["syn_recv"]++
}
}
}
// Add IPv4 connection counts to Nets
systemStats.Nets["conn_established"] = float64(connStatsIPv4["established"])
systemStats.Nets["conn_listen"] = float64(connStatsIPv4["listen"])
systemStats.Nets["conn_timewait"] = float64(connStatsIPv4["time_wait"])
systemStats.Nets["conn_closewait"] = float64(connStatsIPv4["close_wait"])
systemStats.Nets["conn_synrecv"] = float64(connStatsIPv4["syn_recv"])
// Add IPv6 connection counts to Nets
systemStats.Nets["conn6_established"] = float64(connStatsIPv6["established"])
systemStats.Nets["conn6_listen"] = float64(connStatsIPv6["listen"])
systemStats.Nets["conn6_timewait"] = float64(connStatsIPv6["time_wait"])
systemStats.Nets["conn6_closewait"] = float64(connStatsIPv6["close_wait"])
systemStats.Nets["conn6_synrecv"] = float64(connStatsIPv6["syn_recv"])
}
// Returns the size of the ZFS ARC memory cache in bytes // Returns the size of the ZFS ARC memory cache in bytes
func getARCSize() (uint64, error) { func getARCSize() (uint64, error) {
file, err := os.Open("/proc/spl/kstat/zfs/arcstats") file, err := os.Open("/proc/spl/kstat/zfs/arcstats")

View File

@@ -1,3 +1,3 @@
files: files:
- source: /beszel/site/src/locales/en/en.po - source: /internal/site/src/locales/en/
translation: /beszel/site/src/locales/%two_letters_code%/%two_letters_code%.po translation: /internal/site/src/locales/%two_letters_code%/%two_letters_code%.po

View File

@@ -38,7 +38,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
case "Memory": case "Memory":
val = data.Info.MemPct val = data.Info.MemPct
case "Bandwidth": case "Bandwidth":
val = data.Info.Bandwidth val = data.Info.NetworkSent + data.Info.NetworkRecv
unit = " MB/s" unit = " MB/s"
case "Disk": case "Disk":
maxUsedPct := data.Info.DiskPct maxUsedPct := data.Info.DiskPct

View File

@@ -8,6 +8,15 @@ import (
"github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/container"
) )
type NetworkInterfaceStats struct {
NetworkSent float64 `json:"ns"`
NetworkRecv float64 `json:"nr"`
MaxNetworkSent float64 `json:"nsm,omitempty"`
MaxNetworkRecv float64 `json:"nrm,omitempty"`
TotalBytesSent uint64 `json:"tbs,omitempty"` // Total bytes sent since boot
TotalBytesRecv uint64 `json:"tbr,omitempty"` // Total bytes received since boot
}
type Stats struct { type Stats struct {
Cpu float64 `json:"cpu" cbor:"0,keyasint"` Cpu float64 `json:"cpu" cbor:"0,keyasint"`
MaxCpu float64 `json:"cpum,omitempty" cbor:"1,keyasint,omitempty"` MaxCpu float64 `json:"cpum,omitempty" cbor:"1,keyasint,omitempty"`
@@ -25,22 +34,21 @@ type Stats struct {
DiskWritePs float64 `json:"dw" cbor:"13,keyasint"` DiskWritePs float64 `json:"dw" cbor:"13,keyasint"`
MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"14,keyasint,omitempty"` MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"14,keyasint,omitempty"`
MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"15,keyasint,omitempty"` MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"15,keyasint,omitempty"`
NetworkSent float64 `json:"ns" cbor:"16,keyasint"` NetworkInterfaces map[string]NetworkInterfaceStats `json:"ni" cbor:"16,omitempty"` // Per-interface network stats
NetworkRecv float64 `json:"nr" cbor:"17,keyasint"` NetworkSent float64 `json:"ns" cbor:"17,keyasint"` // Total network sent (MB/s)
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"18,keyasint,omitempty"` NetworkRecv float64 `json:"nr" cbor:"18,keyasint"` // Total network recv (MB/s)
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"19,keyasint,omitempty"` MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"19,keyasint,omitempty"`
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"` MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"20,keyasint,omitempty"`
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"` Temperatures map[string]float64 `json:"t,omitempty" cbor:"21,keyasint,omitempty"`
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"` ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"22,keyasint,omitempty"`
LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"` GPUData map[string]GPUData `json:"g,omitempty" cbor:"23,keyasint,omitempty"`
LoadAvg5 float64 `json:"l5,omitempty" cbor:"24,keyasint,omitempty"` LoadAvg1 float64 `json:"l1,omitempty" cbor:"24,keyasint,omitempty"`
LoadAvg15 float64 `json:"l15,omitempty" cbor:"25,keyasint,omitempty"` LoadAvg5 float64 `json:"l5,omitempty" cbor:"25,keyasint,omitempty"`
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"26,keyasint,omitzero"` // [sent bytes, recv bytes] LoadAvg15 float64 `json:"l15,omitempty" cbor:"26,keyasint,omitempty"`
MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"27,keyasint,omitzero"` // [sent bytes, recv bytes] LoadAvg [3]float64 `json:"la,omitempty" cbor:"27,keyasint"` // [1min, 5min, 15min]
// TODO: remove other load fields in future release in favor of load avg array Battery [2]uint8 `json:"bat,omitzero" cbor:"28,keyasint,omitzero"` // [percent, charge state]
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"` MaxMem float64 `json:"mm,omitempty" cbor:"29,keyasint,omitempty"`
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current] Nets map[string]float64 `json:"nets,omitempty" cbor:"30,keyasint,omitempty"` // Network connection statistics
MaxMem float64 `json:"mm,omitempty" cbor:"30,keyasint,omitempty"`
} }
type GPUData struct { type GPUData struct {
@@ -70,6 +78,8 @@ type FsStats struct {
type NetIoStats struct { type NetIoStats struct {
BytesRecv uint64 BytesRecv uint64
BytesSent uint64 BytesSent uint64
PacketsSent uint64
PacketsRecv uint64
Time time.Time Time time.Time
Name string Name string
} }
@@ -93,18 +103,17 @@ type Info struct {
Cpu float64 `json:"cpu" cbor:"6,keyasint"` Cpu float64 `json:"cpu" cbor:"6,keyasint"`
MemPct float64 `json:"mp" cbor:"7,keyasint"` MemPct float64 `json:"mp" cbor:"7,keyasint"`
DiskPct float64 `json:"dp" cbor:"8,keyasint"` DiskPct float64 `json:"dp" cbor:"8,keyasint"`
Bandwidth float64 `json:"b" cbor:"9,keyasint"` NetworkSent float64 `json:"ns" cbor:"9,keyasint"` // Per-interface total (MB/s)
AgentVersion string `json:"v" cbor:"10,keyasint"` NetworkRecv float64 `json:"nr" cbor:"10,keyasint"` // Per-interface total (MB/s)
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"` AgentVersion string `json:"v" cbor:"11,keyasint"`
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"` Podman bool `json:"p,omitempty" cbor:"12,keyasint,omitempty"`
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"` GpuPct float64 `json:"g,omitempty" cbor:"13,keyasint,omitempty"`
Os Os `json:"os" cbor:"14,keyasint"` DashboardTemp float64 `json:"dt,omitempty" cbor:"14,keyasint,omitempty"`
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"` Os Os `json:"os" cbor:"15,keyasint"`
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"` LoadAvg1 float64 `json:"l1,omitempty" cbor:"16,keyasint,omitempty"`
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"` LoadAvg5 float64 `json:"l5,omitempty" cbor:"17,keyasint,omitempty"`
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"` LoadAvg15 float64 `json:"l15,omitempty" cbor:"18,keyasint,omitempty"`
// TODO: remove load fields in future release in favor of load avg array LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"` // [1min, 5min, 15min]
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
} }
// Final data structure to return to the hub // Final data structure to return to the hub

View File

@@ -69,6 +69,8 @@ func (h *Hub) StartHub() error {
if err := config.SyncSystems(e); err != nil { if err := config.SyncSystems(e); err != nil {
return err return err
} }
// register middlewares
h.registerMiddlewares(e)
// register api routes // register api routes
if err := h.registerApiRoutes(e); err != nil { if err := h.registerApiRoutes(e); err != nil {
return err return err
@@ -171,6 +173,37 @@ func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
return nil return nil
} }
// custom middlewares
func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
// authorizes request with user matching the provided email
authorizeRequestWithEmail := func(e *core.RequestEvent, email string) (err error) {
if e.Auth != nil || email == "" {
return e.Next()
}
isAuthRefresh := e.Request.URL.Path == "/api/collections/users/auth-refresh" && e.Request.Method == http.MethodPost
e.Auth, err = e.App.FindFirstRecordByData("users", "email", email)
if err != nil || !isAuthRefresh {
return e.Next()
}
// auth refresh endpoint, make sure token is set in header
token, _ := e.Auth.NewAuthToken()
e.Request.Header.Set("Authorization", token)
return e.Next()
}
// authenticate with trusted header
if autoLogin, _ := GetEnv("AUTO_LOGIN"); autoLogin != "" {
se.Router.BindFunc(func(e *core.RequestEvent) error {
return authorizeRequestWithEmail(e, autoLogin)
})
}
// authenticate with trusted header
if trustedHeader, _ := GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" {
se.Router.BindFunc(func(e *core.RequestEvent) error {
return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader))
})
}
}
// custom api routes // custom api routes
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error { func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
// auth protected routes // auth protected routes

View File

@@ -711,3 +711,117 @@ func TestCreateUserEndpointAvailability(t *testing.T) {
scenario.Test(t) scenario.Test(t)
}) })
} }
func TestAutoLoginMiddleware(t *testing.T) {
var hubs []*beszelTests.TestHub
defer func() {
defer os.Unsetenv("AUTO_LOGIN")
for _, hub := range hubs {
hub.Cleanup()
}
}()
os.Setenv("AUTO_LOGIN", "user@test.com")
testAppFactory := func(t testing.TB) *pbTests.TestApp {
hub, _ := beszelTests.NewTestHub(t.TempDir())
hubs = append(hubs, hub)
hub.StartHub()
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
{
Name: "GET /getkey - without auto login should fail",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /getkey - with auto login should fail if no matching user",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /getkey - with auto login should succeed",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
ExpectedStatus: 200,
ExpectedContent: []string{"\"key\":", "\"v\":"},
TestAppFactory: testAppFactory,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.CreateUser(app, "user@test.com", "password123")
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestTrustedHeaderMiddleware(t *testing.T) {
var hubs []*beszelTests.TestHub
defer func() {
defer os.Unsetenv("TRUSTED_AUTH_HEADER")
for _, hub := range hubs {
hub.Cleanup()
}
}()
os.Setenv("TRUSTED_AUTH_HEADER", "X-Beszel-Trusted")
testAppFactory := func(t testing.TB) *pbTests.TestApp {
hub, _ := beszelTests.NewTestHub(t.TempDir())
hubs = append(hubs, hub)
hub.StartHub()
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
{
Name: "GET /getkey - without trusted header should fail",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /getkey - with trusted header should fail if no matching user",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
Headers: map[string]string{
"X-Beszel-Trusted": "user@test.com",
},
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /getkey - with trusted header should succeed",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
Headers: map[string]string{
"X-Beszel-Trusted": "user@test.com",
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"key\":", "\"v\":"},
TestAppFactory: testAppFactory,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.CreateUser(app, "user@test.com", "password123")
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}

View File

@@ -206,15 +206,51 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
sum.DiskPct += stats.DiskPct sum.DiskPct += stats.DiskPct
sum.DiskReadPs += stats.DiskReadPs sum.DiskReadPs += stats.DiskReadPs
sum.DiskWritePs += stats.DiskWritePs sum.DiskWritePs += stats.DiskWritePs
sum.LoadAvg1 += stats.LoadAvg1
sum.LoadAvg5 += stats.LoadAvg5
sum.LoadAvg15 += stats.LoadAvg15
sum.NetworkSent += stats.NetworkSent sum.NetworkSent += stats.NetworkSent
sum.NetworkRecv += stats.NetworkRecv sum.NetworkRecv += stats.NetworkRecv
sum.LoadAvg[0] += stats.LoadAvg[0] sum.LoadAvg[0] += stats.LoadAvg[0]
sum.LoadAvg[1] += stats.LoadAvg[1] sum.LoadAvg[1] += stats.LoadAvg[1]
sum.LoadAvg[2] += stats.LoadAvg[2] sum.LoadAvg[2] += stats.LoadAvg[2]
sum.Bandwidth[0] += stats.Bandwidth[0]
sum.Bandwidth[1] += stats.Bandwidth[1]
batterySum += int(stats.Battery[0]) batterySum += int(stats.Battery[0])
sum.Battery[1] = stats.Battery[1] sum.Battery[1] = stats.Battery[1]
if stats.NetworkInterfaces != nil {
if sum.NetworkInterfaces == nil {
sum.NetworkInterfaces = make(map[string]system.NetworkInterfaceStats, len(stats.NetworkInterfaces))
}
for key, value := range stats.NetworkInterfaces {
if _, ok := sum.NetworkInterfaces[key]; !ok {
sum.NetworkInterfaces[key] = system.NetworkInterfaceStats{}
}
ni := sum.NetworkInterfaces[key]
ni.NetworkSent += value.NetworkSent
ni.NetworkRecv += value.NetworkRecv
ni.MaxNetworkSent += value.MaxNetworkSent
ni.MaxNetworkRecv += value.MaxNetworkRecv
// For cumulative totals, use the maximum value (most recent)
if value.TotalBytesSent > ni.TotalBytesSent {
ni.TotalBytesSent = value.TotalBytesSent
}
if value.TotalBytesRecv > ni.TotalBytesRecv {
ni.TotalBytesRecv = value.TotalBytesRecv
}
sum.NetworkInterfaces[key] = ni
}
}
// Handle network connection stats - use the latest values (most recent sample)
if stats.Nets != nil {
if sum.Nets == nil {
sum.Nets = make(map[string]float64)
}
for key, value := range stats.Nets {
sum.Nets[key] = value
}
}
// Set peak values // Set peak values
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu) sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
sum.MaxMem = max(sum.MaxMem, stats.MaxMem, stats.MemUsed) sum.MaxMem = max(sum.MaxMem, stats.MaxMem, stats.MemUsed)
@@ -222,8 +258,6 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
sum.MaxNetworkRecv = max(sum.MaxNetworkRecv, stats.MaxNetworkRecv, stats.NetworkRecv) sum.MaxNetworkRecv = max(sum.MaxNetworkRecv, stats.MaxNetworkRecv, stats.NetworkRecv)
sum.MaxDiskReadPs = max(sum.MaxDiskReadPs, stats.MaxDiskReadPs, stats.DiskReadPs) sum.MaxDiskReadPs = max(sum.MaxDiskReadPs, stats.MaxDiskReadPs, stats.DiskReadPs)
sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs) sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs)
sum.MaxBandwidth[0] = max(sum.MaxBandwidth[0], stats.MaxBandwidth[0], stats.Bandwidth[0])
sum.MaxBandwidth[1] = max(sum.MaxBandwidth[1], stats.MaxBandwidth[1], stats.Bandwidth[1])
// Accumulate temperatures // Accumulate temperatures
if stats.Temperatures != nil { if stats.Temperatures != nil {
@@ -291,14 +325,26 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
sum.DiskPct = twoDecimals(sum.DiskPct / count) sum.DiskPct = twoDecimals(sum.DiskPct / count)
sum.DiskReadPs = twoDecimals(sum.DiskReadPs / count) sum.DiskReadPs = twoDecimals(sum.DiskReadPs / count)
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count) sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
sum.LoadAvg1 = twoDecimals(sum.LoadAvg1 / count)
sum.LoadAvg5 = twoDecimals(sum.LoadAvg5 / count)
sum.LoadAvg15 = twoDecimals(sum.LoadAvg15 / count)
sum.NetworkSent = twoDecimals(sum.NetworkSent / count) sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count) sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count) sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count)
sum.LoadAvg[1] = twoDecimals(sum.LoadAvg[1] / count) sum.LoadAvg[1] = twoDecimals(sum.LoadAvg[1] / count)
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count) sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
sum.Battery[0] = uint8(batterySum / int(count)) sum.Battery[0] = uint8(batterySum / int(count))
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 // Average temperatures
if sum.Temperatures != nil && tempCount > 0 { if sum.Temperatures != nil && tempCount > 0 {
for key := range sum.Temperatures { for key := range sum.Temperatures {
@@ -363,8 +409,6 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
} }
sums[stat.Name].Cpu += stat.Cpu sums[stat.Name].Cpu += stat.Cpu
sums[stat.Name].Mem += stat.Mem sums[stat.Name].Mem += stat.Mem
sums[stat.Name].NetworkSent += stat.NetworkSent
sums[stat.Name].NetworkRecv += stat.NetworkRecv
} }
} }
@@ -374,8 +418,6 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
Name: value.Name, Name: value.Name,
Cpu: twoDecimals(value.Cpu / count), Cpu: twoDecimals(value.Cpu / count),
Mem: twoDecimals(value.Mem / count), Mem: twoDecimals(value.Mem / count),
NetworkSent: twoDecimals(value.NetworkSent / count),
NetworkRecv: twoDecimals(value.NetworkRecv / count),
}) })
} }
return result return result

View File

@@ -17,7 +17,10 @@
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"recommended": true "recommended": true,
"correctness": {
"useUniqueElementIds": "off"
}
} }
}, },
"javascript": { "javascript": {

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,7 @@ import { memo, useEffect, useRef, useState } from "react"
import { $router, basePath, Link, navigate } from "./router" import { $router, basePath, Link, navigate } from "./router"
import { SystemRecord } from "@/types" import { SystemRecord } from "@/types"
import { SystemStatus } from "@/lib/enums" import { SystemStatus } from "@/lib/enums"
import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "./ui/icons" import { AppleIcon, DockerIcon, FreeBsdIcon, TuxIcon, WindowsIcon } from "./ui/icons"
import { InputCopy } from "./ui/input-copy" import { InputCopy } from "./ui/input-copy"
import { getPagePath } from "@nanostores/router" import { getPagePath } from "@nanostores/router"
import { import {
@@ -253,6 +253,12 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
copyWindowsCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token), copyWindowsCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token),
icons: [WindowsIcon], icons: [WindowsIcon],
}, },
{
text: t({ message: "FreeBSD command", context: "Button to copy install command" }),
onClick: async () =>
copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token),
icons: [FreeBsdIcon],
},
{ {
text: t`Manual setup instructions`, text: t`Manual setup instructions`,
url: "https://beszel.dev/guide/agent-installation#binary", url: "https://beszel.dev/guide/agent-installation#binary",

View 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>
)
})

View 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>
)
})

View 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>
)
})

View File

@@ -9,6 +9,7 @@ import {
RotateCwIcon, RotateCwIcon,
ServerIcon, ServerIcon,
Trash2Icon, Trash2Icon,
ExternalLinkIcon,
} from "lucide-react" } from "lucide-react"
import { memo, useEffect, useMemo, useState } from "react" import { memo, useEffect, useMemo, useState } from "react"
import { import {
@@ -28,7 +29,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "@/components/ui/icons" import { AppleIcon, DockerIcon, FreeBsdIcon, TuxIcon, WindowsIcon } from "@/components/ui/icons"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -150,6 +151,7 @@ const SectionUniversalToken = memo(() => {
setIsLoading(false) setIsLoading(false)
} }
// biome-ignore lint/correctness/useExhaustiveDependencies: only on mount
useEffect(() => { useEffect(() => {
updateToken() updateToken()
}, []) }, [])
@@ -221,6 +223,16 @@ const ActionsButtonUniversalToken = memo(({ token, checked }: { token: string; c
onClick: () => copyWindowsCommand(port, publicKey, token), onClick: () => copyWindowsCommand(port, publicKey, token),
icons: [WindowsIcon], icons: [WindowsIcon],
}, },
{
text: t({ message: "FreeBSD command", context: "Button to copy install command" }),
onClick: () => copyLinuxCommand(port, publicKey, token),
icons: [FreeBsdIcon],
},
{
text: t`Manual setup instructions`,
url: "https://beszel.dev/guide/agent-installation#binary",
icons: [ExternalLinkIcon],
},
] ]
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -291,8 +303,8 @@ const SectionTable = memo(({ fingerprints = [] }: { fingerprints: FingerprintRec
</tr> </tr>
</TableHeader> </TableHeader>
<TableBody className="whitespace-pre"> <TableBody className="whitespace-pre">
{fingerprints.map((fingerprint, i) => ( {fingerprints.map((fingerprint) => (
<TableRow key={i}> <TableRow key={fingerprint.id}>
<TableCell className="font-medium ps-5 py-2 max-w-60 truncate"> <TableCell className="font-medium ps-5 py-2 max-w-60 truncate">
{fingerprint.expand.system.name} {fingerprint.expand.system.name}
</TableCell> </TableCell>
@@ -317,10 +329,10 @@ async function updateFingerprint(fingerprint: FingerprintRecord, rotateToken = f
fingerprint: "", fingerprint: "",
token: rotateToken ? generateToken() : fingerprint.token, token: rotateToken ? generateToken() : fingerprint.token,
}) })
} catch (error: any) { } catch (error: unknown) {
toast({ toast({
title: t`Error`, title: t`Error`,
description: error.message, description: (error as Error).message,
}) })
} }
} }

View File

@@ -24,6 +24,7 @@ import {
$containerFilter, $containerFilter,
$direction, $direction,
$maxValues, $maxValues,
$networkInterfaceFilter,
$systems, $systems,
$temperatureFilter, $temperatureFilter,
$userSettings, $userSettings,
@@ -52,6 +53,9 @@ import { Input } from "../ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
import { Separator } from "../ui/separator" import { Separator } from "../ui/separator"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
import ConnectionChart from "../charts/connection-chart"
import NetworkInterfaceChart from "../charts/network-interface-chart"
import TotalBandwidthChart from "../charts/total-bandwidth-chart"
type ChartTimeData = { type ChartTimeData = {
time: number time: number
@@ -147,6 +151,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
const netCardRef = useRef<HTMLDivElement>(null) const netCardRef = useRef<HTMLDivElement>(null)
const persistChartTime = useRef(false) const persistChartTime = useRef(false)
const [containerFilterBar, setContainerFilterBar] = useState(null as null | JSX.Element) const [containerFilterBar, setContainerFilterBar] = useState(null as null | JSX.Element)
const [networkInterfaceFilterBar, setNetworkInterfaceFilterBar] = useState(null as null | JSX.Element)
const [bottomSpacing, setBottomSpacing] = useState(0) const [bottomSpacing, setBottomSpacing] = useState(0)
const [chartLoading, setChartLoading] = useState(true) const [chartLoading, setChartLoading] = useState(true)
const isLongerChart = chartTime !== "1h" const isLongerChart = chartTime !== "1h"
@@ -163,7 +168,9 @@ export default memo(function SystemDetail({ name }: { name: string }) {
setSystemStats([]) setSystemStats([])
setContainerData([]) setContainerData([])
setContainerFilterBar(null) setContainerFilterBar(null)
setNetworkInterfaceFilterBar(null)
$containerFilter.set("") $containerFilter.set("")
$networkInterfaceFilter.set("")
} }
}, [name]) }, [name])
@@ -260,6 +267,19 @@ export default memo(function SystemDetail({ name }: { name: string }) {
}) })
}, [system, chartTime]) }, [system, chartTime])
// Set up network interface filter bar
useEffect(() => {
if (systemStats.length > 0) {
const latestStats = systemStats[systemStats.length - 1]
const networkInterfaces = Object.keys(latestStats.stats.ns || {})
if (networkInterfaces.length > 0) {
!networkInterfaceFilterBar && setNetworkInterfaceFilterBar(<FilterBar store={$networkInterfaceFilter} />)
} else if (networkInterfaceFilterBar) {
setNetworkInterfaceFilterBar(null)
}
}
}, [systemStats, networkInterfaceFilterBar])
// values for system info bar // values for system info bar
const systemInfo = useMemo(() => { const systemInfo = useMemo(() => {
if (!system.info) { if (!system.info) {
@@ -389,6 +409,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
const lastGpuVals = Object.values(systemStats.at(-1)?.stats.g ?? {}) const lastGpuVals = Object.values(systemStats.at(-1)?.stats.g ?? {})
const hasGpuData = lastGpuVals.length > 0 const hasGpuData = lastGpuVals.length > 0
const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined) const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined)
const latestNetworkStats = systemStats.at(-1)?.stats.ni
let translatedStatus: string = system.status let translatedStatus: string = system.status
if (system.status === SystemStatus.Up) { if (system.status === SystemStatus.Up) {
@@ -555,7 +576,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
empty={dataEmpty} empty={dataEmpty}
grid={grid} grid={grid}
title={t`Disk I/O`} title={t`Disk I/O`}
description={t`Throughput of root filesystem`} description={t`Disk read and write throughput`}
cornerEl={maxValSelect} cornerEl={maxValSelect}
> >
<AreaChartDefault <AreaChartDefault
@@ -586,51 +607,44 @@ export default memo(function SystemDetail({ name }: { name: string }) {
/> />
</ChartCard> </ChartCard>
{/* Network interface charts */}
{Object.keys(latestNetworkStats ?? {}).length > 0 && (
<ChartCard <ChartCard
empty={dataEmpty} empty={dataEmpty}
grid={grid} grid={grid}
title={t`Bandwidth`} title={t`Network Interfaces`}
cornerEl={maxValSelect} description={t`Network traffic per interface`}
description={t`Network traffic of public interfaces`} cornerEl={networkInterfaceFilterBar}
> >
<AreaChartDefault {/* @ts-ignore */}
chartData={chartData} <NetworkInterfaceChart 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> </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 && ( {containerFilterBar && containerData.length > 0 && (
<div <div

View File

@@ -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", id: "net",
name: () => t`Net`, name: () => t`Net`,
size: 0, size: 0,
Icon: EthernetIcon, Icon: EthernetIcon,
header: sortableHeader, header: sortableHeader,
sortDescFirst: true,
sortingFn: (rowA, rowB) => {
const a = (rowA.original.info.ns || 0) + (rowA.original.info.nr || 0)
const b = (rowB.original.info.ns || 0) + (rowB.original.info.nr || 0)
return a - b
},
cell(info) { cell(info) {
const 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"] }) const userSettings = useStore($userSettings, { keys: ["unitNet"] })
if (sys.status === SystemStatus.Paused) { if (system.status === SystemStatus.Paused) {
return null 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 ( return (
<span className="tabular-nums whitespace-nowrap"> <span className={cn("tabular-nums whitespace-nowrap", { "ps-1": viewMode === "table" })}>
{decimalString(value, value >= 100 ? 1 : 2)} {unit} <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> </span>
) )
}, },

View File

@@ -55,6 +55,9 @@ listenKeys($userSettings, ["chartTime"], ({ chartTime }) => $chartTime.set(chart
/** Container chart filter */ /** Container chart filter */
export const $containerFilter = atom("") export const $containerFilter = atom("")
/** Network interface chart filter */
export const $networkInterfaceFilter = atom("")
/** Temperature chart filter */ /** Temperature chart filter */
export const $temperatureFilter = atom("") export const $temperatureFilter = atom("")

View File

@@ -1,3 +1,4 @@
/** biome-ignore-all lint/suspicious/noAssignInExpressions: it's fine :) */
import type { PreinitializedMapStore } from "nanostores" import type { PreinitializedMapStore } from "nanostores"
import { pb, verifyAuth } from "@/lib/api" import { pb, verifyAuth } from "@/lib/api"
import { import {
@@ -16,9 +17,10 @@ const COLLECTION = pb.collection<SystemRecord>("systems")
const FIELDS_DEFAULT = "id,name,host,port,info,status" const FIELDS_DEFAULT = "id,name,host,port,info,status"
/** Maximum system name length for display purposes */ /** Maximum system name length for display purposes */
const MAX_SYSTEM_NAME_LENGTH = 20 const MAX_SYSTEM_NAME_LENGTH = 22
let initialized = false let initialized = false
// biome-ignore lint/suspicious/noConfusingVoidType: typescript rocks
let unsub: (() => void) | undefined | void let unsub: (() => void) | undefined | void
/** Initialize the systems manager and set up listeners */ /** Initialize the systems manager and set up listeners */
@@ -104,20 +106,37 @@ async function fetchSystems(): Promise<SystemRecord[]> {
} }
} }
/** Makes sure the system has valid info object and throws if not */
function validateSystemInfo(system: SystemRecord) {
if (!("cpu" in system.info)) {
throw new Error(`${system.name} has no CPU info`)
}
}
/** Add system to both name and ID stores */ /** Add system to both name and ID stores */
export function add(system: SystemRecord) { export function add(system: SystemRecord) {
try {
validateSystemInfo(system)
$allSystemsByName.setKey(system.name, system) $allSystemsByName.setKey(system.name, system)
$allSystemsById.setKey(system.id, system) $allSystemsById.setKey(system.id, system)
} catch (error) {
console.error(error)
}
} }
/** Update system in stores */ /** Update system in stores */
export function update(system: SystemRecord) { export function update(system: SystemRecord) {
try {
validateSystemInfo(system)
// if name changed, make sure old name is removed from the name store // if name changed, make sure old name is removed from the name store
const oldName = $allSystemsById.get()[system.id]?.name const oldName = $allSystemsById.get()[system.id]?.name
if (oldName !== system.name) { if (oldName !== system.name) {
$allSystemsByName.setKey(oldName, undefined as any) $allSystemsByName.setKey(oldName, undefined as unknown as SystemRecord)
} }
add(system) add(system)
} catch (error) {
console.error(error)
}
} }
/** Remove system from stores */ /** Remove system from stores */
@@ -132,7 +151,7 @@ export function remove(system: SystemRecord) {
/** Remove system from specific store */ /** Remove system from specific store */
function removeFromStore(system: SystemRecord, store: PreinitializedMapStore<Record<string, SystemRecord>>) { function removeFromStore(system: SystemRecord, store: PreinitializedMapStore<Record<string, SystemRecord>>) {
const key = store === $allSystemsByName ? system.name : system.id const key = store === $allSystemsByName ? system.name : system.id
store.setKey(key, undefined as any) store.setKey(key, undefined as unknown as SystemRecord)
} }
/** Action functions for subscription */ /** Action functions for subscription */

View File

@@ -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) */ /** Map of system IDs to their corresponding tokens (used to avoid fetching in add-system dialog) */
export const tokenMap = new Map<SystemRecord["id"], FingerprintRecord["token"]>() export const tokenMap = new Map<SystemRecord["id"], FingerprintRecord["token"]>()
/**
* Formats a network speed value (in MB/s) to the most readable unit (B/s, KB/s, MB/s, GB/s, TB/s).
* @param valueMBps The value in MB/s
* @returns A string with the value and the appropriate unit
*/
export function formatSpeed(valueMBps: number): string {
const bitsPerSec = valueMBps * 8_000_000
if (bitsPerSec >= 1_000_000_000) {
return (bitsPerSec / 1_000_000_000).toFixed(2) + ' Gbit/s'
} else if (bitsPerSec >= 1_000_000) {
return (bitsPerSec / 1_000_000).toFixed(2) + ' Mbit/s'
} else {
return (bitsPerSec / 1_000).toFixed(2) + ' kbit/s'
}
}
/** Calculate duration between two dates and format as human-readable string */ /** Calculate duration between two dates and format as human-readable string */
export function formatDuration( export function formatDuration(
createdDate: string | null | undefined, createdDate: string | null | undefined,

View File

@@ -660,11 +660,11 @@ msgstr "内存"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Memory Usage" msgid "Memory Usage"
msgstr "内存使用" msgstr "内存使用"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Memory usage of docker containers" msgid "Memory usage of docker containers"
msgstr "Docker 容器的内存使用" msgstr "Docker 容器的内存使用"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
@@ -931,7 +931,7 @@ msgstr "系统使用的 SWAP 空间"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Swap Usage" msgid "Swap Usage"
msgstr "SWAP 使用" msgstr "SWAP 使用"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
@@ -1024,7 +1024,7 @@ msgstr "令牌"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens & Fingerprints" msgid "Tokens & Fingerprints"
msgstr "令牌指纹" msgstr "令牌指纹"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection." msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
@@ -1032,7 +1032,7 @@ msgstr "令牌允许客户端连接和注册。指纹是每个系统唯一的稳
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "令牌指纹用于验证到中心的WebSocket连接。" msgstr "令牌指纹用于验证到中心的 WebSocket 连接。"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Triggers when 1 minute load average exceeds a threshold" msgid "Triggers when 1 minute load average exceeds a threshold"

View File

@@ -74,6 +74,19 @@ const Layout = () => {
document.documentElement.dir = direction document.documentElement.dir = direction
}, [direction]) }, [direction])
// biome-ignore lint/correctness/useExhaustiveDependencies: only run on mount
useEffect(() => {
// refresh auth if not authenticated (required for trusted auth header)
if (!authenticated) {
pb.collection("users")
.authRefresh()
.then((res) => {
pb.authStore.save(res.token, res.record)
$authenticated.set(!!pb.authStore.isValid)
})
}
}, [])
return ( return (
<DirectionProvider dir={direction}> <DirectionProvider dir={direction}>
{!authenticated ? ( {!authenticated ? (

View File

@@ -75,6 +75,25 @@ export interface SystemInfo {
dt?: number dt?: number
/** operating system */ /** operating system */
os?: Os os?: Os
/** network sent (mb) */
ns?: number
/** network received (mb) */
nr?: number
}
export interface NetworkInterfaceStats {
/** network sent (mb) */
ns: number
/** network received (mb) */
nr: number
/** max network sent (mb) */
nsm?: number
/** max network received (mb) */
nrm?: number
/** total bytes sent since boot */
tbs?: number
/** total bytes received since boot */
tbr?: number
} }
export interface SystemStats { export interface SystemStats {
@@ -131,7 +150,9 @@ export interface SystemStats {
nsm?: number nsm?: number
/** max network received (mb) */ /** max network received (mb) */
nrm?: number nrm?: number
/** max network sent (bytes) */ /** per-interface network stats */
ni?: Record<string, NetworkInterfaceStats>
/** max bandwidth (bytes) [sent, received] */
bm?: [number, number] bm?: [number, number]
/** temperatures */ /** temperatures */
t?: Record<string, number> t?: Record<string, number>
@@ -141,6 +162,8 @@ export interface SystemStats {
g?: Record<string, GPUData> g?: Record<string, GPUData>
/** battery percent and state */ /** battery percent and state */
bat?: [number, BatteryState] bat?: [number, BatteryState]
/** network connection statistics */
nets?: Record<string, number>
} }
export interface GPUData { export interface GPUData {

View File

@@ -1,5 +1,13 @@
## 0.12.8 ## 0.12.8
- Add setting for time format (12h / 24h). (#424)
- Add experimental one-time password (OTP) support.
- Add `TRUSTED_AUTH_HEADER` environment variable for authentication forwarding. (#399)
- Add `AUTO_LOGIN` environment variable for automatic login. (#399)
- Add FreeBSD support for agent install script and update command. - Add FreeBSD support for agent install script and update command.
## 0.12.7 ## 0.12.7

View File

@@ -1,7 +1,7 @@
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: {{ include "beszel.fullname" . }}-web name: {{ include "beszel.fullname" . }}
labels: labels:
{{- include "beszel.labels" . | nindent 4 }} {{- include "beszel.labels" . | nindent 4 }}
{{- if .Values.service.annotations }} {{- if .Values.service.annotations }}

View File

@@ -30,14 +30,10 @@ securityContext: {}
service: service:
enabled: true enabled: true
type: LoadBalancer annotations: {}
loadBalancerIP: "10.0.10.251" type: ClusterIP
loadBalancerIP: ""
port: 8090 port: 8090
# -- Annotations for the DHCP service
annotations:
metallb.universe.tf/address-pool: pool
metallb.universe.tf/allow-shared-ip: beszel-hub-web
# -- Labels for the DHCP service
ingress: ingress:
enabled: false enabled: false
@@ -96,7 +92,7 @@ persistentVolumeClaim:
accessModes: accessModes:
- ReadWriteOnce - ReadWriteOnce
storageClass: "retain-local-path" storageClass: ""
# -- volume claim size # -- volume claim size
size: "500Mi" size: "500Mi"