Compare commits

..

6 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
61 changed files with 3070 additions and 27131 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

@@ -22,23 +22,23 @@ import (
) )
type Agent struct { type Agent struct {
sync.Mutex // Used to lock agent while collecting data sync.Mutex // Used to lock agent while collecting data
debug bool // true if LOG_LEVEL is set to debug debug bool // true if LOG_LEVEL is set to debug
zfs bool // true if system has arcstats zfs bool // true if system has arcstats
memCalc string // Memory calculation formula memCalc string // Memory calculation formula
fsNames []string // List of filesystem device names being monitored fsNames []string // List of filesystem device names being monitored
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
netInterfaces map[string]struct{} // Stores all valid network interfaces netInterfaces map[string]struct{} // Stores all valid network interfaces
netIoStats 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
gpuManager *GPUManager // Manages GPU data gpuManager *GPUManager // Manages GPU data
cache *SessionCache // Cache for system stats based on primary session ID cache *SessionCache // Cache for system stats based on primary session ID
connectionManager *ConnectionManager // Channel to signal connection events connectionManager *ConnectionManager // Channel to signal connection events
server *ssh.Server // SSH server server *ssh.Server // SSH server
dataDir string // Directory for persisting data dataDir string // Directory for persisting data
keys []gossh.PublicKey // SSH public keys keys []gossh.PublicKey // SSH public keys
} }
// NewAgent creates a new agent with the given data directory for persisting data. // NewAgent creates a new agent with the given data directory for persisting data.

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]
// add to systemStats var networkSentPs, networkRecvPs float64
var bytesSentPerSecond, bytesRecvPerSecond uint64
if msElapsed > 0 { if exists {
bytesSentPerSecond = (totalBytesSent - a.netIoStats.BytesSent) * 1000 / msElapsed secondsElapsed := time.Since(prevStats.Time).Seconds()
bytesRecvPerSecond = (totalBytesRecv - a.netIoStats.BytesRecv) * 1000 / msElapsed if secondsElapsed > 0 {
} // direct calculation to MB/s, avoiding intermediate bytes/sec
networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond)) networkSentPs = bytesToMegabytes(float64(v.BytesSent-prevStats.BytesSent) / secondsElapsed)
networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond)) networkRecvPs = bytesToMegabytes(float64(v.BytesRecv-prevStats.BytesRecv) / secondsElapsed)
// add check for issue (#150) where sent is a massive number
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
for _, v := range netIO {
if _, exists := a.netInterfaces[v.Name]; !exists {
continue
} }
slog.Info(v.Name, "recv", v.BytesRecv, "sent", v.BytesSent)
} }
// accumulate totals
totalSent += networkSentPs
totalRecv += networkRecvPs
// store per-interface stats
systemStats.NetworkInterfaces[v.Name] = system.NetworkInterfaceStats{
NetworkSent: networkSentPs,
NetworkRecv: networkRecvPs,
TotalBytesSent: v.BytesSent,
TotalBytesRecv: v.BytesRecv,
}
// update previous stats (reuse existing struct if possible)
if prevStats.Name == v.Name {
prevStats.BytesRecv = v.BytesRecv
prevStats.BytesSent = v.BytesSent
prevStats.PacketsSent = v.PacketsSent
prevStats.PacketsRecv = v.PacketsRecv
prevStats.Time = now
a.netIoStats[v.Name] = prevStats
} else {
a.netIoStats[v.Name] = system.NetIoStats{
BytesRecv: v.BytesRecv,
BytesSent: v.BytesSent,
PacketsSent: v.PacketsSent,
PacketsRecv: v.PacketsRecv,
Time: now,
Name: v.Name,
}
}
}
// add check for issue (#150) where sent is a massive number
if totalSent > 10_000 || totalRecv > 10_000 {
slog.Warn("Invalid net stats. Resetting.", "sent", totalSent, "recv", totalRecv)
// reset network I/O stats // 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

@@ -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,39 +8,47 @@ import (
"github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/container"
) )
type NetworkInterfaceStats struct {
NetworkSent float64 `json:"ns"`
NetworkRecv float64 `json:"nr"`
MaxNetworkSent float64 `json:"nsm,omitempty"`
MaxNetworkRecv float64 `json:"nrm,omitempty"`
TotalBytesSent uint64 `json:"tbs,omitempty"` // Total bytes sent since boot
TotalBytesRecv uint64 `json:"tbr,omitempty"` // Total bytes received since boot
}
type Stats struct { type Stats struct {
Cpu float64 `json:"cpu" cbor:"0,keyasint"` Cpu float64 `json:"cpu" cbor:"0,keyasint"`
MaxCpu float64 `json:"cpum,omitempty" cbor:"1,keyasint,omitempty"` MaxCpu float64 `json:"cpum,omitempty" cbor:"1,keyasint,omitempty"`
Mem float64 `json:"m" cbor:"2,keyasint"` Mem float64 `json:"m" cbor:"2,keyasint"`
MemUsed float64 `json:"mu" cbor:"3,keyasint"` MemUsed float64 `json:"mu" cbor:"3,keyasint"`
MemPct float64 `json:"mp" cbor:"4,keyasint"` MemPct float64 `json:"mp" cbor:"4,keyasint"`
MemBuffCache float64 `json:"mb" cbor:"5,keyasint"` MemBuffCache float64 `json:"mb" cbor:"5,keyasint"`
MemZfsArc float64 `json:"mz,omitempty" cbor:"6,keyasint,omitempty"` // ZFS ARC memory MemZfsArc float64 `json:"mz,omitempty" cbor:"6,keyasint,omitempty"` // ZFS ARC memory
Swap float64 `json:"s,omitempty" cbor:"7,keyasint,omitempty"` Swap float64 `json:"s,omitempty" cbor:"7,keyasint,omitempty"`
SwapUsed float64 `json:"su,omitempty" cbor:"8,keyasint,omitempty"` SwapUsed float64 `json:"su,omitempty" cbor:"8,keyasint,omitempty"`
DiskTotal float64 `json:"d" cbor:"9,keyasint"` DiskTotal float64 `json:"d" cbor:"9,keyasint"`
DiskUsed float64 `json:"du" cbor:"10,keyasint"` DiskUsed float64 `json:"du" cbor:"10,keyasint"`
DiskPct float64 `json:"dp" cbor:"11,keyasint"` DiskPct float64 `json:"dp" cbor:"11,keyasint"`
DiskReadPs float64 `json:"dr" cbor:"12,keyasint"` DiskReadPs float64 `json:"dr" cbor:"12,keyasint"`
DiskWritePs float64 `json:"dw" cbor:"13,keyasint"` DiskWritePs float64 `json:"dw" cbor:"13,keyasint"`
MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"14,keyasint,omitempty"` MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"14,keyasint,omitempty"`
MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"15,keyasint,omitempty"` MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"15,keyasint,omitempty"`
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 {
@@ -68,10 +76,12 @@ type FsStats struct {
} }
type NetIoStats struct { type NetIoStats struct {
BytesRecv uint64 BytesRecv uint64
BytesSent uint64 BytesSent uint64
Time time.Time PacketsSent uint64
Name string PacketsRecv uint64
Time time.Time
Name string
} }
type Os = uint8 type Os = uint8
@@ -84,27 +94,26 @@ const (
) )
type Info struct { type Info struct {
Hostname string `json:"h" cbor:"0,keyasint"` Hostname string `json:"h" cbor:"0,keyasint"`
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"` KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
Cores int `json:"c" cbor:"2,keyasint"` Cores int `json:"c" cbor:"2,keyasint"`
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"` Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
CpuModel string `json:"m" cbor:"4,keyasint"` CpuModel string `json:"m" cbor:"4,keyasint"`
Uptime uint64 `json:"u" cbor:"5,keyasint"` Uptime uint64 `json:"u" cbor:"5,keyasint"`
Cpu float64 `json:"cpu" cbor:"6,keyasint"` Cpu float64 `json:"cpu" cbor:"6,keyasint"`
MemPct float64 `json:"mp" cbor:"7,keyasint"` MemPct float64 `json:"mp" cbor:"7,keyasint"`
DiskPct float64 `json:"dp" cbor:"8,keyasint"` DiskPct float64 `json:"dp" cbor:"8,keyasint"`
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

@@ -175,35 +175,31 @@ func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
// custom middlewares // custom middlewares
func (h *Hub) registerMiddlewares(se *core.ServeEvent) { 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 // authenticate with trusted header
if trustedHeader, _ := GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" { if trustedHeader, _ := GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" {
se.Router.BindFunc(func(e *core.RequestEvent) error { se.Router.BindFunc(func(e *core.RequestEvent) error {
if e.Auth != nil { return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader))
return e.Next()
}
trustedEmail := e.Request.Header.Get(trustedHeader)
if trustedEmail == "" {
return e.Next()
}
isAuthRefresh := e.Request.URL.Path == "/api/collections/users/auth-refresh" && e.Request.Method == http.MethodPost
if !isAuthRefresh {
authRecord, err := e.App.FindAuthRecordByEmail("users", trustedEmail)
if err == nil {
e.Auth = authRecord
}
return e.Next()
}
// if auth refresh endpoint, find user record directly and generate token
user, err := e.App.FindFirstRecordByData("users", "email", trustedEmail)
if err != nil {
return e.Next()
}
e.Auth = user
// need to set the authorization header for the client sdk to pick up the token
if token, err := user.NewAuthToken(); err == nil {
e.Request.Header.Set("Authorization", token)
}
return e.Next()
}) })
} }
} }

