Compare commits

..

36 Commits

Author SHA1 Message Date
Sven van Ginkel
40165dca32 [Feature] Improve Container monitoring (#928)
* align docker colors

* change the name of the chart

* Add tabs

* Add volumes, health, stack and uptime

* remove tests

* fix table

* Add stack filtering

* search in filter

* add container id

* fix uptime

* feat add ability to exclude container

* remove stack colors, didnt look clear

* better table

* add disk io

* Sync with main

* undo locale

* undo locale
2025-10-19 17:14:19 -04:00
henrygd
2d8739052b likely fix for huge net traffic on interface reset (#1267) 2025-10-18 15:40:54 -04:00
henrygd
1e32d13650 make windows firewall rule opt-in with -ConfigureFirewall 2025-10-11 13:34:57 -04:00
hank
dbf3f94247 New translations by rodrigorm (Portuguese) 2025-10-09 14:21:48 -04:00
Roy W. Andersen
8a81c7bbac New translations en.po (Norwegian) 2025-10-09 14:15:17 -04:00
henrygd
d24150c78b release 0.13.2 2025-10-09 14:01:45 -04:00
henrygd
013da18789 expand check for bad container memory values (#1236) 2025-10-09 14:00:24 -04:00
henrygd
5b663621e4 rename favicon to break cache 2025-10-09 13:37:09 -04:00
henrygd
4056345216 add ability to set custom name for extra filesystems (#379) 2025-10-09 13:18:10 -04:00
henrygd
d00c0488c3 improve websocket agent reconnection after network interruptions (#1263) 2025-10-09 12:09:52 -04:00
henrygd
d352ce00fa allow a bit more latency in the one minute chart points (#1247) 2025-10-07 17:28:48 -04:00
henrygd
1623f5e751 refactor: async docker version check 2025-10-07 15:33:46 -04:00
Amanda Wee
612ad1238f Retry agent's attempt to get the Docker version (#1250) 2025-10-07 14:25:02 -04:00
henrygd
1ad4409609 update favicon to show down count in bubble 2025-10-07 14:13:41 -04:00
evrial
2a94e1d1ec OpenWRT - graceful service stop, restart and respawn if crashes (#1245) 2025-10-06 11:35:44 -04:00
henrygd
75b372437c add small end buffer to chart x axis 2025-10-05 21:18:16 -04:00
henrygd
b661d00159 release 0.13.1 2025-10-05 20:09:49 -04:00
henrygd
898dbf73c8 update agent dockerfile volume 2025-10-05 20:06:17 -04:00
Marrrrrrrrry
e099304948 Add VOLUME to preserve config across container recreations (#1235) 2025-10-05 20:05:00 -04:00
Maximilian Krause
b61b7a12dc New translations en.po (German) 2025-10-05 19:40:44 -04:00
henrygd
37769050e5 fix loading system with direct id url 2025-10-05 19:38:37 -04:00
henrygd
d81e137291 update system permalinks to use id instead of name (#1231)
maintains backward compatibility with old permalinks
2025-10-05 14:18:00 -04:00
henrygd
ae820d348e fix one minute chart on systems without docker (#1237) 2025-10-05 13:19:35 -04:00
henrygd
ddb298ac7c 0.13.0 release 2025-10-03 13:53:12 -04:00
henrygd
cca7b36039 add SYSTEM_NAME env var (#1184) 2025-10-03 13:44:10 -04:00
henrygd
adda381d9d update language files 2025-10-03 13:21:02 -04:00
zoixc
1630b1558f New translations en.po (Russian) 2025-10-03 13:08:58 -04:00
itssloplayz
733c10ff31 New translations en.po (Slovenian) 2025-10-03 13:08:00 -04:00
henrygd
ed3fd185d3 update pocketbase 2025-10-03 12:44:20 -04:00
henrygd
b1fd7e6695 fix intel engine delta tracking across cache keys
- plus a couple tiny lil refactors
2025-10-02 20:24:54 -04:00
henrygd
7d6230de74 add one minute chart + refactor rpc
- add one minute charts
- update disk io to use bytes
- update hub and agent connection interfaces / handlers to be more
flexible
- change agent cache to use cache time instead of session id
- refactor collection of metrics which require deltas to track
separately per cache time
2025-10-02 17:56:51 -04:00
henrygd
f9a39c6004 add noindex meta tag to html (#1218) 2025-09-30 19:16:15 -04:00
henrygd
f21a6d15fe update agent install script to use get.beszel.dev/latest-version (#1212) 2025-09-29 13:37:51 -04:00
Timothy Pillow
bf38716095 Modify GPU usage section in readme (#1216)
Updated GPU metrics to include Intel support and removed temperature. Synced section as currently written in https://beszel.dev/guide/what-is-beszel#supported-metrics
2025-09-29 12:27:20 -04:00
henrygd
45816e7de6 agent install script: refactor mirror handling (#1212)
- add --mirror flag
- use mirror url for api.github.com
- remove prompt confirmation for mirror usage
2025-09-28 13:49:41 -04:00
evrial
2a6946906e Fixed OpenWRT agent restarter logic (#1210)
* Fixed OpenWRT restarter logic

* Update update.go
2025-09-26 12:15:42 -04:00
103 changed files with 7774 additions and 922 deletions

View File

@@ -12,33 +12,36 @@ import (
"path/filepath"
"strings"
"sync"
"time"
"github.com/gliderlabs/ssh"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/agent/deltatracker"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/host"
gossh "golang.org/x/crypto/ssh"
)
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
diskPrev map[uint16]map[string]prevDisk // Previous disk I/O counters per cache interval
netInterfaces map[string]struct{} // Stores all valid network interfaces
netIoStats map[uint16]system.NetIoStats // Keeps track of bandwidth usage per cache interval
netInterfaceDeltaTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] // Per-cache-time NIC delta trackers
dockerManager *dockerManager // Manages Docker API requests
sensorConfig *SensorConfig // Sensors config
systemInfo system.Info // Host system info
gpuManager *GPUManager // Manages GPU data
cache *systemDataCache // Cache for system stats based on cache time
connectionManager *ConnectionManager // Channel to signal connection events
handlerRegistry *HandlerRegistry // Registry for routing incoming messages
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.
@@ -46,9 +49,15 @@ type Agent struct {
func NewAgent(dataDir ...string) (agent *Agent, err error) {
agent = &Agent{
fsStats: make(map[string]*system.FsStats),
cache: NewSessionCache(69 * time.Second),
cache: NewSystemDataCache(),
}
// Initialize disk I/O previous counters storage
agent.diskPrev = make(map[uint16]map[string]prevDisk)
// Initialize per-cache-time network tracking structures
agent.netIoStats = make(map[uint16]system.NetIoStats)
agent.netInterfaceDeltaTrackers = make(map[uint16]*deltatracker.DeltaTracker[string, uint64])
agent.dataDir, err = getDataDir(dataDir...)
if err != nil {
slog.Warn("Data directory not found")
@@ -79,6 +88,9 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
// initialize connection manager
agent.connectionManager = newConnectionManager(agent)
// initialize handler registry
agent.handlerRegistry = NewHandlerRegistry()
// initialize disk info
agent.initializeDiskInfo()
@@ -97,7 +109,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
// if debugging, print stats
if agent.debug {
slog.Debug("Stats", "data", agent.gatherStats(""))
slog.Debug("Stats", "data", agent.gatherStats(0))
}
return agent, nil
@@ -112,24 +124,24 @@ func GetEnv(key string) (value string, exists bool) {
return os.LookupEnv(key)
}
func (a *Agent) gatherStats(sessionID string) *system.CombinedData {
func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
a.Lock()
defer a.Unlock()
data, isCached := a.cache.Get(sessionID)
data, isCached := a.cache.Get(cacheTimeMs)
if isCached {
slog.Debug("Cached data", "session", sessionID)
slog.Debug("Cached data", "cacheTimeMs", cacheTimeMs)
return data
}
*data = system.CombinedData{
Stats: a.getSystemStats(),
Stats: a.getSystemStats(cacheTimeMs),
Info: a.systemInfo,
}
slog.Debug("System data", "data", data)
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
if a.dockerManager != nil {
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
if containerStats, err := a.dockerManager.getDockerStats(cacheTimeMs); err == nil {
data.Containers = containerStats
slog.Debug("Containers", "data", data.Containers)
} else {
@@ -140,12 +152,17 @@ func (a *Agent) gatherStats(sessionID string) *system.CombinedData {
data.Stats.ExtraFs = make(map[string]*system.FsStats)
for name, stats := range a.fsStats {
if !stats.Root && stats.DiskTotal > 0 {
data.Stats.ExtraFs[name] = stats
// Use custom name if available, otherwise use device name
key := name
if stats.Name != "" {
key = stats.Name
}
data.Stats.ExtraFs[key] = stats
}
}
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
a.cache.Set(sessionID, data)
a.cache.Set(data, cacheTimeMs)
return data
}

View File

@@ -1,37 +1,55 @@
package agent
import (
"sync"
"time"
"github.com/henrygd/beszel/internal/entities/system"
)
// Not thread safe since we only access from gatherStats which is already locked
type SessionCache struct {
data *system.CombinedData
lastUpdate time.Time
primarySession string
leaseTime time.Duration
type systemDataCache struct {
sync.RWMutex
cache map[uint16]*cacheNode
}
func NewSessionCache(leaseTime time.Duration) *SessionCache {
return &SessionCache{
leaseTime: leaseTime,
data: &system.CombinedData{},
type cacheNode struct {
data *system.CombinedData
lastUpdate time.Time
}
// NewSystemDataCache creates a cache keyed by the polling interval in milliseconds.
func NewSystemDataCache() *systemDataCache {
return &systemDataCache{
cache: make(map[uint16]*cacheNode),
}
}
func (c *SessionCache) Get(sessionID string) (stats *system.CombinedData, isCached bool) {
if sessionID != c.primarySession && time.Since(c.lastUpdate) < c.leaseTime {
return c.data, true
// Get returns cached combined data when the entry is still considered fresh.
func (c *systemDataCache) Get(cacheTimeMs uint16) (stats *system.CombinedData, isCached bool) {
c.RLock()
defer c.RUnlock()
node, ok := c.cache[cacheTimeMs]
if !ok {
return &system.CombinedData{}, false
}
return c.data, false
// allowedSkew := time.Second
// isFresh := time.Since(node.lastUpdate) < time.Duration(cacheTimeMs)*time.Millisecond-allowedSkew
// allow a 50% skew of the cache time
isFresh := time.Since(node.lastUpdate) < time.Duration(cacheTimeMs/2)*time.Millisecond
return node.data, isFresh
}
func (c *SessionCache) Set(sessionID string, data *system.CombinedData) {
if data != nil {
*c.data = *data
// Set stores the latest combined data snapshot for the given interval.
func (c *systemDataCache) Set(data *system.CombinedData, cacheTimeMs uint16) {
c.Lock()
defer c.Unlock()
node, ok := c.cache[cacheTimeMs]
if !ok {
node = &cacheNode{}
c.cache[cacheTimeMs] = node
}
c.primarySession = sessionID
c.lastUpdate = time.Now()
node.data = data
node.lastUpdate = time.Now()
}

View File

@@ -8,82 +8,239 @@ import (
"testing/synctest"
"time"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSessionCache_GetSet(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
cache := NewSessionCache(69 * time.Second)
func createTestCacheData() *system.CombinedData {
return &system.CombinedData{
Stats: system.Stats{
Cpu: 50.5,
Mem: 8192,
DiskTotal: 100000,
},
Info: system.Info{
Hostname: "test-host",
},
Containers: []*container.Stats{
{
Name: "test-container",
Cpu: 25.0,
},
},
}
}
testData := &system.CombinedData{
Info: system.Info{
Hostname: "test-host",
Cores: 4,
},
func TestNewSystemDataCache(t *testing.T) {
cache := NewSystemDataCache()
require.NotNil(t, cache)
assert.NotNil(t, cache.cache)
assert.Empty(t, cache.cache)
}
func TestCacheGetSet(t *testing.T) {
cache := NewSystemDataCache()
data := createTestCacheData()
// Test setting data
cache.Set(data, 1000) // 1 second cache
// Test getting fresh data
retrieved, isCached := cache.Get(1000)
assert.True(t, isCached)
assert.Equal(t, data, retrieved)
// Test getting non-existent cache key
_, isCached = cache.Get(2000)
assert.False(t, isCached)
}
func TestCacheFreshness(t *testing.T) {
cache := NewSystemDataCache()
data := createTestCacheData()
testCases := []struct {
name string
cacheTimeMs uint16
sleepMs time.Duration
expectFresh bool
}{
{
name: "fresh data - well within cache time",
cacheTimeMs: 1000, // 1 second
sleepMs: 100, // 100ms
expectFresh: true,
},
{
name: "fresh data - at 50% of cache time boundary",
cacheTimeMs: 1000, // 1 second, 50% = 500ms
sleepMs: 499, // just under 500ms
expectFresh: true,
},
{
name: "stale data - exactly at 50% cache time",
cacheTimeMs: 1000, // 1 second, 50% = 500ms
sleepMs: 500, // exactly 500ms
expectFresh: false,
},
{
name: "stale data - well beyond cache time",
cacheTimeMs: 1000, // 1 second
sleepMs: 800, // 800ms
expectFresh: false,
},
{
name: "short cache time",
cacheTimeMs: 200, // 200ms, 50% = 100ms
sleepMs: 150, // 150ms > 100ms
expectFresh: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// Set data
cache.Set(data, tc.cacheTimeMs)
// Wait for the specified duration
if tc.sleepMs > 0 {
time.Sleep(tc.sleepMs * time.Millisecond)
}
// Check freshness
_, isCached := cache.Get(tc.cacheTimeMs)
assert.Equal(t, tc.expectFresh, isCached)
})
})
}
}
func TestCacheMultipleIntervals(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
cache := NewSystemDataCache()
data1 := createTestCacheData()
data2 := &system.CombinedData{
Stats: system.Stats{
Cpu: 50.0,
MemPct: 30.0,
DiskPct: 40.0,
Cpu: 75.0,
Mem: 16384,
},
Info: system.Info{
Hostname: "test-host-2",
},
Containers: []*container.Stats{},
}
// Test initial state - should not be cached
data, isCached := cache.Get("session1")
assert.False(t, isCached, "Expected no cached data initially")
assert.NotNil(t, data, "Expected data to be initialized")
// Set data for session1
cache.Set("session1", testData)
// Set data for different intervals
cache.Set(data1, 500) // 500ms cache
cache.Set(data2, 1000) // 1000ms cache
time.Sleep(15 * time.Second)
// Both should be fresh immediately
retrieved1, isCached1 := cache.Get(500)
assert.True(t, isCached1)
assert.Equal(t, data1, retrieved1)
// Get data for a different session - should be cached
data, isCached = cache.Get("session2")
assert.True(t, isCached, "Expected data to be cached for non-primary session")
require.NotNil(t, data, "Expected cached data to be returned")
assert.Equal(t, "test-host", data.Info.Hostname, "Hostname should match test data")
assert.Equal(t, 4, data.Info.Cores, "Cores should match test data")
assert.Equal(t, 50.0, data.Stats.Cpu, "CPU should match test data")
assert.Equal(t, 30.0, data.Stats.MemPct, "Memory percentage should match test data")
assert.Equal(t, 40.0, data.Stats.DiskPct, "Disk percentage should match test data")
retrieved2, isCached2 := cache.Get(1000)
assert.True(t, isCached2)
assert.Equal(t, data2, retrieved2)
time.Sleep(10 * time.Second)
// Wait 300ms - 500ms cache should be stale (250ms threshold), 1000ms should still be fresh (500ms threshold)
time.Sleep(300 * time.Millisecond)
// Get data for the primary session - should not be cached
data, isCached = cache.Get("session1")
assert.False(t, isCached, "Expected data not to be cached for primary session")
require.NotNil(t, data, "Expected data to be returned even if not cached")
assert.Equal(t, "test-host", data.Info.Hostname, "Hostname should match test data")
// if not cached, agent will update the data
cache.Set("session1", testData)
_, isCached1 = cache.Get(500)
assert.False(t, isCached1)
time.Sleep(45 * time.Second)
_, isCached2 = cache.Get(1000)
assert.True(t, isCached2)
// Get data for a different session - should still be cached
_, isCached = cache.Get("session2")
assert.True(t, isCached, "Expected data to be cached for non-primary session")
// Wait for the lease to expire
time.Sleep(30 * time.Second)
// Get data for session2 - should not be cached
_, isCached = cache.Get("session2")
assert.False(t, isCached, "Expected data not to be cached after lease expiration")
// Wait another 300ms (total 600ms) - now 1000ms cache should also be stale
time.Sleep(300 * time.Millisecond)
_, isCached2 = cache.Get(1000)
assert.False(t, isCached2)
})
}
func TestSessionCache_NilData(t *testing.T) {
// Create a new SessionCache
cache := NewSessionCache(30 * time.Second)
func TestCacheOverwrite(t *testing.T) {
cache := NewSystemDataCache()
data1 := createTestCacheData()
data2 := &system.CombinedData{
Stats: system.Stats{
Cpu: 90.0,
Mem: 32768,
},
Info: system.Info{
Hostname: "updated-host",
},
Containers: []*container.Stats{},
}
// Test setting nil data (should not panic)
assert.NotPanics(t, func() {
cache.Set("session1", nil)
}, "Setting nil data should not panic")
// Set initial data
cache.Set(data1, 1000)
retrieved, isCached := cache.Get(1000)
assert.True(t, isCached)
assert.Equal(t, data1, retrieved)
// Get data - should not be nil even though we set nil
data, _ := cache.Get("session2")
assert.NotNil(t, data, "Expected data to not be nil after setting nil data")
// Overwrite with new data
cache.Set(data2, 1000)
retrieved, isCached = cache.Get(1000)
assert.True(t, isCached)
assert.Equal(t, data2, retrieved)
assert.NotEqual(t, data1, retrieved)
}
func TestCacheMiss(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
cache := NewSystemDataCache()
// Test getting from empty cache
_, isCached := cache.Get(1000)
assert.False(t, isCached)
// Set data for one interval
data := createTestCacheData()
cache.Set(data, 1000)
// Test getting different interval
_, isCached = cache.Get(2000)
assert.False(t, isCached)
// Test getting after data has expired
time.Sleep(600 * time.Millisecond) // 600ms > 500ms (50% of 1000ms)
_, isCached = cache.Get(1000)
assert.False(t, isCached)
})
}
func TestCacheZeroInterval(t *testing.T) {
cache := NewSystemDataCache()
data := createTestCacheData()
// Set with zero interval - should allow immediate cache
cache.Set(data, 0)
// With 0 interval, 50% is 0, so it should never be considered fresh
// (time.Since(lastUpdate) >= 0, which is not < 0)
_, isCached := cache.Get(0)
assert.False(t, isCached)
}
func TestCacheLargeInterval(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
cache := NewSystemDataCache()
data := createTestCacheData()
// Test with maximum uint16 value
cache.Set(data, 65535) // ~65 seconds
// Should be fresh immediately
_, isCached := cache.Get(65535)
assert.True(t, isCached)
// Should still be fresh after a short time
time.Sleep(100 * time.Millisecond)
_, isCached = cache.Get(65535)
assert.True(t, isCached)
})
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/fxamacker/cbor/v2"
"github.com/lxzan/gws"
@@ -142,7 +143,9 @@ func (client *WebSocketClient) OnOpen(conn *gws.Conn) {
// OnClose handles WebSocket connection closure.
// It logs the closure reason and notifies the connection manager.
func (client *WebSocketClient) OnClose(conn *gws.Conn, err error) {
slog.Warn("Connection closed", "err", strings.TrimPrefix(err.Error(), "gws: "))
if err != nil {
slog.Warn("Connection closed", "err", strings.TrimPrefix(err.Error(), "gws: "))
}
client.agent.connectionManager.eventChan <- WebSocketDisconnect
}
@@ -156,11 +159,15 @@ func (client *WebSocketClient) OnMessage(conn *gws.Conn, message *gws.Message) {
return
}
if err := cbor.NewDecoder(message.Data).Decode(client.hubRequest); err != nil {
var HubRequest common.HubRequest[cbor.RawMessage]
err := cbor.Unmarshal(message.Data.Bytes(), &HubRequest)
if err != nil {
slog.Error("Error parsing message", "err", err)
return
}
if err := client.handleHubRequest(client.hubRequest); err != nil {
if err := client.handleHubRequest(&HubRequest, HubRequest.Id); err != nil {
slog.Error("Error handling message", "err", err)
}
}
@@ -173,7 +180,7 @@ func (client *WebSocketClient) OnPing(conn *gws.Conn, message []byte) {
}
// handleAuthChallenge verifies the authenticity of the hub and returns the system's fingerprint.
func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.RawMessage]) (err error) {
func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.RawMessage], requestID *uint32) (err error) {
var authRequest common.FingerprintRequest
if err := cbor.Unmarshal(msg.Data, &authRequest); err != nil {
return err
@@ -191,12 +198,13 @@ func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.R
}
if authRequest.NeedSysInfo {
response.Name, _ = GetEnv("SYSTEM_NAME")
response.Hostname = client.agent.systemInfo.Hostname
serverAddr := client.agent.connectionManager.serverOptions.Addr
_, response.Port, _ = net.SplitHostPort(serverAddr)
}
return client.sendMessage(response)
return client.sendResponse(response, requestID)
}
// verifySignature verifies the signature of the token using the public keys.
@@ -221,25 +229,17 @@ func (client *WebSocketClient) Close() {
}
}
// handleHubRequest routes the request to the appropriate handler.
// It ensures the hub is verified before processing most requests.
func (client *WebSocketClient) handleHubRequest(msg *common.HubRequest[cbor.RawMessage]) error {
if !client.hubVerified && msg.Action != common.CheckFingerprint {
return errors.New("hub not verified")
// handleHubRequest routes the request to the appropriate handler using the handler registry.
func (client *WebSocketClient) handleHubRequest(msg *common.HubRequest[cbor.RawMessage], requestID *uint32) error {
ctx := &HandlerContext{
Client: client,
Agent: client.agent,
Request: msg,
RequestID: requestID,
HubVerified: client.hubVerified,
SendResponse: client.sendResponse,
}
switch msg.Action {
case common.GetData:
return client.sendSystemData()
case common.CheckFingerprint:
return client.handleAuthChallenge(msg)
}
return nil
}
// sendSystemData gathers and sends current system statistics to the hub.
func (client *WebSocketClient) sendSystemData() error {
sysStats := client.agent.gatherStats(client.token)
return client.sendMessage(sysStats)
return client.agent.handlerRegistry.Handle(ctx)
}
// sendMessage encodes the given data to CBOR and sends it as a binary message over the WebSocket connection to the hub.
@@ -248,7 +248,43 @@ func (client *WebSocketClient) sendMessage(data any) error {
if err != nil {
return err
}
return client.Conn.WriteMessage(gws.OpcodeBinary, bytes)
err = client.Conn.WriteMessage(gws.OpcodeBinary, bytes)
if err != nil {
// If writing fails (e.g., broken pipe due to network issues),
// close the connection to trigger reconnection logic (#1263)
client.Close()
}
return err
}
// sendResponse sends a response with optional request ID for the new protocol
func (client *WebSocketClient) sendResponse(data any, requestID *uint32) error {
if requestID != nil {
// New format with ID - use typed fields
response := common.AgentResponse{
Id: requestID,
}
// Set the appropriate typed field based on data type
switch v := data.(type) {
case *system.CombinedData:
response.SystemData = v
case *common.FingerprintResponse:
response.Fingerprint = v
// case []byte:
// response.RawBytes = v
// case string:
// response.RawBytes = []byte(v)
default:
// For any other type, convert to error
response.Error = fmt.Sprintf("unsupported response type: %T", data)
}
return client.sendMessage(response)
} else {
// Legacy format - send data directly
return client.sendMessage(data)
}
}
// getUserAgent returns one of two User-Agent strings based on current time.

View File

@@ -301,7 +301,7 @@ func TestWebSocketClient_HandleHubRequest(t *testing.T) {
Data: cbor.RawMessage{},
}
err := client.handleHubRequest(hubRequest)
err := client.handleHubRequest(hubRequest, nil)
if tc.expectError {
assert.Error(t, err)

66
agent/cpu.go Normal file
View File

@@ -0,0 +1,66 @@
package agent
import (
"math"
"runtime"
"github.com/shirou/gopsutil/v4/cpu"
)
var lastCpuTimes = make(map[uint16]cpu.TimesStat)
// init initializes the CPU monitoring by storing the initial CPU times
// for the default 60-second cache interval.
func init() {
if times, err := cpu.Times(false); err == nil {
lastCpuTimes[60000] = times[0]
}
}
// getCpuPercent calculates the CPU usage percentage using cached previous measurements.
// It uses the specified cache time interval to determine the time window for calculation.
// Returns the CPU usage percentage (0-100) and any error encountered.
func getCpuPercent(cacheTimeMs uint16) (float64, error) {
times, err := cpu.Times(false)
if err != nil || len(times) == 0 {
return 0, err
}
// if cacheTimeMs is not in lastCpuTimes, use 60000 as fallback lastCpuTime
if _, ok := lastCpuTimes[cacheTimeMs]; !ok {
lastCpuTimes[cacheTimeMs] = lastCpuTimes[60000]
}
delta := calculateBusy(lastCpuTimes[cacheTimeMs], times[0])
lastCpuTimes[cacheTimeMs] = times[0]
return delta, nil
}
// calculateBusy calculates the CPU busy percentage between two time points.
// It computes the ratio of busy time to total time elapsed between t1 and t2,
// returning a percentage clamped between 0 and 100.
func calculateBusy(t1, t2 cpu.TimesStat) float64 {
t1All, t1Busy := getAllBusy(t1)
t2All, t2Busy := getAllBusy(t2)
if t2Busy <= t1Busy {
return 0
}
if t2All <= t1All {
return 100
}
return math.Min(100, math.Max(0, (t2Busy-t1Busy)/(t2All-t1All)*100))
}
// getAllBusy calculates the total CPU time and busy CPU time from CPU times statistics.
// On Linux, it excludes guest and guest_nice time from the total to match kernel behavior.
// Returns total CPU time and busy CPU time (total minus idle and I/O wait time).
func getAllBusy(t cpu.TimesStat) (float64, float64) {
tot := t.Total()
if runtime.GOOS == "linux" {
tot -= t.Guest // Linux 2.6.24+
tot -= t.GuestNice // Linux 3.2.0+
}
busy := tot - t.Idle - t.Iowait
return tot, busy
}

View File

@@ -37,6 +37,16 @@ func (t *DeltaTracker[K, V]) Set(id K, value V) {
t.current[id] = value
}
// Snapshot returns a copy of the current map.
// func (t *DeltaTracker[K, V]) Snapshot() map[K]V {
// t.RLock()
// defer t.RUnlock()
// copyMap := make(map[K]V, len(t.current))
// maps.Copy(copyMap, t.current)
// return copyMap
// }
// Deltas returns a map of all calculated deltas for the current interval.
func (t *DeltaTracker[K, V]) Deltas() map[K]V {
t.RLock()
@@ -53,6 +63,15 @@ func (t *DeltaTracker[K, V]) Deltas() map[K]V {
return deltas
}
// Previous returns the previously recorded value for the given key, if it exists.
func (t *DeltaTracker[K, V]) Previous(id K) (V, bool) {
t.RLock()
defer t.RUnlock()
value, ok := t.previous[id]
return value, ok
}
// Delta returns the delta for a single key.
// Returns 0 if the key doesn't exist or has no previous value.
func (t *DeltaTracker[K, V]) Delta(id K) V {

View File

@@ -13,6 +13,19 @@ import (
"github.com/shirou/gopsutil/v4/disk"
)
// parseFilesystemEntry parses a filesystem entry in the format "device__customname"
// Returns the device/filesystem part and the custom name part
func parseFilesystemEntry(entry string) (device, customName string) {
entry = strings.TrimSpace(entry)
if parts := strings.SplitN(entry, "__", 2); len(parts) == 2 {
device = strings.TrimSpace(parts[0])
customName = strings.TrimSpace(parts[1])
} else {
device = entry
}
return device, customName
}
// Sets up the filesystems to monitor for disk usage and I/O.
func (a *Agent) initializeDiskInfo() {
filesystem, _ := GetEnv("FILESYSTEM")
@@ -37,7 +50,7 @@ func (a *Agent) initializeDiskInfo() {
slog.Debug("Disk I/O", "diskstats", diskIoCounters)
// Helper function to add a filesystem to fsStats if it doesn't exist
addFsStat := func(device, mountpoint string, root bool) {
addFsStat := func(device, mountpoint string, root bool, customName ...string) {
var key string
if runtime.GOOS == "windows" {
key = device
@@ -66,7 +79,11 @@ func (a *Agent) initializeDiskInfo() {
}
}
}
a.fsStats[key] = &system.FsStats{Root: root, Mountpoint: mountpoint}
fsStats := &system.FsStats{Root: root, Mountpoint: mountpoint}
if len(customName) > 0 && customName[0] != "" {
fsStats.Name = customName[0]
}
a.fsStats[key] = fsStats
}
}
@@ -86,11 +103,14 @@ func (a *Agent) initializeDiskInfo() {
// Add EXTRA_FILESYSTEMS env var values to fsStats
if extraFilesystems, exists := GetEnv("EXTRA_FILESYSTEMS"); exists {
for _, fs := range strings.Split(extraFilesystems, ",") {
for _, fsEntry := range strings.Split(extraFilesystems, ",") {
// Parse custom name from format: device__customname
fs, customName := parseFilesystemEntry(fsEntry)
found := false
for _, p := range partitions {
if strings.HasSuffix(p.Device, fs) || p.Mountpoint == fs {
addFsStat(p.Device, p.Mountpoint, false)
addFsStat(p.Device, p.Mountpoint, false, customName)
found = true
break
}
@@ -98,7 +118,7 @@ func (a *Agent) initializeDiskInfo() {
// if not in partitions, test if we can get disk usage
if !found {
if _, err := disk.Usage(fs); err == nil {
addFsStat(filepath.Base(fs), fs, false)
addFsStat(filepath.Base(fs), fs, false, customName)
} else {
slog.Error("Invalid filesystem", "name", fs, "err", err)
}
@@ -120,7 +140,8 @@ func (a *Agent) initializeDiskInfo() {
// Check if device is in /extra-filesystems
if strings.HasPrefix(p.Mountpoint, efPath) {
addFsStat(p.Device, p.Mountpoint, false)
device, customName := parseFilesystemEntry(p.Mountpoint)
addFsStat(device, p.Mountpoint, false, customName)
}
}
@@ -135,7 +156,8 @@ func (a *Agent) initializeDiskInfo() {
mountpoint := filepath.Join(efPath, folder.Name())
slog.Debug("/extra-filesystems", "mountpoint", mountpoint)
if !existingMountpoints[mountpoint] {
addFsStat(folder.Name(), mountpoint, false)
device, customName := parseFilesystemEntry(folder.Name())
addFsStat(device, mountpoint, false, customName)
}
}
}
@@ -189,3 +211,96 @@ func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersS
a.fsNames = append(a.fsNames, device)
}
}
// Updates disk usage statistics for all monitored filesystems
func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
// disk usage
for _, stats := range a.fsStats {
if d, err := disk.Usage(stats.Mountpoint); err == nil {
stats.DiskTotal = bytesToGigabytes(d.Total)
stats.DiskUsed = bytesToGigabytes(d.Used)
if stats.Root {
systemStats.DiskTotal = bytesToGigabytes(d.Total)
systemStats.DiskUsed = bytesToGigabytes(d.Used)
systemStats.DiskPct = twoDecimals(d.UsedPercent)
}
} else {
// reset stats if error (likely unmounted)
slog.Error("Error getting disk stats", "name", stats.Mountpoint, "err", err)
stats.DiskTotal = 0
stats.DiskUsed = 0
stats.TotalRead = 0
stats.TotalWrite = 0
}
}
}
// Updates disk I/O statistics for all monitored filesystems
func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
// disk i/o (cache-aware per interval)
if ioCounters, err := disk.IOCounters(a.fsNames...); err == nil {
// Ensure map for this interval exists
if _, ok := a.diskPrev[cacheTimeMs]; !ok {
a.diskPrev[cacheTimeMs] = make(map[string]prevDisk)
}
now := time.Now()
for name, d := range ioCounters {
stats := a.fsStats[d.Name]
if stats == nil {
// skip devices not tracked
continue
}
// Previous snapshot for this interval and device
prev, hasPrev := a.diskPrev[cacheTimeMs][name]
if !hasPrev {
// Seed from agent-level fsStats if present, else seed from current
prev = prevDisk{readBytes: stats.TotalRead, writeBytes: stats.TotalWrite, at: stats.Time}
if prev.at.IsZero() {
prev = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
}
}
msElapsed := uint64(now.Sub(prev.at).Milliseconds())
if msElapsed < 100 {
// Avoid division by zero or clock issues; update snapshot and continue
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
continue
}
diskIORead := (d.ReadBytes - prev.readBytes) * 1000 / msElapsed
diskIOWrite := (d.WriteBytes - prev.writeBytes) * 1000 / msElapsed
readMbPerSecond := bytesToMegabytes(float64(diskIORead))
writeMbPerSecond := bytesToMegabytes(float64(diskIOWrite))
// validate values
if readMbPerSecond > 50_000 || writeMbPerSecond > 50_000 {
slog.Warn("Invalid disk I/O. Resetting.", "name", d.Name, "read", readMbPerSecond, "write", writeMbPerSecond)
// Reset interval snapshot and seed from current
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
// also refresh agent baseline to avoid future negatives
a.initializeDiskIoStats(ioCounters)
continue
}
// Update per-interval snapshot
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
// Update global fsStats baseline for cross-interval correctness
stats.Time = now
stats.TotalRead = d.ReadBytes
stats.TotalWrite = d.WriteBytes
stats.DiskReadPs = readMbPerSecond
stats.DiskWritePs = writeMbPerSecond
stats.DiskReadBytes = diskIORead
stats.DiskWriteBytes = diskIOWrite
if stats.Root {
systemStats.DiskReadPs = stats.DiskReadPs
systemStats.DiskWritePs = stats.DiskWritePs
systemStats.DiskIO[0] = diskIORead
systemStats.DiskIO[1] = diskIOWrite
}
}
}
}

235
agent/disk_test.go Normal file
View File

@@ -0,0 +1,235 @@
//go:build testing
// +build testing
package agent
import (
"os"
"strings"
"testing"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/disk"
"github.com/stretchr/testify/assert"
)
func TestParseFilesystemEntry(t *testing.T) {
tests := []struct {
name string
input string
expectedFs string
expectedName string
}{
{
name: "simple device name",
input: "sda1",
expectedFs: "sda1",
expectedName: "",
},
{
name: "device with custom name",
input: "sda1__my-storage",
expectedFs: "sda1",
expectedName: "my-storage",
},
{
name: "full device path with custom name",
input: "/dev/sdb1__backup-drive",
expectedFs: "/dev/sdb1",
expectedName: "backup-drive",
},
{
name: "NVMe device with custom name",
input: "nvme0n1p2__fast-ssd",
expectedFs: "nvme0n1p2",
expectedName: "fast-ssd",
},
{
name: "whitespace trimmed",
input: " sda2__trimmed-name ",
expectedFs: "sda2",
expectedName: "trimmed-name",
},
{
name: "empty custom name",
input: "sda3__",
expectedFs: "sda3",
expectedName: "",
},
{
name: "empty device name",
input: "__just-custom",
expectedFs: "",
expectedName: "just-custom",
},
{
name: "multiple underscores in custom name",
input: "sda1__my_custom_drive",
expectedFs: "sda1",
expectedName: "my_custom_drive",
},
{
name: "custom name with spaces",
input: "sda1__My Storage Drive",
expectedFs: "sda1",
expectedName: "My Storage Drive",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fsEntry := strings.TrimSpace(tt.input)
var fs, customName string
if parts := strings.SplitN(fsEntry, "__", 2); len(parts) == 2 {
fs = strings.TrimSpace(parts[0])
customName = strings.TrimSpace(parts[1])
} else {
fs = fsEntry
}
assert.Equal(t, tt.expectedFs, fs)
assert.Equal(t, tt.expectedName, customName)
})
}
}
func TestInitializeDiskInfoWithCustomNames(t *testing.T) {
// Set up environment variables
oldEnv := os.Getenv("EXTRA_FILESYSTEMS")
defer func() {
if oldEnv != "" {
os.Setenv("EXTRA_FILESYSTEMS", oldEnv)
} else {
os.Unsetenv("EXTRA_FILESYSTEMS")
}
}()
// Test with custom names
os.Setenv("EXTRA_FILESYSTEMS", "sda1__my-storage,/dev/sdb1__backup-drive,nvme0n1p2")
// Mock disk partitions (we'll just test the parsing logic)
// Since the actual disk operations are system-dependent, we'll focus on the parsing
testCases := []struct {
envValue string
expectedFs []string
expectedNames map[string]string
}{
{
envValue: "sda1__my-storage,sdb1__backup-drive",
expectedFs: []string{"sda1", "sdb1"},
expectedNames: map[string]string{
"sda1": "my-storage",
"sdb1": "backup-drive",
},
},
{
envValue: "sda1,nvme0n1p2__fast-ssd",
expectedFs: []string{"sda1", "nvme0n1p2"},
expectedNames: map[string]string{
"nvme0n1p2": "fast-ssd",
},
},
}
for _, tc := range testCases {
t.Run("env_"+tc.envValue, func(t *testing.T) {
os.Setenv("EXTRA_FILESYSTEMS", tc.envValue)
// Create mock partitions that would match our test cases
partitions := []disk.PartitionStat{}
for _, fs := range tc.expectedFs {
if strings.HasPrefix(fs, "/dev/") {
partitions = append(partitions, disk.PartitionStat{
Device: fs,
Mountpoint: fs,
})
} else {
partitions = append(partitions, disk.PartitionStat{
Device: "/dev/" + fs,
Mountpoint: "/" + fs,
})
}
}
// Test the parsing logic by calling the relevant part
// We'll create a simplified version to test just the parsing
extraFilesystems := tc.envValue
for _, fsEntry := range strings.Split(extraFilesystems, ",") {
// Parse the entry
fsEntry = strings.TrimSpace(fsEntry)
var fs, customName string
if parts := strings.SplitN(fsEntry, "__", 2); len(parts) == 2 {
fs = strings.TrimSpace(parts[0])
customName = strings.TrimSpace(parts[1])
} else {
fs = fsEntry
}
// Verify the device is in our expected list
assert.Contains(t, tc.expectedFs, fs, "parsed device should be in expected list")
// Check if custom name should exist
if expectedName, exists := tc.expectedNames[fs]; exists {
assert.Equal(t, expectedName, customName, "custom name should match expected")
} else {
assert.Empty(t, customName, "custom name should be empty when not expected")
}
}
})
}
}
func TestFsStatsWithCustomNames(t *testing.T) {
// Test that FsStats properly stores custom names
fsStats := &system.FsStats{
Mountpoint: "/mnt/storage",
Name: "my-custom-storage",
DiskTotal: 100.0,
DiskUsed: 50.0,
}
assert.Equal(t, "my-custom-storage", fsStats.Name)
assert.Equal(t, "/mnt/storage", fsStats.Mountpoint)
assert.Equal(t, 100.0, fsStats.DiskTotal)
assert.Equal(t, 50.0, fsStats.DiskUsed)
}
func TestExtraFsKeyGeneration(t *testing.T) {
// Test the logic for generating ExtraFs keys with custom names
testCases := []struct {
name string
deviceName string
customName string
expectedKey string
}{
{
name: "with custom name",
deviceName: "sda1",
customName: "my-storage",
expectedKey: "my-storage",
},
{
name: "without custom name",
deviceName: "sda1",
customName: "",
expectedKey: "sda1",
},
{
name: "empty custom name falls back to device",
deviceName: "nvme0n1p2",
customName: "",
expectedKey: "nvme0n1p2",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Simulate the key generation logic from agent.go
key := tc.deviceName
if tc.customName != "" {
key = tc.customName
}
assert.Equal(t, tc.expectedKey, key)
})
}
}

View File

@@ -14,17 +14,37 @@ import (
"sync"
"time"
"github.com/henrygd/beszel/agent/deltatracker"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/blang/semver"
)
const (
// Docker API timeout in milliseconds
dockerTimeoutMs = 2100
// Maximum realistic network speed (5 GB/s) to detect bad deltas
maxNetworkSpeedBps uint64 = 5e9
// Container and health constants
composeProjectLabel = "com.docker.compose.project"
healthStatusNone = "none"
containerStateRunning = "running"
containerStateUnknown = "unknown"
volumeTypeVolume = "volume"
diskOpRead = "read"
diskOpReadCap = "Read"
diskOpWrite = "write"
diskOpWriteCap = "Write"
// Maximum conceivable memory usage of a container (100TB) to detect bad memory stats
maxMemoryUsage uint64 = 100 * 1024 * 1024 * 1024 * 1024
)
type dockerManager struct {
client *http.Client // Client to query Docker API
wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish
sem chan struct{} // Semaphore to limit concurrent container requests
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap
apiContainerList []*container.ApiInfo // List of containers from Docker API (no pointer)
apiContainerList []*container.ApiInfo // List of containers from Docker API
containerStatsMap map[string]*container.Stats // Keeps track of container stats
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
@@ -32,6 +52,24 @@ type dockerManager struct {
buf *bytes.Buffer // Buffer to store and read response bodies
decoder *json.Decoder // Reusable JSON decoder that reads from buf
apiStats *container.ApiStats // Reusable API stats object
volumeSizeCache map[string]float64 // Cached volume sizes (name -> size in MB)
volumeSizeUpdated time.Time // Last time volume sizes were updated
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
// Maps cache time intervals to container-specific CPU usage tracking
lastCpuContainer map[uint16]map[string]uint64 // cacheTimeMs -> containerId -> last cpu container usage
lastCpuSystem map[uint16]map[string]uint64 // cacheTimeMs -> containerId -> last cpu system usage
lastCpuReadTime map[uint16]map[string]time.Time // cacheTimeMs -> containerId -> last read time (Windows)
// Network delta trackers - one per cache time to avoid interference
// cacheTimeMs -> DeltaTracker for network bytes sent/received
networkSentTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
networkRecvTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
// Disk I/O delta trackers - one per cache time to avoid interference
// cacheTimeMs -> DeltaTracker for disk bytes read/written
diskReadTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
diskWriteTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
}
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
@@ -62,8 +100,8 @@ func (d *dockerManager) dequeue() {
}
}
// Returns stats for all running containers
func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
// Returns stats for all running containers with cache-time-aware delta tracking
func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats, error) {
resp, err := dm.client.Get("http://localhost/containers/json")
if err != nil {
return nil, err
@@ -87,8 +125,7 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
var failedContainers []*container.ApiInfo
for i := range dm.apiContainerList {
ctr := dm.apiContainerList[i]
for _, ctr := range dm.apiContainerList {
ctr.IdShort = ctr.Id[:12]
dm.validIds[ctr.IdShort] = struct{}{}
// check if container is less than 1 minute old (possible restart)
@@ -98,9 +135,9 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
dm.deleteContainerStatsSync(ctr.IdShort)
}
dm.queue()
go func() {
go func(ctr *container.ApiInfo) {
defer dm.dequeue()
err := dm.updateContainerStats(ctr)
err := dm.updateContainerStats(ctr, cacheTimeMs)
// if error, delete from map and add to failed list to retry
if err != nil {
dm.containerStatsMutex.Lock()
@@ -108,7 +145,7 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
failedContainers = append(failedContainers, ctr)
dm.containerStatsMutex.Unlock()
}
}()
}(ctr)
}
dm.wg.Wait()
@@ -119,13 +156,12 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
for i := range failedContainers {
ctr := failedContainers[i]
dm.queue()
go func() {
go func(ctr *container.ApiInfo) {
defer dm.dequeue()
err = dm.updateContainerStats(ctr)
if err != nil {
slog.Error("Error getting container stats", "err", err)
if err2 := dm.updateContainerStats(ctr, cacheTimeMs); err2 != nil {
slog.Error("Error getting container stats", "err", err2)
}
}()
}(ctr)
}
dm.wg.Wait()
}
@@ -140,18 +176,229 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
}
}
// prepare network and disk trackers for next interval for this cache time
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
dm.cycleDiskDeltasForCacheTime(cacheTimeMs)
return stats, nil
}
// Updates stats for individual container
func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error {
// initializeCpuTracking initializes CPU tracking maps for a specific cache time interval
func (dm *dockerManager) initializeCpuTracking(cacheTimeMs uint16) {
// Initialize cache time maps if they don't exist
if dm.lastCpuContainer[cacheTimeMs] == nil {
dm.lastCpuContainer[cacheTimeMs] = make(map[string]uint64)
}
if dm.lastCpuSystem[cacheTimeMs] == nil {
dm.lastCpuSystem[cacheTimeMs] = make(map[string]uint64)
}
// Ensure the outer map exists before indexing
if dm.lastCpuReadTime == nil {
dm.lastCpuReadTime = make(map[uint16]map[string]time.Time)
}
if dm.lastCpuReadTime[cacheTimeMs] == nil {
dm.lastCpuReadTime[cacheTimeMs] = make(map[string]time.Time)
}
}
// getCpuPreviousValues returns previous CPU values for a container and cache time interval
func (dm *dockerManager) getCpuPreviousValues(cacheTimeMs uint16, containerId string) (uint64, uint64) {
return dm.lastCpuContainer[cacheTimeMs][containerId], dm.lastCpuSystem[cacheTimeMs][containerId]
}
// setCpuCurrentValues stores current CPU values for a container and cache time interval
func (dm *dockerManager) setCpuCurrentValues(cacheTimeMs uint16, containerId string, cpuContainer, cpuSystem uint64) {
dm.lastCpuContainer[cacheTimeMs][containerId] = cpuContainer
dm.lastCpuSystem[cacheTimeMs][containerId] = cpuSystem
}
// calculateMemoryUsage calculates memory usage from Docker API stats
func calculateMemoryUsage(apiStats *container.ApiStats, isWindows bool) (uint64, error) {
if isWindows {
return apiStats.MemoryStats.PrivateWorkingSet, nil
}
memCache := apiStats.MemoryStats.Stats.InactiveFile
if memCache == 0 {
memCache = apiStats.MemoryStats.Stats.Cache
}
usedDelta := apiStats.MemoryStats.Usage - memCache
if usedDelta <= 0 || usedDelta > maxMemoryUsage {
return 0, fmt.Errorf("bad memory stats")
}
return usedDelta, nil
}
// getNetworkTracker returns the DeltaTracker for a specific cache time, creating it if needed
func (dm *dockerManager) getNetworkTracker(cacheTimeMs uint16, isSent bool) *deltatracker.DeltaTracker[string, uint64] {
var trackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
if isSent {
trackers = dm.networkSentTrackers
} else {
trackers = dm.networkRecvTrackers
}
if trackers[cacheTimeMs] == nil {
trackers[cacheTimeMs] = deltatracker.NewDeltaTracker[string, uint64]()
}
return trackers[cacheTimeMs]
}
// cycleNetworkDeltasForCacheTime cycles the network delta trackers for a specific cache time
func (dm *dockerManager) cycleNetworkDeltasForCacheTime(cacheTimeMs uint16) {
if dm.networkSentTrackers[cacheTimeMs] != nil {
dm.networkSentTrackers[cacheTimeMs].Cycle()
}
if dm.networkRecvTrackers[cacheTimeMs] != nil {
dm.networkRecvTrackers[cacheTimeMs].Cycle()
}
}
// getDiskTracker returns the DeltaTracker for disk I/O for a specific cache time, creating it if needed
func (dm *dockerManager) getDiskTracker(cacheTimeMs uint16, isRead bool) *deltatracker.DeltaTracker[string, uint64] {
var trackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
if isRead {
trackers = dm.diskReadTrackers
} else {
trackers = dm.diskWriteTrackers
}
if trackers[cacheTimeMs] == nil {
trackers[cacheTimeMs] = deltatracker.NewDeltaTracker[string, uint64]()
}
return trackers[cacheTimeMs]
}
// cycleDiskDeltasForCacheTime cycles the disk delta trackers for a specific cache time
func (dm *dockerManager) cycleDiskDeltasForCacheTime(cacheTimeMs uint16) {
if dm.diskReadTrackers[cacheTimeMs] != nil {
dm.diskReadTrackers[cacheTimeMs].Cycle()
}
if dm.diskWriteTrackers[cacheTimeMs] != nil {
dm.diskWriteTrackers[cacheTimeMs].Cycle()
}
}
// calculateNetworkStats calculates network sent/receive deltas using DeltaTracker
func (dm *dockerManager) calculateNetworkStats(ctr *container.ApiInfo, apiStats *container.ApiStats, stats *container.Stats, initialized bool, name string, cacheTimeMs uint16) (uint64, uint64) {
var total_sent, total_recv uint64
for _, v := range apiStats.Networks {
total_sent += v.TxBytes
total_recv += v.RxBytes
}
// Get the DeltaTracker for this specific cache time
sentTracker := dm.getNetworkTracker(cacheTimeMs, true)
recvTracker := dm.getNetworkTracker(cacheTimeMs, false)
// Set current values in the cache-time-specific DeltaTracker
sentTracker.Set(ctr.IdShort, total_sent)
recvTracker.Set(ctr.IdShort, total_recv)
// Get deltas (bytes since last measurement)
sent_delta_raw := sentTracker.Delta(ctr.IdShort)
recv_delta_raw := recvTracker.Delta(ctr.IdShort)
// Calculate bytes per second independently for Tx and Rx if we have previous data
var sent_delta, recv_delta uint64
if initialized {
millisecondsElapsed := uint64(time.Since(stats.PrevReadTime).Milliseconds())
if millisecondsElapsed > 0 {
if sent_delta_raw > 0 {
sent_delta = sent_delta_raw * 1000 / millisecondsElapsed
if sent_delta > maxNetworkSpeedBps {
slog.Warn("Bad network delta", "container", name)
sent_delta = 0
}
}
if recv_delta_raw > 0 {
recv_delta = recv_delta_raw * 1000 / millisecondsElapsed
if recv_delta > maxNetworkSpeedBps {
slog.Warn("Bad network delta", "container", name)
recv_delta = 0
}
}
}
}
return sent_delta, recv_delta
}
// calculateDiskStats calculates disk read/write deltas using DeltaTracker
func (dm *dockerManager) calculateDiskStats(ctr *container.ApiInfo, apiStats *container.ApiStats, stats *container.Stats, initialized bool, cacheTimeMs uint16) (uint64, uint64) {
var total_read, total_write uint64
for _, entry := range apiStats.BlkioStats.IoServiceBytesRecursive {
switch entry.Op {
case diskOpRead, diskOpReadCap:
total_read += entry.Value
case diskOpWrite, diskOpWriteCap:
total_write += entry.Value
}
}
// Get the DeltaTracker for this specific cache time
readTracker := dm.getDiskTracker(cacheTimeMs, true)
writeTracker := dm.getDiskTracker(cacheTimeMs, false)
// Set current values in the cache-time-specific DeltaTracker
readTracker.Set(ctr.IdShort, total_read)
writeTracker.Set(ctr.IdShort, total_write)
// Get deltas (bytes since last measurement)
read_delta_raw := readTracker.Delta(ctr.IdShort)
write_delta_raw := writeTracker.Delta(ctr.IdShort)
// Calculate bytes per second if we have previous data
var read_delta, write_delta uint64
if initialized {
millisecondsElapsed := uint64(time.Since(stats.PrevReadTime).Milliseconds())
if millisecondsElapsed > 0 {
if read_delta_raw > 0 {
read_delta = read_delta_raw * 1000 / millisecondsElapsed
}
if write_delta_raw > 0 {
write_delta = write_delta_raw * 1000 / millisecondsElapsed
}
}
}
// Store current disk values for legacy compatibility
stats.PrevDisk.Read, stats.PrevDisk.Write = total_read, total_write
return read_delta, write_delta
}
// validateCpuPercentage checks if CPU percentage is within valid range
func validateCpuPercentage(cpuPct float64, containerName string) error {
if cpuPct > 100 {
return fmt.Errorf("%s cpu pct greater than 100: %+v", containerName, cpuPct)
}
return nil
}
// updateContainerStatsValues updates the final stats values
func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemory uint64, sent_delta, recv_delta, read_delta, write_delta uint64, readTime time.Time) {
stats.Cpu = twoDecimals(cpuPct)
stats.Mem = bytesToMegabytes(float64(usedMemory))
stats.NetworkSent = bytesToMegabytes(float64(sent_delta))
stats.NetworkRecv = bytesToMegabytes(float64(recv_delta))
stats.DiskRead = bytesToMegabytes(float64(read_delta))
stats.DiskWrite = bytesToMegabytes(float64(write_delta))
stats.PrevReadTime = readTime
}
// Updates stats for individual container with cache-time-aware delta tracking
func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeMs uint16) error {
name := ctr.Names[0][1:]
resp, err := dm.client.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=1")
if err != nil {
return err
}
defer resp.Body.Close()
dm.containerStatsMutex.Lock()
defer dm.containerStatsMutex.Unlock()
@@ -163,16 +410,64 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error {
dm.containerStatsMap[ctr.IdShort] = stats
}
// Update name in case it changed
stats.Name = name
// Set container metadata
stats.IdShort = ctr.IdShort
stats.Status = ctr.State
if stats.Status == "" {
stats.Status = containerStateUnknown
}
// Set health status
stats.Health = healthStatusNone
if ctr.Health != "" {
stats.Health = ctr.Health
}
// Set Docker Compose project name
if ctr.Labels != nil {
if projectName, exists := ctr.Labels[composeProjectLabel]; exists {
stats.Project = projectName
}
}
// Calculate uptime for running containers
if ctr.StartedAt > 0 && stats.Status == containerStateRunning {
startedTime := time.Unix(ctr.StartedAt, 0)
stats.Uptime = twoDecimals(time.Since(startedTime).Seconds())
} else {
stats.Uptime = 0
}
// Collect volume information and fetch sizes
volumeCount := 0
for _, mount := range ctr.Mounts {
if mount.Type == volumeTypeVolume && mount.Name != "" {
volumeCount++
}
}
if volumeCount > 0 {
stats.Volumes = make(map[string]float64, volumeCount)
for _, mount := range ctr.Mounts {
if mount.Type == volumeTypeVolume && mount.Name != "" {
// Fetch volume size using Docker system df API
size := dm.getVolumeSize(mount.Name)
stats.Volumes[mount.Name] = size
}
}
} else {
stats.Volumes = nil
}
// reset current stats
stats.Cpu = 0
stats.Mem = 0
stats.NetworkSent = 0
stats.NetworkRecv = 0
// docker host container stats response
// res := dm.getApiStats()
// defer dm.putApiStats(res)
//
stats.DiskRead = 0
stats.DiskWrite = 0
res := dm.apiStats
res.Networks = nil
@@ -180,61 +475,55 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error {
return err
}
// calculate cpu and memory stats
var usedMemory uint64
// Initialize CPU tracking for this cache time interval
dm.initializeCpuTracking(cacheTimeMs)
// Get previous CPU values
prevCpuContainer, prevCpuSystem := dm.getCpuPreviousValues(cacheTimeMs, ctr.IdShort)
// Calculate CPU percentage based on platform
var cpuPct float64
// store current cpu stats
prevCpuContainer, prevCpuSystem := stats.CpuContainer, stats.CpuSystem
stats.CpuContainer = res.CPUStats.CPUUsage.TotalUsage
stats.CpuSystem = res.CPUStats.SystemUsage
if dm.isWindows {
usedMemory = res.MemoryStats.PrivateWorkingSet
cpuPct = res.CalculateCpuPercentWindows(prevCpuContainer, stats.PrevReadTime)
prevRead := dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort]
cpuPct = res.CalculateCpuPercentWindows(prevCpuContainer, prevRead)
} else {
// check if container has valid data, otherwise may be in restart loop (#103)
if res.MemoryStats.Usage == 0 {
return fmt.Errorf("%s - no memory stats - see https://github.com/henrygd/beszel/issues/144", name)
}
memCache := res.MemoryStats.Stats.InactiveFile
if memCache == 0 {
memCache = res.MemoryStats.Stats.Cache
}
usedMemory = res.MemoryStats.Usage - memCache
cpuPct = res.CalculateCpuPercentLinux(prevCpuContainer, prevCpuSystem)
}
if cpuPct > 100 {
return fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct)
// Calculate memory usage
usedMemory, err := calculateMemoryUsage(res, dm.isWindows)
if err != nil {
return fmt.Errorf("%s - %w - see https://github.com/henrygd/beszel/issues/144", name, err)
}
// network
// Store current CPU stats for next calculation
currentCpuContainer := res.CPUStats.CPUUsage.TotalUsage
currentCpuSystem := res.CPUStats.SystemUsage
dm.setCpuCurrentValues(cacheTimeMs, ctr.IdShort, currentCpuContainer, currentCpuSystem)
// Validate CPU percentage
if err := validateCpuPercentage(cpuPct, name); err != nil {
return err
}
// Calculate network stats using DeltaTracker
sent_delta, recv_delta := dm.calculateNetworkStats(ctr, res, stats, initialized, name, cacheTimeMs)
// Store current network values for legacy compatibility
var total_sent, total_recv uint64
for _, v := range res.Networks {
total_sent += v.TxBytes
total_recv += v.RxBytes
}
var sent_delta, recv_delta uint64
millisecondsElapsed := uint64(time.Since(stats.PrevReadTime).Milliseconds())
if initialized && millisecondsElapsed > 0 {
// get bytes per second
sent_delta = (total_sent - stats.PrevNet.Sent) * 1000 / millisecondsElapsed
recv_delta = (total_recv - stats.PrevNet.Recv) * 1000 / millisecondsElapsed
// check for unrealistic network values (> 5GB/s)
if sent_delta > 5e9 || recv_delta > 5e9 {
slog.Warn("Bad network delta", "container", name)
sent_delta, recv_delta = 0, 0
}
}
stats.PrevNet.Sent, stats.PrevNet.Recv = total_sent, total_recv
stats.Cpu = twoDecimals(cpuPct)
stats.Mem = bytesToMegabytes(float64(usedMemory))
stats.NetworkSent = bytesToMegabytes(float64(sent_delta))
stats.NetworkRecv = bytesToMegabytes(float64(recv_delta))
stats.PrevReadTime = res.Read
// Calculate disk I/O stats using DeltaTracker
read_delta, write_delta := dm.calculateDiskStats(ctr, res, stats, initialized, cacheTimeMs)
// Update final stats values
updateContainerStatsValues(stats, cpuPct, usedMemory, sent_delta, recv_delta, read_delta, write_delta, res.Read)
// store per-cache-time read time for Windows CPU percent calc
dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort] = res.Read
return nil
}
@@ -244,6 +533,15 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
dm.containerStatsMutex.Lock()
defer dm.containerStatsMutex.Unlock()
delete(dm.containerStatsMap, id)
for ct := range dm.lastCpuContainer {
delete(dm.lastCpuContainer[ct], id)
}
for ct := range dm.lastCpuSystem {
delete(dm.lastCpuSystem[ct], id)
}
for ct := range dm.lastCpuReadTime {
delete(dm.lastCpuReadTime[ct], id)
}
}
// Creates a new http client for Docker or Podman API
@@ -283,7 +581,7 @@ func newDockerManager(a *Agent) *dockerManager {
}
// configurable timeout
timeout := time.Millisecond * 2100
timeout := time.Millisecond * time.Duration(dockerTimeoutMs)
if t, set := GetEnv("DOCKER_TIMEOUT"); set {
timeout, err = time.ParseDuration(t)
if err != nil {
@@ -308,6 +606,16 @@ func newDockerManager(a *Agent) *dockerManager {
sem: make(chan struct{}, 5),
apiContainerList: []*container.ApiInfo{},
apiStats: &container.ApiStats{},
volumeSizeCache: make(map[string]float64),
// Initialize cache-time-aware tracking structures
lastCpuContainer: make(map[uint16]map[string]uint64),
lastCpuSystem: make(map[uint16]map[string]uint64),
lastCpuReadTime: make(map[uint16]map[string]time.Time),
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
diskReadTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
diskWriteTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
}
// If using podman, return client
@@ -317,28 +625,92 @@ func newDockerManager(a *Agent) *dockerManager {
return manager
}
// Check docker version
// (versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch)
// this can take up to 5 seconds with retry, so run in goroutine
go manager.checkDockerVersion()
// give version check a chance to complete before returning
time.Sleep(50 * time.Millisecond)
return manager
}
// checkDockerVersion checks Docker version and sets goodDockerVersion if at least 25.0.0.
// Versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch.
func (dm *dockerManager) checkDockerVersion() {
var err error
var resp *http.Response
var versionInfo struct {
Version string `json:"Version"`
}
resp, err := manager.client.Get("http://localhost/version")
const versionMaxTries = 2
for i := 1; i <= versionMaxTries; i++ {
resp, err = dm.client.Get("http://localhost/version")
if err == nil {
break
}
if resp != nil {
resp.Body.Close()
}
if i < versionMaxTries {
slog.Debug("Failed to get Docker version; retrying", "attempt", i, "error", err)
time.Sleep(5 * time.Second)
}
}
if err != nil {
return manager
return
}
if err := manager.decode(resp, &versionInfo); err != nil {
return manager
if err := dm.decode(resp, &versionInfo); err != nil {
return
}
// if version > 24, one-shot works correctly and we can limit concurrent operations
if dockerVersion, err := semver.Parse(versionInfo.Version); err == nil && dockerVersion.Major > 24 {
manager.goodDockerVersion = true
dm.goodDockerVersion = true
} else {
slog.Info(fmt.Sprintf("Docker %s is outdated. Upgrade if possible. See https://github.com/henrygd/beszel/issues/58", versionInfo.Version))
}
}
return manager
// getVolumeSize returns the cached size of a Docker volume
// Refreshes the cache every 5 minutes using the system df API
// Returns size in MB (megabytes)
func (dm *dockerManager) getVolumeSize(volumeName string) float64 {
// Refresh cache if older than 5 minutes
if time.Since(dm.volumeSizeUpdated) > 5*time.Minute {
dm.refreshVolumeSizes()
}
return dm.volumeSizeCache[volumeName]
}
// refreshVolumeSizes fetches all volume sizes from Docker and updates the cache
func (dm *dockerManager) refreshVolumeSizes() {
type volumeInfo struct {
Name string
UsageData struct {
Size int64
}
}
type systemDfResponse struct {
Volumes []volumeInfo
}
resp, err := dm.client.Get("http://localhost/system/df")
if err != nil {
return
}
var dfData systemDfResponse
if err := dm.decode(resp, &dfData); err != nil {
return
}
// Update all volume sizes in cache
for _, vol := range dfData.Volumes {
// Convert bytes to MB (megabytes)
dm.volumeSizeCache[vol.Name] = float64(vol.UsageData.Size) / 1_000_000
}
dm.volumeSizeUpdated = time.Now()
}
// Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe.

875
agent/docker_test.go Normal file
View File

@@ -0,0 +1,875 @@
//go:build testing
// +build testing
package agent
import (
"encoding/json"
"os"
"testing"
"time"
"github.com/henrygd/beszel/agent/deltatracker"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var defaultCacheTimeMs = uint16(60_000)
// cycleCpuDeltas cycles the CPU tracking data for a specific cache time interval
func (dm *dockerManager) cycleCpuDeltas(cacheTimeMs uint16) {
// Clear the CPU tracking maps for this cache time interval
if dm.lastCpuContainer[cacheTimeMs] != nil {
clear(dm.lastCpuContainer[cacheTimeMs])
}
if dm.lastCpuSystem[cacheTimeMs] != nil {
clear(dm.lastCpuSystem[cacheTimeMs])
}
}
func TestCalculateMemoryUsage(t *testing.T) {
tests := []struct {
name string
apiStats *container.ApiStats
isWindows bool
expected uint64
expectError bool
}{
{
name: "Linux with valid memory stats",
apiStats: &container.ApiStats{
MemoryStats: container.MemoryStats{
Usage: 1048576, // 1MB
Stats: container.MemoryStatsStats{
Cache: 524288, // 512KB
InactiveFile: 262144, // 256KB
},
},
},
isWindows: false,
expected: 786432, // 1MB - 256KB (inactive_file takes precedence) = 768KB
expectError: false,
},
{
name: "Linux with zero cache uses inactive_file",
apiStats: &container.ApiStats{
MemoryStats: container.MemoryStats{
Usage: 1048576, // 1MB
Stats: container.MemoryStatsStats{
Cache: 0,
InactiveFile: 262144, // 256KB
},
},
},
isWindows: false,
expected: 786432, // 1MB - 256KB = 768KB
expectError: false,
},
{
name: "Windows with valid memory stats",
apiStats: &container.ApiStats{
MemoryStats: container.MemoryStats{
PrivateWorkingSet: 524288, // 512KB
},
},
isWindows: true,
expected: 524288,
expectError: false,
},
{
name: "Linux with zero usage returns error",
apiStats: &container.ApiStats{
MemoryStats: container.MemoryStats{
Usage: 0,
Stats: container.MemoryStatsStats{
Cache: 0,
InactiveFile: 0,
},
},
},
isWindows: false,
expected: 0,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := calculateMemoryUsage(tt.apiStats, tt.isWindows)
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
}
})
}
}
func TestValidateCpuPercentage(t *testing.T) {
tests := []struct {
name string
cpuPct float64
containerName string
expectError bool
expectedError string
}{
{
name: "valid CPU percentage",
cpuPct: 50.5,
containerName: "test-container",
expectError: false,
},
{
name: "zero CPU percentage",
cpuPct: 0.0,
containerName: "test-container",
expectError: false,
},
{
name: "CPU percentage over 100",
cpuPct: 150.5,
containerName: "test-container",
expectError: true,
expectedError: "test-container cpu pct greater than 100: 150.5",
},
{
name: "CPU percentage exactly 100",
cpuPct: 100.0,
containerName: "test-container",
expectError: false,
},
{
name: "negative CPU percentage",
cpuPct: -10.0,
containerName: "test-container",
expectError: false, // Function only checks for > 100, not negative
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateCpuPercentage(tt.cpuPct, tt.containerName)
if tt.expectError {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
} else {
assert.NoError(t, err)
}
})
}
}
func TestUpdateContainerStatsValues(t *testing.T) {
stats := &container.Stats{
Name: "test-container",
Cpu: 0.0,
Mem: 0.0,
NetworkSent: 0.0,
NetworkRecv: 0.0,
PrevReadTime: time.Time{},
}
testTime := time.Now()
updateContainerStatsValues(stats, 75.5, 1048576, 524288, 262144, testTime)
// Check CPU percentage (should be rounded to 2 decimals)
assert.Equal(t, 75.5, stats.Cpu)
// Check memory (should be converted to MB: 1048576 bytes = 1 MB)
assert.Equal(t, 1.0, stats.Mem)
// Check network sent (should be converted to MB: 524288 bytes = 0.5 MB)
assert.Equal(t, 0.5, stats.NetworkSent)
// Check network recv (should be converted to MB: 262144 bytes = 0.25 MB)
assert.Equal(t, 0.25, stats.NetworkRecv)
// Check read time
assert.Equal(t, testTime, stats.PrevReadTime)
}
func TestTwoDecimals(t *testing.T) {
tests := []struct {
name string
input float64
expected float64
}{
{"round down", 1.234, 1.23},
{"round half up", 1.235, 1.24}, // math.Round rounds half up
{"no rounding needed", 1.23, 1.23},
{"negative number", -1.235, -1.24}, // math.Round rounds half up (more negative)
{"zero", 0.0, 0.0},
{"large number", 123.456, 123.46}, // rounds 5 up
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := twoDecimals(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestBytesToMegabytes(t *testing.T) {
tests := []struct {
name string
input float64
expected float64
}{
{"1 MB", 1048576, 1.0},
{"512 KB", 524288, 0.5},
{"zero", 0, 0},
{"large value", 1073741824, 1024}, // 1 GB = 1024 MB
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := bytesToMegabytes(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestInitializeCpuTracking(t *testing.T) {
dm := &dockerManager{
lastCpuContainer: make(map[uint16]map[string]uint64),
lastCpuSystem: make(map[uint16]map[string]uint64),
lastCpuReadTime: make(map[uint16]map[string]time.Time),
}
cacheTimeMs := uint16(30000)
// Test initializing a new cache time
dm.initializeCpuTracking(cacheTimeMs)
// Check that maps were created
assert.NotNil(t, dm.lastCpuContainer[cacheTimeMs])
assert.NotNil(t, dm.lastCpuSystem[cacheTimeMs])
assert.NotNil(t, dm.lastCpuReadTime[cacheTimeMs])
assert.Empty(t, dm.lastCpuContainer[cacheTimeMs])
assert.Empty(t, dm.lastCpuSystem[cacheTimeMs])
// Test initializing existing cache time (should not overwrite)
dm.lastCpuContainer[cacheTimeMs]["test"] = 100
dm.lastCpuSystem[cacheTimeMs]["test"] = 200
dm.initializeCpuTracking(cacheTimeMs)
// Should still have the existing values
assert.Equal(t, uint64(100), dm.lastCpuContainer[cacheTimeMs]["test"])
assert.Equal(t, uint64(200), dm.lastCpuSystem[cacheTimeMs]["test"])
}
func TestGetCpuPreviousValues(t *testing.T) {
dm := &dockerManager{
lastCpuContainer: map[uint16]map[string]uint64{
30000: {"container1": 100, "container2": 200},
},
lastCpuSystem: map[uint16]map[string]uint64{
30000: {"container1": 150, "container2": 250},
},
}
// Test getting existing values
container, system := dm.getCpuPreviousValues(30000, "container1")
assert.Equal(t, uint64(100), container)
assert.Equal(t, uint64(150), system)
// Test getting non-existing container
container, system = dm.getCpuPreviousValues(30000, "nonexistent")
assert.Equal(t, uint64(0), container)
assert.Equal(t, uint64(0), system)
// Test getting non-existing cache time
container, system = dm.getCpuPreviousValues(60000, "container1")
assert.Equal(t, uint64(0), container)
assert.Equal(t, uint64(0), system)
}
func TestSetCpuCurrentValues(t *testing.T) {
dm := &dockerManager{
lastCpuContainer: make(map[uint16]map[string]uint64),
lastCpuSystem: make(map[uint16]map[string]uint64),
}
cacheTimeMs := uint16(30000)
containerId := "test-container"
// Initialize the cache time maps first
dm.initializeCpuTracking(cacheTimeMs)
// Set values
dm.setCpuCurrentValues(cacheTimeMs, containerId, 500, 750)
// Check that values were set
assert.Equal(t, uint64(500), dm.lastCpuContainer[cacheTimeMs][containerId])
assert.Equal(t, uint64(750), dm.lastCpuSystem[cacheTimeMs][containerId])
}
func TestCalculateNetworkStats(t *testing.T) {
// Create docker manager with tracker maps
dm := &dockerManager{
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
}
cacheTimeMs := uint16(30000)
// Pre-populate tracker for this cache time with initial values
sentTracker := deltatracker.NewDeltaTracker[string, uint64]()
recvTracker := deltatracker.NewDeltaTracker[string, uint64]()
sentTracker.Set("container1", 1000)
recvTracker.Set("container1", 800)
sentTracker.Cycle() // Move to previous
recvTracker.Cycle()
dm.networkSentTrackers[cacheTimeMs] = sentTracker
dm.networkRecvTrackers[cacheTimeMs] = recvTracker
ctr := &container.ApiInfo{
IdShort: "container1",
}
apiStats := &container.ApiStats{
Networks: map[string]container.NetworkStats{
"eth0": {TxBytes: 2000, RxBytes: 1800}, // New values
},
}
stats := &container.Stats{
PrevReadTime: time.Now().Add(-time.Second), // 1 second ago
}
// Test with initialized container
sent, recv := dm.calculateNetworkStats(ctr, apiStats, stats, true, "test-container", cacheTimeMs)
// Should return calculated byte rates per second
assert.GreaterOrEqual(t, sent, uint64(0))
assert.GreaterOrEqual(t, recv, uint64(0))
// Cycle and test one-direction change (Tx only) is reflected independently
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
apiStats.Networks["eth0"] = container.NetworkStats{TxBytes: 2500, RxBytes: 1800} // +500 Tx only
sent, recv = dm.calculateNetworkStats(ctr, apiStats, stats, true, "test-container", cacheTimeMs)
assert.Greater(t, sent, uint64(0))
assert.Equal(t, uint64(0), recv)
}
func TestDockerManagerCreation(t *testing.T) {
// Test that dockerManager can be created without panicking
dm := &dockerManager{
lastCpuContainer: make(map[uint16]map[string]uint64),
lastCpuSystem: make(map[uint16]map[string]uint64),
lastCpuReadTime: make(map[uint16]map[string]time.Time),
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
}
assert.NotNil(t, dm)
assert.NotNil(t, dm.lastCpuContainer)
assert.NotNil(t, dm.lastCpuSystem)
assert.NotNil(t, dm.networkSentTrackers)
assert.NotNil(t, dm.networkRecvTrackers)
}
func TestCycleCpuDeltas(t *testing.T) {
dm := &dockerManager{
lastCpuContainer: map[uint16]map[string]uint64{
30000: {"container1": 100, "container2": 200},
},
lastCpuSystem: map[uint16]map[string]uint64{
30000: {"container1": 150, "container2": 250},
},
lastCpuReadTime: map[uint16]map[string]time.Time{
30000: {"container1": time.Now()},
},
}
cacheTimeMs := uint16(30000)
// Verify values exist before cycling
assert.Equal(t, uint64(100), dm.lastCpuContainer[cacheTimeMs]["container1"])
assert.Equal(t, uint64(200), dm.lastCpuContainer[cacheTimeMs]["container2"])
// Cycle the CPU deltas
dm.cycleCpuDeltas(cacheTimeMs)
// Verify values are cleared
assert.Empty(t, dm.lastCpuContainer[cacheTimeMs])
assert.Empty(t, dm.lastCpuSystem[cacheTimeMs])
// lastCpuReadTime is not affected by cycleCpuDeltas
assert.NotEmpty(t, dm.lastCpuReadTime[cacheTimeMs])
}
func TestCycleNetworkDeltas(t *testing.T) {
// Create docker manager with tracker maps
dm := &dockerManager{
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
}
cacheTimeMs := uint16(30000)
// Get trackers for this cache time (creates them)
sentTracker := dm.getNetworkTracker(cacheTimeMs, true)
recvTracker := dm.getNetworkTracker(cacheTimeMs, false)
// Set some test data
sentTracker.Set("test", 100)
recvTracker.Set("test", 200)
// This should not panic
assert.NotPanics(t, func() {
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
})
// Verify that cycle worked by checking deltas are now zero (no previous values)
assert.Equal(t, uint64(0), sentTracker.Delta("test"))
assert.Equal(t, uint64(0), recvTracker.Delta("test"))
}
func TestConstants(t *testing.T) {
// Test that constants are properly defined
assert.Equal(t, uint16(60000), defaultCacheTimeMs)
assert.Equal(t, uint64(5e9), maxNetworkSpeedBps)
assert.Equal(t, 2100, dockerTimeoutMs)
}
func TestDockerStatsWithMockData(t *testing.T) {
// Create a docker manager with initialized tracking
dm := &dockerManager{
lastCpuContainer: make(map[uint16]map[string]uint64),
lastCpuSystem: make(map[uint16]map[string]uint64),
lastCpuReadTime: make(map[uint16]map[string]time.Time),
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
containerStatsMap: make(map[string]*container.Stats),
}
cacheTimeMs := uint16(30000)
// Test that initializeCpuTracking works
dm.initializeCpuTracking(cacheTimeMs)
assert.NotNil(t, dm.lastCpuContainer[cacheTimeMs])
assert.NotNil(t, dm.lastCpuSystem[cacheTimeMs])
// Test that we can set and get CPU values
dm.setCpuCurrentValues(cacheTimeMs, "test-container", 1000, 2000)
container, system := dm.getCpuPreviousValues(cacheTimeMs, "test-container")
assert.Equal(t, uint64(1000), container)
assert.Equal(t, uint64(2000), system)
}
func TestMemoryStatsEdgeCases(t *testing.T) {
tests := []struct {
name string
usage uint64
cache uint64
inactive uint64
isWindows bool
expected uint64
hasError bool
}{
{"Linux normal case", 1000, 200, 0, false, 800, false},
{"Linux with inactive file", 1000, 0, 300, false, 700, false},
{"Windows normal case", 0, 0, 0, true, 500, false},
{"Linux zero usage error", 0, 0, 0, false, 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
apiStats := &container.ApiStats{
MemoryStats: container.MemoryStats{
Usage: tt.usage,
Stats: container.MemoryStatsStats{
Cache: tt.cache,
InactiveFile: tt.inactive,
},
},
}
if tt.isWindows {
apiStats.MemoryStats.PrivateWorkingSet = tt.expected
}
result, err := calculateMemoryUsage(apiStats, tt.isWindows)
if tt.hasError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
}
})
}
}
func TestContainerStatsInitialization(t *testing.T) {
stats := &container.Stats{Name: "test-container"}
// Verify initial values
assert.Equal(t, "test-container", stats.Name)
assert.Equal(t, 0.0, stats.Cpu)
assert.Equal(t, 0.0, stats.Mem)
assert.Equal(t, 0.0, stats.NetworkSent)
assert.Equal(t, 0.0, stats.NetworkRecv)
assert.Equal(t, time.Time{}, stats.PrevReadTime)
// Test updating values
testTime := time.Now()
updateContainerStatsValues(stats, 45.67, 2097152, 1048576, 524288, testTime)
assert.Equal(t, 45.67, stats.Cpu)
assert.Equal(t, 2.0, stats.Mem)
assert.Equal(t, 1.0, stats.NetworkSent)
assert.Equal(t, 0.5, stats.NetworkRecv)
assert.Equal(t, testTime, stats.PrevReadTime)
}
// Test with real Docker API test data
func TestCalculateMemoryUsageWithRealData(t *testing.T) {
// Load minimal container stats from test data
data, err := os.ReadFile("test-data/container.json")
require.NoError(t, err)
var apiStats container.ApiStats
err = json.Unmarshal(data, &apiStats)
require.NoError(t, err)
// Test memory calculation with real data
usedMemory, err := calculateMemoryUsage(&apiStats, false)
require.NoError(t, err)
// From the real data: usage - inactive_file = 507400192 - 165130240 = 342269952
expected := uint64(507400192 - 165130240)
assert.Equal(t, expected, usedMemory)
}
func TestCpuPercentageCalculationWithRealData(t *testing.T) {
// Load minimal container stats from test data
data1, err := os.ReadFile("test-data/container.json")
require.NoError(t, err)
data2, err := os.ReadFile("test-data/container2.json")
require.NoError(t, err)
var apiStats1, apiStats2 container.ApiStats
err = json.Unmarshal(data1, &apiStats1)
require.NoError(t, err)
err = json.Unmarshal(data2, &apiStats2)
require.NoError(t, err)
// Calculate delta manually: 314891801000 - 312055276000 = 2836525000
// System delta: 1368474900000000 - 1366399830000000 = 2075070000000
// Expected %: (2836525000 / 2075070000000) * 100 ≈ 0.1367%
expectedPct := float64(2836525000) / float64(2075070000000) * 100.0
actualPct := apiStats2.CalculateCpuPercentLinux(apiStats1.CPUStats.CPUUsage.TotalUsage, apiStats1.CPUStats.SystemUsage)
assert.InDelta(t, expectedPct, actualPct, 0.01)
}
func TestNetworkStatsCalculationWithRealData(t *testing.T) {
// Create synthetic test data to avoid timing issues
apiStats1 := &container.ApiStats{
Networks: map[string]container.NetworkStats{
"eth0": {TxBytes: 1000000, RxBytes: 500000},
},
}
apiStats2 := &container.ApiStats{
Networks: map[string]container.NetworkStats{
"eth0": {TxBytes: 3000000, RxBytes: 1500000}, // 2MB sent, 1MB received increase
},
}
// Create docker manager with tracker maps
dm := &dockerManager{
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
}
ctr := &container.ApiInfo{IdShort: "test-container"}
cacheTimeMs := uint16(30000) // Test with 30 second cache
// Use exact timing for deterministic results
exactly1000msAgo := time.Now().Add(-1000 * time.Millisecond)
stats := &container.Stats{
PrevReadTime: exactly1000msAgo,
}
// First call sets baseline
sent1, recv1 := dm.calculateNetworkStats(ctr, apiStats1, stats, true, "test", cacheTimeMs)
assert.Equal(t, uint64(0), sent1)
assert.Equal(t, uint64(0), recv1)
// Cycle to establish baseline for this cache time
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
// Calculate expected results precisely
deltaSent := uint64(2000000) // 3000000 - 1000000
deltaRecv := uint64(1000000) // 1500000 - 500000
expectedElapsedMs := uint64(1000) // Exactly 1000ms
expectedSentRate := deltaSent * 1000 / expectedElapsedMs // Should be exactly 2000000
expectedRecvRate := deltaRecv * 1000 / expectedElapsedMs // Should be exactly 1000000
// Second call with changed data
sent2, recv2 := dm.calculateNetworkStats(ctr, apiStats2, stats, true, "test", cacheTimeMs)
// Should be exactly the expected rates (no tolerance needed)
assert.Equal(t, expectedSentRate, sent2)
assert.Equal(t, expectedRecvRate, recv2)
// Bad speed cap: set absurd delta over 1ms and expect 0 due to cap
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
stats.PrevReadTime = time.Now().Add(-1 * time.Millisecond)
apiStats1.Networks["eth0"] = container.NetworkStats{TxBytes: 0, RxBytes: 0}
apiStats2.Networks["eth0"] = container.NetworkStats{TxBytes: 10 * 1024 * 1024 * 1024, RxBytes: 0} // 10GB delta
_, _ = dm.calculateNetworkStats(ctr, apiStats1, stats, true, "test", cacheTimeMs) // baseline
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
sent3, recv3 := dm.calculateNetworkStats(ctr, apiStats2, stats, true, "test", cacheTimeMs)
assert.Equal(t, uint64(0), sent3)
assert.Equal(t, uint64(0), recv3)
}
func TestContainerStatsEndToEndWithRealData(t *testing.T) {
// Load minimal container stats
data, err := os.ReadFile("test-data/container.json")
require.NoError(t, err)
var apiStats container.ApiStats
err = json.Unmarshal(data, &apiStats)
require.NoError(t, err)
// Create a docker manager with proper initialization
dm := &dockerManager{
lastCpuContainer: make(map[uint16]map[string]uint64),
lastCpuSystem: make(map[uint16]map[string]uint64),
lastCpuReadTime: make(map[uint16]map[string]time.Time),
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
containerStatsMap: make(map[string]*container.Stats),
}
// Initialize CPU tracking
cacheTimeMs := uint16(30000)
dm.initializeCpuTracking(cacheTimeMs)
// Create container info
ctr := &container.ApiInfo{
IdShort: "abc123",
}
// Initialize container stats
stats := &container.Stats{Name: "jellyfin"}
dm.containerStatsMap[ctr.IdShort] = stats
// Test individual components that we can verify
usedMemory, memErr := calculateMemoryUsage(&apiStats, false)
assert.NoError(t, memErr)
assert.Greater(t, usedMemory, uint64(0))
// Test CPU percentage validation
cpuPct := 85.5
err = validateCpuPercentage(cpuPct, "jellyfin")
assert.NoError(t, err)
err = validateCpuPercentage(150.0, "jellyfin")
assert.Error(t, err)
// Test stats value updates
testStats := &container.Stats{}
testTime := time.Now()
updateContainerStatsValues(testStats, cpuPct, usedMemory, 1000000, 500000, testTime)
assert.Equal(t, cpuPct, testStats.Cpu)
assert.Equal(t, bytesToMegabytes(float64(usedMemory)), testStats.Mem)
assert.Equal(t, bytesToMegabytes(1000000), testStats.NetworkSent)
assert.Equal(t, bytesToMegabytes(500000), testStats.NetworkRecv)
assert.Equal(t, testTime, testStats.PrevReadTime)
}
func TestEdgeCasesWithRealData(t *testing.T) {
// Test with minimal container stats
minimalStats := &container.ApiStats{
CPUStats: container.CPUStats{
CPUUsage: container.CPUUsage{TotalUsage: 1000},
SystemUsage: 50000,
},
MemoryStats: container.MemoryStats{
Usage: 1000000,
Stats: container.MemoryStatsStats{
Cache: 0,
InactiveFile: 0,
},
},
Networks: map[string]container.NetworkStats{
"eth0": {TxBytes: 1000, RxBytes: 500},
},
}
// Test memory calculation with zero cache/inactive
usedMemory, err := calculateMemoryUsage(minimalStats, false)
assert.NoError(t, err)
assert.Equal(t, uint64(1000000), usedMemory) // Should equal usage when no cache
// Test CPU percentage calculation
cpuPct := minimalStats.CalculateCpuPercentLinux(0, 0) // First run
assert.Equal(t, 0.0, cpuPct)
// Test with Windows data
minimalStats.MemoryStats.PrivateWorkingSet = 800000
usedMemory, err = calculateMemoryUsage(minimalStats, true)
assert.NoError(t, err)
assert.Equal(t, uint64(800000), usedMemory)
}
func TestDockerStatsWorkflow(t *testing.T) {
// Test the complete workflow that can be tested without HTTP calls
dm := &dockerManager{
lastCpuContainer: make(map[uint16]map[string]uint64),
lastCpuSystem: make(map[uint16]map[string]uint64),
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
containerStatsMap: make(map[string]*container.Stats),
}
cacheTimeMs := uint16(30000)
// Test CPU tracking workflow
dm.initializeCpuTracking(cacheTimeMs)
assert.NotNil(t, dm.lastCpuContainer[cacheTimeMs])
// Test setting and getting CPU values
dm.setCpuCurrentValues(cacheTimeMs, "test-container", 1000, 50000)
containerVal, systemVal := dm.getCpuPreviousValues(cacheTimeMs, "test-container")
assert.Equal(t, uint64(1000), containerVal)
assert.Equal(t, uint64(50000), systemVal)
// Test network tracking workflow (multi-interface summation)
sentTracker := dm.getNetworkTracker(cacheTimeMs, true)
recvTracker := dm.getNetworkTracker(cacheTimeMs, false)
// Simulate two interfaces summed by setting combined totals
sentTracker.Set("test-container", 1000+2000)
recvTracker.Set("test-container", 500+700)
deltaSent := sentTracker.Delta("test-container")
deltaRecv := recvTracker.Delta("test-container")
assert.Equal(t, uint64(0), deltaSent) // No previous value
assert.Equal(t, uint64(0), deltaRecv)
// Cycle and test again
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
// Increase each interface total (combined totals go up by 1500 and 800)
sentTracker.Set("test-container", (1000+2000)+1500)
recvTracker.Set("test-container", (500+700)+800)
deltaSent = sentTracker.Delta("test-container")
deltaRecv = recvTracker.Delta("test-container")
assert.Equal(t, uint64(1500), deltaSent)
assert.Equal(t, uint64(800), deltaRecv)
}
func TestNetworkRateCalculationFormula(t *testing.T) {
// Test the exact formula used in calculateNetworkStats
testCases := []struct {
name string
deltaBytes uint64
elapsedMs uint64
expectedRate uint64
}{
{"1MB over 1 second", 1000000, 1000, 1000000},
{"2MB over 1 second", 2000000, 1000, 2000000},
{"1MB over 2 seconds", 1000000, 2000, 500000},
{"500KB over 500ms", 500000, 500, 1000000},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// This is the exact formula from calculateNetworkStats
actualRate := tc.deltaBytes * 1000 / tc.elapsedMs
assert.Equal(t, tc.expectedRate, actualRate,
"Rate calculation should be exact: %d bytes * 1000 / %d ms = %d",
tc.deltaBytes, tc.elapsedMs, tc.expectedRate)
})
}
}
func TestDeltaTrackerCacheTimeIsolation(t *testing.T) {
// Test that different cache times have separate DeltaTracker instances
dm := &dockerManager{
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
}
ctr := &container.ApiInfo{IdShort: "web-server"}
cacheTime1 := uint16(30000)
cacheTime2 := uint16(60000)
// Get trackers for different cache times (creates separate instances)
sentTracker1 := dm.getNetworkTracker(cacheTime1, true)
recvTracker1 := dm.getNetworkTracker(cacheTime1, false)
sentTracker2 := dm.getNetworkTracker(cacheTime2, true)
recvTracker2 := dm.getNetworkTracker(cacheTime2, false)
// Verify they are different instances
assert.NotSame(t, sentTracker1, sentTracker2)
assert.NotSame(t, recvTracker1, recvTracker2)
// Set values for cache time 1
sentTracker1.Set(ctr.IdShort, 1000000)
recvTracker1.Set(ctr.IdShort, 500000)
// Set values for cache time 2
sentTracker2.Set(ctr.IdShort, 2000000)
recvTracker2.Set(ctr.IdShort, 1000000)
// Verify they don't interfere (both should return 0 since no previous values)
assert.Equal(t, uint64(0), sentTracker1.Delta(ctr.IdShort))
assert.Equal(t, uint64(0), recvTracker1.Delta(ctr.IdShort))
assert.Equal(t, uint64(0), sentTracker2.Delta(ctr.IdShort))
assert.Equal(t, uint64(0), recvTracker2.Delta(ctr.IdShort))
// Cycle cache time 1 trackers
dm.cycleNetworkDeltasForCacheTime(cacheTime1)
// Set new values for cache time 1
sentTracker1.Set(ctr.IdShort, 3000000) // 2MB increase
recvTracker1.Set(ctr.IdShort, 1500000) // 1MB increase
// Cache time 1 should show deltas, cache time 2 should still be 0
assert.Equal(t, uint64(2000000), sentTracker1.Delta(ctr.IdShort))
assert.Equal(t, uint64(1000000), recvTracker1.Delta(ctr.IdShort))
assert.Equal(t, uint64(0), sentTracker2.Delta(ctr.IdShort)) // Unaffected
assert.Equal(t, uint64(0), recvTracker2.Delta(ctr.IdShort)) // Unaffected
// Cycle cache time 2 and verify it works independently
dm.cycleNetworkDeltasForCacheTime(cacheTime2)
sentTracker2.Set(ctr.IdShort, 2500000) // 0.5MB increase
recvTracker2.Set(ctr.IdShort, 1200000) // 0.2MB increase
assert.Equal(t, uint64(500000), sentTracker2.Delta(ctr.IdShort))
assert.Equal(t, uint64(200000), recvTracker2.Delta(ctr.IdShort))
}
func TestConstantsAndUtilityFunctions(t *testing.T) {
// Test constants are properly defined
assert.Equal(t, uint16(60000), defaultCacheTimeMs)
assert.Equal(t, uint64(5e9), maxNetworkSpeedBps)
assert.Equal(t, 2100, dockerTimeoutMs)
// Test utility functions
assert.Equal(t, 1.5, twoDecimals(1.499))
assert.Equal(t, 1.5, twoDecimals(1.5))
assert.Equal(t, 1.5, twoDecimals(1.501))
assert.Equal(t, 1.0, bytesToMegabytes(1048576)) // 1 MB
assert.Equal(t, 0.5, bytesToMegabytes(524288)) // 512 KB
assert.Equal(t, 0.0, bytesToMegabytes(0))
}

View File

@@ -5,6 +5,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"maps"
"os/exec"
"regexp"
"strconv"
@@ -44,6 +45,21 @@ type GPUManager struct {
tegrastats bool
intelGpuStats bool
GpuDataMap map[string]*system.GPUData
// lastAvgData stores the last calculated averages for each GPU
// Used when a collection happens before new data arrives (Count == 0)
lastAvgData map[string]system.GPUData
// Per-cache-key tracking for delta calculations
// cacheKey -> gpuId -> snapshot of last count/usage/power values
lastSnapshots map[uint16]map[string]*gpuSnapshot
}
// gpuSnapshot stores the last observed incremental values for delta tracking
type gpuSnapshot struct {
count uint32
usage float64
power float64
powerPkg float64
engines map[string]float64
}
// RocmSmiJson represents the JSON structure of rocm-smi output
@@ -229,48 +245,21 @@ func (gm *GPUManager) parseAmdData(output []byte) bool {
return true
}
// sums and resets the current GPU utilization data since the last update
func (gm *GPUManager) GetCurrentData() map[string]system.GPUData {
// GetCurrentData returns GPU utilization data averaged since the last call with this cacheKey
func (gm *GPUManager) GetCurrentData(cacheKey uint16) map[string]system.GPUData {
gm.Lock()
defer gm.Unlock()
// check for GPUs with the same name
nameCounts := make(map[string]int)
for _, gpu := range gm.GpuDataMap {
nameCounts[gpu.Name]++
}
gm.initializeSnapshots(cacheKey)
nameCounts := gm.countGPUNames()
// copy / reset the data
gpuData := make(map[string]system.GPUData, len(gm.GpuDataMap))
for id, gpu := range gm.GpuDataMap {
// avoid division by zero
count := max(gpu.Count, 1)
gpuAvg := gm.calculateGPUAverage(id, gpu, cacheKey)
gm.updateInstantaneousValues(&gpuAvg, gpu)
gm.storeSnapshot(id, gpu, cacheKey)
// average the data
gpuAvg := *gpu
gpuAvg.Temperature = twoDecimals(gpu.Temperature)
gpuAvg.Power = twoDecimals(gpu.Power / count)
// intel gpu stats doesn't provide usage, memory used, or memory total
if gpu.Engines != nil {
maxEngineUsage := 0.0
for name, engine := range gpu.Engines {
gpuAvg.Engines[name] = twoDecimals(engine / count)
maxEngineUsage = max(maxEngineUsage, engine/count)
}
gpuAvg.PowerPkg = twoDecimals(gpu.PowerPkg / count)
gpuAvg.Usage = twoDecimals(maxEngineUsage)
} else {
gpuAvg.Usage = twoDecimals(gpu.Usage / count)
gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed)
gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal)
}
// reset accumulators in the original gpu data for next collection
gpu.Usage, gpu.Power, gpu.PowerPkg, gpu.Count = gpuAvg.Usage, gpuAvg.Power, gpuAvg.PowerPkg, 1
gpu.Engines = gpuAvg.Engines
// append id to the name if there are multiple GPUs with the same name
// Append id to name if there are multiple GPUs with the same name
if nameCounts[gpu.Name] > 1 {
gpuAvg.Name = fmt.Sprintf("%s %s", gpu.Name, id)
}
@@ -280,6 +269,115 @@ func (gm *GPUManager) GetCurrentData() map[string]system.GPUData {
return gpuData
}
// initializeSnapshots ensures snapshot maps are initialized for the given cache key
func (gm *GPUManager) initializeSnapshots(cacheKey uint16) {
if gm.lastAvgData == nil {
gm.lastAvgData = make(map[string]system.GPUData)
}
if gm.lastSnapshots == nil {
gm.lastSnapshots = make(map[uint16]map[string]*gpuSnapshot)
}
if gm.lastSnapshots[cacheKey] == nil {
gm.lastSnapshots[cacheKey] = make(map[string]*gpuSnapshot)
}
}
// countGPUNames returns a map of GPU names to their occurrence count
func (gm *GPUManager) countGPUNames() map[string]int {
nameCounts := make(map[string]int)
for _, gpu := range gm.GpuDataMap {
nameCounts[gpu.Name]++
}
return nameCounts
}
// calculateGPUAverage computes the average GPU metrics since the last snapshot for this cache key
func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheKey uint16) system.GPUData {
lastSnapshot := gm.lastSnapshots[cacheKey][id]
currentCount := uint32(gpu.Count)
deltaCount := gm.calculateDeltaCount(currentCount, lastSnapshot)
// If no new data arrived, use last known average
if deltaCount == 0 {
return gm.lastAvgData[id] // zero value if not found
}
// Calculate new average
gpuAvg := *gpu
deltaUsage, deltaPower, deltaPowerPkg := gm.calculateDeltas(gpu, lastSnapshot)
gpuAvg.Power = twoDecimals(deltaPower / float64(deltaCount))
if gpu.Engines != nil {
// make fresh map for averaged engine metrics to avoid mutating
// the accumulator map stored in gm.GpuDataMap
gpuAvg.Engines = make(map[string]float64, len(gpu.Engines))
gpuAvg.Usage = gm.calculateIntelGPUUsage(&gpuAvg, gpu, lastSnapshot, deltaCount)
gpuAvg.PowerPkg = twoDecimals(deltaPowerPkg / float64(deltaCount))
} else {
gpuAvg.Usage = twoDecimals(deltaUsage / float64(deltaCount))
}
gm.lastAvgData[id] = gpuAvg
return gpuAvg
}
// calculateDeltaCount returns the change in count since the last snapshot
func (gm *GPUManager) calculateDeltaCount(currentCount uint32, lastSnapshot *gpuSnapshot) uint32 {
if lastSnapshot != nil {
return currentCount - lastSnapshot.count
}
return currentCount
}
// calculateDeltas computes the change in usage, power, and powerPkg since the last snapshot
func (gm *GPUManager) calculateDeltas(gpu *system.GPUData, lastSnapshot *gpuSnapshot) (deltaUsage, deltaPower, deltaPowerPkg float64) {
if lastSnapshot != nil {
return gpu.Usage - lastSnapshot.usage,
gpu.Power - lastSnapshot.power,
gpu.PowerPkg - lastSnapshot.powerPkg
}
return gpu.Usage, gpu.Power, gpu.PowerPkg
}
// calculateIntelGPUUsage computes Intel GPU usage from engine metrics and returns max engine usage
func (gm *GPUManager) calculateIntelGPUUsage(gpuAvg, gpu *system.GPUData, lastSnapshot *gpuSnapshot, deltaCount uint32) float64 {
maxEngineUsage := 0.0
for name, engine := range gpu.Engines {
var deltaEngine float64
if lastSnapshot != nil && lastSnapshot.engines != nil {
deltaEngine = engine - lastSnapshot.engines[name]
} else {
deltaEngine = engine
}
gpuAvg.Engines[name] = twoDecimals(deltaEngine / float64(deltaCount))
maxEngineUsage = max(maxEngineUsage, deltaEngine/float64(deltaCount))
}
return twoDecimals(maxEngineUsage)
}
// updateInstantaneousValues updates values that should reflect current state, not averages
func (gm *GPUManager) updateInstantaneousValues(gpuAvg *system.GPUData, gpu *system.GPUData) {
gpuAvg.Temperature = twoDecimals(gpu.Temperature)
gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed)
gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal)
}
// storeSnapshot saves the current GPU state for this cache key
func (gm *GPUManager) storeSnapshot(id string, gpu *system.GPUData, cacheKey uint16) {
snapshot := &gpuSnapshot{
count: uint32(gpu.Count),
usage: gpu.Usage,
power: gpu.Power,
powerPkg: gpu.PowerPkg,
}
if gpu.Engines != nil {
snapshot.engines = make(map[string]float64, len(gpu.Engines))
maps.Copy(snapshot.engines, gpu.Engines)
}
gm.lastSnapshots[cacheKey][id] = snapshot
}
// detectGPUs checks for the presence of GPU management tools (nvidia-smi, rocm-smi, tegrastats)
// in the system path. It sets the corresponding flags in the GPUManager struct if any of these
// tools are found. If none of the tools are found, it returns an error indicating that no GPU

View File

@@ -332,7 +332,7 @@ func TestParseJetsonData(t *testing.T) {
}
func TestGetCurrentData(t *testing.T) {
t.Run("calculates averages and resets accumulators", func(t *testing.T) {
t.Run("calculates averages with per-cache-key delta tracking", func(t *testing.T) {
gm := &GPUManager{
GpuDataMap: map[string]*system.GPUData{
"0": {
@@ -365,7 +365,8 @@ func TestGetCurrentData(t *testing.T) {
},
}
result := gm.GetCurrentData()
cacheKey := uint16(5000)
result := gm.GetCurrentData(cacheKey)
// Verify name disambiguation
assert.Equal(t, "GPU1 0", result["0"].Name)
@@ -378,13 +379,19 @@ func TestGetCurrentData(t *testing.T) {
assert.InDelta(t, 30.0, result["1"].Usage, 0.01)
assert.InDelta(t, 60.0, result["1"].Power, 0.01)
// Verify that accumulators in the original map are reset
assert.EqualValues(t, float64(1), gm.GpuDataMap["0"].Count, "GPU 0 Count should be reset")
assert.EqualValues(t, float64(50.0), gm.GpuDataMap["0"].Usage, "GPU 0 Usage should be reset")
assert.Equal(t, float64(100.0), gm.GpuDataMap["0"].Power, "GPU 0 Power should be reset")
assert.Equal(t, float64(1), gm.GpuDataMap["1"].Count, "GPU 1 Count should be reset")
assert.Equal(t, float64(30), gm.GpuDataMap["1"].Usage, "GPU 1 Usage should be reset")
assert.Equal(t, float64(60), gm.GpuDataMap["1"].Power, "GPU 1 Power should be reset")
// Verify that accumulators in the original map are NOT reset (they keep growing)
assert.EqualValues(t, 2, gm.GpuDataMap["0"].Count, "GPU 0 Count should remain at 2")
assert.EqualValues(t, 100, gm.GpuDataMap["0"].Usage, "GPU 0 Usage should remain at 100")
assert.Equal(t, 200.0, gm.GpuDataMap["0"].Power, "GPU 0 Power should remain at 200")
assert.Equal(t, 1.0, gm.GpuDataMap["1"].Count, "GPU 1 Count should remain at 1")
assert.Equal(t, 30.0, gm.GpuDataMap["1"].Usage, "GPU 1 Usage should remain at 30")
assert.Equal(t, 60.0, gm.GpuDataMap["1"].Power, "GPU 1 Power should remain at 60")
// Verify snapshots were stored for this cache key
assert.NotNil(t, gm.lastSnapshots[cacheKey]["0"])
assert.Equal(t, uint32(2), gm.lastSnapshots[cacheKey]["0"].count)
assert.Equal(t, 100.0, gm.lastSnapshots[cacheKey]["0"].usage)
assert.Equal(t, 200.0, gm.lastSnapshots[cacheKey]["0"].power)
})
t.Run("handles zero count without panicking", func(t *testing.T) {
@@ -399,17 +406,543 @@ func TestGetCurrentData(t *testing.T) {
},
}
cacheKey := uint16(5000)
var result map[string]system.GPUData
assert.NotPanics(t, func() {
result = gm.GetCurrentData()
result = gm.GetCurrentData(cacheKey)
})
// Check that usage and power are 0
assert.Equal(t, 0.0, result["0"].Usage)
assert.Equal(t, 0.0, result["0"].Power)
// Verify reset count
assert.EqualValues(t, 1, gm.GpuDataMap["0"].Count)
// Verify count remains 0
assert.EqualValues(t, 0, gm.GpuDataMap["0"].Count)
})
t.Run("uses last average when no new data arrives", func(t *testing.T) {
gm := &GPUManager{
GpuDataMap: map[string]*system.GPUData{
"0": {
Name: "TestGPU",
Temperature: 55.0,
MemoryUsed: 1500,
MemoryTotal: 8000,
Usage: 100, // Will average to 50
Power: 200, // Will average to 100
Count: 2,
},
},
}
cacheKey := uint16(5000)
// First collection - should calculate averages and store them
result1 := gm.GetCurrentData(cacheKey)
assert.InDelta(t, 50.0, result1["0"].Usage, 0.01)
assert.InDelta(t, 100.0, result1["0"].Power, 0.01)
assert.EqualValues(t, 2, gm.GpuDataMap["0"].Count, "Count should remain at 2")
// Update temperature but no new usage/power data (count stays same)
gm.GpuDataMap["0"].Temperature = 60.0
gm.GpuDataMap["0"].MemoryUsed = 1600
// Second collection - should use last averages since count hasn't changed (delta = 0)
result2 := gm.GetCurrentData(cacheKey)
assert.InDelta(t, 50.0, result2["0"].Usage, 0.01, "Should use last average")
assert.InDelta(t, 100.0, result2["0"].Power, 0.01, "Should use last average")
assert.InDelta(t, 60.0, result2["0"].Temperature, 0.01, "Should use current temperature")
assert.InDelta(t, 1600.0, result2["0"].MemoryUsed, 0.01, "Should use current memory")
assert.EqualValues(t, 2, gm.GpuDataMap["0"].Count, "Count should still be 2")
})
t.Run("tracks separate averages per cache key", func(t *testing.T) {
gm := &GPUManager{
GpuDataMap: map[string]*system.GPUData{
"0": {
Name: "TestGPU",
Temperature: 55.0,
MemoryUsed: 1500,
MemoryTotal: 8000,
Usage: 100, // Initial: 100 over 2 counts = 50 avg
Power: 200, // Initial: 200 over 2 counts = 100 avg
Count: 2,
},
},
}
cacheKey1 := uint16(5000)
cacheKey2 := uint16(10000)
// First check with cacheKey1 - baseline
result1 := gm.GetCurrentData(cacheKey1)
assert.InDelta(t, 50.0, result1["0"].Usage, 0.01, "CacheKey1: Initial average should be 50")
assert.InDelta(t, 100.0, result1["0"].Power, 0.01, "CacheKey1: Initial average should be 100")
// Simulate GPU activity - accumulate more data
gm.GpuDataMap["0"].Usage += 60 // Now total: 160
gm.GpuDataMap["0"].Power += 150 // Now total: 350
gm.GpuDataMap["0"].Count += 3 // Now total: 5
// Check with cacheKey1 again - should get delta since last cacheKey1 check
result2 := gm.GetCurrentData(cacheKey1)
assert.InDelta(t, 20.0, result2["0"].Usage, 0.01, "CacheKey1: Delta average should be 60/3 = 20")
assert.InDelta(t, 50.0, result2["0"].Power, 0.01, "CacheKey1: Delta average should be 150/3 = 50")
// Check with cacheKey2 for the first time - should get average since beginning
result3 := gm.GetCurrentData(cacheKey2)
assert.InDelta(t, 32.0, result3["0"].Usage, 0.01, "CacheKey2: Total average should be 160/5 = 32")
assert.InDelta(t, 70.0, result3["0"].Power, 0.01, "CacheKey2: Total average should be 350/5 = 70")
// Simulate more GPU activity
gm.GpuDataMap["0"].Usage += 80 // Now total: 240
gm.GpuDataMap["0"].Power += 160 // Now total: 510
gm.GpuDataMap["0"].Count += 2 // Now total: 7
// Check with cacheKey1 - should get delta since last cacheKey1 check
result4 := gm.GetCurrentData(cacheKey1)
assert.InDelta(t, 40.0, result4["0"].Usage, 0.01, "CacheKey1: New delta average should be 80/2 = 40")
assert.InDelta(t, 80.0, result4["0"].Power, 0.01, "CacheKey1: New delta average should be 160/2 = 80")
// Check with cacheKey2 - should get delta since last cacheKey2 check
result5 := gm.GetCurrentData(cacheKey2)
assert.InDelta(t, 40.0, result5["0"].Usage, 0.01, "CacheKey2: Delta average should be 80/2 = 40")
assert.InDelta(t, 80.0, result5["0"].Power, 0.01, "CacheKey2: Delta average should be 160/2 = 80")
// Verify snapshots exist for both cache keys
assert.NotNil(t, gm.lastSnapshots[cacheKey1])
assert.NotNil(t, gm.lastSnapshots[cacheKey2])
assert.NotNil(t, gm.lastSnapshots[cacheKey1]["0"])
assert.NotNil(t, gm.lastSnapshots[cacheKey2]["0"])
})
}
func TestCalculateDeltaCount(t *testing.T) {
gm := &GPUManager{}
t.Run("with no previous snapshot", func(t *testing.T) {
delta := gm.calculateDeltaCount(10, nil)
assert.Equal(t, uint32(10), delta, "Should return current count when no snapshot exists")
})
t.Run("with previous snapshot", func(t *testing.T) {
snapshot := &gpuSnapshot{count: 5}
delta := gm.calculateDeltaCount(15, snapshot)
assert.Equal(t, uint32(10), delta, "Should return difference between current and snapshot")
})
t.Run("with same count", func(t *testing.T) {
snapshot := &gpuSnapshot{count: 10}
delta := gm.calculateDeltaCount(10, snapshot)
assert.Equal(t, uint32(0), delta, "Should return zero when count hasn't changed")
})
}
func TestCalculateDeltas(t *testing.T) {
gm := &GPUManager{}
t.Run("with no previous snapshot", func(t *testing.T) {
gpu := &system.GPUData{
Usage: 100.5,
Power: 250.75,
PowerPkg: 300.25,
}
deltaUsage, deltaPower, deltaPowerPkg := gm.calculateDeltas(gpu, nil)
assert.Equal(t, 100.5, deltaUsage)
assert.Equal(t, 250.75, deltaPower)
assert.Equal(t, 300.25, deltaPowerPkg)
})
t.Run("with previous snapshot", func(t *testing.T) {
gpu := &system.GPUData{
Usage: 150.5,
Power: 300.75,
PowerPkg: 400.25,
}
snapshot := &gpuSnapshot{
usage: 100.5,
power: 250.75,
powerPkg: 300.25,
}
deltaUsage, deltaPower, deltaPowerPkg := gm.calculateDeltas(gpu, snapshot)
assert.InDelta(t, 50.0, deltaUsage, 0.01)
assert.InDelta(t, 50.0, deltaPower, 0.01)
assert.InDelta(t, 100.0, deltaPowerPkg, 0.01)
})
}
func TestCalculateIntelGPUUsage(t *testing.T) {
gm := &GPUManager{}
t.Run("with no previous snapshot", func(t *testing.T) {
gpuAvg := &system.GPUData{
Engines: make(map[string]float64),
}
gpu := &system.GPUData{
Engines: map[string]float64{
"Render/3D": 80.0,
"Video": 40.0,
"Compute": 60.0,
},
}
maxUsage := gm.calculateIntelGPUUsage(gpuAvg, gpu, nil, 2)
assert.Equal(t, 40.0, maxUsage, "Should return max engine usage (80/2=40)")
assert.Equal(t, 40.0, gpuAvg.Engines["Render/3D"])
assert.Equal(t, 20.0, gpuAvg.Engines["Video"])
assert.Equal(t, 30.0, gpuAvg.Engines["Compute"])
})
t.Run("with previous snapshot", func(t *testing.T) {
gpuAvg := &system.GPUData{
Engines: make(map[string]float64),
}
gpu := &system.GPUData{
Engines: map[string]float64{
"Render/3D": 180.0,
"Video": 100.0,
"Compute": 140.0,
},
}
snapshot := &gpuSnapshot{
engines: map[string]float64{
"Render/3D": 80.0,
"Video": 40.0,
"Compute": 60.0,
},
}
maxUsage := gm.calculateIntelGPUUsage(gpuAvg, gpu, snapshot, 5)
// Deltas: Render/3D=100, Video=60, Compute=80 over 5 counts
assert.Equal(t, 20.0, maxUsage, "Should return max engine delta (100/5=20)")
assert.Equal(t, 20.0, gpuAvg.Engines["Render/3D"])
assert.Equal(t, 12.0, gpuAvg.Engines["Video"])
assert.Equal(t, 16.0, gpuAvg.Engines["Compute"])
})
t.Run("handles missing engine in snapshot", func(t *testing.T) {
gpuAvg := &system.GPUData{
Engines: make(map[string]float64),
}
gpu := &system.GPUData{
Engines: map[string]float64{
"Render/3D": 100.0,
"NewEngine": 50.0,
},
}
snapshot := &gpuSnapshot{
engines: map[string]float64{
"Render/3D": 80.0,
// NewEngine doesn't exist in snapshot
},
}
maxUsage := gm.calculateIntelGPUUsage(gpuAvg, gpu, snapshot, 2)
assert.Equal(t, 25.0, maxUsage)
assert.Equal(t, 10.0, gpuAvg.Engines["Render/3D"], "Should use delta for existing engine")
assert.Equal(t, 25.0, gpuAvg.Engines["NewEngine"], "Should use full value for new engine")
})
}
func TestUpdateInstantaneousValues(t *testing.T) {
gm := &GPUManager{}
t.Run("updates temperature, memory used and total", func(t *testing.T) {
gpuAvg := &system.GPUData{
Temperature: 50.123,
MemoryUsed: 1000.456,
MemoryTotal: 8000.789,
}
gpu := &system.GPUData{
Temperature: 75.567,
MemoryUsed: 2500.891,
MemoryTotal: 8192.234,
}
gm.updateInstantaneousValues(gpuAvg, gpu)
assert.Equal(t, 75.57, gpuAvg.Temperature, "Should update and round temperature")
assert.Equal(t, 2500.89, gpuAvg.MemoryUsed, "Should update and round memory used")
assert.Equal(t, 8192.23, gpuAvg.MemoryTotal, "Should update and round memory total")
})
}
func TestStoreSnapshot(t *testing.T) {
gm := &GPUManager{
lastSnapshots: make(map[uint16]map[string]*gpuSnapshot),
}
t.Run("stores standard GPU snapshot", func(t *testing.T) {
cacheKey := uint16(5000)
gm.lastSnapshots[cacheKey] = make(map[string]*gpuSnapshot)
gpu := &system.GPUData{
Count: 10.0,
Usage: 150.5,
Power: 250.75,
PowerPkg: 300.25,
}
gm.storeSnapshot("0", gpu, cacheKey)
snapshot := gm.lastSnapshots[cacheKey]["0"]
assert.NotNil(t, snapshot)
assert.Equal(t, uint32(10), snapshot.count)
assert.Equal(t, 150.5, snapshot.usage)
assert.Equal(t, 250.75, snapshot.power)
assert.Equal(t, 300.25, snapshot.powerPkg)
assert.Nil(t, snapshot.engines, "Should not have engines for standard GPU")
})
t.Run("stores Intel GPU snapshot with engines", func(t *testing.T) {
cacheKey := uint16(10000)
gm.lastSnapshots[cacheKey] = make(map[string]*gpuSnapshot)
gpu := &system.GPUData{
Count: 5.0,
Usage: 100.0,
Power: 200.0,
PowerPkg: 250.0,
Engines: map[string]float64{
"Render/3D": 80.0,
"Video": 40.0,
},
}
gm.storeSnapshot("0", gpu, cacheKey)
snapshot := gm.lastSnapshots[cacheKey]["0"]
assert.NotNil(t, snapshot)
assert.Equal(t, uint32(5), snapshot.count)
assert.NotNil(t, snapshot.engines, "Should have engines for Intel GPU")
assert.Equal(t, 80.0, snapshot.engines["Render/3D"])
assert.Equal(t, 40.0, snapshot.engines["Video"])
assert.Len(t, snapshot.engines, 2)
})
t.Run("overwrites existing snapshot", func(t *testing.T) {
cacheKey := uint16(5000)
gm.lastSnapshots[cacheKey] = make(map[string]*gpuSnapshot)
// Store initial snapshot
gpu1 := &system.GPUData{Count: 5.0, Usage: 100.0, Power: 200.0}
gm.storeSnapshot("0", gpu1, cacheKey)
// Store updated snapshot
gpu2 := &system.GPUData{Count: 10.0, Usage: 250.0, Power: 400.0}
gm.storeSnapshot("0", gpu2, cacheKey)
snapshot := gm.lastSnapshots[cacheKey]["0"]
assert.Equal(t, uint32(10), snapshot.count, "Should overwrite previous count")
assert.Equal(t, 250.0, snapshot.usage, "Should overwrite previous usage")
assert.Equal(t, 400.0, snapshot.power, "Should overwrite previous power")
})
}
func TestCountGPUNames(t *testing.T) {
t.Run("returns empty map for no GPUs", func(t *testing.T) {
gm := &GPUManager{
GpuDataMap: make(map[string]*system.GPUData),
}
counts := gm.countGPUNames()
assert.Empty(t, counts)
})
t.Run("counts unique GPU names", func(t *testing.T) {
gm := &GPUManager{
GpuDataMap: map[string]*system.GPUData{
"0": {Name: "GPU A"},
"1": {Name: "GPU B"},
"2": {Name: "GPU C"},
},
}
counts := gm.countGPUNames()
assert.Equal(t, 1, counts["GPU A"])
assert.Equal(t, 1, counts["GPU B"])
assert.Equal(t, 1, counts["GPU C"])
assert.Len(t, counts, 3)
})
t.Run("counts duplicate GPU names", func(t *testing.T) {
gm := &GPUManager{
GpuDataMap: map[string]*system.GPUData{
"0": {Name: "RTX 4090"},
"1": {Name: "RTX 4090"},
"2": {Name: "RTX 4090"},
"3": {Name: "RTX 3080"},
},
}
counts := gm.countGPUNames()
assert.Equal(t, 3, counts["RTX 4090"])
assert.Equal(t, 1, counts["RTX 3080"])
assert.Len(t, counts, 2)
})
}
func TestInitializeSnapshots(t *testing.T) {
t.Run("initializes all maps from scratch", func(t *testing.T) {
gm := &GPUManager{}
cacheKey := uint16(5000)
gm.initializeSnapshots(cacheKey)
assert.NotNil(t, gm.lastAvgData)
assert.NotNil(t, gm.lastSnapshots)
assert.NotNil(t, gm.lastSnapshots[cacheKey])
})
t.Run("initializes only missing maps", func(t *testing.T) {
gm := &GPUManager{
lastAvgData: make(map[string]system.GPUData),
}
cacheKey := uint16(5000)
gm.initializeSnapshots(cacheKey)
assert.NotNil(t, gm.lastAvgData, "Should preserve existing lastAvgData")
assert.NotNil(t, gm.lastSnapshots)
assert.NotNil(t, gm.lastSnapshots[cacheKey])
})
t.Run("adds new cache key to existing snapshots", func(t *testing.T) {
existingKey := uint16(5000)
newKey := uint16(10000)
gm := &GPUManager{
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
existingKey: {"0": {count: 10}},
},
}
gm.initializeSnapshots(newKey)
assert.NotNil(t, gm.lastSnapshots[existingKey], "Should preserve existing cache key")
assert.NotNil(t, gm.lastSnapshots[newKey], "Should add new cache key")
assert.NotNil(t, gm.lastSnapshots[existingKey]["0"], "Should preserve existing snapshot data")
})
}
func TestCalculateGPUAverage(t *testing.T) {
t.Run("returns zero value when deltaCount is zero", func(t *testing.T) {
gm := &GPUManager{
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
5000: {
"0": {count: 10, usage: 100, power: 200},
},
},
lastAvgData: map[string]system.GPUData{
"0": {Usage: 50.0, Power: 100.0},
},
}
gpu := &system.GPUData{
Count: 10.0, // Same as snapshot, so delta = 0
Usage: 100.0,
Power: 200.0,
}
result := gm.calculateGPUAverage("0", gpu, 5000)
assert.Equal(t, 50.0, result.Usage, "Should return cached average")
assert.Equal(t, 100.0, result.Power, "Should return cached average")
})
t.Run("calculates average for standard GPU", func(t *testing.T) {
gm := &GPUManager{
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
5000: {},
},
lastAvgData: make(map[string]system.GPUData),
}
gpu := &system.GPUData{
Name: "Test GPU",
Count: 4.0,
Usage: 200.0, // 200 / 4 = 50
Power: 400.0, // 400 / 4 = 100
}
result := gm.calculateGPUAverage("0", gpu, 5000)
assert.Equal(t, 50.0, result.Usage)
assert.Equal(t, 100.0, result.Power)
assert.Equal(t, "Test GPU", result.Name)
})
t.Run("calculates average for Intel GPU with engines", func(t *testing.T) {
gm := &GPUManager{
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
5000: {},
},
lastAvgData: make(map[string]system.GPUData),
}
gpu := &system.GPUData{
Name: "Intel GPU",
Count: 5.0,
Power: 500.0,
PowerPkg: 600.0,
Engines: map[string]float64{
"Render/3D": 100.0, // 100 / 5 = 20
"Video": 50.0, // 50 / 5 = 10
},
}
result := gm.calculateGPUAverage("0", gpu, 5000)
assert.Equal(t, 100.0, result.Power)
assert.Equal(t, 120.0, result.PowerPkg)
assert.Equal(t, 20.0, result.Usage, "Should use max engine usage")
assert.Equal(t, 20.0, result.Engines["Render/3D"])
assert.Equal(t, 10.0, result.Engines["Video"])
})
t.Run("calculates delta from previous snapshot", func(t *testing.T) {
gm := &GPUManager{
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
5000: {
"0": {
count: 2,
usage: 50.0,
power: 100.0,
powerPkg: 120.0,
},
},
},
lastAvgData: make(map[string]system.GPUData),
}
gpu := &system.GPUData{
Name: "Test GPU",
Count: 7.0, // Delta = 7 - 2 = 5
Usage: 200.0, // Delta = 200 - 50 = 150, avg = 150/5 = 30
Power: 350.0, // Delta = 350 - 100 = 250, avg = 250/5 = 50
PowerPkg: 420.0, // Delta = 420 - 120 = 300, avg = 300/5 = 60
}
result := gm.calculateGPUAverage("0", gpu, 5000)
assert.Equal(t, 30.0, result.Usage)
assert.Equal(t, 50.0, result.Power)
})
t.Run("stores result in lastAvgData", func(t *testing.T) {
gm := &GPUManager{
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
5000: {},
},
lastAvgData: make(map[string]system.GPUData),
}
gpu := &system.GPUData{
Count: 2.0,
Usage: 100.0,
Power: 200.0,
}
result := gm.calculateGPUAverage("0", gpu, 5000)
assert.Equal(t, result, gm.lastAvgData["0"], "Should store calculated average")
})
}
@@ -765,7 +1298,8 @@ func TestAccumulation(t *testing.T) {
}
// Verify average calculation in GetCurrentData
result := gm.GetCurrentData()
cacheKey := uint16(5000)
result := gm.GetCurrentData(cacheKey)
for id, expected := range tt.expectedValues {
gpu, exists := result[id]
assert.True(t, exists, "GPU with ID %s should exist in GetCurrentData result", id)
@@ -778,16 +1312,16 @@ func TestAccumulation(t *testing.T) {
assert.EqualValues(t, expected.avgPower, gpu.Power, "Average power in GetCurrentData should match")
}
// Verify that accumulators in the original map are reset
// Verify that accumulators in the original map are NOT reset (they keep growing)
for id, expected := range tt.expectedValues {
gpu, exists := gm.GpuDataMap[id]
assert.True(t, exists, "GPU with ID %s should still exist after GetCurrentData", id)
if !exists {
continue
}
assert.EqualValues(t, 1, gpu.Count, "Count should be reset for GPU ID %s", id)
assert.EqualValues(t, expected.avgUsage, gpu.Usage, "Usage should be reset for GPU ID %s", id)
assert.EqualValues(t, expected.avgPower, gpu.Power, "Power should be reset for GPU ID %s", id)
assert.EqualValues(t, expected.count, gpu.Count, "Count should remain at accumulated value for GPU ID %s", id)
assert.EqualValues(t, expected.usage, gpu.Usage, "Usage should remain at accumulated value for GPU ID %s", id)
assert.EqualValues(t, expected.power, gpu.Power, "Power should remain at accumulated value for GPU ID %s", id)
}
})
}

101
agent/handlers.go Normal file
View File

@@ -0,0 +1,101 @@
package agent
import (
"errors"
"fmt"
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common"
)
// HandlerContext provides context for request handlers
type HandlerContext struct {
Client *WebSocketClient
Agent *Agent
Request *common.HubRequest[cbor.RawMessage]
RequestID *uint32
HubVerified bool
// SendResponse abstracts how a handler sends responses (WS or SSH)
SendResponse func(data any, requestID *uint32) error
}
// RequestHandler defines the interface for handling specific websocket request types
type RequestHandler interface {
// Handle processes the request and returns an error if unsuccessful
Handle(hctx *HandlerContext) error
}
// Responder sends handler responses back to the hub (over WS or SSH)
type Responder interface {
SendResponse(data any, requestID *uint32) error
}
// HandlerRegistry manages the mapping between actions and their handlers
type HandlerRegistry struct {
handlers map[common.WebSocketAction]RequestHandler
}
// NewHandlerRegistry creates a new handler registry with default handlers
func NewHandlerRegistry() *HandlerRegistry {
registry := &HandlerRegistry{
handlers: make(map[common.WebSocketAction]RequestHandler),
}
registry.Register(common.GetData, &GetDataHandler{})
registry.Register(common.CheckFingerprint, &CheckFingerprintHandler{})
return registry
}
// Register registers a handler for a specific action type
func (hr *HandlerRegistry) Register(action common.WebSocketAction, handler RequestHandler) {
hr.handlers[action] = handler
}
// Handle routes the request to the appropriate handler
func (hr *HandlerRegistry) Handle(hctx *HandlerContext) error {
handler, exists := hr.handlers[hctx.Request.Action]
if !exists {
return fmt.Errorf("unknown action: %d", hctx.Request.Action)
}
// Check verification requirement - default to requiring verification
if hctx.Request.Action != common.CheckFingerprint && !hctx.HubVerified {
return errors.New("hub not verified")
}
// Log handler execution for debugging
// slog.Debug("Executing handler", "action", hctx.Request.Action)
return handler.Handle(hctx)
}
// GetHandler returns the handler for a specific action
func (hr *HandlerRegistry) GetHandler(action common.WebSocketAction) (RequestHandler, bool) {
handler, exists := hr.handlers[action]
return handler, exists
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// GetDataHandler handles system data requests
type GetDataHandler struct{}
func (h *GetDataHandler) Handle(hctx *HandlerContext) error {
var options common.DataRequestOptions
_ = cbor.Unmarshal(hctx.Request.Data, &options)
sysStats := hctx.Agent.gatherStats(options.CacheTimeMs)
return hctx.SendResponse(sysStats, hctx.RequestID)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// CheckFingerprintHandler handles authentication challenges
type CheckFingerprintHandler struct{}
func (h *CheckFingerprintHandler) Handle(hctx *HandlerContext) error {
return hctx.Client.handleAuthChallenge(hctx.Request, hctx.RequestID)
}

112
agent/handlers_test.go Normal file
View File

@@ -0,0 +1,112 @@
//go:build testing
// +build testing
package agent
import (
"testing"
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common"
"github.com/stretchr/testify/assert"
)
// MockHandler for testing
type MockHandler struct {
requiresVerification bool
description string
handleFunc func(ctx *HandlerContext) error
}
func (m *MockHandler) Handle(ctx *HandlerContext) error {
if m.handleFunc != nil {
return m.handleFunc(ctx)
}
return nil
}
func (m *MockHandler) RequiresVerification() bool {
return m.requiresVerification
}
// TestHandlerRegistry tests the handler registry functionality
func TestHandlerRegistry(t *testing.T) {
t.Run("default registration", func(t *testing.T) {
registry := NewHandlerRegistry()
// Check default handlers are registered
getDataHandler, exists := registry.GetHandler(common.GetData)
assert.True(t, exists)
assert.IsType(t, &GetDataHandler{}, getDataHandler)
fingerprintHandler, exists := registry.GetHandler(common.CheckFingerprint)
assert.True(t, exists)
assert.IsType(t, &CheckFingerprintHandler{}, fingerprintHandler)
})
t.Run("custom handler registration", func(t *testing.T) {
registry := NewHandlerRegistry()
mockHandler := &MockHandler{
requiresVerification: true,
description: "Test handler",
}
// Register a custom handler for a mock action
const mockAction common.WebSocketAction = 99
registry.Register(mockAction, mockHandler)
// Verify registration
handler, exists := registry.GetHandler(mockAction)
assert.True(t, exists)
assert.Equal(t, mockHandler, handler)
})
t.Run("unknown action", func(t *testing.T) {
registry := NewHandlerRegistry()
ctx := &HandlerContext{
Request: &common.HubRequest[cbor.RawMessage]{
Action: common.WebSocketAction(255), // Unknown action
},
HubVerified: true,
}
err := registry.Handle(ctx)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unknown action: 255")
})
t.Run("verification required", func(t *testing.T) {
registry := NewHandlerRegistry()
ctx := &HandlerContext{
Request: &common.HubRequest[cbor.RawMessage]{
Action: common.GetData, // Requires verification
},
HubVerified: false, // Not verified
}
err := registry.Handle(ctx)
assert.Error(t, err)
assert.Contains(t, err.Error(), "hub not verified")
})
}
// TestCheckFingerprintHandler tests the CheckFingerprint handler
func TestCheckFingerprintHandler(t *testing.T) {
handler := &CheckFingerprintHandler{}
t.Run("handle with invalid data", func(t *testing.T) {
client := &WebSocketClient{}
ctx := &HandlerContext{
Client: client,
HubVerified: false,
Request: &common.HubRequest[cbor.RawMessage]{
Action: common.CheckFingerprint,
Data: cbor.RawMessage{}, // Empty/invalid data
},
}
// Should fail to decode the fingerprint request
err := handler.Handle(ctx)
assert.Error(t, err)
})
}

View File

@@ -12,8 +12,6 @@ import (
psutilNet "github.com/shirou/gopsutil/v4/net"
)
var netInterfaceDeltaTracker = deltatracker.NewDeltaTracker[string, uint64]()
// NicConfig controls inclusion/exclusion of network interfaces via the NICS env var
//
// Behavior mirrors SensorConfig's matching logic:
@@ -77,75 +75,17 @@ func isValidNic(nicName string, cfg *NicConfig) bool {
return cfg.isBlacklist
}
func (a *Agent) updateNetworkStats(systemStats *system.Stats) {
func (a *Agent) updateNetworkStats(cacheTimeMs uint16, systemStats *system.Stats) {
// network 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()
}
a.ensureNetInterfacesInitialized()
if systemStats.NetworkInterfaces == nil {
systemStats.NetworkInterfaces = make(map[string][4]uint64, 0)
}
a.ensureNetworkInterfacesMap(systemStats)
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)
netInterfaceDeltaTracker.Cycle()
// sum all bytes sent and received
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
// track deltas for each network interface
var upDelta, downDelta uint64
upKey, downKey := fmt.Sprintf("%sup", v.Name), fmt.Sprintf("%sdown", v.Name)
netInterfaceDeltaTracker.Set(upKey, v.BytesSent)
netInterfaceDeltaTracker.Set(downKey, v.BytesRecv)
if msElapsed > 0 {
upDelta = netInterfaceDeltaTracker.Delta(upKey) * 1000 / msElapsed
downDelta = netInterfaceDeltaTracker.Delta(downKey) * 1000 / msElapsed
}
// add interface to systemStats
systemStats.NetworkInterfaces[v.Name] = [4]uint64{upDelta, downDelta, v.BytesSent, 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
}
slog.Info(v.Name, "recv", v.BytesRecv, "sent", v.BytesSent)
}
// 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
}
nis, msElapsed := a.loadAndTickNetBaseline(cacheTimeMs)
totalBytesSent, totalBytesRecv := a.sumAndTrackPerNicDeltas(cacheTimeMs, msElapsed, netIO, systemStats)
bytesSentPerSecond, bytesRecvPerSecond := a.computeBytesPerSecond(msElapsed, totalBytesSent, totalBytesRecv, nis)
a.applyNetworkTotals(cacheTimeMs, netIO, systemStats, nis, totalBytesSent, totalBytesRecv, bytesSentPerSecond, bytesRecvPerSecond)
}
}
@@ -160,13 +100,8 @@ func (a *Agent) initializeNetIoStats() {
nicCfg = newNicConfig(nicsEnvVal)
}
// reset network I/O stats
a.netIoStats.BytesSent = 0
a.netIoStats.BytesRecv = 0
// get intial network I/O stats
// get current network I/O stats and record valid interfaces
if netIO, err := psutilNet.IOCounters(true); err == nil {
a.netIoStats.Time = time.Now()
for _, v := range netIO {
if nicsEnvExists && !isValidNic(v.Name, nicCfg) {
continue
@@ -175,12 +110,136 @@ func (a *Agent) initializeNetIoStats() {
continue
}
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{}{}
}
}
// Reset per-cache-time trackers and baselines so they will reinitialize on next use
a.netInterfaceDeltaTrackers = make(map[uint16]*deltatracker.DeltaTracker[string, uint64])
a.netIoStats = make(map[uint16]system.NetIoStats)
}
// ensureNetInterfacesInitialized re-initializes NICs if none are currently tracked
func (a *Agent) ensureNetInterfacesInitialized() {
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()
}
}
// ensureNetworkInterfacesMap ensures systemStats.NetworkInterfaces map exists
func (a *Agent) ensureNetworkInterfacesMap(systemStats *system.Stats) {
if systemStats.NetworkInterfaces == nil {
systemStats.NetworkInterfaces = make(map[string][4]uint64, 0)
}
}
// loadAndTickNetBaseline returns the NetIoStats baseline and milliseconds elapsed, updating time
func (a *Agent) loadAndTickNetBaseline(cacheTimeMs uint16) (netIoStat system.NetIoStats, msElapsed uint64) {
netIoStat = a.netIoStats[cacheTimeMs]
if netIoStat.Time.IsZero() {
netIoStat.Time = time.Now()
msElapsed = 0
} else {
msElapsed = uint64(time.Since(netIoStat.Time).Milliseconds())
netIoStat.Time = time.Now()
}
return netIoStat, msElapsed
}
// sumAndTrackPerNicDeltas accumulates totals and records per-NIC up/down deltas into systemStats
func (a *Agent) sumAndTrackPerNicDeltas(cacheTimeMs uint16, msElapsed uint64, netIO []psutilNet.IOCountersStat, systemStats *system.Stats) (totalBytesSent, totalBytesRecv uint64) {
tracker := a.netInterfaceDeltaTrackers[cacheTimeMs]
if tracker == nil {
tracker = deltatracker.NewDeltaTracker[string, uint64]()
a.netInterfaceDeltaTrackers[cacheTimeMs] = tracker
}
tracker.Cycle()
for _, v := range netIO {
if _, exists := a.netInterfaces[v.Name]; !exists {
continue
}
totalBytesSent += v.BytesSent
totalBytesRecv += v.BytesRecv
var upDelta, downDelta uint64
upKey, downKey := fmt.Sprintf("%sup", v.Name), fmt.Sprintf("%sdown", v.Name)
tracker.Set(upKey, v.BytesSent)
tracker.Set(downKey, v.BytesRecv)
if msElapsed > 0 {
if prevVal, ok := tracker.Previous(upKey); ok {
var deltaBytes uint64
if v.BytesSent >= prevVal {
deltaBytes = v.BytesSent - prevVal
} else {
deltaBytes = v.BytesSent
}
upDelta = deltaBytes * 1000 / msElapsed
}
if prevVal, ok := tracker.Previous(downKey); ok {
var deltaBytes uint64
if v.BytesRecv >= prevVal {
deltaBytes = v.BytesRecv - prevVal
} else {
deltaBytes = v.BytesRecv
}
downDelta = deltaBytes * 1000 / msElapsed
}
}
systemStats.NetworkInterfaces[v.Name] = [4]uint64{upDelta, downDelta, v.BytesSent, v.BytesRecv}
}
return totalBytesSent, totalBytesRecv
}
// computeBytesPerSecond calculates per-second totals from elapsed time and totals
func (a *Agent) computeBytesPerSecond(msElapsed, totalBytesSent, totalBytesRecv uint64, nis system.NetIoStats) (bytesSentPerSecond, bytesRecvPerSecond uint64) {
if msElapsed > 0 {
bytesSentPerSecond = (totalBytesSent - nis.BytesSent) * 1000 / msElapsed
bytesRecvPerSecond = (totalBytesRecv - nis.BytesRecv) * 1000 / msElapsed
}
return bytesSentPerSecond, bytesRecvPerSecond
}
// applyNetworkTotals validates and writes computed network stats, or resets on anomaly
func (a *Agent) applyNetworkTotals(
cacheTimeMs uint16,
netIO []psutilNet.IOCountersStat,
systemStats *system.Stats,
nis system.NetIoStats,
totalBytesSent, totalBytesRecv uint64,
bytesSentPerSecond, bytesRecvPerSecond uint64,
) {
networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond))
networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond))
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)
}
a.initializeNetIoStats()
delete(a.netIoStats, cacheTimeMs)
delete(a.netInterfaceDeltaTrackers, cacheTimeMs)
systemStats.NetworkSent = 0
systemStats.NetworkRecv = 0
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = 0, 0
return
}
systemStats.NetworkSent = networkSentPs
systemStats.NetworkRecv = networkRecvPs
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
nis.BytesSent = totalBytesSent
nis.BytesRecv = totalBytesRecv
a.netIoStats[cacheTimeMs] = nis
}
func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool {

View File

@@ -4,7 +4,11 @@ package agent
import (
"testing"
"time"
"github.com/henrygd/beszel/agent/deltatracker"
"github.com/henrygd/beszel/internal/entities/system"
psutilNet "github.com/shirou/gopsutil/v4/net"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -257,3 +261,242 @@ func TestNewNicConfig(t *testing.T) {
})
}
}
func TestEnsureNetworkInterfacesMap(t *testing.T) {
var a Agent
var stats system.Stats
// Initially nil
assert.Nil(t, stats.NetworkInterfaces)
// Ensure map is created
a.ensureNetworkInterfacesMap(&stats)
assert.NotNil(t, stats.NetworkInterfaces)
// Idempotent
a.ensureNetworkInterfacesMap(&stats)
assert.NotNil(t, stats.NetworkInterfaces)
}
func TestLoadAndTickNetBaseline(t *testing.T) {
a := &Agent{netIoStats: make(map[uint16]system.NetIoStats)}
// First call initializes time and returns 0 elapsed
ni, elapsed := a.loadAndTickNetBaseline(100)
assert.Equal(t, uint64(0), elapsed)
assert.False(t, ni.Time.IsZero())
// Store back what loadAndTick returns to mimic updateNetworkStats behavior
a.netIoStats[100] = ni
time.Sleep(2 * time.Millisecond)
// Next call should produce >= 0 elapsed and update time
ni2, elapsed2 := a.loadAndTickNetBaseline(100)
assert.True(t, elapsed2 > 0)
assert.False(t, ni2.Time.IsZero())
}
func TestComputeBytesPerSecond(t *testing.T) {
a := &Agent{}
// No elapsed -> zero rate
bytesUp, bytesDown := a.computeBytesPerSecond(0, 2000, 3000, system.NetIoStats{BytesSent: 1000, BytesRecv: 1000})
assert.Equal(t, uint64(0), bytesUp)
assert.Equal(t, uint64(0), bytesDown)
// With elapsed -> per-second calculation
bytesUp, bytesDown = a.computeBytesPerSecond(500, 6000, 11000, system.NetIoStats{BytesSent: 1000, BytesRecv: 1000})
// (6000-1000)*1000/500 = 10000; (11000-1000)*1000/500 = 20000
assert.Equal(t, uint64(10000), bytesUp)
assert.Equal(t, uint64(20000), bytesDown)
}
func TestSumAndTrackPerNicDeltas(t *testing.T) {
a := &Agent{
netInterfaces: map[string]struct{}{"eth0": {}, "wlan0": {}},
netInterfaceDeltaTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
}
// Two samples for same cache interval to verify delta behavior
cache := uint16(42)
net1 := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 1000, BytesRecv: 2000}}
stats1 := &system.Stats{}
a.ensureNetworkInterfacesMap(stats1)
tx1, rx1 := a.sumAndTrackPerNicDeltas(cache, 0, net1, stats1)
assert.Equal(t, uint64(1000), tx1)
assert.Equal(t, uint64(2000), rx1)
// Second cycle with elapsed, larger counters -> deltas computed inside
net2 := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 4000, BytesRecv: 9000}}
stats := &system.Stats{}
a.ensureNetworkInterfacesMap(stats)
tx2, rx2 := a.sumAndTrackPerNicDeltas(cache, 1000, net2, stats)
assert.Equal(t, uint64(4000), tx2)
assert.Equal(t, uint64(9000), rx2)
// Up/Down deltas per second should be (4000-1000)/1s = 3000 and (9000-2000)/1s = 7000
ni, ok := stats.NetworkInterfaces["eth0"]
assert.True(t, ok)
assert.Equal(t, uint64(3000), ni[0])
assert.Equal(t, uint64(7000), ni[1])
}
func TestSumAndTrackPerNicDeltasHandlesCounterReset(t *testing.T) {
a := &Agent{
netInterfaces: map[string]struct{}{"eth0": {}},
netInterfaceDeltaTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
}
cache := uint16(77)
// First interval establishes baseline values
initial := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 4_000, BytesRecv: 6_000}}
statsInitial := &system.Stats{}
a.ensureNetworkInterfacesMap(statsInitial)
_, _ = a.sumAndTrackPerNicDeltas(cache, 0, initial, statsInitial)
// Second interval increments counters normally so previous snapshot gets populated
increment := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 9_000, BytesRecv: 11_000}}
statsIncrement := &system.Stats{}
a.ensureNetworkInterfacesMap(statsIncrement)
_, _ = a.sumAndTrackPerNicDeltas(cache, 1_000, increment, statsIncrement)
niIncrement, ok := statsIncrement.NetworkInterfaces["eth0"]
require.True(t, ok)
assert.Equal(t, uint64(5_000), niIncrement[0])
assert.Equal(t, uint64(5_000), niIncrement[1])
// Third interval simulates counter reset (values drop below previous totals)
reset := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 1_200, BytesRecv: 1_500}}
statsReset := &system.Stats{}
a.ensureNetworkInterfacesMap(statsReset)
_, _ = a.sumAndTrackPerNicDeltas(cache, 1_000, reset, statsReset)
niReset, ok := statsReset.NetworkInterfaces["eth0"]
require.True(t, ok)
assert.Equal(t, uint64(1_200), niReset[0], "upload delta should match new counter value after reset")
assert.Equal(t, uint64(1_500), niReset[1], "download delta should match new counter value after reset")
}
func TestApplyNetworkTotals(t *testing.T) {
tests := []struct {
name string
bytesSentPerSecond uint64
bytesRecvPerSecond uint64
totalBytesSent uint64
totalBytesRecv uint64
expectReset bool
expectedNetworkSent float64
expectedNetworkRecv float64
expectedBandwidthSent uint64
expectedBandwidthRecv uint64
}{
{
name: "Valid network stats - normal values",
bytesSentPerSecond: 1000000, // 1 MB/s
bytesRecvPerSecond: 2000000, // 2 MB/s
totalBytesSent: 10000000,
totalBytesRecv: 20000000,
expectReset: false,
expectedNetworkSent: 0.95, // ~1 MB/s rounded to 2 decimals
expectedNetworkRecv: 1.91, // ~2 MB/s rounded to 2 decimals
expectedBandwidthSent: 1000000,
expectedBandwidthRecv: 2000000,
},
{
name: "Invalid network stats - sent exceeds threshold",
bytesSentPerSecond: 11000000000, // ~10.5 GB/s > 10 GB/s threshold
bytesRecvPerSecond: 1000000, // 1 MB/s
totalBytesSent: 10000000,
totalBytesRecv: 20000000,
expectReset: true,
},
{
name: "Invalid network stats - recv exceeds threshold",
bytesSentPerSecond: 1000000, // 1 MB/s
bytesRecvPerSecond: 11000000000, // ~10.5 GB/s > 10 GB/s threshold
totalBytesSent: 10000000,
totalBytesRecv: 20000000,
expectReset: true,
},
{
name: "Invalid network stats - both exceed threshold",
bytesSentPerSecond: 12000000000, // ~11.4 GB/s
bytesRecvPerSecond: 13000000000, // ~12.4 GB/s
totalBytesSent: 10000000,
totalBytesRecv: 20000000,
expectReset: true,
},
{
name: "Valid network stats - at threshold boundary",
bytesSentPerSecond: 10485750000, // ~9999.99 MB/s (rounds to 9999.99)
bytesRecvPerSecond: 10485750000, // ~9999.99 MB/s (rounds to 9999.99)
totalBytesSent: 10000000,
totalBytesRecv: 20000000,
expectReset: false,
expectedNetworkSent: 9999.99,
expectedNetworkRecv: 9999.99,
expectedBandwidthSent: 10485750000,
expectedBandwidthRecv: 10485750000,
},
{
name: "Zero values",
bytesSentPerSecond: 0,
bytesRecvPerSecond: 0,
totalBytesSent: 0,
totalBytesRecv: 0,
expectReset: false,
expectedNetworkSent: 0.0,
expectedNetworkRecv: 0.0,
expectedBandwidthSent: 0,
expectedBandwidthRecv: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup agent with initialized maps
a := &Agent{
netInterfaces: make(map[string]struct{}),
netIoStats: make(map[uint16]system.NetIoStats),
netInterfaceDeltaTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
}
cacheTimeMs := uint16(100)
netIO := []psutilNet.IOCountersStat{
{Name: "eth0", BytesSent: 1000, BytesRecv: 2000},
}
systemStats := &system.Stats{}
nis := system.NetIoStats{}
a.applyNetworkTotals(
cacheTimeMs,
netIO,
systemStats,
nis,
tt.totalBytesSent,
tt.totalBytesRecv,
tt.bytesSentPerSecond,
tt.bytesRecvPerSecond,
)
if tt.expectReset {
// Should have reset network tracking state - maps cleared and stats zeroed
assert.NotContains(t, a.netIoStats, cacheTimeMs, "cache entry should be cleared after reset")
assert.NotContains(t, a.netInterfaceDeltaTrackers, cacheTimeMs, "tracker should be cleared on reset")
assert.Zero(t, systemStats.NetworkSent)
assert.Zero(t, systemStats.NetworkRecv)
assert.Zero(t, systemStats.Bandwidth[0])
assert.Zero(t, systemStats.Bandwidth[1])
} else {
// Should have applied stats
assert.Equal(t, tt.expectedNetworkSent, systemStats.NetworkSent)
assert.Equal(t, tt.expectedNetworkRecv, systemStats.NetworkRecv)
assert.Equal(t, tt.expectedBandwidthSent, systemStats.Bandwidth[0])
assert.Equal(t, tt.expectedBandwidthRecv, systemStats.Bandwidth[1])
// Should have updated NetIoStats
updatedNis := a.netIoStats[cacheTimeMs]
assert.Equal(t, tt.totalBytesSent, updatedNis.BytesSent)
assert.Equal(t, tt.totalBytesRecv, updatedNis.BytesRecv)
}
})
}
}

View File

@@ -127,15 +127,75 @@ func (a *Agent) handleSession(s ssh.Session) {
hubVersion := a.getHubVersion(sessionID, sessionCtx)
stats := a.gatherStats(sessionID)
err := a.writeToSession(s, stats, hubVersion)
if err != nil {
slog.Error("Error encoding stats", "err", err, "stats", stats)
s.Exit(1)
} else {
s.Exit(0)
// Legacy one-shot behavior for older hubs
if hubVersion.LT(beszel.MinVersionAgentResponse) {
if err := a.handleLegacyStats(s, hubVersion); err != nil {
slog.Error("Error encoding stats", "err", err)
s.Exit(1)
return
}
}
var req common.HubRequest[cbor.RawMessage]
if err := cbor.NewDecoder(s).Decode(&req); err != nil {
// Fallback to legacy one-shot if the first decode fails
if err2 := a.handleLegacyStats(s, hubVersion); err2 != nil {
slog.Error("Error encoding stats (fallback)", "err", err2)
s.Exit(1)
return
}
s.Exit(0)
return
}
if err := a.handleSSHRequest(s, &req); err != nil {
slog.Error("SSH request handling failed", "err", err)
s.Exit(1)
return
}
s.Exit(0)
}
// handleSSHRequest builds a handler context and dispatches to the shared registry
func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMessage]) error {
// SSH does not support fingerprint auth action
if req.Action == common.CheckFingerprint {
return cbor.NewEncoder(w).Encode(common.AgentResponse{Error: "unsupported action"})
}
// responder that writes AgentResponse to stdout
sshResponder := func(data any, requestID *uint32) error {
response := common.AgentResponse{Id: requestID}
switch v := data.(type) {
case *system.CombinedData:
response.SystemData = v
default:
response.Error = fmt.Sprintf("unsupported response type: %T", data)
}
return cbor.NewEncoder(w).Encode(response)
}
ctx := &HandlerContext{
Client: nil,
Agent: a,
Request: req,
RequestID: nil,
HubVerified: true,
SendResponse: sshResponder,
}
if handler, ok := a.handlerRegistry.GetHandler(req.Action); ok {
if err := handler.Handle(ctx); err != nil {
return cbor.NewEncoder(w).Encode(common.AgentResponse{Error: err.Error()})
}
return nil
}
return cbor.NewEncoder(w).Encode(common.AgentResponse{Error: fmt.Sprintf("unknown action: %d", req.Action)})
}
// handleLegacyStats serves the legacy one-shot stats payload for older hubs
func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error {
stats := a.gatherStats(60_000)
return a.writeToSession(w, stats, hubVersion)
}
// writeToSession encodes and writes system statistics to the session.

View File

@@ -14,12 +14,18 @@ import (
"github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/host"
"github.com/shirou/gopsutil/v4/load"
"github.com/shirou/gopsutil/v4/mem"
)
// prevDisk stores previous per-device disk counters for a given cache interval
type prevDisk struct {
readBytes uint64
writeBytes uint64
at time.Time
}
// Sets initial / non-changing values about the host system
func (a *Agent) initializeSystemInfo() {
a.systemInfo.AgentVersion = beszel.Version
@@ -68,7 +74,7 @@ func (a *Agent) initializeSystemInfo() {
}
// Returns current info, stats about the host system
func (a *Agent) getSystemStats() system.Stats {
func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
var systemStats system.Stats
// battery
@@ -77,11 +83,11 @@ func (a *Agent) getSystemStats() system.Stats {
}
// cpu percent
cpuPct, err := cpu.Percent(0, false)
if err != nil {
cpuPercent, err := getCpuPercent(cacheTimeMs)
if err == nil {
systemStats.Cpu = twoDecimals(cpuPercent)
} else {
slog.Error("Error getting cpu percent", "err", err)
} else if len(cpuPct) > 0 {
systemStats.Cpu = twoDecimals(cpuPct[0])
}
// load average
@@ -131,56 +137,13 @@ func (a *Agent) getSystemStats() system.Stats {
}
// disk usage
for _, stats := range a.fsStats {
if d, err := disk.Usage(stats.Mountpoint); err == nil {
stats.DiskTotal = bytesToGigabytes(d.Total)
stats.DiskUsed = bytesToGigabytes(d.Used)
if stats.Root {
systemStats.DiskTotal = bytesToGigabytes(d.Total)
systemStats.DiskUsed = bytesToGigabytes(d.Used)
systemStats.DiskPct = twoDecimals(d.UsedPercent)
}
} else {
// reset stats if error (likely unmounted)
slog.Error("Error getting disk stats", "name", stats.Mountpoint, "err", err)
stats.DiskTotal = 0
stats.DiskUsed = 0
stats.TotalRead = 0
stats.TotalWrite = 0
}
}
a.updateDiskUsage(&systemStats)
// disk i/o
if ioCounters, err := disk.IOCounters(a.fsNames...); err == nil {
for _, d := range ioCounters {
stats := a.fsStats[d.Name]
if stats == nil {
continue
}
secondsElapsed := time.Since(stats.Time).Seconds()
readPerSecond := bytesToMegabytes(float64(d.ReadBytes-stats.TotalRead) / secondsElapsed)
writePerSecond := bytesToMegabytes(float64(d.WriteBytes-stats.TotalWrite) / secondsElapsed)
// check for invalid values and reset stats if so
if readPerSecond < 0 || writePerSecond < 0 || readPerSecond > 50_000 || writePerSecond > 50_000 {
slog.Warn("Invalid disk I/O. Resetting.", "name", d.Name, "read", readPerSecond, "write", writePerSecond)
a.initializeDiskIoStats(ioCounters)
break
}
stats.Time = time.Now()
stats.DiskReadPs = readPerSecond
stats.DiskWritePs = writePerSecond
stats.TotalRead = d.ReadBytes
stats.TotalWrite = d.WriteBytes
// if root filesystem, update system stats
if stats.Root {
systemStats.DiskReadPs = stats.DiskReadPs
systemStats.DiskWritePs = stats.DiskWritePs
}
}
}
// disk i/o (cache-aware per interval)
a.updateDiskIo(cacheTimeMs, &systemStats)
// network stats
a.updateNetworkStats(&systemStats)
// network stats (per cache interval)
a.updateNetworkStats(cacheTimeMs, &systemStats)
// temperatures
// TODO: maybe refactor to methods on systemStats
@@ -191,7 +154,7 @@ func (a *Agent) getSystemStats() system.Stats {
// reset high gpu percent
a.systemInfo.GpuPct = 0
// get current GPU data
if gpuData := a.gpuManager.GetCurrentData(); len(gpuData) > 0 {
if gpuData := a.gpuManager.GetCurrentData(cacheTimeMs); len(gpuData) > 0 {
systemStats.GPUData = gpuData
// add temperatures

View File

@@ -0,0 +1,24 @@
{
"cpu_stats": {
"cpu_usage": {
"total_usage": 312055276000
},
"system_cpu_usage": 1366399830000000
},
"memory_stats": {
"usage": 507400192,
"stats": {
"inactive_file": 165130240
}
},
"networks": {
"eth0": {
"tx_bytes": 20376558,
"rx_bytes": 537029455
},
"eth1": {
"tx_bytes": 2003766,
"rx_bytes": 6241
}
}
}

View File

@@ -0,0 +1,24 @@
{
"cpu_stats": {
"cpu_usage": {
"total_usage": 314891801000
},
"system_cpu_usage": 1368474900000000
},
"memory_stats": {
"usage": 507400192,
"stats": {
"inactive_file": 165130240
}
},
"networks": {
"eth0": {
"tx_bytes": 20376558,
"rx_bytes": 537029455
},
"eth1": {
"tx_bytes": 2003766,
"rx_bytes": 6241
}
}
}

View File

@@ -40,11 +40,12 @@ func (o *openRCRestarter) Restart() error {
type openWRTRestarter struct{ cmd string }
func (w *openWRTRestarter) Restart() error {
if err := exec.Command(w.cmd, "running", "beszel-agent").Run(); err != nil {
// https://openwrt.org/docs/guide-user/base-system/managing_services?s[]=service
if err := exec.Command("/etc/init.d/beszel-agent", "running").Run(); err != nil {
return nil
}
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via procd…")
return exec.Command(w.cmd, "restart", "beszel-agent").Run()
return exec.Command("/etc/init.d/beszel-agent", "restart").Run()
}
type freeBSDRestarter struct{ cmd string }
@@ -64,11 +65,13 @@ func detectRestarter() restarter {
if path, err := exec.LookPath("rc-service"); err == nil {
return &openRCRestarter{cmd: path}
}
if path, err := exec.LookPath("procd"); err == nil {
return &openWRTRestarter{cmd: path}
}
if path, err := exec.LookPath("service"); err == nil {
if runtime.GOOS == "freebsd" {
return &freeBSDRestarter{cmd: path}
}
return &openWRTRestarter{cmd: path}
}
return nil
}

View File

@@ -6,10 +6,13 @@ import "github.com/blang/semver"
const (
// Version is the current version of the application.
Version = "0.12.12"
Version = "0.13.2"
// AppName is the name of the application.
AppName = "beszel"
)
// MinVersionCbor is the minimum supported version for CBOR compatibility.
var MinVersionCbor = semver.MustParse("0.12.0")
// MinVersionAgentResponse is the minimum supported version for AgentResponse compatibility.
var MinVersionAgentResponse = semver.MustParse("0.13.0")

10
go.mod
View File

@@ -12,16 +12,16 @@ require (
github.com/gliderlabs/ssh v0.3.8
github.com/google/uuid v1.6.0
github.com/lxzan/gws v1.8.9
github.com/nicholas-fedor/shoutrrr v0.9.1
github.com/nicholas-fedor/shoutrrr v0.10.0
github.com/pocketbase/dbx v1.11.0
github.com/pocketbase/pocketbase v0.30.0
github.com/shirou/gopsutil/v4 v4.25.8
github.com/pocketbase/pocketbase v0.30.1
github.com/shirou/gopsutil/v4 v4.25.9
github.com/spf13/cast v1.10.0
github.com/spf13/cobra v1.10.1
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.42.0
golang.org/x/exp v0.0.0-20250911091902-df9299821621
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9
gopkg.in/yaml.v3 v3.0.1
)
@@ -65,5 +65,5 @@ require (
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.38.2 // indirect
modernc.org/sqlite v1.39.0 // indirect
)

28
go.sum
View File

@@ -90,8 +90,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.30.0 h1:7v9O3hBYyHyptnnFjdP8tEJIuyHEfjhG6PC4gjf5eoE=
github.com/pocketbase/pocketbase v0.30.0/go.mod h1:gZIwampw4VqMcEdGHwBZgSa54xWIDgVJb4uINUMXLmA=
github.com/pocketbase/pocketbase v0.30.1 h1:8lgfhH+HiSw1PyKVMq2sjtC4ZNvda2f/envTAzWMLOA=
github.com/pocketbase/pocketbase v0.30.1/go.mod h1:sUI+uekXZam5Wa0eh+DClc+HieKMCeqsHA7Ydd9vwyE=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -99,8 +99,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970=
github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI=
github.com/shirou/gopsutil/v4 v4.25.9 h1:JImNpf6gCVhKgZhtaAHJ0serfFGtlfIlSC08eaKdTrU=
github.com/shirou/gopsutil/v4 v4.25.9/go.mod h1:gxIxoC+7nQRwUl/xNhutXlD8lq+jxTgpIkEf3rADHL8=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
@@ -127,8 +127,8 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU=
golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A=
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
@@ -169,20 +169,20 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
modernc.org/cc/v4 v4.26.4 h1:jPhG8oNjtTYuP2FA4YefTJ/wioNUGALmGuEWt7SUR6s=
modernc.org/cc/v4 v4.26.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.28 h1:Vp156KUA2nPu9F1NEv036x9UGOjg2qsi5QlWTjZmtMk=
modernc.org/fileutil v1.3.28/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/libc v1.66.9 h1:YkHp7E1EWrN2iyNav7JE/nHasmshPvlGkon1VxGqOw0=
modernc.org/libc v1.66.9/go.mod h1:aVdcY7udcawRqauu0HukYYxtBSizV+R80n/6aQe9D5k=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -191,8 +191,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -1,22 +1,33 @@
package common
type WebSocketAction = uint8
import (
"github.com/henrygd/beszel/internal/entities/system"
)
// Not implemented yet
// type AgentError = uint8
type WebSocketAction = uint8
const (
// Request system data from agent
GetData WebSocketAction = iota
// Check the fingerprint of the agent
CheckFingerprint
// Add new actions here...
)
// HubRequest defines the structure for requests sent from hub to agent.
type HubRequest[T any] struct {
Action WebSocketAction `cbor:"0,keyasint"`
Data T `cbor:"1,keyasint,omitempty,omitzero"`
// Error AgentError `cbor:"error,omitempty,omitzero"`
Id *uint32 `cbor:"2,keyasint,omitempty"`
}
// AgentResponse defines the structure for responses sent from agent to hub.
type AgentResponse struct {
Id *uint32 `cbor:"0,keyasint,omitempty"`
SystemData *system.CombinedData `cbor:"1,keyasint,omitempty,omitzero"`
Fingerprint *FingerprintResponse `cbor:"2,keyasint,omitempty,omitzero"`
Error string `cbor:"3,keyasint,omitempty,omitzero"`
// RawBytes []byte `cbor:"4,keyasint,omitempty,omitzero"`
}
type FingerprintRequest struct {
@@ -27,6 +38,12 @@ type FingerprintRequest struct {
type FingerprintResponse struct {
Fingerprint string `cbor:"0,keyasint"`
// Optional system info for universal token system creation
Hostname string `cbor:"1,keyasint,omitempty,omitzero"`
Port string `cbor:"2,keyasint,omitempty,omitzero"`
Hostname string `cbor:"1,keyasint,omitzero"`
Port string `cbor:"2,keyasint,omitzero"`
Name string `cbor:"3,keyasint,omitzero"`
}
type DataRequestOptions struct {
CacheTimeMs uint16 `cbor:"0,keyasint"`
// ResourceType uint8 `cbor:"1,keyasint,omitempty,omitzero"`
}

View File

@@ -23,4 +23,7 @@ COPY --from=builder /agent /agent
# this is so we don't need to create the /tmp directory in the scratch container
COPY --from=builder /tmp /tmp
# Ensure data persistence across container recreations
VOLUME ["/var/lib/beszel-agent"]
ENTRYPOINT ["/agent"]

View File

@@ -22,4 +22,7 @@ COPY --from=builder /agent /agent
RUN apk add --no-cache -X https://dl-cdn.alpinelinux.org/alpine/edge/testing igt-gpu-tools
# Ensure data persistence across container recreations
VOLUME ["/var/lib/beszel-agent"]
ENTRYPOINT ["/agent"]

View File

@@ -24,4 +24,7 @@ COPY --from=builder /agent /agent
# this is so we don't need to create the /tmp directory in the scratch container
COPY --from=builder /tmp /tmp
# Ensure data persistence across container recreations
VOLUME ["/var/lib/beszel-agent"]
ENTRYPOINT ["/agent"]

View File

@@ -25,6 +25,9 @@ FROM scratch
COPY --from=builder /beszel /
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Ensure data persistence across container recreations
VOLUME ["/beszel_data"]
EXPOSE 8090
ENTRYPOINT [ "/beszel" ]

View File

@@ -4,25 +4,29 @@ import "time"
// Docker container info from /containers/json
type ApiInfo struct {
Id string
IdShort string
Names []string
Status string
Id string
IdShort string
Names []string
Status string
Health string `json:"Health,omitempty"` // Container health status
Created int64 `json:"Created,omitempty"` // Container creation timestamp
StartedAt int64 `json:"StartedAt,omitempty"` // Container start timestamp
FinishedAt int64 `json:"FinishedAt,omitempty"` // Container finish timestamp
State string `json:"State,omitempty"` // Container state (running, stopped, etc.)
// Image string
// ImageID string
// Command string
// Created int64
// Ports []Port
// SizeRw int64 `json:",omitempty"`
// SizeRootFs int64 `json:",omitempty"`
// Labels map[string]string
Labels map[string]string
// State string
// HostConfig struct {
// NetworkMode string `json:",omitempty"`
// Annotations map[string]string `json:",omitempty"`
// }
// NetworkSettings *SummaryNetworkSettings
// Mounts []MountPoint
Mounts []MountPoint
}
// Docker container resources from /containers/{id}/stats
@@ -32,6 +36,7 @@ type ApiStats struct {
Networks map[string]NetworkStats
CPUStats CPUStats `json:"cpu_stats"`
MemoryStats MemoryStats `json:"memory_stats"`
BlkioStats BlkioStats `json:"blkio_stats"`
}
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {
@@ -98,21 +103,58 @@ type NetworkStats struct {
TxBytes uint64 `json:"tx_bytes"`
}
type BlkioStats struct {
IoServiceBytesRecursive []BlkioStatEntry `json:"io_service_bytes_recursive"`
IoServicedRecursive []BlkioStatEntry `json:"io_serviced_recursive"`
}
type BlkioStatEntry struct {
Major uint64 `json:"major"`
Minor uint64 `json:"minor"`
Op string `json:"op"`
Value uint64 `json:"value"`
}
type prevNetStats struct {
Sent uint64
Recv uint64
}
type prevDiskStats struct {
Read uint64
Write uint64
}
// Docker container stats
type Stats struct {
Name string `json:"n" cbor:"0,keyasint"`
Cpu float64 `json:"c" cbor:"1,keyasint"`
Mem float64 `json:"m" cbor:"2,keyasint"`
NetworkSent float64 `json:"ns" cbor:"3,keyasint"`
NetworkRecv float64 `json:"nr" cbor:"4,keyasint"`
// PrevCpu [2]uint64 `json:"-"`
CpuSystem uint64 `json:"-"`
CpuContainer uint64 `json:"-"`
PrevNet prevNetStats `json:"-"`
PrevReadTime time.Time `json:"-"`
Name string `json:"n" cbor:"0,keyasint"`
Cpu float64 `json:"c" cbor:"1,keyasint"`
Mem float64 `json:"m" cbor:"2,keyasint"`
NetworkSent float64 `json:"ns" cbor:"3,keyasint"`
NetworkRecv float64 `json:"nr" cbor:"4,keyasint"`
DiskRead float64 `json:"dr" cbor:"5,keyasint"` // Disk read rate in MB/s
DiskWrite float64 `json:"dw" cbor:"6,keyasint"` // Disk write rate in MB/s
Volumes map[string]float64 `json:"v,omitempty" cbor:"7,keyasint"` // Volume name to size mapping
Health string `json:"h,omitempty" cbor:"8,keyasint"` // Container health status
Status string `json:"s,omitempty" cbor:"9,keyasint"` // Container status (running, stopped, etc.)
Uptime float64 `json:"u,omitempty" cbor:"10,keyasint"` // Container uptime in seconds
Project string `json:"p,omitempty" cbor:"11,keyasint"` // Docker Compose project name
IdShort string `json:"idShort,omitempty" cbor:"12,keyasint"` // Container short ID for frontend
CpuSystem uint64 `json:"-"`
CpuContainer uint64 `json:"-"`
PrevNet prevNetStats `json:"-"`
PrevDisk prevDiskStats `json:"-"`
PrevReadTime time.Time `json:"-"`
}
// MountPoint represents a mount point in a container
type MountPoint struct {
Type string `json:"Type"`
Name string `json:"Name"`
Source string `json:"Source"`
Destination string `json:"Destination"`
Driver string `json:"Driver,omitempty"`
Mode string `json:"Mode"`
RW bool `json:"RW"`
Propagation string `json:"Propagation"`
}

View File

@@ -42,6 +42,8 @@ type Stats struct {
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
MaxMem float64 `json:"mm,omitempty" cbor:"30,keyasint,omitempty"`
NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download]
DiskIO [2]uint64 `json:"dio,omitzero" cbor:"32,keyasint,omitzero"` // [read bytes, write bytes]
MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes]
}
type GPUData struct {
@@ -60,6 +62,7 @@ type FsStats struct {
Time time.Time `json:"-"`
Root bool `json:"-"`
Mountpoint string `json:"-"`
Name string `json:"-"`
DiskTotal float64 `json:"d" cbor:"0,keyasint"`
DiskUsed float64 `json:"du" cbor:"1,keyasint"`
TotalRead uint64 `json:"-"`
@@ -68,6 +71,11 @@ type FsStats struct {
DiskWritePs float64 `json:"w" cbor:"3,keyasint"`
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"4,keyasint,omitempty"`
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"5,keyasint,omitempty"`
// TODO: remove DiskReadPs and DiskWritePs in future release in favor of DiskReadBytes and DiskWriteBytes
DiskReadBytes uint64 `json:"rb" cbor:"6,keyasint,omitempty"`
DiskWriteBytes uint64 `json:"wb" cbor:"7,keyasint,omitempty"`
MaxDiskReadBytes uint64 `json:"rbm,omitempty" cbor:"-"`
MaxDiskWriteBytes uint64 `json:"wbm,omitempty" cbor:"-"`
}
type NetIoStats struct {

View File

@@ -1,6 +1,7 @@
package hub
import (
"context"
"errors"
"net"
"net/http"
@@ -93,7 +94,7 @@ func (acr *agentConnectRequest) agentConnect() (err error) {
// verifyWsConn verifies the WebSocket connection using the agent's fingerprint and
// SSH key signature, then adds the system to the system manager.
func (acr *agentConnectRequest) verifyWsConn(conn *gws.Conn, fpRecords []ws.FingerprintRecord) (err error) {
wsConn := ws.NewWsConnection(conn)
wsConn := ws.NewWsConnection(conn, acr.agentSemVer)
// must set wsConn in connection store before the read loop
conn.Session().Store("wsConn", wsConn)
@@ -112,7 +113,7 @@ func (acr *agentConnectRequest) verifyWsConn(conn *gws.Conn, fpRecords []ws.Fing
return err
}
agentFingerprint, err := wsConn.GetFingerprint(acr.token, signer, acr.isUniversalToken)
agentFingerprint, err := wsConn.GetFingerprint(context.Background(), acr.token, signer, acr.isUniversalToken)
if err != nil {
return err
}
@@ -267,9 +268,12 @@ func (acr *agentConnectRequest) createSystem(agentFingerprint common.Fingerprint
if agentFingerprint.Port == "" {
agentFingerprint.Port = "45876"
}
if agentFingerprint.Name == "" {
agentFingerprint.Name = agentFingerprint.Hostname
}
// create new record
systemRecord := core.NewRecord(systemsCollection)
systemRecord.Set("name", agentFingerprint.Hostname)
systemRecord.Set("name", agentFingerprint.Name)
systemRecord.Set("host", remoteAddr)
systemRecord.Set("port", agentFingerprint.Port)
systemRecord.Set("users", []string{acr.userId})

View File

@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/hub/ws"
"github.com/henrygd/beszel/internal/entities/system"
@@ -107,7 +108,7 @@ func (sys *System) update() error {
sys.handlePaused()
return nil
}
data, err := sys.fetchDataFromAgent()
data, err := sys.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: uint16(interval)})
if err == nil {
_, err = sys.createRecords(data)
}
@@ -209,13 +210,13 @@ func (sys *System) getContext() (context.Context, context.CancelFunc) {
// fetchDataFromAgent attempts to fetch data from the agent,
// prioritizing WebSocket if available.
func (sys *System) fetchDataFromAgent() (*system.CombinedData, error) {
func (sys *System) fetchDataFromAgent(options common.DataRequestOptions) (*system.CombinedData, error) {
if sys.data == nil {
sys.data = &system.CombinedData{}
}
if sys.WsConn != nil && sys.WsConn.IsConnected() {
wsData, err := sys.fetchDataViaWebSocket()
wsData, err := sys.fetchDataViaWebSocket(options)
if err == nil {
return wsData, nil
}
@@ -223,18 +224,18 @@ func (sys *System) fetchDataFromAgent() (*system.CombinedData, error) {
sys.closeWebSocketConnection()
}
sshData, err := sys.fetchDataViaSSH()
sshData, err := sys.fetchDataViaSSH(options)
if err != nil {
return nil, err
}
return sshData, nil
}
func (sys *System) fetchDataViaWebSocket() (*system.CombinedData, error) {
func (sys *System) fetchDataViaWebSocket(options common.DataRequestOptions) (*system.CombinedData, error) {
if sys.WsConn == nil || !sys.WsConn.IsConnected() {
return nil, errors.New("no websocket connection")
}
err := sys.WsConn.RequestSystemData(sys.data)
err := sys.WsConn.RequestSystemData(context.Background(), sys.data, options)
if err != nil {
return nil, err
}
@@ -244,7 +245,7 @@ func (sys *System) fetchDataViaWebSocket() (*system.CombinedData, error) {
// fetchDataViaSSH handles fetching data using SSH.
// This function encapsulates the original SSH logic.
// It updates sys.data directly upon successful fetch.
func (sys *System) fetchDataViaSSH() (*system.CombinedData, error) {
func (sys *System) fetchDataViaSSH(options common.DataRequestOptions) (*system.CombinedData, error) {
maxRetries := 1
for attempt := 0; attempt <= maxRetries; attempt++ {
if sys.client == nil || sys.Status == down {
@@ -269,12 +270,31 @@ func (sys *System) fetchDataViaSSH() (*system.CombinedData, error) {
if err != nil {
return nil, err
}
stdin, stdinErr := session.StdinPipe()
if err := session.Shell(); err != nil {
return nil, err
}
*sys.data = system.CombinedData{}
if sys.agentVersion.GTE(beszel.MinVersionAgentResponse) && stdinErr == nil {
req := common.HubRequest[any]{Action: common.GetData, Data: options}
_ = cbor.NewEncoder(stdin).Encode(req)
// Close write side to signal end of request
_ = stdin.Close()
var resp common.AgentResponse
if decErr := cbor.NewDecoder(stdout).Decode(&resp); decErr == nil && resp.SystemData != nil {
*sys.data = *resp.SystemData
// wait for the session to complete
if err := session.Wait(); err != nil {
return nil, err
}
return sys.data, nil
}
// If decoding failed, fall back below
}
if sys.agentVersion.GTE(beszel.MinVersionCbor) {
err = cbor.NewDecoder(stdout).Decode(sys.data)
} else {
@@ -379,11 +399,11 @@ func extractAgentVersion(versionString string) (semver.Version, error) {
}
// getJitter returns a channel that will be triggered after a random delay
// between 40% and 90% of the interval.
// between 51% and 95% of the interval.
// This is used to stagger the initial WebSocket connections to prevent clustering.
func getJitter() <-chan time.Time {
minPercent := 40
maxPercent := 90
minPercent := 51
maxPercent := 95
jitterRange := maxPercent - minPercent
msDelay := (interval * minPercent / 100) + rand.Intn(interval*jitterRange/100)
return time.After(time.Duration(msDelay) * time.Millisecond)

View File

@@ -106,6 +106,8 @@ func (sm *SystemManager) bindEventHooks() {
sm.hub.OnRecordAfterUpdateSuccess("systems").BindFunc(sm.onRecordAfterUpdateSuccess)
sm.hub.OnRecordAfterDeleteSuccess("systems").BindFunc(sm.onRecordAfterDeleteSuccess)
sm.hub.OnRecordAfterUpdateSuccess("fingerprints").BindFunc(sm.onTokenRotated)
sm.hub.OnRealtimeSubscribeRequest().BindFunc(sm.onRealtimeSubscribeRequest)
sm.hub.OnRealtimeConnectRequest().BindFunc(sm.onRealtimeConnectRequest)
}
// onTokenRotated handles fingerprint token rotation events.

View File

@@ -0,0 +1,187 @@
package systems
import (
"encoding/json"
"strings"
"sync"
"time"
"github.com/henrygd/beszel/internal/common"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/subscriptions"
)
type subscriptionInfo struct {
subscription string
connectedClients uint8
}
var (
activeSubscriptions = make(map[string]*subscriptionInfo)
workerRunning bool
realtimeTicker *time.Ticker
tickerStopChan chan struct{}
realtimeMutex sync.Mutex
)
// onRealtimeConnectRequest handles client connection events for realtime subscriptions.
// It cleans up existing subscriptions when a client connects.
func (sm *SystemManager) onRealtimeConnectRequest(e *core.RealtimeConnectRequestEvent) error {
// after e.Next() is the client disconnection
e.Next()
subscriptions := e.Client.Subscriptions()
for k := range subscriptions {
sm.removeRealtimeSubscription(k, subscriptions[k])
}
return nil
}
// onRealtimeSubscribeRequest handles client subscription events for realtime metrics.
// It tracks new subscriptions and unsubscriptions to manage the realtime worker lifecycle.
func (sm *SystemManager) onRealtimeSubscribeRequest(e *core.RealtimeSubscribeRequestEvent) error {
oldSubs := e.Client.Subscriptions()
// after e.Next() is the result of the subscribe request
err := e.Next()
newSubs := e.Client.Subscriptions()
// handle new subscriptions
for k, options := range newSubs {
if _, ok := oldSubs[k]; !ok {
if strings.HasPrefix(k, "rt_metrics") {
systemId := options.Query["system"]
if _, ok := activeSubscriptions[systemId]; !ok {
activeSubscriptions[systemId] = &subscriptionInfo{
subscription: k,
}
}
activeSubscriptions[systemId].connectedClients += 1
sm.onRealtimeSubscriptionAdded()
}
}
}
// handle unsubscriptions
for k := range oldSubs {
if _, ok := newSubs[k]; !ok {
sm.removeRealtimeSubscription(k, oldSubs[k])
}
}
return err
}
// onRealtimeSubscriptionAdded initializes or starts the realtime worker when the first subscription is added.
// It ensures only one worker runs at a time and creates the ticker for periodic data fetching.
func (sm *SystemManager) onRealtimeSubscriptionAdded() {
realtimeMutex.Lock()
defer realtimeMutex.Unlock()
// Start the worker if it's not already running
if !workerRunning {
workerRunning = true
// Create a new stop channel for this worker instance
tickerStopChan = make(chan struct{})
go sm.startRealtimeWorker()
}
// If no ticker exists, create one
if realtimeTicker == nil {
realtimeTicker = time.NewTicker(1 * time.Second)
}
}
// checkSubscriptions stops the realtime worker when there are no active subscriptions.
// This prevents unnecessary resource usage when no clients are listening for realtime data.
func (sm *SystemManager) checkSubscriptions() {
if !workerRunning || len(activeSubscriptions) > 0 {
return
}
realtimeMutex.Lock()
defer realtimeMutex.Unlock()
// Signal the worker to stop
if tickerStopChan != nil {
select {
case tickerStopChan <- struct{}{}:
default:
}
}
if realtimeTicker != nil {
realtimeTicker.Stop()
realtimeTicker = nil
}
// Mark worker as stopped (will be reset when next subscription comes in)
workerRunning = false
}
// removeRealtimeSubscription removes a realtime subscription and checks if the worker should be stopped.
// It only processes subscriptions with the "rt_metrics" prefix and triggers cleanup when subscriptions are removed.
func (sm *SystemManager) removeRealtimeSubscription(subscription string, options subscriptions.SubscriptionOptions) {
if strings.HasPrefix(subscription, "rt_metrics") {
systemId := options.Query["system"]
if info, ok := activeSubscriptions[systemId]; ok {
info.connectedClients -= 1
if info.connectedClients <= 0 {
delete(activeSubscriptions, systemId)
}
}
sm.checkSubscriptions()
}
}
// startRealtimeWorker runs the main loop for fetching realtime data from agents.
// It continuously fetches system data and broadcasts it to subscribed clients via WebSocket.
func (sm *SystemManager) startRealtimeWorker() {
sm.fetchRealtimeDataAndNotify()
for {
select {
case <-tickerStopChan:
return
case <-realtimeTicker.C:
// Check if ticker is still valid (might have been stopped)
if realtimeTicker == nil || len(activeSubscriptions) == 0 {
return
}
// slog.Debug("activeSubscriptions", "count", len(activeSubscriptions))
sm.fetchRealtimeDataAndNotify()
}
}
}
// fetchRealtimeDataAndNotify fetches realtime data for all active subscriptions and notifies the clients.
func (sm *SystemManager) fetchRealtimeDataAndNotify() {
for systemId, info := range activeSubscriptions {
system, ok := sm.systems.GetOk(systemId)
if ok {
go func() {
data, err := system.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: 1000})
if err != nil {
return
}
bytes, err := json.Marshal(data)
if err == nil {
notify(sm.hub, info.subscription, bytes)
}
}()
}
}
}
// notify broadcasts realtime data to all clients subscribed to a specific subscription.
// It iterates through all connected clients and sends the data only to those with matching subscriptions.
func notify(app core.App, subscription string, data []byte) error {
message := subscriptions.Message{
Name: subscription,
Data: data,
}
for _, client := range app.SubscriptionsBroker().Clients() {
if !client.HasSubscription(subscription) {
continue
}
client.Send(message)
}
return nil
}

107
internal/hub/ws/handlers.go Normal file
View File

@@ -0,0 +1,107 @@
package ws
import (
"context"
"errors"
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/lxzan/gws"
"golang.org/x/crypto/ssh"
)
// ResponseHandler defines interface for handling agent responses
type ResponseHandler interface {
Handle(agentResponse common.AgentResponse) error
HandleLegacy(rawData []byte) error
}
// BaseHandler provides a default implementation that can be embedded to make HandleLegacy optional
// type BaseHandler struct{}
// func (h *BaseHandler) HandleLegacy(rawData []byte) error {
// return errors.New("legacy format not supported")
// }
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// systemDataHandler implements ResponseHandler for system data requests
type systemDataHandler struct {
data *system.CombinedData
}
func (h *systemDataHandler) HandleLegacy(rawData []byte) error {
return cbor.Unmarshal(rawData, h.data)
}
func (h *systemDataHandler) Handle(agentResponse common.AgentResponse) error {
if agentResponse.SystemData != nil {
*h.data = *agentResponse.SystemData
}
return nil
}
// RequestSystemData requests system metrics from the agent and unmarshals the response.
func (ws *WsConn) RequestSystemData(ctx context.Context, data *system.CombinedData, options common.DataRequestOptions) error {
if !ws.IsConnected() {
return gws.ErrConnClosed
}
req, err := ws.requestManager.SendRequest(ctx, common.GetData, options)
if err != nil {
return err
}
handler := &systemDataHandler{data: data}
return ws.handleAgentRequest(req, handler)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// fingerprintHandler implements ResponseHandler for fingerprint requests
type fingerprintHandler struct {
result *common.FingerprintResponse
}
func (h *fingerprintHandler) HandleLegacy(rawData []byte) error {
return cbor.Unmarshal(rawData, h.result)
}
func (h *fingerprintHandler) Handle(agentResponse common.AgentResponse) error {
if agentResponse.Fingerprint != nil {
*h.result = *agentResponse.Fingerprint
return nil
}
return errors.New("no fingerprint data in response")
}
// GetFingerprint authenticates with the agent using SSH signature and returns the agent's fingerprint.
func (ws *WsConn) GetFingerprint(ctx context.Context, token string, signer ssh.Signer, needSysInfo bool) (common.FingerprintResponse, error) {
if !ws.IsConnected() {
return common.FingerprintResponse{}, gws.ErrConnClosed
}
challenge := []byte(token)
signature, err := signer.Sign(nil, challenge)
if err != nil {
return common.FingerprintResponse{}, err
}
req, err := ws.requestManager.SendRequest(ctx, common.CheckFingerprint, common.FingerprintRequest{
Signature: signature.Blob,
NeedSysInfo: needSysInfo,
})
if err != nil {
return common.FingerprintResponse{}, err
}
var result common.FingerprintResponse
handler := &fingerprintHandler{result: &result}
err = ws.handleAgentRequest(req, handler)
return result, err
}

View File

@@ -0,0 +1,186 @@
package ws
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common"
"github.com/lxzan/gws"
)
// RequestID uniquely identifies a request
type RequestID uint32
// PendingRequest tracks an in-flight request
type PendingRequest struct {
ID RequestID
ResponseCh chan *gws.Message
Context context.Context
Cancel context.CancelFunc
CreatedAt time.Time
}
// RequestManager handles concurrent requests to an agent
type RequestManager struct {
sync.RWMutex
conn *gws.Conn
pendingReqs map[RequestID]*PendingRequest
nextID atomic.Uint32
}
// NewRequestManager creates a new request manager for a WebSocket connection
func NewRequestManager(conn *gws.Conn) *RequestManager {
rm := &RequestManager{
conn: conn,
pendingReqs: make(map[RequestID]*PendingRequest),
}
return rm
}
// SendRequest sends a request and returns a channel for the response
func (rm *RequestManager) SendRequest(ctx context.Context, action common.WebSocketAction, data any) (*PendingRequest, error) {
reqID := RequestID(rm.nextID.Add(1))
reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
req := &PendingRequest{
ID: reqID,
ResponseCh: make(chan *gws.Message, 1),
Context: reqCtx,
Cancel: cancel,
CreatedAt: time.Now(),
}
rm.Lock()
rm.pendingReqs[reqID] = req
rm.Unlock()
hubReq := common.HubRequest[any]{
Id: (*uint32)(&reqID),
Action: action,
Data: data,
}
// Send the request
if err := rm.sendMessage(hubReq); err != nil {
rm.cancelRequest(reqID)
return nil, fmt.Errorf("failed to send request: %w", err)
}
// Start cleanup watcher for timeout/cancellation
go rm.cleanupRequest(req)
return req, nil
}
// sendMessage encodes and sends a message over WebSocket
func (rm *RequestManager) sendMessage(data any) error {
if rm.conn == nil {
return gws.ErrConnClosed
}
bytes, err := cbor.Marshal(data)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
return rm.conn.WriteMessage(gws.OpcodeBinary, bytes)
}
// handleResponse processes a single response message
func (rm *RequestManager) handleResponse(message *gws.Message) {
var response common.AgentResponse
if err := cbor.Unmarshal(message.Data.Bytes(), &response); err != nil {
// Legacy response without ID - route to first pending request of any type
rm.routeLegacyResponse(message)
return
}
reqID := RequestID(*response.Id)
rm.RLock()
req, exists := rm.pendingReqs[reqID]
rm.RUnlock()
if !exists {
// Request not found (might have timed out) - close the message
message.Close()
return
}
select {
case req.ResponseCh <- message:
// Message successfully delivered - the receiver will close it
rm.deleteRequest(reqID)
case <-req.Context.Done():
// Request was cancelled/timed out - close the message
message.Close()
}
}
// routeLegacyResponse handles responses that don't have request IDs (backwards compatibility)
func (rm *RequestManager) routeLegacyResponse(message *gws.Message) {
// Snapshot the oldest pending request without holding the lock during send
rm.RLock()
var oldestReq *PendingRequest
for _, req := range rm.pendingReqs {
if oldestReq == nil || req.CreatedAt.Before(oldestReq.CreatedAt) {
oldestReq = req
}
}
rm.RUnlock()
if oldestReq != nil {
select {
case oldestReq.ResponseCh <- message:
// Message successfully delivered - the receiver will close it
rm.deleteRequest(oldestReq.ID)
case <-oldestReq.Context.Done():
// Request was cancelled - close the message
message.Close()
}
} else {
// No pending requests - close the message
message.Close()
}
}
// cleanupRequest handles request timeout and cleanup
func (rm *RequestManager) cleanupRequest(req *PendingRequest) {
<-req.Context.Done()
rm.cancelRequest(req.ID)
}
// cancelRequest removes a request and cancels its context
func (rm *RequestManager) cancelRequest(reqID RequestID) {
rm.Lock()
defer rm.Unlock()
if req, exists := rm.pendingReqs[reqID]; exists {
req.Cancel()
delete(rm.pendingReqs, reqID)
}
}
// deleteRequest removes a request from the pending map without cancelling its context.
func (rm *RequestManager) deleteRequest(reqID RequestID) {
rm.Lock()
defer rm.Unlock()
delete(rm.pendingReqs, reqID)
}
// Close shuts down the request manager
func (rm *RequestManager) Close() {
rm.Lock()
defer rm.Unlock()
// Cancel all pending requests
for _, req := range rm.pendingReqs {
req.Cancel()
}
rm.pendingReqs = make(map[RequestID]*PendingRequest)
}

View File

@@ -0,0 +1,81 @@
//go:build testing
// +build testing
package ws
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// TestRequestManager_BasicFunctionality tests the request manager without mocking gws.Conn
func TestRequestManager_BasicFunctionality(t *testing.T) {
// We'll test the core logic without mocking the connection
// since the gws.Conn interface is complex to mock properly
t.Run("request ID generation", func(t *testing.T) {
// Test that request IDs are generated sequentially and uniquely
rm := &RequestManager{}
// Simulate multiple ID generations
id1 := rm.nextID.Add(1)
id2 := rm.nextID.Add(1)
id3 := rm.nextID.Add(1)
assert.NotEqual(t, id1, id2)
assert.NotEqual(t, id2, id3)
assert.Greater(t, id2, id1)
assert.Greater(t, id3, id2)
})
t.Run("pending request tracking", func(t *testing.T) {
rm := &RequestManager{
pendingReqs: make(map[RequestID]*PendingRequest),
}
// Initially no pending requests
assert.Equal(t, 0, rm.GetPendingCount())
// Add some fake pending requests
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
req1 := &PendingRequest{
ID: RequestID(1),
Context: ctx,
Cancel: cancel,
}
req2 := &PendingRequest{
ID: RequestID(2),
Context: ctx,
Cancel: cancel,
}
rm.pendingReqs[req1.ID] = req1
rm.pendingReqs[req2.ID] = req2
assert.Equal(t, 2, rm.GetPendingCount())
// Remove one
delete(rm.pendingReqs, req1.ID)
assert.Equal(t, 1, rm.GetPendingCount())
// Remove all
delete(rm.pendingReqs, req2.ID)
assert.Equal(t, 0, rm.GetPendingCount())
})
t.Run("context cancellation", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer cancel()
// Wait for context to timeout
<-ctx.Done()
// Verify context was cancelled
assert.Equal(t, context.DeadlineExceeded, ctx.Err())
})
}

View File

@@ -5,13 +5,13 @@ import (
"time"
"weak"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/blang/semver"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/common"
"github.com/fxamacker/cbor/v2"
"github.com/lxzan/gws"
"golang.org/x/crypto/ssh"
)
const (
@@ -25,9 +25,10 @@ type Handler struct {
// WsConn represents a WebSocket connection to an agent.
type WsConn struct {
conn *gws.Conn
responseChan chan *gws.Message
DownChan chan struct{}
conn *gws.Conn
requestManager *RequestManager
DownChan chan struct{}
agentVersion semver.Version
}
// FingerprintRecord is fingerprints collection record data in the hub
@@ -50,21 +51,22 @@ func GetUpgrader() *gws.Upgrader {
return upgrader
}
// NewWsConnection creates a new WebSocket connection wrapper.
func NewWsConnection(conn *gws.Conn) *WsConn {
// NewWsConnection creates a new WebSocket connection wrapper with agent version.
func NewWsConnection(conn *gws.Conn, agentVersion semver.Version) *WsConn {
return &WsConn{
conn: conn,
responseChan: make(chan *gws.Message, 1),
DownChan: make(chan struct{}, 1),
conn: conn,
requestManager: NewRequestManager(conn),
DownChan: make(chan struct{}, 1),
agentVersion: agentVersion,
}
}
// OnOpen sets a deadline for the WebSocket connection.
// OnOpen sets a deadline for the WebSocket connection and extracts agent version.
func (h *Handler) OnOpen(conn *gws.Conn) {
conn.SetDeadline(time.Now().Add(deadline))
}
// OnMessage routes incoming WebSocket messages to the response channel.
// OnMessage routes incoming WebSocket messages to the request manager.
func (h *Handler) OnMessage(conn *gws.Conn, message *gws.Message) {
conn.SetDeadline(time.Now().Add(deadline))
if message.Opcode != gws.OpcodeBinary || message.Data.Len() == 0 {
@@ -75,12 +77,7 @@ func (h *Handler) OnMessage(conn *gws.Conn, message *gws.Message) {
_ = conn.WriteClose(1000, nil)
return
}
select {
case wsConn.(*WsConn).responseChan <- message:
default:
// close if the connection is not expecting a response
wsConn.(*WsConn).Close(nil)
}
wsConn.(*WsConn).requestManager.handleResponse(message)
}
// OnClose handles WebSocket connection closures and triggers system down status after delay.
@@ -106,6 +103,9 @@ func (ws *WsConn) Close(msg []byte) {
if ws.IsConnected() {
ws.conn.WriteClose(1000, msg)
}
if ws.requestManager != nil {
ws.requestManager.Close()
}
}
// Ping sends a ping frame to keep the connection alive.
@@ -115,6 +115,7 @@ func (ws *WsConn) Ping() error {
}
// sendMessage encodes data to CBOR and sends it as a binary message to the agent.
// This is kept for backwards compatibility but new actions should use RequestManager.
func (ws *WsConn) sendMessage(data common.HubRequest[any]) error {
if ws.conn == nil {
return gws.ErrConnClosed
@@ -126,54 +127,34 @@ func (ws *WsConn) sendMessage(data common.HubRequest[any]) error {
return ws.conn.WriteMessage(gws.OpcodeBinary, bytes)
}
// RequestSystemData requests system metrics from the agent and unmarshals the response.
func (ws *WsConn) RequestSystemData(data *system.CombinedData) error {
var message *gws.Message
ws.sendMessage(common.HubRequest[any]{
Action: common.GetData,
})
// handleAgentRequest processes a request to the agent, handling both legacy and new formats.
func (ws *WsConn) handleAgentRequest(req *PendingRequest, handler ResponseHandler) error {
// Wait for response
select {
case <-time.After(10 * time.Second):
ws.Close(nil)
return gws.ErrConnClosed
case message = <-ws.responseChan:
case message := <-req.ResponseCh:
defer message.Close()
// Cancel request context to stop timeout watcher promptly
defer req.Cancel()
data := message.Data.Bytes()
// Legacy format - unmarshal directly
if ws.agentVersion.LT(beszel.MinVersionAgentResponse) {
return handler.HandleLegacy(data)
}
// New format with AgentResponse wrapper
var agentResponse common.AgentResponse
if err := cbor.Unmarshal(data, &agentResponse); err != nil {
return err
}
if agentResponse.Error != "" {
return errors.New(agentResponse.Error)
}
return handler.Handle(agentResponse)
case <-req.Context.Done():
return req.Context.Err()
}
defer message.Close()
return cbor.Unmarshal(message.Data.Bytes(), data)
}
// GetFingerprint authenticates with the agent using SSH signature and returns the agent's fingerprint.
func (ws *WsConn) GetFingerprint(token string, signer ssh.Signer, needSysInfo bool) (common.FingerprintResponse, error) {
var clientFingerprint common.FingerprintResponse
challenge := []byte(token)
signature, err := signer.Sign(nil, challenge)
if err != nil {
return clientFingerprint, err
}
err = ws.sendMessage(common.HubRequest[any]{
Action: common.CheckFingerprint,
Data: common.FingerprintRequest{
Signature: signature.Blob,
NeedSysInfo: needSysInfo,
},
})
if err != nil {
return clientFingerprint, err
}
var message *gws.Message
select {
case message = <-ws.responseChan:
case <-time.After(10 * time.Second):
return clientFingerprint, errors.New("request expired")
}
defer message.Close()
err = cbor.Unmarshal(message.Data.Bytes(), &clientFingerprint)
return clientFingerprint, err
}
// IsConnected returns true if the WebSocket connection is active.

View File

@@ -8,6 +8,7 @@ import (
"testing"
"time"
"github.com/blang/semver"
"github.com/henrygd/beszel/internal/common"
"github.com/fxamacker/cbor/v2"
@@ -36,26 +37,25 @@ func TestGetUpgrader(t *testing.T) {
// TestNewWsConnection tests WebSocket connection creation
func TestNewWsConnection(t *testing.T) {
// We can't easily mock gws.Conn, so we'll pass nil and test the structure
wsConn := NewWsConnection(nil)
wsConn := NewWsConnection(nil, semver.MustParse("0.12.10"))
assert.NotNil(t, wsConn, "WebSocket connection should not be nil")
assert.Nil(t, wsConn.conn, "Connection should be nil as passed")
assert.NotNil(t, wsConn.responseChan, "Response channel should be initialized")
assert.NotNil(t, wsConn.requestManager, "Request manager should be initialized")
assert.NotNil(t, wsConn.DownChan, "Down channel should be initialized")
assert.Equal(t, 1, cap(wsConn.responseChan), "Response channel should have capacity of 1")
assert.Equal(t, 1, cap(wsConn.DownChan), "Down channel should have capacity of 1")
}
// TestWsConn_IsConnected tests the connection status check
func TestWsConn_IsConnected(t *testing.T) {
// Test with nil connection
wsConn := NewWsConnection(nil)
wsConn := NewWsConnection(nil, semver.MustParse("0.12.10"))
assert.False(t, wsConn.IsConnected(), "Should not be connected when conn is nil")
}
// TestWsConn_Close tests the connection closing with nil connection
func TestWsConn_Close(t *testing.T) {
wsConn := NewWsConnection(nil)
wsConn := NewWsConnection(nil, semver.MustParse("0.12.10"))
// Should handle nil connection gracefully
assert.NotPanics(t, func() {
@@ -65,7 +65,7 @@ func TestWsConn_Close(t *testing.T) {
// TestWsConn_SendMessage_CBOR tests CBOR encoding in sendMessage
func TestWsConn_SendMessage_CBOR(t *testing.T) {
wsConn := NewWsConnection(nil)
wsConn := NewWsConnection(nil, semver.MustParse("0.12.10"))
testData := common.HubRequest[any]{
Action: common.GetData,
@@ -194,7 +194,7 @@ func TestHandler(t *testing.T) {
// TestWsConnChannelBehavior tests channel behavior without WebSocket connections
func TestWsConnChannelBehavior(t *testing.T) {
wsConn := NewWsConnection(nil)
wsConn := NewWsConnection(nil, semver.MustParse("0.12.10"))
// Test that channels are properly initialized and can be used
select {
@@ -212,11 +212,6 @@ func TestWsConnChannelBehavior(t *testing.T) {
t.Error("Should be able to read from DownChan")
}
// Response channel should be empty initially
select {
case <-wsConn.responseChan:
t.Error("Response channel should be empty initially")
default:
// Expected - channel should be empty
}
// Request manager should have no pending requests initially
assert.Equal(t, 0, wsConn.requestManager.GetPendingCount(), "Should have no pending requests initially")
}

View File

@@ -0,0 +1,11 @@
//go:build testing
// +build testing
package ws
// GetPendingCount returns the number of pending requests (for monitoring)
func (rm *RequestManager) GetPendingCount() int {
rm.RLock()
defer rm.RUnlock()
return len(rm.pendingReqs)
}

View File

@@ -213,6 +213,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
sum.LoadAvg[2] += stats.LoadAvg[2]
sum.Bandwidth[0] += stats.Bandwidth[0]
sum.Bandwidth[1] += stats.Bandwidth[1]
sum.DiskIO[0] += stats.DiskIO[0]
sum.DiskIO[1] += stats.DiskIO[1]
batterySum += int(stats.Battery[0])
sum.Battery[1] = stats.Battery[1]
// Set peak values
@@ -224,6 +226,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
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])
sum.MaxDiskIO[0] = max(sum.MaxDiskIO[0], stats.MaxDiskIO[0], stats.DiskIO[0])
sum.MaxDiskIO[1] = max(sum.MaxDiskIO[1], stats.MaxDiskIO[1], stats.DiskIO[1])
// Accumulate network interfaces
if sum.NetworkInterfaces == nil {
@@ -314,6 +318,8 @@ 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.DiskIO[0] = sum.DiskIO[0] / uint64(count)
sum.DiskIO[1] = sum.DiskIO[1] / uint64(count)
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count)
@@ -408,6 +414,8 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
sums[stat.Name].Mem += stat.Mem
sums[stat.Name].NetworkSent += stat.NetworkSent
sums[stat.Name].NetworkRecv += stat.NetworkRecv
sums[stat.Name].DiskRead += stat.DiskRead
sums[stat.Name].DiskWrite += stat.DiskWrite
}
}
@@ -419,6 +427,8 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
Mem: twoDecimals(value.Mem / count),
NetworkSent: twoDecimals(value.NetworkSent / count),
NetworkRecv: twoDecimals(value.NetworkRecv / count),
DiskRead: twoDecimals(value.DiskRead / count),
DiskWrite: twoDecimals(value.DiskWrite / count),
})
}
return result

View File

@@ -1,41 +1,83 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.3/schema.json",
"vcs": {
"enabled": false,
"enabled": true,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false
"useIgnoreFile": true,
"defaultBranch": "main"
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"indentWidth": 2,
"lineWidth": 120
"lineWidth": 120,
"formatWithErrors": true
},
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"complexity": {
"noUselessStringConcat": "error",
"noUselessUndefinedInitialization": "error",
"noVoid": "error",
"useDateNow": "error"
},
"correctness": {
"useUniqueElementIds": "off"
"noConstantMathMinMaxClamp": "error",
"noUndeclaredVariables": "error",
"noUnusedImports": "error",
"noUnusedFunctionParameters": "error",
"noUnusedPrivateClassMembers": "error",
"useExhaustiveDependencies": {
"level": "error",
"options": {
"reportUnnecessaryDependencies": false
}
},
"noUnusedVariables": "error"
},
"style": {
"noParameterProperties": "error",
"noYodaExpression": "error",
"useConsistentBuiltinInstantiation": "error",
"useFragmentSyntax": "error",
"useShorthandAssign": "error",
"useArrayLiterals": "error"
},
"suspicious": {
"useAwait": "error",
"noEvolvingTypes": "error"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "asNeeded",
"trailingCommas": "es5"
"trailingCommas": "es5",
"semicolons": "asNeeded"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
"overrides": [
{
"includes": ["**/*.jsx", "**/*.tsx"],
"linter": {
"rules": {
"style": {
"noParameterAssign": "error"
}
}
}
},
{
"includes": ["**/*.ts", "**/*.tsx"],
"linter": {
"rules": {
"correctness": {
"noUnusedVariables": "off"
}
}
}
}
}
]
}

View File

@@ -3,8 +3,9 @@
<head>
<meta charset="UTF-8" />
<link rel="manifest" href="./static/manifest.json" />
<link rel="icon" type="image/svg+xml" href="./static/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="./static/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0,maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="robots" content="noindex, nofollow" />
<title>Beszel</title>
<script>
globalThis.BESZEL = {

View File

@@ -1,12 +1,12 @@
{
"name": "beszel",
"version": "0.12.12",
"version": "0.13.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "beszel",
"version": "0.12.12",
"version": "0.13.2",
"dependencies": {
"@henrygd/queue": "^1.0.7",
"@henrygd/semaphore": "^0.0.2",
@@ -69,7 +69,7 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -83,7 +83,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.27.1",
@@ -98,7 +98,7 @@
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz",
"integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -108,7 +108,7 @@
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz",
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.0",
@@ -139,7 +139,7 @@
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz",
"integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.3",
@@ -156,7 +156,7 @@
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
"integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.27.2",
@@ -173,7 +173,7 @@
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -183,7 +183,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.27.1",
@@ -197,7 +197,7 @@
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
"integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.27.1",
@@ -215,7 +215,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -225,7 +225,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -235,7 +235,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -245,7 +245,7 @@
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz",
"integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.27.2",
@@ -259,7 +259,7 @@
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz",
"integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.2"
@@ -287,7 +287,7 @@
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
@@ -302,7 +302,7 @@
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz",
"integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
@@ -321,7 +321,7 @@
"version": "7.28.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz",
"integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
@@ -1020,7 +1020,7 @@
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
"integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@sinclair/typebox": "^0.27.8"
@@ -1033,7 +1033,7 @@
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz",
"integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jest/schemas": "^29.6.3",
@@ -1051,7 +1051,7 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -1073,7 +1073,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -1083,14 +1083,14 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.30",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz",
"integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -1111,7 +1111,7 @@
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/@lingui/babel-plugin-lingui-macro/-/babel-plugin-lingui-macro-5.4.1.tgz",
"integrity": "sha512-9IO+PDvdneY8OCI8zvI1oDXpzryTMtyRv7uq9O0U1mFCvIPVd5dWQKQDu/CpgpYAc2+JG/izn5PNl9xzPc6ckw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.20.12",
@@ -1331,7 +1331,7 @@
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/@lingui/conf/-/conf-5.4.1.tgz",
"integrity": "sha512-aDkj/bMSr/mCL8Nr1TS52v0GLCuVa4YqtRz+WvUCFZw/ovVInX0hKq1TClx/bSlhu60FzB/CbclxFMBw8aLVUg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
@@ -2750,7 +2750,7 @@
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
"integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@swc/core": {
@@ -3420,14 +3420,14 @@
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
"integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/istanbul-lib-report": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz",
"integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/istanbul-lib-coverage": "*"
@@ -3437,7 +3437,7 @@
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz",
"integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/istanbul-lib-report": "*"
@@ -3447,7 +3447,7 @@
"version": "24.3.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
"integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.10.0"
@@ -3457,7 +3457,7 @@
"version": "19.1.11",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.11.tgz",
"integrity": "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -3467,7 +3467,7 @@
"version": "19.1.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz",
"integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.0.0"
@@ -3477,7 +3477,7 @@
"version": "17.0.33",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
"integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/yargs-parser": "*"
@@ -3487,7 +3487,7 @@
"version": "21.0.3",
"resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz",
"integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@vitejs/plugin-react-swc": {
@@ -3524,7 +3524,7 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -3567,7 +3567,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"devOptional": true,
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
@@ -3662,7 +3662,7 @@
"version": "4.25.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
"integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==",
"dev": true,
"devOptional": true,
"funding": [
{
"type": "opencollective",
@@ -3733,7 +3733,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -3743,7 +3743,7 @@
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
"integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -3756,7 +3756,7 @@
"version": "1.0.30001727",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
"integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
"dev": true,
"devOptional": true,
"funding": [
{
"type": "opencollective",
@@ -3777,7 +3777,7 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
@@ -3889,7 +3889,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -3902,7 +3902,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/colors": {
@@ -3919,14 +3919,14 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/cosmiconfig": {
"version": "8.3.6",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
"integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"import-fresh": "^3.3.0",
@@ -4106,7 +4106,7 @@
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -4176,7 +4176,7 @@
"version": "1.5.182",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.182.tgz",
"integrity": "sha512-Lv65Btwv9W4J9pyODI6EWpdnhfvrve/us5h1WspW8B2Fb0366REPtY3hX7ounk1CkV/TBjWCEvCBBbYbmV0qCA==",
"dev": true,
"devOptional": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
@@ -4204,7 +4204,7 @@
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
"integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.2.1"
@@ -4273,7 +4273,7 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -4361,7 +4361,7 @@
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -4387,7 +4387,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -4418,7 +4418,7 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"parent-module": "^1.0.0",
@@ -4461,7 +4461,7 @@
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/is-binary-path": {
@@ -4554,7 +4554,7 @@
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz",
"integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
@@ -4564,7 +4564,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz",
"integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jest/types": "^29.6.3",
@@ -4582,7 +4582,7 @@
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"devOptional": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@@ -4604,7 +4604,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -4617,7 +4617,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
@@ -4630,14 +4630,14 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
@@ -4650,7 +4650,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
"integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -4899,7 +4899,7 @@
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/lodash": {
@@ -4948,7 +4948,7 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"dev": true,
"devOptional": true,
"license": "ISC",
"dependencies": {
"yallist": "^3.0.2"
@@ -5059,7 +5059,7 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -5100,7 +5100,7 @@
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/normalize-path": {
@@ -5196,7 +5196,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"callsites": "^3.0.0"
@@ -5209,7 +5209,7 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.0.0",
@@ -5238,7 +5238,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -5248,7 +5248,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"devOptional": true,
"license": "ISC"
},
"node_modules/picomatch": {
@@ -5310,7 +5310,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
"integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jest/schemas": "^29.6.3",
@@ -5325,7 +5325,7 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -5571,7 +5571,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=4"
@@ -5669,7 +5669,7 @@
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"devOptional": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -5852,7 +5852,7 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
@@ -5986,7 +5986,7 @@
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -6000,14 +6000,14 @@
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
"dev": true,
"devOptional": true,
"funding": [
{
"type": "opencollective",
@@ -6339,7 +6339,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"devOptional": true,
"license": "ISC"
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "beszel",
"private": true,
"version": "0.12.12",
"version": "0.13.2",
"type": "module",
"scripts": {
"dev": "vite --host",

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 70" fill="#22c55e"><path d="M35 70H0V0h35q4.4 0 8.2 1.7a21.4 21.4 0 0 1 6.6 4.5q2.9 2.8 4.5 6.6Q56 16.7 56 21a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5Q53 39.1 54 41a18.3 18.3 0 0 1 1.5 4 17.4 17.4 0 0 1 .5 3 15.3 15.3 0 0 1 0 1q0 4.4-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.6Q39.4 70 35 70ZM14 14v14h21a7 7 0 0 0 2.3-.3 6.6 6.6 0 0 0 .4-.2Q39 27 40 26a6.9 6.9 0 0 0 1.5-2.2q.5-1.3.5-2.8a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 17 40 16a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm0 28v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 54.9 40 54a7 7 0 0 0 1.5-2.2 6.9 6.9 0 0 0 .5-2.6 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 45 40 44a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Z"/></svg>

Before

Width:  |  Height:  |  Size: 906 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 70" fill="#dc2626"><path d="M35 70H0V0h35q4.4 0 8.2 1.7a21.4 21.4 0 0 1 6.6 4.5q2.9 2.8 4.5 6.6Q56 16.7 56 21a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5Q53 39.1 54 41a18.3 18.3 0 0 1 1.5 4 17.4 17.4 0 0 1 .5 3 15.3 15.3 0 0 1 0 1q0 4.4-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.6Q39.4 70 35 70ZM14 14v14h21a7 7 0 0 0 2.3-.3 6.6 6.6 0 0 0 .4-.2Q39 27 40 26a6.9 6.9 0 0 0 1.5-2.2q.5-1.3.5-2.8a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 17 40 16a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm0 28v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 54.9 40 54a7 7 0 0 0 1.5-2.2 6.9 6.9 0 0 0 .5-2.6 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 45 40 44a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Z"/></svg>

Before

Width:  |  Height:  |  Size: 906 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 70" fill="#888"><path d="M35 70H0V0h35q4.4 0 8.2 1.7a21.4 21.4 0 0 1 6.6 4.5q2.9 2.8 4.5 6.6Q56 16.7 56 21a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5Q53 39.1 54 41a18.3 18.3 0 0 1 1.5 4 17.4 17.4 0 0 1 .5 3 15.3 15.3 0 0 1 0 1q0 4.4-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.6Q39.4 70 35 70ZM14 14v14h21a7 7 0 0 0 2.3-.3 6.6 6.6 0 0 0 .4-.2Q39 27 40 26a6.9 6.9 0 0 0 1.5-2.2q.5-1.3.5-2.8a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 17 40 16a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm0 28v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 54.9 40 54a7 7 0 0 0 1.5-2.2 6.9 6.9 0 0 0 .5-2.6 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 45 40 44a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Z"/></svg>

Before

Width:  |  Height:  |  Size: 903 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 70">
<defs>
<linearGradient id="gradient" x1="0%" y1="20%" x2="100%" y2="120%">
<stop offset="0%" style="stop-color:#747bff"/>
<stop offset="100%" style="stop-color:#24eb5c"/>
</linearGradient>
</defs>
<path fill="url(#gradient)" d="M35 70H0V0h35q4.4 0 8.2 1.7a21.4 21.4 0 0 1 6.6 4.5q2.9 2.8 4.5 6.6Q56 16.7 56 21a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5Q53 39.1 54 41a18.3 18.3 0 0 1 1.5 4 17.4 17.4 0 0 1 .5 3 15.3 15.3 0 0 1 0 1q0 4.4-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.6Q39.4 70 35 70ZM14 14v14h21a7 7 0 0 0 2.3-.3 6.6 6.6 0 0 0 .4-.2Q39 27 40 26a6.9 6.9 0 0 0 1.5-2.2q.5-1.3.5-2.8a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 17 40 16a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm0 28v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 54.9 40 54a7 7 0 0 0 1.5-2.2 6.9 6.9 0 0 0 .5-2.6 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 45 40 44a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -2,12 +2,27 @@ import { useStore } from "@nanostores/react"
import { HistoryIcon } from "lucide-react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { $chartTime } from "@/lib/stores"
import { chartTimeData, cn } from "@/lib/utils"
import type { ChartTimes } from "@/types"
import { chartTimeData, cn, compareSemVer, parseSemVer } from "@/lib/utils"
import type { ChartTimes, SemVer } from "@/types"
import { memo } from "react"
export default function ChartTimeSelect({ className }: { className?: string }) {
export default memo(function ChartTimeSelect({
className,
agentVersion,
}: {
className?: string
agentVersion: SemVer
}) {
const chartTime = useStore($chartTime)
// remove chart times that are not supported by the system agent version
const availableChartTimes = Object.entries(chartTimeData).filter(([_, { minVersion }]) => {
if (!minVersion) {
return true
}
return compareSemVer(agentVersion, parseSemVer(minVersion)) >= 0
})
return (
<Select defaultValue="1h" value={chartTime} onValueChange={(value: ChartTimes) => $chartTime.set(value)}>
<SelectTrigger className={cn(className, "relative ps-10 pe-5")}>
@@ -15,7 +30,7 @@ export default function ChartTimeSelect({ className }: { className?: string }) {
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(chartTimeData).map(([value, { label }]) => (
{availableChartTimes.map(([value, { label }]) => (
<SelectItem key={value} value={value}>
{label()}
</SelectItem>
@@ -23,4 +38,4 @@ export default function ChartTimeSelect({ className }: { className?: string }) {
</SelectContent>
</Select>
)
}
})

View File

@@ -1,20 +1,32 @@
// import Spinner from '../spinner'
import { useStore } from "@nanostores/react"
import { memo, useMemo } from "react"
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import React from "react"
import { Area, AreaChart, CartesianGrid, Line, LineChart, YAxis } from "recharts"
import { Badge } from "@/components/ui/badge"
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { Separator } from "@/components/ui/separator"
import { ChartType, Unit } from "@/lib/enums"
import { $containerFilter, $userSettings } from "@/lib/stores"
import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils"
import { $containerColors, $containerFilter, $stackFilter, $userSettings } from "@/lib/stores"
import {
chartMargin,
cn,
decimalString,
formatBytes,
formatShortDate,
generateFallbackColor,
getSizeAndUnit,
toFixedFloat,
toFixedWithoutTrailingZeros,
} from "@/lib/utils"
import type { ChartData } from "@/types"
import { Separator } from "../ui/separator"
import { useYAxisWidth } from "./hooks"
export default memo(function ContainerChart({
dataKey,
chartData,
chartType,
chartConfig,
chartConfig: propChartConfig,
unit = "%",
}: {
dataKey: string
@@ -23,13 +35,168 @@ export default memo(function ContainerChart({
chartConfig: ChartConfig
unit?: string
}) {
const filter = useStore($containerFilter)
const containerFilter = useStore($containerFilter)
const stackFilter = useStore($stackFilter)
const containerColors = useStore($containerColors)
const userSettings = useStore($userSettings)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { containerData } = chartData
const isNetChart = chartType === ChartType.Network
const isVolumeChart = chartType === ChartType.Volume
const isHealthChart = chartType === ChartType.Health
const isUptimeChart = chartType === ChartType.Uptime
const isHealthUptimeChart = chartType === ChartType.HealthUptime
const isDiskIOChart = chartType === ChartType.DiskIO
// Centralized data processing for all chart types
const chartDatasets = useMemo(() => {
const volumeChartData = { data: [], colors: {} } as {
data: Record<string, number | string>[]
colors: Record<string, string>
}
const healthChartData = { data: [], colors: {} } as {
data: Record<string, number | string>[]
colors: Record<string, string>
}
const uptimeChartData = { data: [], colors: {} } as {
data: Record<string, number | string>[]
colors: Record<string, string>
}
const healthUptimeChartData = { data: [], colors: {} } as {
data: Record<string, number | string>[]
colors: Record<string, string>
}
const containerChartConfig = {} as Record<string, { label: string; color: string }>
const volumeSums: Record<string, number> = {}
const volumeContainers: Record<string, string[]> = {}
const allContainerNames = new Set<string>()
const healthUptimeContainerNames = new Set<string>()
for (const containerStats of containerData) {
if (!containerStats.created) {
// For gaps in data
volumeChartData.data.push({ created: "" })
healthChartData.data.push({ created: "" })
uptimeChartData.data.push({ created: "" })
healthUptimeChartData.data.push({ created: "" })
continue
}
const volumeData = { created: containerStats.created } as Record<string, number | string>
const healthData = { created: containerStats.created } as Record<string, number | string>
const uptimeData = { created: containerStats.created } as Record<string, number | string>
const healthUptimeData = { created: containerStats.created } as Record<string, number | string>
for (const [containerName, containerDataObj] of Object.entries(containerStats)) {
if (containerName === "created") continue
// Apply container filter
if (containerFilter.length > 0 && !containerFilter.includes(containerName)) {
continue
}
// Apply stack filter
if (stackFilter.length > 0 && typeof containerDataObj === "object" && containerDataObj) {
const stackName = (containerDataObj as any).p || "—"
if (!stackFilter.includes(stackName)) {
continue
}
}
allContainerNames.add(containerName)
if (typeof containerDataObj === "object" && containerDataObj) {
// Volume
if ("v" in containerDataObj && containerDataObj.v) {
for (const [volumeName, volumeSize] of Object.entries(containerDataObj.v)) {
if (typeof volumeSize === "number" && volumeSize > 0) {
volumeData[volumeName] = ((volumeData[volumeName] as number) || 0) + volumeSize
volumeSums[volumeName] = (volumeSums[volumeName] ?? 0) + volumeSize
if (!volumeContainers[volumeName]) volumeContainers[volumeName] = []
if (!volumeContainers[volumeName].includes(containerName))
volumeContainers[volumeName].push(containerName)
}
}
}
// Health
if ("h" in containerDataObj) {
const healthStatus = ((containerDataObj.h as string) || "").toLowerCase()
let healthValue = 0
switch (healthStatus) {
case "healthy":
healthValue = 3
break
case "starting":
healthValue = 2
break
case "unhealthy":
healthValue = 1
break
default:
healthValue = 0
}
healthData[containerName] = healthValue
// Health+Uptime
healthUptimeData[`${containerName}_health`] = healthValue
healthUptimeContainerNames.add(containerName)
}
// Uptime
if ("u" in containerDataObj && containerDataObj.u) {
uptimeData[containerName] = (containerDataObj.u as number) / 3600
// Health+Uptime
healthUptimeData[`${containerName}_uptime`] = (containerDataObj.u as number) / 3600
healthUptimeContainerNames.add(containerName)
}
}
}
volumeChartData.data.push(volumeData)
healthChartData.data.push(healthData)
uptimeChartData.data.push(uptimeData)
healthUptimeChartData.data.push(healthUptimeData)
}
// Only process volumes attached to containers
const volumeKeys = Object.keys(volumeSums)
.filter((key) => (volumeContainers[key] || []).length > 0)
.sort((a, b) => volumeSums[b] - volumeSums[a])
for (const key of volumeKeys) {
const containers = volumeContainers[key] || []
const firstContainer = containers[0]
volumeChartData.colors[key] =
containerColors[firstContainer] || generateFallbackColor(firstContainer)
}
const healthKeys = Object.keys(healthChartData.data[0] || {}).filter((key) => key !== "created")
for (const key of healthKeys) {
healthChartData.colors[key] = containerColors[key] || generateFallbackColor(key)
}
const uptimeKeys = Object.keys(uptimeChartData.data[0] || {}).filter((key) => key !== "created")
for (const key of uptimeKeys) {
uptimeChartData.colors[key] = containerColors[key] || generateFallbackColor(key)
}
for (const containerName of healthUptimeContainerNames) {
const color = containerColors[containerName] || generateFallbackColor(containerName)
healthUptimeChartData.colors[`${containerName}_uptime`] = color
healthUptimeChartData.colors[`${containerName}_health`] = color
}
for (const containerName of allContainerNames) {
const color = containerColors[containerName] || generateFallbackColor(containerName)
containerChartConfig[containerName] = { label: containerName, color }
}
return {
volumeChartData,
healthChartData,
uptimeChartData,
healthUptimeChartData,
containerChartConfig,
}
}, [containerData, containerColors, containerFilter, stackFilter])
const { volumeChartData, healthChartData, uptimeChartData, healthUptimeChartData, containerChartConfig } =
chartDatasets
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
const { toolTipFormatter, dataFunction, tickFormatter } = useMemo(() => {
@@ -41,9 +208,40 @@ export default memo(function ContainerChart({
// tick formatter
if (chartType === ChartType.CPU) {
obj.tickFormatter = (value) => {
const val = toFixedFloat(value, 2) + unit
const val = `${toFixedWithoutTrailingZeros(value, 2)}${unit}`
return updateYAxisWidth(val)
}
} else if (isHealthChart) {
obj.tickFormatter = (value) => {
let healthLabel = "Unknown"
switch (value) {
case 3:
healthLabel = "Healthy"
break
case 2:
healthLabel = "Starting"
break
case 1:
healthLabel = "Unhealthy"
break
case 0:
healthLabel = "None"
break
}
return updateYAxisWidth(healthLabel)
}
} else if (isUptimeChart) {
obj.tickFormatter = (value) => {
const hours = Math.floor(value)
const minutes = Math.floor((value - hours) * 60)
const label = `${hours}h ${minutes}m`
return updateYAxisWidth(label)
}
} else if (isVolumeChart || isDiskIOChart) {
obj.tickFormatter = (value) => {
const { v, u } = getSizeAndUnit(value, false)
return updateYAxisWidth(`${toFixedFloat(v, 2)}${u}${isDiskIOChart ? "/s" : ""}`)
}
} else {
const chartUnit = isNetChart ? userSettings.unitNet : Unit.Bytes
obj.tickFormatter = (val) => {
@@ -57,7 +255,12 @@ export default memo(function ContainerChart({
try {
const sent = item?.payload?.[key]?.ns ?? 0
const received = item?.payload?.[key]?.nr ?? 0
const { value: receivedValue, unit: receivedUnit } = formatBytes(received, true, userSettings.unitNet, true)
const { value: receivedValue, unit: receivedUnit } = formatBytes(
received,
true,
userSettings.unitNet,
true
)
const { value: sentValue, unit: sentUnit } = formatBytes(sent, true, userSettings.unitNet, true)
return (
<span className="flex">
@@ -72,17 +275,74 @@ export default memo(function ContainerChart({
return null
}
}
} else if (isDiskIOChart) {
obj.toolTipFormatter = (item: any, key: string) => {
try {
const read = item?.payload?.[key]?.dr ?? 0
const write = item?.payload?.[key]?.dw ?? 0
return (
<span className="flex">
{decimalString(read)} MB/s
<span className="opacity-70 ms-0.5"> read </span>
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" />
{decimalString(write)} MB/s
<span className="opacity-70 ms-0.5"> write</span>
</span>
)
} catch (e) {
return null
}
}
} else if (chartType === ChartType.Memory) {
obj.toolTipFormatter = (item: any) => {
const { value, unit } = formatBytes(item.value, false, Unit.Bytes, true)
return `${decimalString(value)} ${unit}`
}
} else if (isVolumeChart) {
obj.toolTipFormatter = (item: any) => {
const { v, u } = getSizeAndUnit(item.value, false)
return `${decimalString(v, 2)}${u}`
}
} else if (isHealthChart) {
obj.toolTipFormatter = (item: any) => {
let healthLabel = "Unknown"
switch (item.value) {
case 3:
healthLabel = "Healthy"
break
case 2:
healthLabel = "Starting"
break
case 1:
healthLabel = "Unhealthy"
break
case 0:
healthLabel = "None"
break
}
return healthLabel
}
} else if (isUptimeChart) {
obj.toolTipFormatter = (item: any) => {
const hours = Math.floor(item.value)
const minutes = Math.floor((item.value - hours) * 60)
const days = Math.floor(hours / 24)
const remainingHours = hours % 24
if (days > 0) {
return `${days}d ${remainingHours}h ${minutes}m`
}
return `${hours}h ${minutes}m`
}
} else {
obj.toolTipFormatter = (item: any) => `${decimalString(item.value)} ${unit}`
}
// data function
if (isNetChart) {
obj.dataFunction = (key: string, data: any) => (data[key] ? data[key].nr + data[key].ns : null)
} else if (isDiskIOChart) {
obj.dataFunction = (key: string, data: any) =>
data[key] ? (data[key].dr || 0) + (data[key].dw || 0) : null
} else {
obj.dataFunction = (key: string, data: any) => data[key]?.[dataKey] ?? null
}
@@ -91,12 +351,11 @@ export default memo(function ContainerChart({
// Filter with set lookup
const filteredKeys = useMemo(() => {
if (!filter) {
if (!containerFilter || containerFilter.length === 0) {
return new Set<string>()
}
const filterLower = filter.toLowerCase()
return new Set(Object.keys(chartConfig).filter((key) => !key.toLowerCase().includes(filterLower)))
}, [chartConfig, filter])
return new Set(Object.keys(containerChartConfig).filter((key) => !containerFilter.includes(key)))
}, [containerChartConfig, containerFilter])
// console.log('rendered at', new Date())
@@ -104,8 +363,228 @@ export default memo(function ContainerChart({
return null
}
// For volume charts, check if we have volume data
if (isVolumeChart) {
if (!volumeChartData || Object.keys(volumeChartData.colors).length === 0) {
return null
}
}
// For health charts, check if we have health data
if (isHealthChart) {
if (!healthChartData || Object.keys(healthChartData.colors).length === 0) {
return null
}
}
// For uptime charts, check if we have uptime data
if (isUptimeChart) {
if (!uptimeChartData || Object.keys(uptimeChartData.colors).length === 0) {
return null
}
}
// For combined health+uptime chart
if (isHealthUptimeChart) {
if (!healthUptimeChartData || healthUptimeChartData.data.length === 0) return null
return (
<HealthUptimeTable
containerData={containerData}
healthUptimeChartData={healthUptimeChartData}
containerColors={containerColors}
filter={containerFilter}
/>
)
}
// Only show selected containers, or all if none selected
const filterableKeys = isVolumeChart
? Object.keys(containerChartConfig)
: Object.keys(containerChartConfig).filter(
(key) =>
!(
containerChartConfig[key] &&
containerChartConfig[key].label &&
containerChartConfig[key].label.startsWith("(orphaned volume)")
)
)
// Render volume chart
if (isVolumeChart) {
const colors = Object.keys(volumeChartData!.colors)
return (
<div className="w-full h-full">
<ChartContainer
className={cn("h-full w-full absolute bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<AreaChart
accessibilityLayer
data={volumeChartData!.data}
margin={chartMargin}
reverseStackOrder={true}
>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
domain={[0, "auto"]}
width={yAxisWidth}
tickFormatter={(value) => {
const { v, u } = getSizeAndUnit(value, false)
return updateYAxisWidth(`${toFixedFloat(v, 2)}${u}`)
}}
tickLine={false}
axisLine={false}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
// @ts-expect-error
itemSorter={(a, b) => b.value - a.value}
content={
<ChartTooltipContent
truncate={true}
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={toolTipFormatter}
/>
}
/>
{colors.map((key) => (
<Area
key={key}
dataKey={key}
name={key}
type="monotoneX"
fill={volumeChartData!.colors[key]}
fillOpacity={0.4}
stroke={volumeChartData!.colors[key]}
strokeOpacity={1}
activeDot={{ opacity: 1 }}
stackId="a"
isAnimationActive={false}
/>
))}
</AreaChart>
</ChartContainer>
</div>
)
}
// Render health chart
if (isHealthChart) {
const colors = Object.keys(healthChartData!.colors)
return (
<div className="w-full h-full">
<ChartContainer
className={cn("h-full w-full absolute bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<LineChart accessibilityLayer data={healthChartData!.data} margin={chartMargin}>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
domain={[0, 3]}
width={yAxisWidth}
tickFormatter={tickFormatter}
tickLine={false}
axisLine={false}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
// @ts-expect-error
itemSorter={(a, b) => b.value - a.value}
content={
<ChartTooltipContent
truncate={true}
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={toolTipFormatter}
/>
}
/>
{colors.map((key) => (
<Line
key={key}
dataKey={key}
name={key}
type="monotoneX"
dot={false}
strokeWidth={1.5}
stroke={healthChartData!.colors[key]}
isAnimationActive={false}
/>
))}
</LineChart>
</ChartContainer>
</div>
)
}
// Render uptime chart
if (isUptimeChart) {
const colors = Object.keys(uptimeChartData!.colors)
return (
<div className="w-full h-full">
<ChartContainer
className={cn("h-full w-full absolute bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<LineChart accessibilityLayer data={uptimeChartData!.data} margin={chartMargin}>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
domain={[0, "auto"]}
width={yAxisWidth}
tickFormatter={tickFormatter}
tickLine={false}
axisLine={false}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
// @ts-expect-error
itemSorter={(a, b) => b.value - a.value}
content={
<ChartTooltipContent
truncate={true}
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={toolTipFormatter}
/>
}
/>
{colors.map((key) => (
<Line
key={key}
dataKey={key}
name={key}
type="monotoneX"
dot={false}
strokeWidth={1.5}
stroke={uptimeChartData!.colors[key]}
isAnimationActive={false}
/>
))}
</LineChart>
</ChartContainer>
</div>
)
}
// Render regular container chart (Area chart)
return (
<div>
<div className="w-full h-full">
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
@@ -136,9 +615,14 @@ export default memo(function ContainerChart({
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
// @ts-expect-error
itemSorter={(a, b) => b.value - a.value}
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} />}
content={
<ChartTooltipContent
filter={containerFilter.length > 0 ? containerFilter.join(",") : undefined}
contentFormatter={toolTipFormatter}
/>
}
/>
{Object.keys(chartConfig).map((key) => {
{filterableKeys.map((key) => {
const filtered = filteredKeys.has(key)
const fillOpacity = filtered ? 0.05 : 0.4
const strokeOpacity = filtered ? 0.1 : 1
@@ -149,11 +633,11 @@ export default memo(function ContainerChart({
dataKey={dataFunction.bind(null, key)}
name={key}
type="monotoneX"
fill={chartConfig[key].color}
fill={containerChartConfig[key].color}
fillOpacity={fillOpacity}
stroke={chartConfig[key].color}
stroke={containerChartConfig[key].color}
strokeOpacity={strokeOpacity}
activeDot={{ opacity: filtered ? 0 : 1 }}
activeDot={{ opacity: 1 }}
stackId="a"
/>
)
@@ -163,3 +647,358 @@ export default memo(function ContainerChart({
</div>
)
})
// Extracted HealthUptimeTable component
const HealthUptimeTable = React.memo(function HealthUptimeTable({
containerData,
healthUptimeChartData,
containerColors,
filter,
}: {
containerData: any[]
healthUptimeChartData: { data: any[]; colors: Record<string, string> }
containerColors: Record<string, string>
filter: string[]
}) {
const stackFilter = useStore($stackFilter)
// Get the latest data point for table display
const latestData = healthUptimeChartData.data[healthUptimeChartData.data.length - 1]
if (!latestData) return null
// Extract container data for table
const containerTableData = React.useMemo(() => {
const containerNames = new Set<string>()
for (const key of Object.keys(latestData)) {
if (key === "created") continue
const containerName = key.replace(/_uptime$/, "").replace(/_health$/, "")
// Skip orphaned volumes
if (containerName.startsWith("(orphaned volume)")) continue
containerNames.add(containerName)
}
const tableData = []
for (const containerName of containerNames) {
const uptimeKey = `${containerName}_uptime`
const healthKey = `${containerName}_health`
const uptimeHours = latestData[uptimeKey]
const healthValue = latestData[healthKey] || 0
let healthStatus = "Unknown"
switch (healthValue) {
case 3:
healthStatus = "Healthy"
break
case 2:
healthStatus = "Starting"
break
case 1:
healthStatus = "Unhealthy"
break
case 0:
healthStatus = "None"
break
}
let uptimeDisplay = "N/A"
if (typeof uptimeHours === "number" && !Number.isNaN(uptimeHours)) {
const hours = Math.floor(uptimeHours)
const minutes = Math.floor((uptimeHours - hours) * 60)
const days = Math.floor(hours / 24)
const remainingHours = hours % 24
if (days > 0) {
uptimeDisplay = `${days}d ${remainingHours}h ${minutes}m`
} else {
uptimeDisplay = `${hours}h ${minutes}m`
}
}
let stackName = "—"
let statusInfo = "—"
let idShort = ""
for (let i = containerData.length - 1; i >= 0; i--) {
const containerStats = containerData[i]
if (containerStats.created && containerStats[containerName]) {
const containerDataObj = containerStats[containerName]
if (typeof containerDataObj === "object" && containerDataObj) {
if ("p" in containerDataObj) {
stackName = containerDataObj.p as string
}
if ("s" in containerDataObj) {
statusInfo = containerDataObj.s as string
}
if ("idShort" in containerDataObj) {
idShort = containerDataObj.idShort as string
}
break
}
}
}
tableData.push({
name: containerName,
idShort,
health: healthStatus,
status: statusInfo,
uptime: uptimeDisplay,
uptimeHours: uptimeHours,
healthValue: healthValue,
stack: stackName,
color: containerColors[containerName] || generateFallbackColor(containerName),
})
}
return tableData
}, [containerData, latestData, containerColors])
// Sort and filter state
const [sortField, setSortField] = React.useState<"name" | "idShort" | "stack" | "health" | "status" | "uptime">(
"uptime"
)
const [sortDirection, setSortDirection] = React.useState<"asc" | "desc">("desc")
const [currentPage, setCurrentPage] = React.useState(1)
const containersPerPage = 4
// Filtered data
const filteredContainerData = React.useMemo(() => {
let filtered = containerTableData
// Apply container filter
if (Array.isArray(filter) && filter.length > 0) {
filtered = filtered.filter((container) => filter.includes(container.name))
}
// Apply stack filter
if (Array.isArray(stackFilter) && stackFilter.length > 0) {
filtered = filtered.filter((container) => stackFilter.includes(container.stack))
}
return filtered
}, [containerTableData, filter, stackFilter])
// Sorted data
const sortedContainerData = React.useMemo(() => {
return [...filteredContainerData].sort((a, b) => {
let aValue: string | number
let bValue: string | number
switch (sortField) {
case "name":
aValue = a.name?.toLowerCase?.() || ""
bValue = b.name?.toLowerCase?.() || ""
break
case "idShort":
aValue = a.idShort || ""
bValue = b.idShort || ""
break
case "stack":
aValue = a.stack?.toLowerCase?.() || ""
bValue = b.stack?.toLowerCase?.() || ""
break
case "health":
aValue = typeof a.healthValue === "number" ? a.healthValue : 0
bValue = typeof b.healthValue === "number" ? b.healthValue : 0
break
case "status":
aValue = a.status?.toLowerCase?.() || ""
bValue = b.status?.toLowerCase?.() || ""
break
case "uptime":
aValue = typeof a.uptimeHours === "number" ? a.uptimeHours : 0
bValue = typeof b.uptimeHours === "number" ? b.uptimeHours : 0
break
default:
return 0
}
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1
return 0
})
}, [filteredContainerData, sortField, sortDirection])
// Pagination
const totalPages = Math.ceil(sortedContainerData.length / containersPerPage)
const startIndex = (currentPage - 1) * containersPerPage
const endIndex = startIndex + containersPerPage
const currentContainers = sortedContainerData.slice(startIndex, endIndex)
// Handlers
const handleSort = (field: "name" | "idShort" | "stack" | "health" | "status" | "uptime") => {
if (sortField === field) {
setSortDirection(sortDirection === "asc" ? "desc" : "asc")
} else {
setSortField(field)
setSortDirection("asc")
}
}
const getSortIcon = (field: "name" | "idShort" | "stack" | "health" | "status" | "uptime") => {
if (sortField !== field) return "↑↓"
return sortDirection === "asc" ? "↑" : "↓"
}
React.useEffect(() => {
setCurrentPage(1)
}, [filteredContainerData, sortField, sortDirection])
return (
<div className="w-full h-full flex flex-col opacity-100">
<div className="flex-1 p-2 overflow-hidden">
<div className="overflow-x-auto h-full">
<table className="w-full text-xs table-fixed">
<thead>
<tr className="border-b border-border">
<th
className="text-left font-medium p-1 w-1/6 cursor-pointer hover:bg-muted/50 transition-colors select-none"
onClick={() => handleSort("idShort")}
>
<div className="flex items-center gap-1">
ID
<span className="text-xs opacity-60">{getSortIcon("idShort")}</span>
</div>
</th>
<th
className="text-left font-medium p-1 w-1/4 cursor-pointer hover:bg-muted/50 transition-colors select-none"
onClick={() => handleSort("name")}
>
<div className="flex items-center gap-1">
Container
<span className="text-xs opacity-60">{getSortIcon("name")}</span>
</div>
</th>
<th
className="text-left font-medium p-1 w-1/6 cursor-pointer hover:bg-muted/50 transition-colors select-none"
onClick={() => handleSort("stack")}
>
<div className="flex items-center gap-1">
Stack
<span className="text-xs opacity-60">{getSortIcon("stack")}</span>
</div>
</th>
<th
className="text-left font-medium p-1 w-1/6 cursor-pointer hover:bg-muted/50 transition-colors select-none"
onClick={() => handleSort("health")}
>
<div className="flex items-center gap-1">
Health
<span className="text-xs opacity-60">{getSortIcon("health")}</span>
</div>
</th>
<th
className="text-left font-medium p-1 w-1/6 cursor-pointer hover:bg-muted/50 transition-colors select-none"
onClick={() => handleSort("status")}
>
<div className="flex items-center gap-1">
Status
<span className="text-xs opacity-60">{getSortIcon("status")}</span>
</div>
</th>
<th
className="text-left font-medium p-1 w-1/6 cursor-pointer hover:bg-muted/50 transition-colors select-none"
onClick={() => handleSort("uptime")}
>
<div className="flex items-center gap-1">
Uptime
<span className="text-xs opacity-60">{getSortIcon("uptime")}</span>
</div>
</th>
</tr>
</thead>
<tbody>
{currentContainers.map((container) => (
<tr key={container.name} className="border-b border-border/30 hover:bg-muted/30">
<td className="p-1 w-1/6 font-mono text-xs text-muted-foreground" title={container.idShort}>
{container.idShort}
</td>
<td className="p-1 w-1/4">
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: container.color }} />
<span className="text-xs truncate">{container.name}</span>
</div>
</td>
<td className="p-1 w-1/6">
<span className="text-xs text-muted-foreground truncate" title={container.stack}>
{container.stack}
</span>
</td>
<td className="p-1 w-1/6">
<Badge
className={cn("px-1.5 py-0.5 text-xs font-medium whitespace-nowrap border-0", {
"bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400":
container.healthValue === 3,
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400":
container.healthValue === 2,
"bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400": container.healthValue === 1,
"bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400": container.healthValue === 0,
})}
>
{container.health}
</Badge>
</td>
<td className="p-1 w-1/6">
<Badge
className={cn("px-1.5 py-0.5 text-xs font-medium whitespace-nowrap border-0", {
"bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400":
container.status?.toLowerCase() === "running",
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400":
container.status?.toLowerCase() === "paused" || container.status?.toLowerCase() === "restarting",
"bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400":
container.status?.toLowerCase().includes("exited") ||
container.status?.toLowerCase().includes("dead") ||
container.status?.toLowerCase().includes("removing"),
"bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400":
!container.status || container.status?.toLowerCase() === "created",
})}
title={container.status}
>
{container.status}
</Badge>
</td>
<td className="p-1 w-1/6 text-xs whitespace-nowrap">{container.uptime}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-2 py-2 border-t border-border bg-muted/20">
<div className="text-xs text-muted-foreground">
Showing {startIndex + 1}-{Math.min(endIndex, sortedContainerData.length)} of{" "}
{sortedContainerData.length} containers
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className={cn(
"px-2 py-1 text-xs rounded border transition-colors",
"hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed",
"border-border hover:border-border/60"
)}
>
Previous
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={cn(
"px-2 py-1 text-xs rounded border transition-colors min-w-[28px]",
page === currentPage
? "bg-primary text-primary-foreground border-primary"
: "border-border hover:bg-muted hover:border-border/60"
)}
>
{page}
</button>
))}
<button
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className={cn(
"px-2 py-1 text-xs rounded border transition-colors",
"hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed",
"border-border hover:border-border/60"
)}
>
Next
</button>
</div>
</div>
)}
</div>
)
})

View File

@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import {
ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable,
VisibilityState,
} from "@tanstack/react-table"
import { ArrowUpDown } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
export type DockerHealthRow = {
id: string
name: string
stack: string
health: string
healthValue: number
status: string
uptime: string
color: string
}
type Props = {
data: DockerHealthRow[]
}
export function ContainerHealthTable({ data }: Props) {
const [sorting, setSorting] = React.useState<SortingState>([])
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
const [rowSelection, setRowSelection] = React.useState({})
const columns = React.useMemo<ColumnDef<DockerHealthRow>[]>(() => [
{
accessorKey: "id",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
ID
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground" title={row.original.id}>{row.original.id}</span>
),
},
{
accessorKey: "name",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Container
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: row.original.color }} />
<span className="text-xs truncate">{row.original.name}</span>
</div>
),
},
{
accessorKey: "stack",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Stack
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="text-xs text-muted-foreground truncate" title={row.original.stack}>{row.original.stack}</span>
),
},
{
accessorKey: "health",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Health
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<Badge className={cn(
"px-1.5 py-0.5 text-xs font-medium whitespace-nowrap border-0",
{
"bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400": row.original.healthValue === 3,
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400": row.original.healthValue === 2,
"bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400": row.original.healthValue === 1,
"bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400": row.original.healthValue === 0,
}
)}>{row.original.health}</Badge>
),
},
{
accessorKey: "status",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Status
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<Badge className={cn(
"px-1.5 py-0.5 text-xs font-medium whitespace-nowrap border-0",
{
"bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400": row.original.status?.toLowerCase() === "running",
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400": row.original.status?.toLowerCase() === "paused" || row.original.status?.toLowerCase() === "restarting",
"bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400":
row.original.status?.toLowerCase().includes("exited") ||
row.original.status?.toLowerCase().includes("dead") ||
row.original.status?.toLowerCase().includes("removing"),
"bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400": !row.original.status || row.original.status?.toLowerCase() === "created",
}
)} title={row.original.status}>{row.original.status}</Badge>
),
},
{
accessorKey: "uptime",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Uptime
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="text-xs whitespace-nowrap">{row.original.uptime}</span>
),
},
], [])
const table = useReactTable({
data,
columns,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnVisibility,
rowSelection,
},
})
React.useEffect(() => {
table.setPageSize(5)
}, [table])
return (
<div className="w-full">
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-1">
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
)
}

View File

@@ -4,7 +4,7 @@ import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { Unit } from "@/lib/enums"
import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils"
import type { ChartData } from "@/types"
import type { ChartData, SystemStatsRecord } from "@/types"
import { useYAxisWidth } from "./hooks"
export default memo(function DiskChart({
@@ -12,7 +12,7 @@ export default memo(function DiskChart({
diskSize,
chartData,
}: {
dataKey: string
dataKey: string | ((data: SystemStatsRecord) => number | undefined)
diskSize: number
chartData: ChartData
}) {

View File

@@ -59,8 +59,6 @@ export default memo(function LoadAverageChart({ chartData }: { chartData: ChartD
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
// @ts-expect-error
// itemSorter={(a, b) => b.value - a.value}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
@@ -70,14 +68,15 @@ export default memo(function LoadAverageChart({ chartData }: { chartData: ChartD
/>
{keys.map(({ legacy, color, label }, i) => {
const dataKey = (value: { stats: SystemStats }) => {
if (chartData.agentVersion.patch < 1) {
const { minor, patch } = chartData.agentVersion
if (minor <= 12 && patch < 1) {
return value.stats?.[legacy]
}
return value.stats?.la?.[i] ?? value.stats?.[legacy]
}
return (
<Line
key={i}
key={label}
dataKey={dataKey}
name={label}
type="monotoneX"

View File

@@ -65,7 +65,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
<CommandItem
key={system.id}
onSelect={() => {
navigate(getPagePath($router, "system", { name: system.name }))
navigate(getPagePath($router, "system", { id: system.id }))
setOpen(false)
}}
>

View File

@@ -2,7 +2,7 @@ import { createRouter } from "@nanostores/router"
const routes = {
home: "/",
system: `/system/:name`,
system: `/system/:id`,
settings: `/settings/:name?`,
forgot_password: `/forgot-password`,
request_otp: `/request-otp`,

View File

@@ -112,7 +112,7 @@ const ActiveAlerts = () => {
)}
</AlertDescription>
<Link
href={getPagePath($router, "system", { name: systems[alert.system]?.name })}
href={getPagePath($router, "system", { id: systems[alert.system]?.id })}
className="absolute inset-0 w-full h-full"
aria-label="View system"
></Link>

View File

@@ -6,15 +6,17 @@ import { timeTicks } from "d3-time"
import {
ChevronRightSquareIcon,
ClockArrowUp,
Container as ContainerIcon,
CpuIcon,
GlobeIcon,
LayoutGridIcon,
MonitorIcon,
Server as ServerIcon,
XIcon,
} from "lucide-react"
import { subscribeKeys } from "nanostores"
import React, { type JSX, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import AreaChartDefault from "@/components/charts/area-chart"
import AreaChartDefault, { type DataPoint } from "@/components/charts/area-chart"
import ContainerChart from "@/components/charts/container-chart"
import DiskChart from "@/components/charts/disk-chart"
import GpuPowerChart from "@/components/charts/gpu-power-chart"
@@ -27,11 +29,14 @@ import { getPbTimestamp, pb } from "@/lib/api"
import { ChartType, ConnectionType, connectionTypeLabels, Os, SystemStatus, Unit } from "@/lib/enums"
import { batteryStateTranslations } from "@/lib/i18n"
import {
$allSystemsById,
$allSystemsByName,
$chartTime,
$containerColors,
$containerFilter,
$direction,
$maxValues,
$stackFilter,
$systems,
$temperatureFilter,
$userSettings,
@@ -49,7 +54,16 @@ import {
toFixedFloat,
useBrowserStorage,
} from "@/lib/utils"
import type { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types"
import type {
ChartData,
ChartTimes,
ContainerStatsRecord,
GPUData,
SystemInfo,
SystemRecord,
SystemStats,
SystemStatsRecord,
} from "@/types"
import ChartTimeSelect from "../charts/chart-time-select"
import { $router, navigate } from "../router"
import Spinner from "../spinner"
@@ -59,6 +73,7 @@ import { AppleIcon, ChartAverage, ChartMax, FreeBsdIcon, Rows, TuxIcon, WebSocke
import { Input } from "../ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
import { Separator } from "../ui/separator"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
import NetworkSheet from "./system/network-sheet"
import LineChartDefault from "../charts/line-chart"
@@ -83,7 +98,8 @@ function getTimeData(chartTime: ChartTimes, lastCreated: number) {
}
}
const now = new Date()
const buffer = chartTime === "1m" ? 400 : 20_000
const now = new Date(Date.now() + buffer)
const startTime = chartTimeData[chartTime].getOffset(now)
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
const data = {
@@ -95,25 +111,28 @@ function getTimeData(chartTime: ChartTimes, lastCreated: number) {
}
// add empty values between records to make gaps if interval is too large
function addEmptyValues<T extends SystemStatsRecord | ContainerStatsRecord>(
function addEmptyValues<T extends { created: string | number | null }>(
prevRecords: T[],
newRecords: T[],
expectedInterval: number
) {
): T[] {
const modifiedRecords: T[] = []
let prevTime = (prevRecords.at(-1)?.created ?? 0) as number
for (let i = 0; i < newRecords.length; i++) {
const record = newRecords[i]
record.created = new Date(record.created).getTime()
if (prevTime) {
if (record.created !== null) {
record.created = new Date(record.created).getTime()
}
if (prevTime && record.created !== null) {
const interval = record.created - prevTime
// if interval is too large, add a null record
if (interval > expectedInterval / 2 + expectedInterval) {
// @ts-expect-error
modifiedRecords.push({ created: null, stats: null })
modifiedRecords.push({ created: null, ...("stats" in record ? { stats: null } : {}) } as T)
}
}
prevTime = record.created
if (record.created !== null) {
prevTime = record.created
}
modifiedRecords.push(record)
}
return modifiedRecords
@@ -137,14 +156,14 @@ async function getStats<T extends SystemStatsRecord | ContainerStatsRecord>(
})
}
function dockerOrPodman(str: string, system: SystemRecord) {
function dockerOrPodman(str: string, system: SystemRecord): string {
if (system.info.p) {
return str.replace("docker", "podman").replace("Docker", "Podman")
}
return str
}
export default memo(function SystemDetail({ name }: { name: string }) {
export default memo(function SystemDetail({ id }: { id: string }) {
const direction = useStore($direction)
const { t } = useLingui()
const systems = useStore($systems)
@@ -156,15 +175,13 @@ export default memo(function SystemDetail({ name }: { name: string }) {
const [containerData, setContainerData] = useState([] as ChartData["containerData"])
const netCardRef = useRef<HTMLDivElement>(null)
const persistChartTime = useRef(false)
const [containerFilterBar, setContainerFilterBar] = useState(null as null | JSX.Element)
const [bottomSpacing, setBottomSpacing] = useState(0)
const [chartLoading, setChartLoading] = useState(true)
const isLongerChart = chartTime !== "1h"
const isLongerChart = !["1m", "1h"].includes(chartTime) // true if chart time is not 1m or 1h
const userSettings = $userSettings.get()
const chartWrapRef = useRef<HTMLDivElement>(null)
useEffect(() => {
document.title = `${name} / Beszel`
return () => {
if (!persistChartTime.current) {
$chartTime.set($userSettings.get().chartTime)
@@ -172,18 +189,71 @@ export default memo(function SystemDetail({ name }: { name: string }) {
persistChartTime.current = false
setSystemStats([])
setContainerData([])
setContainerFilterBar(null)
$containerFilter.set("")
}
}, [name])
}, [id])
// find matching system and update when it changes
useEffect(() => {
return subscribeKeys($allSystemsByName, [name], (newSystems) => {
const sys = newSystems[name]
sys?.id && setSystem(sys)
if (!systems.length) {
return
}
// allow old system-name slug to work
const store = $allSystemsById.get()[id] ? $allSystemsById : $allSystemsByName
return subscribeKeys(store, [id], (newSystems) => {
const sys = newSystems[id]
if (sys) {
setSystem(sys)
document.title = `${sys?.name} / Beszel`
}
})
}, [name])
}, [id, systems.length])
// hide 1m chart time if system agent version is less than 0.13.0
useEffect(() => {
if (parseSemVer(system?.info?.v) < parseSemVer("0.13.0")) {
$chartTime.set("1h")
}
}, [system?.info?.v])
// subscribe to realtime metrics if chart time is 1m
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
useEffect(() => {
let unsub = () => {}
if (!system.id || chartTime !== "1m") {
return
}
if (system.status !== SystemStatus.Up || parseSemVer(system?.info?.v).minor < 13) {
$chartTime.set("1h")
return
}
pb.realtime
.subscribe(
`rt_metrics`,
(data: { container: ContainerStatsRecord[]; info: SystemInfo; stats: SystemStats }) => {
if (data.container?.length > 0) {
const newContainerData = makeContainerData([
{ created: Date.now(), stats: data.container } as unknown as ContainerStatsRecord,
])
setContainerData((prevData) => addEmptyValues(prevData, prevData.slice(-59).concat(newContainerData), 1000))
}
setSystemStats((prevStats) =>
addEmptyValues(
prevStats,
prevStats.slice(-59).concat({ created: Date.now(), stats: data.stats } as SystemStatsRecord),
1000
)
)
},
{ query: { system: system.id } }
)
.then((us) => {
unsub = us
})
return () => {
unsub?.()
}
}, [chartTime, system.id])
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
const chartData: ChartData = useMemo(() => {
@@ -221,13 +291,13 @@ export default memo(function SystemDetail({ name }: { name: string }) {
}
containerData.push(containerStats)
}
setContainerData(containerData)
return containerData
}, [])
// get stats
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
useEffect(() => {
if (!system.id || !chartTime) {
if (!system.id || !chartTime || chartTime === "1m") {
return
}
// loading: true
@@ -261,12 +331,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
}
cache.set(cs_cache_key, containerData)
}
if (containerData.length) {
!containerFilterBar && setContainerFilterBar(<FilterBar />)
} else if (containerFilterBar) {
setContainerFilterBar(null)
}
makeContainerData(containerData)
setContainerData(makeContainerData(containerData))
})
}, [system, chartTime])
@@ -336,17 +401,74 @@ export default memo(function SystemDetail({ name }: { name: string }) {
}, [system, t])
/** Space for tooltip if more than 12 containers */
// biome-ignore lint/correctness/useExhaustiveDependencies: filters accessed via .get()
useEffect(() => {
if (!netCardRef.current || !containerData.length) {
setBottomSpacing(0)
return
const calculateSpacing = () => {
if (!netCardRef.current || !containerData.length) {
setBottomSpacing(0)
return
}
// Count visible containers after applying filters
const containerFilter = $containerFilter.get()
const stackFilter = $stackFilter.get()
let visibleCount = 0
if (containerData[0]) {
for (const [key, value] of Object.entries(containerData[0])) {
if (key === "created") continue
// Apply container filter
if (containerFilter.length > 0 && !containerFilter.includes(key)) {
continue
}
// Apply stack filter
if (stackFilter.length > 0 && typeof value === "object" && value) {
const stackName = (value as any).p || "—"
if (!stackFilter.includes(stackName)) {
continue
}
}
visibleCount++
}
}
// Only add spacing if there are more than 12 visible containers
if (visibleCount > 12) {
const tooltipHeight = (visibleCount - 11) * 17.8 - 40
const wrapperEl = chartWrapRef.current as HTMLDivElement
const wrapperRect = wrapperEl.getBoundingClientRect()
const chartRect = netCardRef.current.getBoundingClientRect()
const distanceToBottom = wrapperRect.bottom - chartRect.bottom
const spacing = Math.max(0, tooltipHeight - distanceToBottom)
setBottomSpacing(spacing)
} else {
setBottomSpacing(0)
}
}
// Initial calculation
calculateSpacing()
// Subscribe to filter changes to recalculate
// Use requestAnimationFrame to wait for chart re-render, then add small delay for layout
const unsubContainer = $containerFilter.subscribe(() => {
requestAnimationFrame(() => {
setTimeout(calculateSpacing, 100)
})
})
const unsubStack = $stackFilter.subscribe(() => {
requestAnimationFrame(() => {
setTimeout(calculateSpacing, 100)
})
})
return () => {
unsubContainer()
unsubStack()
}
const tooltipHeight = (Object.keys(containerData[0]).length - 11) * 17.8 - 40
const wrapperEl = chartWrapRef.current as HTMLDivElement
const wrapperRect = wrapperEl.getBoundingClientRect()
const chartRect = netCardRef.current.getBoundingClientRect()
const distanceToBottom = wrapperRect.bottom - chartRect.bottom
setBottomSpacing(tooltipHeight - distanceToBottom)
}, [containerData])
// keyboard navigation between systems
@@ -364,7 +486,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
) {
return
}
const currentIndex = systems.findIndex((s) => s.name === name)
const currentIndex = systems.findIndex((s) => s.id === id)
if (currentIndex === -1 || systems.length <= 1) {
return
}
@@ -373,18 +495,18 @@ export default memo(function SystemDetail({ name }: { name: string }) {
case "h": {
const prevIndex = (currentIndex - 1 + systems.length) % systems.length
persistChartTime.current = true
return navigate(getPagePath($router, "system", { name: systems[prevIndex].name }))
return navigate(getPagePath($router, "system", { id: systems[prevIndex].id }))
}
case "ArrowRight":
case "l": {
const nextIndex = (currentIndex + 1) % systems.length
persistChartTime.current = true
return navigate(getPagePath($router, "system", { name: systems[nextIndex].name }))
return navigate(getPagePath($router, "system", { id: systems[nextIndex].id }))
}
}
}
return listen(document, "keyup", handleKeyUp)
}, [name, systems])
}, [id, systems])
if (!system.id) {
return null
@@ -392,9 +514,17 @@ export default memo(function SystemDetail({ name }: { name: string }) {
// select field for switching between avg and max values
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
const showMax = chartTime !== "1h" && maxValues
const showMax = maxValues && isLongerChart
const containerFilterBar = containerData.length ? <FilterBar containerData={containerData} /> : null
const stackFilterBar = containerData.length ? <FilterBar containerData={containerData} store={$stackFilter} mode="stack" /> : null
const combinedFilterBar = containerData.length ? (
<div className="flex gap-2">
<FilterBar containerData={containerData} />
<FilterBar containerData={containerData} store={$stackFilter} mode="stack" />
</div>
) : null
// if no data, show empty message
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
const lastGpuVals = Object.values(systemStats.at(-1)?.stats.g ?? {})
const hasGpuData = lastGpuVals.length > 0
@@ -483,7 +613,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
</div>
</div>
<div className="xl:ms-auto flex items-center gap-2 max-sm:-mb-1">
<ChartTimeSelect className="w-full xl:w-40" />
<ChartTimeSelect className="w-full xl:w-40" agentVersion={chartData.agentVersion} />
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
@@ -508,6 +638,22 @@ export default memo(function SystemDetail({ name }: { name: string }) {
</div>
</Card>
{/* Tabbed interface for system and Docker stats */}
<Tabs defaultValue="system" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-4">
<TabsTrigger value="system" className="flex items-center gap-2">
<ServerIcon className="h-4 w-4" />
{t`System Stats`}
</TabsTrigger>
<TabsTrigger value="docker" className="flex items-center gap-2">
<ContainerIcon className="h-4 w-4" />
{dockerOrPodman(t`Docker Stats`, system)}
</TabsTrigger>
</TabsList>
{/* System Stats Tab */}
<TabsContent value="system" className="space-y-4">
{/* main charts */}
<div className="grid xl:grid-cols-2 gap-4">
<ChartCard
@@ -533,23 +679,6 @@ export default memo(function SystemDetail({ name }: { name: string }) {
/>
</ChartCard>
{containerFilterBar && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={dockerOrPodman(t`Docker CPU Usage`, system)}
description={t`Average CPU utilization of containers`}
cornerEl={containerFilterBar}
>
<ContainerChart
chartData={chartData}
dataKey="c"
chartType={ChartType.CPU}
chartConfig={containerChartConfigs.cpu}
/>
</ChartCard>
)}
<ChartCard
empty={dataEmpty}
grid={grid}
@@ -560,23 +689,6 @@ export default memo(function SystemDetail({ name }: { name: string }) {
<MemChart chartData={chartData} showMax={showMax} />
</ChartCard>
{containerFilterBar && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={dockerOrPodman(t`Docker Memory Usage`, system)}
description={dockerOrPodman(t`Memory usage of docker containers`, system)}
cornerEl={containerFilterBar}
>
<ContainerChart
chartData={chartData}
dataKey="m"
chartType={ChartType.Memory}
chartConfig={containerChartConfigs.memory}
/>
</ChartCard>
)}
<ChartCard empty={dataEmpty} grid={grid} title={t`Disk Usage`} description={t`Usage of root partition`}>
<DiskChart chartData={chartData} dataKey="stats.du" diskSize={systemStats.at(-1)?.stats.d ?? NaN} />
</ChartCard>
@@ -594,23 +706,33 @@ export default memo(function SystemDetail({ name }: { name: string }) {
dataPoints={[
{
label: t({ message: "Write", comment: "Disk write" }),
dataKey: ({ stats }: SystemStatsRecord) => (showMax ? stats?.dwm : stats?.dw),
dataKey: ({ stats }: SystemStatsRecord) => {
if (showMax) {
return stats?.dio?.[1] ?? (stats?.dwm ?? 0) * 1024 * 1024
}
return stats?.dio?.[1] ?? (stats?.dw ?? 0) * 1024 * 1024
},
color: 3,
opacity: 0.3,
},
{
label: t({ message: "Read", comment: "Disk read" }),
dataKey: ({ stats }: SystemStatsRecord) => (showMax ? stats?.drm : stats?.dr),
dataKey: ({ stats }: SystemStatsRecord) => {
if (showMax) {
return stats?.diom?.[0] ?? (stats?.drm ?? 0) * 1024 * 1024
}
return stats?.dio?.[0] ?? (stats?.dr ?? 0) * 1024 * 1024
},
color: 1,
opacity: 0.3,
},
]}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, true)
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, false)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, true)
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false)
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
}}
/>
@@ -669,29 +791,6 @@ export default memo(function SystemDetail({ name }: { name: string }) {
/>
</ChartCard>
{containerFilterBar && containerData.length > 0 && (
<div
ref={netCardRef}
className={cn({
"col-span-full": !grid,
})}
>
<ChartCard
empty={dataEmpty}
title={dockerOrPodman(t`Docker Network I/O`, system)}
description={dockerOrPodman(t`Network traffic of docker containers`, system)}
cornerEl={containerFilterBar}
>
<ContainerChart
chartData={chartData}
chartType={ChartType.Network}
dataKey="n"
chartConfig={containerChartConfigs.network}
/>
</ChartCard>
</div>
)}
{/* Swap chart */}
{(systemStats.at(-1)?.stats.su ?? 0) > 0 && (
<ChartCard
@@ -791,7 +890,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
return (
<div key={id} className="contents">
<ChartCard
className="!col-span-1"
className={cn(grid && "!col-span-1")}
empty={dataEmpty}
grid={grid}
title={`${gpu.n} ${t`Usage`}`}
@@ -861,7 +960,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
>
<DiskChart
chartData={chartData}
dataKey={`stats.efs.${extraFsName}.du`}
dataKey={({ stats }: SystemStatsRecord) => stats?.efs?.[extraFsName]?.du}
diskSize={systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN}
/>
</ChartCard>
@@ -877,24 +976,36 @@ export default memo(function SystemDetail({ name }: { name: string }) {
dataPoints={[
{
label: t`Write`,
dataKey: ({ stats }) => stats?.efs?.[extraFsName]?.[showMax ? "wm" : "w"] ?? 0,
dataKey: ({ stats }) => {
if (showMax) {
return stats?.efs?.[extraFsName]?.wb ?? (stats?.efs?.[extraFsName]?.wm ?? 0) * 1024 * 1024
}
return stats?.efs?.[extraFsName]?.wb ?? (stats?.efs?.[extraFsName]?.w ?? 0) * 1024 * 1024
},
color: 3,
opacity: 0.3,
},
{
label: t`Read`,
dataKey: ({ stats }) => stats?.efs?.[extraFsName]?.[showMax ? "rm" : "r"] ?? 0,
dataKey: ({ stats }) => {
if (showMax) {
return (
stats?.efs?.[extraFsName]?.rbm ?? (stats?.efs?.[extraFsName]?.rm ?? 0) * 1024 * 1024
)
}
return stats?.efs?.[extraFsName]?.rb ?? (stats?.efs?.[extraFsName]?.r ?? 0) * 1024 * 1024
},
color: 1,
opacity: 0.3,
},
]}
maxToggled={maxValues}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, true)
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, false)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, true)
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false)
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
}}
/>
@@ -904,6 +1015,125 @@ export default memo(function SystemDetail({ name }: { name: string }) {
})}
</div>
)}
</TabsContent>
{/* Docker Stats Tab */}
<TabsContent value="docker" className="space-y-4">
{/* Centralized filter bar for all Docker charts */}
{combinedFilterBar && (
<div className="flex justify-end gap-2 pb-2">
{combinedFilterBar}
</div>
)}
<div className="grid xl:grid-cols-2 gap-4">
{containerFilterBar && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={dockerOrPodman(t`Docker CPU Usage`, system)}
description={t`Average CPU utilization of containers`}
>
<ContainerChart
chartData={chartData}
dataKey="c"
chartType={ChartType.CPU}
chartConfig={containerChartConfigs.cpu}
/>
</ChartCard>
)}
{containerFilterBar && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={dockerOrPodman(t`Docker Memory Usage`, system)}
description={dockerOrPodman(t`Memory usage of docker containers`, system)}
>
<ContainerChart
chartData={chartData}
dataKey="m"
chartType={ChartType.Memory}
chartConfig={containerChartConfigs.memory}
/>
</ChartCard>
)}
{containerFilterBar && containerData.length > 0 && (
<div
ref={netCardRef}
className={cn({
"col-span-full": !grid,
})}
>
<ChartCard
empty={dataEmpty}
title={dockerOrPodman(t`Docker Network I/O`, system)}
description={dockerOrPodman(t`Network traffic of docker containers`, system)}
>
<ContainerChart
chartData={chartData}
chartType={ChartType.Network}
dataKey="n"
chartConfig={containerChartConfigs.network}
/>
</ChartCard>
</div>
)}
{/* Docker Disk I/O chart */}
{containerFilterBar && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={dockerOrPodman(t`Docker Disk I/O`, system)}
description={dockerOrPodman(t`Disk read/write rates of docker containers`, system)}
>
<ContainerChart
chartData={chartData}
chartType={ChartType.DiskIO}
dataKey="d"
chartConfig={{}}
unit=" MB/s"
/>
</ChartCard>
)}
{/* Docker Volumes chart */}
{containerFilterBar && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={dockerOrPodman(t`Docker Volumes`, system)}
description={dockerOrPodman(t`Volume usage of docker containers`, system)}
>
<ContainerChart
chartData={chartData}
chartType={ChartType.Volume}
dataKey="v"
chartConfig={{}}
/>
</ChartCard>
)}
{/* Docker Health & Uptime chart */}
{containerFilterBar && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={dockerOrPodman(t`Docker Health & Uptime`, system)}
description={dockerOrPodman(t`Container health status and uptime`, system)}
>
<ContainerChart
chartData={chartData}
chartType={ChartType.HealthUptime}
dataKey="h"
chartConfig={{}}
/>
</ChartCard>
)}
</div>
</TabsContent>
</Tabs>
</div>
{/* add space for tooltip if more than 12 containers */}
@@ -913,7 +1143,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
})
function GpuEnginesChart({ chartData }: { chartData: ChartData }) {
const dataPoints = []
const dataPoints: DataPoint[] = []
const engines = Object.keys(chartData.systemStats?.at(-1)?.stats.g?.[0]?.e ?? {}).sort()
for (const engine of engines) {
dataPoints.push({
@@ -934,38 +1164,121 @@ function GpuEnginesChart({ chartData }: { chartData: ChartData }) {
)
}
function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilter }) {
const containerFilter = useStore(store)
function FilterBar({
store = $containerFilter,
containerData,
mode = "container"
}: {
store?: typeof $containerFilter
containerData?: ChartData["containerData"]
mode?: "container" | "stack"
}) {
const selected = useStore(store)
const { t } = useLingui()
const [open, setOpen] = useState(false)
const debouncedStoreSet = useMemo(() => debounce((value: string) => store.set(value), 80), [store])
// Extract all unique container names or stack names from current data
const availableItems = useMemo(() => {
const items = new Set<string>()
if (containerData) {
for (const dataPoint of containerData) {
if (dataPoint.created) {
for (const [key, value] of Object.entries(dataPoint)) {
if (key !== "created") {
if (mode === "stack" && typeof value === "object" && value) {
// Extract stack/project name
const stackName = (value as any).p || "—"
items.add(stackName)
} else if (mode === "container") {
// Add container name
items.add(key)
}
}
}
}
}
}
return Array.from(items).sort()
}, [containerData, mode])
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => debouncedStoreSet(e.target.value),
[debouncedStoreSet]
)
const toggleItem = useCallback((item: string) => {
const current = store.get()
if (current.includes(item)) {
store.set(current.filter(i => i !== item))
} else {
store.set([...current, item])
}
}, [store])
const clearAll = useCallback(() => {
store.set([])
setOpen(false)
}, [store])
// Close dropdown when clicking outside
useEffect(() => {
if (!open) return
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('[data-filter-dropdown]')) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [open])
return (
<>
<Input
placeholder={t`Filter...`}
className="ps-4 pe-8 w-full sm:w-44"
onChange={handleChange}
value={containerFilter}
/>
{containerFilter && (
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Clear"
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
onClick={() => store.set("")}
>
<XIcon className="h-4 w-4" />
</Button>
<div className="relative" data-filter-dropdown>
<Button
variant="outline"
size="sm"
className="h-9 w-full sm:w-44 justify-between"
onClick={() => setOpen(!open)}
>
<span className="truncate">
{selected.length === 0
? (mode === "stack" ? t`Filter stacks...` : t`Filter containers...`)
: `${selected.length} selected`
}
</span>
<ChevronRightSquareIcon className="ml-2 h-4 w-4 opacity-50" />
</Button>
{open && (
<div className="absolute z-50 mt-1 w-full sm:w-64 bg-popover border rounded-md shadow-md">
<div className="p-2 space-y-1 max-h-64 overflow-y-auto">
{availableItems.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
{t`No items available`}
</div>
) : (
availableItems.map((item) => (
<div
key={item}
className="flex items-center space-x-2 rounded-sm px-2 py-1.5 cursor-pointer hover:bg-accent"
onClick={() => toggleItem(item)}
>
<input
type="checkbox"
checked={selected.includes(item)}
onChange={() => {}}
className="h-4 w-4"
/>
<span className="text-sm truncate flex-1">{item}</span>
</div>
))
)}
</div>
{selected.length > 0 && (
<div className="border-t p-2">
<Button variant="ghost" size="sm" className="w-full" onClick={clearAll}>
{t`Clear all`}
</Button>
</div>
)}
</div>
)}
</>
</div>
)
}

View File

@@ -53,7 +53,7 @@ export default memo(function NetworkSheet({
</SheetTrigger>
{hasOpened.current && (
<SheetContent aria-describedby={undefined} className="overflow-auto w-200 !max-w-full p-4 sm:p-6">
<ChartTimeSelect className="w-[calc(100%-2em)]" />
<ChartTimeSelect className="w-[calc(100%-2em)]" agentVersion={chartData.agentVersion} />
<ChartCard
empty={dataEmpty}
grid={grid}

View File

@@ -77,6 +77,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
accessorKey: "name",
id: "system",
name: () => t`System`,
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
filterFn: (() => {
let filterInput = ""
let filterInputLower = ""
@@ -110,7 +111,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
invertSorting: false,
Icon: ServerIcon,
cell: (info) => {
const { name } = info.row.original
const { name, id } = info.row.original
const longestName = useStore($longestSystemNameLen)
return (
<>
@@ -122,7 +123,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
</span>
</span>
<Link
href={getPagePath($router, "system", { name })}
href={getPagePath($router, "system", { id })}
className="inset-0 absolute size-full"
aria-label={name}
></Link>
@@ -279,7 +280,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
}
return (
<Link
href={getPagePath($router, "system", { name: system.name })}
href={getPagePath($router, "system", { id: system.id })}
className={cn(
"flex gap-1.5 items-center md:pe-5 tabular-nums relative z-10",
viewMode === "table" && "ps-0.5"

View File

@@ -131,7 +131,6 @@ export default function SystemsTable() {
return [Object.values(upSystems).length, Object.values(downSystems).length, Object.values(pausedSystems).length]
}, [upSystems, downSystems, pausedSystems])
// TODO: hiding temp then gpu messes up table headers
const CardHead = useMemo(() => {
return (
<CardHeader className="pb-4.5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
@@ -487,7 +486,7 @@ const SystemCard = memo(
</div>
</CardContent>
<Link
href={getPagePath($router, "system", { name: row.original.name })}
href={getPagePath($router, "system", { id: row.original.id })}
className="inset-0 absolute w-full h-full"
>
<span className="sr-only">{row.original.name}</span>

View File

@@ -97,7 +97,7 @@ const ChartTooltipContent = React.forwardRef<
nameKey?: string
labelKey?: string
unit?: string
filter?: string
filter?: string | string[]
contentFormatter?: (item: any, key: string) => React.ReactNode | string
truncate?: boolean
}
@@ -129,13 +129,19 @@ const ChartTooltipContent = React.forwardRef<
React.useMemo(() => {
if (filter) {
payload = payload?.filter((item) => (item.name as string)?.toLowerCase().includes(filter.toLowerCase()))
if (Array.isArray(filter)) {
// Array filter: only show items that are in the filter array
payload = payload?.filter((item) => filter.includes(item.name as string))
} else {
// String filter: show items that match the string (backward compatibility)
payload = payload?.filter((item) => (item.name as string)?.toLowerCase().includes(filter.toLowerCase()))
}
}
if (itemSorter) {
// @ts-expect-error
payload?.sort(itemSorter)
}
}, [itemSorter, payload])
}, [itemSorter, payload, filter])
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {

View File

@@ -3,7 +3,7 @@ import PocketBase from "pocketbase"
import { basePath } from "@/components/router"
import { toast } from "@/components/ui/use-toast"
import type { ChartTimes, UserSettings } from "@/types"
import { $alerts, $allSystemsByName, $userSettings } from "./stores"
import { $alerts, $allSystemsById, $allSystemsByName, $userSettings } from "./stores"
import { chartTimeData } from "./utils"
/** PocketBase JS Client */
@@ -26,8 +26,9 @@ export const verifyAuth = () => {
}
/** Logs the user out by clearing the auth store and unsubscribing from realtime updates. */
export async function logOut() {
export function logOut() {
$allSystemsByName.set({})
$allSystemsById.set({})
$alerts.set({})
$userSettings.set({} as UserSettings)
sessionStorage.setItem("lo", "t") // prevent auto login on logout

View File

@@ -12,6 +12,11 @@ export enum ChartType {
Disk,
Network,
CPU,
Volume,
Health,
Uptime,
HealthUptime,
DiskIO,
}
/** Unit of measurement */

View File

@@ -53,7 +53,13 @@ export const $userSettings = map<UserSettings>({
listenKeys($userSettings, ["chartTime"], ({ chartTime }) => $chartTime.set(chartTime))
/** Container chart filter */
export const $containerFilter = atom("")
export const $containerFilter = atom<string[]>([])
/** Stack chart filter */
export const $stackFilter = atom<string[]>([])
/** Container color mapping for consistent colors across charts */
export const $containerColors = atom<Record<string, string>>({})
/** Temperature chart filter */
export const $temperatureFilter = atom("")

View File

@@ -9,7 +9,7 @@ import {
$pausedSystems,
$upSystems,
} from "@/lib/stores"
import { FAVICON_DEFAULT, FAVICON_GREEN, FAVICON_RED, updateFavicon } from "@/lib/utils"
import { updateFavicon } from "@/lib/utils"
import type { SystemRecord } from "@/types"
import { SystemStatus } from "./enums"
@@ -74,9 +74,7 @@ export function init() {
/** Update the longest system name length and favicon based on system status */
function onSystemsChanged(_: Record<string, SystemRecord>, changedSystem: SystemRecord | undefined) {
const upSystemsStore = $upSystems.get()
const downSystemsStore = $downSystems.get()
const upSystems = Object.values(upSystemsStore)
const downSystems = Object.values(downSystemsStore)
// Update longest system name length
@@ -86,14 +84,7 @@ function onSystemsChanged(_: Record<string, SystemRecord>, changedSystem: System
$longestSystemNameLen.set(nameLen)
}
// Update favicon based on system status
if (downSystems.length > 0) {
updateFavicon(FAVICON_RED)
} else if (upSystems.length > 0) {
updateFavicon(FAVICON_GREEN)
} else {
updateFavicon(FAVICON_DEFAULT)
}
updateFavicon(downSystems.length)
}
/** Fetch systems from collection */

View File

@@ -1,19 +1,14 @@
import { t } from "@lingui/core/macro"
import { type ClassValue, clsx } from "clsx"
import { timeDay, timeHour } from "d3-time"
import { listenKeys } from "nanostores"
import { timeDay, timeHour, timeMinute } from "d3-time"
import { useEffect, useState } from "react"
import { twMerge } from "tailwind-merge"
import { prependBasePath } from "@/components/router"
import { toast } from "@/components/ui/use-toast"
import type { ChartTimeData, FingerprintRecord, SemVer, SystemRecord } from "@/types"
import { HourFormat, MeterState, Unit } from "./enums"
import { $copyContent, $userSettings } from "./stores"
export const FAVICON_DEFAULT = "favicon.svg"
export const FAVICON_GREEN = "favicon-green.svg"
export const FAVICON_RED = "favicon-red.svg"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
@@ -54,9 +49,18 @@ const createShortDateFormatter = (hour12?: boolean) =>
hour12,
})
const createHourWithSecondsFormatter = (hour12?: boolean) =>
new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "numeric",
second: "numeric",
hour12,
})
// Initialize formatters with default values
let hourWithMinutesFormatter = createHourWithMinutesFormatter()
let shortDateFormatter = createShortDateFormatter()
let hourWithSecondsFormatter = createHourWithSecondsFormatter()
export const currentHour12 = () => shortDateFormatter.resolvedOptions().hour12
@@ -68,6 +72,10 @@ export const formatShortDate = (timestamp: string) => {
return shortDateFormatter.format(new Date(timestamp))
}
export const hourWithSeconds = (timestamp: string) => {
return hourWithSecondsFormatter.format(new Date(timestamp))
}
// Update the time formatters if user changes hourFormat
listenKeys($userSettings, ["hourFormat"], ({ hourFormat }) => {
if (!hourFormat) return
@@ -75,6 +83,7 @@ listenKeys($userSettings, ["hourFormat"], ({ hourFormat }) => {
if (currentHour12() !== newHour12) {
hourWithMinutesFormatter = createHourWithMinutesFormatter(newHour12)
shortDateFormatter = createShortDateFormatter(newHour12)
hourWithSecondsFormatter = createHourWithSecondsFormatter(newHour12)
}
})
@@ -86,11 +95,47 @@ export const formatDay = (timestamp: string) => {
return dayFormatter.format(new Date(timestamp))
}
export const updateFavicon = (newIcon: string) => {
;(document.querySelector("link[rel='icon']") as HTMLLinkElement).href = prependBasePath(`/static/${newIcon}`)
}
export const updateFavicon = (() => {
let prevDownCount = 0
return (downCount = 0) => {
if (downCount === prevDownCount) {
return
}
prevDownCount = downCount
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 70">
<defs>
<linearGradient id="gradient" x1="0%" y1="20%" x2="100%" y2="120%">
<stop offset="0%" style="stop-color:#747bff"/>
<stop offset="100%" style="stop-color:#24eb5c"/>
</linearGradient>
</defs>
<path fill="url(#gradient)" d="M35 70H0V0h35q4.4 0 8.2 1.7a21.4 21.4 0 0 1 6.6 4.5q2.9 2.8 4.5 6.6Q56 16.7 56 21a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5Q53 39.1 54 41a18.3 18.3 0 0 1 1.5 4 17.4 17.4 0 0 1 .5 3 15.3 15.3 0 0 1 0 1q0 4.4-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.6Q39.4 70 35 70ZM14 14v14h21a7 7 0 0 0 2.3-.3 6.6 6.6 0 0 0 .4-.2Q39 27 40 26a6.9 6.9 0 0 0 1.5-2.2q.5-1.3.5-2.8a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 17 40 16a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm0 28v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 54.9 40 54a7 7 0 0 0 1.5-2.2 6.9 6.9 0 0 0 .5-2.6 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 45 40 44a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Z"/>
${
downCount > 0 &&
`
<circle cx="40" cy="50" r="22" fill="#f00"/>
<text x="40" y="60" font-size="34" text-anchor="middle" fill="#fff" font-family="Arial" font-weight="bold">${downCount}</text>
`
}
</svg>
`
const blob = new Blob([svg], { type: "image/svg+xml" })
const url = URL.createObjectURL(blob)
;(document.querySelector("link[rel='icon']") as HTMLLinkElement).href = url
}
})()
export const chartTimeData: ChartTimeData = {
"1m": {
type: "1m",
expectedInterval: 2000, // allow a bit of latency for one second updates (#1247)
label: () => t`1 minute`,
format: (timestamp: string) => hourWithSeconds(timestamp),
ticks: 3,
getOffset: (endTime: Date) => timeMinute.offset(endTime, -1),
minVersion: "0.13.0",
},
"1h": {
type: "1m",
expectedInterval: 60_000,
@@ -245,6 +290,45 @@ export function formatBytes(
export const chartMargin = { top: 12 }
export function toFixedWithoutTrailingZeros(num: number, decimals: number): string {
const str = num.toFixed(decimals)
return str.replace(/\.?0+$/, "")
}
export const getSizeAndUnit = (n: number, isGigabytes = true) => {
const sizeInGB = isGigabytes ? n : n / 1_000
if (sizeInGB >= 1_000) {
return { v: sizeInGB / 1_000, u: " TB" }
}
if (sizeInGB >= 1) {
return { v: sizeInGB, u: " GB" }
}
return { v: isGigabytes ? sizeInGB * 1_000 : n, u: " MB" }
}
/**
* Generate a consistent fallback color for containers without assigned colors
* @param name Container name or identifier
* @returns HSL color string
*/
export function generateFallbackColor(name: string): string {
// Use a simple hash of the name to generate consistent colors
let hash = 0
for (let i = 0; i < name.length; i++) {
const char = name.charCodeAt(i)
hash = (hash << 5) - hash + char
hash = hash & hash // Convert to 32-bit integer
}
// Generate hue, saturation, and lightness from the hash
const hue = Math.abs(hash) % 360
const saturation = 65 + (Math.abs(hash) % 3) * 10 // 65%, 75%, 85%
const lightness = 50 + (Math.abs(hash) % 3) * 10 // 50%, 60%, 70%
return `hsl(${hue}, ${saturation}%, ${lightness}%)`
}
/**
* Retuns value of system host, truncating full path if socket.
* @example
@@ -278,7 +362,7 @@ export const generateToken = () => {
}
/** Get the hub URL from the global BESZEL object */
export const getHubURL = () => BESZEL?.HUB_URL || window.location.origin
export const getHubURL = () => globalThis.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"]>()
@@ -333,6 +417,17 @@ export const parseSemVer = (semVer = ""): SemVer => {
return { major: parts?.[0] ?? 0, minor: parts?.[1] ?? 0, patch: parts?.[2] ?? 0 }
}
/** Compare two semver strings. Returns -1 if a is less than b, 0 if a is equal to b, and 1 if a is greater than b. */
export function compareSemVer(a: SemVer, b: SemVer) {
if (a.major !== b.major) {
return a.major - b.major
}
if (a.minor !== b.minor) {
return a.minor - b.minor
}
return a.patch - b.patch
}
/** Get meter state from 0-100 value. Used for color coding meters. */
export function getMeterState(value: number): MeterState {
const { colorWarn = 65, colorCrit = 90 } = $userSettings.get()

View File

@@ -48,6 +48,10 @@ msgstr "1 ساعة"
msgid "1 min"
msgstr "دقيقة واحدة"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 دقيقة"
#: src/lib/utils.ts
msgid "1 week"
msgstr "1 أسبوع"
@@ -263,6 +267,10 @@ msgstr "تحقق من السجلات لمزيد من التفاصيل."
msgid "Check your notification service"
msgstr "تحقق من خدمة الإشعارات الخاصة بك"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr "انقر على نظام لعرض مزيد من المعلومات."
@@ -289,6 +297,10 @@ msgstr "تأكيد كلمة المرور"
msgid "Connection is down"
msgstr "الاتصال مقطوع"
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -411,6 +423,10 @@ msgstr "القرص"
msgid "Disk I/O"
msgstr "إدخال/إخراج القرص"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr "وحدة القرص"
@@ -429,6 +445,14 @@ msgstr "استخدام القرص لـ {extraFsName}"
msgid "Docker CPU Usage"
msgstr "استخدام المعالج للدوكر"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "استخدام الذاكرة للدوكر"
@@ -437,6 +461,14 @@ msgstr "استخدام الذاكرة للدوكر"
msgid "Docker Network I/O"
msgstr "إدخال/إخراج الشبكة للدوكر"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "التوثيق"
@@ -545,8 +577,15 @@ msgstr "فشل في إرسال إشعار الاختبار"
msgid "Failed to update alert"
msgstr "فشل في تحديث التنبيه"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "تصفية..."
@@ -723,6 +762,10 @@ msgstr "حركة مرور الشبكة للواجهات العامة"
msgid "Network unit"
msgstr "وحدة الشبكة"
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "لم يتم العثور على نتائج."
@@ -987,6 +1030,10 @@ msgstr "النظام"
msgid "System load averages over time"
msgstr "متوسط تحميل النظام مع مرور الوقت"
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
msgstr "الأنظمة"
@@ -1199,6 +1246,10 @@ msgstr "عرض أحدث 200 تنبيه."
msgid "Visible Fields"
msgstr "الأعمدة الظاهرة"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "في انتظار وجود سجلات كافية للعرض"

View File

@@ -48,6 +48,10 @@ msgstr "1 час"
msgid "1 min"
msgstr "1 минута"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 минута"
#: src/lib/utils.ts
msgid "1 week"
msgstr "1 седмица"
@@ -263,6 +267,10 @@ msgstr "Провери log-овете за повече информация."
msgid "Check your notification service"
msgstr "Провери услугата си за удостоверяване"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr "Кликнете върху система, за да видите повече информация."
@@ -289,6 +297,10 @@ msgstr "Потвърди парола"
msgid "Connection is down"
msgstr "Връзката е прекъсната"
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -411,6 +423,10 @@ msgstr "Диск"
msgid "Disk I/O"
msgstr "Диск I/O"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr "Единица за диск"
@@ -429,6 +445,14 @@ msgstr "Изполване на диск от {extraFsName}"
msgid "Docker CPU Usage"
msgstr "Използване на процесор от docker"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "Изполване на памет от docker"
@@ -437,6 +461,14 @@ msgstr "Изполване на памет от docker"
msgid "Docker Network I/O"
msgstr "Мрежов I/O използван от docker"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "Документация"
@@ -545,8 +577,15 @@ msgstr "Неуспешно изпрати тестова нотификация"
msgid "Failed to update alert"
msgstr "Неуспешно обнови тревога"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "Филтрирай..."
@@ -723,6 +762,10 @@ msgstr "Мрежов трафик на публични интерфейси"
msgid "Network unit"
msgstr "Единица за измерване на скорост"
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "Няма намерени резултати."
@@ -987,6 +1030,10 @@ msgstr "Система"
msgid "System load averages over time"
msgstr "Средно натоварване на системата във времето"
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
msgstr "Системи"
@@ -1199,6 +1246,10 @@ msgstr "Прегледайте последните си 200 сигнала."
msgid "Visible Fields"
msgstr "Видими полета"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "Изчаква се за достатъчно записи за показване"

View File

@@ -48,6 +48,10 @@ msgstr "1 hodina"
msgid "1 min"
msgstr "1 min"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 minuta"
#: src/lib/utils.ts
msgid "1 week"
msgstr "1 týden"
@@ -263,6 +267,10 @@ msgstr "Pro více informací zkontrolujte logy."
msgid "Check your notification service"
msgstr "Zkontrolujte službu upozornění"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr "Klikněte na systém pro zobrazení více informací."
@@ -289,6 +297,10 @@ msgstr "Potvrdit heslo"
msgid "Connection is down"
msgstr "Připojení je nedostupné"
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -411,6 +423,10 @@ msgstr "Disk"
msgid "Disk I/O"
msgstr "Disk I/O"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr "Disková jednotka"
@@ -429,6 +445,14 @@ msgstr "Využití disku {extraFsName}"
msgid "Docker CPU Usage"
msgstr "Využití CPU Dockeru"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "Využití paměti Dockeru"
@@ -437,6 +461,14 @@ msgstr "Využití paměti Dockeru"
msgid "Docker Network I/O"
msgstr "Síťové I/O Dockeru"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "Dokumentace"
@@ -545,8 +577,15 @@ msgstr "Nepodařilo se odeslat testovací oznámení"
msgid "Failed to update alert"
msgstr "Nepodařilo se aktualizovat upozornění"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "Filtr..."
@@ -723,6 +762,10 @@ msgstr "Síťový provoz veřejných rozhraní"
msgid "Network unit"
msgstr "Síťová jednotka"
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "Nenalezeny žádné výskyty."
@@ -987,6 +1030,10 @@ msgstr "Systém"
msgid "System load averages over time"
msgstr "Průměry zatížení systému v průběhu času"
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
msgstr "Systémy"
@@ -1199,6 +1246,10 @@ msgstr "Zobrazit vašich 200 nejnovějších upozornění."
msgid "Visible Fields"
msgstr "Viditelné sloupce"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "Čeká se na dostatek záznamů k zobrazení"

View File

@@ -48,6 +48,10 @@ msgstr "1 time"
msgid "1 min"
msgstr ""
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 minut"
#: src/lib/utils.ts
msgid "1 week"
msgstr "1 uge"
@@ -263,6 +267,10 @@ msgstr "Tjek logfiler for flere detaljer."
msgid "Check your notification service"
msgstr "Tjek din notifikationstjeneste"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr ""
@@ -289,6 +297,10 @@ msgstr "Bekræft adgangskode"
msgid "Connection is down"
msgstr ""
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -411,6 +423,10 @@ msgstr "Disk"
msgid "Disk I/O"
msgstr "Disk I/O"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr ""
@@ -429,6 +445,14 @@ msgstr "Diskforbrug af {extraFsName}"
msgid "Docker CPU Usage"
msgstr "Docker CPU forbrug"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "Docker Hukommelsesforbrug"
@@ -437,6 +461,14 @@ msgstr "Docker Hukommelsesforbrug"
msgid "Docker Network I/O"
msgstr "Docker Netværk I/O"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "Dokumentation"
@@ -545,8 +577,15 @@ msgstr "Afsendelse af testnotifikation mislykkedes"
msgid "Failed to update alert"
msgstr "Kunne ikke opdatere alarm"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "Filter..."
@@ -723,6 +762,10 @@ msgstr "Netværkstrafik af offentlige grænseflader"
msgid "Network unit"
msgstr ""
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "Ingen resultater fundet."
@@ -987,6 +1030,10 @@ msgstr "System"
msgid "System load averages over time"
msgstr ""
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
msgstr "Systemer"
@@ -1199,6 +1246,10 @@ msgstr ""
msgid "Visible Fields"
msgstr "Synlige felter"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "Venter på nok posteringer til at vise"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: de\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-08-28 23:21\n"
"PO-Revision-Date: 2025-10-05 16:13\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -48,6 +48,10 @@ msgstr "1 Stunde"
msgid "1 min"
msgstr "1 Min"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 Minute"
#: src/lib/utils.ts
msgid "1 week"
msgstr "1 Woche"
@@ -263,6 +267,10 @@ msgstr "Überprüfe die Protokolle für weitere Details."
msgid "Check your notification service"
msgstr "Überprüfe deinen Benachrichtigungsdienst"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr "Klicke auf ein System, um weitere Informationen zu sehen."
@@ -289,6 +297,10 @@ msgstr "Passwort bestätigen"
msgid "Connection is down"
msgstr "Verbindung unterbrochen"
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -411,6 +423,10 @@ msgstr "Festplatte"
msgid "Disk I/O"
msgstr "Festplatten-I/O"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr "Festplatteneinheit"
@@ -429,6 +445,14 @@ msgstr "Festplattennutzung von {extraFsName}"
msgid "Docker CPU Usage"
msgstr "Docker-CPU-Auslastung"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "Docker-Arbeitsspeichernutzung"
@@ -437,6 +461,14 @@ msgstr "Docker-Arbeitsspeichernutzung"
msgid "Docker Network I/O"
msgstr "Docker-Netzwerk-I/O"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "Dokumentation"
@@ -455,7 +487,7 @@ msgstr "Offline ({downSystemsLength})"
#: src/components/routes/system/network-sheet.tsx
msgid "Download"
msgstr "Download"
msgstr "Herunterladen"
#: src/components/alerts-history-columns.tsx
msgid "Duration"
@@ -545,8 +577,15 @@ msgstr "Testbenachrichtigung konnte nicht gesendet werden"
msgid "Failed to update alert"
msgstr "Warnung konnte nicht aktualisiert werden"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "Filter..."
@@ -723,6 +762,10 @@ msgstr "Netzwerkverkehr der öffentlichen Schnittstellen"
msgid "Network unit"
msgstr "Netzwerkeinheit"
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "Keine Ergebnisse gefunden."
@@ -987,6 +1030,10 @@ msgstr "System"
msgid "System load averages over time"
msgstr "Systemlastdurchschnitt im Zeitverlauf"
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
msgstr "Systeme"
@@ -1152,7 +1199,7 @@ msgstr "aktiv ({upSystemsLength})"
#: src/components/routes/system/network-sheet.tsx
msgid "Upload"
msgstr "Upload"
msgstr "Hochladen"
#: src/components/routes/system.tsx
msgid "Uptime"
@@ -1199,6 +1246,10 @@ msgstr "Sieh dir die neusten 200 Alarme an."
msgid "Visible Fields"
msgstr "Sichtbare Spalten"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "Warten auf genügend Datensätze zur Anzeige"

View File

@@ -43,6 +43,10 @@ msgstr "1 hour"
msgid "1 min"
msgstr "1 min"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 minute"
#: src/lib/utils.ts
msgid "1 week"
msgstr "1 week"
@@ -258,6 +262,10 @@ msgstr "Check logs for more details."
msgid "Check your notification service"
msgstr "Check your notification service"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr "Clear all"
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr "Click on a system to view more information."
@@ -284,6 +292,10 @@ msgstr "Confirm password"
msgid "Connection is down"
msgstr "Connection is down"
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr "Container health status and uptime"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -406,6 +418,10 @@ msgstr "Disk"
msgid "Disk I/O"
msgstr "Disk I/O"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr "Disk read/write rates of docker containers"
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr "Disk unit"
@@ -424,6 +440,14 @@ msgstr "Disk usage of {extraFsName}"
msgid "Docker CPU Usage"
msgstr "Docker CPU Usage"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr "Docker Disk I/O"
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr "Docker Health & Uptime"
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "Docker Memory Usage"
@@ -432,6 +456,14 @@ msgstr "Docker Memory Usage"
msgid "Docker Network I/O"
msgstr "Docker Network I/O"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr "Docker Stats"
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr "Docker Volumes"
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "Documentation"
@@ -540,8 +572,15 @@ msgstr "Failed to send test notification"
msgid "Failed to update alert"
msgstr "Failed to update alert"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr "Filter containers..."
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr "Filter stacks..."
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "Filter..."
@@ -718,6 +757,10 @@ msgstr "Network traffic of public interfaces"
msgid "Network unit"
msgstr "Network unit"
#: src/components/routes/system.tsx
msgid "No items available"
msgstr "No items available"
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "No results found."
@@ -982,6 +1025,10 @@ msgstr "System"
msgid "System load averages over time"
msgstr "System load averages over time"
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr "System Stats"
#: src/components/navbar.tsx
msgid "Systems"
msgstr "Systems"
@@ -1194,6 +1241,10 @@ msgstr "View your 200 most recent alerts."
msgid "Visible Fields"
msgstr "Visible Fields"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr "Volume usage of docker containers"
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "Waiting for enough records to display"

View File

@@ -48,6 +48,10 @@ msgstr "1 hora"
msgid "1 min"
msgstr "1 min"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 minuto"
#: src/lib/utils.ts
msgid "1 week"
msgstr "1 semana"
@@ -263,6 +267,10 @@ msgstr "Revise los registros para más detalles."
msgid "Check your notification service"
msgstr "Verifique su servicio de notificaciones"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr "Haga clic en un sistema para ver más información."
@@ -289,6 +297,10 @@ msgstr "Confirmar contraseña"
msgid "Connection is down"
msgstr "La conexión está caída"
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -411,6 +423,10 @@ msgstr "Disco"
msgid "Disk I/O"
msgstr "E/S de Disco"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr "Unidad de disco"
@@ -429,6 +445,14 @@ msgstr "Uso de disco de {extraFsName}"
msgid "Docker CPU Usage"
msgstr "Uso de CPU de Docker"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "Uso de Memoria de Docker"
@@ -437,6 +461,14 @@ msgstr "Uso de Memoria de Docker"
msgid "Docker Network I/O"
msgstr "E/S de Red de Docker"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "Documentación"
@@ -545,8 +577,15 @@ msgstr "Error al enviar la notificación de prueba"
msgid "Failed to update alert"
msgstr "Error al actualizar la alerta"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "Filtrar..."
@@ -723,6 +762,10 @@ msgstr "Tráfico de red de interfaces públicas"
msgid "Network unit"
msgstr "Unidad de red"
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "No se encontraron resultados."
@@ -987,6 +1030,10 @@ msgstr "Sistema"
msgid "System load averages over time"
msgstr "Promedios de carga del sistema a lo largo del tiempo"
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
msgstr "Sistemas"
@@ -1199,6 +1246,10 @@ msgstr "Ver sus 200 alertas más recientes."
msgid "Visible Fields"
msgstr "Columnas visibles"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "Esperando suficientes registros para mostrar"

View File

@@ -48,6 +48,10 @@ msgstr "۱ ساعت"
msgid "1 min"
msgstr "۱ دقیقه"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 دقیقه"
#: src/lib/utils.ts
msgid "1 week"
msgstr "۱ هفته"
@@ -263,6 +267,10 @@ msgstr "برای جزئیات بیشتر، لاگ‌ها را بررسی کنی
msgid "Check your notification service"
msgstr "سرویس اطلاع‌رسانی خود را بررسی کنید"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr "برای مشاهده اطلاعات بیشتر روی یک سیستم کلیک کنید."
@@ -289,6 +297,10 @@ msgstr "تأیید رمز عبور"
msgid "Connection is down"
msgstr "اتصال قطع است"
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -411,6 +423,10 @@ msgstr "دیسک"
msgid "Disk I/O"
msgstr "ورودی/خروجی دیسک"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr "واحد دیسک"
@@ -429,6 +445,14 @@ msgstr "میزان استفاده از دیسک {extraFsName}"
msgid "Docker CPU Usage"
msgstr "میزان استفاده از CPU داکر"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "میزان استفاده از حافظه داکر"
@@ -437,6 +461,14 @@ msgstr "میزان استفاده از حافظه داکر"
msgid "Docker Network I/O"
msgstr "ورودی/خروجی شبکه داکر"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "مستندات"
@@ -545,8 +577,15 @@ msgstr "ارسال اعلان آزمایشی ناموفق بود"
msgid "Failed to update alert"
msgstr "به‌روزرسانی هشدار ناموفق بود"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "فیلتر..."
@@ -723,6 +762,10 @@ msgstr "ترافیک شبکه رابط‌های عمومی"
msgid "Network unit"
msgstr "واحد شبکه"
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "هیچ نتیجه‌ای یافت نشد."
@@ -987,6 +1030,10 @@ msgstr "سیستم"
msgid "System load averages over time"
msgstr "میانگین بار سیستم در طول زمان"
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
msgstr "سیستم‌ها"
@@ -1199,6 +1246,10 @@ msgstr "۲۰۰ هشدار اخیر خود را مشاهده کنید."
msgid "Visible Fields"
msgstr "فیلدهای قابل مشاهده"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "در انتظار رکوردهای کافی برای نمایش"

View File

@@ -48,6 +48,10 @@ msgstr "1 heure"
msgid "1 min"
msgstr ""
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 minute"
#: src/lib/utils.ts
msgid "1 week"
msgstr "1 semaine"
@@ -263,6 +267,10 @@ msgstr "Vérifiez les journaux pour plus de détails."
msgid "Check your notification service"
msgstr "Vérifiez votre service de notification"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr ""
@@ -289,6 +297,10 @@ msgstr "Confirmer le mot de passe"
msgid "Connection is down"
msgstr ""
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -411,6 +423,10 @@ msgstr "Disque"
msgid "Disk I/O"
msgstr "Entrée/Sortie disque"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr ""
@@ -429,6 +445,14 @@ msgstr "Utilisation du disque de {extraFsName}"
msgid "Docker CPU Usage"
msgstr "Utilisation du CPU Docker"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "Utilisation de la mémoire Docker"
@@ -437,6 +461,14 @@ msgstr "Utilisation de la mémoire Docker"
msgid "Docker Network I/O"
msgstr "Entrée/Sortie réseau Docker"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "Documentation"
@@ -545,8 +577,15 @@ msgstr "Échec de l'envoi de la notification de test"
msgid "Failed to update alert"
msgstr "Échec de la mise à jour de l'alerte"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "Filtrer..."
@@ -723,6 +762,10 @@ msgstr "Trafic réseau des interfaces publiques"
msgid "Network unit"
msgstr ""
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "Aucun résultat trouvé."
@@ -987,6 +1030,10 @@ msgstr "Système"
msgid "System load averages over time"
msgstr ""
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
msgstr "Systèmes"
@@ -1199,6 +1246,10 @@ msgstr ""
msgid "Visible Fields"
msgstr "Colonnes visibles"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "En attente de suffisamment d'enregistrements à afficher"

View File

@@ -48,6 +48,10 @@ msgstr "1 sat"
msgid "1 min"
msgstr "1 minut"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 minuta"
#: src/lib/utils.ts
msgid "1 week"
msgstr "1 tjedan"
@@ -263,6 +267,10 @@ msgstr "Provjerite logove za više detalja."
msgid "Check your notification service"
msgstr "Provjerite Vaš servis notifikacija"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr ""
@@ -289,6 +297,10 @@ msgstr "Potvrdite lozinku"
msgid "Connection is down"
msgstr ""
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -411,6 +423,10 @@ msgstr "Disk"
msgid "Disk I/O"
msgstr "Disk I/O"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr ""
@@ -429,6 +445,14 @@ msgstr "Iskorištenost diska od {extraFsName}"
msgid "Docker CPU Usage"
msgstr "Iskorištenost Docker Procesora"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "Iskorištenost Docker Memorije"
@@ -437,6 +461,14 @@ msgstr "Iskorištenost Docker Memorije"
msgid "Docker Network I/O"
msgstr "Docker Mrežni I/O"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "Dokumentacija"
@@ -545,8 +577,15 @@ msgstr "Neuspješno slanje testne notifikacije"
msgid "Failed to update alert"
msgstr "Ažuriranje upozorenja nije uspjelo"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "Filtriraj..."
@@ -723,6 +762,10 @@ msgstr "Mrežni promet javnih sučelja"
msgid "Network unit"
msgstr ""
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "Nema rezultata."
@@ -987,6 +1030,10 @@ msgstr "Sistem"
msgid "System load averages over time"
msgstr ""
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
msgstr "Sistemi"
@@ -1199,6 +1246,10 @@ msgstr ""
msgid "Visible Fields"
msgstr "Vidljiva polja"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "Čeka se na više podataka prije prikaza"

View File

@@ -48,6 +48,10 @@ msgstr "1 óra"
msgid "1 min"
msgstr ""
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 perc"
#: src/lib/utils.ts
msgid "1 week"
msgstr "1 hét"
@@ -263,6 +267,10 @@ msgstr "Ellenőrizd a naplót a további részletekért."
msgid "Check your notification service"
msgstr "Ellenőrizd az értesítési szolgáltatásodat"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr ""
@@ -289,6 +297,10 @@ msgstr "Jelszó megerősítése"
msgid "Connection is down"
msgstr ""
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -411,6 +423,10 @@ msgstr "Lemez"
msgid "Disk I/O"
msgstr "Lemez I/O"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr ""
@@ -429,6 +445,14 @@ msgstr "Lemezhasználat a {extraFsName}"
msgid "Docker CPU Usage"
msgstr "Docker CPU használat"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "Docker memória használat"
@@ -437,6 +461,14 @@ msgstr "Docker memória használat"
msgid "Docker Network I/O"
msgstr "Docker hálózat I/O"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "Dokumentáció"
@@ -545,8 +577,15 @@ msgstr "Teszt értesítés elküldése sikertelen"
msgid "Failed to update alert"
msgstr "Nem sikerült frissíteni a riasztást"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "Szűrő..."
@@ -723,6 +762,10 @@ msgstr "Nyilvános interfészek hálózati forgalma"
msgid "Network unit"
msgstr ""
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "Nincs találat."
@@ -987,6 +1030,10 @@ msgstr "Rendszer"
msgid "System load averages over time"
msgstr ""
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
msgstr "Rendszer"
@@ -1199,6 +1246,10 @@ msgstr ""
msgid "Visible Fields"
msgstr "Látható mezők"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "Elegendő rekordra várva a megjelenítéshez"

View File

@@ -48,6 +48,10 @@ msgstr "1 klukkustund"
msgid "1 min"
msgstr ""
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 mínúta"
#: src/lib/utils.ts
msgid "1 week"
msgstr "1 vika"
@@ -263,6 +267,10 @@ msgstr "Skoðaðu logga til að sjá meiri upplýsingar."
msgid "Check your notification service"
msgstr "Athugaðu tilkynningaþjónustuna þína"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr ""
@@ -289,6 +297,10 @@ msgstr "Staðfestu lykilorð"
msgid "Connection is down"
msgstr ""
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -411,6 +423,10 @@ msgstr "Diskur"
msgid "Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr ""
@@ -429,6 +445,14 @@ msgstr "Diska notkun af {extraFsName}"
msgid "Docker CPU Usage"
msgstr "Docker CPU notkun"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "Minnisnotkun Docker"
@@ -437,6 +461,14 @@ msgstr "Minnisnotkun Docker"
msgid "Docker Network I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "Skjal"
@@ -545,8 +577,15 @@ msgstr "Villa í sendingu prufu skilaboða"
msgid "Failed to update alert"
msgstr "Mistókst að uppfæra tilkynningu"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "Sía..."
@@ -723,6 +762,10 @@ msgstr ""
msgid "Network unit"
msgstr ""
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "Engar niðurstöður fundust."
@@ -987,6 +1030,10 @@ msgstr "Kerfi"
msgid "System load averages over time"
msgstr ""
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
msgstr "Kerfi"
@@ -1199,6 +1246,10 @@ msgstr ""
msgid "Visible Fields"
msgstr "Sjáanlegir reitir"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "Bíður eftir nægum upplýsingum til að sýna"

View File

@@ -48,6 +48,10 @@ msgstr "1 ora"
msgid "1 min"
msgstr "1 min"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 minuto"
#: src/lib/utils.ts
msgid "1 week"
msgstr "1 settimana"
@@ -263,6 +267,10 @@ msgstr "Controlla i log per maggiori dettagli."
msgid "Check your notification service"
msgstr "Controlla il tuo servizio di notifica"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr "Clicca su un sistema per visualizzare più informazioni."
@@ -289,6 +297,10 @@ msgstr "Conferma password"
msgid "Connection is down"
msgstr "La connessione è interrotta"
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -411,6 +423,10 @@ msgstr "Disco"
msgid "Disk I/O"
msgstr "I/O Disco"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr "Unità disco"
@@ -429,6 +445,14 @@ msgstr "Utilizzo del disco di {extraFsName}"
msgid "Docker CPU Usage"
msgstr "Utilizzo CPU Docker"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "Utilizzo Memoria Docker"
@@ -437,6 +461,14 @@ msgstr "Utilizzo Memoria Docker"
msgid "Docker Network I/O"
msgstr "I/O di Rete Docker"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "Documentazione"
@@ -545,8 +577,15 @@ msgstr "Invio della notifica di test fallito"
msgid "Failed to update alert"
msgstr "Aggiornamento dell'avviso fallito"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "Filtra..."
@@ -723,6 +762,10 @@ msgstr "Traffico di rete delle interfacce pubbliche"
msgid "Network unit"
msgstr "Unità rete"
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "Nessun risultato trovato."
@@ -987,6 +1030,10 @@ msgstr "Sistema"
msgid "System load averages over time"
msgstr "Medie di carico del sistema nel tempo"
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
msgstr "Sistemi"
@@ -1199,6 +1246,10 @@ msgstr "Visualizza i tuoi 200 avvisi più recenti."
msgid "Visible Fields"
msgstr "Colonne visibili"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "In attesa di abbastanza record da visualizzare"

View File

@@ -48,6 +48,10 @@ msgstr "1時間"
msgid "1 min"
msgstr "1分"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1分"
#: src/lib/utils.ts
msgid "1 week"
msgstr "1週間"
@@ -263,6 +267,10 @@ msgstr "詳細についてはログを確認してください。"
msgid "Check your notification service"
msgstr "通知サービスを確認してください"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr "システムをクリックして詳細を表示します。"
@@ -289,6 +297,10 @@ msgstr "パスワードを確認"
msgid "Connection is down"
msgstr "接続が切断されました"
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -411,6 +423,10 @@ msgstr "ディスク"
msgid "Disk I/O"
msgstr "ディスクI/O"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr "ディスク単位"
@@ -429,6 +445,14 @@ msgstr "{extraFsName}のディスク使用率"
msgid "Docker CPU Usage"
msgstr "Docker CPU使用率"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "Dockerメモリ使用率"
@@ -437,6 +461,14 @@ msgstr "Dockerメモリ使用率"
msgid "Docker Network I/O"
msgstr "DockerネットワークI/O"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "ドキュメント"
@@ -545,8 +577,15 @@ msgstr "テスト通知の送信に失敗しました"
msgid "Failed to update alert"
msgstr "アラートの更新に失敗しました"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "フィルター..."
@@ -723,6 +762,10 @@ msgstr "パブリックインターフェースのネットワークトラフィ
msgid "Network unit"
msgstr "ネットワーク単位"
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "結果が見つかりませんでした。"
@@ -987,6 +1030,10 @@ msgstr "システム"
msgid "System load averages over time"
msgstr "システムの負荷平均の推移"
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
msgstr "システム"
@@ -1199,6 +1246,10 @@ msgstr "直近200件のアラートを表示します。"
msgid "Visible Fields"
msgstr "表示列"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "表示するのに十分なレコードを待っています"

View File

@@ -48,6 +48,10 @@ msgstr "1시간"
msgid "1 min"
msgstr "1분"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1분"
#: src/lib/utils.ts
msgid "1 week"
msgstr "1주"
@@ -263,6 +267,10 @@ msgstr "자세한 내용은 로그를 확인하세요."
msgid "Check your notification service"
msgstr "알림 서비스를 확인하세요."
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr "더 많은 정보를 보려면 시스템을 클릭하세요."
@@ -289,6 +297,10 @@ msgstr "비밀번호 확인"
msgid "Connection is down"
msgstr "연결이 끊겼습니다"
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -411,6 +423,10 @@ msgstr "디스크"
msgid "Disk I/O"
msgstr "디스크 I/O"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr "디스크 단위"
@@ -429,6 +445,14 @@ msgstr "{extraFsName}의 디스크 사용량"
msgid "Docker CPU Usage"
msgstr "Docker CPU 사용량"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "Docker 메모리 사용량"
@@ -437,6 +461,14 @@ msgstr "Docker 메모리 사용량"
msgid "Docker Network I/O"
msgstr "Docker 네트워크 I/O"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "문서"
@@ -545,8 +577,15 @@ msgstr "테스트 알림 전송 실패"
msgid "Failed to update alert"
msgstr "알림 수정 실패"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "필터..."
@@ -723,6 +762,10 @@ msgstr "공용 인터페이스의 네트워크 트래픽"
msgid "Network unit"
msgstr "네트워크 단위"
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "결과가 없습니다."
@@ -987,6 +1030,10 @@ msgstr "시스템"
msgid "System load averages over time"
msgstr "시간에 따른 시스템 부하 평균"
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
msgstr "시스템"
@@ -1199,6 +1246,10 @@ msgstr "최근 200개의 알림을 봅니다."
msgid "Visible Fields"
msgstr "표시할 열"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "표시할 충분한 기록을 기다리는 중"

View File

@@ -48,6 +48,10 @@ msgstr "1 uur"
msgid "1 min"
msgstr "1 minuut"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 minuut"
#: src/lib/utils.ts
msgid "1 week"
msgstr "1 week"
@@ -263,6 +267,10 @@ msgstr "Controleer de logs voor meer details."
msgid "Check your notification service"
msgstr "Controleer je meldingsservice"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr "Klik op een systeem om meer informatie te bekijken."
@@ -289,6 +297,10 @@ msgstr "Bevestig wachtwoord"
msgid "Connection is down"
msgstr "Verbinding is niet actief"
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -411,6 +423,10 @@ msgstr "Schijf"
msgid "Disk I/O"
msgstr "Schijf I/O"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr "Schijf eenheid"
@@ -429,6 +445,14 @@ msgstr "Schijfgebruik van {extraFsName}"
msgid "Docker CPU Usage"
msgstr "Docker CPU-gebruik"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "Docker geheugengebruik"
@@ -437,6 +461,14 @@ msgstr "Docker geheugengebruik"
msgid "Docker Network I/O"
msgstr "Docker netwerk I/O"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "Documentatie"
@@ -545,8 +577,15 @@ msgstr "Versturen test notificatie mislukt"
msgid "Failed to update alert"
msgstr "Bijwerken waarschuwing mislukt"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "Filter..."
@@ -723,6 +762,10 @@ msgstr "Netwerkverkeer van publieke interfaces"
msgid "Network unit"
msgstr "Netwerk eenheid"
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "Geen resultaten gevonden."
@@ -987,6 +1030,10 @@ msgstr "Systeem"
msgid "System load averages over time"
msgstr "Gemiddelde systeembelasting na verloop van tijd"
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
msgstr "Systemen"
@@ -1199,6 +1246,10 @@ msgstr "Bekijk je 200 meest recente meldingen."
msgid "Visible Fields"
msgstr "Zichtbare kolommen"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "Wachtend op genoeg records om weer te geven"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: no\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-08-28 23:21\n"
"PO-Revision-Date: 2025-10-06 07:37\n"
"Last-Translator: \n"
"Language-Team: Norwegian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -46,7 +46,11 @@ msgstr "1 time"
#. Load average
#: src/components/charts/load-average-chart.tsx
msgid "1 min"
msgstr ""
msgstr "1 min"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 minutt"
#: src/lib/utils.ts
msgid "1 week"
@@ -263,6 +267,10 @@ msgstr "Sjekk loggene for flere detaljer."
msgid "Check your notification service"
msgstr "Sjekk din meldingstjeneste"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr ""
@@ -289,6 +297,10 @@ msgstr "Bekreft passord"
msgid "Connection is down"
msgstr ""
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -411,6 +423,10 @@ msgstr "Disk"
msgid "Disk I/O"
msgstr "Disk I/O"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr ""
@@ -429,6 +445,14 @@ msgstr "Diskbruk av {extraFsName}"
msgid "Docker CPU Usage"
msgstr "Docker CPU-bruk"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "Docker Minnebruk"
@@ -437,6 +461,14 @@ msgstr "Docker Minnebruk"
msgid "Docker Network I/O"
msgstr "Docker Nettverks-I/O"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "Dokumentasjon"
@@ -545,8 +577,15 @@ msgstr "Kunne ikke sende test-varsling"
msgid "Failed to update alert"
msgstr "Kunne ikke oppdatere alarm"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "Filter..."
@@ -723,6 +762,10 @@ msgstr "Nettverkstrafikk av eksterne nettverksgrensesnitt"
msgid "Network unit"
msgstr ""
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "Ingen resultater funnet."
@@ -985,6 +1028,10 @@ msgstr "System"
#: src/components/routes/system.tsx
msgid "System load averages over time"
msgstr "Systembelastning gjennomsnitt over tid"
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
@@ -1193,12 +1240,16 @@ msgstr "Se mer"
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "View your 200 most recent alerts."
msgstr ""
msgstr "Vis de 200 siste varslene."
#: src/components/systems-table/systems-table.tsx
msgid "Visible Fields"
msgstr "Synlige Felter"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "Venter på nok registreringer til å vise"

View File

@@ -48,6 +48,10 @@ msgstr "1 godzina"
msgid "1 min"
msgstr "1 min"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 minuta"
#: src/lib/utils.ts
msgid "1 week"
msgstr "1 tydzień"
@@ -263,6 +267,10 @@ msgstr "Sprawdź logi, aby uzyskać więcej informacji."
msgid "Check your notification service"
msgstr "Sprawdź swój serwis powiadomień"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr "Kliknij na system, aby zobaczyć więcej informacji."
@@ -289,6 +297,10 @@ msgstr "Potwierdź hasło"
msgid "Connection is down"
msgstr "Brak połączenia"
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -411,6 +423,10 @@ msgstr "Dysk"
msgid "Disk I/O"
msgstr "Dysk I/O"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr "Jednostka dysku"
@@ -429,6 +445,14 @@ msgstr "Wykorzystanie dysku {extraFsName}"
msgid "Docker CPU Usage"
msgstr "Wykorzystanie procesora przez Docker"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "Wykorzystanie pamięci przez Docker"
@@ -437,6 +461,14 @@ msgstr "Wykorzystanie pamięci przez Docker"
msgid "Docker Network I/O"
msgstr "Sieć Docker I/O"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "Dokumentacja"
@@ -545,8 +577,15 @@ msgstr "Nie udało się wysłać testowego powiadomienia"
msgid "Failed to update alert"
msgstr "Nie udało się zaktualizować powiadomienia"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "Filtruj..."
@@ -723,6 +762,10 @@ msgstr "Ruch sieciowy interfejsów publicznych"
msgid "Network unit"
msgstr "Jednostka sieciowa"
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "Brak wyników."
@@ -987,6 +1030,10 @@ msgstr "System"
msgid "System load averages over time"
msgstr "Średnie obciążenie systemu w czasie"
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
msgstr "Systemy"
@@ -1199,6 +1246,10 @@ msgstr "Wyświetl 200 ostatnich alertów."
msgid "Visible Fields"
msgstr "Widoczne kolumny"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "Oczekiwanie na wystarczającą liczbę rekordów do wyświetlenia"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: pt\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-08-28 23:21\n"
"PO-Revision-Date: 2025-10-09 12:03\n"
"Last-Translator: \n"
"Language-Team: Portuguese\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -48,6 +48,10 @@ msgstr "1 hora"
msgid "1 min"
msgstr "1 min"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 minuto"
#: src/lib/utils.ts
msgid "1 week"
msgstr "1 semana"
@@ -180,7 +184,7 @@ msgstr "Utilização média dos motores GPU"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Backups"
msgstr "Backups"
msgstr "Cópias de segurança"
#: src/components/routes/system.tsx
#: src/lib/alerts.ts
@@ -263,6 +267,10 @@ msgstr "Verifique os logs para mais detalhes."
msgid "Check your notification service"
msgstr "Verifique seu serviço de notificação"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr "Clique em um sistema para ver mais informações."
@@ -289,6 +297,10 @@ msgstr "Confirmar senha"
msgid "Connection is down"
msgstr "A conexão está inativa"
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -411,6 +423,10 @@ msgstr "Disco"
msgid "Disk I/O"
msgstr "E/S de Disco"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr "Unidade de disco"
@@ -429,6 +445,14 @@ msgstr "Uso de disco de {extraFsName}"
msgid "Docker CPU Usage"
msgstr "Uso de CPU do Docker"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "Uso de Memória do Docker"
@@ -437,6 +461,14 @@ msgstr "Uso de Memória do Docker"
msgid "Docker Network I/O"
msgstr "E/S de Rede do Docker"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "Documentação"
@@ -545,8 +577,15 @@ msgstr "Falha ao enviar notificação de teste"
msgid "Failed to update alert"
msgstr "Falha ao atualizar alerta"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "Filtrar..."
@@ -723,6 +762,10 @@ msgstr "Tráfego de rede das interfaces públicas"
msgid "Network unit"
msgstr "Unidade de rede"
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "Nenhum resultado encontrado."
@@ -987,6 +1030,10 @@ msgstr "Sistema"
msgid "System load averages over time"
msgstr "Médias de carga do sistema ao longo do tempo"
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
msgstr "Sistemas"
@@ -1199,6 +1246,10 @@ msgstr "Veja os seus 200 alertas mais recentes."
msgid "Visible Fields"
msgstr "Campos Visíveis"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "Aguardando registros suficientes para exibir"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: ru\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-08-28 23:21\n"
"PO-Revision-Date: 2025-09-28 07:31\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
@@ -31,7 +31,7 @@ msgstr "{0, plural, one {# час} other {# часов}}"
#. placeholder {0}: Math.trunc(system.info.u / 60)
#: src/components/routes/system.tsx
msgid "{0, plural, one {# minute} few {# minutes} many {# minutes} other {# minutes}}"
msgstr ""
msgstr "{0, plural, one {# минута} few {# минут} many {# минут} other {# минуты}}"
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
#. placeholder {1}: table.getFilteredRowModel().rows.length
@@ -48,6 +48,10 @@ msgstr "1 час"
msgid "1 min"
msgstr "1 мин"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 минута"
#: src/lib/utils.ts
msgid "1 week"
msgstr "1 неделя"
@@ -263,9 +267,13 @@ msgstr "Проверьте журналы для получения более
msgid "Check your notification service"
msgstr "Проверьте ваш сервис уведомлений"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr ""
msgstr "Нажмите на систему для просмотра дополнительной информации."
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
@@ -289,6 +297,10 @@ msgstr "Подтвердите пароль"
msgid "Connection is down"
msgstr "Нет соединения"
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -346,7 +358,7 @@ msgstr "Скопировать YAML"
#: src/components/systems-table/systems-table-columns.tsx
msgid "CPU"
msgstr "CPU"
msgstr "ЦП"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
@@ -411,6 +423,10 @@ msgstr "Диск"
msgid "Disk I/O"
msgstr "Дисковый ввод/вывод"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr "Единицы измерения температуры"
@@ -429,6 +445,14 @@ msgstr "Использование диска {extraFsName}"
msgid "Docker CPU Usage"
msgstr "Использование CPU Docker"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "Использование памяти Docker"
@@ -437,6 +461,14 @@ msgstr "Использование памяти Docker"
msgid "Docker Network I/O"
msgstr "Сетевой ввод/вывод Docker"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "Документация"
@@ -451,7 +483,7 @@ msgstr "Не в сети"
#: src/components/systems-table/systems-table.tsx
msgid "Down ({downSystemsLength})"
msgstr ""
msgstr "Не в сети ({downSystemsLength})"
#: src/components/routes/system/network-sheet.tsx
msgid "Download"
@@ -545,8 +577,15 @@ msgstr "Не удалось отправить тестовое уведомле
msgid "Failed to update alert"
msgstr "Не удалось обновить оповещение"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "Фильтр..."
@@ -723,6 +762,10 @@ msgstr "Сетевой трафик публичных интерфейсов"
msgid "Network unit"
msgstr "Единицы измерения скорости сети"
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "Результаты не найдены."
@@ -809,7 +852,7 @@ msgstr "Пауза"
#: src/components/systems-table/systems-table.tsx
msgid "Paused ({pausedSystemsLength})"
msgstr ""
msgstr "Пауза ({pausedSystemsLength})"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
@@ -987,6 +1030,10 @@ msgstr "Система"
msgid "System load averages over time"
msgstr "Средняя загрузка системы за время"
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
msgstr "Системы"
@@ -1148,7 +1195,7 @@ msgstr "В сети"
#: src/components/systems-table/systems-table.tsx
msgid "Up ({upSystemsLength})"
msgstr ""
msgstr "В сети ({upSystemsLength})"
#: src/components/routes/system/network-sheet.tsx
msgid "Upload"
@@ -1199,6 +1246,10 @@ msgstr "Просмотреть 200 последних оповещений."
msgid "Visible Fields"
msgstr "Видимые столбцы"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "Ожидание достаточного количества записей для отображения"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: sl\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-08-28 23:21\n"
"PO-Revision-Date: 2025-09-25 17:11\n"
"Last-Translator: \n"
"Language-Team: Slovenian\n"
"Plural-Forms: nplurals=4; plural=n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3;\n"
@@ -31,13 +31,13 @@ msgstr "{0, plural, one {# ura} two {# uri} few {# ur} other {# ur}}"
#. placeholder {0}: Math.trunc(system.info.u / 60)
#: src/components/routes/system.tsx
msgid "{0, plural, one {# minute} few {# minutes} many {# minutes} other {# minutes}}"
msgstr ""
msgstr "{0, plural, one {# minuta} few {# minuti} many {# minut} other {# minut}}"
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
#. placeholder {1}: table.getFilteredRowModel().rows.length
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "{0} of {1} row(s) selected."
msgstr ""
msgstr "{0} od {1} vrstic izbranih."
#: src/lib/utils.ts
msgid "1 hour"
@@ -46,7 +46,11 @@ msgstr "1 ura"
#. Load average
#: src/components/charts/load-average-chart.tsx
msgid "1 min"
msgstr ""
msgstr "1 min"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 minuta"
#: src/lib/utils.ts
msgid "1 week"
@@ -206,7 +210,7 @@ msgstr "Binarno"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx
msgid "Bits (Kbps, Mbps, Gbps)"
msgstr ""
msgstr "Biti (Kbps, Mbps, Gbps)"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx
@@ -263,6 +267,10 @@ msgstr "Za več podrobnosti preverite dnevnike."
msgid "Check your notification service"
msgstr "Preverite storitev obveščanja"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr ""
@@ -289,6 +297,10 @@ msgstr "Potrdite geslo"
msgid "Connection is down"
msgstr ""
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -411,6 +423,10 @@ msgstr "Disk"
msgid "Disk I/O"
msgstr "Disk I/O"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr ""
@@ -429,6 +445,14 @@ msgstr "Poraba diska za {extraFsName}"
msgid "Docker CPU Usage"
msgstr "Docker CPU poraba"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "Docker poraba spomina"
@@ -437,6 +461,14 @@ msgstr "Docker poraba spomina"
msgid "Docker Network I/O"
msgstr "Docker I/O mreže"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "Dokumentacija"
@@ -545,8 +577,15 @@ msgstr "Pošiljanje testnega obvestila ni uspelo"
msgid "Failed to update alert"
msgstr "Opozorila ni bilo mogoče posodobiti"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "Filter..."
@@ -723,6 +762,10 @@ msgstr "Omrežni promet javnih vmesnikov"
msgid "Network unit"
msgstr ""
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "Ni rezultatov."
@@ -809,7 +852,7 @@ msgstr "Zaustavljeno"
#: src/components/systems-table/systems-table.tsx
msgid "Paused ({pausedSystemsLength})"
msgstr ""
msgstr "Pavzirano za {pausedSystemsLength}"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
@@ -987,6 +1030,10 @@ msgstr "Sistemsko"
msgid "System load averages over time"
msgstr ""
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
msgstr "Sistemi"
@@ -1199,6 +1246,10 @@ msgstr ""
msgid "Visible Fields"
msgstr "Vidna polja"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "Čakam na dovolj zapisov za prikaz"

View File

@@ -48,6 +48,10 @@ msgstr "1 timme"
msgid "1 min"
msgstr "1 min"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 minut"
#: src/lib/utils.ts
msgid "1 week"
msgstr "1 vecka"
@@ -263,6 +267,10 @@ msgstr "Kontrollera loggarna för mer information."
msgid "Check your notification service"
msgstr "Kontrollera din aviseringstjänst"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr ""
@@ -289,6 +297,10 @@ msgstr "Bekräfta lösenord"
msgid "Connection is down"
msgstr "Ej ansluten"
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -411,6 +423,10 @@ msgstr "Disk"
msgid "Disk I/O"
msgstr "Disk I/O"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr ""
@@ -429,6 +445,14 @@ msgstr "Diskanvändning av {extraFsName}"
msgid "Docker CPU Usage"
msgstr "Docker CPU-användning"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "Docker Minnesanvändning"
@@ -437,6 +461,14 @@ msgstr "Docker Minnesanvändning"
msgid "Docker Network I/O"
msgstr "Docker Nätverks-I/O"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "Dokumentation"
@@ -545,8 +577,15 @@ msgstr "Kunde inte skicka testavisering"
msgid "Failed to update alert"
msgstr "Kunde inte uppdatera larm"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "Filtrera..."
@@ -723,6 +762,10 @@ msgstr "Nätverkstrafik för publika gränssnitt"
msgid "Network unit"
msgstr ""
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "Inga resultat hittades."
@@ -987,6 +1030,10 @@ msgstr "System"
msgid "System load averages over time"
msgstr ""
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
msgstr "System"
@@ -1199,6 +1246,10 @@ msgstr ""
msgid "Visible Fields"
msgstr "Synliga fält"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "Väntar på tillräckligt med poster att visa"

View File

@@ -48,6 +48,10 @@ msgstr "1 saat"
msgid "1 min"
msgstr "1 dk"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 dakika"
#: src/lib/utils.ts
msgid "1 week"
msgstr "1 hafta"
@@ -263,6 +267,10 @@ msgstr "Daha fazla ayrıntı için günlükleri kontrol edin."
msgid "Check your notification service"
msgstr "Bildirim hizmetinizi kontrol edin"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr "Daha fazla bilgi görmek için bir sisteme tıklayın."
@@ -289,6 +297,10 @@ msgstr "Şifreyi onayla"
msgid "Connection is down"
msgstr "Bağlantı kesildi"
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -411,6 +423,10 @@ msgstr "Disk"
msgid "Disk I/O"
msgstr "Disk G/Ç"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr "Disk birimi"
@@ -429,6 +445,14 @@ msgstr "{extraFsName} disk kullanımı"
msgid "Docker CPU Usage"
msgstr "Docker CPU Kullanımı"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "Docker Bellek Kullanımı"
@@ -437,6 +461,14 @@ msgstr "Docker Bellek Kullanımı"
msgid "Docker Network I/O"
msgstr "Docker Ağ G/Ç"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "Dokümantasyon"
@@ -545,8 +577,15 @@ msgstr "Test bildirimi gönderilemedi"
msgid "Failed to update alert"
msgstr "Uyarı güncellenemedi"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "Filtrele..."
@@ -723,6 +762,10 @@ msgstr "Genel arayüzlerin ağ trafiği"
msgid "Network unit"
msgstr "Ağ birimi"
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "Sonuç bulunamadı."
@@ -987,6 +1030,10 @@ msgstr "Sistem"
msgid "System load averages over time"
msgstr "Zaman içindeki sistem yükü ortalamaları"
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
msgstr "Sistemler"
@@ -1199,6 +1246,10 @@ msgstr "En son 200 uyarınızı görüntüleyin."
msgid "Visible Fields"
msgstr "Görünür Alanlar"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "Görüntülemek için yeterli kayıt bekleniyor"

View File

@@ -48,6 +48,10 @@ msgstr "1 година"
msgid "1 min"
msgstr "1 хв"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 хвилина"
#: src/lib/utils.ts
msgid "1 week"
msgstr "1 тиждень"
@@ -263,6 +267,10 @@ msgstr "Перевірте журнали для отримання додатк
msgid "Check your notification service"
msgstr "Перевірте свій сервіс сповіщень"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr "Натисніть на систему, щоб переглянути більше інформації."
@@ -289,6 +297,10 @@ msgstr "Підтвердьте пароль"
msgid "Connection is down"
msgstr "З'єднання розірвано"
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -411,6 +423,10 @@ msgstr "Диск"
msgid "Disk I/O"
msgstr "Дисковий ввід/вивід"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr "Одиниця виміру диска"
@@ -429,6 +445,14 @@ msgstr "Використання диска {extraFsName}"
msgid "Docker CPU Usage"
msgstr "Використання ЦП Docker"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "Використання пам'яті Docker"
@@ -437,6 +461,14 @@ msgstr "Використання пам'яті Docker"
msgid "Docker Network I/O"
msgstr "Мережевий ввід/вивід Docker"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "Документація"
@@ -545,8 +577,15 @@ msgstr "Не вдалося надіслати тестове сповіщенн
msgid "Failed to update alert"
msgstr "Не вдалося оновити сповіщення"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "Фільтр..."
@@ -723,6 +762,10 @@ msgstr "Мережевий трафік публічних інтерфейсі
msgid "Network unit"
msgstr "Одиниця виміру мережі"
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "Результатів не знайдено."
@@ -987,6 +1030,10 @@ msgstr "Система"
msgid "System load averages over time"
msgstr "Середнє навантаження системи з часом"
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
msgstr "Системи"
@@ -1199,6 +1246,10 @@ msgstr "Переглянути 200 останніх сповіщень."
msgid "Visible Fields"
msgstr "Видимі стовпці"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "Очікування достатньої кількості записів для відображення"

View File

@@ -48,6 +48,10 @@ msgstr "1 giờ"
msgid "1 min"
msgstr "1 phút"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 phút"
#: src/lib/utils.ts
msgid "1 week"
msgstr "1 tuần"
@@ -263,6 +267,10 @@ msgstr "Kiểm tra nhật ký để biết thêm chi tiết."
msgid "Check your notification service"
msgstr "Kiểm tra dịch vụ thông báo của bạn"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr "Nhấp vào hệ thống để xem thêm thông tin."
@@ -289,6 +297,10 @@ msgstr "Xác nhận mật khẩu"
msgid "Connection is down"
msgstr "Mất kết nối"
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -411,6 +423,10 @@ msgstr "Đĩa"
msgid "Disk I/O"
msgstr "Đĩa I/O"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr "Đơn vị đĩa"
@@ -429,6 +445,14 @@ msgstr "Sử dụng đĩa của {extraFsName}"
msgid "Docker CPU Usage"
msgstr "Sử dụng CPU Docker"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "Sử dụng Bộ nhớ Docker"
@@ -437,6 +461,14 @@ msgstr "Sử dụng Bộ nhớ Docker"
msgid "Docker Network I/O"
msgstr "Mạng I/O Docker"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "Tài liệu"
@@ -545,8 +577,15 @@ msgstr "Gửi thông báo thử nghiệm thất bại"
msgid "Failed to update alert"
msgstr "Cập nhật cảnh báo thất bại"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "Lọc..."
@@ -723,6 +762,10 @@ msgstr "Lưu lượng mạng của các giao diện công cộng"
msgid "Network unit"
msgstr "Đơn vị mạng"
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "Không tìm thấy kết quả."
@@ -987,6 +1030,10 @@ msgstr "Hệ thống"
msgid "System load averages over time"
msgstr "Tải trung bình của hệ thống theo thời gian"
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
msgstr "Các hệ thống"
@@ -1199,6 +1246,10 @@ msgstr "Xem 200 cảnh báo gần đây nhất của bạn."
msgid "Visible Fields"
msgstr "Các cột hiển thị"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "Đang chờ đủ bản ghi để hiển thị"

View File

@@ -48,6 +48,10 @@ msgstr "1 小时"
msgid "1 min"
msgstr "1 分钟"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 分钟"
#: src/lib/utils.ts
msgid "1 week"
msgstr "1 周"
@@ -263,6 +267,10 @@ msgstr "检查日志以获取更多详细信息。"
msgid "Check your notification service"
msgstr "检查您的通知服务"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr "点击系统查看更多信息。"
@@ -289,6 +297,10 @@ msgstr "确认密码"
msgid "Connection is down"
msgstr "连接已断开"
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -411,6 +423,10 @@ msgstr "磁盘"
msgid "Disk I/O"
msgstr "磁盘 I/O"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr "磁盘单位"
@@ -429,6 +445,14 @@ msgstr "{extraFsName}的磁盘使用"
msgid "Docker CPU Usage"
msgstr "Docker CPU 使用"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "Docker 内存使用"
@@ -437,6 +461,14 @@ msgstr "Docker 内存使用"
msgid "Docker Network I/O"
msgstr "Docker 网络 I/O"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "文档"
@@ -545,8 +577,15 @@ msgstr "发送测试通知失败"
msgid "Failed to update alert"
msgstr "更新警报失败"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "过滤..."
@@ -723,6 +762,10 @@ msgstr "公共接口的网络流量"
msgid "Network unit"
msgstr "网络单位"
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "未找到结果。"
@@ -987,6 +1030,10 @@ msgstr "系统"
msgid "System load averages over time"
msgstr "系统负载平均值随时间变化"
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
msgstr "系统"
@@ -1199,6 +1246,10 @@ msgstr "查看您最近的200个警报。"
msgid "Visible Fields"
msgstr "可见列"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "正在收集足够的数据来显示"

View File

@@ -48,6 +48,10 @@ msgstr "1小時"
msgid "1 min"
msgstr "1 分鐘"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 分鐘"
#: src/lib/utils.ts
msgid "1 week"
msgstr "1週"
@@ -263,6 +267,10 @@ msgstr "檢查日誌以取得更多資訊。"
msgid "Check your notification service"
msgstr "檢查您的通知服務"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr "點擊系統以查看更多資訊。"
@@ -289,6 +297,10 @@ msgstr "確認密碼"
msgid "Connection is down"
msgstr "連線中斷"
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -411,6 +423,10 @@ msgstr "磁碟"
msgid "Disk I/O"
msgstr "磁碟 I/O"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr "磁碟單位"
@@ -429,6 +445,14 @@ msgstr "{extraFsName} 的磁碟使用量"
msgid "Docker CPU Usage"
msgstr "Docker CPU 使用率"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "Docker 記憶體使用率"
@@ -437,6 +461,14 @@ msgstr "Docker 記憶體使用率"
msgid "Docker Network I/O"
msgstr "Docker 網絡 I/O"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "文件"
@@ -545,8 +577,15 @@ msgstr "發送測試通知失敗"
msgid "Failed to update alert"
msgstr "更新警報失敗"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "篩選..."
@@ -723,6 +762,10 @@ msgstr "公共接口的網絡流量"
msgid "Network unit"
msgstr "網路單位"
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "未找到結果。"
@@ -987,6 +1030,10 @@ msgstr "系統"
msgid "System load averages over time"
msgstr "系統平均負載隨時間變化"
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
msgstr "系統"
@@ -1199,6 +1246,10 @@ msgstr "檢視最近 200 則警報。"
msgid "Visible Fields"
msgstr "可見欄位"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "等待足夠的記錄以顯示"

View File

@@ -48,6 +48,10 @@ msgstr "1小時"
msgid "1 min"
msgstr "1 分鐘"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 分钟"
#: src/lib/utils.ts
msgid "1 week"
msgstr "1週"
@@ -263,6 +267,10 @@ msgstr "檢查系統記錄以取得更多資訊。"
msgid "Check your notification service"
msgstr "檢查您的通知服務"
#: src/components/routes/system.tsx
msgid "Clear all"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr "點擊系統以查看更多資訊。"
@@ -289,6 +297,10 @@ msgstr "確認密碼"
msgid "Connection is down"
msgstr "連線中斷"
#: src/components/routes/system.tsx
msgid "Container health status and uptime"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -411,6 +423,10 @@ msgstr "磁碟"
msgid "Disk I/O"
msgstr "磁碟 I/O"
#: src/components/routes/system.tsx
msgid "Disk read/write rates of docker containers"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr "磁碟單位"
@@ -429,6 +445,14 @@ msgstr "{extraFsName}的磁碟使用量"
msgid "Docker CPU Usage"
msgstr "Docker CPU 使用率"
#: src/components/routes/system.tsx
msgid "Docker Disk I/O"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Health & Uptime"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Memory Usage"
msgstr "Docker 記憶體使用率"
@@ -437,6 +461,14 @@ msgstr "Docker 記憶體使用率"
msgid "Docker Network I/O"
msgstr "Docker 網路 I/O"
#: src/components/routes/system.tsx
msgid "Docker Stats"
msgstr ""
#: src/components/routes/system.tsx
msgid "Docker Volumes"
msgstr ""
#: src/components/command-palette.tsx
msgid "Documentation"
msgstr "文件"
@@ -545,8 +577,15 @@ msgstr "發送測試通知失敗"
msgid "Failed to update alert"
msgstr "更新警報失敗"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
msgid "Filter containers..."
msgstr ""
#: src/components/routes/system.tsx
msgid "Filter stacks..."
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "篩選..."
@@ -723,6 +762,10 @@ msgstr "公開介面的網路流量"
msgid "Network unit"
msgstr "網路單位"
#: src/components/routes/system.tsx
msgid "No items available"
msgstr ""
#: src/components/command-palette.tsx
msgid "No results found."
msgstr "找不到結果。"
@@ -987,6 +1030,10 @@ msgstr "系統"
msgid "System load averages over time"
msgstr "系統平均負載隨時間變化"
#: src/components/routes/system.tsx
msgid "System Stats"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
msgstr "系統"
@@ -1199,6 +1246,10 @@ msgstr "檢視最近 200 則警報。"
msgid "Visible Fields"
msgstr "顯示欄位"
#: src/components/routes/system.tsx
msgid "Volume usage of docker containers"
msgstr ""
#: src/components/routes/system.tsx
msgid "Waiting for enough records to display"
msgstr "等待足夠的記錄以顯示"

View File

@@ -48,7 +48,6 @@ const App = memo(() => {
// subscribe to new alert updates
.then(alertManager.subscribe)
return () => {
// updateFavicon("favicon.svg")
alertManager.unsubscribe()
systemsManager.unsubscribe()
}
@@ -59,7 +58,7 @@ const App = memo(() => {
} else if (page.route === "home") {
return <Home />
} else if (page.route === "system") {
return <SystemDetail name={page.params.name} />
return <SystemDetail id={page.params.id} />
} else if (page.route === "settings") {
return <Settings />
}

View File

@@ -123,6 +123,10 @@ export interface SystemStats {
drm?: number
/** max disk write (mb) */
dwm?: number
/** disk I/O bytes [read, write] */
dio?: [number, number]
/** max disk I/O bytes [read, write] */
diom?: [number, number]
/** network sent (mb) */
ns: number
/** network received (mb) */
@@ -164,6 +168,13 @@ export interface GPUData {
e?: Record<string, number>
}
export interface VolumeData {
/** name */
n: string
/** size (mb) */
s: number
}
export interface ExtraFsStats {
/** disk size (gb) */
d: number
@@ -177,6 +188,14 @@ export interface ExtraFsStats {
rm: number
/** max write (mb) */
wm: number
/** read per second (bytes) */
rb: number
/** write per second (bytes) */
wb: number
/** max read per second (bytes) */
rbm: number
/** max write per second (mb) */
wbm: number
}
export interface ContainerStatsRecord extends RecordModel {
@@ -196,6 +215,18 @@ interface ContainerStats {
ns: number
// network received (mb)
nr: number
// volumes (volume name to size in MB)
v?: Record<string, number>
// health status
h?: string
// status (running, stopped, etc.)
s?: string
// uptime in seconds
u?: number
// project name
p?: string
// container short id
idShort?: string
}
export interface SystemStatsRecord extends RecordModel {
@@ -224,7 +255,7 @@ export interface AlertsHistoryRecord extends RecordModel {
resolved?: string | null
}
export type ChartTimes = "1h" | "12h" | "24h" | "1w" | "30d"
export type ChartTimes = "1m" | "1h" | "12h" | "24h" | "1w" | "30d"
export interface ChartTimeData {
[key: string]: {
@@ -234,6 +265,7 @@ export interface ChartTimeData {
ticks?: number
format: (timestamp: string) => string
getOffset: (endTime: Date) => Date
minVersion?: string
}
}

View File

@@ -48,7 +48,7 @@ The [quick start guide](https://beszel.dev/guide/getting-started) and other docu
- **Network usage** - Host system and containers.
- **Load average** - Host system.
- **Temperature** - Host system sensors.
- **GPU usage / temperature / power draw** - Nvidia and AMD only. Must use binary agent.
- **GPU usage / power draw** - Nvidia, AMD, and Intel.
- **Battery** - Host system battery charge.
## Help and discussion

Some files were not shown because too many files have changed in this diff Show More