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/bin
dockerfile_agent_dev
.vite

View File

@@ -77,7 +77,7 @@ dev-hub: export ENV=dev
dev-hub:
mkdir -p ./internal/site/dist && touch ./internal/site/dist/index.html
@if command -v entr >/dev/null 2>&1; then \
find ./internal/cmd/hub/*.go ./internal/{alerts,hub,records,users}/*.go | entr -r -s "cd ./internal/cmd/hub && go run -tags development . serve --http 0.0.0.0:8090"; \
find ./internal -type f -name '*.go' | entr -r -s "cd ./internal/cmd/hub && go run -tags development . serve --http 0.0.0.0:8090"; \
else \
cd ./internal/cmd/hub && go run -tags development . serve --http 0.0.0.0:8090; \
fi

View File

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

View File

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

View File

@@ -176,53 +176,85 @@ func (a *Agent) getSystemStats() system.Stats {
if len(a.netInterfaces) == 0 {
// if no network interfaces, initialize again
// this is a fix if agent started before network is online (#466)
// maybe refactor this in the future to not cache interface names at all so we
// don't miss an interface that's been added after agent started in any circumstance
a.initializeNetIoStats()
}
if netIO, err := psutilNet.IOCounters(true); err == nil {
msElapsed := uint64(time.Since(a.netIoStats.Time).Milliseconds())
a.netIoStats.Time = time.Now()
totalBytesSent := uint64(0)
totalBytesRecv := uint64(0)
// sum all bytes sent and received
now := time.Now()
// pre-allocate maps with known capacity
interfaceCount := len(a.netInterfaces)
if systemStats.NetworkInterfaces == nil || len(systemStats.NetworkInterfaces) != interfaceCount {
systemStats.NetworkInterfaces = make(map[string]system.NetworkInterfaceStats, interfaceCount)
}
var totalSent, totalRecv float64
// single pass through interfaces
for _, v := range netIO {
// skip if not in valid network interfaces list
if _, exists := a.netInterfaces[v.Name]; !exists {
continue
}
totalBytesSent += v.BytesSent
totalBytesRecv += v.BytesRecv
}
// add to systemStats
var bytesSentPerSecond, bytesRecvPerSecond uint64
if msElapsed > 0 {
bytesSentPerSecond = (totalBytesSent - a.netIoStats.BytesSent) * 1000 / msElapsed
bytesRecvPerSecond = (totalBytesRecv - a.netIoStats.BytesRecv) * 1000 / msElapsed
}
networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond))
networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond))
// add check for issue (#150) where sent is a massive number
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
for _, v := range netIO {
if _, exists := a.netInterfaces[v.Name]; !exists {
continue
// get previous stats for this interface
prevStats, exists := a.netIoStats[v.Name]
var networkSentPs, networkRecvPs float64
if exists {
secondsElapsed := time.Since(prevStats.Time).Seconds()
if secondsElapsed > 0 {
// direct calculation to MB/s, avoiding intermediate bytes/sec
networkSentPs = bytesToMegabytes(float64(v.BytesSent-prevStats.BytesSent) / secondsElapsed)
networkRecvPs = bytesToMegabytes(float64(v.BytesRecv-prevStats.BytesRecv) / secondsElapsed)
}
slog.Info(v.Name, "recv", v.BytesRecv, "sent", v.BytesSent)
}
// accumulate totals
totalSent += networkSentPs
totalRecv += networkRecvPs
// store per-interface stats
systemStats.NetworkInterfaces[v.Name] = system.NetworkInterfaceStats{
NetworkSent: networkSentPs,
NetworkRecv: networkRecvPs,
TotalBytesSent: v.BytesSent,
TotalBytesRecv: v.BytesRecv,
}
// update previous stats (reuse existing struct if possible)
if prevStats.Name == v.Name {
prevStats.BytesRecv = v.BytesRecv
prevStats.BytesSent = v.BytesSent
prevStats.PacketsSent = v.PacketsSent
prevStats.PacketsRecv = v.PacketsRecv
prevStats.Time = now
a.netIoStats[v.Name] = prevStats
} else {
a.netIoStats[v.Name] = system.NetIoStats{
BytesRecv: v.BytesRecv,
BytesSent: v.BytesSent,
PacketsSent: v.PacketsSent,
PacketsRecv: v.PacketsRecv,
Time: now,
Name: v.Name,
}
}
}
// add check for issue (#150) where sent is a massive number
if totalSent > 10_000 || totalRecv > 10_000 {
slog.Warn("Invalid net stats. Resetting.", "sent", totalSent, "recv", totalRecv)
// reset network I/O stats
a.initializeNetIoStats()
} else {
systemStats.NetworkSent = networkSentPs
systemStats.NetworkRecv = networkRecvPs
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
// update netIoStats
a.netIoStats.BytesSent = totalBytesSent
a.netIoStats.BytesRecv = totalBytesRecv
systemStats.NetworkSent = totalSent
systemStats.NetworkRecv = totalRecv
}
}
// connection counts
a.updateConnectionCounts(&systemStats)
// temperatures
// TODO: maybe refactor to methods on systemStats
a.updateTemperatures(&systemStats)
@@ -270,14 +302,109 @@ func (a *Agent) getSystemStats() system.Stats {
a.systemInfo.MemPct = systemStats.MemPct
a.systemInfo.DiskPct = systemStats.DiskPct
a.systemInfo.Uptime, _ = host.Uptime()
// TODO: in future release, remove MB bandwidth values in favor of bytes
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
// Sum all per-interface network sent/recv and assign to systemInfo
var totalSent, totalRecv float64
for _, iface := range systemStats.NetworkInterfaces {
totalSent += iface.NetworkSent
totalRecv += iface.NetworkRecv
}
a.systemInfo.NetworkSent = twoDecimals(totalSent)
a.systemInfo.NetworkRecv = twoDecimals(totalRecv)
slog.Debug("sysinfo", "data", a.systemInfo)
return systemStats
}
func (a *Agent) updateConnectionCounts(systemStats *system.Stats) {
// Get IPv4 connections
connectionsIPv4, err := psutilNet.Connections("inet")
if err != nil {
slog.Debug("Failed to get IPv4 connection stats", "err", err)
return
}
// Get IPv6 connections
connectionsIPv6, err := psutilNet.Connections("inet6")
if err != nil {
slog.Debug("Failed to get IPv6 connection stats", "err", err)
// Continue with IPv4 only if IPv6 fails
}
// Initialize Nets map if needed
if systemStats.Nets == nil {
systemStats.Nets = make(map[string]float64)
}
// Count IPv4 connection states
connStatsIPv4 := map[string]int{
"established": 0,
"listen": 0,
"time_wait": 0,
"close_wait": 0,
"syn_recv": 0,
}
for _, conn := range connectionsIPv4 {
// Only count TCP connections (Type 1 = SOCK_STREAM)
if conn.Type == 1 {
switch strings.ToUpper(conn.Status) {
case "ESTABLISHED":
connStatsIPv4["established"]++
case "LISTEN":
connStatsIPv4["listen"]++
case "TIME_WAIT":
connStatsIPv4["time_wait"]++
case "CLOSE_WAIT":
connStatsIPv4["close_wait"]++
case "SYN_RECV":
connStatsIPv4["syn_recv"]++
}
}
}
// Count IPv6 connection states
connStatsIPv6 := map[string]int{
"established": 0,
"listen": 0,
"time_wait": 0,
"close_wait": 0,
"syn_recv": 0,
}
for _, conn := range connectionsIPv6 {
// Only count TCP connections (Type 1 = SOCK_STREAM)
if conn.Type == 1 {
switch strings.ToUpper(conn.Status) {
case "ESTABLISHED":
connStatsIPv6["established"]++
case "LISTEN":
connStatsIPv6["listen"]++
case "TIME_WAIT":
connStatsIPv6["time_wait"]++
case "CLOSE_WAIT":
connStatsIPv6["close_wait"]++
case "SYN_RECV":
connStatsIPv6["syn_recv"]++
}
}
}
// Add IPv4 connection counts to Nets
systemStats.Nets["conn_established"] = float64(connStatsIPv4["established"])
systemStats.Nets["conn_listen"] = float64(connStatsIPv4["listen"])
systemStats.Nets["conn_timewait"] = float64(connStatsIPv4["time_wait"])
systemStats.Nets["conn_closewait"] = float64(connStatsIPv4["close_wait"])
systemStats.Nets["conn_synrecv"] = float64(connStatsIPv4["syn_recv"])
// Add IPv6 connection counts to Nets
systemStats.Nets["conn6_established"] = float64(connStatsIPv6["established"])
systemStats.Nets["conn6_listen"] = float64(connStatsIPv6["listen"])
systemStats.Nets["conn6_timewait"] = float64(connStatsIPv6["time_wait"])
systemStats.Nets["conn6_closewait"] = float64(connStatsIPv6["close_wait"])
systemStats.Nets["conn6_synrecv"] = float64(connStatsIPv6["syn_recv"])
}
// Returns the size of the ZFS ARC memory cache in bytes
func getARCSize() (uint64, error) {
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")

View File

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

View File

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

View File

@@ -175,35 +175,31 @@ func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
// 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 {
if e.Auth != nil {
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()
return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader))
})
}
}

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) {
var hubs []*beszelTests.TestHub

View File

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

View File

@@ -17,7 +17,10 @@
"linter": {
"enabled": true,
"rules": {
"recommended": true
"recommended": true,
"correctness": {
"useUniqueElementIds": "off"
}
}
},
"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 { SystemRecord } from "@/types"
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 { getPagePath } from "@nanostores/router"
import {
@@ -253,6 +253,12 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
copyWindowsCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token),
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`,
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,
ServerIcon,
Trash2Icon,
ExternalLinkIcon,
} from "lucide-react"
import { memo, useEffect, useMemo, useState } from "react"
import {
@@ -28,7 +29,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} 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 { Switch } from "@/components/ui/switch"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -150,6 +151,7 @@ const SectionUniversalToken = memo(() => {
setIsLoading(false)
}
// biome-ignore lint/correctness/useExhaustiveDependencies: only on mount
useEffect(() => {
updateToken()
}, [])
@@ -221,6 +223,16 @@ const ActionsButtonUniversalToken = memo(({ token, checked }: { token: string; c
onClick: () => copyWindowsCommand(port, publicKey, token),
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 (
<div className="flex items-center gap-2">
@@ -291,8 +303,8 @@ const SectionTable = memo(({ fingerprints = [] }: { fingerprints: FingerprintRec
</tr>
</TableHeader>
<TableBody className="whitespace-pre">
{fingerprints.map((fingerprint, i) => (
<TableRow key={i}>
{fingerprints.map((fingerprint) => (
<TableRow key={fingerprint.id}>
<TableCell className="font-medium ps-5 py-2 max-w-60 truncate">
{fingerprint.expand.system.name}
</TableCell>
@@ -317,10 +329,10 @@ async function updateFingerprint(fingerprint: FingerprintRecord, rotateToken = f
fingerprint: "",
token: rotateToken ? generateToken() : fingerprint.token,
})
} catch (error: any) {
} catch (error: unknown) {
toast({
title: t`Error`,
description: error.message,
description: (error as Error).message,
})
}
}

View File

@@ -24,6 +24,7 @@ import {
$containerFilter,
$direction,
$maxValues,
$networkInterfaceFilter,
$systems,
$temperatureFilter,
$userSettings,
@@ -52,6 +53,9 @@ import { Input } from "../ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
import { Separator } from "../ui/separator"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
import ConnectionChart from "../charts/connection-chart"
import NetworkInterfaceChart from "../charts/network-interface-chart"
import TotalBandwidthChart from "../charts/total-bandwidth-chart"
type ChartTimeData = {
time: number
@@ -147,6 +151,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
const netCardRef = useRef<HTMLDivElement>(null)
const persistChartTime = useRef(false)
const [containerFilterBar, setContainerFilterBar] = useState(null as null | JSX.Element)
const [networkInterfaceFilterBar, setNetworkInterfaceFilterBar] = useState(null as null | JSX.Element)
const [bottomSpacing, setBottomSpacing] = useState(0)
const [chartLoading, setChartLoading] = useState(true)
const isLongerChart = chartTime !== "1h"
@@ -163,7 +168,9 @@ export default memo(function SystemDetail({ name }: { name: string }) {
setSystemStats([])
setContainerData([])
setContainerFilterBar(null)
setNetworkInterfaceFilterBar(null)
$containerFilter.set("")
$networkInterfaceFilter.set("")
}
}, [name])
@@ -260,6 +267,19 @@ export default memo(function SystemDetail({ name }: { name: string }) {
})
}, [system, chartTime])
// Set up network interface filter bar
useEffect(() => {
if (systemStats.length > 0) {
const latestStats = systemStats[systemStats.length - 1]
const networkInterfaces = Object.keys(latestStats.stats.ns || {})
if (networkInterfaces.length > 0) {
!networkInterfaceFilterBar && setNetworkInterfaceFilterBar(<FilterBar store={$networkInterfaceFilter} />)
} else if (networkInterfaceFilterBar) {
setNetworkInterfaceFilterBar(null)
}
}
}, [systemStats, networkInterfaceFilterBar])
// values for system info bar
const systemInfo = useMemo(() => {
if (!system.info) {
@@ -389,6 +409,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
const lastGpuVals = Object.values(systemStats.at(-1)?.stats.g ?? {})
const hasGpuData = lastGpuVals.length > 0
const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined)
const latestNetworkStats = systemStats.at(-1)?.stats.ni
let translatedStatus: string = system.status
if (system.status === SystemStatus.Up) {
@@ -555,7 +576,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
empty={dataEmpty}
grid={grid}
title={t`Disk I/O`}
description={t`Throughput of root filesystem`}
description={t`Disk read and write throughput`}
cornerEl={maxValSelect}
>
<AreaChartDefault
@@ -586,51 +607,44 @@ export default memo(function SystemDetail({ name }: { name: string }) {
/>
</ChartCard>
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Bandwidth`}
cornerEl={maxValSelect}
description={t`Network traffic of public interfaces`}
>
<AreaChartDefault
chartData={chartData}
maxToggled={maxValues}
dataPoints={[
{
label: t`Sent`,
// use bytes if available, otherwise multiply old MB (can remove in future)
dataKey(data) {
if (showMax) {
return data?.stats?.bm?.[0] ?? (data?.stats?.nsm ?? 0) * 1024 * 1024
}
return data?.stats?.b?.[0] ?? data?.stats?.ns * 1024 * 1024
},
color: 5,
opacity: 0.2,
},
{
label: t`Received`,
dataKey(data) {
if (showMax) {
return data?.stats?.bm?.[1] ?? (data?.stats?.nrm ?? 0) * 1024 * 1024
}
return data?.stats?.b?.[1] ?? data?.stats?.nr * 1024 * 1024
},
color: 2,
opacity: 0.2,
},
]}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={(data) => {
const { value, unit } = formatBytes(data.value, true, userSettings.unitNet, false)
return `${decimalString(value, value >= 100 ? 1 : 2)} ${unit}`
}}
/>
</ChartCard>
{/* Network interface charts */}
{Object.keys(latestNetworkStats ?? {}).length > 0 && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Network Interfaces`}
description={t`Network traffic per interface`}
cornerEl={networkInterfaceFilterBar}
>
{/* @ts-ignore */}
<NetworkInterfaceChart chartData={chartData} />
</ChartCard>
)}
{/* Per-Interface Cumulative Bandwidth chart */}
{Object.keys(latestNetworkStats ?? {}).length > 0 && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Cumulative Bandwidth`}
description={t`Total bytes sent and received per network interface since boot`}
>
{/* @ts-ignore */}
<TotalBandwidthChart chartData={chartData} />
</ChartCard>
)}
{/* TCP Connection States chart */}
{systemStats.at(-1)?.stats.nets && Object.keys(systemStats.at(-1)?.stats.nets ?? {}).length > 0 && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`TCP Connection States`}
description={t`TCP connection states for IPv4 and IPv6`}
>
<ConnectionChart chartData={chartData} />
</ChartCard>
)}
{containerFilterBar && containerData.length > 0 && (
<div

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",
name: () => t`Net`,
size: 0,
Icon: EthernetIcon,
header: sortableHeader,
sortDescFirst: true,
sortingFn: (rowA, rowB) => {
const a = (rowA.original.info.ns || 0) + (rowA.original.info.nr || 0)
const b = (rowB.original.info.ns || 0) + (rowB.original.info.nr || 0)
return a - b
},
cell(info) {
const sys = info.row.original
const system = info.row.original
const sent = system.info.ns || 0
const received = system.info.nr || 0
const userSettings = useStore($userSettings, { keys: ["unitNet"] })
if (sys.status === SystemStatus.Paused) {
if (system.status === SystemStatus.Paused) {
return null
}
const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, false)
const sentFmt = formatBytes(sent, true, userSettings.unitNet, true)
const receivedFmt = formatBytes(received, true, userSettings.unitNet, true)
return (
<span className="tabular-nums whitespace-nowrap">
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
<span className={cn("tabular-nums whitespace-nowrap", { "ps-1": viewMode === "table" })}>
<span className="text-green-600"></span> {Math.round(sentFmt.value)} {sentFmt.unit}{" "}
<span className="text-blue-600"></span> {Math.round(receivedFmt.value)} {receivedFmt.unit}
</span>
)
},

View File

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

View File

@@ -1,3 +1,4 @@
/** biome-ignore-all lint/suspicious/noAssignInExpressions: it's fine :) */
import type { PreinitializedMapStore } from "nanostores"
import { pb, verifyAuth } from "@/lib/api"
import {
@@ -16,9 +17,10 @@ const COLLECTION = pb.collection<SystemRecord>("systems")
const FIELDS_DEFAULT = "id,name,host,port,info,status"
/** Maximum system name length for display purposes */
const MAX_SYSTEM_NAME_LENGTH = 20
const MAX_SYSTEM_NAME_LENGTH = 22
let initialized = false
// biome-ignore lint/suspicious/noConfusingVoidType: typescript rocks
let unsub: (() => void) | undefined | void
/** 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 */
export function add(system: SystemRecord) {
$allSystemsByName.setKey(system.name, system)
$allSystemsById.setKey(system.id, system)
try {
validateSystemInfo(system)
$allSystemsByName.setKey(system.name, system)
$allSystemsById.setKey(system.id, system)
} catch (error) {
console.error(error)
}
}
/** Update system in stores */
export function update(system: SystemRecord) {
// if name changed, make sure old name is removed from the name store
const oldName = $allSystemsById.get()[system.id]?.name
if (oldName !== system.name) {
$allSystemsByName.setKey(oldName, undefined as any)
try {
validateSystemInfo(system)
// if name changed, make sure old name is removed from the name store
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 */
@@ -132,7 +151,7 @@ export function remove(system: SystemRecord) {
/** Remove system from specific store */
function removeFromStore(system: SystemRecord, store: PreinitializedMapStore<Record<string, SystemRecord>>) {
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 */

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

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"
"Project-Id-Version: beszel\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"
"Language-Team: Icelandic\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: beszel\n"
"X-Crowdin-Project-ID: 733311\n"
"X-Crowdin-Language: is\n"
"X-Crowdin-File: /main/internal/site/src/locales/en/en.po\n"
"X-Crowdin-File-ID: 32\n"
"X-Crowdin-File: /main/beszel/site/src/locales/en/en.po\n"
"X-Crowdin-File-ID: 16\n"
#. placeholder {0}: Math.trunc(system.info?.u / 86400)
#: src/components/routes/system.tsx
@@ -112,7 +112,7 @@ msgstr ""
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
msgstr ""
msgstr "Admin"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
@@ -173,10 +173,6 @@ msgstr "Meðal nýting örgjörva yfir allt kerfið"
msgid "Average utilization of {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/navbar.tsx
msgid "Backups"
@@ -201,7 +197,7 @@ msgstr "Beszel notar <0>Shoutrrr</0> til að tengjast vinsælum tilkynningaþjó
#: src/components/add-system.tsx
msgid "Binary"
msgstr ""
msgstr "Binary"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx
@@ -367,14 +363,6 @@ msgstr ""
msgid "Critical (%)"
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
#: src/components/routes/system.tsx
msgid "Current state"
@@ -453,10 +441,6 @@ msgstr ""
msgid "Down ({downSystemsLength})"
msgstr ""
#: src/components/routes/system/network-sheet.tsx
msgid "Download"
msgstr ""
#: src/components/alerts-history-columns.tsx
msgid "Duration"
msgstr ""
@@ -468,7 +452,6 @@ msgstr ""
#: src/components/login/auth-form.tsx
#: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx
msgid "Email"
msgstr "Netfang"
@@ -489,10 +472,6 @@ msgstr "Settu netfang til að endursetja lykilorð"
msgid "Enter email address..."
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/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
@@ -563,16 +542,10 @@ msgstr ""
msgid "Forgot password?"
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
#: src/lib/i18n.ts
msgid "Full"
msgstr ""
msgstr "Full"
#. Context: General settings
#: src/components/routes/settings/general.tsx
@@ -580,10 +553,6 @@ msgstr ""
msgid "General"
msgstr "Almennt"
#: src/components/routes/system.tsx
msgid "GPU Engines"
msgstr ""
#: src/components/routes/system.tsx
msgid "GPU Power Draw"
msgstr "Skjákorts rafmagnsnotkun"
@@ -600,7 +569,7 @@ msgstr "Homebrew skipun"
#: src/components/add-system.tsx
msgid "Host / IP"
msgstr ""
msgstr "Host / IP"
#. Context: Battery state
#: src/lib/i18n.ts
@@ -676,7 +645,6 @@ msgid "Manage display and notification preferences."
msgstr ""
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Manual setup instructions"
msgstr ""
@@ -705,15 +673,13 @@ msgstr "Nafn"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Net"
msgstr ""
msgstr "Net"
#: src/components/routes/system.tsx
msgid "Network traffic of docker containers"
msgstr "Net traffík docker kerfa"
#: 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"
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."
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/systems-table/systems-table-columns.tsx
@@ -845,7 +807,7 @@ msgstr "Vinsamlegast skráðu þig inn á aðganginn þinn"
#: src/components/add-system.tsx
msgid "Port"
msgstr ""
msgstr "Port"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
@@ -871,14 +833,6 @@ msgstr "Lesa"
msgid "Received"
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
msgid "Reset Password"
msgstr "Endurstilla lykilorð"
@@ -928,12 +882,16 @@ msgstr ""
#: src/components/routes/system.tsx
msgid "Sent"
msgstr ""
msgstr "Sent"
#: src/components/routes/settings/general.tsx
msgid "Set percentage thresholds for meter colors."
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/routes/settings/layout.tsx
@@ -1044,10 +1002,6 @@ msgstr ""
msgid "Throughput of root filesystem"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Time format"
msgstr ""
#: src/components/routes/settings/notifications.tsx
msgid "To email(s)"
msgstr "Til tölvupósta"
@@ -1080,14 +1034,6 @@ msgstr ""
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
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
msgid "Triggers when 1 minute load average exceeds a threshold"
msgstr ""
@@ -1149,10 +1095,6 @@ msgstr ""
msgid "Up ({upSystemsLength})"
msgstr ""
#: src/components/routes/system/network-sheet.tsx
msgid "Upload"
msgstr ""
#: src/components/routes/system.tsx
msgid "Uptime"
msgstr ""
@@ -1186,10 +1128,6 @@ msgstr ""
msgid "View"
msgstr "Skoða"
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "View your 200 most recent alerts."
msgstr ""
@@ -1245,4 +1183,3 @@ msgstr ""
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
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
/** operating system */
os?: Os
/** network sent (mb) */
ns?: number
/** network received (mb) */
nr?: number
}
export interface NetworkInterfaceStats {
/** network sent (mb) */
ns: number
/** network received (mb) */
nr: number
/** max network sent (mb) */
nsm?: number
/** max network received (mb) */
nrm?: number
/** total bytes sent since boot */
tbs?: number
/** total bytes received since boot */
tbr?: number
}
export interface SystemStats {
@@ -131,7 +150,9 @@ export interface SystemStats {
nsm?: number
/** max network received (mb) */
nrm?: number
/** max network sent (bytes) */
/** per-interface network stats */
ni?: Record<string, NetworkInterfaceStats>
/** max bandwidth (bytes) [sent, received] */
bm?: [number, number]
/** temperatures */
t?: Record<string, number>
@@ -141,6 +162,8 @@ export interface SystemStats {
g?: Record<string, GPUData>
/** battery percent and state */
bat?: [number, BatteryState]
/** network connection statistics */
nets?: Record<string, number>
}
export interface GPUData {

View File

@@ -1,5 +1,13 @@
## 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.
## 0.12.7

View File

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

View File

@@ -30,14 +30,10 @@ securityContext: {}
service:
enabled: true
type: LoadBalancer
loadBalancerIP: "10.0.10.251"
annotations: {}
type: ClusterIP
loadBalancerIP: ""
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:
enabled: false
@@ -96,7 +92,7 @@ persistentVolumeClaim:
accessModes:
- ReadWriteOnce
storageClass: "retain-local-path"
storageClass: ""
# -- volume claim size
size: "500Mi"