View File

@@ -712,6 +712,60 @@ func TestCreateUserEndpointAvailability(t *testing.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) { func TestTrustedHeaderMiddleware(t *testing.T) {
var hubs []*beszelTests.TestHub var hubs []*beszelTests.TestHub

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,19 +409,15 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
} }
sums[stat.Name].Cpu += stat.Cpu sums[stat.Name].Cpu += stat.Cpu
sums[stat.Name].Mem += stat.Mem sums[stat.Name].Mem += stat.Mem
sums[stat.Name].NetworkSent += stat.NetworkSent
sums[stat.Name].NetworkRecv += stat.NetworkRecv
} }
} }
result := make([]container.Stats, 0, len(sums)) result := make([]container.Stats, 0, len(sums))
for _, value := range sums { for _, value := range sums {
result = append(result, container.Stats{ result = append(result, container.Stats{
Name: value.Name, Name: value.Name,
Cpu: twoDecimals(value.Cpu / count), Cpu: twoDecimals(value.Cpu / count),
Mem: twoDecimals(value.Mem / count), Mem: twoDecimals(value.Mem / count),
NetworkSent: twoDecimals(value.NetworkSent / count),
NetworkRecv: twoDecimals(value.NetworkRecv / count),
}) })
} }
return result return result

View File

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

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>
<ChartCard {/* Network interface charts */}
empty={dataEmpty} {Object.keys(latestNetworkStats ?? {}).length > 0 && (
grid={grid} <ChartCard
title={t`Bandwidth`} empty={dataEmpty}
cornerEl={maxValSelect} grid={grid}
description={t`Network traffic of public interfaces`} title={t`Network Interfaces`}
> description={t`Network traffic per interface`}
<AreaChartDefault cornerEl={networkInterfaceFilterBar}
chartData={chartData} >
maxToggled={maxValues} {/* @ts-ignore */}
dataPoints={[ <NetworkInterfaceChart chartData={chartData} />
{ </ChartCard>
label: t`Sent`, )}
// use bytes if available, otherwise multiply old MB (can remove in future)
dataKey(data) { {/* Per-Interface Cumulative Bandwidth chart */}
if (showMax) { {Object.keys(latestNetworkStats ?? {}).length > 0 && (
return data?.stats?.bm?.[0] ?? (data?.stats?.nsm ?? 0) * 1024 * 1024 <ChartCard
} empty={dataEmpty}
return data?.stats?.b?.[0] ?? data?.stats?.ns * 1024 * 1024 grid={grid}
}, title={t`Cumulative Bandwidth`}
color: 5, description={t`Total bytes sent and received per network interface since boot`}
opacity: 0.2, >
}, {/* @ts-ignore */}
{ <TotalBandwidthChart chartData={chartData} />
label: t`Received`, </ChartCard>
dataKey(data) { )}
if (showMax) {
return data?.stats?.bm?.[1] ?? (data?.stats?.nrm ?? 0) * 1024 * 1024 {/* TCP Connection States chart */}
} {systemStats.at(-1)?.stats.nets && Object.keys(systemStats.at(-1)?.stats.nets ?? {}).length > 0 && (
return data?.stats?.b?.[1] ?? data?.stats?.nr * 1024 * 1024 <ChartCard
}, empty={dataEmpty}
color: 2, grid={grid}
opacity: 0.2, title={t`TCP Connection States`}
}, description={t`TCP connection states for IPv4 and IPv6`}
]} >
tickFormatter={(val) => { <ConnectionChart chartData={chartData} />
const { value, unit } = formatBytes(val, true, userSettings.unitNet, false) </ChartCard>
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}` )}
}}
contentFormatter={(data) => {
const { value, unit } = formatBytes(data.value, true, userSettings.unitNet, false)
return `${decimalString(value, value >= 100 ? 1 : 2)} ${unit}`
}}
/>
</ChartCard>
{containerFilterBar && containerData.length > 0 && ( {containerFilterBar && containerData.length > 0 && (
<div <div

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) {
$allSystemsByName.setKey(system.name, system) try {
$allSystemsById.setKey(system.id, system) validateSystemInfo(system)
$allSystemsByName.setKey(system.name, 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) {
// if name changed, make sure old name is removed from the name store try {
const oldName = $allSystemsById.get()[system.id]?.name validateSystemInfo(system)
if (oldName !== system.name) { // if name changed, make sure old name is removed from the name store
$allSystemsByName.setKey(oldName, undefined as any) const oldName = $allSystemsById.get()[system.id]?.name
if (oldName !== system.name) {
$allSystemsByName.setKey(oldName, undefined as unknown as SystemRecord)
}
add(system)
} catch (error) {
console.error(error)
} }
add(system)
} }
/** 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,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -8,15 +8,15 @@ msgstr ""
"Language: is\n" "Language: is\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-09-22 23:10\n" "PO-Revision-Date: 2025-08-28 23:21\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Icelandic\n" "Language-Team: Icelandic\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: beszel\n" "X-Crowdin-Project: beszel\n"
"X-Crowdin-Project-ID: 733311\n" "X-Crowdin-Project-ID: 733311\n"
"X-Crowdin-Language: is\n" "X-Crowdin-Language: is\n"
"X-Crowdin-File: /main/internal/site/src/locales/en/en.po\n" "X-Crowdin-File: /main/beszel/site/src/locales/en/en.po\n"
"X-Crowdin-File-ID: 32\n" "X-Crowdin-File-ID: 16\n"
#. placeholder {0}: Math.trunc(system.info?.u / 86400) #. placeholder {0}: Math.trunc(system.info?.u / 86400)
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
@@ -112,7 +112,7 @@ msgstr ""
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "Admin" msgid "Admin"
msgstr "" msgstr "Admin"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Agent" msgid "Agent"
@@ -173,10 +173,6 @@ msgstr "Meðal nýting örgjörva yfir allt kerfið"
msgid "Average utilization of {0}" msgid "Average utilization of {0}"
msgstr "Meðal notkun af {0}" msgstr "Meðal notkun af {0}"
#: src/components/routes/system.tsx
msgid "Average utilization of GPU engines"
msgstr ""
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Backups" msgid "Backups"
@@ -201,7 +197,7 @@ msgstr "Beszel notar <0>Shoutrrr</0> til að tengjast vinsælum tilkynningaþjó
#: src/components/add-system.tsx #: src/components/add-system.tsx
msgid "Binary" msgid "Binary"
msgstr "" msgstr "Binary"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
@@ -367,14 +363,6 @@ msgstr ""
msgid "Critical (%)" msgid "Critical (%)"
msgstr "Kritískt (%)" msgstr "Kritískt (%)"
#: src/components/routes/system/network-sheet.tsx
msgid "Cumulative Download"
msgstr ""
#: src/components/routes/system/network-sheet.tsx
msgid "Cumulative Upload"
msgstr ""
#. Context: Battery state #. Context: Battery state
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Current state" msgid "Current state"
@@ -453,10 +441,6 @@ msgstr ""
msgid "Down ({downSystemsLength})" msgid "Down ({downSystemsLength})"
msgstr "" msgstr ""
#: src/components/routes/system/network-sheet.tsx
msgid "Download"
msgstr ""
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
msgid "Duration" msgid "Duration"
msgstr "" msgstr ""
@@ -468,7 +452,6 @@ msgstr ""
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
#: src/components/login/forgot-pass-form.tsx #: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx
msgid "Email" msgid "Email"
msgstr "Netfang" msgstr "Netfang"
@@ -489,10 +472,6 @@ msgstr "Settu netfang til að endursetja lykilorð"
msgid "Enter email address..." msgid "Enter email address..."
msgstr "Settu inn Netfang..." msgstr "Settu inn Netfang..."
#: src/components/login/otp-forms.tsx
msgid "Enter your one-time password."
msgstr ""
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx #: src/components/routes/settings/config-yaml.tsx
@@ -563,16 +542,10 @@ msgstr ""
msgid "Forgot password?" msgid "Forgot password?"
msgstr "Gleymt lykilorð?" msgstr "Gleymt lykilorð?"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"
msgid "FreeBSD command"
msgstr ""
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Full" msgid "Full"
msgstr "" msgstr "Full"
#. Context: General settings #. Context: General settings
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
@@ -580,10 +553,6 @@ msgstr ""
msgid "General" msgid "General"
msgstr "Almennt" msgstr "Almennt"
#: src/components/routes/system.tsx
msgid "GPU Engines"
msgstr ""
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "GPU Power Draw" msgid "GPU Power Draw"
msgstr "Skjákorts rafmagnsnotkun" msgstr "Skjákorts rafmagnsnotkun"
@@ -600,7 +569,7 @@ msgstr "Homebrew skipun"
#: src/components/add-system.tsx #: src/components/add-system.tsx
msgid "Host / IP" msgid "Host / IP"
msgstr "" msgstr "Host / IP"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
@@ -676,7 +645,6 @@ msgid "Manage display and notification preferences."
msgstr "" msgstr ""
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Manual setup instructions" msgid "Manual setup instructions"
msgstr "" msgstr ""
@@ -705,15 +673,13 @@ msgstr "Nafn"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Net" msgid "Net"
msgstr "" msgstr "Net"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Network traffic of docker containers" msgid "Network traffic of docker containers"
msgstr "Net traffík docker kerfa" msgstr "Net traffík docker kerfa"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/routes/system/network-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "Network traffic of public interfaces" msgid "Network traffic of public interfaces"
msgstr "" msgstr ""
@@ -749,10 +715,6 @@ msgstr "OAuth 2 / OIDC stuðningur"
msgid "On each restart, systems in the database will be updated to match the systems defined in the file." msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
msgstr "" msgstr ""
#: src/components/login/auth-form.tsx
msgid "One-time password"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
@@ -845,7 +807,7 @@ msgstr "Vinsamlegast skráðu þig inn á aðganginn þinn"
#: src/components/add-system.tsx #: src/components/add-system.tsx
msgid "Port" msgid "Port"
msgstr "" msgstr "Port"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
@@ -871,14 +833,6 @@ msgstr "Lesa"
msgid "Received" msgid "Received"
msgstr "Móttekið" msgstr "Móttekið"
#: src/components/login/login.tsx
msgid "Request a one-time password"
msgstr ""
#: src/components/login/otp-forms.tsx
msgid "Request OTP"
msgstr ""
#: src/components/login/forgot-pass-form.tsx #: src/components/login/forgot-pass-form.tsx
msgid "Reset Password" msgid "Reset Password"
msgstr "Endurstilla lykilorð" msgstr "Endurstilla lykilorð"
@@ -928,12 +882,16 @@ msgstr ""
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Sent" msgid "Sent"
msgstr "" msgstr "Sent"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Set percentage thresholds for meter colors." msgid "Set percentage thresholds for meter colors."
msgstr "Stilltu prósentuþröskuld fyrir mælaliti." msgstr "Stilltu prósentuþröskuld fyrir mælaliti."
#: src/components/routes/settings/general.tsx
msgid "Sets the default time range for charts when a system is viewed."
msgstr ""
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
@@ -1044,10 +1002,6 @@ msgstr ""
msgid "Throughput of root filesystem" msgid "Throughput of root filesystem"
msgstr "" msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Time format"
msgstr ""
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "To email(s)" msgid "To email(s)"
msgstr "Til tölvupósta" msgstr "Til tölvupósta"
@@ -1080,14 +1034,6 @@ msgstr ""
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 "" msgstr ""
#: src/components/routes/system/network-sheet.tsx
msgid "Total data received for each interface"
msgstr ""
#: src/components/routes/system/network-sheet.tsx
msgid "Total data sent for each interface"
msgstr ""
#: 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"
msgstr "" msgstr ""
@@ -1149,10 +1095,6 @@ msgstr ""
msgid "Up ({upSystemsLength})" msgid "Up ({upSystemsLength})"
msgstr "" msgstr ""
#: src/components/routes/system/network-sheet.tsx
msgid "Upload"
msgstr ""
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Uptime" msgid "Uptime"
msgstr "" msgstr ""
@@ -1186,10 +1128,6 @@ msgstr ""
msgid "View" msgid "View"
msgstr "Skoða" msgstr "Skoða"
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "View your 200 most recent alerts." msgid "View your 200 most recent alerts."
msgstr "" msgstr ""
@@ -1245,4 +1183,3 @@ msgstr ""
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "Notenda stillingar vistaðar." msgstr "Notenda stillingar vistaðar."

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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"