Compare commits

...

56 Commits

Author SHA1 Message Date
henrygd
a911670a2d release 0.14.1 2025-10-20 17:44:31 -04:00
henrygd
b0cb0c2269 update language files 2025-10-20 17:31:07 -04:00
Teo
735d03577f New Croatian translations 2025-10-20 17:28:57 -04:00
Bruno
a33f88d822 New translations en.po (French) 2025-10-20 17:22:53 -04:00
henrygd
dfd1fc8fda add image name to containers table (#1302) 2025-10-20 17:11:26 -04:00
henrygd
1df08801a2 fix unfound filter values hiding containers chart (#1301) 2025-10-20 14:33:49 -04:00
henrygd
62f5f986bb add spacing for long temperature chart tooltip (#1299) 2025-10-20 14:32:27 -04:00
henrygd
a87b9af9d5 Add MFA_OTP env var to enable email-based one-time password for users and/or superusers 2025-10-20 14:29:56 -04:00
henrygd
03900e54cc correctly sort status column in containers table (#1294) 2025-10-19 11:18:32 -04:00
henrygd
f4abbd1a5b fix system link in container sheet when serving on subpath 2025-10-18 20:02:58 -04:00
henrygd
77ed90cb4a release 0.14.0 :) 2025-10-18 19:36:23 -04:00
henrygd
2fe3b1adb1 increase chart tooltip z-index 2025-10-18 19:20:16 -04:00
henrygd
f56093d0f0 hide container table if no containers 2025-10-18 18:52:59 -04:00
henrygd
77dba42f17 update language files 2025-10-18 18:51:09 -04:00
henrygd
e233a0b0dc new zh translations 2025-10-18 17:33:26 -04:00
derilevi
18e4c88875 new Hungarian translations 2025-10-18 17:32:34 -04:00
Rasko
904a6038cd new Norwegian translations 2025-10-18 17:30:24 -04:00
henrygd
ae55b86493 improve chart filtering logic to support multiple terms (#1274) 2025-10-18 17:10:08 -04:00
henrygd
5360f762e4 expand container monitoring functionality (#928)
- Add new /containers route with virtualized table showing all containers across systems
- Implement container stats collection (CPU, memory, network usage) with health status tracking
- Add container logs and info API endpoints with syntax highlighting using Shiki
- Create detailed container views with fullscreen logs/info dialogs and refresh functionality
- Add container table to individual system pages with lazy loading
- Implement container record storage with automatic cleanup and historical averaging
- Update navbar with container navigation icon
- Extract reusable ActiveAlerts component from home page
- Add FooterRepoLink component for consistent GitHub/version display
- Enhance filtering and search capabilities across container tables
2025-10-18 16:32:16 -04:00
henrygd
0d464787f2 logo color on hover 2025-10-18 15:48:59 -04:00
henrygd
24f72ef596 update collections snapshot 2025-10-18 15:47:12 -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
116 changed files with 8600 additions and 1420 deletions

View File

@@ -12,33 +12,36 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
"time"
"github.com/gliderlabs/ssh" "github.com/gliderlabs/ssh"
"github.com/henrygd/beszel" "github.com/henrygd/beszel"
"github.com/henrygd/beszel/agent/deltatracker"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/host" "github.com/shirou/gopsutil/v4/host"
gossh "golang.org/x/crypto/ssh" gossh "golang.org/x/crypto/ssh"
) )
type Agent struct { type Agent struct {
sync.Mutex // Used to lock agent while collecting data sync.Mutex // Used to lock agent while collecting data
debug bool // true if LOG_LEVEL is set to debug debug bool // true if LOG_LEVEL is set to debug
zfs bool // true if system has arcstats zfs bool // true if system has arcstats
memCalc string // Memory calculation formula memCalc string // Memory calculation formula
fsNames []string // List of filesystem device names being monitored fsNames []string // List of filesystem device names being monitored
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
netInterfaces map[string]struct{} // Stores all valid network interfaces diskPrev map[uint16]map[string]prevDisk // Previous disk I/O counters per cache interval
netIoStats system.NetIoStats // Keeps track of bandwidth usage netInterfaces map[string]struct{} // Stores all valid network interfaces
dockerManager *dockerManager // Manages Docker API requests netIoStats map[uint16]system.NetIoStats // Keeps track of bandwidth usage per cache interval
sensorConfig *SensorConfig // Sensors config netInterfaceDeltaTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] // Per-cache-time NIC delta trackers
systemInfo system.Info // Host system info dockerManager *dockerManager // Manages Docker API requests
gpuManager *GPUManager // Manages GPU data sensorConfig *SensorConfig // Sensors config
cache *SessionCache // Cache for system stats based on primary session ID systemInfo system.Info // Host system info
connectionManager *ConnectionManager // Channel to signal connection events gpuManager *GPUManager // Manages GPU data
server *ssh.Server // SSH server cache *systemDataCache // Cache for system stats based on cache time
dataDir string // Directory for persisting data connectionManager *ConnectionManager // Channel to signal connection events
keys []gossh.PublicKey // SSH public keys 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. // 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) { func NewAgent(dataDir ...string) (agent *Agent, err error) {
agent = &Agent{ agent = &Agent{
fsStats: make(map[string]*system.FsStats), 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...) agent.dataDir, err = getDataDir(dataDir...)
if err != nil { if err != nil {
slog.Warn("Data directory not found") slog.Warn("Data directory not found")
@@ -79,6 +88,9 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
// initialize connection manager // initialize connection manager
agent.connectionManager = newConnectionManager(agent) agent.connectionManager = newConnectionManager(agent)
// initialize handler registry
agent.handlerRegistry = NewHandlerRegistry()
// initialize disk info // initialize disk info
agent.initializeDiskInfo() agent.initializeDiskInfo()
@@ -97,7 +109,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
// if debugging, print stats // if debugging, print stats
if agent.debug { if agent.debug {
slog.Debug("Stats", "data", agent.gatherStats("")) slog.Debug("Stats", "data", agent.gatherStats(0))
} }
return agent, nil return agent, nil
@@ -112,24 +124,24 @@ func GetEnv(key string) (value string, exists bool) {
return os.LookupEnv(key) return os.LookupEnv(key)
} }
func (a *Agent) gatherStats(sessionID string) *system.CombinedData { func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
a.Lock() a.Lock()
defer a.Unlock() defer a.Unlock()
data, isCached := a.cache.Get(sessionID) data, isCached := a.cache.Get(cacheTimeMs)
if isCached { if isCached {
slog.Debug("Cached data", "session", sessionID) slog.Debug("Cached data", "cacheTimeMs", cacheTimeMs)
return data return data
} }
*data = system.CombinedData{ *data = system.CombinedData{
Stats: a.getSystemStats(), Stats: a.getSystemStats(cacheTimeMs),
Info: a.systemInfo, Info: a.systemInfo,
} }
slog.Debug("System data", "data", data) // slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
if a.dockerManager != nil { if a.dockerManager != nil {
if containerStats, err := a.dockerManager.getDockerStats(); err == nil { if containerStats, err := a.dockerManager.getDockerStats(cacheTimeMs); err == nil {
data.Containers = containerStats data.Containers = containerStats
slog.Debug("Containers", "data", data.Containers) slog.Debug("Containers", "data", data.Containers)
} else { } else {
@@ -140,12 +152,17 @@ func (a *Agent) gatherStats(sessionID string) *system.CombinedData {
data.Stats.ExtraFs = make(map[string]*system.FsStats) data.Stats.ExtraFs = make(map[string]*system.FsStats)
for name, stats := range a.fsStats { for name, stats := range a.fsStats {
if !stats.Root && stats.DiskTotal > 0 { 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) slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
a.cache.Set(sessionID, data) a.cache.Set(data, cacheTimeMs)
return data return data
} }

View File

@@ -1,37 +1,55 @@
package agent package agent
import ( import (
"sync"
"time" "time"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
) )
// Not thread safe since we only access from gatherStats which is already locked type systemDataCache struct {
type SessionCache struct { sync.RWMutex
data *system.CombinedData cache map[uint16]*cacheNode
lastUpdate time.Time
primarySession string
leaseTime time.Duration
} }
func NewSessionCache(leaseTime time.Duration) *SessionCache { type cacheNode struct {
return &SessionCache{ data *system.CombinedData
leaseTime: leaseTime, lastUpdate time.Time
data: &system.CombinedData{}, }
// 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) { // Get returns cached combined data when the entry is still considered fresh.
if sessionID != c.primarySession && time.Since(c.lastUpdate) < c.leaseTime { func (c *systemDataCache) Get(cacheTimeMs uint16) (stats *system.CombinedData, isCached bool) {
return c.data, true 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) { // Set stores the latest combined data snapshot for the given interval.
if data != nil { func (c *systemDataCache) Set(data *system.CombinedData, cacheTimeMs uint16) {
*c.data = *data c.Lock()
defer c.Unlock()
node, ok := c.cache[cacheTimeMs]
if !ok {
node = &cacheNode{}
c.cache[cacheTimeMs] = node
} }
c.primarySession = sessionID node.data = data
c.lastUpdate = time.Now() node.lastUpdate = time.Now()
} }

View File

@@ -8,82 +8,239 @@ import (
"testing/synctest" "testing/synctest"
"time" "time"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestSessionCache_GetSet(t *testing.T) { func createTestCacheData() *system.CombinedData {
synctest.Test(t, func(t *testing.T) { return &system.CombinedData{
cache := NewSessionCache(69 * time.Second) 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{ func TestNewSystemDataCache(t *testing.T) {
Info: system.Info{ cache := NewSystemDataCache()
Hostname: "test-host", require.NotNil(t, cache)
Cores: 4, 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{ Stats: system.Stats{
Cpu: 50.0, Cpu: 75.0,
MemPct: 30.0, Mem: 16384,
DiskPct: 40.0,
}, },
Info: system.Info{
Hostname: "test-host-2",
},
Containers: []*container.Stats{},
} }
// Test initial state - should not be cached // Set data for different intervals
data, isCached := cache.Get("session1") cache.Set(data1, 500) // 500ms cache
assert.False(t, isCached, "Expected no cached data initially") cache.Set(data2, 1000) // 1000ms cache
assert.NotNil(t, data, "Expected data to be initialized")
// Set data for session1
cache.Set("session1", testData)
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 retrieved2, isCached2 := cache.Get(1000)
data, isCached = cache.Get("session2") assert.True(t, isCached2)
assert.True(t, isCached, "Expected data to be cached for non-primary session") assert.Equal(t, data2, retrieved2)
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")
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 _, isCached1 = cache.Get(500)
data, isCached = cache.Get("session1") assert.False(t, isCached1)
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)
time.Sleep(45 * time.Second) _, isCached2 = cache.Get(1000)
assert.True(t, isCached2)
// Get data for a different session - should still be cached // Wait another 300ms (total 600ms) - now 1000ms cache should also be stale
_, isCached = cache.Get("session2") time.Sleep(300 * time.Millisecond)
assert.True(t, isCached, "Expected data to be cached for non-primary session") _, isCached2 = cache.Get(1000)
assert.False(t, isCached2)
// 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")
}) })
} }
func TestSessionCache_NilData(t *testing.T) { func TestCacheOverwrite(t *testing.T) {
// Create a new SessionCache cache := NewSystemDataCache()
cache := NewSessionCache(30 * time.Second) 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) // Set initial data
assert.NotPanics(t, func() { cache.Set(data1, 1000)
cache.Set("session1", nil) retrieved, isCached := cache.Get(1000)
}, "Setting nil data should not panic") assert.True(t, isCached)
assert.Equal(t, data1, retrieved)
// Get data - should not be nil even though we set nil // Overwrite with new data
data, _ := cache.Get("session2") cache.Set(data2, 1000)
assert.NotNil(t, data, "Expected data to not be nil after setting nil data") 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"
"github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/fxamacker/cbor/v2" "github.com/fxamacker/cbor/v2"
"github.com/lxzan/gws" "github.com/lxzan/gws"
@@ -142,7 +143,9 @@ func (client *WebSocketClient) OnOpen(conn *gws.Conn) {
// OnClose handles WebSocket connection closure. // OnClose handles WebSocket connection closure.
// It logs the closure reason and notifies the connection manager. // It logs the closure reason and notifies the connection manager.
func (client *WebSocketClient) OnClose(conn *gws.Conn, err error) { 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 client.agent.connectionManager.eventChan <- WebSocketDisconnect
} }
@@ -156,11 +159,15 @@ func (client *WebSocketClient) OnMessage(conn *gws.Conn, message *gws.Message) {
return 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) slog.Error("Error parsing message", "err", err)
return 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) 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. // 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 var authRequest common.FingerprintRequest
if err := cbor.Unmarshal(msg.Data, &authRequest); err != nil { if err := cbor.Unmarshal(msg.Data, &authRequest); err != nil {
return err return err
@@ -191,12 +198,13 @@ func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.R
} }
if authRequest.NeedSysInfo { if authRequest.NeedSysInfo {
response.Name, _ = GetEnv("SYSTEM_NAME")
response.Hostname = client.agent.systemInfo.Hostname response.Hostname = client.agent.systemInfo.Hostname
serverAddr := client.agent.connectionManager.serverOptions.Addr serverAddr := client.agent.connectionManager.serverOptions.Addr
_, response.Port, _ = net.SplitHostPort(serverAddr) _, 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. // 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. // handleHubRequest routes the request to the appropriate handler using the handler registry.
// It ensures the hub is verified before processing most requests. func (client *WebSocketClient) handleHubRequest(msg *common.HubRequest[cbor.RawMessage], requestID *uint32) error {
func (client *WebSocketClient) handleHubRequest(msg *common.HubRequest[cbor.RawMessage]) error { ctx := &HandlerContext{
if !client.hubVerified && msg.Action != common.CheckFingerprint { Client: client,
return errors.New("hub not verified") Agent: client.agent,
Request: msg,
RequestID: requestID,
HubVerified: client.hubVerified,
SendResponse: client.sendResponse,
} }
switch msg.Action { return client.agent.handlerRegistry.Handle(ctx)
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)
} }
// sendMessage encodes the given data to CBOR and sends it as a binary message over the WebSocket connection to the hub. // sendMessage encodes the given data to CBOR and sends it as a binary message over the WebSocket connection to the hub.
@@ -248,7 +248,45 @@ func (client *WebSocketClient) sendMessage(data any) error {
if err != nil { if err != nil {
return err 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 string:
response.String = &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. // 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{}, Data: cbor.RawMessage{},
} }
err := client.handleHubRequest(hubRequest) err := client.handleHubRequest(hubRequest, nil)
if tc.expectError { if tc.expectError {
assert.Error(t, err) 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 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. // Deltas returns a map of all calculated deltas for the current interval.
func (t *DeltaTracker[K, V]) Deltas() map[K]V { func (t *DeltaTracker[K, V]) Deltas() map[K]V {
t.RLock() t.RLock()
@@ -53,6 +63,15 @@ func (t *DeltaTracker[K, V]) Deltas() map[K]V {
return deltas 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. // Delta returns the delta for a single key.
// Returns 0 if the key doesn't exist or has no previous value. // Returns 0 if the key doesn't exist or has no previous value.
func (t *DeltaTracker[K, V]) Delta(id K) V { func (t *DeltaTracker[K, V]) Delta(id K) V {

View File

@@ -13,6 +13,19 @@ import (
"github.com/shirou/gopsutil/v4/disk" "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. // Sets up the filesystems to monitor for disk usage and I/O.
func (a *Agent) initializeDiskInfo() { func (a *Agent) initializeDiskInfo() {
filesystem, _ := GetEnv("FILESYSTEM") filesystem, _ := GetEnv("FILESYSTEM")
@@ -37,7 +50,7 @@ func (a *Agent) initializeDiskInfo() {
slog.Debug("Disk I/O", "diskstats", diskIoCounters) slog.Debug("Disk I/O", "diskstats", diskIoCounters)
// Helper function to add a filesystem to fsStats if it doesn't exist // 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 var key string
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
key = device 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 // Add EXTRA_FILESYSTEMS env var values to fsStats
if extraFilesystems, exists := GetEnv("EXTRA_FILESYSTEMS"); exists { 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 found := false
for _, p := range partitions { for _, p := range partitions {
if strings.HasSuffix(p.Device, fs) || p.Mountpoint == fs { if strings.HasSuffix(p.Device, fs) || p.Mountpoint == fs {
addFsStat(p.Device, p.Mountpoint, false) addFsStat(p.Device, p.Mountpoint, false, customName)
found = true found = true
break break
} }
@@ -98,7 +118,7 @@ func (a *Agent) initializeDiskInfo() {
// if not in partitions, test if we can get disk usage // if not in partitions, test if we can get disk usage
if !found { if !found {
if _, err := disk.Usage(fs); err == nil { if _, err := disk.Usage(fs); err == nil {
addFsStat(filepath.Base(fs), fs, false) addFsStat(filepath.Base(fs), fs, false, customName)
} else { } else {
slog.Error("Invalid filesystem", "name", fs, "err", err) slog.Error("Invalid filesystem", "name", fs, "err", err)
} }
@@ -120,7 +140,8 @@ func (a *Agent) initializeDiskInfo() {
// Check if device is in /extra-filesystems // Check if device is in /extra-filesystems
if strings.HasPrefix(p.Mountpoint, efPath) { 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()) mountpoint := filepath.Join(efPath, folder.Name())
slog.Debug("/extra-filesystems", "mountpoint", mountpoint) slog.Debug("/extra-filesystems", "mountpoint", mountpoint)
if !existingMountpoints[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) 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

@@ -3,8 +3,11 @@ package agent
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/binary"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net" "net"
"net/http" "net/http"
@@ -14,17 +17,29 @@ import (
"sync" "sync"
"time" "time"
"github.com/henrygd/beszel/agent/deltatracker"
"github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/container"
"github.com/blang/semver" "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
// Maximum conceivable memory usage of a container (100TB) to detect bad memory stats
maxMemoryUsage uint64 = 100 * 1024 * 1024 * 1024 * 1024
// Number of log lines to request when fetching container logs
dockerLogsTail = 200
)
type dockerManager struct { type dockerManager struct {
client *http.Client // Client to query Docker API client *http.Client // Client to query Docker API
wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish
sem chan struct{} // Semaphore to limit concurrent container requests sem chan struct{} // Semaphore to limit concurrent container requests
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap 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 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 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) goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
@@ -32,6 +47,17 @@ type dockerManager struct {
buf *bytes.Buffer // Buffer to store and read response bodies buf *bytes.Buffer // Buffer to store and read response bodies
decoder *json.Decoder // Reusable JSON decoder that reads from buf decoder *json.Decoder // Reusable JSON decoder that reads from buf
apiStats *container.ApiStats // Reusable API stats object apiStats *container.ApiStats // Reusable API stats object
// 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]
} }
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests // userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
@@ -62,8 +88,8 @@ func (d *dockerManager) dequeue() {
} }
} }
// Returns stats for all running containers // Returns stats for all running containers with cache-time-aware delta tracking
func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) { func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats, error) {
resp, err := dm.client.Get("http://localhost/containers/json") resp, err := dm.client.Get("http://localhost/containers/json")
if err != nil { if err != nil {
return nil, err return nil, err
@@ -87,8 +113,7 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
var failedContainers []*container.ApiInfo var failedContainers []*container.ApiInfo
for i := range dm.apiContainerList { for _, ctr := range dm.apiContainerList {
ctr := dm.apiContainerList[i]
ctr.IdShort = ctr.Id[:12] ctr.IdShort = ctr.Id[:12]
dm.validIds[ctr.IdShort] = struct{}{} dm.validIds[ctr.IdShort] = struct{}{}
// check if container is less than 1 minute old (possible restart) // check if container is less than 1 minute old (possible restart)
@@ -98,9 +123,9 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
dm.deleteContainerStatsSync(ctr.IdShort) dm.deleteContainerStatsSync(ctr.IdShort)
} }
dm.queue() dm.queue()
go func() { go func(ctr *container.ApiInfo) {
defer dm.dequeue() 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 error, delete from map and add to failed list to retry
if err != nil { if err != nil {
dm.containerStatsMutex.Lock() dm.containerStatsMutex.Lock()
@@ -108,7 +133,7 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
failedContainers = append(failedContainers, ctr) failedContainers = append(failedContainers, ctr)
dm.containerStatsMutex.Unlock() dm.containerStatsMutex.Unlock()
} }
}() }(ctr)
} }
dm.wg.Wait() dm.wg.Wait()
@@ -119,13 +144,12 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
for i := range failedContainers { for i := range failedContainers {
ctr := failedContainers[i] ctr := failedContainers[i]
dm.queue() dm.queue()
go func() { go func(ctr *container.ApiInfo) {
defer dm.dequeue() defer dm.dequeue()
err = dm.updateContainerStats(ctr) if err2 := dm.updateContainerStats(ctr, cacheTimeMs); err2 != nil {
if err != nil { slog.Error("Error getting container stats", "err", err2)
slog.Error("Error getting container stats", "err", err)
} }
}() }(ctr)
} }
dm.wg.Wait() dm.wg.Wait()
} }
@@ -140,18 +164,191 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
} }
} }
// prepare network trackers for next interval for this cache time
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
return stats, nil return stats, nil
} }
// Updates stats for individual container // initializeCpuTracking initializes CPU tracking maps for a specific cache time interval
func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error { 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()
}
}
// 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
}
// 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 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.PrevReadTime = readTime
}
func parseDockerStatus(status string) (string, container.DockerHealth) {
trimmed := strings.TrimSpace(status)
if trimmed == "" {
return "", container.DockerHealthNone
}
// Remove "About " from status
trimmed = strings.Replace(trimmed, "About ", "", 1)
openIdx := strings.LastIndex(trimmed, "(")
if openIdx == -1 || !strings.HasSuffix(trimmed, ")") {
return trimmed, container.DockerHealthNone
}
statusText := strings.TrimSpace(trimmed[:openIdx])
if statusText == "" {
statusText = trimmed
}
healthText := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(trimmed[openIdx+1:], ")")))
// Some Docker statuses include a "health:" prefix inside the parentheses.
// Strip it so it maps correctly to the known health states.
if colonIdx := strings.IndexRune(healthText, ':'); colonIdx != -1 {
prefix := strings.TrimSpace(healthText[:colonIdx])
if prefix == "health" || prefix == "health status" {
healthText = strings.TrimSpace(healthText[colonIdx+1:])
}
}
if health, ok := container.DockerHealthStrings[healthText]; ok {
return statusText, health
}
return trimmed, container.DockerHealthNone
}
// 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:] name := ctr.Names[0][1:]
resp, err := dm.client.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=1") resp, err := dm.client.Get(fmt.Sprintf("http://localhost/containers/%s/stats?stream=0&one-shot=1", ctr.IdShort))
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close()
dm.containerStatsMutex.Lock() dm.containerStatsMutex.Lock()
defer dm.containerStatsMutex.Unlock() defer dm.containerStatsMutex.Unlock()
@@ -159,82 +356,74 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error {
// add empty values if they doesn't exist in map // add empty values if they doesn't exist in map
stats, initialized := dm.containerStatsMap[ctr.IdShort] stats, initialized := dm.containerStatsMap[ctr.IdShort]
if !initialized { if !initialized {
stats = &container.Stats{Name: name} stats = &container.Stats{Name: name, Id: ctr.IdShort, Image: ctr.Image}
dm.containerStatsMap[ctr.IdShort] = stats dm.containerStatsMap[ctr.IdShort] = stats
} }
stats.Id = ctr.IdShort
statusText, health := parseDockerStatus(ctr.Status)
stats.Status = statusText
stats.Health = health
// reset current stats // reset current stats
stats.Cpu = 0 stats.Cpu = 0
stats.Mem = 0 stats.Mem = 0
stats.NetworkSent = 0 stats.NetworkSent = 0
stats.NetworkRecv = 0 stats.NetworkRecv = 0
// docker host container stats response
// res := dm.getApiStats()
// defer dm.putApiStats(res)
//
res := dm.apiStats res := dm.apiStats
res.Networks = nil res.Networks = nil
if err := dm.decode(resp, res); err != nil { if err := dm.decode(resp, res); err != nil {
return err return err
} }
// calculate cpu and memory stats // Initialize CPU tracking for this cache time interval
var usedMemory uint64 dm.initializeCpuTracking(cacheTimeMs)
// Get previous CPU values
prevCpuContainer, prevCpuSystem := dm.getCpuPreviousValues(cacheTimeMs, ctr.IdShort)
// Calculate CPU percentage based on platform
var cpuPct float64 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 { if dm.isWindows {
usedMemory = res.MemoryStats.PrivateWorkingSet prevRead := dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort]
cpuPct = res.CalculateCpuPercentWindows(prevCpuContainer, stats.PrevReadTime) cpuPct = res.CalculateCpuPercentWindows(prevCpuContainer, prevRead)
} else { } 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) cpuPct = res.CalculateCpuPercentLinux(prevCpuContainer, prevCpuSystem)
} }
if cpuPct > 100 { // Calculate memory usage
return fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct) 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 var total_sent, total_recv uint64
for _, v := range res.Networks { for _, v := range res.Networks {
total_sent += v.TxBytes total_sent += v.TxBytes
total_recv += v.RxBytes 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.PrevNet.Sent, stats.PrevNet.Recv = total_sent, total_recv
stats.Cpu = twoDecimals(cpuPct) // Update final stats values
stats.Mem = bytesToMegabytes(float64(usedMemory)) updateContainerStatsValues(stats, cpuPct, usedMemory, sent_delta, recv_delta, res.Read)
stats.NetworkSent = bytesToMegabytes(float64(sent_delta)) // store per-cache-time read time for Windows CPU percent calc
stats.NetworkRecv = bytesToMegabytes(float64(recv_delta)) dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort] = res.Read
stats.PrevReadTime = res.Read
return nil return nil
} }
@@ -244,6 +433,15 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
dm.containerStatsMutex.Lock() dm.containerStatsMutex.Lock()
defer dm.containerStatsMutex.Unlock() defer dm.containerStatsMutex.Unlock()
delete(dm.containerStatsMap, id) 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 // Creates a new http client for Docker or Podman API
@@ -283,7 +481,7 @@ func newDockerManager(a *Agent) *dockerManager {
} }
// configurable timeout // configurable timeout
timeout := time.Millisecond * 2100 timeout := time.Millisecond * time.Duration(dockerTimeoutMs)
if t, set := GetEnv("DOCKER_TIMEOUT"); set { if t, set := GetEnv("DOCKER_TIMEOUT"); set {
timeout, err = time.ParseDuration(t) timeout, err = time.ParseDuration(t)
if err != nil { if err != nil {
@@ -308,6 +506,13 @@ func newDockerManager(a *Agent) *dockerManager {
sem: make(chan struct{}, 5), sem: make(chan struct{}, 5),
apiContainerList: []*container.ApiInfo{}, apiContainerList: []*container.ApiInfo{},
apiStats: &container.ApiStats{}, apiStats: &container.ApiStats{},
// 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]),
} }
// If using podman, return client // If using podman, return client
@@ -317,28 +522,49 @@ func newDockerManager(a *Agent) *dockerManager {
return manager return manager
} }
// Check docker version // this can take up to 5 seconds with retry, so run in goroutine
// (versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch) 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 { var versionInfo struct {
Version string `json:"Version"` 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 { if err != nil {
return manager return
} }
if err := dm.decode(resp, &versionInfo); err != nil {
if err := manager.decode(resp, &versionInfo); err != nil { return
return manager
} }
// if version > 24, one-shot works correctly and we can limit concurrent operations // 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 { if dockerVersion, err := semver.Parse(versionInfo.Version); err == nil && dockerVersion.Major > 24 {
manager.goodDockerVersion = true dm.goodDockerVersion = true
} else { } else {
slog.Info(fmt.Sprintf("Docker %s is outdated. Upgrade if possible. See https://github.com/henrygd/beszel/issues/58", versionInfo.Version)) slog.Info(fmt.Sprintf("Docker %s is outdated. Upgrade if possible. See https://github.com/henrygd/beszel/issues/58", versionInfo.Version))
} }
return manager
} }
// Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe. // Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe.
@@ -368,3 +594,103 @@ func getDockerHost() string {
} }
return scheme + socks[0] return scheme + socks[0]
} }
// getContainerInfo fetches the inspection data for a container
func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID string) (string, error) {
endpoint := fmt.Sprintf("http://localhost/containers/%s/json", containerID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", err
}
resp, err := dm.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return "", fmt.Errorf("container info request failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(data), nil
}
// getLogs fetches the logs for a container
func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (string, error) {
endpoint := fmt.Sprintf("http://localhost/containers/%s/logs?stdout=1&stderr=1&tail=%d", containerID, dockerLogsTail)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", err
}
resp, err := dm.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return "", fmt.Errorf("logs request failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
var builder strings.Builder
if err := decodeDockerLogStream(resp.Body, &builder); err != nil {
return "", err
}
return builder.String(), nil
}
func decodeDockerLogStream(reader io.Reader, builder *strings.Builder) error {
const headerSize = 8
var header [headerSize]byte
buf := make([]byte, 0, dockerLogsTail*200)
for {
if _, err := io.ReadFull(reader, header[:]); err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return nil
}
return err
}
frameLen := binary.BigEndian.Uint32(header[4:])
if frameLen == 0 {
continue
}
buf = allocateBuffer(buf, int(frameLen))
if _, err := io.ReadFull(reader, buf[:frameLen]); err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
if len(buf) > 0 {
builder.Write(buf[:min(int(frameLen), len(buf))])
}
return nil
}
return err
}
builder.Write(buf[:frameLen])
}
}
func allocateBuffer(current []byte, needed int) []byte {
if cap(current) >= needed {
return current[:needed]
}
return make([]byte, needed)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

923
agent/docker_test.go Normal file
View File

@@ -0,0 +1,923 @@
//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 TestParseDockerStatus(t *testing.T) {
tests := []struct {
name string
input string
expectedStatus string
expectedHealth container.DockerHealth
}{
{
name: "status with About an removed",
input: "Up About an hour (healthy)",
expectedStatus: "Up an hour",
expectedHealth: container.DockerHealthHealthy,
},
{
name: "status without About an unchanged",
input: "Up 2 hours (healthy)",
expectedStatus: "Up 2 hours",
expectedHealth: container.DockerHealthHealthy,
},
{
name: "status with About and no parentheses",
input: "Up About an hour",
expectedStatus: "Up an hour",
expectedHealth: container.DockerHealthNone,
},
{
name: "status without parentheses",
input: "Created",
expectedStatus: "Created",
expectedHealth: container.DockerHealthNone,
},
{
name: "empty status",
input: "",
expectedStatus: "",
expectedHealth: container.DockerHealthNone,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
status, health := parseDockerStatus(tt.input)
assert.Equal(t, tt.expectedStatus, status)
assert.Equal(t, tt.expectedHealth, health)
})
}
}
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" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"maps"
"os/exec" "os/exec"
"regexp" "regexp"
"strconv" "strconv"
@@ -44,6 +45,21 @@ type GPUManager struct {
tegrastats bool tegrastats bool
intelGpuStats bool intelGpuStats bool
GpuDataMap map[string]*system.GPUData 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 // RocmSmiJson represents the JSON structure of rocm-smi output
@@ -229,48 +245,21 @@ func (gm *GPUManager) parseAmdData(output []byte) bool {
return true return true
} }
// sums and resets the current GPU utilization data since the last update // GetCurrentData returns GPU utilization data averaged since the last call with this cacheKey
func (gm *GPUManager) GetCurrentData() map[string]system.GPUData { func (gm *GPUManager) GetCurrentData(cacheKey uint16) map[string]system.GPUData {
gm.Lock() gm.Lock()
defer gm.Unlock() defer gm.Unlock()
// check for GPUs with the same name gm.initializeSnapshots(cacheKey)
nameCounts := make(map[string]int) nameCounts := gm.countGPUNames()
for _, gpu := range gm.GpuDataMap {
nameCounts[gpu.Name]++
}
// copy / reset the data
gpuData := make(map[string]system.GPUData, len(gm.GpuDataMap)) gpuData := make(map[string]system.GPUData, len(gm.GpuDataMap))
for id, gpu := range gm.GpuDataMap { for id, gpu := range gm.GpuDataMap {
// avoid division by zero gpuAvg := gm.calculateGPUAverage(id, gpu, cacheKey)
count := max(gpu.Count, 1) gm.updateInstantaneousValues(&gpuAvg, gpu)
gm.storeSnapshot(id, gpu, cacheKey)
// average the data // Append id to name if there are multiple GPUs with the same name
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
if nameCounts[gpu.Name] > 1 { if nameCounts[gpu.Name] > 1 {
gpuAvg.Name = fmt.Sprintf("%s %s", gpu.Name, id) gpuAvg.Name = fmt.Sprintf("%s %s", gpu.Name, id)
} }
@@ -280,6 +269,115 @@ func (gm *GPUManager) GetCurrentData() map[string]system.GPUData {
return 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) // 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 // 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 // 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) { 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{ gm := &GPUManager{
GpuDataMap: map[string]*system.GPUData{ GpuDataMap: map[string]*system.GPUData{
"0": { "0": {
@@ -365,7 +365,8 @@ func TestGetCurrentData(t *testing.T) {
}, },
} }
result := gm.GetCurrentData() cacheKey := uint16(5000)
result := gm.GetCurrentData(cacheKey)
// Verify name disambiguation // Verify name disambiguation
assert.Equal(t, "GPU1 0", result["0"].Name) 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, 30.0, result["1"].Usage, 0.01)
assert.InDelta(t, 60.0, result["1"].Power, 0.01) assert.InDelta(t, 60.0, result["1"].Power, 0.01)
// Verify that accumulators in the original map are reset // Verify that accumulators in the original map are NOT reset (they keep growing)
assert.EqualValues(t, float64(1), gm.GpuDataMap["0"].Count, "GPU 0 Count should be reset") assert.EqualValues(t, 2, gm.GpuDataMap["0"].Count, "GPU 0 Count should remain at 2")
assert.EqualValues(t, float64(50.0), gm.GpuDataMap["0"].Usage, "GPU 0 Usage should be reset") assert.EqualValues(t, 100, gm.GpuDataMap["0"].Usage, "GPU 0 Usage should remain at 100")
assert.Equal(t, float64(100.0), gm.GpuDataMap["0"].Power, "GPU 0 Power should be reset") assert.Equal(t, 200.0, gm.GpuDataMap["0"].Power, "GPU 0 Power should remain at 200")
assert.Equal(t, float64(1), gm.GpuDataMap["1"].Count, "GPU 1 Count should be reset") assert.Equal(t, 1.0, gm.GpuDataMap["1"].Count, "GPU 1 Count should remain at 1")
assert.Equal(t, float64(30), gm.GpuDataMap["1"].Usage, "GPU 1 Usage should be reset") assert.Equal(t, 30.0, gm.GpuDataMap["1"].Usage, "GPU 1 Usage should remain at 30")
assert.Equal(t, float64(60), gm.GpuDataMap["1"].Power, "GPU 1 Power should be reset") 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) { 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 var result map[string]system.GPUData
assert.NotPanics(t, func() { assert.NotPanics(t, func() {
result = gm.GetCurrentData() result = gm.GetCurrentData(cacheKey)
}) })
// Check that usage and power are 0 // Check that usage and power are 0
assert.Equal(t, 0.0, result["0"].Usage) assert.Equal(t, 0.0, result["0"].Usage)
assert.Equal(t, 0.0, result["0"].Power) assert.Equal(t, 0.0, result["0"].Power)
// Verify reset count // Verify count remains 0
assert.EqualValues(t, 1, gm.GpuDataMap["0"].Count) 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 // Verify average calculation in GetCurrentData
result := gm.GetCurrentData() cacheKey := uint16(5000)
result := gm.GetCurrentData(cacheKey)
for id, expected := range tt.expectedValues { for id, expected := range tt.expectedValues {
gpu, exists := result[id] gpu, exists := result[id]
assert.True(t, exists, "GPU with ID %s should exist in GetCurrentData 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") 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 { for id, expected := range tt.expectedValues {
gpu, exists := gm.GpuDataMap[id] gpu, exists := gm.GpuDataMap[id]
assert.True(t, exists, "GPU with ID %s should still exist after GetCurrentData", id) assert.True(t, exists, "GPU with ID %s should still exist after GetCurrentData", id)
if !exists { if !exists {
continue continue
} }
assert.EqualValues(t, 1, gpu.Count, "Count 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.avgUsage, gpu.Usage, "Usage should be reset 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.avgPower, gpu.Power, "Power should be reset for GPU ID %s", id) assert.EqualValues(t, expected.power, gpu.Power, "Power should remain at accumulated value for GPU ID %s", id)
} }
}) })
} }

154
agent/handlers.go Normal file
View File

@@ -0,0 +1,154 @@
package agent
import (
"context"
"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{})
registry.Register(common.GetContainerLogs, &GetContainerLogsHandler{})
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
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)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// GetContainerLogsHandler handles container log requests
type GetContainerLogsHandler struct{}
func (h *GetContainerLogsHandler) Handle(hctx *HandlerContext) error {
if hctx.Agent.dockerManager == nil {
return hctx.SendResponse("", hctx.RequestID)
}
var req common.ContainerLogsRequest
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
return err
}
ctx := context.Background()
logContent, err := hctx.Agent.dockerManager.getLogs(ctx, req.ContainerID)
if err != nil {
return err
}
return hctx.SendResponse(logContent, hctx.RequestID)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// GetContainerInfoHandler handles container info requests
type GetContainerInfoHandler struct{}
func (h *GetContainerInfoHandler) Handle(hctx *HandlerContext) error {
if hctx.Agent.dockerManager == nil {
return hctx.SendResponse("", hctx.RequestID)
}
var req common.ContainerInfoRequest
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
return err
}
ctx := context.Background()
info, err := hctx.Agent.dockerManager.getContainerInfo(ctx, req.ContainerID)
if err != nil {
return err
}
return hctx.SendResponse(info, 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" 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 // NicConfig controls inclusion/exclusion of network interfaces via the NICS env var
// //
// Behavior mirrors SensorConfig's matching logic: // Behavior mirrors SensorConfig's matching logic:
@@ -77,75 +75,17 @@ func isValidNic(nicName string, cfg *NicConfig) bool {
return cfg.isBlacklist return cfg.isBlacklist
} }
func (a *Agent) updateNetworkStats(systemStats *system.Stats) { func (a *Agent) updateNetworkStats(cacheTimeMs uint16, systemStats *system.Stats) {
// network stats // network stats
if len(a.netInterfaces) == 0 { a.ensureNetInterfacesInitialized()
// if no network interfaces, initialize again
// this is a fix if agent started before network is online (#466)
// maybe refactor this in the future to not cache interface names at all so we
// don't miss an interface that's been added after agent started in any circumstance
a.initializeNetIoStats()
}
if systemStats.NetworkInterfaces == nil { a.ensureNetworkInterfacesMap(systemStats)
systemStats.NetworkInterfaces = make(map[string][4]uint64, 0)
}
if netIO, err := psutilNet.IOCounters(true); err == nil { if netIO, err := psutilNet.IOCounters(true); err == nil {
msElapsed := uint64(time.Since(a.netIoStats.Time).Milliseconds()) nis, msElapsed := a.loadAndTickNetBaseline(cacheTimeMs)
a.netIoStats.Time = time.Now() totalBytesSent, totalBytesRecv := a.sumAndTrackPerNicDeltas(cacheTimeMs, msElapsed, netIO, systemStats)
totalBytesSent := uint64(0) bytesSentPerSecond, bytesRecvPerSecond := a.computeBytesPerSecond(msElapsed, totalBytesSent, totalBytesRecv, nis)
totalBytesRecv := uint64(0) a.applyNetworkTotals(cacheTimeMs, netIO, systemStats, nis, totalBytesSent, totalBytesRecv, bytesSentPerSecond, bytesRecvPerSecond)
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
}
} }
} }
@@ -160,13 +100,8 @@ func (a *Agent) initializeNetIoStats() {
nicCfg = newNicConfig(nicsEnvVal) nicCfg = newNicConfig(nicsEnvVal)
} }
// reset network I/O stats // get current network I/O stats and record valid interfaces
a.netIoStats.BytesSent = 0
a.netIoStats.BytesRecv = 0
// get intial network I/O stats
if netIO, err := psutilNet.IOCounters(true); err == nil { if netIO, err := psutilNet.IOCounters(true); err == nil {
a.netIoStats.Time = time.Now()
for _, v := range netIO { for _, v := range netIO {
if nicsEnvExists && !isValidNic(v.Name, nicCfg) { if nicsEnvExists && !isValidNic(v.Name, nicCfg) {
continue continue
@@ -175,12 +110,136 @@ func (a *Agent) initializeNetIoStats() {
continue continue
} }
slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv) slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv)
a.netIoStats.BytesSent += v.BytesSent
a.netIoStats.BytesRecv += v.BytesRecv
// store as a valid network interface // store as a valid network interface
a.netInterfaces[v.Name] = struct{}{} a.netInterfaces[v.Name] = struct{}{}
} }
} }
// 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 { func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool {

View File

@@ -4,7 +4,11 @@ package agent
import ( import (
"testing" "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/assert"
"github.com/stretchr/testify/require" "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,77 @@ func (a *Agent) handleSession(s ssh.Session) {
hubVersion := a.getHubVersion(sessionID, sessionCtx) hubVersion := a.getHubVersion(sessionID, sessionCtx)
stats := a.gatherStats(sessionID) // Legacy one-shot behavior for older hubs
if hubVersion.LT(beszel.MinVersionAgentResponse) {
err := a.writeToSession(s, stats, hubVersion) if err := a.handleLegacyStats(s, hubVersion); err != nil {
if err != nil { slog.Error("Error encoding stats", "err", err)
slog.Error("Error encoding stats", "err", err, "stats", stats) s.Exit(1)
s.Exit(1) return
} else { }
s.Exit(0)
} }
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
case string:
response.String = &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. // 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/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/cpu" "github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/host" "github.com/shirou/gopsutil/v4/host"
"github.com/shirou/gopsutil/v4/load" "github.com/shirou/gopsutil/v4/load"
"github.com/shirou/gopsutil/v4/mem" "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 // Sets initial / non-changing values about the host system
func (a *Agent) initializeSystemInfo() { func (a *Agent) initializeSystemInfo() {
a.systemInfo.AgentVersion = beszel.Version a.systemInfo.AgentVersion = beszel.Version
@@ -68,7 +74,7 @@ func (a *Agent) initializeSystemInfo() {
} }
// Returns current info, stats about the host system // 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 var systemStats system.Stats
// battery // battery
@@ -77,11 +83,11 @@ func (a *Agent) getSystemStats() system.Stats {
} }
// cpu percent // cpu percent
cpuPct, err := cpu.Percent(0, false) cpuPercent, err := getCpuPercent(cacheTimeMs)
if err != nil { if err == nil {
systemStats.Cpu = twoDecimals(cpuPercent)
} else {
slog.Error("Error getting cpu percent", "err", err) slog.Error("Error getting cpu percent", "err", err)
} else if len(cpuPct) > 0 {
systemStats.Cpu = twoDecimals(cpuPct[0])
} }
// load average // load average
@@ -131,56 +137,13 @@ func (a *Agent) getSystemStats() system.Stats {
} }
// disk usage // disk usage
for _, stats := range a.fsStats { a.updateDiskUsage(&systemStats)
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
}
}
// disk i/o // disk i/o (cache-aware per interval)
if ioCounters, err := disk.IOCounters(a.fsNames...); err == nil { a.updateDiskIo(cacheTimeMs, &systemStats)
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
}
}
}
// network stats // network stats (per cache interval)
a.updateNetworkStats(&systemStats) a.updateNetworkStats(cacheTimeMs, &systemStats)
// temperatures // temperatures
// TODO: maybe refactor to methods on systemStats // TODO: maybe refactor to methods on systemStats
@@ -191,7 +154,7 @@ func (a *Agent) getSystemStats() system.Stats {
// reset high gpu percent // reset high gpu percent
a.systemInfo.GpuPct = 0 a.systemInfo.GpuPct = 0
// get current GPU data // get current GPU data
if gpuData := a.gpuManager.GetCurrentData(); len(gpuData) > 0 { if gpuData := a.gpuManager.GetCurrentData(cacheTimeMs); len(gpuData) > 0 {
systemStats.GPUData = gpuData systemStats.GPUData = gpuData
// add temperatures // 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 } type openWRTRestarter struct{ cmd string }
func (w *openWRTRestarter) Restart() error { 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 return nil
} }
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via procd…") 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 } type freeBSDRestarter struct{ cmd string }
@@ -64,11 +65,13 @@ func detectRestarter() restarter {
if path, err := exec.LookPath("rc-service"); err == nil { if path, err := exec.LookPath("rc-service"); err == nil {
return &openRCRestarter{cmd: path} 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 path, err := exec.LookPath("service"); err == nil {
if runtime.GOOS == "freebsd" { if runtime.GOOS == "freebsd" {
return &freeBSDRestarter{cmd: path} return &freeBSDRestarter{cmd: path}
} }
return &openWRTRestarter{cmd: path}
} }
return nil return nil
} }

View File

@@ -6,10 +6,13 @@ import "github.com/blang/semver"
const ( const (
// Version is the current version of the application. // Version is the current version of the application.
Version = "0.12.12" Version = "0.14.1"
// AppName is the name of the application. // AppName is the name of the application.
AppName = "beszel" AppName = "beszel"
) )
// MinVersionCbor is the minimum supported version for CBOR compatibility. // MinVersionCbor is the minimum supported version for CBOR compatibility.
var MinVersionCbor = semver.MustParse("0.12.0") 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/gliderlabs/ssh v0.3.8
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/lxzan/gws v1.8.9 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/dbx v1.11.0
github.com/pocketbase/pocketbase v0.30.0 github.com/pocketbase/pocketbase v0.30.1
github.com/shirou/gopsutil/v4 v4.25.8 github.com/shirou/gopsutil/v4 v4.25.9
github.com/spf13/cast v1.10.0 github.com/spf13/cast v1.10.0
github.com/spf13/cobra v1.10.1 github.com/spf13/cobra v1.10.1
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.42.0 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 gopkg.in/yaml.v3 v3.0.1
) )
@@ -65,5 +65,5 @@ require (
modernc.org/libc v1.66.3 // indirect modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // 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/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 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= 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.1 h1:8lgfhH+HiSw1PyKVMq2sjtC4ZNvda2f/envTAzWMLOA=
github.com/pocketbase/pocketbase v0.30.0/go.mod h1:gZIwampw4VqMcEdGHwBZgSa54xWIDgVJb4uINUMXLmA= 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 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= 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= 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 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 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/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.9 h1:JImNpf6gCVhKgZhtaAHJ0serfFGtlfIlSC08eaKdTrU=
github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI= 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 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= 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.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 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= 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-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A=
golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= 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.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 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA= 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= 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 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= 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.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= 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 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= 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.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.28/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= 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 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 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 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= 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 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/libc v1.66.9 h1:YkHp7E1EWrN2iyNav7JE/nHasmshPvlGkon1VxGqOw0= modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.9/go.mod h1:aVdcY7udcawRqauu0HukYYxtBSizV+R80n/6aQe9D5k= 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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 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/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= 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 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -1,22 +1,39 @@
package common package common
type WebSocketAction = uint8 import (
"github.com/henrygd/beszel/internal/entities/system"
)
// Not implemented yet type WebSocketAction = uint8
// type AgentError = uint8
const ( const (
// Request system data from agent // Request system data from agent
GetData WebSocketAction = iota GetData WebSocketAction = iota
// Check the fingerprint of the agent // Check the fingerprint of the agent
CheckFingerprint CheckFingerprint
// Request container logs from agent
GetContainerLogs
// Request container info from agent
GetContainerInfo
// Add new actions here...
) )
// HubRequest defines the structure for requests sent from hub to agent. // HubRequest defines the structure for requests sent from hub to agent.
type HubRequest[T any] struct { type HubRequest[T any] struct {
Action WebSocketAction `cbor:"0,keyasint"` Action WebSocketAction `cbor:"0,keyasint"`
Data T `cbor:"1,keyasint,omitempty,omitzero"` 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"`
String *string `cbor:"4,keyasint,omitempty,omitzero"`
// Logs *LogsPayload `cbor:"4,keyasint,omitempty,omitzero"`
// RawBytes []byte `cbor:"4,keyasint,omitempty,omitzero"`
} }
type FingerprintRequest struct { type FingerprintRequest struct {
@@ -27,6 +44,20 @@ type FingerprintRequest struct {
type FingerprintResponse struct { type FingerprintResponse struct {
Fingerprint string `cbor:"0,keyasint"` Fingerprint string `cbor:"0,keyasint"`
// Optional system info for universal token system creation // Optional system info for universal token system creation
Hostname string `cbor:"1,keyasint,omitempty,omitzero"` Hostname string `cbor:"1,keyasint,omitzero"`
Port string `cbor:"2,keyasint,omitempty,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"`
}
type ContainerLogsRequest struct {
ContainerID string `cbor:"0,keyasint"`
}
type ContainerInfoRequest struct {
ContainerID string `cbor:"0,keyasint"`
} }

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 # this is so we don't need to create the /tmp directory in the scratch container
COPY --from=builder /tmp /tmp COPY --from=builder /tmp /tmp
# Ensure data persistence across container recreations
VOLUME ["/var/lib/beszel-agent"]
ENTRYPOINT ["/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 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"] 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 # this is so we don't need to create the /tmp directory in the scratch container
COPY --from=builder /tmp /tmp COPY --from=builder /tmp /tmp
# Ensure data persistence across container recreations
VOLUME ["/var/lib/beszel-agent"]
ENTRYPOINT ["/agent"] ENTRYPOINT ["/agent"]

View File

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

View File

@@ -8,7 +8,8 @@ type ApiInfo struct {
IdShort string IdShort string
Names []string Names []string
Status string Status string
// Image string State string
Image string
// ImageID string // ImageID string
// Command string // Command string
// Created int64 // Created int64
@@ -16,7 +17,6 @@ type ApiInfo struct {
// SizeRw int64 `json:",omitempty"` // SizeRw int64 `json:",omitempty"`
// SizeRootFs int64 `json:",omitempty"` // SizeRootFs int64 `json:",omitempty"`
// Labels map[string]string // Labels map[string]string
// State string
// HostConfig struct { // HostConfig struct {
// NetworkMode string `json:",omitempty"` // NetworkMode string `json:",omitempty"`
// Annotations map[string]string `json:",omitempty"` // Annotations map[string]string `json:",omitempty"`
@@ -103,6 +103,22 @@ type prevNetStats struct {
Recv uint64 Recv uint64
} }
type DockerHealth = uint8
const (
DockerHealthNone DockerHealth = iota
DockerHealthStarting
DockerHealthHealthy
DockerHealthUnhealthy
)
var DockerHealthStrings = map[string]DockerHealth{
"none": DockerHealthNone,
"starting": DockerHealthStarting,
"healthy": DockerHealthHealthy,
"unhealthy": DockerHealthUnhealthy,
}
// Docker container stats // Docker container stats
type Stats struct { type Stats struct {
Name string `json:"n" cbor:"0,keyasint"` Name string `json:"n" cbor:"0,keyasint"`
@@ -110,6 +126,11 @@ type Stats struct {
Mem float64 `json:"m" cbor:"2,keyasint"` Mem float64 `json:"m" cbor:"2,keyasint"`
NetworkSent float64 `json:"ns" cbor:"3,keyasint"` NetworkSent float64 `json:"ns" cbor:"3,keyasint"`
NetworkRecv float64 `json:"nr" cbor:"4,keyasint"` NetworkRecv float64 `json:"nr" cbor:"4,keyasint"`
Health DockerHealth `json:"-" cbor:"5,keyasint"`
Status string `json:"-" cbor:"6,keyasint"`
Id string `json:"-" cbor:"7,keyasint"`
Image string `json:"-" cbor:"8,keyasint"`
// PrevCpu [2]uint64 `json:"-"` // PrevCpu [2]uint64 `json:"-"`
CpuSystem uint64 `json:"-"` CpuSystem uint64 `json:"-"`
CpuContainer uint64 `json:"-"` CpuContainer uint64 `json:"-"`

View File

@@ -42,6 +42,8 @@ type Stats struct {
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current] Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
MaxMem float64 `json:"mm,omitempty" cbor:"30,keyasint,omitempty"` 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] 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 { type GPUData struct {
@@ -60,6 +62,7 @@ type FsStats struct {
Time time.Time `json:"-"` Time time.Time `json:"-"`
Root bool `json:"-"` Root bool `json:"-"`
Mountpoint string `json:"-"` Mountpoint string `json:"-"`
Name string `json:"-"`
DiskTotal float64 `json:"d" cbor:"0,keyasint"` DiskTotal float64 `json:"d" cbor:"0,keyasint"`
DiskUsed float64 `json:"du" cbor:"1,keyasint"` DiskUsed float64 `json:"du" cbor:"1,keyasint"`
TotalRead uint64 `json:"-"` TotalRead uint64 `json:"-"`
@@ -68,6 +71,11 @@ type FsStats struct {
DiskWritePs float64 `json:"w" cbor:"3,keyasint"` DiskWritePs float64 `json:"w" cbor:"3,keyasint"`
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"4,keyasint,omitempty"` MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"4,keyasint,omitempty"`
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"5,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 { type NetIoStats struct {

View File

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

View File

@@ -120,7 +120,19 @@ func (h *Hub) initialize(e *core.ServeEvent) error {
return err return err
} }
// set auth settings // set auth settings
usersCollection, err := e.App.FindCollectionByNameOrId("users") if err := setCollectionAuthSettings(e.App); err != nil {
return err
}
return nil
}
// setCollectionAuthSettings sets up default authentication settings for the app
func setCollectionAuthSettings(app core.App) error {
usersCollection, err := app.FindCollectionByNameOrId("users")
if err != nil {
return err
}
superusersCollection, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
if err != nil { if err != nil {
return err return err
} }
@@ -128,10 +140,6 @@ func (h *Hub) initialize(e *core.ServeEvent) error {
disablePasswordAuth, _ := GetEnv("DISABLE_PASSWORD_AUTH") disablePasswordAuth, _ := GetEnv("DISABLE_PASSWORD_AUTH")
usersCollection.PasswordAuth.Enabled = disablePasswordAuth != "true" usersCollection.PasswordAuth.Enabled = disablePasswordAuth != "true"
usersCollection.PasswordAuth.IdentityFields = []string{"email"} usersCollection.PasswordAuth.IdentityFields = []string{"email"}
// disable oauth if no providers are configured (todo: remove this in post 0.9.0 release)
if usersCollection.OAuth2.Enabled {
usersCollection.OAuth2.Enabled = len(usersCollection.OAuth2.Providers) > 0
}
// allow oauth user creation if USER_CREATION is set // allow oauth user creation if USER_CREATION is set
if userCreation, _ := GetEnv("USER_CREATION"); userCreation == "true" { if userCreation, _ := GetEnv("USER_CREATION"); userCreation == "true" {
cr := "@request.context = 'oauth2'" cr := "@request.context = 'oauth2'"
@@ -139,11 +147,22 @@ func (h *Hub) initialize(e *core.ServeEvent) error {
} else { } else {
usersCollection.CreateRule = nil usersCollection.CreateRule = nil
} }
if err := e.App.Save(usersCollection); err != nil { // enable mfaOtp mfa if MFA_OTP env var is set
mfaOtp, _ := GetEnv("MFA_OTP")
usersCollection.OTP.Length = 6
superusersCollection.OTP.Length = 6
usersCollection.OTP.Enabled = mfaOtp == "true"
usersCollection.MFA.Enabled = mfaOtp == "true"
superusersCollection.OTP.Enabled = mfaOtp == "true" || mfaOtp == "superusers"
superusersCollection.MFA.Enabled = mfaOtp == "true" || mfaOtp == "superusers"
if err := app.Save(superusersCollection); err != nil {
return err
}
if err := app.Save(usersCollection); err != nil {
return err return err
} }
// allow all users to access systems if SHARE_ALL_SYSTEMS is set // allow all users to access systems if SHARE_ALL_SYSTEMS is set
systemsCollection, err := e.App.FindCachedCollectionByNameOrId("systems") systemsCollection, err := app.FindCollectionByNameOrId("systems")
if err != nil { if err != nil {
return err return err
} }
@@ -158,10 +177,7 @@ func (h *Hub) initialize(e *core.ServeEvent) error {
systemsCollection.ViewRule = &systemsReadRule systemsCollection.ViewRule = &systemsReadRule
systemsCollection.UpdateRule = &updateDeleteRule systemsCollection.UpdateRule = &updateDeleteRule
systemsCollection.DeleteRule = &updateDeleteRule systemsCollection.DeleteRule = &updateDeleteRule
if err := e.App.Save(systemsCollection); err != nil { return app.Save(systemsCollection)
return err
}
return nil
} }
// registerCronJobs sets up scheduled tasks // registerCronJobs sets up scheduled tasks
@@ -236,7 +252,10 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
// update / delete user alerts // update / delete user alerts
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts) apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts) apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
// get container logs
apiAuth.GET("/containers/logs", h.getContainerLogs)
// get container info
apiAuth.GET("/containers/info", h.getContainerInfo)
return nil return nil
} }
@@ -267,6 +286,41 @@ func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, response) return e.JSON(http.StatusOK, response)
} }
// containerRequestHandler handles both container logs and info requests
func (h *Hub) containerRequestHandler(e *core.RequestEvent, fetchFunc func(*systems.System, string) (string, error), responseKey string) error {
systemID := e.Request.URL.Query().Get("system")
containerID := e.Request.URL.Query().Get("container")
if systemID == "" || containerID == "" {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and container parameters are required"})
}
system, err := h.sm.GetSystem(systemID)
if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
}
data, err := fetchFunc(system, containerID)
if err != nil {
return e.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return e.JSON(http.StatusOK, map[string]string{responseKey: data})
}
// getContainerLogs handles GET /api/beszel/containers/logs requests
func (h *Hub) getContainerLogs(e *core.RequestEvent) error {
return h.containerRequestHandler(e, func(system *systems.System, containerID string) (string, error) {
return system.FetchContainerLogsFromAgent(containerID)
}, "logs")
}
func (h *Hub) getContainerInfo(e *core.RequestEvent) error {
return h.containerRequestHandler(e, func(system *systems.System, containerID string) (string, error) {
return system.FetchContainerInfoFromAgent(containerID)
}, "info")
}
// generates key pair if it doesn't exist and returns signer // generates key pair if it doesn't exist and returns signer
func (h *Hub) GetSSHKey(dataDir string) (ssh.Signer, error) { func (h *Hub) GetSSHKey(dataDir string) (ssh.Signer, error) {
if h.signer != nil { if h.signer != nil {

View File

@@ -449,6 +449,47 @@ func TestApiRoutesAuthentication(t *testing.T) {
}) })
}, },
}, },
{
Name: "GET /containers/logs - no auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/containers/logs?system=test-system&container=test-container",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/logs - with auth but missing system param should fail",
Method: http.MethodGet,
URL: "/api/beszel/containers/logs?container=test-container",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"system and container parameters are required"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/logs - with auth but missing container param should fail",
Method: http.MethodGet,
URL: "/api/beszel/containers/logs?system=test-system",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"system and container parameters are required"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/logs - with auth but invalid system should fail",
Method: http.MethodGet,
URL: "/api/beszel/containers/logs?system=invalid-system&container=test-container",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 404,
ExpectedContent: []string{"system not found"},
TestAppFactory: testAppFactory,
},
// Auth Optional Routes - Should work without authentication // Auth Optional Routes - Should work without authentication
{ {

View File

@@ -10,14 +10,17 @@ import (
"strings" "strings"
"time" "time"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/hub/ws" "github.com/henrygd/beszel/internal/hub/ws"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel" "github.com/henrygd/beszel"
"github.com/blang/semver" "github.com/blang/semver"
"github.com/fxamacker/cbor/v2" "github.com/fxamacker/cbor/v2"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
@@ -107,7 +110,7 @@ func (sys *System) update() error {
sys.handlePaused() sys.handlePaused()
return nil return nil
} }
data, err := sys.fetchDataFromAgent() data, err := sys.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: uint16(interval)})
if err == nil { if err == nil {
_, err = sys.createRecords(data) _, err = sys.createRecords(data)
} }
@@ -134,41 +137,81 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
return nil, err return nil, err
} }
hub := sys.manager.hub hub := sys.manager.hub
// add system_stats and container_stats records err = hub.RunInTransaction(func(txApp core.App) error {
systemStatsCollection, err := hub.FindCachedCollectionByNameOrId("system_stats") // add system_stats and container_stats records
if err != nil { systemStatsCollection, err := txApp.FindCachedCollectionByNameOrId("system_stats")
return nil, err
}
systemStatsRecord := core.NewRecord(systemStatsCollection)
systemStatsRecord.Set("system", systemRecord.Id)
systemStatsRecord.Set("stats", data.Stats)
systemStatsRecord.Set("type", "1m")
if err := hub.SaveNoValidate(systemStatsRecord); err != nil {
return nil, err
}
// add new container_stats record
if len(data.Containers) > 0 {
containerStatsCollection, err := hub.FindCachedCollectionByNameOrId("container_stats")
if err != nil { if err != nil {
return nil, err return err
} }
containerStatsRecord := core.NewRecord(containerStatsCollection)
containerStatsRecord.Set("system", systemRecord.Id)
containerStatsRecord.Set("stats", data.Containers)
containerStatsRecord.Set("type", "1m")
if err := hub.SaveNoValidate(containerStatsRecord); err != nil {
return nil, err
}
}
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
systemRecord.Set("status", up)
systemRecord.Set("info", data.Info) systemStatsRecord := core.NewRecord(systemStatsCollection)
if err := hub.SaveNoValidate(systemRecord); err != nil { systemStatsRecord.Set("system", systemRecord.Id)
return nil, err systemStatsRecord.Set("stats", data.Stats)
systemStatsRecord.Set("type", "1m")
if err := txApp.SaveNoValidate(systemStatsRecord); err != nil {
return err
}
if len(data.Containers) > 0 {
// add / update containers records
if data.Containers[0].Id != "" {
if err := createContainerRecords(txApp, data.Containers, sys.Id); err != nil {
return err
}
}
// add new container_stats record
containerStatsCollection, err := txApp.FindCachedCollectionByNameOrId("container_stats")
if err != nil {
return err
}
containerStatsRecord := core.NewRecord(containerStatsCollection)
containerStatsRecord.Set("system", systemRecord.Id)
containerStatsRecord.Set("stats", data.Containers)
containerStatsRecord.Set("type", "1m")
if err := txApp.SaveNoValidate(containerStatsRecord); err != nil {
return err
}
}
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
systemRecord.Set("status", up)
systemRecord.Set("info", data.Info)
if err := txApp.SaveNoValidate(systemRecord); err != nil {
return err
}
return nil
})
return systemRecord, err
}
// createContainerRecords creates container records
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
if len(data) == 0 {
return nil
} }
return systemRecord, nil params := dbx.Params{
"system": systemId,
"updated": time.Now().UTC().UnixMilli(),
}
valueStrings := make([]string, 0, len(data))
for i, container := range data {
suffix := fmt.Sprintf("%d", i)
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:image%[1]s}, {:status%[1]s}, {:health%[1]s}, {:cpu%[1]s}, {:memory%[1]s}, {:net%[1]s}, {:updated})", suffix))
params["id"+suffix] = container.Id
params["name"+suffix] = container.Name
params["image"+suffix] = container.Image
params["status"+suffix] = container.Status
params["health"+suffix] = container.Health
params["cpu"+suffix] = container.Cpu
params["memory"+suffix] = container.Mem
params["net"+suffix] = container.NetworkSent + container.NetworkRecv
}
queryString := fmt.Sprintf(
"INSERT INTO containers (id, system, name, image, status, health, cpu, memory, net, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, image = excluded.image, status = excluded.status, health = excluded.health, cpu = excluded.cpu, memory = excluded.memory, net = excluded.net, updated = excluded.updated",
strings.Join(valueStrings, ","),
)
_, err := app.DB().NewQuery(queryString).Bind(params).Execute()
return err
} }
// getRecord retrieves the system record from the database. // getRecord retrieves the system record from the database.
@@ -209,13 +252,13 @@ func (sys *System) getContext() (context.Context, context.CancelFunc) {
// fetchDataFromAgent attempts to fetch data from the agent, // fetchDataFromAgent attempts to fetch data from the agent,
// prioritizing WebSocket if available. // 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 { if sys.data == nil {
sys.data = &system.CombinedData{} sys.data = &system.CombinedData{}
} }
if sys.WsConn != nil && sys.WsConn.IsConnected() { if sys.WsConn != nil && sys.WsConn.IsConnected() {
wsData, err := sys.fetchDataViaWebSocket() wsData, err := sys.fetchDataViaWebSocket(options)
if err == nil { if err == nil {
return wsData, nil return wsData, nil
} }
@@ -223,82 +266,175 @@ func (sys *System) fetchDataFromAgent() (*system.CombinedData, error) {
sys.closeWebSocketConnection() sys.closeWebSocketConnection()
} }
sshData, err := sys.fetchDataViaSSH() sshData, err := sys.fetchDataViaSSH(options)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return sshData, nil 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() { if sys.WsConn == nil || !sys.WsConn.IsConnected() {
return nil, errors.New("no websocket connection") 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 { if err != nil {
return nil, err return nil, err
} }
return sys.data, nil return sys.data, nil
} }
// fetchStringFromAgentViaSSH is a generic function to fetch strings via SSH
func (sys *System) fetchStringFromAgentViaSSH(action common.WebSocketAction, requestData any, errorMsg string) (string, error) {
var result string
err := sys.runSSHOperation(4*time.Second, 1, func(session *ssh.Session) (bool, error) {
stdout, err := session.StdoutPipe()
if err != nil {
return false, err
}
stdin, stdinErr := session.StdinPipe()
if stdinErr != nil {
return false, stdinErr
}
if err := session.Shell(); err != nil {
return false, err
}
req := common.HubRequest[any]{Action: action, Data: requestData}
_ = cbor.NewEncoder(stdin).Encode(req)
_ = stdin.Close()
var resp common.AgentResponse
err = cbor.NewDecoder(stdout).Decode(&resp)
if err != nil {
return false, err
}
if resp.String == nil {
return false, errors.New(errorMsg)
}
result = *resp.String
return false, nil
})
return result, err
}
// FetchContainerInfoFromAgent fetches container info from the agent
func (sys *System) FetchContainerInfoFromAgent(containerID string) (string, error) {
// fetch via websocket
if sys.WsConn != nil && sys.WsConn.IsConnected() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return sys.WsConn.RequestContainerInfo(ctx, containerID)
}
// fetch via SSH
return sys.fetchStringFromAgentViaSSH(common.GetContainerInfo, common.ContainerInfoRequest{ContainerID: containerID}, "no info in response")
}
// FetchContainerLogsFromAgent fetches container logs from the agent
func (sys *System) FetchContainerLogsFromAgent(containerID string) (string, error) {
// fetch via websocket
if sys.WsConn != nil && sys.WsConn.IsConnected() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return sys.WsConn.RequestContainerLogs(ctx, containerID)
}
// fetch via SSH
return sys.fetchStringFromAgentViaSSH(common.GetContainerLogs, common.ContainerLogsRequest{ContainerID: containerID}, "no logs in response")
}
// fetchDataViaSSH handles fetching data using SSH. // fetchDataViaSSH handles fetching data using SSH.
// This function encapsulates the original SSH logic. // This function encapsulates the original SSH logic.
// It updates sys.data directly upon successful fetch. // 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 err := sys.runSSHOperation(4*time.Second, 1, func(session *ssh.Session) (bool, error) {
for attempt := 0; attempt <= maxRetries; attempt++ {
if sys.client == nil || sys.Status == down {
if err := sys.createSSHClient(); err != nil {
return nil, err
}
}
session, err := sys.createSessionWithTimeout(4 * time.Second)
if err != nil {
if attempt >= maxRetries {
return nil, err
}
sys.manager.hub.Logger().Warn("Session closed. Retrying...", "host", sys.Host, "port", sys.Port, "err", err)
sys.closeSSHConnection()
// Reset format detection on connection failure - agent might have been upgraded
continue
}
defer session.Close()
stdout, err := session.StdoutPipe() stdout, err := session.StdoutPipe()
if err != nil { if err != nil {
return nil, err return false, err
} }
stdin, stdinErr := session.StdinPipe()
if err := session.Shell(); err != nil { if err := session.Shell(); err != nil {
return nil, err return false, err
} }
*sys.data = system.CombinedData{} *sys.data = system.CombinedData{}
if sys.agentVersion.GTE(beszel.MinVersionCbor) { if sys.agentVersion.GTE(beszel.MinVersionAgentResponse) && stdinErr == nil {
err = cbor.NewDecoder(stdout).Decode(sys.data) req := common.HubRequest[any]{Action: common.GetData, Data: options}
} else { _ = cbor.NewEncoder(stdin).Encode(req)
err = json.NewDecoder(stdout).Decode(sys.data) _ = stdin.Close()
}
if err != nil { var resp common.AgentResponse
sys.closeSSHConnection() if decErr := cbor.NewDecoder(stdout).Decode(&resp); decErr == nil && resp.SystemData != nil {
if attempt < maxRetries { *sys.data = *resp.SystemData
continue if err := session.Wait(); err != nil {
return false, err
}
return false, nil
} }
return nil, err
} }
// wait for the session to complete var decodeErr error
if sys.agentVersion.GTE(beszel.MinVersionCbor) {
decodeErr = cbor.NewDecoder(stdout).Decode(sys.data)
} else {
decodeErr = json.NewDecoder(stdout).Decode(sys.data)
}
if decodeErr != nil {
return true, decodeErr
}
if err := session.Wait(); err != nil { if err := session.Wait(); err != nil {
return nil, err return false, err
} }
return sys.data, nil return false, nil
})
if err != nil {
return nil, err
} }
// this should never be reached due to the return in the loop return sys.data, nil
return nil, fmt.Errorf("failed to fetch data") }
// runSSHOperation establishes an SSH session and executes the provided operation.
// The operation can request a retry by returning true as the first return value.
func (sys *System) runSSHOperation(timeout time.Duration, retries int, operation func(*ssh.Session) (bool, error)) error {
for attempt := 0; attempt <= retries; attempt++ {
if sys.client == nil || sys.Status == down {
if err := sys.createSSHClient(); err != nil {
return err
}
}
session, err := sys.createSessionWithTimeout(timeout)
if err != nil {
if attempt >= retries {
return err
}
sys.manager.hub.Logger().Warn("Session closed. Retrying...", "host", sys.Host, "port", sys.Port, "err", err)
sys.closeSSHConnection()
continue
}
retry, opErr := func() (bool, error) {
defer session.Close()
return operation(session)
}()
if opErr == nil {
return nil
}
if retry {
sys.closeSSHConnection()
if attempt < retries {
continue
}
}
return opErr
}
return fmt.Errorf("ssh operation failed")
} }
// createSSHClient creates a new SSH client for the system // createSSHClient creates a new SSH client for the system
@@ -379,11 +515,11 @@ func extractAgentVersion(versionString string) (semver.Version, error) {
} }
// getJitter returns a channel that will be triggered after a random delay // 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. // This is used to stagger the initial WebSocket connections to prevent clustering.
func getJitter() <-chan time.Time { func getJitter() <-chan time.Time {
minPercent := 40 minPercent := 51
maxPercent := 90 maxPercent := 95
jitterRange := maxPercent - minPercent jitterRange := maxPercent - minPercent
msDelay := (interval * minPercent / 100) + rand.Intn(interval*jitterRange/100) msDelay := (interval * minPercent / 100) + rand.Intn(interval*jitterRange/100)
return time.After(time.Duration(msDelay) * time.Millisecond) return time.After(time.Duration(msDelay) * time.Millisecond)

View File

@@ -63,6 +63,15 @@ func NewSystemManager(hub hubLike) *SystemManager {
} }
} }
// GetSystem returns a system by ID from the store
func (sm *SystemManager) GetSystem(systemID string) (*System, error) {
sys, ok := sm.systems.GetOk(systemID)
if !ok {
return nil, fmt.Errorf("system not found")
}
return sys, nil
}
// Initialize sets up the system manager by binding event hooks and starting existing systems. // Initialize sets up the system manager by binding event hooks and starting existing systems.
// It configures SSH client settings and begins monitoring all non-paused systems from the database. // It configures SSH client settings and begins monitoring all non-paused systems from the database.
// Systems are started with staggered delays to prevent overwhelming the hub during startup. // Systems are started with staggered delays to prevent overwhelming the hub during startup.
@@ -106,6 +115,8 @@ func (sm *SystemManager) bindEventHooks() {
sm.hub.OnRecordAfterUpdateSuccess("systems").BindFunc(sm.onRecordAfterUpdateSuccess) sm.hub.OnRecordAfterUpdateSuccess("systems").BindFunc(sm.onRecordAfterUpdateSuccess)
sm.hub.OnRecordAfterDeleteSuccess("systems").BindFunc(sm.onRecordAfterDeleteSuccess) sm.hub.OnRecordAfterDeleteSuccess("systems").BindFunc(sm.onRecordAfterDeleteSuccess)
sm.hub.OnRecordAfterUpdateSuccess("fingerprints").BindFunc(sm.onTokenRotated) 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. // onTokenRotated handles fingerprint token rotation events.

View File

@@ -0,0 +1,188 @@
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, err := sm.GetSystem(systemId)
if err != nil {
continue
}
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
}

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

@@ -0,0 +1,159 @@
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)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// stringResponseHandler is a generic handler for string responses from agents
type stringResponseHandler struct {
BaseHandler
value string
errorMsg string
}
func (h *stringResponseHandler) Handle(agentResponse common.AgentResponse) error {
if agentResponse.String == nil {
return errors.New(h.errorMsg)
}
h.value = *agentResponse.String
return nil
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// requestContainerStringViaWS is a generic function to request container-related strings via WebSocket
func (ws *WsConn) requestContainerStringViaWS(ctx context.Context, action common.WebSocketAction, requestData any, errorMsg string) (string, error) {
if !ws.IsConnected() {
return "", gws.ErrConnClosed
}
req, err := ws.requestManager.SendRequest(ctx, action, requestData)
if err != nil {
return "", err
}
handler := &stringResponseHandler{errorMsg: errorMsg}
if err := ws.handleAgentRequest(req, handler); err != nil {
return "", err
}
return handler.value, nil
}
// RequestContainerLogs requests logs for a specific container via WebSocket.
func (ws *WsConn) RequestContainerLogs(ctx context.Context, containerID string) (string, error) {
return ws.requestContainerStringViaWS(ctx, common.GetContainerLogs, common.ContainerLogsRequest{ContainerID: containerID}, "no logs in response")
}
// RequestContainerInfo requests information about a specific container via WebSocket.
func (ws *WsConn) RequestContainerInfo(ctx context.Context, containerID string) (string, error) {
return ws.requestContainerStringViaWS(ctx, common.GetContainerInfo, common.ContainerInfoRequest{ContainerID: containerID}, "no info in response")
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// 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" "time"
"weak" "weak"
"github.com/henrygd/beszel/internal/entities/system" "github.com/blang/semver"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/common"
"github.com/fxamacker/cbor/v2" "github.com/fxamacker/cbor/v2"
"github.com/lxzan/gws" "github.com/lxzan/gws"
"golang.org/x/crypto/ssh"
) )
const ( const (
@@ -25,9 +25,10 @@ type Handler struct {
// WsConn represents a WebSocket connection to an agent. // WsConn represents a WebSocket connection to an agent.
type WsConn struct { type WsConn struct {
conn *gws.Conn conn *gws.Conn
responseChan chan *gws.Message requestManager *RequestManager
DownChan chan struct{} DownChan chan struct{}
agentVersion semver.Version
} }
// FingerprintRecord is fingerprints collection record data in the hub // FingerprintRecord is fingerprints collection record data in the hub
@@ -50,21 +51,22 @@ func GetUpgrader() *gws.Upgrader {
return upgrader return upgrader
} }
// NewWsConnection creates a new WebSocket connection wrapper. // NewWsConnection creates a new WebSocket connection wrapper with agent version.
func NewWsConnection(conn *gws.Conn) *WsConn { func NewWsConnection(conn *gws.Conn, agentVersion semver.Version) *WsConn {
return &WsConn{ return &WsConn{
conn: conn, conn: conn,
responseChan: make(chan *gws.Message, 1), requestManager: NewRequestManager(conn),
DownChan: make(chan struct{}, 1), 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) { func (h *Handler) OnOpen(conn *gws.Conn) {
conn.SetDeadline(time.Now().Add(deadline)) 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) { func (h *Handler) OnMessage(conn *gws.Conn, message *gws.Message) {
conn.SetDeadline(time.Now().Add(deadline)) conn.SetDeadline(time.Now().Add(deadline))
if message.Opcode != gws.OpcodeBinary || message.Data.Len() == 0 { 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) _ = conn.WriteClose(1000, nil)
return return
} }
select { wsConn.(*WsConn).requestManager.handleResponse(message)
case wsConn.(*WsConn).responseChan <- message:
default:
// close if the connection is not expecting a response
wsConn.(*WsConn).Close(nil)
}
} }
// OnClose handles WebSocket connection closures and triggers system down status after delay. // 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() { if ws.IsConnected() {
ws.conn.WriteClose(1000, msg) ws.conn.WriteClose(1000, msg)
} }
if ws.requestManager != nil {
ws.requestManager.Close()
}
} }
// Ping sends a ping frame to keep the connection alive. // 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. // 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 { func (ws *WsConn) sendMessage(data common.HubRequest[any]) error {
if ws.conn == nil { if ws.conn == nil {
return gws.ErrConnClosed return gws.ErrConnClosed
@@ -126,54 +127,34 @@ func (ws *WsConn) sendMessage(data common.HubRequest[any]) error {
return ws.conn.WriteMessage(gws.OpcodeBinary, bytes) return ws.conn.WriteMessage(gws.OpcodeBinary, bytes)
} }
// RequestSystemData requests system metrics from the agent and unmarshals the response. // handleAgentRequest processes a request to the agent, handling both legacy and new formats.
func (ws *WsConn) RequestSystemData(data *system.CombinedData) error { func (ws *WsConn) handleAgentRequest(req *PendingRequest, handler ResponseHandler) error {
var message *gws.Message // Wait for response
ws.sendMessage(common.HubRequest[any]{
Action: common.GetData,
})
select { select {
case <-time.After(10 * time.Second): case message := <-req.ResponseCh:
ws.Close(nil) defer message.Close()
return gws.ErrConnClosed // Cancel request context to stop timeout watcher promptly
case message = <-ws.responseChan: 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. // IsConnected returns true if the WebSocket connection is active.

View File

@@ -8,6 +8,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/blang/semver"
"github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/common"
"github.com/fxamacker/cbor/v2" "github.com/fxamacker/cbor/v2"
@@ -36,26 +37,25 @@ func TestGetUpgrader(t *testing.T) {
// TestNewWsConnection tests WebSocket connection creation // TestNewWsConnection tests WebSocket connection creation
func TestNewWsConnection(t *testing.T) { func TestNewWsConnection(t *testing.T) {
// We can't easily mock gws.Conn, so we'll pass nil and test the structure // 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.NotNil(t, wsConn, "WebSocket connection should not be nil")
assert.Nil(t, wsConn.conn, "Connection should be nil as passed") 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.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") assert.Equal(t, 1, cap(wsConn.DownChan), "Down channel should have capacity of 1")
} }
// TestWsConn_IsConnected tests the connection status check // TestWsConn_IsConnected tests the connection status check
func TestWsConn_IsConnected(t *testing.T) { func TestWsConn_IsConnected(t *testing.T) {
// Test with nil connection // 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") assert.False(t, wsConn.IsConnected(), "Should not be connected when conn is nil")
} }
// TestWsConn_Close tests the connection closing with nil connection // TestWsConn_Close tests the connection closing with nil connection
func TestWsConn_Close(t *testing.T) { func TestWsConn_Close(t *testing.T) {
wsConn := NewWsConnection(nil) wsConn := NewWsConnection(nil, semver.MustParse("0.12.10"))
// Should handle nil connection gracefully // Should handle nil connection gracefully
assert.NotPanics(t, func() { assert.NotPanics(t, func() {
@@ -65,7 +65,7 @@ func TestWsConn_Close(t *testing.T) {
// TestWsConn_SendMessage_CBOR tests CBOR encoding in sendMessage // TestWsConn_SendMessage_CBOR tests CBOR encoding in sendMessage
func TestWsConn_SendMessage_CBOR(t *testing.T) { func TestWsConn_SendMessage_CBOR(t *testing.T) {
wsConn := NewWsConnection(nil) wsConn := NewWsConnection(nil, semver.MustParse("0.12.10"))
testData := common.HubRequest[any]{ testData := common.HubRequest[any]{
Action: common.GetData, Action: common.GetData,
@@ -181,6 +181,17 @@ func TestCommonActions(t *testing.T) {
// Test that the actions we use exist and have expected values // Test that the actions we use exist and have expected values
assert.Equal(t, common.WebSocketAction(0), common.GetData, "GetData should be action 0") assert.Equal(t, common.WebSocketAction(0), common.GetData, "GetData should be action 0")
assert.Equal(t, common.WebSocketAction(1), common.CheckFingerprint, "CheckFingerprint should be action 1") assert.Equal(t, common.WebSocketAction(1), common.CheckFingerprint, "CheckFingerprint should be action 1")
assert.Equal(t, common.WebSocketAction(2), common.GetContainerLogs, "GetLogs should be action 2")
}
func TestLogsHandler(t *testing.T) {
h := &stringResponseHandler{errorMsg: "no logs in response"}
logValue := "test logs"
resp := common.AgentResponse{String: &logValue}
err := h.Handle(resp)
assert.NoError(t, err)
assert.Equal(t, logValue, h.value)
} }
// TestHandler tests that we can create a Handler // TestHandler tests that we can create a Handler
@@ -194,7 +205,7 @@ func TestHandler(t *testing.T) {
// TestWsConnChannelBehavior tests channel behavior without WebSocket connections // TestWsConnChannelBehavior tests channel behavior without WebSocket connections
func TestWsConnChannelBehavior(t *testing.T) { 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 // Test that channels are properly initialized and can be used
select { select {
@@ -212,11 +223,6 @@ func TestWsConnChannelBehavior(t *testing.T) {
t.Error("Should be able to read from DownChan") t.Error("Should be able to read from DownChan")
} }
// Response channel should be empty initially // Request manager should have no pending requests initially
select { assert.Equal(t, 0, wsConn.requestManager.GetPendingCount(), "Should have no pending requests initially")
case <-wsConn.responseChan:
t.Error("Response channel should be empty initially")
default:
// Expected - channel should be empty
}
} }

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

@@ -1,7 +1,6 @@
package migrations package migrations
import ( import (
"github.com/google/uuid"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations" m "github.com/pocketbase/pocketbase/migrations"
) )
@@ -860,6 +859,152 @@ func init() {
"system": false, "system": false,
"authRule": "verified=true", "authRule": "verified=true",
"manageRule": null "manageRule": null
},
{
"id": "pbc_1864144027",
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"name": "containers",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-f0-9]{6}",
"hidden": false,
"id": "text3208210256",
"max": 12,
"min": 6,
"name": "id",
"pattern": "^[a-f0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "2hz5ncl8tizk5nx",
"hidden": false,
"id": "relation3377271179",
"maxSelect": 1,
"minSelect": 0,
"name": "system",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1579384326",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2063623452",
"max": 0,
"min": 0,
"name": "status",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "number3470402323",
"max": null,
"min": null,
"name": "health",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number3128971310",
"max": 100,
"min": 0,
"name": "cpu",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number3933025333",
"max": null,
"min": 0,
"name": "memory",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number4075427327",
"max": null,
"min": null,
"name": "net",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number3332085495",
"max": null,
"min": null,
"name": "updated",
"onlyInt": true,
"presentable": false,
"required": true,
"system": false,
"type": "number"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3309110367",
"max": 0,
"min": 0,
"name": "image",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}
],
"indexes": [
"CREATE INDEX ` + "`" + `idx_JxWirjdhyO` + "`" + ` ON ` + "`" + `containers` + "`" + ` (` + "`" + `updated` + "`" + `)",
"CREATE INDEX ` + "`" + `idx_r3Ja0rs102` + "`" + ` ON ` + "`" + `containers` + "`" + ` (` + "`" + `system` + "`" + `)"
],
"system": false
} }
]` ]`
@@ -868,31 +1013,6 @@ func init() {
return err return err
} }
// Get all systems that don't have fingerprint records
var systemIds []string
err = app.DB().NewQuery(`
SELECT s.id FROM systems s
LEFT JOIN fingerprints f ON s.id = f.system
WHERE f.system IS NULL
`).Column(&systemIds)
if err != nil {
return err
}
// Create fingerprint records with unique UUID tokens for each system
for _, systemId := range systemIds {
token := uuid.New().String()
_, err = app.DB().NewQuery(`
INSERT INTO fingerprints (system, token)
VALUES ({:system}, {:token})
`).Bind(map[string]any{
"system": systemId,
"token": token,
}).Execute()
if err != nil {
return err
}
}
return nil return nil
}, func(app core.App) error { }, func(app core.App) error {
return nil return nil

View File

@@ -213,6 +213,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
sum.LoadAvg[2] += stats.LoadAvg[2] sum.LoadAvg[2] += stats.LoadAvg[2]
sum.Bandwidth[0] += stats.Bandwidth[0] sum.Bandwidth[0] += stats.Bandwidth[0]
sum.Bandwidth[1] += stats.Bandwidth[1] sum.Bandwidth[1] += stats.Bandwidth[1]
sum.DiskIO[0] += stats.DiskIO[0]
sum.DiskIO[1] += stats.DiskIO[1]
batterySum += int(stats.Battery[0]) batterySum += int(stats.Battery[0])
sum.Battery[1] = stats.Battery[1] sum.Battery[1] = stats.Battery[1]
// Set peak values // 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.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs)
sum.MaxBandwidth[0] = max(sum.MaxBandwidth[0], stats.MaxBandwidth[0], stats.Bandwidth[0]) 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.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 // Accumulate network interfaces
if sum.NetworkInterfaces == nil { if sum.NetworkInterfaces == nil {
@@ -314,6 +318,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
sum.DiskPct = twoDecimals(sum.DiskPct / count) sum.DiskPct = twoDecimals(sum.DiskPct / count)
sum.DiskReadPs = twoDecimals(sum.DiskReadPs / count) sum.DiskReadPs = twoDecimals(sum.DiskReadPs / count)
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count) sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
sum.DiskIO[0] = sum.DiskIO[0] / uint64(count)
sum.DiskIO[1] = sum.DiskIO[1] / uint64(count)
sum.NetworkSent = twoDecimals(sum.NetworkSent / count) sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count) sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count) sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count)
@@ -431,6 +437,10 @@ func (rm *RecordManager) DeleteOldRecords() {
if err != nil { if err != nil {
return err return err
} }
err = deleteOldContainerRecords(txApp)
if err != nil {
return err
}
err = deleteOldAlertsHistory(txApp, 200, 250) err = deleteOldAlertsHistory(txApp, 200, 250)
if err != nil { if err != nil {
return err return err
@@ -500,6 +510,20 @@ func deleteOldSystemStats(app core.App) error {
return nil return nil
} }
// Deletes container records that haven't been updated in the last 10 minutes
func deleteOldContainerRecords(app core.App) error {
now := time.Now().UTC()
tenMinutesAgo := now.Add(-10 * time.Minute)
// Delete container records where updated < tenMinutesAgo
_, err := app.DB().NewQuery("DELETE FROM containers WHERE updated < {:updated}").Bind(dbx.Params{"updated": tenMinutesAgo.UnixMilli()}).Execute()
if err != nil {
return fmt.Errorf("failed to delete old container records: %v", err)
}
return nil
}
/* Round float to two decimals */ /* Round float to two decimals */
func twoDecimals(value float64) float64 { func twoDecimals(value float64) float64 {
return math.Round(value*100) / 100 return math.Round(value*100) / 100

View File

@@ -1,41 +1,83 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.2.3/schema.json", "$schema": "https://biomejs.dev/schemas/2.2.3/schema.json",
"vcs": { "vcs": {
"enabled": false, "enabled": true,
"clientKind": "git", "clientKind": "git",
"useIgnoreFile": false "useIgnoreFile": true,
}, "defaultBranch": "main"
"files": {
"ignoreUnknown": false
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,
"indentStyle": "tab", "indentStyle": "tab",
"indentWidth": 2, "lineWidth": 120,
"lineWidth": 120 "formatWithErrors": true
}, },
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"recommended": true, "recommended": true,
"complexity": {
"noUselessStringConcat": "error",
"noUselessUndefinedInitialization": "error",
"noVoid": "error",
"useDateNow": "error"
},
"correctness": { "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": { "javascript": {
"formatter": { "formatter": {
"quoteStyle": "double", "quoteStyle": "double",
"semicolons": "asNeeded", "trailingCommas": "es5",
"trailingCommas": "es5" "semicolons": "asNeeded"
} }
}, },
"assist": { "overrides": [
"enabled": true, {
"actions": { "includes": ["**/*.jsx", "**/*.tsx"],
"source": { "linter": {
"organizeImports": "on" "rules": {
"style": {
"noParameterAssign": "error"
}
}
}
},
{
"includes": ["**/*.ts", "**/*.tsx"],
"linter": {
"rules": {
"correctness": {
"noUnusedVariables": "off"
}
}
} }
} }
} ]
} }

Binary file not shown.

View File

@@ -3,8 +3,9 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="manifest" href="./static/manifest.json" /> <link rel="manifest" href="./static/manifest.json" />
<link rel="icon" type="image/svg+xml" href="./static/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="./static/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="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> <title>Beszel</title>
<script> <script>
globalThis.BESZEL = { globalThis.BESZEL = {

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{ {
"name": "beszel", "name": "beszel",
"private": true, "private": true,
"version": "0.12.12", "version": "0.14.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite --host",
@@ -49,11 +49,12 @@
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"recharts": "^2.15.4", "recharts": "^2.15.4",
"shiki": "^3.13.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"valibot": "^0.42.1" "valibot": "^0.42.1"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.2.3", "@biomejs/biome": "2.2.4",
"@lingui/cli": "^5.4.1", "@lingui/cli": "^5.4.1",
"@lingui/swc-plugin": "^5.6.1", "@lingui/swc-plugin": "^5.6.1",
"@lingui/vite-plugin": "^5.4.1", "@lingui/vite-plugin": "^5.4.1",
@@ -76,4 +77,4 @@
"optionalDependencies": { "optionalDependencies": {
"@esbuild/linux-arm64": "^0.21.5" "@esbuild/linux-arm64": "^0.21.5"
} }
} }

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

@@ -0,0 +1,85 @@
import { alertInfo } from "@/lib/alerts"
import { $alerts, $allSystemsById } from "@/lib/stores"
import type { AlertRecord } from "@/types"
import { Plural, Trans } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router"
import { useMemo } from "react"
import { $router, Link } from "./router"
import { Alert, AlertTitle, AlertDescription } from "./ui/alert"
import { Card, CardHeader, CardTitle, CardContent } from "./ui/card"
export const ActiveAlerts = () => {
const alerts = useStore($alerts)
const systems = useStore($allSystemsById)
const { activeAlerts, alertsKey } = useMemo(() => {
const activeAlerts: AlertRecord[] = []
// key to prevent re-rendering if alerts change but active alerts didn't
const alertsKey: string[] = []
for (const systemId of Object.keys(alerts)) {
for (const alert of alerts[systemId].values()) {
if (alert.triggered && alert.name in alertInfo) {
activeAlerts.push(alert)
alertsKey.push(`${alert.system}${alert.value}${alert.min}`)
}
}
}
return { activeAlerts, alertsKey }
}, [alerts])
// biome-ignore lint/correctness/useExhaustiveDependencies: alertsKey is inclusive
return useMemo(() => {
if (activeAlerts.length === 0) {
return null
}
return (
<Card>
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
<div className="px-2 sm:px-1">
<CardTitle>
<Trans>Active Alerts</Trans>
</CardTitle>
</div>
</CardHeader>
<CardContent className="max-sm:p-2">
{activeAlerts.length > 0 && (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
{activeAlerts.map((alert) => {
const info = alertInfo[alert.name as keyof typeof alertInfo]
return (
<Alert
key={alert.id}
className="hover:-translate-y-px duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black/5"
>
<info.icon className="h-4 w-4" />
<AlertTitle>
{systems[alert.system]?.name} {info.name().toLowerCase().replace("cpu", "CPU")}
</AlertTitle>
<AlertDescription>
{alert.name === "Status" ? (
<Trans>Connection is down</Trans>
) : (
<Trans>
Exceeds {alert.value}
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
</Trans>
)}
</AlertDescription>
<Link
href={getPagePath($router, "system", { id: systems[alert.system]?.id })}
className="absolute inset-0 w-full h-full"
aria-label="View system"
></Link>
</Alert>
)
})}
</div>
)}
</CardContent>
</Card>
)
}, [alertsKey.join("")])
}

View File

@@ -2,12 +2,27 @@ import { useStore } from "@nanostores/react"
import { HistoryIcon } from "lucide-react" import { HistoryIcon } from "lucide-react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { $chartTime } from "@/lib/stores" import { $chartTime } from "@/lib/stores"
import { chartTimeData, cn } from "@/lib/utils" import { chartTimeData, cn, compareSemVer, parseSemVer } from "@/lib/utils"
import type { ChartTimes } from "@/types" 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) 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 ( return (
<Select defaultValue="1h" value={chartTime} onValueChange={(value: ChartTimes) => $chartTime.set(value)}> <Select defaultValue="1h" value={chartTime} onValueChange={(value: ChartTimes) => $chartTime.set(value)}>
<SelectTrigger className={cn(className, "relative ps-10 pe-5")}> <SelectTrigger className={cn(className, "relative ps-10 pe-5")}>
@@ -15,7 +30,7 @@ export default function ChartTimeSelect({ className }: { className?: string }) {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{Object.entries(chartTimeData).map(([value, { label }]) => ( {availableChartTimes.map(([value, { label }]) => (
<SelectItem key={value} value={value}> <SelectItem key={value} value={value}>
{label()} {label()}
</SelectItem> </SelectItem>
@@ -23,4 +38,4 @@ export default function ChartTimeSelect({ className }: { className?: string }) {
</SelectContent> </SelectContent>
</Select> </Select>
) )
} })

View File

@@ -94,8 +94,11 @@ export default memo(function ContainerChart({
if (!filter) { if (!filter) {
return new Set<string>() return new Set<string>()
} }
const filterLower = filter.toLowerCase() const filterTerms = filter.toLowerCase().split(" ").filter(term => term.length > 0)
return new Set(Object.keys(chartConfig).filter((key) => !key.toLowerCase().includes(filterLower))) return new Set(Object.keys(chartConfig).filter((key) => {
const keyLower = key.toLowerCase()
return !filterTerms.some(term => keyLower.includes(term))
}))
}, [chartConfig, filter]) }, [chartConfig, filter])
// console.log('rendered at', new Date()) // console.log('rendered at', new Date())

View File

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

View File

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

View File

@@ -91,7 +91,8 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
} }
/> />
{colors.map((key) => { {colors.map((key) => {
const filtered = filter && !key.toLowerCase().includes(filter.toLowerCase()) const filterTerms = filter ? filter.toLowerCase().split(" ").filter(term => term.length > 0) : []
const filtered = filterTerms.length > 0 && !filterTerms.some(term => key.toLowerCase().includes(term))
const strokeOpacity = filtered ? 0.1 : 1 const strokeOpacity = filtered ? 0.1 : 1
return ( return (
<Line <Line

View File

@@ -5,6 +5,7 @@ import { DialogDescription } from "@radix-ui/react-dialog"
import { import {
AlertOctagonIcon, AlertOctagonIcon,
BookIcon, BookIcon,
ContainerIcon,
DatabaseBackupIcon, DatabaseBackupIcon,
FingerprintIcon, FingerprintIcon,
LayoutDashboard, LayoutDashboard,
@@ -65,7 +66,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
<CommandItem <CommandItem
key={system.id} key={system.id}
onSelect={() => { onSelect={() => {
navigate(getPagePath($router, "system", { name: system.name })) navigate(getPagePath($router, "system", { id: system.id }))
setOpen(false) setOpen(false)
}} }}
> >
@@ -80,7 +81,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
)} )}
<CommandGroup heading={t`Pages / Settings`}> <CommandGroup heading={t`Pages / Settings`}>
<CommandItem <CommandItem
keywords={["home"]} keywords={["home", t`All Systems`]}
onSelect={() => { onSelect={() => {
navigate(basePath) navigate(basePath)
setOpen(false) setOpen(false)
@@ -94,6 +95,20 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
<Trans>Page</Trans> <Trans>Page</Trans>
</CommandShortcut> </CommandShortcut>
</CommandItem> </CommandItem>
<CommandItem
onSelect={() => {
navigate(getPagePath($router, "containers"))
setOpen(false)
}}
>
<ContainerIcon className="me-2 size-4" />
<span>
<Trans>All Containers</Trans>
</span>
<CommandShortcut>
<Trans>Page</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem <CommandItem
onSelect={() => { onSelect={() => {
navigate(getPagePath($router, "settings", { name: "general" })) navigate(getPagePath($router, "settings", { name: "general" }))

View File

@@ -0,0 +1,176 @@
import type { Column, ColumnDef } from "@tanstack/react-table"
import { Button } from "@/components/ui/button"
import { cn, decimalString, formatBytes, hourWithSeconds } from "@/lib/utils"
import type { ContainerRecord } from "@/types"
import { ContainerHealth, ContainerHealthLabels } from "@/lib/enums"
import {
ArrowUpDownIcon,
ClockIcon,
ContainerIcon,
CpuIcon,
LayersIcon,
MemoryStickIcon,
ServerIcon,
ShieldCheckIcon,
} from "lucide-react"
import { EthernetIcon, HourglassIcon } from "../ui/icons"
import { Badge } from "../ui/badge"
import { t } from "@lingui/core/macro"
import { $allSystemsById } from "@/lib/stores"
import { useStore } from "@nanostores/react"
// Unit names and their corresponding number of seconds for converting docker status strings
const unitSeconds = [["s", 1], ["mi", 60], ["h", 3600], ["d", 86400], ["w", 604800], ["mo", 2592000]] as const
// Convert docker status string to number of seconds ("Up X minutes", "Up X hours", etc.)
function getStatusValue(status: string): number {
const [_, num, unit] = status.split(" ")
const numValue = Number(num)
for (const [unitName, value] of unitSeconds) {
if (unit.startsWith(unitName)) {
return numValue * value
}
}
return 0
}
export const containerChartCols: ColumnDef<ContainerRecord>[] = [
{
id: "name",
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
accessorFn: (record) => record.name,
header: ({ column }) => <HeaderButton column={column} name={t`Name`} Icon={ContainerIcon} />,
cell: ({ getValue }) => {
return <span className="ms-1.5 xl:w-45 block truncate">{getValue() as string}</span>
},
},
{
id: "system",
accessorFn: (record) => record.system,
sortingFn: (a, b) => {
const allSystems = $allSystemsById.get()
const systemNameA = allSystems[a.original.system]?.name ?? ""
const systemNameB = allSystems[b.original.system]?.name ?? ""
return systemNameA.localeCompare(systemNameB)
},
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
cell: ({ getValue }) => {
const allSystems = useStore($allSystemsById)
return <span className="ms-1.5 xl:w-32 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
},
},
// {
// id: "id",
// accessorFn: (record) => record.id,
// sortingFn: (a, b) => a.original.id.localeCompare(b.original.id),
// header: ({ column }) => <HeaderButton column={column} name="ID" Icon={HashIcon} />,
// cell: ({ getValue }) => {
// return <span className="ms-1.5 me-3 font-mono">{getValue() as string}</span>
// },
// },
{
id: "cpu",
accessorFn: (record) => record.cpu,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`CPU`} Icon={CpuIcon} />,
cell: ({ getValue }) => {
const val = getValue() as number
return <span className="ms-1.5 tabular-nums">{`${decimalString(val, val >= 10 ? 1 : 2)}%`}</span>
},
},
{
id: "memory",
accessorFn: (record) => record.memory,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Memory`} Icon={MemoryStickIcon} />,
cell: ({ getValue }) => {
const val = getValue() as number
const formatted = formatBytes(val, false, undefined, true)
return (
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
)
},
},
{
id: "net",
accessorFn: (record) => record.net,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Net`} Icon={EthernetIcon} />,
cell: ({ getValue }) => {
const val = getValue() as number
const formatted = formatBytes(val, true, undefined, true)
return (
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
)
},
},
{
id: "health",
invertSorting: true,
accessorFn: (record) => record.health,
header: ({ column }) => <HeaderButton column={column} name={t`Health`} Icon={ShieldCheckIcon} />,
cell: ({ getValue }) => {
const healthValue = getValue() as number
const healthStatus = ContainerHealthLabels[healthValue] || "Unknown"
return (
<Badge variant="outline" className="dark:border-white/12">
<span className={cn("size-2 me-1.5 rounded-full", {
"bg-green-500": healthValue === ContainerHealth.Healthy,
"bg-red-500": healthValue === ContainerHealth.Unhealthy,
"bg-yellow-500": healthValue === ContainerHealth.Starting,
"bg-zinc-500": healthValue === ContainerHealth.None,
})}>
</span>
{healthStatus}
</Badge>
)
},
},
{
id: "image",
sortingFn: (a, b) => a.original.image.localeCompare(b.original.image),
accessorFn: (record) => record.image,
header: ({ column }) => <HeaderButton column={column} name={t({ message: "Image", context: "Docker image" })} Icon={LayersIcon} />,
cell: ({ getValue }) => {
return <span className="ms-1.5 xl:w-36 block truncate">{getValue() as string}</span>
},
},
{
id: "status",
accessorFn: (record) => record.status,
invertSorting: true,
sortingFn: (a, b) => getStatusValue(a.original.status) - getStatusValue(b.original.status),
header: ({ column }) => <HeaderButton column={column} name={t`Status`} Icon={HourglassIcon} />,
cell: ({ getValue }) => {
return <span className="ms-1.5 w-25 block truncate">{getValue() as string}</span>
},
},
{
id: "updated",
invertSorting: true,
accessorFn: (record) => record.updated,
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
cell: ({ getValue }) => {
const timestamp = getValue() as number
return (
<span className="ms-1.5 tabular-nums">
{hourWithSeconds(new Date(timestamp).toISOString())}
</span>
)
},
},
]
function HeaderButton({ column, name, Icon }: { column: Column<ContainerRecord>; name: string; Icon: React.ElementType }) {
const isSorted = column.getIsSorted()
return (
<Button
className={cn("h-9 px-3 flex items-center gap-2 duration-50", isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90")}
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{Icon && <Icon className="size-4" />}
{name}
<ArrowUpDownIcon className="size-4" />
</Button>
)
}

View File

@@ -0,0 +1,495 @@
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import {
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
type Row,
type SortingState,
type Table as TableType,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table"
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
import { memo, RefObject, useEffect, useRef, useState } from "react"
import { Input } from "@/components/ui/input"
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { pb } from "@/lib/api"
import type { ContainerRecord } from "@/types"
import { containerChartCols } from "@/components/containers-table/containers-table-columns"
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { type ContainerHealth, ContainerHealthLabels } from "@/lib/enums"
import { cn, useBrowserStorage } from "@/lib/utils"
import { Sheet, SheetTitle, SheetHeader, SheetContent, SheetDescription } from "../ui/sheet"
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"
import { Button } from "@/components/ui/button"
import { $allSystemsById } from "@/lib/stores"
import { MaximizeIcon, RefreshCwIcon } from "lucide-react"
import { Separator } from "../ui/separator"
import { $router, Link } from "../router"
import { listenKeys } from "nanostores"
import { getPagePath } from "@nanostores/router"
const syntaxTheme = "github-dark-dimmed"
export default function ContainersTable({ systemId }: { systemId?: string }) {
const [data, setData] = useState<ContainerRecord[]>([])
const [sorting, setSorting] = useBrowserStorage<SortingState>(
`sort-c-${systemId ? 1 : 0}`,
[{ id: systemId ? "name" : "system", desc: false }],
sessionStorage
)
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [rowSelection, setRowSelection] = useState({})
const [globalFilter, setGlobalFilter] = useState("")
useEffect(() => {
const pbOptions = {
fields: "id,name,image,cpu,memory,net,health,status,system,updated",
}
const fetchData = (lastXMs: number) => {
const updated = Date.now() - lastXMs
let filter: string
if (systemId) {
filter = pb.filter("system={:system} && updated > {:updated}", { system: systemId, updated })
} else {
filter = pb.filter("updated > {:updated}", { updated })
}
pb.collection<ContainerRecord>("containers")
.getList(0, 2000, {
...pbOptions,
filter,
})
.then(({ items }) => setData((curItems) => {
const containerIds = new Set(items.map(item => item.id))
const now = Date.now()
for (const item of curItems) {
if (!containerIds.has(item.id) && now - item.updated < 70_000) {
items.push(item)
}
}
return items
}))
}
// initial load
fetchData(70_000)
// if no systemId, poll every 10 seconds
if (!systemId) {
// poll every 10 seconds
const intervalId = setInterval(() => fetchData(10_500), 10_000)
// clear interval on unmount
return () => clearInterval(intervalId)
}
// if systemId, fetch containers after the system is updated
return listenKeys($allSystemsById, [systemId], (_newSystems) => {
setTimeout(() => fetchData(1000), 100)
})
}, [])
const table = useReactTable({
data,
columns: containerChartCols.filter(col => systemId ? col.id !== "system" : true),
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
defaultColumn: {
sortUndefined: "last",
size: 100,
minSize: 0,
},
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
globalFilter,
},
onGlobalFilterChange: setGlobalFilter,
globalFilterFn: (row, _columnId, filterValue) => {
const container = row.original
const systemName = $allSystemsById.get()[container.system]?.name ?? ""
const id = container.id ?? ""
const name = container.name ?? ""
const status = container.status ?? ""
const healthLabel = ContainerHealthLabels[container.health as ContainerHealth] ?? ""
const image = container.image ?? ""
const searchString = `${systemName} ${id} ${name} ${healthLabel} ${status} ${image}`.toLowerCase()
return (filterValue as string)
.toLowerCase()
.split(" ")
.every((term) => searchString.includes(term))
},
})
const rows = table.getRowModel().rows
const visibleColumns = table.getVisibleLeafColumns()
return (
<Card className="p-6 @container w-full">
<CardHeader className="p-0 mb-4">
<div className="grid md:flex gap-5 w-full items-end">
<div className="px-2 sm:px-1">
<CardTitle className="mb-2">
<Trans>All Containers</Trans>
</CardTitle>
<CardDescription className="flex">
<Trans>Click on a container to view more information.</Trans>
</CardDescription>
</div>
<Input
placeholder={t`Filter...`}
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="ms-auto px-4 w-full max-w-full md:w-64"
/>
</div>
</CardHeader>
<div className="rounded-md">
<AllContainersTable table={table} rows={rows} colLength={visibleColumns.length} />
</div>
</Card>
)
}
const AllContainersTable = memo(
function AllContainersTable({ table, rows, colLength }: { table: TableType<ContainerRecord>; rows: Row<ContainerRecord>[]; colLength: number }) {
// The virtualizer will need a reference to the scrollable container element
const scrollRef = useRef<HTMLDivElement>(null)
const activeContainer = useRef<ContainerRecord | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const openSheet = (container: ContainerRecord) => {
activeContainer.current = container
setSheetOpen(true)
}
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
count: rows.length,
estimateSize: () => 54,
getScrollElement: () => scrollRef.current,
overscan: 5,
})
const virtualRows = virtualizer.getVirtualItems()
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
return (
<div
className={cn(
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
// don't set min height if there are less than 2 rows, do set if we need to display the empty state
(!rows.length || rows.length > 2) && "min-h-50"
)}
ref={scrollRef}
>
{/* add header height to table size */}
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
<table className="text-sm w-full h-full text-nowrap">
<ContainersTableHead table={table} />
<TableBody>
{rows.length ? (
virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
return (
<ContainerTableRow
key={row.id}
row={row}
virtualRow={virtualRow}
openSheet={openSheet}
/>
)
})
) : (
<TableRow>
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
<Trans>No results.</Trans>
</TableCell>
</TableRow>
)}
</TableBody>
</table>
</div>
<ContainerSheet sheetOpen={sheetOpen} setSheetOpen={setSheetOpen} activeContainer={activeContainer} />
</div>
)
}
)
async function getLogsHtml(container: ContainerRecord): Promise<string> {
try {
const [{ highlighter }, logsHtml] = await Promise.all([import('@/lib/shiki'), pb.send<{ logs: string }>("/api/beszel/containers/logs", {
system: container.system,
container: container.id,
})])
return highlighter.codeToHtml(logsHtml.logs, { lang: "log", theme: syntaxTheme })
} catch (error) {
console.error(error)
return ""
}
}
async function getInfoHtml(container: ContainerRecord): Promise<string> {
try {
let [{ highlighter }, { info }] = await Promise.all([import('@/lib/shiki'), pb.send<{ info: string }>("/api/beszel/containers/info", {
system: container.system,
container: container.id,
})])
try {
info = JSON.stringify(JSON.parse(info), null, 2)
} catch (_) { }
return highlighter.codeToHtml(info, { lang: "json", theme: syntaxTheme })
} catch (error) {
console.error(error)
return ""
}
}
function ContainerSheet({ sheetOpen, setSheetOpen, activeContainer }: { sheetOpen: boolean, setSheetOpen: (open: boolean) => void, activeContainer: RefObject<ContainerRecord | null> }) {
const container = activeContainer.current
if (!container) return null
const [logsDisplay, setLogsDisplay] = useState<string>("")
const [infoDisplay, setInfoDisplay] = useState<string>("")
const [logsFullscreenOpen, setLogsFullscreenOpen] = useState<boolean>(false)
const [infoFullscreenOpen, setInfoFullscreenOpen] = useState<boolean>(false)
const [isRefreshingLogs, setIsRefreshingLogs] = useState<boolean>(false)
const logsContainerRef = useRef<HTMLDivElement>(null)
function scrollLogsToBottom() {
if (logsContainerRef.current) {
logsContainerRef.current.scrollTo({ top: logsContainerRef.current.scrollHeight })
}
}
const refreshLogs = async () => {
setIsRefreshingLogs(true)
const startTime = Date.now()
try {
const logsHtml = await getLogsHtml(container)
setLogsDisplay(logsHtml)
setTimeout(scrollLogsToBottom, 20)
} catch (error) {
console.error(error)
} finally {
// Ensure minimum spin duration of 800ms
const elapsed = Date.now() - startTime
const remaining = Math.max(0, 500 - elapsed)
setTimeout(() => {
setIsRefreshingLogs(false)
}, remaining)
}
}
useEffect(() => {
setLogsDisplay("")
setInfoDisplay("");
if (!container) return
(async () => {
const [logsHtml, infoHtml] = await Promise.all([getLogsHtml(container), getInfoHtml(container)])
setLogsDisplay(logsHtml)
setInfoDisplay(infoHtml)
setTimeout(scrollLogsToBottom, 20)
})()
}, [container])
return (
<>
<LogsFullscreenDialog
open={logsFullscreenOpen}
onOpenChange={setLogsFullscreenOpen}
logsDisplay={logsDisplay}
containerName={container.name}
onRefresh={refreshLogs}
isRefreshing={isRefreshingLogs}
/>
<InfoFullscreenDialog
open={infoFullscreenOpen}
onOpenChange={setInfoFullscreenOpen}
infoDisplay={infoDisplay}
containerName={container.name}
/>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent className="w-full sm:max-w-220 p-2">
<SheetHeader>
<SheetTitle>{container.name}</SheetTitle>
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Link className="hover:underline" href={getPagePath($router, "system", { id: container.system })}>{$allSystemsById.get()[container.system]?.name ?? ""}</Link>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
{container.status}
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
{container.image}
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
{container.id}
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
{ContainerHealthLabels[container.health as ContainerHealth]}
</SheetDescription>
</SheetHeader>
<div className="px-3 pb-3 -mt-4 flex flex-col gap-3 h-full items-start">
<div className="flex items-center w-full">
<h3>{t`Logs`}</h3>
<Button
variant="ghost"
size="sm"
onClick={refreshLogs}
className="h-8 w-8 p-0 ms-auto"
disabled={isRefreshingLogs}
>
<RefreshCwIcon
className={`size-4 transition-transform duration-300 ${isRefreshingLogs ? 'animate-spin' : ''}`}
/>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setLogsFullscreenOpen(true)}
className="h-8 w-8 p-0"
>
<MaximizeIcon className="size-4" />
</Button>
</div>
<div ref={logsContainerRef} className={cn("max-h-[calc(50dvh-10rem)] w-full overflow-auto p-3 rounded-md bg-gh-dark text-sm", !logsDisplay && ["animate-pulse", "h-full"])}>
<div dangerouslySetInnerHTML={{ __html: logsDisplay }} />
</div>
<div className="flex items-center w-full">
<h3>{t`Detail`}</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setInfoFullscreenOpen(true)}
className="h-8 w-8 p-0 ms-auto"
>
<MaximizeIcon className="size-4" />
</Button>
</div>
<div className={cn("grow h-[calc(50dvh-4rem)] w-full overflow-auto p-3 rounded-md bg-gh-dark text-sm", !infoDisplay && "animate-pulse")}>
<div dangerouslySetInnerHTML={{ __html: infoDisplay }} />
</div>
</div>
</SheetContent>
</Sheet>
</>
)
}
function ContainersTableHead({ table }: { table: TableType<ContainerRecord> }) {
return (
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead className="px-2" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</tr>
))}
</TableHeader>
)
}
const ContainerTableRow = memo(
function ContainerTableRow({
row,
virtualRow,
openSheet,
}: {
row: Row<ContainerRecord>
virtualRow: VirtualItem
openSheet: (container: ContainerRecord) => void
}) {
return (
<TableRow
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer transition-opacity"
onClick={() => openSheet(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="py-0"
style={{
height: virtualRow.size,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}
)
function LogsFullscreenDialog({ open, onOpenChange, logsDisplay, containerName, onRefresh, isRefreshing }: { open: boolean, onOpenChange: (open: boolean) => void, logsDisplay: string, containerName: string, onRefresh: () => void | Promise<void>, isRefreshing: boolean }) {
const outerContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (open && logsDisplay) {
// Scroll the outer container to bottom
const scrollToBottom = () => {
if (outerContainerRef.current) {
outerContainerRef.current.scrollTop = outerContainerRef.current.scrollHeight
}
}
setTimeout(scrollToBottom, 50)
}
}, [open, logsDisplay])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-20px)] h-[calc(100dvh-20px)] max-w-none p-0 bg-gh-dark border-0 text-white">
<DialogTitle className="sr-only">{containerName} logs</DialogTitle>
<div ref={outerContainerRef} className="h-full overflow-auto">
<div className="h-full w-full px-3 leading-relaxed rounded-md bg-gh-dark text-sm">
<div className="py-3" dangerouslySetInnerHTML={{ __html: logsDisplay }} />
</div>
</div>
<button
onClick={() => {
void onRefresh()
}}
className="absolute top-3 right-11 opacity-60 hover:opacity-100 p-1"
disabled={isRefreshing}
title={t`Refresh`}
aria-label={t`Refresh`}
>
<RefreshCwIcon
className={`size-4 transition-transform duration-300 ${isRefreshing ? 'animate-spin' : ''}`}
/>
</button>
</DialogContent>
</Dialog>
)
}
function InfoFullscreenDialog({ open, onOpenChange, infoDisplay, containerName }: { open: boolean, onOpenChange: (open: boolean) => void, infoDisplay: string, containerName: string }) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-20px)] h-[calc(100dvh-20px)] max-w-none p-0 bg-gh-dark border-0 text-white">
<DialogTitle className="sr-only">{containerName} info</DialogTitle>
<div className="flex-1 overflow-auto">
<div className="h-full w-full overflow-auto p-3 rounded-md bg-gh-dark text-sm leading-relaxed">
<div dangerouslySetInnerHTML={{ __html: infoDisplay }} />
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,26 @@
import { GithubIcon } from "lucide-react"
import { Separator } from "./ui/separator"
export function FooterRepoLink() {
return (
<div className="flex gap-1.5 justify-end items-center pe-3 sm:pe-6 mt-3.5 mb-4 text-xs opacity-80">
<a
href="https://github.com/henrygd/beszel"
target="_blank"
className="flex items-center gap-0.5 text-muted-foreground hover:text-foreground duration-75"
rel="noopener"
>
<GithubIcon className="h-3 w-3" /> GitHub
</a>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<a
href="https://github.com/henrygd/beszel/releases"
target="_blank"
className="text-muted-foreground hover:text-foreground duration-75"
rel="noopener"
>
Beszel {globalThis.BESZEL.HUB_VERSION}
</a>
</div>
)
}

View File

@@ -12,7 +12,7 @@ export function LangToggle() {
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant={"ghost"} size="icon" className="hidden 450:flex"> <Button variant={"ghost"} size="icon" className="hidden sm:flex">
<LanguagesIcon className="absolute h-[1.2rem] w-[1.2rem] light:opacity-85" /> <LanguagesIcon className="absolute h-[1.2rem] w-[1.2rem] light:opacity-85" />
<span className="sr-only">Language</span> <span className="sr-only">Language</span>
</Button> </Button>

View File

@@ -1,16 +1,27 @@
import { useId } from "react"
const d = "M146.4 73.1h-30.5V59.8h30.5a3.2 3.2 0 0 0 2.3-1 3.2 3.2 0 0 0 1-2.3q0-.8-.3-1.3a1.5 1.5 0 0 0-.7-.6 4.7 4.7 0 0 0-1-.3l-1.3-.1h-13.9q-3.4 0-6.5-1.3-3-1.3-5.2-3.6a16.9 16.9 0 0 1-3.6-5.3 16.3 16.3 0 0 1-1.3-6.5 16.4 16.4 0 0 1 1.3-6.4q1.3-3.1 3.6-5.4 2.2-2.2 5.2-3.5a16.3 16.3 0 0 1 6.5-1.3h27v13.3h-27a3.2 3.2 0 0 0-2.3 1 3.2 3.2 0 0 0-1 2.3 3.3 3.3 0 0 0 1 2.4 3.3 3.3 0 0 0 1.2.8 3.2 3.2 0 0 0 1.1.2h13.9a18.1 18.1 0 0 1 6 1 17.3 17.3 0 0 1 .4.2q3 1.1 5.3 3.2a15.1 15.1 0 0 1 3.6 4.9 14.7 14.7 0 0 1 1.3 5.4 17.2 17.2 0 0 1 0 .9 16 16 0 0 1-1 5.8 15.4 15.4 0 0 1-.3.7 17.3 17.3 0 0 1-3.6 5.2 16.4 16.4 0 0 1-5.3 3.6 16.2 16.2 0 0 1-6.4 1.3Zm64.5-13.3v13.3h-43.6l22-39h-22V21h43.6l-22 39h22ZM35 73.1H0v-70h35q4.4 0 8.2 1.6a21.4 21.4 0 0 1 6.6 4.6q2.9 2.8 4.5 6.6 1.7 3.8 1.7 8.2a15.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.5 1.4 1.6 2.4 3.5a18.3 18.3 0 0 1 1.5 4A17.4 17.4 0 0 1 56 51a15.3 15.3 0 0 1 0 1.1q0 4.3-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.5-3.8 1.7-8.2 1.7Zm76-43L86 60.4l1.5.3a16.7 16.7 0 0 0 1.6 0q2 0 3.8-.4 1.8-.6 3.4-1.6 1.6-1 2.8-2.4a12.8 12.8 0 0 0 2-3.2l9.8 9.8q-1.9 2.6-4.3 4.7a27 27 0 0 1-5.2 3.6 26.1 26.1 0 0 1-6 2.2 26.8 26.8 0 0 1-6.3.8 26.4 26.4 0 0 1-10.4-2 26.2 26.2 0 0 1-8.5-5.8 26.7 26.7 0 0 1-5.5-8.3 30.4 30.4 0 0 1-.2-.4q-2.1-5-2.1-11.1a31.9 31.9 0 0 1 .7-7 27 27 0 0 1 1.4-4.3 27 27 0 0 1 3.8-6.6 24.5 24.5 0 0 1 2-2.2 26 26 0 0 1 8.4-5.6 27 27 0 0 1 10.4-2 26.3 26.3 0 0 1 6.4.8 26.9 26.9 0 0 1 6 2.2q2.7 1.5 5.2 3.6 2.4 2.1 4.3 4.8Zm152.3 0-25 30.2 1.5.3a16.7 16.7 0 0 0 1.6 0q2 0 3.8-.4 1.8-.6 3.4-1.6 1.5-1 2.8-2.4a12.8 12.8 0 0 0 2-3.2l9.8 9.8q-1.9 2.6-4.3 4.7a27 27 0 0 1-5.2 3.6 26.1 26.1 0 0 1-6 2.2 26.8 26.8 0 0 1-6.3.8 26.4 26.4 0 0 1-10.4-2 26.2 26.2 0 0 1-8.5-5.8A26.7 26.7 0 0 1 217 58a30.4 30.4 0 0 1-.2-.4q-2.1-5-2.1-11.1a31.9 31.9 0 0 1 .7-7 27 27 0 0 1 1.4-4.3 27 27 0 0 1 3.8-6.6 24.5 24.5 0 0 1 2-2.2 26 26 0 0 1 8.4-5.6 27 27 0 0 1 10.4-2 26.3 26.3 0 0 1 6.4.8 26.9 26.9 0 0 1 6 2.2q2.7 1.5 5.2 3.6 2.4 2.1 4.3 4.8ZM283.4 0v73.1H270V0h13.4ZM14 17v14.1h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 30 40 29a6.9 6.9 0 0 0 1.5-2.3q.5-1.3.5-2.7a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.5q-.6-1.2-1.5-2.2a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.5 7.9 7.9 0 0 0-.2 0H14Zm0 28.1v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.2Q39 58 40 57.1a7 7 0 0 0 1.5-2.3 6.9 6.9 0 0 0 .5-2.5 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 48 40 47a7 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 0H14Zm63.3 8.3 15.5-20.6a8 8 0 0 0-1.4-.4 7 7 0 0 0-.4 0 17.2 17.2 0 0 0-1.6-.1 19.2 19.2 0 0 0-.3 0 13.3 13.3 0 0 0-5.1 1q-2.5 1-4.2 2.8a13.1 13.1 0 0 0-2.5 3.6 15.5 15.5 0 0 0-.3.9 14.7 14.7 0 0 0-1 3.5 18.7 18.7 0 0 0 0 2.4 17.6 17.6 0 0 0 0 .7v.8a29.4 29.4 0 0 0 0 .1 19.2 19.2 0 0 0 .2 2 20.2 20.2 0 0 0 .4 1.6 18.6 18.6 0 0 0 0 .2 7.5 7.5 0 0 0 .4.9 6 6 0 0 0 .3.6Zm152.3 0L245 32.8a8 8 0 0 0-1.4-.4 7 7 0 0 0-.4 0 17.2 17.2 0 0 0-1.6-.1 19.2 19.2 0 0 0-.3 0 13.3 13.3 0 0 0-5.1 1q-2.5 1-4.2 2.8a13.1 13.1 0 0 0-2.5 3.6 15.5 15.5 0 0 0-.4.9 14.7 14.7 0 0 0-.8 3.5 18.7 18.7 0 0 0-.2 2.4 17.6 17.6 0 0 0 0 .7v.8a29.4 29.4 0 0 0 .1.1 19.2 19.2 0 0 0 .2 2 20.2 20.2 0 0 0 .4 1.6 18.6 18.6 0 0 0 0 .2 7.5 7.5 0 0 0 .4.9 6 6 0 0 0 .3.6Z"
export function Logo({ className }: { className?: string }) { export function Logo({ className }: { className?: string }) {
const id = useId()
return ( return (
// Righteous // Righteous font from Google Fonts
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 285 75" className={className}> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 285 75" className={className}>
{/* <defs> <defs>
<linearGradient id="gradient" x1="0%" y1="20%" x2="100%" y2="120%"> <linearGradient id={id} x1="0%" y1="20%" x2="100%" y2="120%">
<stop offset="0%" style={{ stopColor: "#747bff" }} /> <stop offset="10%" style={{ stopColor: "#747bff" }} />
<stop offset="100%" style={{ stopColor: "#24eb5c" }} /> <stop offset="90%" style={{ stopColor: "#24eb5c" }} />
</linearGradient> </linearGradient>
</defs> */} </defs>
<path <path
// fill="url(#gradient)" className="duration-250 group-hover:opacity-0 group-hover:ease-in ease-out"
d="M146.4 73.1h-30.5V59.8h30.5a3.2 3.2 0 0 0 2.3-1 3.2 3.2 0 0 0 1-2.3q0-.8-.3-1.3a1.5 1.5 0 0 0-.7-.6 4.7 4.7 0 0 0-1-.3l-1.3-.1h-13.9q-3.4 0-6.5-1.3-3-1.3-5.2-3.6a16.9 16.9 0 0 1-3.6-5.3 16.3 16.3 0 0 1-1.3-6.5 16.4 16.4 0 0 1 1.3-6.4q1.3-3.1 3.6-5.4 2.2-2.2 5.2-3.5a16.3 16.3 0 0 1 6.5-1.3h27v13.3h-27a3.2 3.2 0 0 0-2.3 1 3.2 3.2 0 0 0-1 2.3 3.3 3.3 0 0 0 1 2.4 3.3 3.3 0 0 0 1.2.8 3.2 3.2 0 0 0 1.1.2h13.9a18.1 18.1 0 0 1 6 1 17.3 17.3 0 0 1 .4.2q3 1.1 5.3 3.2a15.1 15.1 0 0 1 3.6 4.9 14.7 14.7 0 0 1 1.3 5.4 17.2 17.2 0 0 1 0 .9 16 16 0 0 1-1 5.8 15.4 15.4 0 0 1-.3.7 17.3 17.3 0 0 1-3.6 5.2 16.4 16.4 0 0 1-5.3 3.6 16.2 16.2 0 0 1-6.4 1.3Zm64.5-13.3v13.3h-43.6l22-39h-22V21h43.6l-22 39h22ZM35 73.1H0v-70h35q4.4 0 8.2 1.6a21.4 21.4 0 0 1 6.6 4.6q2.9 2.8 4.5 6.6 1.7 3.8 1.7 8.2a15.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.5 1.4 1.6 2.4 3.5a18.3 18.3 0 0 1 1.5 4A17.4 17.4 0 0 1 56 51a15.3 15.3 0 0 1 0 1.1q0 4.3-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.5-3.8 1.7-8.2 1.7Zm76-43L86 60.4l1.5.3a16.7 16.7 0 0 0 1.6 0q2 0 3.8-.4 1.8-.6 3.4-1.6 1.6-1 2.8-2.4a12.8 12.8 0 0 0 2-3.2l9.8 9.8q-1.9 2.6-4.3 4.7a27 27 0 0 1-5.2 3.6 26.1 26.1 0 0 1-6 2.2 26.8 26.8 0 0 1-6.3.8 26.4 26.4 0 0 1-10.4-2 26.2 26.2 0 0 1-8.5-5.8 26.7 26.7 0 0 1-5.5-8.3 30.4 30.4 0 0 1-.2-.4q-2.1-5-2.1-11.1a31.9 31.9 0 0 1 .7-7 27 27 0 0 1 1.4-4.3 27 27 0 0 1 3.8-6.6 24.5 24.5 0 0 1 2-2.2 26 26 0 0 1 8.4-5.6 27 27 0 0 1 10.4-2 26.3 26.3 0 0 1 6.4.8 26.9 26.9 0 0 1 6 2.2q2.7 1.5 5.2 3.6 2.4 2.1 4.3 4.8Zm152.3 0-25 30.2 1.5.3a16.7 16.7 0 0 0 1.6 0q2 0 3.8-.4 1.8-.6 3.4-1.6 1.5-1 2.8-2.4a12.8 12.8 0 0 0 2-3.2l9.8 9.8q-1.9 2.6-4.3 4.7a27 27 0 0 1-5.2 3.6 26.1 26.1 0 0 1-6 2.2 26.8 26.8 0 0 1-6.3.8 26.4 26.4 0 0 1-10.4-2 26.2 26.2 0 0 1-8.5-5.8A26.7 26.7 0 0 1 217 58a30.4 30.4 0 0 1-.2-.4q-2.1-5-2.1-11.1a31.9 31.9 0 0 1 .7-7 27 27 0 0 1 1.4-4.3 27 27 0 0 1 3.8-6.6 24.5 24.5 0 0 1 2-2.2 26 26 0 0 1 8.4-5.6 27 27 0 0 1 10.4-2 26.3 26.3 0 0 1 6.4.8 26.9 26.9 0 0 1 6 2.2q2.7 1.5 5.2 3.6 2.4 2.1 4.3 4.8ZM283.4 0v73.1H270V0h13.4ZM14 17v14.1h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 30 40 29a6.9 6.9 0 0 0 1.5-2.3q.5-1.3.5-2.7a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.5q-.6-1.2-1.5-2.2a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.5 7.9 7.9 0 0 0-.2 0H14Zm0 28.1v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.2Q39 58 40 57.1a7 7 0 0 0 1.5-2.3 6.9 6.9 0 0 0 .5-2.5 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 48 40 47a7 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 0H14Zm63.3 8.3 15.5-20.6a8 8 0 0 0-1.4-.4 7 7 0 0 0-.4 0 17.2 17.2 0 0 0-1.6-.1 19.2 19.2 0 0 0-.3 0 13.3 13.3 0 0 0-5.1 1q-2.5 1-4.2 2.8a13.1 13.1 0 0 0-2.5 3.6 15.5 15.5 0 0 0-.3.9 14.7 14.7 0 0 0-1 3.5 18.7 18.7 0 0 0 0 2.4 17.6 17.6 0 0 0 0 .7v.8a29.4 29.4 0 0 0 0 .1 19.2 19.2 0 0 0 .2 2 20.2 20.2 0 0 0 .4 1.6 18.6 18.6 0 0 0 0 .2 7.5 7.5 0 0 0 .4.9 6 6 0 0 0 .3.6Zm152.3 0L245 32.8a8 8 0 0 0-1.4-.4 7 7 0 0 0-.4 0 17.2 17.2 0 0 0-1.6-.1 19.2 19.2 0 0 0-.3 0 13.3 13.3 0 0 0-5.1 1q-2.5 1-4.2 2.8a13.1 13.1 0 0 0-2.5 3.6 15.5 15.5 0 0 0-.4.9 14.7 14.7 0 0 0-.8 3.5 18.7 18.7 0 0 0-.2 2.4 17.6 17.6 0 0 0 0 .7v.8a29.4 29.4 0 0 0 .1.1 19.2 19.2 0 0 0 .2 2 20.2 20.2 0 0 0 .4 1.6 18.6 18.6 0 0 0 0 .2 7.5 7.5 0 0 0 .4.9 6 6 0 0 0 .3.6Z" d={d}
/>
<path
className="opacity-0 duration-250 group-hover:opacity-100 ease-in-out"
fill={`url(#${id})`}
d={d}
/> />
</svg> </svg>
) )

View File

@@ -1,6 +1,7 @@
import { Trans } from "@lingui/react/macro" import { Trans } from "@lingui/react/macro"
import { getPagePath } from "@nanostores/router" import { getPagePath } from "@nanostores/router"
import { import {
ContainerIcon,
DatabaseBackupIcon, DatabaseBackupIcon,
LogOutIcon, LogOutIcon,
LogsIcon, LogsIcon,
@@ -39,7 +40,7 @@ export default function Navbar() {
<Link <Link
href={basePath} href={basePath}
aria-label="Home" aria-label="Home"
className="p-2 ps-0 me-3" className="p-2 ps-0 me-3 group"
onMouseEnter={runOnce(() => import("@/components/routes/home"))} onMouseEnter={runOnce(() => import("@/components/routes/home"))}
> >
<Logo className="h-[1.1rem] md:h-5 fill-foreground" /> <Logo className="h-[1.1rem] md:h-5 fill-foreground" />
@@ -47,18 +48,25 @@ export default function Navbar() {
<SearchButton /> <SearchButton />
<div className="flex items-center ms-auto" onMouseEnter={() => import("@/components/routes/settings/general")}> <div className="flex items-center ms-auto" onMouseEnter={() => import("@/components/routes/settings/general")}>
<Link
href={getPagePath($router, "containers")}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
aria-label="Containers"
>
<ContainerIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
</Link>
<LangToggle /> <LangToggle />
<ModeToggle /> <ModeToggle />
<Link <Link
href={getPagePath($router, "settings", { name: "general" })} href={getPagePath($router, "settings", { name: "general" })}
aria-label="Settings" aria-label="Settings"
className={cn("", buttonVariants({ variant: "ghost", size: "icon" }))} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
> >
<SettingsIcon className="h-[1.2rem] w-[1.2rem]" /> <SettingsIcon className="h-[1.2rem] w-[1.2rem]" />
</Link> </Link>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button aria-label="User Actions" className={cn("", buttonVariants({ variant: "ghost", size: "icon" }))}> <button aria-label="User Actions" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
<UserIcon className="h-[1.2rem] w-[1.2rem]" /> <UserIcon className="h-[1.2rem] w-[1.2rem]" />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -112,7 +120,7 @@ export default function Navbar() {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<AddSystemButton className="ms-2" /> <AddSystemButton className="ms-2 hidden 450:flex" />
</div> </div>
</div> </div>
) )

View File

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

View File

@@ -0,0 +1,26 @@
import { useLingui } from "@lingui/react/macro"
import { memo, useEffect, useMemo } from "react"
import ContainersTable from "@/components/containers-table/containers-table"
import { ActiveAlerts } from "@/components/active-alerts"
import { FooterRepoLink } from "@/components/footer-repo-link"
export default memo(() => {
const { t } = useLingui()
useEffect(() => {
document.title = `${t`All Containers`} / Beszel`
}, [t])
return useMemo(
() => (
<>
<div className="grid gap-4">
<ActiveAlerts />
<ContainersTable />
</div>
<FooterRepoLink />
</>
),
[]
)
})

View File

@@ -1,128 +1,28 @@
import { Plural, Trans, useLingui } from "@lingui/react/macro" import { useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router"
import { GithubIcon } from "lucide-react"
import { memo, Suspense, useEffect, useMemo } from "react" import { memo, Suspense, useEffect, useMemo } from "react"
import { $router, Link } from "@/components/router"
import SystemsTable from "@/components/systems-table/systems-table" import SystemsTable from "@/components/systems-table/systems-table"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { ActiveAlerts } from "@/components/active-alerts"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { FooterRepoLink } from "@/components/footer-repo-link"
import { Separator } from "@/components/ui/separator"
import { alertInfo } from "@/lib/alerts"
import { $alerts, $allSystemsById } from "@/lib/stores"
import type { AlertRecord } from "@/types"
export default memo(() => { export default memo(() => {
const { t } = useLingui() const { t } = useLingui()
useEffect(() => { useEffect(() => {
document.title = `${t`Dashboard`} / Beszel` document.title = `${t`All Systems`} / Beszel`
}, [t]) }, [t])
return useMemo( return useMemo(
() => ( () => (
<> <>
<ActiveAlerts /> <div className="flex flex-col gap-4">
<Suspense> <ActiveAlerts />
<SystemsTable /> <Suspense>
</Suspense> <SystemsTable />
</Suspense>
<div className="flex gap-1.5 justify-end items-center pe-3 sm:pe-6 mt-3.5 mb-4 text-xs opacity-80">
<a
href="https://github.com/henrygd/beszel"
target="_blank"
className="flex items-center gap-0.5 text-muted-foreground hover:text-foreground duration-75"
rel="noopener"
>
<GithubIcon className="h-3 w-3" /> GitHub
</a>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<a
href="https://github.com/henrygd/beszel/releases"
target="_blank"
className="text-muted-foreground hover:text-foreground duration-75"
rel="noopener"
>
Beszel {globalThis.BESZEL.HUB_VERSION}
</a>
</div> </div>
<FooterRepoLink />
</> </>
), ),
[] []
) )
}) })
const ActiveAlerts = () => {
const alerts = useStore($alerts)
const systems = useStore($allSystemsById)
const { activeAlerts, alertsKey } = useMemo(() => {
const activeAlerts: AlertRecord[] = []
// key to prevent re-rendering if alerts change but active alerts didn't
const alertsKey: string[] = []
for (const systemId of Object.keys(alerts)) {
for (const alert of alerts[systemId].values()) {
if (alert.triggered && alert.name in alertInfo) {
activeAlerts.push(alert)
alertsKey.push(`${alert.system}${alert.value}${alert.min}`)
}
}
}
return { activeAlerts, alertsKey }
}, [alerts])
// biome-ignore lint/correctness/useExhaustiveDependencies: alertsKey is inclusive
return useMemo(() => {
if (activeAlerts.length === 0) {
return null
}
return (
<Card className="mb-4">
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
<div className="px-2 sm:px-1">
<CardTitle>
<Trans>Active Alerts</Trans>
</CardTitle>
</div>
</CardHeader>
<CardContent className="max-sm:p-2">
{activeAlerts.length > 0 && (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
{activeAlerts.map((alert) => {
const info = alertInfo[alert.name as keyof typeof alertInfo]
return (
<Alert
key={alert.id}
className="hover:-translate-y-px duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black/5"
>
<info.icon className="h-4 w-4" />
<AlertTitle>
{systems[alert.system]?.name} {info.name().toLowerCase().replace("cpu", "CPU")}
</AlertTitle>
<AlertDescription>
{alert.name === "Status" ? (
<Trans>Connection is down</Trans>
) : (
<Trans>
Exceeds {alert.value}
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
</Trans>
)}
</AlertDescription>
<Link
href={getPagePath($router, "system", { name: systems[alert.system]?.name })}
className="absolute inset-0 w-full h-full"
aria-label="View system"
></Link>
</Alert>
)
})}
</div>
)}
</CardContent>
</Card>
)
}, [alertsKey.join("")])
}

View File

@@ -13,8 +13,8 @@ import {
XIcon, XIcon,
} from "lucide-react" } from "lucide-react"
import { subscribeKeys } from "nanostores" import { subscribeKeys } from "nanostores"
import React, { type JSX, memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import React, { type JSX, lazy, 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 ContainerChart from "@/components/charts/container-chart"
import DiskChart from "@/components/charts/disk-chart" import DiskChart from "@/components/charts/disk-chart"
import GpuPowerChart from "@/components/charts/gpu-power-chart" import GpuPowerChart from "@/components/charts/gpu-power-chart"
@@ -27,6 +27,7 @@ import { getPbTimestamp, pb } from "@/lib/api"
import { ChartType, ConnectionType, connectionTypeLabels, Os, SystemStatus, Unit } from "@/lib/enums" import { ChartType, ConnectionType, connectionTypeLabels, Os, SystemStatus, Unit } from "@/lib/enums"
import { batteryStateTranslations } from "@/lib/i18n" import { batteryStateTranslations } from "@/lib/i18n"
import { import {
$allSystemsById,
$allSystemsByName, $allSystemsByName,
$chartTime, $chartTime,
$containerFilter, $containerFilter,
@@ -40,6 +41,7 @@ import { useIntersectionObserver } from "@/lib/use-intersection-observer"
import { import {
chartTimeData, chartTimeData,
cn, cn,
compareSemVer,
debounce, debounce,
decimalString, decimalString,
formatBytes, formatBytes,
@@ -49,7 +51,16 @@ import {
toFixedFloat, toFixedFloat,
useBrowserStorage, useBrowserStorage,
} from "@/lib/utils" } 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 ChartTimeSelect from "../charts/chart-time-select"
import { $router, navigate } from "../router" import { $router, navigate } from "../router"
import Spinner from "../spinner" import Spinner from "../spinner"
@@ -63,6 +74,8 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/
import NetworkSheet from "./system/network-sheet" import NetworkSheet from "./system/network-sheet"
import LineChartDefault from "../charts/line-chart" import LineChartDefault from "../charts/line-chart"
type ChartTimeData = { type ChartTimeData = {
time: number time: number
data: { data: {
@@ -83,7 +96,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 startTime = chartTimeData[chartTime].getOffset(now)
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime()) const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
const data = { const data = {
@@ -95,25 +109,28 @@ function getTimeData(chartTime: ChartTimes, lastCreated: number) {
} }
// add empty values between records to make gaps if interval is too large // 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[], prevRecords: T[],
newRecords: T[], newRecords: T[],
expectedInterval: number expectedInterval: number
) { ): T[] {
const modifiedRecords: T[] = [] const modifiedRecords: T[] = []
let prevTime = (prevRecords.at(-1)?.created ?? 0) as number let prevTime = (prevRecords.at(-1)?.created ?? 0) as number
for (let i = 0; i < newRecords.length; i++) { for (let i = 0; i < newRecords.length; i++) {
const record = newRecords[i] const record = newRecords[i]
record.created = new Date(record.created).getTime() if (record.created !== null) {
if (prevTime) { record.created = new Date(record.created).getTime()
}
if (prevTime && record.created !== null) {
const interval = record.created - prevTime const interval = record.created - prevTime
// if interval is too large, add a null record // if interval is too large, add a null record
if (interval > expectedInterval / 2 + expectedInterval) { if (interval > expectedInterval / 2 + expectedInterval) {
// @ts-expect-error modifiedRecords.push({ created: null, ...("stats" in record ? { stats: null } : {}) } as T)
modifiedRecords.push({ created: null, stats: null })
} }
} }
prevTime = record.created if (record.created !== null) {
prevTime = record.created
}
modifiedRecords.push(record) modifiedRecords.push(record)
} }
return modifiedRecords return modifiedRecords
@@ -137,14 +154,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) { if (system.info.p) {
return str.replace("docker", "podman").replace("Docker", "Podman") return str.replace("docker", "podman").replace("Docker", "Podman")
} }
return str return str
} }
export default memo(function SystemDetail({ name }: { name: string }) { export default memo(function SystemDetail({ id }: { id: string }) {
const direction = useStore($direction) const direction = useStore($direction)
const { t } = useLingui() const { t } = useLingui()
const systems = useStore($systems) const systems = useStore($systems)
@@ -154,17 +171,15 @@ export default memo(function SystemDetail({ name }: { name: string }) {
const [system, setSystem] = useState({} as SystemRecord) const [system, setSystem] = useState({} as SystemRecord)
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[]) const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
const [containerData, setContainerData] = useState([] as ChartData["containerData"]) const [containerData, setContainerData] = useState([] as ChartData["containerData"])
const netCardRef = useRef<HTMLDivElement>(null) const temperatureChartRef = useRef<HTMLDivElement>(null)
const persistChartTime = useRef(false) const persistChartTime = useRef(false)
const [containerFilterBar, setContainerFilterBar] = useState(null as null | JSX.Element)
const [bottomSpacing, setBottomSpacing] = useState(0) const [bottomSpacing, setBottomSpacing] = useState(0)
const [chartLoading, setChartLoading] = useState(true) const [chartLoading, setChartLoading] = useState(true)
const isLongerChart = chartTime !== "1h" const isLongerChart = !["1m", "1h"].includes(chartTime) // true if chart time is not 1m or 1h
const userSettings = $userSettings.get() const userSettings = $userSettings.get()
const chartWrapRef = useRef<HTMLDivElement>(null) const chartWrapRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
document.title = `${name} / Beszel`
return () => { return () => {
if (!persistChartTime.current) { if (!persistChartTime.current) {
$chartTime.set($userSettings.get().chartTime) $chartTime.set($userSettings.get().chartTime)
@@ -172,18 +187,71 @@ export default memo(function SystemDetail({ name }: { name: string }) {
persistChartTime.current = false persistChartTime.current = false
setSystemStats([]) setSystemStats([])
setContainerData([]) setContainerData([])
setContainerFilterBar(null)
$containerFilter.set("") $containerFilter.set("")
} }
}, [name]) }, [id])
// find matching system and update when it changes // find matching system and update when it changes
useEffect(() => { useEffect(() => {
return subscribeKeys($allSystemsByName, [name], (newSystems) => { if (!systems.length) {
const sys = newSystems[name] return
sys?.id && setSystem(sys) }
// 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 // biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
const chartData: ChartData = useMemo(() => { const chartData: ChartData = useMemo(() => {
@@ -221,13 +289,13 @@ export default memo(function SystemDetail({ name }: { name: string }) {
} }
containerData.push(containerStats) containerData.push(containerStats)
} }
setContainerData(containerData) return containerData
}, []) }, [])
// get stats // get stats
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary // biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
useEffect(() => { useEffect(() => {
if (!system.id || !chartTime) { if (!system.id || !chartTime || chartTime === "1m") {
return return
} }
// loading: true // loading: true
@@ -261,12 +329,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
} }
cache.set(cs_cache_key, containerData) cache.set(cs_cache_key, containerData)
} }
if (containerData.length) { setContainerData(makeContainerData(containerData))
!containerFilterBar && setContainerFilterBar(<FilterBar />)
} else if (containerFilterBar) {
setContainerFilterBar(null)
}
makeContainerData(containerData)
}) })
}, [system, chartTime]) }, [system, chartTime])
@@ -335,19 +398,20 @@ export default memo(function SystemDetail({ name }: { name: string }) {
}[] }[]
}, [system, t]) }, [system, t])
/** Space for tooltip if more than 12 containers */ /** Space for tooltip if more than 10 sensors and no containers table */
useEffect(() => { useEffect(() => {
if (!netCardRef.current || !containerData.length) { const sensors = Object.keys(systemStats.at(-1)?.stats.t ?? {})
if (!temperatureChartRef.current || sensors.length < 10 || containerData.length > 0) {
setBottomSpacing(0) setBottomSpacing(0)
return return
} }
const tooltipHeight = (Object.keys(containerData[0]).length - 11) * 17.8 - 40 const tooltipHeight = (sensors.length - 10) * 17.8 - 40
const wrapperEl = chartWrapRef.current as HTMLDivElement const wrapperEl = chartWrapRef.current as HTMLDivElement
const wrapperRect = wrapperEl.getBoundingClientRect() const wrapperRect = wrapperEl.getBoundingClientRect()
const chartRect = netCardRef.current.getBoundingClientRect() const chartRect = temperatureChartRef.current.getBoundingClientRect()
const distanceToBottom = wrapperRect.bottom - chartRect.bottom const distanceToBottom = wrapperRect.bottom - chartRect.bottom
setBottomSpacing(tooltipHeight - distanceToBottom) setBottomSpacing(tooltipHeight - distanceToBottom)
}, [containerData]) }, [])
// keyboard navigation between systems // keyboard navigation between systems
useEffect(() => { useEffect(() => {
@@ -364,7 +428,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
) { ) {
return return
} }
const currentIndex = systems.findIndex((s) => s.name === name) const currentIndex = systems.findIndex((s) => s.id === id)
if (currentIndex === -1 || systems.length <= 1) { if (currentIndex === -1 || systems.length <= 1) {
return return
} }
@@ -373,18 +437,18 @@ export default memo(function SystemDetail({ name }: { name: string }) {
case "h": { case "h": {
const prevIndex = (currentIndex - 1 + systems.length) % systems.length const prevIndex = (currentIndex - 1 + systems.length) % systems.length
persistChartTime.current = true persistChartTime.current = true
return navigate(getPagePath($router, "system", { name: systems[prevIndex].name })) return navigate(getPagePath($router, "system", { id: systems[prevIndex].id }))
} }
case "ArrowRight": case "ArrowRight":
case "l": { case "l": {
const nextIndex = (currentIndex + 1) % systems.length const nextIndex = (currentIndex + 1) % systems.length
persistChartTime.current = true 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) return listen(document, "keyup", handleKeyUp)
}, [name, systems]) }, [id, systems])
if (!system.id) { if (!system.id) {
return null return null
@@ -392,9 +456,10 @@ export default memo(function SystemDetail({ name }: { name: string }) {
// select field for switching between avg and max values // select field for switching between avg and max values
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
const showMax = chartTime !== "1h" && maxValues const showMax = maxValues && isLongerChart
const containerFilterBar = containerData.length ? <FilterBar /> : null
// if no data, show empty message
const dataEmpty = !chartLoading && chartData.systemStats.length === 0 const dataEmpty = !chartLoading && chartData.systemStats.length === 0
const lastGpuVals = Object.values(systemStats.at(-1)?.stats.g ?? {}) const lastGpuVals = Object.values(systemStats.at(-1)?.stats.g ?? {})
const hasGpuData = lastGpuVals.length > 0 const hasGpuData = lastGpuVals.length > 0
@@ -483,7 +548,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
</div> </div>
</div> </div>
<div className="xl:ms-auto flex items-center gap-2 max-sm:-mb-1"> <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}> <TooltipProvider delayDuration={100}>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -594,23 +659,33 @@ export default memo(function SystemDetail({ name }: { name: string }) {
dataPoints={[ dataPoints={[
{ {
label: t({ message: "Write", comment: "Disk write" }), 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, color: 3,
opacity: 0.3, opacity: 0.3,
}, },
{ {
label: t({ message: "Read", comment: "Disk read" }), 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, color: 1,
opacity: 0.3, opacity: 0.3,
}, },
]} ]}
tickFormatter={(val) => { 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}` return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}} }}
contentFormatter={({ value }) => { 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}` return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
}} }}
/> />
@@ -670,26 +745,20 @@ export default memo(function SystemDetail({ name }: { name: string }) {
</ChartCard> </ChartCard>
{containerFilterBar && containerData.length > 0 && ( {containerFilterBar && containerData.length > 0 && (
<div <ChartCard
ref={netCardRef} empty={dataEmpty}
className={cn({ grid={grid}
"col-span-full": !grid, title={dockerOrPodman(t`Docker Network I/O`, system)}
})} description={dockerOrPodman(t`Network traffic of docker containers`, system)}
cornerEl={containerFilterBar}
> >
<ChartCard <ContainerChart
empty={dataEmpty} chartData={chartData}
title={dockerOrPodman(t`Docker Network I/O`, system)} chartType={ChartType.Network}
description={dockerOrPodman(t`Network traffic of docker containers`, system)} dataKey="n"
cornerEl={containerFilterBar} chartConfig={containerChartConfigs.network}
> />
<ContainerChart </ChartCard>
chartData={chartData}
chartType={ChartType.Network}
dataKey="n"
chartConfig={containerChartConfigs.network}
/>
</ChartCard>
</div>
)} )}
{/* Swap chart */} {/* Swap chart */}
@@ -719,16 +788,21 @@ export default memo(function SystemDetail({ name }: { name: string }) {
{/* Temperature chart */} {/* Temperature chart */}
{systemStats.at(-1)?.stats.t && ( {systemStats.at(-1)?.stats.t && (
<ChartCard <div
empty={dataEmpty} ref={temperatureChartRef}
grid={grid} className={cn("odd:last-of-type:col-span-full", { "col-span-full": !grid })}
title={t`Temperature`}
description={t`Temperatures of system sensors`}
cornerEl={<FilterBar store={$temperatureFilter} />}
legend={Object.keys(systemStats.at(-1)?.stats.t ?? {}).length < 12}
> >
<TemperatureChart chartData={chartData} /> <ChartCard
</ChartCard> empty={dataEmpty}
grid={grid}
title={t`Temperature`}
description={t`Temperatures of system sensors`}
cornerEl={<FilterBar store={$temperatureFilter} />}
legend={Object.keys(systemStats.at(-1)?.stats.t ?? {}).length < 12}
>
<TemperatureChart chartData={chartData} />
</ChartCard>
</div>
)} )}
{/* Battery chart */} {/* Battery chart */}
@@ -791,7 +865,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
return ( return (
<div key={id} className="contents"> <div key={id} className="contents">
<ChartCard <ChartCard
className="!col-span-1" className={cn(grid && "!col-span-1")}
empty={dataEmpty} empty={dataEmpty}
grid={grid} grid={grid}
title={`${gpu.n} ${t`Usage`}`} title={`${gpu.n} ${t`Usage`}`}
@@ -861,7 +935,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
> >
<DiskChart <DiskChart
chartData={chartData} chartData={chartData}
dataKey={`stats.efs.${extraFsName}.du`} dataKey={({ stats }: SystemStatsRecord) => stats?.efs?.[extraFsName]?.du}
diskSize={systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN} diskSize={systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN}
/> />
</ChartCard> </ChartCard>
@@ -877,24 +951,36 @@ export default memo(function SystemDetail({ name }: { name: string }) {
dataPoints={[ dataPoints={[
{ {
label: t`Write`, 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, color: 3,
opacity: 0.3, opacity: 0.3,
}, },
{ {
label: t`Read`, 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, color: 1,
opacity: 0.3, opacity: 0.3,
}, },
]} ]}
maxToggled={maxValues} maxToggled={maxValues}
tickFormatter={(val) => { tickFormatter={(val) => {
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, true) const { value, unit } = formatBytes(val, true, userSettings.unitDisk, false)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}` return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}} }}
contentFormatter={({ value }) => { 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}` return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
}} }}
/> />
@@ -904,6 +990,9 @@ export default memo(function SystemDetail({ name }: { name: string }) {
})} })}
</div> </div>
)} )}
{containerData.length > 0 && compareSemVer(chartData.agentVersion, parseSemVer("0.14.0")) >= 0 && (
<LazyContainersTable systemId={id} />
)}
</div> </div>
{/* add space for tooltip if more than 12 containers */} {/* add space for tooltip if more than 12 containers */}
@@ -913,7 +1002,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
}) })
function GpuEnginesChart({ chartData }: { chartData: ChartData }) { function GpuEnginesChart({ chartData }: { chartData: ChartData }) {
const dataPoints = [] const dataPoints: DataPoint[] = []
const engines = Object.keys(chartData.systemStats?.at(-1)?.stats.g?.[0]?.e ?? {}).sort() const engines = Object.keys(chartData.systemStats?.at(-1)?.stats.g?.[0]?.e ?? {}).sort()
for (const engine of engines) { for (const engine of engines) {
dataPoints.push({ dataPoints.push({
@@ -1033,3 +1122,14 @@ export function ChartCard({
</Card> </Card>
) )
} }
const ContainersTable = lazy(() => import("../containers-table/containers-table"))
function LazyContainersTable({ systemId }: { systemId: string }) {
const { isIntersecting, ref } = useIntersectionObserver()
return (
<div ref={ref}>
{isIntersecting && <ContainersTable systemId={systemId} />}
</div>
)
}

View File

@@ -53,7 +53,7 @@ export default memo(function NetworkSheet({
</SheetTrigger> </SheetTrigger>
{hasOpened.current && ( {hasOpened.current && (
<SheetContent aria-describedby={undefined} className="overflow-auto w-200 !max-w-full p-4 sm:p-6"> <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 <ChartCard
empty={dataEmpty} empty={dataEmpty}
grid={grid} grid={grid}

View File

@@ -77,6 +77,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
accessorKey: "name", accessorKey: "name",
id: "system", id: "system",
name: () => t`System`, name: () => t`System`,
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
filterFn: (() => { filterFn: (() => {
let filterInput = "" let filterInput = ""
let filterInputLower = "" let filterInputLower = ""
@@ -110,7 +111,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
invertSorting: false, invertSorting: false,
Icon: ServerIcon, Icon: ServerIcon,
cell: (info) => { cell: (info) => {
const { name } = info.row.original const { name, id } = info.row.original
const longestName = useStore($longestSystemNameLen) const longestName = useStore($longestSystemNameLen)
return ( return (
<> <>
@@ -122,7 +123,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
</span> </span>
</span> </span>
<Link <Link
href={getPagePath($router, "system", { name })} href={getPagePath($router, "system", { id })}
className="inset-0 absolute size-full" className="inset-0 absolute size-full"
aria-label={name} aria-label={name}
></Link> ></Link>
@@ -279,7 +280,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
} }
return ( return (
<Link <Link
href={getPagePath($router, "system", { name: system.name })} href={getPagePath($router, "system", { id: system.id })}
className={cn( className={cn(
"flex gap-1.5 items-center md:pe-5 tabular-nums relative z-10", "flex gap-1.5 items-center md:pe-5 tabular-nums relative z-10",
viewMode === "table" && "ps-0.5" 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] return [Object.values(upSystems).length, Object.values(downSystems).length, Object.values(pausedSystems).length]
}, [upSystems, downSystems, pausedSystems]) }, [upSystems, downSystems, pausedSystems])
// TODO: hiding temp then gpu messes up table headers
const CardHead = useMemo(() => { const CardHead = useMemo(() => {
return ( return (
<CardHeader className="pb-4.5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1"> <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> </div>
</CardContent> </CardContent>
<Link <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" className="inset-0 absolute w-full h-full"
> >
<span className="sr-only">{row.original.name}</span> <span className="sr-only">{row.original.name}</span>

View File

@@ -91,16 +91,16 @@ const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef< const ChartTooltipContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> & React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & { React.ComponentProps<"div"> & {
hideLabel?: boolean hideLabel?: boolean
indicator?: "line" | "dot" | "dashed" indicator?: "line" | "dot" | "dashed"
nameKey?: string nameKey?: string
labelKey?: string labelKey?: string
unit?: string unit?: string
filter?: string filter?: string
contentFormatter?: (item: any, key: string) => React.ReactNode | string contentFormatter?: (item: any, key: string) => React.ReactNode | string
truncate?: boolean truncate?: boolean
} }
>( >(
( (
{ {
@@ -129,7 +129,11 @@ const ChartTooltipContent = React.forwardRef<
React.useMemo(() => { React.useMemo(() => {
if (filter) { if (filter) {
payload = payload?.filter((item) => (item.name as string)?.toLowerCase().includes(filter.toLowerCase())) const filterTerms = filter.toLowerCase().split(" ").filter(term => term.length > 0)
payload = payload?.filter((item) => {
const itemName = (item.name as string)?.toLowerCase()
return filterTerms.some(term => itemName?.includes(term))
})
} }
if (itemSorter) { if (itemSorter) {
// @ts-expect-error // @ts-expect-error
@@ -250,10 +254,10 @@ const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef< const ChartLegendContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<"div"> & React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean hideIcon?: boolean
nameKey?: string nameKey?: string
} }
>(({ className, payload, verticalAlign = "bottom" }, ref) => { >(({ className, payload, verticalAlign = "bottom" }, ref) => {
// const { config } = useChart() // const { config } = useChart()

View File

@@ -82,6 +82,8 @@
--color-green-900: hsl(140 54% 12%); --color-green-900: hsl(140 54% 12%);
--color-green-950: hsl(140 57% 6%); --color-green-950: hsl(140 57% 6%);
--color-gh-dark: #22272e;
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
@@ -110,12 +112,14 @@
} }
@layer utilities { @layer utilities {
/* Fonts */ /* Fonts */
@supports (font-variation-settings: normal) { @supports (font-variation-settings: normal) {
:root { :root {
font-family: Inter, InterVariable, sans-serif; font-family: Inter, InterVariable, sans-serif;
} }
} }
@font-face { @font-face {
font-family: InterVariable; font-family: InterVariable;
font-style: normal; font-style: normal;
@@ -130,9 +134,11 @@
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
overflow-anchor: none; overflow-anchor: none;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
button { button {
cursor: pointer; cursor: pointer;
} }
@@ -149,16 +155,17 @@
@utility ns-dialog { @utility ns-dialog {
/* New system dialog width */ /* New system dialog width */
min-width: 30.3rem; min-width: 30.3rem;
:where(:lang(zh), :lang(zh-CN), :lang(ko)) & { :where(:lang(zh), :lang(zh-CN), :lang(ko)) & {
min-width: 27.9rem; min-width: 27.9rem;
} }
} }
.recharts-tooltip-wrapper { .recharts-tooltip-wrapper {
z-index: 1; z-index: 51;
@apply tabular-nums; @apply tabular-nums;
} }
.recharts-yAxis { .recharts-yAxis {
@apply tabular-nums; @apply tabular-nums;
} }

View File

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

View File

@@ -54,6 +54,16 @@ export enum HourFormat {
"24h" = "24h", "24h" = "24h",
} }
/** Container health status */
export enum ContainerHealth {
None,
Starting,
Healthy,
Unhealthy,
}
export const ContainerHealthLabels = ["None", "Starting", "Healthy", "Unhealthy"] as const
/** Connection type */ /** Connection type */
export enum ConnectionType { export enum ConnectionType {
SSH = 1, SSH = 1,

View File

@@ -0,0 +1,28 @@
// https://shiki.style/guide/bundles#fine-grained-bundle
// directly import the theme and language modules, only the ones you imported will be bundled.
import githubDarkDimmed from '@shikijs/themes/github-dark-dimmed'
// `shiki/core` entry does not include any themes or languages or the wasm binary.
import { createHighlighterCore } from 'shiki/core'
import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
export const highlighter = await createHighlighterCore({
themes: [
// instead of strings, you need to pass the imported module
githubDarkDimmed,
// or a dynamic import if you want to do chunk splitting
// import('@shikijs/themes/material-theme-ocean')
],
langs: [
import('@shikijs/langs/log'),
import('@shikijs/langs/json'),
// shiki will try to interop the module with the default export
// () => import('@shikijs/langs/css'),
],
// `shiki/wasm` contains the wasm binary inlined as base64 string.
engine: createOnigurumaEngine(import('shiki/wasm'))
})
// optionally, load themes and languages after creation
// await highlighter.loadTheme(import('@shikijs/themes/vitesse-light'))

View File

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

View File

@@ -1,19 +1,14 @@
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro"
import { type ClassValue, clsx } from "clsx" import { type ClassValue, clsx } from "clsx"
import { timeDay, timeHour } from "d3-time"
import { listenKeys } from "nanostores" import { listenKeys } from "nanostores"
import { timeDay, timeHour, timeMinute } from "d3-time"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
import { prependBasePath } from "@/components/router"
import { toast } from "@/components/ui/use-toast" import { toast } from "@/components/ui/use-toast"
import type { ChartTimeData, FingerprintRecord, SemVer, SystemRecord } from "@/types" import type { ChartTimeData, FingerprintRecord, SemVer, SystemRecord } from "@/types"
import { HourFormat, MeterState, Unit } from "./enums" import { HourFormat, MeterState, Unit } from "./enums"
import { $copyContent, $userSettings } from "./stores" 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[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
@@ -54,9 +49,18 @@ const createShortDateFormatter = (hour12?: boolean) =>
hour12, hour12,
}) })
const createHourWithSecondsFormatter = (hour12?: boolean) =>
new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "numeric",
second: "numeric",
hour12,
})
// Initialize formatters with default values // Initialize formatters with default values
let hourWithMinutesFormatter = createHourWithMinutesFormatter() let hourWithMinutesFormatter = createHourWithMinutesFormatter()
let shortDateFormatter = createShortDateFormatter() let shortDateFormatter = createShortDateFormatter()
let hourWithSecondsFormatter = createHourWithSecondsFormatter()
export const currentHour12 = () => shortDateFormatter.resolvedOptions().hour12 export const currentHour12 = () => shortDateFormatter.resolvedOptions().hour12
@@ -68,6 +72,10 @@ export const formatShortDate = (timestamp: string) => {
return shortDateFormatter.format(new Date(timestamp)) 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 // Update the time formatters if user changes hourFormat
listenKeys($userSettings, ["hourFormat"], ({ hourFormat }) => { listenKeys($userSettings, ["hourFormat"], ({ hourFormat }) => {
if (!hourFormat) return if (!hourFormat) return
@@ -75,6 +83,7 @@ listenKeys($userSettings, ["hourFormat"], ({ hourFormat }) => {
if (currentHour12() !== newHour12) { if (currentHour12() !== newHour12) {
hourWithMinutesFormatter = createHourWithMinutesFormatter(newHour12) hourWithMinutesFormatter = createHourWithMinutesFormatter(newHour12)
shortDateFormatter = createShortDateFormatter(newHour12) shortDateFormatter = createShortDateFormatter(newHour12)
hourWithSecondsFormatter = createHourWithSecondsFormatter(newHour12)
} }
}) })
@@ -86,11 +95,47 @@ export const formatDay = (timestamp: string) => {
return dayFormatter.format(new Date(timestamp)) return dayFormatter.format(new Date(timestamp))
} }
export const updateFavicon = (newIcon: string) => { export const updateFavicon = (() => {
;(document.querySelector("link[rel='icon']") as HTMLLinkElement).href = prependBasePath(`/static/${newIcon}`) 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 = { 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": { "1h": {
type: "1m", type: "1m",
expectedInterval: 60_000, expectedInterval: 60_000,
@@ -278,7 +323,7 @@ export const generateToken = () => {
} }
/** Get the hub URL from the global BESZEL object */ /** 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) */ /** Map of system IDs to their corresponding tokens (used to avoid fetching in add-system dialog) */
export const tokenMap = new Map<SystemRecord["id"], FingerprintRecord["token"]>() export const tokenMap = new Map<SystemRecord["id"], FingerprintRecord["token"]>()
@@ -333,6 +378,17 @@ export const parseSemVer = (semVer = ""): SemVer => {
return { major: parts?.[0] ?? 0, minor: parts?.[1] ?? 0, patch: parts?.[2] ?? 0 } 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. */ /** Get meter state from 0-100 value. Used for color coding meters. */
export function getMeterState(value: number): MeterState { export function getMeterState(value: number): MeterState {
const { colorWarn = 65, colorCrit = 90 } = $userSettings.get() const { colorWarn = 65, colorCrit = 90 } = $userSettings.get()

View File

@@ -48,6 +48,10 @@ msgstr "1 ساعة"
msgid "1 min" msgid "1 min"
msgstr "دقيقة واحدة" msgstr "دقيقة واحدة"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 دقيقة"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 week" msgid "1 week"
msgstr "1 أسبوع" msgstr "1 أسبوع"
@@ -85,7 +89,7 @@ msgstr "إجراءات"
msgid "Active" msgid "Active"
msgstr "نشط" msgstr "نشط"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Active Alerts" msgid "Active Alerts"
msgstr "التنبيهات النشطة" msgstr "التنبيهات النشطة"
@@ -129,7 +133,15 @@ msgstr "سجل التنبيهات"
msgid "Alerts" msgid "Alerts"
msgstr "التنبيهات" msgstr "التنبيهات"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "جميع الحاويات"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
#: src/components/command-palette.tsx
#: src/components/routes/home.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "All Systems" msgid "All Systems"
@@ -263,6 +275,10 @@ msgstr "تحقق من السجلات لمزيد من التفاصيل."
msgid "Check your notification service" msgid "Check your notification service"
msgstr "تحقق من خدمة الإشعارات الخاصة بك" msgstr "تحقق من خدمة الإشعارات الخاصة بك"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "انقر على حاوية لعرض مزيد من المعلومات."
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information." msgid "Click on a system to view more information."
msgstr "انقر على نظام لعرض مزيد من المعلومات." msgstr "انقر على نظام لعرض مزيد من المعلومات."
@@ -285,7 +301,7 @@ msgstr "هيئ التنبيهات الواردة"
msgid "Confirm password" msgid "Confirm password"
msgstr "تأكيد كلمة المرور" msgstr "تأكيد كلمة المرور"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Connection is down" msgid "Connection is down"
msgstr "الاتصال مقطوع" msgstr "الاتصال مقطوع"
@@ -344,6 +360,7 @@ msgstr "انسخ محتوى <0>docker-compose.yml</0> للوكيل أدناه،
msgid "Copy YAML" msgid "Copy YAML"
msgstr "نسخ YAML" msgstr "نسخ YAML"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "CPU" msgid "CPU"
msgstr "المعالج" msgstr "المعالج"
@@ -381,7 +398,6 @@ msgid "Current state"
msgstr "الحالة الحالية" msgstr "الحالة الحالية"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/home.tsx
msgid "Dashboard" msgid "Dashboard"
msgstr "لوحة التحكم" msgstr "لوحة التحكم"
@@ -398,6 +414,10 @@ msgstr "حذف"
msgid "Delete fingerprint" msgid "Delete fingerprint"
msgstr "حذف البصمة" msgstr "حذف البصمة"
#: src/components/containers-table/containers-table.tsx
msgid "Detail"
msgstr "التفاصيل"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Discharging" msgid "Discharging"
@@ -504,7 +524,7 @@ msgstr "خطأ"
#. placeholder {0}: alert.value #. placeholder {0}: alert.value
#. placeholder {1}: info.unit #. placeholder {1}: info.unit
#. placeholder {2}: alert.min #. placeholder {2}: alert.min
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}" msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "يتجاوز {0}{1} في آخر {2, plural, one {# دقيقة} other {# دقائق}}" msgstr "يتجاوز {0}{1} في آخر {2, plural, one {# دقيقة} other {# دقائق}}"
@@ -545,6 +565,7 @@ msgstr "فشل في إرسال إشعار الاختبار"
msgid "Failed to update alert" msgid "Failed to update alert"
msgstr "فشل في تحديث التنبيه" msgstr "فشل في تحديث التنبيه"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
@@ -592,6 +613,10 @@ msgstr "استهلاك طاقة وحدة معالجة الرسوميات"
msgid "Grid" msgid "Grid"
msgstr "شبكة" msgstr "شبكة"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Health"
msgstr "الصحة"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"
@@ -611,6 +636,11 @@ msgstr "خاملة"
msgid "If you've lost the password to your admin account, you may reset it using the following command." msgid "If you've lost the password to your admin account, you may reset it using the following command."
msgstr "إذا فقدت كلمة المرور لحساب المسؤول الخاص بك، يمكنك إعادة تعيينها باستخدام الأمر التالي." msgstr "إذا فقدت كلمة المرور لحساب المسؤول الخاص بك، يمكنك إعادة تعيينها باستخدام الأمر التالي."
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image"
msgstr "صورة"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
msgid "Invalid email address." msgid "Invalid email address."
msgstr "عنوان البريد الإشباكي غير صالح." msgstr "عنوان البريد الإشباكي غير صالح."
@@ -663,6 +693,7 @@ msgid "Login attempt failed"
msgstr "فشل محاولة تسجيل الدخول" msgstr "فشل محاولة تسجيل الدخول"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Logs" msgid "Logs"
msgstr "السجلات" msgstr "السجلات"
@@ -685,6 +716,7 @@ msgstr "تعليمات الإعداد اليدوي"
msgid "Max 1 min" msgid "Max 1 min"
msgstr "الحد الأقصى دقيقة" msgstr "الحد الأقصى دقيقة"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Memory" msgid "Memory"
msgstr "الذاكرة" msgstr "الذاكرة"
@@ -700,9 +732,11 @@ msgstr "استخدام الذاكرة لحاويات دوكر"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgid "Name" msgid "Name"
msgstr "الاسم" msgstr "الاسم"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Net" msgid "Net"
msgstr "الشبكة" msgstr "الشبكة"
@@ -727,6 +761,7 @@ msgstr "وحدة الشبكة"
msgid "No results found." msgid "No results found."
msgstr "لم يتم العثور على نتائج." msgstr "لم يتم العثور على نتائج."
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "No results." msgid "No results."
msgstr "لا توجد نتائج." msgstr "لا توجد نتائج."
@@ -768,6 +803,7 @@ msgstr "أو المتابعة باستخدام"
msgid "Overwrite existing alerts" msgid "Overwrite existing alerts"
msgstr "الكتابة فوق التنبيهات الحالية" msgstr "الكتابة فوق التنبيهات الحالية"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "Page" msgid "Page"
msgstr "صفحة" msgstr "صفحة"
@@ -872,6 +908,11 @@ msgstr "قراءة"
msgid "Received" msgid "Received"
msgstr "تم الاستلام" msgstr "تم الاستلام"
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
msgid "Refresh"
msgstr "تحديث"
#: src/components/login/login.tsx #: src/components/login/login.tsx
msgid "Request a one-time password" msgid "Request a one-time password"
msgstr "طلب كلمة مرور لمرة واحدة" msgstr "طلب كلمة مرور لمرة واحدة"
@@ -963,6 +1004,7 @@ msgstr "الترتيب حسب"
msgid "State" msgid "State"
msgstr "الحالة" msgstr "الحالة"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Status" msgid "Status"
@@ -977,6 +1019,7 @@ msgid "Swap Usage"
msgstr "استخدام التبديل" msgstr "استخدام التبديل"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
@@ -1150,6 +1193,10 @@ msgstr "قيد التشغيل"
msgid "Up ({upSystemsLength})" msgid "Up ({upSystemsLength})"
msgstr "قيد التشغيل ({upSystemsLength})" msgstr "قيد التشغيل ({upSystemsLength})"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Updated"
msgstr "تم التحديث"
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Upload" msgid "Upload"
msgstr "رفع" msgstr "رفع"

View File

@@ -48,6 +48,10 @@ msgstr "1 час"
msgid "1 min" msgid "1 min"
msgstr "1 минута" msgstr "1 минута"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 минута"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 week" msgid "1 week"
msgstr "1 седмица" msgstr "1 седмица"
@@ -85,7 +89,7 @@ msgstr "Действия"
msgid "Active" msgid "Active"
msgstr "Активен" msgstr "Активен"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Active Alerts" msgid "Active Alerts"
msgstr "Активни тревоги" msgstr "Активни тревоги"
@@ -129,7 +133,15 @@ msgstr "История на нотификациите"
msgid "Alerts" msgid "Alerts"
msgstr "Тревоги" msgstr "Тревоги"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "Всички контейнери"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
#: src/components/command-palette.tsx
#: src/components/routes/home.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "All Systems" msgid "All Systems"
@@ -263,6 +275,10 @@ msgstr "Провери log-овете за повече информация."
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Провери услугата си за удостоверяване" msgstr "Провери услугата си за удостоверяване"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "Кликнете върху контейнер, за да видите повече информация."
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information." msgid "Click on a system to view more information."
msgstr "Кликнете върху система, за да видите повече информация." msgstr "Кликнете върху система, за да видите повече информация."
@@ -285,7 +301,7 @@ msgstr "Настрой как получаваш нотификации за т
msgid "Confirm password" msgid "Confirm password"
msgstr "Потвърди парола" msgstr "Потвърди парола"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Connection is down" msgid "Connection is down"
msgstr "Връзката е прекъсната" msgstr "Връзката е прекъсната"
@@ -344,6 +360,7 @@ msgstr "Копирайте съдържанието на<0>docker-compose.yml</0
msgid "Copy YAML" msgid "Copy YAML"
msgstr "Копирай YAML" msgstr "Копирай YAML"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "CPU" msgid "CPU"
msgstr "Процесор" msgstr "Процесор"
@@ -381,7 +398,6 @@ msgid "Current state"
msgstr "Текущо състояние" msgstr "Текущо състояние"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/home.tsx
msgid "Dashboard" msgid "Dashboard"
msgstr "Табло" msgstr "Табло"
@@ -398,6 +414,10 @@ msgstr "Изтрий"
msgid "Delete fingerprint" msgid "Delete fingerprint"
msgstr "Изтрий пръстов отпечатък" msgstr "Изтрий пръстов отпечатък"
#: src/components/containers-table/containers-table.tsx
msgid "Detail"
msgstr "Подробности"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Discharging" msgid "Discharging"
@@ -504,7 +524,7 @@ msgstr "Грешка"
#. placeholder {0}: alert.value #. placeholder {0}: alert.value
#. placeholder {1}: info.unit #. placeholder {1}: info.unit
#. placeholder {2}: alert.min #. placeholder {2}: alert.min
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}" msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "Надвишава {0}{1} в последните {2, plural, one {# минута} other {# минути}}" msgstr "Надвишава {0}{1} в последните {2, plural, one {# минута} other {# минути}}"
@@ -545,6 +565,7 @@ msgstr "Неуспешно изпрати тестова нотификация"
msgid "Failed to update alert" msgid "Failed to update alert"
msgstr "Неуспешно обнови тревога" msgstr "Неуспешно обнови тревога"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
@@ -592,6 +613,10 @@ msgstr "Консумация на ток от графична карта"
msgid "Grid" msgid "Grid"
msgstr "Мрежово" msgstr "Мрежово"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Health"
msgstr "Здраве"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"
@@ -611,6 +636,11 @@ msgstr "Неактивна"
msgid "If you've lost the password to your admin account, you may reset it using the following command." msgid "If you've lost the password to your admin account, you may reset it using the following command."
msgstr "Ако си загубил паролата до администраторския акаунт, можеш да я нулираш със следващата команда." msgstr "Ако си загубил паролата до администраторския акаунт, можеш да я нулираш със следващата команда."
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image"
msgstr "Образ"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
msgid "Invalid email address." msgid "Invalid email address."
msgstr "Невалиден имейл адрес." msgstr "Невалиден имейл адрес."
@@ -663,6 +693,7 @@ msgid "Login attempt failed"
msgstr "Неуспешен опит за вход" msgstr "Неуспешен опит за вход"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Logs" msgid "Logs"
msgstr "Логове" msgstr "Логове"
@@ -685,6 +716,7 @@ msgstr "Инструкции за ръчна настройка"
msgid "Max 1 min" msgid "Max 1 min"
msgstr "Максимум 1 минута" msgstr "Максимум 1 минута"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Memory" msgid "Memory"
msgstr "Памет" msgstr "Памет"
@@ -700,9 +732,11 @@ msgstr "Използването на памет от docker контейнер
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgid "Name" msgid "Name"
msgstr "Име" msgstr "Име"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Net" msgid "Net"
msgstr "Мрежа" msgstr "Мрежа"
@@ -727,6 +761,7 @@ msgstr "Единица за измерване на скорост"
msgid "No results found." msgid "No results found."
msgstr "Няма намерени резултати." msgstr "Няма намерени резултати."
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "No results." msgid "No results."
msgstr "Няма резултати." msgstr "Няма резултати."
@@ -768,6 +803,7 @@ msgstr "Или продължи с"
msgid "Overwrite existing alerts" msgid "Overwrite existing alerts"
msgstr "Презапиши съществуващи тревоги" msgstr "Презапиши съществуващи тревоги"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "Page" msgid "Page"
msgstr "Страница" msgstr "Страница"
@@ -872,6 +908,11 @@ msgstr "Прочети"
msgid "Received" msgid "Received"
msgstr "Получени" msgstr "Получени"
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
msgid "Refresh"
msgstr "Опресни"
#: src/components/login/login.tsx #: src/components/login/login.tsx
msgid "Request a one-time password" msgid "Request a one-time password"
msgstr "Заявка за еднократна парола" msgstr "Заявка за еднократна парола"
@@ -963,6 +1004,7 @@ msgstr "Сортиране по"
msgid "State" msgid "State"
msgstr "Състояние" msgstr "Състояние"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Status" msgid "Status"
@@ -977,6 +1019,7 @@ msgid "Swap Usage"
msgstr "Използване на swap" msgstr "Използване на swap"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
@@ -1150,6 +1193,10 @@ msgstr "Нагоре"
msgid "Up ({upSystemsLength})" msgid "Up ({upSystemsLength})"
msgstr "Нагоре ({upSystemsLength})" msgstr "Нагоре ({upSystemsLength})"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Updated"
msgstr "Актуализирано"
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Upload" msgid "Upload"
msgstr "Качване" msgstr "Качване"

View File

@@ -48,6 +48,10 @@ msgstr "1 hodina"
msgid "1 min" msgid "1 min"
msgstr "1 min" msgstr "1 min"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 minuta"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 week" msgid "1 week"
msgstr "1 týden" msgstr "1 týden"
@@ -85,7 +89,7 @@ msgstr "Akce"
msgid "Active" msgid "Active"
msgstr "Aktivní" msgstr "Aktivní"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Active Alerts" msgid "Active Alerts"
msgstr "Aktivní výstrahy" msgstr "Aktivní výstrahy"
@@ -129,7 +133,15 @@ msgstr "Historie upozornění"
msgid "Alerts" msgid "Alerts"
msgstr "Výstrahy" msgstr "Výstrahy"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "Všechny kontejnery"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
#: src/components/command-palette.tsx
#: src/components/routes/home.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "All Systems" msgid "All Systems"
@@ -263,6 +275,10 @@ msgstr "Pro více informací zkontrolujte logy."
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Zkontrolujte službu upozornění" msgstr "Zkontrolujte službu upozornění"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "Klikněte na kontejner pro zobrazení dalších informací."
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information." msgid "Click on a system to view more information."
msgstr "Klikněte na systém pro zobrazení více informací." msgstr "Klikněte na systém pro zobrazení více informací."
@@ -285,7 +301,7 @@ msgstr "Konfigurace způsobu přijímání upozornění."
msgid "Confirm password" msgid "Confirm password"
msgstr "Potvrdit heslo" msgstr "Potvrdit heslo"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Connection is down" msgid "Connection is down"
msgstr "Připojení je nedostupné" msgstr "Připojení je nedostupné"
@@ -344,6 +360,7 @@ msgstr "Zkopírujte obsah <0>docker-compose.yml</0> pro agenta níže nebo autom
msgid "Copy YAML" msgid "Copy YAML"
msgstr "Kopírovat YAML" msgstr "Kopírovat YAML"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "CPU" msgid "CPU"
msgstr "Procesor" msgstr "Procesor"
@@ -381,7 +398,6 @@ msgid "Current state"
msgstr "Aktuální stav" msgstr "Aktuální stav"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/home.tsx
msgid "Dashboard" msgid "Dashboard"
msgstr "Přehled" msgstr "Přehled"
@@ -398,6 +414,10 @@ msgstr "Odstranit"
msgid "Delete fingerprint" msgid "Delete fingerprint"
msgstr "Smazat identifikátor" msgstr "Smazat identifikátor"
#: src/components/containers-table/containers-table.tsx
msgid "Detail"
msgstr "Detail"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Discharging" msgid "Discharging"
@@ -504,7 +524,7 @@ msgstr "Chyba"
#. placeholder {0}: alert.value #. placeholder {0}: alert.value
#. placeholder {1}: info.unit #. placeholder {1}: info.unit
#. placeholder {2}: alert.min #. placeholder {2}: alert.min
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}" msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "Překračuje {0}{1} za {2, plural, one {poslední # minutu} few {poslední # minuty} other {posledních # minut}}" msgstr "Překračuje {0}{1} za {2, plural, one {poslední # minutu} few {poslední # minuty} other {posledních # minut}}"
@@ -545,6 +565,7 @@ msgstr "Nepodařilo se odeslat testovací oznámení"
msgid "Failed to update alert" msgid "Failed to update alert"
msgstr "Nepodařilo se aktualizovat upozornění" msgstr "Nepodařilo se aktualizovat upozornění"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
@@ -592,6 +613,10 @@ msgstr "Spotřeba energie GPU"
msgid "Grid" msgid "Grid"
msgstr "Mřížka" msgstr "Mřížka"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Health"
msgstr "Zdraví"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"
@@ -611,6 +636,11 @@ msgstr "Neaktivní"
msgid "If you've lost the password to your admin account, you may reset it using the following command." msgid "If you've lost the password to your admin account, you may reset it using the following command."
msgstr "Pokud jste ztratili heslo k vašemu účtu správce, můžete jej obnovit pomocí následujícího příkazu." msgstr "Pokud jste ztratili heslo k vašemu účtu správce, můžete jej obnovit pomocí následujícího příkazu."
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image"
msgstr "Obraz"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
msgid "Invalid email address." msgid "Invalid email address."
msgstr "Neplatná e-mailová adresa." msgstr "Neplatná e-mailová adresa."
@@ -663,6 +693,7 @@ msgid "Login attempt failed"
msgstr "Pokus o přihlášení selhal" msgstr "Pokus o přihlášení selhal"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Logs" msgid "Logs"
msgstr "Logy" msgstr "Logy"
@@ -685,6 +716,7 @@ msgstr "Pokyny k manuálnímu nastavení"
msgid "Max 1 min" msgid "Max 1 min"
msgstr "Max. 1 min" msgstr "Max. 1 min"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Memory" msgid "Memory"
msgstr "Paměť" msgstr "Paměť"
@@ -700,9 +732,11 @@ msgstr "Využití paměti docker kontejnerů"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgid "Name" msgid "Name"
msgstr "Název" msgstr "Název"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Net" msgid "Net"
msgstr "Síť" msgstr "Síť"
@@ -727,6 +761,7 @@ msgstr "Síťová jednotka"
msgid "No results found." msgid "No results found."
msgstr "Nenalezeny žádné výskyty." msgstr "Nenalezeny žádné výskyty."
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "No results." msgid "No results."
msgstr "Žádné výsledky." msgstr "Žádné výsledky."
@@ -768,6 +803,7 @@ msgstr "Nebo pokračujte s"
msgid "Overwrite existing alerts" msgid "Overwrite existing alerts"
msgstr "Přepsat existující upozornění" msgstr "Přepsat existující upozornění"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "Page" msgid "Page"
msgstr "Stránka" msgstr "Stránka"
@@ -872,6 +908,11 @@ msgstr "Číst"
msgid "Received" msgid "Received"
msgstr "Přijato" msgstr "Přijato"
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
msgid "Refresh"
msgstr "Aktualizovat"
#: src/components/login/login.tsx #: src/components/login/login.tsx
msgid "Request a one-time password" msgid "Request a one-time password"
msgstr "Požádat o jednorázové heslo" msgstr "Požádat o jednorázové heslo"
@@ -963,6 +1004,7 @@ msgstr "Seřadit podle"
msgid "State" msgid "State"
msgstr "Stav" msgstr "Stav"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Status" msgid "Status"
@@ -977,6 +1019,7 @@ msgid "Swap Usage"
msgstr "Swap využití" msgstr "Swap využití"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
@@ -1150,6 +1193,10 @@ msgstr "Funkční"
msgid "Up ({upSystemsLength})" msgid "Up ({upSystemsLength})"
msgstr "Funkční ({upSystemsLength})" msgstr "Funkční ({upSystemsLength})"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Updated"
msgstr "Aktualizováno"
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Upload" msgid "Upload"
msgstr "Odeslání" msgstr "Odeslání"

View File

@@ -48,6 +48,10 @@ msgstr "1 time"
msgid "1 min" msgid "1 min"
msgstr "" msgstr ""
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 minut"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 week" msgid "1 week"
msgstr "1 uge" msgstr "1 uge"
@@ -85,7 +89,7 @@ msgstr "Handlinger"
msgid "Active" msgid "Active"
msgstr "" msgstr ""
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Active Alerts" msgid "Active Alerts"
msgstr "Aktive Alarmer" msgstr "Aktive Alarmer"
@@ -129,7 +133,15 @@ msgstr ""
msgid "Alerts" msgid "Alerts"
msgstr "Alarmer" msgstr "Alarmer"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "Alle containere"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
#: src/components/command-palette.tsx
#: src/components/routes/home.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "All Systems" msgid "All Systems"
@@ -263,6 +275,10 @@ msgstr "Tjek logfiler for flere detaljer."
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Tjek din notifikationstjeneste" msgstr "Tjek din notifikationstjeneste"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "Klik på en container for at se mere information."
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information." msgid "Click on a system to view more information."
msgstr "" msgstr ""
@@ -285,7 +301,7 @@ msgstr "Konfigurer hvordan du modtager advarselsmeddelelser."
msgid "Confirm password" msgid "Confirm password"
msgstr "Bekræft adgangskode" msgstr "Bekræft adgangskode"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Connection is down" msgid "Connection is down"
msgstr "" msgstr ""
@@ -344,6 +360,7 @@ msgstr ""
msgid "Copy YAML" msgid "Copy YAML"
msgstr "" msgstr ""
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "CPU" msgid "CPU"
msgstr "CPU" msgstr "CPU"
@@ -381,7 +398,6 @@ msgid "Current state"
msgstr "Nuværende tilstand" msgstr "Nuværende tilstand"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/home.tsx
msgid "Dashboard" msgid "Dashboard"
msgstr "Oversigtspanel" msgstr "Oversigtspanel"
@@ -398,6 +414,10 @@ msgstr "Slet"
msgid "Delete fingerprint" msgid "Delete fingerprint"
msgstr "" msgstr ""
#: src/components/containers-table/containers-table.tsx
msgid "Detail"
msgstr "Detalje"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Discharging" msgid "Discharging"
@@ -504,7 +524,7 @@ msgstr "Fejl"
#. placeholder {0}: alert.value #. placeholder {0}: alert.value
#. placeholder {1}: info.unit #. placeholder {1}: info.unit
#. placeholder {2}: alert.min #. placeholder {2}: alert.min
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}" msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "Overskrider {0}{1} i sidste {2, plural, one {# minut} other {# minutter}}" msgstr "Overskrider {0}{1} i sidste {2, plural, one {# minut} other {# minutter}}"
@@ -545,6 +565,7 @@ msgstr "Afsendelse af testnotifikation mislykkedes"
msgid "Failed to update alert" msgid "Failed to update alert"
msgstr "Kunne ikke opdatere alarm" msgstr "Kunne ikke opdatere alarm"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
@@ -592,6 +613,10 @@ msgstr "Gpu Strøm Træk"
msgid "Grid" msgid "Grid"
msgstr "Gitter" msgstr "Gitter"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Health"
msgstr "Sundhed"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"
@@ -611,6 +636,11 @@ msgstr "Inaktiv"
msgid "If you've lost the password to your admin account, you may reset it using the following command." msgid "If you've lost the password to your admin account, you may reset it using the following command."
msgstr "Hvis du har mistet adgangskoden til din administratorkonto, kan du nulstille den ved hjælp af følgende kommando." msgstr "Hvis du har mistet adgangskoden til din administratorkonto, kan du nulstille den ved hjælp af følgende kommando."
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image"
msgstr "Image"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
msgid "Invalid email address." msgid "Invalid email address."
msgstr "Ugyldig email adresse." msgstr "Ugyldig email adresse."
@@ -663,6 +693,7 @@ msgid "Login attempt failed"
msgstr "Loginforsøg mislykkedes" msgstr "Loginforsøg mislykkedes"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Logs" msgid "Logs"
msgstr "Logs" msgstr "Logs"
@@ -685,6 +716,7 @@ msgstr "Manuel opsætningsvejledning"
msgid "Max 1 min" msgid "Max 1 min"
msgstr "Maks. 1 min" msgstr "Maks. 1 min"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Memory" msgid "Memory"
msgstr "Hukommelse" msgstr "Hukommelse"
@@ -700,9 +732,11 @@ msgstr "Hukommelsesforbrug af dockercontainere"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgid "Name" msgid "Name"
msgstr "Navn" msgstr "Navn"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Net" msgid "Net"
msgstr "Net" msgstr "Net"
@@ -727,6 +761,7 @@ msgstr ""
msgid "No results found." msgid "No results found."
msgstr "Ingen resultater fundet." msgstr "Ingen resultater fundet."
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "No results." msgid "No results."
msgstr "" msgstr ""
@@ -768,6 +803,7 @@ msgstr "Eller fortsæt med"
msgid "Overwrite existing alerts" msgid "Overwrite existing alerts"
msgstr "Overskriv eksisterende alarmer" msgstr "Overskriv eksisterende alarmer"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "Page" msgid "Page"
msgstr "Side" msgstr "Side"
@@ -872,6 +908,11 @@ msgstr "Læs"
msgid "Received" msgid "Received"
msgstr "Modtaget" msgstr "Modtaget"
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
msgid "Refresh"
msgstr "Opdater"
#: src/components/login/login.tsx #: src/components/login/login.tsx
msgid "Request a one-time password" msgid "Request a one-time password"
msgstr "Anmod om engangsadgangskode" msgstr "Anmod om engangsadgangskode"
@@ -963,6 +1004,7 @@ msgstr "Sorter efter"
msgid "State" msgid "State"
msgstr "" msgstr ""
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Status" msgid "Status"
@@ -977,6 +1019,7 @@ msgid "Swap Usage"
msgstr "Swap forbrug" msgstr "Swap forbrug"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
@@ -1150,6 +1193,10 @@ msgstr "Oppe"
msgid "Up ({upSystemsLength})" msgid "Up ({upSystemsLength})"
msgstr "" msgstr ""
#: src/components/containers-table/containers-table-columns.tsx
msgid "Updated"
msgstr "Opdateret"
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Upload" msgid "Upload"
msgstr "Upload" msgstr "Upload"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: de\n" "Language: de\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-08-28 23:21\n" "PO-Revision-Date: 2025-10-05 16:13\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: German\n" "Language-Team: German\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -48,6 +48,10 @@ msgstr "1 Stunde"
msgid "1 min" msgid "1 min"
msgstr "1 Min" msgstr "1 Min"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 Minute"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 week" msgid "1 week"
msgstr "1 Woche" msgstr "1 Woche"
@@ -85,7 +89,7 @@ msgstr "Aktionen"
msgid "Active" msgid "Active"
msgstr "Aktiv" msgstr "Aktiv"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Active Alerts" msgid "Active Alerts"
msgstr "Aktive Warnungen" msgstr "Aktive Warnungen"
@@ -129,7 +133,15 @@ msgstr "Alarm-Verlauf"
msgid "Alerts" msgid "Alerts"
msgstr "Warnungen" msgstr "Warnungen"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "Alle Container"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
#: src/components/command-palette.tsx
#: src/components/routes/home.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "All Systems" msgid "All Systems"
@@ -263,6 +275,10 @@ msgstr "Überprüfe die Protokolle für weitere Details."
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Überprüfe deinen Benachrichtigungsdienst" msgstr "Überprüfe deinen Benachrichtigungsdienst"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "Klicken Sie auf einen Container, um weitere Informationen zu sehen."
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information." msgid "Click on a system to view more information."
msgstr "Klicke auf ein System, um weitere Informationen zu sehen." msgstr "Klicke auf ein System, um weitere Informationen zu sehen."
@@ -285,7 +301,7 @@ msgstr "Konfiguriere, wie du Warnbenachrichtigungen erhältst."
msgid "Confirm password" msgid "Confirm password"
msgstr "Passwort bestätigen" msgstr "Passwort bestätigen"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Connection is down" msgid "Connection is down"
msgstr "Verbindung unterbrochen" msgstr "Verbindung unterbrochen"
@@ -344,6 +360,7 @@ msgstr "Kopieren Sie den<0>docker-compose.yml</0> Inhalt für den Agent unten od
msgid "Copy YAML" msgid "Copy YAML"
msgstr "YAML kopieren" msgstr "YAML kopieren"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "CPU" msgid "CPU"
msgstr "CPU" msgstr "CPU"
@@ -381,7 +398,6 @@ msgid "Current state"
msgstr "Aktueller Zustand" msgstr "Aktueller Zustand"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/home.tsx
msgid "Dashboard" msgid "Dashboard"
msgstr "Dashboard" msgstr "Dashboard"
@@ -398,6 +414,10 @@ msgstr "Löschen"
msgid "Delete fingerprint" msgid "Delete fingerprint"
msgstr "Fingerabdruck löschen" msgstr "Fingerabdruck löschen"
#: src/components/containers-table/containers-table.tsx
msgid "Detail"
msgstr "Detail"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Discharging" msgid "Discharging"
@@ -455,7 +475,7 @@ msgstr "Offline ({downSystemsLength})"
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Download" msgid "Download"
msgstr "Download" msgstr "Herunterladen"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
msgid "Duration" msgid "Duration"
@@ -504,7 +524,7 @@ msgstr "Fehler"
#. placeholder {0}: alert.value #. placeholder {0}: alert.value
#. placeholder {1}: info.unit #. placeholder {1}: info.unit
#. placeholder {2}: alert.min #. placeholder {2}: alert.min
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}" msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "Überschreitet {0}{1} in den letzten {2, plural, one {# Minute} other {# Minuten}}" msgstr "Überschreitet {0}{1} in den letzten {2, plural, one {# Minute} other {# Minuten}}"
@@ -545,6 +565,7 @@ msgstr "Testbenachrichtigung konnte nicht gesendet werden"
msgid "Failed to update alert" msgid "Failed to update alert"
msgstr "Warnung konnte nicht aktualisiert werden" msgstr "Warnung konnte nicht aktualisiert werden"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
@@ -592,6 +613,10 @@ msgstr "GPU-Leistungsaufnahme"
msgid "Grid" msgid "Grid"
msgstr "Raster" msgstr "Raster"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Health"
msgstr "Gesundheit"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"
@@ -611,6 +636,11 @@ msgstr "Untätig"
msgid "If you've lost the password to your admin account, you may reset it using the following command." msgid "If you've lost the password to your admin account, you may reset it using the following command."
msgstr "Wenn du das Passwort für dein Administratorkonto verloren hast, kannst du es mit dem folgenden Befehl zurücksetzen." msgstr "Wenn du das Passwort für dein Administratorkonto verloren hast, kannst du es mit dem folgenden Befehl zurücksetzen."
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image"
msgstr "Image"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
msgid "Invalid email address." msgid "Invalid email address."
msgstr "Ungültige E-Mail-Adresse." msgstr "Ungültige E-Mail-Adresse."
@@ -663,6 +693,7 @@ msgid "Login attempt failed"
msgstr "Anmeldeversuch fehlgeschlagen" msgstr "Anmeldeversuch fehlgeschlagen"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Logs" msgid "Logs"
msgstr "Protokolle" msgstr "Protokolle"
@@ -685,6 +716,7 @@ msgstr "Anleitung zur manuellen Einrichtung"
msgid "Max 1 min" msgid "Max 1 min"
msgstr "Max 1 Min" msgstr "Max 1 Min"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Memory" msgid "Memory"
msgstr "Arbeitsspeicher" msgstr "Arbeitsspeicher"
@@ -700,9 +732,11 @@ msgstr "Arbeitsspeichernutzung der Docker-Container"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgid "Name" msgid "Name"
msgstr "Name" msgstr "Name"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Net" msgid "Net"
msgstr "Netz" msgstr "Netz"
@@ -727,6 +761,7 @@ msgstr "Netzwerkeinheit"
msgid "No results found." msgid "No results found."
msgstr "Keine Ergebnisse gefunden." msgstr "Keine Ergebnisse gefunden."
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "No results." msgid "No results."
msgstr "Keine Ergebnisse." msgstr "Keine Ergebnisse."
@@ -768,6 +803,7 @@ msgstr "Oder fortfahren mit"
msgid "Overwrite existing alerts" msgid "Overwrite existing alerts"
msgstr "Bestehende Warnungen überschreiben" msgstr "Bestehende Warnungen überschreiben"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "Page" msgid "Page"
msgstr "Seite" msgstr "Seite"
@@ -872,6 +908,11 @@ msgstr "Lesen"
msgid "Received" msgid "Received"
msgstr "Empfangen" msgstr "Empfangen"
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
msgid "Refresh"
msgstr "Aktualisieren"
#: src/components/login/login.tsx #: src/components/login/login.tsx
msgid "Request a one-time password" msgid "Request a one-time password"
msgstr "Einmalpasswort anfordern" msgstr "Einmalpasswort anfordern"
@@ -963,6 +1004,7 @@ msgstr "Sortieren nach"
msgid "State" msgid "State"
msgstr "Status" msgstr "Status"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Status" msgid "Status"
@@ -977,6 +1019,7 @@ msgid "Swap Usage"
msgstr "Swap-Nutzung" msgstr "Swap-Nutzung"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
@@ -1150,9 +1193,13 @@ msgstr "aktiv"
msgid "Up ({upSystemsLength})" msgid "Up ({upSystemsLength})"
msgstr "aktiv ({upSystemsLength})" msgstr "aktiv ({upSystemsLength})"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Updated"
msgstr "Aktualisiert"
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Upload" msgid "Upload"
msgstr "Upload" msgstr "Hochladen"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Uptime" msgid "Uptime"

View File

@@ -43,6 +43,10 @@ msgstr "1 hour"
msgid "1 min" msgid "1 min"
msgstr "1 min" msgstr "1 min"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 minute"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 week" msgid "1 week"
msgstr "1 week" msgstr "1 week"
@@ -80,7 +84,7 @@ msgstr "Actions"
msgid "Active" msgid "Active"
msgstr "Active" msgstr "Active"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Active Alerts" msgid "Active Alerts"
msgstr "Active Alerts" msgstr "Active Alerts"
@@ -124,7 +128,15 @@ msgstr "Alert History"
msgid "Alerts" msgid "Alerts"
msgstr "Alerts" msgstr "Alerts"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "All Containers"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
#: src/components/command-palette.tsx
#: src/components/routes/home.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "All Systems" msgid "All Systems"
@@ -258,6 +270,10 @@ msgstr "Check logs for more details."
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Check your notification service" msgstr "Check your notification service"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "Click on a container to view more information."
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information." msgid "Click on a system to view more information."
msgstr "Click on a system to view more information." msgstr "Click on a system to view more information."
@@ -280,7 +296,7 @@ msgstr "Configure how you receive alert notifications."
msgid "Confirm password" msgid "Confirm password"
msgstr "Confirm password" msgstr "Confirm password"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Connection is down" msgid "Connection is down"
msgstr "Connection is down" msgstr "Connection is down"
@@ -339,6 +355,7 @@ msgstr "Copy the<0>docker-compose.yml</0> content for the agent below, or regist
msgid "Copy YAML" msgid "Copy YAML"
msgstr "Copy YAML" msgstr "Copy YAML"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "CPU" msgid "CPU"
msgstr "CPU" msgstr "CPU"
@@ -376,7 +393,6 @@ msgid "Current state"
msgstr "Current state" msgstr "Current state"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/home.tsx
msgid "Dashboard" msgid "Dashboard"
msgstr "Dashboard" msgstr "Dashboard"
@@ -393,6 +409,10 @@ msgstr "Delete"
msgid "Delete fingerprint" msgid "Delete fingerprint"
msgstr "Delete fingerprint" msgstr "Delete fingerprint"
#: src/components/containers-table/containers-table.tsx
msgid "Detail"
msgstr "Detail"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Discharging" msgid "Discharging"
@@ -499,7 +519,7 @@ msgstr "Error"
#. placeholder {0}: alert.value #. placeholder {0}: alert.value
#. placeholder {1}: info.unit #. placeholder {1}: info.unit
#. placeholder {2}: alert.min #. placeholder {2}: alert.min
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}" msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}" msgstr "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
@@ -540,6 +560,7 @@ msgstr "Failed to send test notification"
msgid "Failed to update alert" msgid "Failed to update alert"
msgstr "Failed to update alert" msgstr "Failed to update alert"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
@@ -587,6 +608,10 @@ msgstr "GPU Power Draw"
msgid "Grid" msgid "Grid"
msgstr "Grid" msgstr "Grid"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Health"
msgstr "Health"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"
@@ -606,6 +631,11 @@ msgstr "Idle"
msgid "If you've lost the password to your admin account, you may reset it using the following command." msgid "If you've lost the password to your admin account, you may reset it using the following command."
msgstr "If you've lost the password to your admin account, you may reset it using the following command." msgstr "If you've lost the password to your admin account, you may reset it using the following command."
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image"
msgstr "Image"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
msgid "Invalid email address." msgid "Invalid email address."
msgstr "Invalid email address." msgstr "Invalid email address."
@@ -658,6 +688,7 @@ msgid "Login attempt failed"
msgstr "Login attempt failed" msgstr "Login attempt failed"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Logs" msgid "Logs"
msgstr "Logs" msgstr "Logs"
@@ -680,6 +711,7 @@ msgstr "Manual setup instructions"
msgid "Max 1 min" msgid "Max 1 min"
msgstr "Max 1 min" msgstr "Max 1 min"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Memory" msgid "Memory"
msgstr "Memory" msgstr "Memory"
@@ -695,9 +727,11 @@ msgstr "Memory usage of docker containers"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgid "Name" msgid "Name"
msgstr "Name" msgstr "Name"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Net" msgid "Net"
msgstr "Net" msgstr "Net"
@@ -722,6 +756,7 @@ msgstr "Network unit"
msgid "No results found." msgid "No results found."
msgstr "No results found." msgstr "No results found."
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "No results." msgid "No results."
msgstr "No results." msgstr "No results."
@@ -763,6 +798,7 @@ msgstr "Or continue with"
msgid "Overwrite existing alerts" msgid "Overwrite existing alerts"
msgstr "Overwrite existing alerts" msgstr "Overwrite existing alerts"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "Page" msgid "Page"
msgstr "Page" msgstr "Page"
@@ -867,6 +903,11 @@ msgstr "Read"
msgid "Received" msgid "Received"
msgstr "Received" msgstr "Received"
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
msgid "Refresh"
msgstr "Refresh"
#: src/components/login/login.tsx #: src/components/login/login.tsx
msgid "Request a one-time password" msgid "Request a one-time password"
msgstr "Request a one-time password" msgstr "Request a one-time password"
@@ -958,6 +999,7 @@ msgstr "Sort By"
msgid "State" msgid "State"
msgstr "State" msgstr "State"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Status" msgid "Status"
@@ -972,6 +1014,7 @@ msgid "Swap Usage"
msgstr "Swap Usage" msgstr "Swap Usage"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
@@ -1145,6 +1188,10 @@ msgstr "Up"
msgid "Up ({upSystemsLength})" msgid "Up ({upSystemsLength})"
msgstr "Up ({upSystemsLength})" msgstr "Up ({upSystemsLength})"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Updated"
msgstr "Updated"
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Upload" msgid "Upload"
msgstr "Upload" msgstr "Upload"

View File

@@ -48,6 +48,10 @@ msgstr "1 hora"
msgid "1 min" msgid "1 min"
msgstr "1 min" msgstr "1 min"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 minuto"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 week" msgid "1 week"
msgstr "1 semana" msgstr "1 semana"
@@ -85,7 +89,7 @@ msgstr "Acciones"
msgid "Active" msgid "Active"
msgstr "Activo" msgstr "Activo"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Active Alerts" msgid "Active Alerts"
msgstr "Alertas Activas" msgstr "Alertas Activas"
@@ -129,7 +133,15 @@ msgstr "Historial de Alertas"
msgid "Alerts" msgid "Alerts"
msgstr "Alertas" msgstr "Alertas"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "Todos los contenedores"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
#: src/components/command-palette.tsx
#: src/components/routes/home.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "All Systems" msgid "All Systems"
@@ -263,6 +275,10 @@ msgstr "Revise los registros para más detalles."
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Verifique su servicio de notificaciones" msgstr "Verifique su servicio de notificaciones"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "Haga clic en un contenedor para ver más información."
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information." msgid "Click on a system to view more information."
msgstr "Haga clic en un sistema para ver más información." msgstr "Haga clic en un sistema para ver más información."
@@ -285,7 +301,7 @@ msgstr "Configure cómo recibe las notificaciones de alertas."
msgid "Confirm password" msgid "Confirm password"
msgstr "Confirmar contraseña" msgstr "Confirmar contraseña"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Connection is down" msgid "Connection is down"
msgstr "La conexión está caída" msgstr "La conexión está caída"
@@ -344,6 +360,7 @@ msgstr "Copia el contenido del<0>docker-compose.yml</0> para el agente a continu
msgid "Copy YAML" msgid "Copy YAML"
msgstr "Copiar YAML" msgstr "Copiar YAML"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "CPU" msgid "CPU"
msgstr "CPU" msgstr "CPU"
@@ -381,7 +398,6 @@ msgid "Current state"
msgstr "Estado actual" msgstr "Estado actual"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/home.tsx
msgid "Dashboard" msgid "Dashboard"
msgstr "Tablero" msgstr "Tablero"
@@ -398,6 +414,10 @@ msgstr "Eliminar"
msgid "Delete fingerprint" msgid "Delete fingerprint"
msgstr "Eliminar huella digital" msgstr "Eliminar huella digital"
#: src/components/containers-table/containers-table.tsx
msgid "Detail"
msgstr "Detalle"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Discharging" msgid "Discharging"
@@ -504,7 +524,7 @@ msgstr "Error"
#. placeholder {0}: alert.value #. placeholder {0}: alert.value
#. placeholder {1}: info.unit #. placeholder {1}: info.unit
#. placeholder {2}: alert.min #. placeholder {2}: alert.min
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}" msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "Excede {0}{1} en el último {2, plural, one {# minuto} other {# minutos}}" msgstr "Excede {0}{1} en el último {2, plural, one {# minuto} other {# minutos}}"
@@ -545,6 +565,7 @@ msgstr "Error al enviar la notificación de prueba"
msgid "Failed to update alert" msgid "Failed to update alert"
msgstr "Error al actualizar la alerta" msgstr "Error al actualizar la alerta"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
@@ -592,6 +613,10 @@ msgstr "Consumo de energía de la GPU"
msgid "Grid" msgid "Grid"
msgstr "Cuadrícula" msgstr "Cuadrícula"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Health"
msgstr "Estado"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"
@@ -611,6 +636,11 @@ msgstr "Inactiva"
msgid "If you've lost the password to your admin account, you may reset it using the following command." msgid "If you've lost the password to your admin account, you may reset it using the following command."
msgstr "Si ha perdido la contraseña de su cuenta de administrador, puede restablecerla usando el siguiente comando." msgstr "Si ha perdido la contraseña de su cuenta de administrador, puede restablecerla usando el siguiente comando."
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image"
msgstr "Imagen"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
msgid "Invalid email address." msgid "Invalid email address."
msgstr "Dirección de correo electrónico no válida." msgstr "Dirección de correo electrónico no válida."
@@ -663,6 +693,7 @@ msgid "Login attempt failed"
msgstr "Intento de inicio de sesión fallido" msgstr "Intento de inicio de sesión fallido"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Logs" msgid "Logs"
msgstr "Registros" msgstr "Registros"
@@ -685,6 +716,7 @@ msgstr "Instrucciones manuales de configuración"
msgid "Max 1 min" msgid "Max 1 min"
msgstr "Máx 1 min" msgstr "Máx 1 min"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Memory" msgid "Memory"
msgstr "Memoria" msgstr "Memoria"
@@ -700,9 +732,11 @@ msgstr "Uso de memoria de los contenedores de Docker"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgid "Name" msgid "Name"
msgstr "Nombre" msgstr "Nombre"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Net" msgid "Net"
msgstr "Red" msgstr "Red"
@@ -727,6 +761,7 @@ msgstr "Unidad de red"
msgid "No results found." msgid "No results found."
msgstr "No se encontraron resultados." msgstr "No se encontraron resultados."
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "No results." msgid "No results."
msgstr "Sin resultados." msgstr "Sin resultados."
@@ -768,6 +803,7 @@ msgstr "O continuar con"
msgid "Overwrite existing alerts" msgid "Overwrite existing alerts"
msgstr "Sobrescribir alertas existentes" msgstr "Sobrescribir alertas existentes"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "Page" msgid "Page"
msgstr "Página" msgstr "Página"
@@ -872,6 +908,11 @@ msgstr "Lectura"
msgid "Received" msgid "Received"
msgstr "Recibido" msgstr "Recibido"
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
msgid "Refresh"
msgstr "Actualizar"
#: src/components/login/login.tsx #: src/components/login/login.tsx
msgid "Request a one-time password" msgid "Request a one-time password"
msgstr "Solicitar contraseña de un solo uso" msgstr "Solicitar contraseña de un solo uso"
@@ -963,6 +1004,7 @@ msgstr "Ordenar por"
msgid "State" msgid "State"
msgstr "Estado" msgstr "Estado"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Status" msgid "Status"
@@ -977,6 +1019,7 @@ msgid "Swap Usage"
msgstr "Uso de Swap" msgstr "Uso de Swap"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
@@ -1150,6 +1193,10 @@ msgstr "Activo"
msgid "Up ({upSystemsLength})" msgid "Up ({upSystemsLength})"
msgstr "Activo ({upSystemsLength})" msgstr "Activo ({upSystemsLength})"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Updated"
msgstr "Actualizado"
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Upload" msgid "Upload"
msgstr "Cargar" msgstr "Cargar"

View File

@@ -48,6 +48,10 @@ msgstr "۱ ساعت"
msgid "1 min" msgid "1 min"
msgstr "۱ دقیقه" msgstr "۱ دقیقه"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 دقیقه"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 week" msgid "1 week"
msgstr "۱ هفته" msgstr "۱ هفته"
@@ -85,7 +89,7 @@ msgstr "عملیات"
msgid "Active" msgid "Active"
msgstr "فعال" msgstr "فعال"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Active Alerts" msgid "Active Alerts"
msgstr " هشدارهای فعال" msgstr " هشدارهای فعال"
@@ -129,7 +133,15 @@ msgstr "تاریخچه هشدارها"
msgid "Alerts" msgid "Alerts"
msgstr "هشدارها" msgstr "هشدارها"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "همه کانتینرها"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
#: src/components/command-palette.tsx
#: src/components/routes/home.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "All Systems" msgid "All Systems"
@@ -263,6 +275,10 @@ msgstr "برای جزئیات بیشتر، لاگ‌ها را بررسی کنی
msgid "Check your notification service" msgid "Check your notification service"
msgstr "سرویس اطلاع‌رسانی خود را بررسی کنید" msgstr "سرویس اطلاع‌رسانی خود را بررسی کنید"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "برای مشاهده اطلاعات بیشتر روی کانتینر کلیک کنید."
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information." msgid "Click on a system to view more information."
msgstr "برای مشاهده اطلاعات بیشتر روی یک سیستم کلیک کنید." msgstr "برای مشاهده اطلاعات بیشتر روی یک سیستم کلیک کنید."
@@ -285,7 +301,7 @@ msgstr "نحوه دریافت هشدارهای اطلاع‌رسانی را پی
msgid "Confirm password" msgid "Confirm password"
msgstr "تأیید رمز عبور" msgstr "تأیید رمز عبور"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Connection is down" msgid "Connection is down"
msgstr "اتصال قطع است" msgstr "اتصال قطع است"
@@ -344,6 +360,7 @@ msgstr "محتوای <0>docker-compose.yml</0> عامل زیر را کپی کن
msgid "Copy YAML" msgid "Copy YAML"
msgstr "کپی YAML" msgstr "کپی YAML"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "CPU" msgid "CPU"
msgstr "پردازنده" msgstr "پردازنده"
@@ -381,7 +398,6 @@ msgid "Current state"
msgstr "وضعیت فعلی" msgstr "وضعیت فعلی"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/home.tsx
msgid "Dashboard" msgid "Dashboard"
msgstr "داشبورد" msgstr "داشبورد"
@@ -398,6 +414,10 @@ msgstr "حذف"
msgid "Delete fingerprint" msgid "Delete fingerprint"
msgstr "حذف اثر انگشت" msgstr "حذف اثر انگشت"
#: src/components/containers-table/containers-table.tsx
msgid "Detail"
msgstr "جزئیات"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Discharging" msgid "Discharging"
@@ -504,7 +524,7 @@ msgstr "خطا"
#. placeholder {0}: alert.value #. placeholder {0}: alert.value
#. placeholder {1}: info.unit #. placeholder {1}: info.unit
#. placeholder {2}: alert.min #. placeholder {2}: alert.min
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}" msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "در {2, plural, one {# دقیقه} other {# دقیقه}} گذشته از {0}{1} بیشتر است" msgstr "در {2, plural, one {# دقیقه} other {# دقیقه}} گذشته از {0}{1} بیشتر است"
@@ -545,6 +565,7 @@ msgstr "ارسال اعلان آزمایشی ناموفق بود"
msgid "Failed to update alert" msgid "Failed to update alert"
msgstr "به‌روزرسانی هشدار ناموفق بود" msgstr "به‌روزرسانی هشدار ناموفق بود"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
@@ -592,6 +613,10 @@ msgstr "مصرف برق پردازنده گرافیکی"
msgid "Grid" msgid "Grid"
msgstr "جدول" msgstr "جدول"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Health"
msgstr "سلامتی"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"
@@ -611,6 +636,11 @@ msgstr "بیکار"
msgid "If you've lost the password to your admin account, you may reset it using the following command." msgid "If you've lost the password to your admin account, you may reset it using the following command."
msgstr "اگر رمز عبور حساب مدیر خود را گم کرده‌اید، می‌توانید آن را با استفاده از دستور زیر بازنشانی کنید." msgstr "اگر رمز عبور حساب مدیر خود را گم کرده‌اید، می‌توانید آن را با استفاده از دستور زیر بازنشانی کنید."
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image"
msgstr "تصویر"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
msgid "Invalid email address." msgid "Invalid email address."
msgstr "آدرس ایمیل نامعتبر است." msgstr "آدرس ایمیل نامعتبر است."
@@ -663,6 +693,7 @@ msgid "Login attempt failed"
msgstr "تلاش برای ورود ناموفق بود" msgstr "تلاش برای ورود ناموفق بود"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Logs" msgid "Logs"
msgstr "لاگ‌ها" msgstr "لاگ‌ها"
@@ -685,6 +716,7 @@ msgstr "دستورالعمل‌های راه‌اندازی دستی"
msgid "Max 1 min" msgid "Max 1 min"
msgstr "حداکثر ۱ دقیقه" msgstr "حداکثر ۱ دقیقه"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Memory" msgid "Memory"
msgstr "حافظه" msgstr "حافظه"
@@ -700,9 +732,11 @@ msgstr "میزان استفاده از حافظه کانتینرهای داکر"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgid "Name" msgid "Name"
msgstr "نام" msgstr "نام"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Net" msgid "Net"
msgstr "شبکه" msgstr "شبکه"
@@ -727,6 +761,7 @@ msgstr "واحد شبکه"
msgid "No results found." msgid "No results found."
msgstr "هیچ نتیجه‌ای یافت نشد." msgstr "هیچ نتیجه‌ای یافت نشد."
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "No results." msgid "No results."
msgstr "نتیجه‌ای یافت نشد." msgstr "نتیجه‌ای یافت نشد."
@@ -768,6 +803,7 @@ msgstr "یا ادامه با"
msgid "Overwrite existing alerts" msgid "Overwrite existing alerts"
msgstr "بازنویسی هشدارهای موجود" msgstr "بازنویسی هشدارهای موجود"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "Page" msgid "Page"
msgstr "صفحه" msgstr "صفحه"
@@ -872,6 +908,11 @@ msgstr "خواندن"
msgid "Received" msgid "Received"
msgstr "دریافت شد" msgstr "دریافت شد"
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
msgid "Refresh"
msgstr "تازه‌سازی"
#: src/components/login/login.tsx #: src/components/login/login.tsx
msgid "Request a one-time password" msgid "Request a one-time password"
msgstr "درخواست رمز عبور یک‌بار مصرف" msgstr "درخواست رمز عبور یک‌بار مصرف"
@@ -963,6 +1004,7 @@ msgstr "مرتب‌سازی بر اساس"
msgid "State" msgid "State"
msgstr "وضعیت" msgstr "وضعیت"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Status" msgid "Status"
@@ -977,6 +1019,7 @@ msgid "Swap Usage"
msgstr "میزان استفاده از Swap" msgstr "میزان استفاده از Swap"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
@@ -1150,6 +1193,10 @@ msgstr "فعال"
msgid "Up ({upSystemsLength})" msgid "Up ({upSystemsLength})"
msgstr "فعال ({upSystemsLength})" msgstr "فعال ({upSystemsLength})"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Updated"
msgstr "به‌روزرسانی شد"
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Upload" msgid "Upload"
msgstr "آپلود" msgstr "آپلود"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: fr\n" "Language: fr\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-08-28 23:21\n" "PO-Revision-Date: 2025-10-20 16:38\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: French\n" "Language-Team: French\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n"
@@ -31,13 +31,13 @@ msgstr "{0, plural, one {# heure} other {# heures}}"
#. placeholder {0}: Math.trunc(system.info.u / 60) #. placeholder {0}: Math.trunc(system.info.u / 60)
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "{0, plural, one {# minute} few {# minutes} many {# minutes} other {# minutes}}" msgid "{0, plural, one {# minute} few {# minutes} many {# minutes} other {# minutes}}"
msgstr "" msgstr "{0, plural, one {# minute} few {# minutes} many {# minutes} other {# minutes}}"
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length #. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
#. placeholder {1}: table.getFilteredRowModel().rows.length #. placeholder {1}: table.getFilteredRowModel().rows.length
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "{0} of {1} row(s) selected." msgid "{0} of {1} row(s) selected."
msgstr "" msgstr "{0} sur {1} ligne(s) sélectionnée(s)."
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 hour" msgid "1 hour"
@@ -46,7 +46,11 @@ msgstr "1 heure"
#. Load average #. Load average
#: src/components/charts/load-average-chart.tsx #: src/components/charts/load-average-chart.tsx
msgid "1 min" msgid "1 min"
msgstr "" msgstr "1 min"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 minute"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 week" msgid "1 week"
@@ -59,7 +63,7 @@ msgstr "12 heures"
#. Load average #. Load average
#: src/components/charts/load-average-chart.tsx #: src/components/charts/load-average-chart.tsx
msgid "15 min" msgid "15 min"
msgstr "" msgstr "15 min"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "24 hours" msgid "24 hours"
@@ -72,7 +76,7 @@ msgstr "30 jours"
#. Load average #. Load average
#: src/components/charts/load-average-chart.tsx #: src/components/charts/load-average-chart.tsx
msgid "5 min" msgid "5 min"
msgstr "" msgstr "5 min"
#. Table column #. Table column
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
@@ -83,9 +87,9 @@ msgstr "Actions"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Active" msgid "Active"
msgstr "" msgstr "Active"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Active Alerts" msgid "Active Alerts"
msgstr "Alertes actives" msgstr "Alertes actives"
@@ -122,14 +126,22 @@ msgstr "Agent"
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Alert History" msgid "Alert History"
msgstr "" msgstr "Historique des alertes"
#: src/components/alerts/alert-button.tsx #: src/components/alerts/alert-button.tsx
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
msgid "Alerts" msgid "Alerts"
msgstr "Alertes" msgstr "Alertes"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "Tous les conteneurs"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
#: src/components/command-palette.tsx
#: src/components/routes/home.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "All Systems" msgid "All Systems"
@@ -141,7 +153,7 @@ msgstr "Êtes-vous sûr de vouloir supprimer {name} ?"
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Are you sure?" msgid "Are you sure?"
msgstr "" msgstr "Êtes-vous sûr ?"
#: src/components/copy-to-clipboard.tsx #: src/components/copy-to-clipboard.tsx
msgid "Automatic copy requires a secure context." msgid "Automatic copy requires a secure context."
@@ -206,12 +218,12 @@ msgstr "Binaire"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Bits (Kbps, Mbps, Gbps)" msgid "Bits (Kbps, Mbps, Gbps)"
msgstr "" msgstr "Bits (Kbps, Mbps, Gbps)"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Bytes (KB/s, MB/s, GB/s)" msgid "Bytes (KB/s, MB/s, GB/s)"
msgstr "" msgstr "Bytes (KB/s, MB/s, GB/s)"
#: src/components/charts/mem-chart.tsx #: src/components/charts/mem-chart.tsx
msgid "Cache / Buffers" msgid "Cache / Buffers"
@@ -228,11 +240,11 @@ msgstr "Attention - perte de données potentielle"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Celsius (°C)" msgid "Celsius (°C)"
msgstr "" msgstr "Celsius (°C)"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Change display units for metrics." msgid "Change display units for metrics."
msgstr "" msgstr "Ajuster les unités d'affichage pour les métriques."
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Change general application options." msgid "Change general application options."
@@ -263,9 +275,13 @@ msgstr "Vérifiez les journaux pour plus de détails."
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Vérifiez votre service de notification" msgstr "Vérifiez votre service de notification"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "Cliquez sur un conteneur pour voir plus d'informations."
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information." msgid "Click on a system to view more information."
msgstr "" msgstr "Cliquez sur un système pour voir plus d'informations."
#: src/components/ui/input-copy.tsx #: src/components/ui/input-copy.tsx
msgid "Click to copy" msgid "Click to copy"
@@ -285,9 +301,9 @@ msgstr "Configurez comment vous recevez les notifications d'alerte."
msgid "Confirm password" msgid "Confirm password"
msgstr "Confirmer le mot de passe" msgstr "Confirmer le mot de passe"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Connection is down" msgid "Connection is down"
msgstr "" msgstr "Connexion interrompue"
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
@@ -344,6 +360,7 @@ msgstr "Copiez le contenu du<0>docker-compose.yml</0> pour l'agent ci-dessous, o
msgid "Copy YAML" msgid "Copy YAML"
msgstr "Copier YAML" msgstr "Copier YAML"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "CPU" msgid "CPU"
msgstr "CPU" msgstr "CPU"
@@ -361,7 +378,7 @@ msgstr "Créer un compte"
#. Context: date created #. Context: date created
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
msgid "Created" msgid "Created"
msgstr "" msgstr "Date de création"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Critical (%)" msgid "Critical (%)"
@@ -381,7 +398,6 @@ msgid "Current state"
msgstr "État actuel" msgstr "État actuel"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/home.tsx
msgid "Dashboard" msgid "Dashboard"
msgstr "Tableau de bord" msgstr "Tableau de bord"
@@ -398,6 +414,10 @@ msgstr "Supprimer"
msgid "Delete fingerprint" msgid "Delete fingerprint"
msgstr "Supprimer l'empreinte" msgstr "Supprimer l'empreinte"
#: src/components/containers-table/containers-table.tsx
msgid "Detail"
msgstr "Détail"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Discharging" msgid "Discharging"
@@ -413,7 +433,7 @@ msgstr "Entrée/Sortie disque"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Disk unit" msgid "Disk unit"
msgstr "" msgstr "Unité disque"
#: src/components/charts/disk-chart.tsx #: src/components/charts/disk-chart.tsx
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
@@ -451,7 +471,7 @@ msgstr "Injoignable"
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Down ({downSystemsLength})" msgid "Down ({downSystemsLength})"
msgstr "" msgstr "Injoignable ({downSystemsLength})"
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Download" msgid "Download"
@@ -459,7 +479,7 @@ msgstr "Télécharger"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
msgid "Duration" msgid "Duration"
msgstr "" msgstr "Durée"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
@@ -504,7 +524,7 @@ msgstr "Erreur"
#. placeholder {0}: alert.value #. placeholder {0}: alert.value
#. placeholder {1}: info.unit #. placeholder {1}: info.unit
#. placeholder {2}: alert.min #. placeholder {2}: alert.min
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}" msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "Dépasse {0}{1} dans {2, plural, one {la dernière # minute} other {les dernières # minutes}}" msgstr "Dépasse {0}{1} dans {2, plural, one {la dernière # minute} other {les dernières # minutes}}"
@@ -514,7 +534,7 @@ msgstr "Les systèmes existants non définis dans <0>config.yml</0> seront suppr
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Export" msgid "Export"
msgstr "" msgstr "Exporter"
#: src/components/routes/settings/config-yaml.tsx #: src/components/routes/settings/config-yaml.tsx
msgid "Export configuration" msgid "Export configuration"
@@ -526,7 +546,7 @@ msgstr "Exportez la configuration actuelle de vos systèmes."
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Fahrenheit (°F)" msgid "Fahrenheit (°F)"
msgstr "" msgstr "Fahrenheit (°F)"
#: src/lib/api.ts #: src/lib/api.ts
msgid "Failed to authenticate" msgid "Failed to authenticate"
@@ -545,6 +565,7 @@ msgstr "Échec de l'envoi de la notification de test"
msgid "Failed to update alert" msgid "Failed to update alert"
msgstr "Échec de la mise à jour de l'alerte" msgstr "Échec de la mise à jour de l'alerte"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
@@ -553,7 +574,7 @@ msgstr "Filtrer..."
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Fingerprint" msgid "Fingerprint"
msgstr "" msgstr "Empreinte"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}" msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
@@ -592,6 +613,10 @@ msgstr "Consommation du GPU"
msgid "Grid" msgid "Grid"
msgstr "Grille" msgstr "Grille"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Health"
msgstr "Santé"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"
@@ -611,6 +636,11 @@ msgstr "Inactive"
msgid "If you've lost the password to your admin account, you may reset it using the following command." msgid "If you've lost the password to your admin account, you may reset it using the following command."
msgstr "Si vous avez perdu le mot de passe de votre compte administrateur, vous pouvez le réinitialiser en utilisant la commande suivante." msgstr "Si vous avez perdu le mot de passe de votre compte administrateur, vous pouvez le réinitialiser en utilisant la commande suivante."
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image"
msgstr "Image"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
msgid "Invalid email address." msgid "Invalid email address."
msgstr "Adresse email invalide." msgstr "Adresse email invalide."
@@ -630,24 +660,24 @@ msgstr "Disposition"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Load Average" msgid "Load Average"
msgstr "" msgstr "Charge moyenne"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Load Average 15m" msgid "Load Average 15m"
msgstr "" msgstr "Charge moyenne 15m"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Load Average 1m" msgid "Load Average 1m"
msgstr "" msgstr "Charge moyenne 1m"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Load Average 5m" msgid "Load Average 5m"
msgstr "" msgstr "Charge moyenne 5m"
#. Short label for load average #. Short label for load average
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Load Avg" msgid "Load Avg"
msgstr "" msgstr "Charge moy."
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
@@ -663,6 +693,7 @@ msgid "Login attempt failed"
msgstr "Échec de la tentative de connexion" msgstr "Échec de la tentative de connexion"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Logs" msgid "Logs"
msgstr "Journaux" msgstr "Journaux"
@@ -685,6 +716,7 @@ msgstr "Guide pour une installation manuelle"
msgid "Max 1 min" msgid "Max 1 min"
msgstr "Max 1 min" msgstr "Max 1 min"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Memory" msgid "Memory"
msgstr "Mémoire" msgstr "Mémoire"
@@ -700,9 +732,11 @@ msgstr "Utilisation de la mémoire des conteneurs Docker"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgid "Name" msgid "Name"
msgstr "Nom" msgstr "Nom"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Net" msgid "Net"
msgstr "Net" msgstr "Net"
@@ -721,15 +755,16 @@ msgstr "Trafic réseau des interfaces publiques"
#. Context: Bytes or bits #. Context: Bytes or bits
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Network unit" msgid "Network unit"
msgstr "" msgstr "Unité réseau"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "No results found." msgid "No results found."
msgstr "Aucun résultat trouvé." msgstr "Aucun résultat trouvé."
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "No results." msgid "No results."
msgstr "" msgstr "Aucun résultat."
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
@@ -768,6 +803,7 @@ msgstr "Ou continuer avec"
msgid "Overwrite existing alerts" msgid "Overwrite existing alerts"
msgstr "Écraser les alertes existantes" msgstr "Écraser les alertes existantes"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "Page" msgid "Page"
msgstr "Page" msgstr "Page"
@@ -776,7 +812,7 @@ msgstr "Page"
#. placeholder {1}: table.getPageCount() #. placeholder {1}: table.getPageCount()
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Page {0} of {1}" msgid "Page {0} of {1}"
msgstr "" msgstr "Page {0} sur {1}"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "Pages / Settings" msgid "Pages / Settings"
@@ -809,7 +845,7 @@ msgstr "En pause"
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Paused ({pausedSystemsLength})" msgid "Paused ({pausedSystemsLength})"
msgstr "" msgstr "Mis en pause ({pausedSystemsLength})"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered." msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
@@ -872,6 +908,11 @@ msgstr "Lecture"
msgid "Received" msgid "Received"
msgstr "Reçu" msgstr "Reçu"
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
msgid "Refresh"
msgstr "Actualiser"
#: src/components/login/login.tsx #: src/components/login/login.tsx
msgid "Request a one-time password" msgid "Request a one-time password"
msgstr "Demander un mot de passe à usage unique" msgstr "Demander un mot de passe à usage unique"
@@ -888,7 +929,7 @@ msgstr "Réinitialiser le mot de passe"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Resolved" msgid "Resolved"
msgstr "" msgstr "Résolue"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Resume" msgid "Resume"
@@ -900,7 +941,7 @@ msgstr "Faire tourner le token"
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Rows per page" msgid "Rows per page"
msgstr "" msgstr "Lignes par page"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications." msgid "Save address using enter key or comma. Leave blank to disable email notifications."
@@ -961,8 +1002,9 @@ msgstr "Trier par"
#. Context: alert state (active or resolved) #. Context: alert state (active or resolved)
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
msgid "State" msgid "State"
msgstr "" msgstr "État"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Status" msgid "Status"
@@ -977,6 +1019,7 @@ msgid "Swap Usage"
msgstr "Utilisation du swap" msgstr "Utilisation du swap"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
@@ -985,7 +1028,7 @@ msgstr "Système"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "System load averages over time" msgid "System load averages over time"
msgstr "" msgstr "Charges moyennes du système dans le temps"
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Systems" msgid "Systems"
@@ -1035,7 +1078,7 @@ msgstr "Cette action ne peut pas être annulée. Cela supprimera définitivement
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "This will permanently delete all selected records from the database." msgid "This will permanently delete all selected records from the database."
msgstr "" msgstr "Ceci supprimera définitivement tous les enregistrements sélectionnés de la base de données."
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Throughput of {extraFsName}" msgid "Throughput of {extraFsName}"
@@ -1065,7 +1108,7 @@ msgstr "Changer le thème"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token" msgid "Token"
msgstr "" msgstr "Token"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
@@ -1095,11 +1138,11 @@ msgstr ""
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Triggers when 15 minute load average exceeds a threshold" msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr "" msgstr "Se déclenche lorsque la charge moyenne sur 15 minute dépasse un seuil"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Triggers when 5 minute load average exceeds a threshold" msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr "" msgstr "Se déclenche lorsque la charge moyenne sur 5 minute dépasse un seuil"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
@@ -1128,7 +1171,7 @@ msgstr "Déclenchement lorsque l'utilisation de tout disque dépasse un seuil"
#. Temperature / network units #. Temperature / network units
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Unit preferences" msgid "Unit preferences"
msgstr "" msgstr "Préférences des unités"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
@@ -1148,7 +1191,11 @@ msgstr "Joignable"
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Up ({upSystemsLength})" msgid "Up ({upSystemsLength})"
msgstr "" msgstr "Joignable ({upSystemsLength})"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Updated"
msgstr "Mis à jour"
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Upload" msgid "Upload"
@@ -1181,7 +1228,7 @@ msgstr "Utilisateurs"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
msgid "Value" msgid "Value"
msgstr "" msgstr "Valeur"
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "View" msgid "View"
@@ -1193,7 +1240,7 @@ msgstr "Voir plus"
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "View your 200 most recent alerts." msgid "View your 200 most recent alerts."
msgstr "" msgstr "Voir vos 200 dernières alertes."
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Visible Fields" msgid "Visible Fields"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: hr\n" "Language: hr\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-09-23 12:43\n" "PO-Revision-Date: 2025-10-18 23:59\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Croatian\n" "Language-Team: Croatian\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
@@ -31,13 +31,13 @@ msgstr "{0, plural, one {# sat} other {# sati}}"
#. placeholder {0}: Math.trunc(system.info.u / 60) #. placeholder {0}: Math.trunc(system.info.u / 60)
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "{0, plural, one {# minute} few {# minutes} many {# minutes} other {# minutes}}" msgid "{0, plural, one {# minute} few {# minutes} many {# minutes} other {# minutes}}"
msgstr "" msgstr "{0, plural, one {# minuta} few {# minuta} many {# minuta} other {# minute}}"
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length #. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
#. placeholder {1}: table.getFilteredRowModel().rows.length #. placeholder {1}: table.getFilteredRowModel().rows.length
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "{0} of {1} row(s) selected." msgid "{0} of {1} row(s) selected."
msgstr "" msgstr "{0} od {1} redaka izabrano."
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 hour" msgid "1 hour"
@@ -48,6 +48,10 @@ msgstr "1 sat"
msgid "1 min" msgid "1 min"
msgstr "1 minut" msgstr "1 minut"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 minuta"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 week" msgid "1 week"
msgstr "1 tjedan" msgstr "1 tjedan"
@@ -72,7 +76,7 @@ msgstr "30 dana"
#. Load average #. Load average
#: src/components/charts/load-average-chart.tsx #: src/components/charts/load-average-chart.tsx
msgid "5 min" msgid "5 min"
msgstr "" msgstr "5 minuta"
#. Table column #. Table column
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
@@ -85,7 +89,7 @@ msgstr "Akcije"
msgid "Active" msgid "Active"
msgstr "Aktivan" msgstr "Aktivan"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Active Alerts" msgid "Active Alerts"
msgstr "Aktivna upozorenja" msgstr "Aktivna upozorenja"
@@ -122,14 +126,22 @@ msgstr "Agent"
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Alert History" msgid "Alert History"
msgstr "" msgstr "Povijest Upozorenja"
#: src/components/alerts/alert-button.tsx #: src/components/alerts/alert-button.tsx
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
msgid "Alerts" msgid "Alerts"
msgstr "Upozorenja" msgstr "Upozorenja"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "Svi spremnici"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
#: src/components/command-palette.tsx
#: src/components/routes/home.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "All Systems" msgid "All Systems"
@@ -162,7 +174,7 @@ msgstr "Prosjek premašuje <0>{value}{0}</0>"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Average power consumption of GPUs" msgid "Average power consumption of GPUs"
msgstr "" msgstr "Prosječna potrošnja energije grafičkog procesora"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Average system-wide CPU utilization" msgid "Average system-wide CPU utilization"
@@ -171,7 +183,7 @@ msgstr "Prosječna iskorištenost procesora na cijelom sustavu"
#. placeholder {0}: gpu.n #. placeholder {0}: gpu.n
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Average utilization of {0}" msgid "Average utilization of {0}"
msgstr "" msgstr "Prosječna iskorištenost {0}"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Average utilization of GPU engines" msgid "Average utilization of GPU engines"
@@ -206,12 +218,12 @@ msgstr "Binarni"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Bits (Kbps, Mbps, Gbps)" msgid "Bits (Kbps, Mbps, Gbps)"
msgstr "" msgstr "Bitovi (Kbps, Mbps, Gbps)"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Bytes (KB/s, MB/s, GB/s)" msgid "Bytes (KB/s, MB/s, GB/s)"
msgstr "" msgstr "Bajtovi (KB/s, MB/s, GB/s)"
#: src/components/charts/mem-chart.tsx #: src/components/charts/mem-chart.tsx
msgid "Cache / Buffers" msgid "Cache / Buffers"
@@ -228,11 +240,11 @@ msgstr "Oprez - mogući gubitak podataka"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Celsius (°C)" msgid "Celsius (°C)"
msgstr "" msgstr "Celsius (°C)"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Change display units for metrics." msgid "Change display units for metrics."
msgstr "" msgstr "Promijenite mjerene jedinice korištene za prikazivanje podataka."
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Change general application options." msgid "Change general application options."
@@ -263,9 +275,13 @@ msgstr "Provjerite logove za više detalja."
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Provjerite Vaš servis notifikacija" msgstr "Provjerite Vaš servis notifikacija"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "Kliknite na spremnik za prikaz više informacija."
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information." msgid "Click on a system to view more information."
msgstr "" msgstr "Odaberite sustav za prikaz više informacija."
#: src/components/ui/input-copy.tsx #: src/components/ui/input-copy.tsx
msgid "Click to copy" msgid "Click to copy"
@@ -285,9 +301,9 @@ msgstr "Konfigurirajte način primanja obavijesti upozorenja."
msgid "Confirm password" msgid "Confirm password"
msgstr "Potvrdite lozinku" msgstr "Potvrdite lozinku"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Connection is down" msgid "Connection is down"
msgstr "" msgstr "Veza je pala"
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
@@ -334,16 +350,17 @@ msgstr "Kopiraj tekst"
#: src/components/add-system.tsx #: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>." msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr "" msgstr "Kopirajte instalacijsku komandu za opisanog agenta ili automatski registrirajte agenta uz pomoć <0>sveopćeg tokena</0>."
#: src/components/add-system.tsx #: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>." msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "" msgstr "Kopirajte sadržaj <0>docker-compose.yml</0> datoteke za opisanog agenta ili automatski registrirajte agenta uz pomoć <1>sveopćeg tokena</1>."
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML" msgid "Copy YAML"
msgstr "" msgstr "Kopiraj YAML"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "CPU" msgid "CPU"
msgstr "Procesor" msgstr "Procesor"
@@ -381,7 +398,6 @@ msgid "Current state"
msgstr "Trenutno stanje" msgstr "Trenutno stanje"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/home.tsx
msgid "Dashboard" msgid "Dashboard"
msgstr "Nadzorna ploča" msgstr "Nadzorna ploča"
@@ -396,7 +412,11 @@ msgstr "Izbriši"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint" msgid "Delete fingerprint"
msgstr "" msgstr "Izbriši otisak"
#: src/components/containers-table/containers-table.tsx
msgid "Detail"
msgstr "Detalj"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
@@ -413,7 +433,7 @@ msgstr "Disk I/O"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Disk unit" msgid "Disk unit"
msgstr "" msgstr "Mjerna jedinica za disk"
#: src/components/charts/disk-chart.tsx #: src/components/charts/disk-chart.tsx
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
@@ -447,11 +467,11 @@ msgstr "Dokumentacija"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Down" msgid "Down"
msgstr "" msgstr "Sustav je pao"
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Down ({downSystemsLength})" msgid "Down ({downSystemsLength})"
msgstr "" msgstr "Sustav je pao ({downSystemsLength})"
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Download" msgid "Download"
@@ -504,7 +524,7 @@ msgstr "Greška"
#. placeholder {0}: alert.value #. placeholder {0}: alert.value
#. placeholder {1}: info.unit #. placeholder {1}: info.unit
#. placeholder {2}: alert.min #. placeholder {2}: alert.min
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}" msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "Premašuje {0}{1} u posljednjih {2, plural, one {# minuta} other {# minute}}" msgstr "Premašuje {0}{1} u posljednjih {2, plural, one {# minuta} other {# minute}}"
@@ -526,7 +546,7 @@ msgstr "Izvoz trenutne sistemske konfiguracije."
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Fahrenheit (°F)" msgid "Fahrenheit (°F)"
msgstr "" msgstr "Farenhajt (°F)"
#: src/lib/api.ts #: src/lib/api.ts
msgid "Failed to authenticate" msgid "Failed to authenticate"
@@ -545,6 +565,7 @@ msgstr "Neuspješno slanje testne notifikacije"
msgid "Failed to update alert" msgid "Failed to update alert"
msgstr "Ažuriranje upozorenja nije uspjelo" msgstr "Ažuriranje upozorenja nije uspjelo"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
@@ -586,12 +607,16 @@ msgstr "GPU motori"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "GPU Power Draw" msgid "GPU Power Draw"
msgstr "" msgstr "Energetska potrošnja grafičkog procesora"
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Grid" msgid "Grid"
msgstr "Mreža" msgstr "Mreža"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Health"
msgstr "Zdravlje"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"
@@ -611,6 +636,11 @@ msgstr "Neaktivna"
msgid "If you've lost the password to your admin account, you may reset it using the following command." msgid "If you've lost the password to your admin account, you may reset it using the following command."
msgstr "Ako ste izgubili lozinku za svoj administratorski račun, možete ju resetirati pomoću sljedeće naredbe." msgstr "Ako ste izgubili lozinku za svoj administratorski račun, možete ju resetirati pomoću sljedeće naredbe."
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image"
msgstr "Slika"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
msgid "Invalid email address." msgid "Invalid email address."
msgstr "Nevažeća adresa e-pošte." msgstr "Nevažeća adresa e-pošte."
@@ -618,7 +648,7 @@ msgstr "Nevažeća adresa e-pošte."
#. Linux kernel #. Linux kernel
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Kernel" msgid "Kernel"
msgstr "Kernel" msgstr "Jezgra"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Language" msgid "Language"
@@ -630,19 +660,19 @@ msgstr "Izgled"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Load Average" msgid "Load Average"
msgstr "" msgstr "Prosječno Opterećenje"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Load Average 15m" msgid "Load Average 15m"
msgstr "" msgstr "Prosječno Opterećenje 15m"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Load Average 1m" msgid "Load Average 1m"
msgstr "" msgstr "Prosječno Opterećenje 1m"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Load Average 5m" msgid "Load Average 5m"
msgstr "" msgstr "Prosječno Opterećenje 5m"
#. Short label for load average #. Short label for load average
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
@@ -663,6 +693,7 @@ msgid "Login attempt failed"
msgstr "Pokušaj prijave nije uspio" msgstr "Pokušaj prijave nije uspio"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Logs" msgid "Logs"
msgstr "Logovi" msgstr "Logovi"
@@ -678,13 +709,14 @@ msgstr "Upravljajte postavkama prikaza i obavijesti."
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Manual setup instructions" msgid "Manual setup instructions"
msgstr "" msgstr "Upute za ručno postavljanje"
#. Chart select field. Please try to keep this short. #. Chart select field. Please try to keep this short.
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Max 1 min" msgid "Max 1 min"
msgstr "Maksimalno 1 minuta" msgstr "Maksimalno 1 minuta"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Memory" msgid "Memory"
msgstr "Memorija" msgstr "Memorija"
@@ -700,9 +732,11 @@ msgstr "Upotreba memorije Docker spremnika"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgid "Name" msgid "Name"
msgstr "Ime" msgstr "Ime"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Net" msgid "Net"
msgstr "Mreža" msgstr "Mreža"
@@ -721,12 +755,13 @@ msgstr "Mrežni promet javnih sučelja"
#. Context: Bytes or bits #. Context: Bytes or bits
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Network unit" msgid "Network unit"
msgstr "" msgstr "Mjerna jedinica za mrežu"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "No results found." msgid "No results found."
msgstr "Nema rezultata." msgstr "Nema rezultata."
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "No results." msgid "No results."
msgstr "Nema rezultata." msgstr "Nema rezultata."
@@ -768,6 +803,7 @@ msgstr "Ili nastavi sa"
msgid "Overwrite existing alerts" msgid "Overwrite existing alerts"
msgstr "Prebrišite postojeća upozorenja" msgstr "Prebrišite postojeća upozorenja"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "Page" msgid "Page"
msgstr "Stranica" msgstr "Stranica"
@@ -872,6 +908,11 @@ msgstr "Pročitaj"
msgid "Received" msgid "Received"
msgstr "Primljeno" msgstr "Primljeno"
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
msgid "Refresh"
msgstr "Osvježi"
#: src/components/login/login.tsx #: src/components/login/login.tsx
msgid "Request a one-time password" msgid "Request a one-time password"
msgstr "Zatraži jednokratnu lozinku" msgstr "Zatraži jednokratnu lozinku"
@@ -888,7 +929,7 @@ msgstr "Resetiraj Lozinku"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Resolved" msgid "Resolved"
msgstr "" msgstr "Razrješeno"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Resume" msgid "Resume"
@@ -896,11 +937,11 @@ msgstr "Nastavi"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token" msgid "Rotate token"
msgstr "" msgstr "Promijeni token"
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Rows per page" msgid "Rows per page"
msgstr "" msgstr "Redovi po stranici"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications." msgid "Save address using enter key or comma. Leave blank to disable email notifications."
@@ -913,7 +954,7 @@ msgstr "Spremi Postavke"
#: src/components/add-system.tsx #: src/components/add-system.tsx
msgid "Save system" msgid "Save system"
msgstr "" msgstr "Spremi sustav"
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Search" msgid "Search"
@@ -961,8 +1002,9 @@ msgstr "Sortiraj po"
#. Context: alert state (active or resolved) #. Context: alert state (active or resolved)
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
msgid "State" msgid "State"
msgstr "" msgstr "Stanje"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Status" msgid "Status"
@@ -977,6 +1019,7 @@ msgid "Swap Usage"
msgstr "Swap Iskorištenost" msgstr "Swap Iskorištenost"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
@@ -985,7 +1028,7 @@ msgstr "Sistem"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "System load averages over time" msgid "System load averages over time"
msgstr "" msgstr "Prosječno opterećenje sustava kroz vrijeme"
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Systems" msgid "Systems"
@@ -1002,7 +1045,7 @@ msgstr "Tablica"
#. Temperature label in systems table #. Temperature label in systems table
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Temp" msgid "Temp"
msgstr "" msgstr "Temp"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
@@ -1011,7 +1054,7 @@ msgstr "Temperatura"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Temperature unit" msgid "Temperature unit"
msgstr "" msgstr "Mjerna jedinica za temperaturu"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Temperatures of system sensors" msgid "Temperatures of system sensors"
@@ -1035,7 +1078,7 @@ msgstr "Ova radnja se ne može poništiti. Ovo će trajno izbrisati sve trenutne
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "This will permanently delete all selected records from the database." msgid "This will permanently delete all selected records from the database."
msgstr "" msgstr "Ovom radnjom će se trajno izbrisati svi odabrani zapisi iz baze podataka."
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Throughput of {extraFsName}" msgid "Throughput of {extraFsName}"
@@ -1065,21 +1108,21 @@ msgstr "Uključi/isključi temu"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token" msgid "Token"
msgstr "" msgstr "Token"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens & Fingerprints" msgid "Tokens & Fingerprints"
msgstr "" msgstr "Tokeni & Otisci"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection." msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr "" msgstr "Tokeni dopuštaju agentima prijavu i registraciju. Otisci su stabilni identifikatori jedinstveni svakom sustavu, koji se postavljaju prilikom prvog spajanja."
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "" msgstr "Tokeni se uz otiske koriste za autentifikaciju WebSocket veza prema središnjoj kontroli."
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Total data received for each interface" msgid "Total data received for each interface"
@@ -1091,15 +1134,15 @@ msgstr "Ukupni podaci poslani za svako sučelje"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Triggers when 1 minute load average exceeds a threshold" msgid "Triggers when 1 minute load average exceeds a threshold"
msgstr "" msgstr "Pokreće se kada prosječna opterećenost sustava unutar 1 minute prijeđe prag"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Triggers when 15 minute load average exceeds a threshold" msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr "" msgstr "Pokreće se kada prosječna opterećenost sustava unutar 15 minuta prijeđe prag"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Triggers when 5 minute load average exceeds a threshold" msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr "" msgstr "Pokreće se kada prosječna opterećenost sustava unutar 5 minuta prijeđe prag"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
@@ -1128,12 +1171,12 @@ msgstr "Pokreće se kada iskorištenost bilo kojeg diska premaši prag"
#. Temperature / network units #. Temperature / network units
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Unit preferences" msgid "Unit preferences"
msgstr "" msgstr "Opcije mjernih jedinica"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token" msgid "Universal token"
msgstr "" msgstr "Sveopći token"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
@@ -1144,11 +1187,15 @@ msgstr "Nepoznata"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Up" msgid "Up"
msgstr "" msgstr "Sustav je podignut"
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Up ({upSystemsLength})" msgid "Up ({upSystemsLength})"
msgstr "" msgstr "Sustav je podignut ({upSystemsLength})"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Updated"
msgstr "Ažurirano"
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Upload" msgid "Upload"
@@ -1193,7 +1240,7 @@ msgstr "Prikaži više"
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "View your 200 most recent alerts." msgid "View your 200 most recent alerts."
msgstr "" msgstr "Pogledajte posljednjih 200 upozorenja."
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Visible Fields" msgid "Visible Fields"
@@ -1221,7 +1268,7 @@ msgstr "Webhook / Push obavijest"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart." msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr "" msgstr "Kada je podešen, ovaj token dopušta agentima da se prijave bez prvobitnog stvaranja sustava. Ističe nakon jednog sata ili ponovnog pokretanja središnje kontrole."
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx

View File

@@ -8,15 +8,15 @@ msgstr ""
"Language: hu\n" "Language: hu\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-08-28 23:21\n" "PO-Revision-Date: 2025-10-14 23:40\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Hungarian\n" "Language-Team: Hungarian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: beszel\n" "X-Crowdin-Project: beszel\n"
"X-Crowdin-Project-ID: 733311\n" "X-Crowdin-Project-ID: 733311\n"
"X-Crowdin-Language: hu\n" "X-Crowdin-Language: hu\n"
"X-Crowdin-File: /main/beszel/site/src/locales/en/en.po\n" "X-Crowdin-File: /main/internal/site/src/locales/en/en.po\n"
"X-Crowdin-File-ID: 16\n" "X-Crowdin-File-ID: 32\n"
#. placeholder {0}: Math.trunc(system.info?.u / 86400) #. placeholder {0}: Math.trunc(system.info?.u / 86400)
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
@@ -31,13 +31,13 @@ msgstr "{0, plural, one {# óra} other {# óra}}"
#. placeholder {0}: Math.trunc(system.info.u / 60) #. placeholder {0}: Math.trunc(system.info.u / 60)
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "{0, plural, one {# minute} few {# minutes} many {# minutes} other {# minutes}}" msgid "{0, plural, one {# minute} few {# minutes} many {# minutes} other {# minutes}}"
msgstr "" msgstr "{0, plural, one {# perc} few {# perc} many {# perc} other {# perc}}"
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length #. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
#. placeholder {1}: table.getFilteredRowModel().rows.length #. placeholder {1}: table.getFilteredRowModel().rows.length
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "{0} of {1} row(s) selected." msgid "{0} of {1} row(s) selected."
msgstr "" msgstr "{0} a(z) {1} sorból kiválasztva."
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 hour" msgid "1 hour"
@@ -46,7 +46,11 @@ msgstr "1 óra"
#. Load average #. Load average
#: src/components/charts/load-average-chart.tsx #: src/components/charts/load-average-chart.tsx
msgid "1 min" msgid "1 min"
msgstr "" msgstr "1 perc"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 perc"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 week" msgid "1 week"
@@ -59,7 +63,7 @@ msgstr "12 óra"
#. Load average #. Load average
#: src/components/charts/load-average-chart.tsx #: src/components/charts/load-average-chart.tsx
msgid "15 min" msgid "15 min"
msgstr "" msgstr "15 perc"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "24 hours" msgid "24 hours"
@@ -72,7 +76,7 @@ msgstr "30 nap"
#. Load average #. Load average
#: src/components/charts/load-average-chart.tsx #: src/components/charts/load-average-chart.tsx
msgid "5 min" msgid "5 min"
msgstr "" msgstr "5 perc"
#. Table column #. Table column
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
@@ -83,9 +87,9 @@ msgstr "Műveletek"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Active" msgid "Active"
msgstr "" msgstr "Aktív"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Active Alerts" msgid "Active Alerts"
msgstr "Aktív riasztások" msgstr "Aktív riasztások"
@@ -112,7 +116,7 @@ msgstr "Állítsa be a diagram megjelenítését."
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "Admin" msgid "Admin"
msgstr "Admin" msgstr "Adminisztráció"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Agent" msgid "Agent"
@@ -122,14 +126,22 @@ msgstr "Ügynök"
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Alert History" msgid "Alert History"
msgstr "" msgstr "Riasztási előzmények"
#: src/components/alerts/alert-button.tsx #: src/components/alerts/alert-button.tsx
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
msgid "Alerts" msgid "Alerts"
msgstr "Riasztások" msgstr "Riasztások"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "Összes konténer"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
#: src/components/command-palette.tsx
#: src/components/routes/home.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "All Systems" msgid "All Systems"
@@ -141,7 +153,7 @@ msgstr "Biztosan törölni szeretnéd {name}-t?"
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Are you sure?" msgid "Are you sure?"
msgstr "" msgstr "Biztos vagy benne?"
#: src/components/copy-to-clipboard.tsx #: src/components/copy-to-clipboard.tsx
msgid "Automatic copy requires a secure context." msgid "Automatic copy requires a secure context."
@@ -206,12 +218,12 @@ msgstr "Bináris"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Bits (Kbps, Mbps, Gbps)" msgid "Bits (Kbps, Mbps, Gbps)"
msgstr "" msgstr "Bitek (Kbps, Mbps, Gbps)"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Bytes (KB/s, MB/s, GB/s)" msgid "Bytes (KB/s, MB/s, GB/s)"
msgstr "" msgstr "Byte-ok (KB/s, MB/s, GB/s)"
#: src/components/charts/mem-chart.tsx #: src/components/charts/mem-chart.tsx
msgid "Cache / Buffers" msgid "Cache / Buffers"
@@ -228,11 +240,11 @@ msgstr "Figyelem - potenciális adatvesztés"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Celsius (°C)" msgid "Celsius (°C)"
msgstr "" msgstr "Celsius (°C)"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Change display units for metrics." msgid "Change display units for metrics."
msgstr "" msgstr "A mértékegységek megjelenítésének megváltoztatása."
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Change general application options." msgid "Change general application options."
@@ -263,9 +275,13 @@ msgstr "Ellenőrizd a naplót a további részletekért."
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Ellenőrizd az értesítési szolgáltatásodat" msgstr "Ellenőrizd az értesítési szolgáltatásodat"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "Kattintson egy konténerre a további információk megtekintéséhez."
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information." msgid "Click on a system to view more information."
msgstr "" msgstr "További információkért kattints egy rendszerre."
#: src/components/ui/input-copy.tsx #: src/components/ui/input-copy.tsx
msgid "Click to copy" msgid "Click to copy"
@@ -285,9 +301,9 @@ msgstr "Konfiguráld, hogyan kapod az értesítéseket."
msgid "Confirm password" msgid "Confirm password"
msgstr "Jelszó megerősítése" msgstr "Jelszó megerősítése"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Connection is down" msgid "Connection is down"
msgstr "" msgstr "Kapcsolat megszakadt"
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
@@ -313,7 +329,7 @@ msgstr "Docker run másolása"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables" msgctxt "Environment variables"
msgid "Copy env" msgid "Copy env"
msgstr "" msgstr "Környezet másolása"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Copy host" msgid "Copy host"
@@ -342,8 +358,9 @@ msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML" msgid "Copy YAML"
msgstr "" msgstr "YAML másolása"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "CPU" msgid "CPU"
msgstr "CPU" msgstr "CPU"
@@ -361,7 +378,7 @@ msgstr "Fiók létrehozása"
#. Context: date created #. Context: date created
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
msgid "Created" msgid "Created"
msgstr "" msgstr "Létrehozva"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Critical (%)" msgid "Critical (%)"
@@ -381,7 +398,6 @@ msgid "Current state"
msgstr "Jelenlegi állapot" msgstr "Jelenlegi állapot"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/home.tsx
msgid "Dashboard" msgid "Dashboard"
msgstr "Áttekintés" msgstr "Áttekintés"
@@ -396,7 +412,11 @@ msgstr "Törlés"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint" msgid "Delete fingerprint"
msgstr "" msgstr "Ujjlenyomat törlése"
#: src/components/containers-table/containers-table.tsx
msgid "Detail"
msgstr "Részlet"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
@@ -413,7 +433,7 @@ msgstr "Lemez I/O"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Disk unit" msgid "Disk unit"
msgstr "" msgstr "Lemez mértékegysége"
#: src/components/charts/disk-chart.tsx #: src/components/charts/disk-chart.tsx
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
@@ -447,11 +467,11 @@ msgstr "Dokumentáció"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Down" msgid "Down"
msgstr "" msgstr "Offline"
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Down ({downSystemsLength})" msgid "Down ({downSystemsLength})"
msgstr "" msgstr "Offline ({downSystemsLength})"
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Download" msgid "Download"
@@ -459,12 +479,12 @@ msgstr "Letöltés"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
msgid "Duration" msgid "Duration"
msgstr "" msgstr "Időtartam"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Edit" msgid "Edit"
msgstr "" msgstr "Szerkesztés"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
#: src/components/login/forgot-pass-form.tsx #: src/components/login/forgot-pass-form.tsx
@@ -504,7 +524,7 @@ msgstr "Hiba"
#. placeholder {0}: alert.value #. placeholder {0}: alert.value
#. placeholder {1}: info.unit #. placeholder {1}: info.unit
#. placeholder {2}: alert.min #. placeholder {2}: alert.min
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}" msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "Túllépi a {0}{1} értéket az elmúlt {2, plural, one {# percben} other {# percben}}" msgstr "Túllépi a {0}{1} értéket az elmúlt {2, plural, one {# percben} other {# percben}}"
@@ -514,7 +534,7 @@ msgstr "A <0>config.yml</0> fájlban nem definiált meglévő rendszerek törlé
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Export" msgid "Export"
msgstr "" msgstr "Exportálás"
#: src/components/routes/settings/config-yaml.tsx #: src/components/routes/settings/config-yaml.tsx
msgid "Export configuration" msgid "Export configuration"
@@ -526,7 +546,7 @@ msgstr "Exportálja a jelenlegi rendszerkonfigurációt."
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Fahrenheit (°F)" msgid "Fahrenheit (°F)"
msgstr "" msgstr "Fahrenheit (°F)"
#: src/lib/api.ts #: src/lib/api.ts
msgid "Failed to authenticate" msgid "Failed to authenticate"
@@ -545,6 +565,7 @@ msgstr "Teszt értesítés elküldése sikertelen"
msgid "Failed to update alert" msgid "Failed to update alert"
msgstr "Nem sikerült frissíteni a riasztást" msgstr "Nem sikerült frissíteni a riasztást"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
@@ -553,7 +574,7 @@ msgstr "Szűrő..."
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Fingerprint" msgid "Fingerprint"
msgstr "" msgstr "Ujjlenyomat"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}" msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
@@ -592,6 +613,10 @@ msgstr "GPU áramfelvétele"
msgid "Grid" msgid "Grid"
msgstr "Rács" msgstr "Rács"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Health"
msgstr "Egészség"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"
@@ -611,6 +636,11 @@ msgstr "Tétlen"
msgid "If you've lost the password to your admin account, you may reset it using the following command." msgid "If you've lost the password to your admin account, you may reset it using the following command."
msgstr "Ha elvesztette az admin fiók jelszavát, a következő paranccsal állíthatja vissza." msgstr "Ha elvesztette az admin fiók jelszavát, a következő paranccsal állíthatja vissza."
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image"
msgstr "Kép"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
msgid "Invalid email address." msgid "Invalid email address."
msgstr "Érvénytelen e-mail cím." msgstr "Érvénytelen e-mail cím."
@@ -630,24 +660,24 @@ msgstr "Elrendezés"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Load Average" msgid "Load Average"
msgstr "" msgstr "Terhelési átlag"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Load Average 15m" msgid "Load Average 15m"
msgstr "" msgstr "Terhelési átlag 15p"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Load Average 1m" msgid "Load Average 1m"
msgstr "" msgstr "Terhelési átlag 1p"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Load Average 5m" msgid "Load Average 5m"
msgstr "" msgstr "Terhelési átlag 5p"
#. Short label for load average #. Short label for load average
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Load Avg" msgid "Load Avg"
msgstr "" msgstr "Terhelési átlag"
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
@@ -663,6 +693,7 @@ msgid "Login attempt failed"
msgstr "Bejelentkezés sikertelen" msgstr "Bejelentkezés sikertelen"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Logs" msgid "Logs"
msgstr "Naplók" msgstr "Naplók"
@@ -678,13 +709,14 @@ msgstr "A megjelenítési és értesítési beállítások kezelése."
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Manual setup instructions" msgid "Manual setup instructions"
msgstr "" msgstr "Manuális beállítási lépések"
#. Chart select field. Please try to keep this short. #. Chart select field. Please try to keep this short.
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Max 1 min" msgid "Max 1 min"
msgstr "Maximum 1 perc" msgstr "Maximum 1 perc"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Memory" msgid "Memory"
msgstr "RAM" msgstr "RAM"
@@ -700,9 +732,11 @@ msgstr "Docker konténerek memória használata"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgid "Name" msgid "Name"
msgstr "Név" msgstr "Név"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Net" msgid "Net"
msgstr "Hálózat" msgstr "Hálózat"
@@ -721,15 +755,16 @@ msgstr "Nyilvános interfészek hálózati forgalma"
#. Context: Bytes or bits #. Context: Bytes or bits
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Network unit" msgid "Network unit"
msgstr "" msgstr "Sávszélesség mértékegysége"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "No results found." msgid "No results found."
msgstr "Nincs találat." msgstr "Nincs találat."
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "No results." msgid "No results."
msgstr "" msgstr "Nincs találat."
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
@@ -768,6 +803,7 @@ msgstr "Vagy folytasd ezzel"
msgid "Overwrite existing alerts" msgid "Overwrite existing alerts"
msgstr "Felülírja a meglévő riasztásokat" msgstr "Felülírja a meglévő riasztásokat"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "Page" msgid "Page"
msgstr "Oldal" msgstr "Oldal"
@@ -776,7 +812,7 @@ msgstr "Oldal"
#. placeholder {1}: table.getPageCount() #. placeholder {1}: table.getPageCount()
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Page {0} of {1}" msgid "Page {0} of {1}"
msgstr "" msgstr "{0}/{1} oldal"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "Pages / Settings" msgid "Pages / Settings"
@@ -793,7 +829,7 @@ msgstr "A jelszónak legalább 8 karakternek kell lennie."
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
msgid "Password must be less than 72 bytes." msgid "Password must be less than 72 bytes."
msgstr "" msgstr "A jelszó legfeljebb 72 byte lehet."
#: src/components/login/forgot-pass-form.tsx #: src/components/login/forgot-pass-form.tsx
msgid "Password reset request received" msgid "Password reset request received"
@@ -809,7 +845,7 @@ msgstr "Szüneteltetve"
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Paused ({pausedSystemsLength})" msgid "Paused ({pausedSystemsLength})"
msgstr "" msgstr "Szüneteltetve ({pausedSystemsLength})"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered." msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
@@ -872,6 +908,11 @@ msgstr "Olvasás"
msgid "Received" msgid "Received"
msgstr "Fogadott" msgstr "Fogadott"
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
msgid "Refresh"
msgstr "Frissítés"
#: src/components/login/login.tsx #: src/components/login/login.tsx
msgid "Request a one-time password" msgid "Request a one-time password"
msgstr "Egyszeri jelszó kérése" msgstr "Egyszeri jelszó kérése"
@@ -888,7 +929,7 @@ msgstr "Jelszó visszaállítása"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Resolved" msgid "Resolved"
msgstr "" msgstr "Megoldva"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Resume" msgid "Resume"
@@ -896,11 +937,11 @@ msgstr "Folytatás"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token" msgid "Rotate token"
msgstr "" msgstr "Tokenváltás"
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Rows per page" msgid "Rows per page"
msgstr "" msgstr "Sorok száma oldalanként"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications." msgid "Save address using enter key or comma. Leave blank to disable email notifications."
@@ -913,7 +954,7 @@ msgstr "Beállítások mentése"
#: src/components/add-system.tsx #: src/components/add-system.tsx
msgid "Save system" msgid "Save system"
msgstr "" msgstr "Rendszer mentése"
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Search" msgid "Search"
@@ -961,8 +1002,9 @@ msgstr "Rendezés"
#. Context: alert state (active or resolved) #. Context: alert state (active or resolved)
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
msgid "State" msgid "State"
msgstr "" msgstr "Állapot"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Status" msgid "Status"
@@ -977,6 +1019,7 @@ msgid "Swap Usage"
msgstr "Swap használat" msgstr "Swap használat"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
@@ -985,7 +1028,7 @@ msgstr "Rendszer"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "System load averages over time" msgid "System load averages over time"
msgstr "" msgstr "Rendszer terhelési átlaga"
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Systems" msgid "Systems"
@@ -1002,7 +1045,7 @@ msgstr "Tábla"
#. Temperature label in systems table #. Temperature label in systems table
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Temp" msgid "Temp"
msgstr "" msgstr "Hőmérséklet"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
@@ -1011,7 +1054,7 @@ msgstr "Hőmérséklet"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Temperature unit" msgid "Temperature unit"
msgstr "" msgstr "Hőmérséklet mértékegysége"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Temperatures of system sensors" msgid "Temperatures of system sensors"
@@ -1035,7 +1078,7 @@ msgstr "Ezt a műveletet nem lehet visszavonni! Véglegesen törli a {name} öss
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "This will permanently delete all selected records from the database." msgid "This will permanently delete all selected records from the database."
msgstr "" msgstr "Ez véglegesen törli az összes kijelölt bejegyzést az adatbázisból."
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Throughput of {extraFsName}" msgid "Throughput of {extraFsName}"
@@ -1065,13 +1108,13 @@ msgstr "Téma váltása"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token" msgid "Token"
msgstr "" msgstr "Token"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens & Fingerprints" msgid "Tokens & Fingerprints"
msgstr "" msgstr "Tokenek & Ujjlenyomatok"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection." msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
@@ -1091,15 +1134,15 @@ msgstr "Összes elküldött adat minden interfészenként"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Triggers when 1 minute load average exceeds a threshold" msgid "Triggers when 1 minute load average exceeds a threshold"
msgstr "" msgstr "Riaszt, ha az 1 perces terhelési átlag túllép egy küszöbértéket"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Triggers when 15 minute load average exceeds a threshold" msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr "" msgstr "Riaszt, ha a 15 perces terhelési átlag túllép egy küszöbértéket"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Triggers when 5 minute load average exceeds a threshold" msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr "" msgstr "Riaszt, ha az 5 perces terhelési átlag túllép egy küszöbértéket"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
@@ -1128,12 +1171,12 @@ msgstr "Bekapcsol, ha a lemez érzékelő túllép egy küszöbértéket"
#. Temperature / network units #. Temperature / network units
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Unit preferences" msgid "Unit preferences"
msgstr "" msgstr "Mértékegység beállítások"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token" msgid "Universal token"
msgstr "" msgstr "Univerzális token"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
@@ -1144,11 +1187,15 @@ msgstr "Ismeretlen"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Up" msgid "Up"
msgstr "" msgstr "Online"
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Up ({upSystemsLength})" msgid "Up ({upSystemsLength})"
msgstr "" msgstr "Online ({upSystemsLength})"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Updated"
msgstr "Frissítve"
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Upload" msgid "Upload"
@@ -1181,7 +1228,7 @@ msgstr "Felhasználók"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
msgid "Value" msgid "Value"
msgstr "" msgstr "Érték"
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "View" msgid "View"
@@ -1193,7 +1240,7 @@ msgstr "Továbbiak megjelenítése"
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "View your 200 most recent alerts." msgid "View your 200 most recent alerts."
msgstr "" msgstr "Legfrissebb 200 riasztásod áttekintése."
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Visible Fields" msgid "Visible Fields"

View File

@@ -48,6 +48,10 @@ msgstr "1 klukkustund"
msgid "1 min" msgid "1 min"
msgstr "" msgstr ""
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 mínúta"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 week" msgid "1 week"
msgstr "1 vika" msgstr "1 vika"
@@ -85,7 +89,7 @@ msgstr "Aðgerðir"
msgid "Active" msgid "Active"
msgstr "" msgstr ""
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Active Alerts" msgid "Active Alerts"
msgstr "Virkar tilkynningar" msgstr "Virkar tilkynningar"
@@ -129,7 +133,15 @@ msgstr ""
msgid "Alerts" msgid "Alerts"
msgstr "Tilkynningar" msgstr "Tilkynningar"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "Allar gámar"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
#: src/components/command-palette.tsx
#: src/components/routes/home.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "All Systems" msgid "All Systems"
@@ -263,6 +275,10 @@ msgstr "Skoðaðu logga til að sjá meiri upplýsingar."
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Athugaðu tilkynningaþjónustuna þína" msgstr "Athugaðu tilkynningaþjónustuna þína"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "Smelltu á gám til að sjá frekari upplýsingar."
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information." msgid "Click on a system to view more information."
msgstr "" msgstr ""
@@ -285,7 +301,7 @@ msgstr "Stilltu hvernig þú vilt fá tilkynningar."
msgid "Confirm password" msgid "Confirm password"
msgstr "Staðfestu lykilorð" msgstr "Staðfestu lykilorð"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Connection is down" msgid "Connection is down"
msgstr "" msgstr ""
@@ -344,6 +360,7 @@ msgstr ""
msgid "Copy YAML" msgid "Copy YAML"
msgstr "" msgstr ""
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "CPU" msgid "CPU"
msgstr "Örgjörvi" msgstr "Örgjörvi"
@@ -381,7 +398,6 @@ msgid "Current state"
msgstr "Núverandi ástand" msgstr "Núverandi ástand"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/home.tsx
msgid "Dashboard" msgid "Dashboard"
msgstr "Yfirlitssíða" msgstr "Yfirlitssíða"
@@ -398,6 +414,10 @@ msgstr "Eyða"
msgid "Delete fingerprint" msgid "Delete fingerprint"
msgstr "" msgstr ""
#: src/components/containers-table/containers-table.tsx
msgid "Detail"
msgstr "Nánar"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Discharging" msgid "Discharging"
@@ -504,7 +524,7 @@ msgstr "Villa"
#. placeholder {0}: alert.value #. placeholder {0}: alert.value
#. placeholder {1}: info.unit #. placeholder {1}: info.unit
#. placeholder {2}: alert.min #. placeholder {2}: alert.min
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}" msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "Fór yfir {0}{1} á síðustu {2, plural, one {# mínútu} other {# mínútum}}" msgstr "Fór yfir {0}{1} á síðustu {2, plural, one {# mínútu} other {# mínútum}}"
@@ -545,6 +565,7 @@ msgstr "Villa í sendingu prufu skilaboða"
msgid "Failed to update alert" msgid "Failed to update alert"
msgstr "Mistókst að uppfæra tilkynningu" msgstr "Mistókst að uppfæra tilkynningu"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
@@ -592,6 +613,10 @@ msgstr "Skjákorts rafmagnsnotkun"
msgid "Grid" msgid "Grid"
msgstr "" msgstr ""
#: src/components/containers-table/containers-table-columns.tsx
msgid "Health"
msgstr "Heilsa"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"
@@ -611,6 +636,11 @@ msgstr "Aðgerðalaus"
msgid "If you've lost the password to your admin account, you may reset it using the following command." msgid "If you've lost the password to your admin account, you may reset it using the following command."
msgstr "" msgstr ""
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image"
msgstr "Mynd"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
msgid "Invalid email address." msgid "Invalid email address."
msgstr "Ógilt netfang." msgstr "Ógilt netfang."
@@ -663,6 +693,7 @@ msgid "Login attempt failed"
msgstr "Innskránings tilraun misheppnaðist" msgstr "Innskránings tilraun misheppnaðist"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Logs" msgid "Logs"
msgstr "Loggar" msgstr "Loggar"
@@ -685,6 +716,7 @@ msgstr ""
msgid "Max 1 min" msgid "Max 1 min"
msgstr "Mest 1 mínúta" msgstr "Mest 1 mínúta"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Memory" msgid "Memory"
msgstr "Minni" msgstr "Minni"
@@ -700,9 +732,11 @@ msgstr "Minnisnotkun docker kerfa"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgid "Name" msgid "Name"
msgstr "Nafn" msgstr "Nafn"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Net" msgid "Net"
msgstr "Net" msgstr "Net"
@@ -727,6 +761,7 @@ msgstr ""
msgid "No results found." msgid "No results found."
msgstr "Engar niðurstöður fundust." msgstr "Engar niðurstöður fundust."
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "No results." msgid "No results."
msgstr "" msgstr ""
@@ -768,6 +803,7 @@ msgstr "Eða halda áfram með"
msgid "Overwrite existing alerts" msgid "Overwrite existing alerts"
msgstr "Yfirskrifa núverandi tilkynningu" msgstr "Yfirskrifa núverandi tilkynningu"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "Page" msgid "Page"
msgstr "Síða" msgstr "Síða"
@@ -872,6 +908,11 @@ msgstr "Lesa"
msgid "Received" msgid "Received"
msgstr "Móttekið" msgstr "Móttekið"
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
msgid "Refresh"
msgstr "Endurhlaða"
#: src/components/login/login.tsx #: src/components/login/login.tsx
msgid "Request a one-time password" msgid "Request a one-time password"
msgstr "" msgstr ""
@@ -963,6 +1004,7 @@ msgstr "Raða eftir"
msgid "State" msgid "State"
msgstr "" msgstr ""
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Status" msgid "Status"
@@ -977,6 +1019,7 @@ msgid "Swap Usage"
msgstr "Skipti minni" msgstr "Skipti minni"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
@@ -1150,6 +1193,10 @@ msgstr ""
msgid "Up ({upSystemsLength})" msgid "Up ({upSystemsLength})"
msgstr "" msgstr ""
#: src/components/containers-table/containers-table-columns.tsx
msgid "Updated"
msgstr "Uppfært"
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Upload" msgid "Upload"
msgstr "" msgstr ""

View File

@@ -48,6 +48,10 @@ msgstr "1 ora"
msgid "1 min" msgid "1 min"
msgstr "1 min" msgstr "1 min"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 minuto"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 week" msgid "1 week"
msgstr "1 settimana" msgstr "1 settimana"
@@ -85,7 +89,7 @@ msgstr "Azioni"
msgid "Active" msgid "Active"
msgstr "Attivo" msgstr "Attivo"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Active Alerts" msgid "Active Alerts"
msgstr "Avvisi Attivi" msgstr "Avvisi Attivi"
@@ -129,7 +133,15 @@ msgstr "Cronologia Avvisi"
msgid "Alerts" msgid "Alerts"
msgstr "Avvisi" msgstr "Avvisi"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "Tutti i contenitori"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
#: src/components/command-palette.tsx
#: src/components/routes/home.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "All Systems" msgid "All Systems"
@@ -263,6 +275,10 @@ msgstr "Controlla i log per maggiori dettagli."
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Controlla il tuo servizio di notifica" msgstr "Controlla il tuo servizio di notifica"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "Fare clic su un contenitore per visualizzare ulteriori informazioni."
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information." msgid "Click on a system to view more information."
msgstr "Clicca su un sistema per visualizzare più informazioni." msgstr "Clicca su un sistema per visualizzare più informazioni."
@@ -285,7 +301,7 @@ msgstr "Configura come ricevere le notifiche di avviso."
msgid "Confirm password" msgid "Confirm password"
msgstr "Conferma password" msgstr "Conferma password"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Connection is down" msgid "Connection is down"
msgstr "La connessione è interrotta" msgstr "La connessione è interrotta"
@@ -344,6 +360,7 @@ msgstr "Copia il contenuto<0>docker-compose.yml</0> per l'agente qui sotto, o re
msgid "Copy YAML" msgid "Copy YAML"
msgstr "Copia YAML" msgstr "Copia YAML"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "CPU" msgid "CPU"
msgstr "CPU" msgstr "CPU"
@@ -381,7 +398,6 @@ msgid "Current state"
msgstr "Stato attuale" msgstr "Stato attuale"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/home.tsx
msgid "Dashboard" msgid "Dashboard"
msgstr "Cruscotto" msgstr "Cruscotto"
@@ -398,6 +414,10 @@ msgstr "Elimina"
msgid "Delete fingerprint" msgid "Delete fingerprint"
msgstr "Elimina impronta digitale" msgstr "Elimina impronta digitale"
#: src/components/containers-table/containers-table.tsx
msgid "Detail"
msgstr "Dettagli"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Discharging" msgid "Discharging"
@@ -504,7 +524,7 @@ msgstr "Errore"
#. placeholder {0}: alert.value #. placeholder {0}: alert.value
#. placeholder {1}: info.unit #. placeholder {1}: info.unit
#. placeholder {2}: alert.min #. placeholder {2}: alert.min
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}" msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "Supera {0}{1} negli ultimi {2, plural, one {# minuto} other {# minuti}}" msgstr "Supera {0}{1} negli ultimi {2, plural, one {# minuto} other {# minuti}}"
@@ -545,6 +565,7 @@ msgstr "Invio della notifica di test fallito"
msgid "Failed to update alert" msgid "Failed to update alert"
msgstr "Aggiornamento dell'avviso fallito" msgstr "Aggiornamento dell'avviso fallito"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
@@ -592,6 +613,10 @@ msgstr "Consumo della GPU"
msgid "Grid" msgid "Grid"
msgstr "Griglia" msgstr "Griglia"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Health"
msgstr "Stato"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"
@@ -611,6 +636,11 @@ msgstr "Inattiva"
msgid "If you've lost the password to your admin account, you may reset it using the following command." msgid "If you've lost the password to your admin account, you may reset it using the following command."
msgstr "Se hai perso la password del tuo account amministratore, puoi reimpostarla utilizzando il seguente comando." msgstr "Se hai perso la password del tuo account amministratore, puoi reimpostarla utilizzando il seguente comando."
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image"
msgstr "Immagine"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
msgid "Invalid email address." msgid "Invalid email address."
msgstr "Indirizzo email non valido." msgstr "Indirizzo email non valido."
@@ -663,6 +693,7 @@ msgid "Login attempt failed"
msgstr "Tentativo di accesso fallito" msgstr "Tentativo di accesso fallito"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Logs" msgid "Logs"
msgstr "Log" msgstr "Log"
@@ -685,6 +716,7 @@ msgstr "Istruzioni di configurazione manuale"
msgid "Max 1 min" msgid "Max 1 min"
msgstr "Max 1 min" msgstr "Max 1 min"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Memory" msgid "Memory"
msgstr "Memoria" msgstr "Memoria"
@@ -700,9 +732,11 @@ msgstr "Utilizzo della memoria dei container Docker"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgid "Name" msgid "Name"
msgstr "Nome" msgstr "Nome"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Net" msgid "Net"
msgstr "Rete" msgstr "Rete"
@@ -727,6 +761,7 @@ msgstr "Unità rete"
msgid "No results found." msgid "No results found."
msgstr "Nessun risultato trovato." msgstr "Nessun risultato trovato."
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "No results." msgid "No results."
msgstr "Nessun risultato." msgstr "Nessun risultato."
@@ -768,6 +803,7 @@ msgstr "Oppure continua con"
msgid "Overwrite existing alerts" msgid "Overwrite existing alerts"
msgstr "Sovrascrivi avvisi esistenti" msgstr "Sovrascrivi avvisi esistenti"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "Page" msgid "Page"
msgstr "Pagina" msgstr "Pagina"
@@ -872,6 +908,11 @@ msgstr "Lettura"
msgid "Received" msgid "Received"
msgstr "Ricevuto" msgstr "Ricevuto"
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
msgid "Refresh"
msgstr "Aggiorna"
#: src/components/login/login.tsx #: src/components/login/login.tsx
msgid "Request a one-time password" msgid "Request a one-time password"
msgstr "Richiedi una password monouso" msgstr "Richiedi una password monouso"
@@ -963,6 +1004,7 @@ msgstr "Ordina per"
msgid "State" msgid "State"
msgstr "Stato" msgstr "Stato"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Status" msgid "Status"
@@ -977,6 +1019,7 @@ msgid "Swap Usage"
msgstr "Utilizzo Swap" msgstr "Utilizzo Swap"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
@@ -1150,6 +1193,10 @@ msgstr "Attivo"
msgid "Up ({upSystemsLength})" msgid "Up ({upSystemsLength})"
msgstr "Attivo ({upSystemsLength})" msgstr "Attivo ({upSystemsLength})"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Updated"
msgstr "Aggiornato"
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Upload" msgid "Upload"
msgstr "Carica" msgstr "Carica"

View File

@@ -48,6 +48,10 @@ msgstr "1時間"
msgid "1 min" msgid "1 min"
msgstr "1分" msgstr "1分"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1分"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 week" msgid "1 week"
msgstr "1週間" msgstr "1週間"
@@ -85,7 +89,7 @@ msgstr "アクション"
msgid "Active" msgid "Active"
msgstr "アクティブ" msgstr "アクティブ"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Active Alerts" msgid "Active Alerts"
msgstr "アクティブなアラート" msgstr "アクティブなアラート"
@@ -129,7 +133,15 @@ msgstr "アラート履歴"
msgid "Alerts" msgid "Alerts"
msgstr "アラート" msgstr "アラート"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "すべてのコンテナ"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
#: src/components/command-palette.tsx
#: src/components/routes/home.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "All Systems" msgid "All Systems"
@@ -263,6 +275,10 @@ msgstr "詳細についてはログを確認してください。"
msgid "Check your notification service" msgid "Check your notification service"
msgstr "通知サービスを確認してください" msgstr "通知サービスを確認してください"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "詳細情報を表示するにはコンテナをクリックしてください。"
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information." msgid "Click on a system to view more information."
msgstr "システムをクリックして詳細を表示します。" msgstr "システムをクリックして詳細を表示します。"
@@ -285,7 +301,7 @@ msgstr "アラート通知の受信方法を設定します。"
msgid "Confirm password" msgid "Confirm password"
msgstr "パスワードを確認" msgstr "パスワードを確認"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Connection is down" msgid "Connection is down"
msgstr "接続が切断されました" msgstr "接続が切断されました"
@@ -344,6 +360,7 @@ msgstr "下記のエージェントの<0>docker-compose.yml</0>内容をコピ
msgid "Copy YAML" msgid "Copy YAML"
msgstr "YAMLをコピー" msgstr "YAMLをコピー"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "CPU" msgid "CPU"
msgstr "CPU" msgstr "CPU"
@@ -381,7 +398,6 @@ msgid "Current state"
msgstr "現在の状態" msgstr "現在の状態"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/home.tsx
msgid "Dashboard" msgid "Dashboard"
msgstr "ダッシュボード" msgstr "ダッシュボード"
@@ -398,6 +414,10 @@ msgstr "削除"
msgid "Delete fingerprint" msgid "Delete fingerprint"
msgstr "フィンガープリントを削除" msgstr "フィンガープリントを削除"
#: src/components/containers-table/containers-table.tsx
msgid "Detail"
msgstr "詳細"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Discharging" msgid "Discharging"
@@ -504,7 +524,7 @@ msgstr "エラー"
#. placeholder {0}: alert.value #. placeholder {0}: alert.value
#. placeholder {1}: info.unit #. placeholder {1}: info.unit
#. placeholder {2}: alert.min #. placeholder {2}: alert.min
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}" msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "過去{2, plural, one {# 分} other {# 分}}で{0}{1}を超えています" msgstr "過去{2, plural, one {# 分} other {# 分}}で{0}{1}を超えています"
@@ -545,6 +565,7 @@ msgstr "テスト通知の送信に失敗しました"
msgid "Failed to update alert" msgid "Failed to update alert"
msgstr "アラートの更新に失敗しました" msgstr "アラートの更新に失敗しました"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
@@ -592,6 +613,10 @@ msgstr "GPUの消費電力"
msgid "Grid" msgid "Grid"
msgstr "グリッド" msgstr "グリッド"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Health"
msgstr "ヘルス"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"
@@ -611,6 +636,11 @@ msgstr "アイドル"
msgid "If you've lost the password to your admin account, you may reset it using the following command." msgid "If you've lost the password to your admin account, you may reset it using the following command."
msgstr "管理者アカウントのパスワードを忘れた場合は、次のコマンドを使用してリセットできます。" msgstr "管理者アカウントのパスワードを忘れた場合は、次のコマンドを使用してリセットできます。"
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image"
msgstr "イメージ"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
msgid "Invalid email address." msgid "Invalid email address."
msgstr "無効なメールアドレスです。" msgstr "無効なメールアドレスです。"
@@ -663,6 +693,7 @@ msgid "Login attempt failed"
msgstr "ログイン試行に失敗しました" msgstr "ログイン試行に失敗しました"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Logs" msgid "Logs"
msgstr "ログ" msgstr "ログ"
@@ -685,6 +716,7 @@ msgstr "手動セットアップの手順"
msgid "Max 1 min" msgid "Max 1 min"
msgstr "最大1分" msgstr "最大1分"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Memory" msgid "Memory"
msgstr "メモリ" msgstr "メモリ"
@@ -700,9 +732,11 @@ msgstr "Dockerコンテナのメモリ使用率"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgid "Name" msgid "Name"
msgstr "名前" msgstr "名前"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Net" msgid "Net"
msgstr "帯域" msgstr "帯域"
@@ -727,6 +761,7 @@ msgstr "ネットワーク単位"
msgid "No results found." msgid "No results found."
msgstr "結果が見つかりませんでした。" msgstr "結果が見つかりませんでした。"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "No results." msgid "No results."
msgstr "結果がありません。" msgstr "結果がありません。"
@@ -768,6 +803,7 @@ msgstr "または、以下の方法でログイン"
msgid "Overwrite existing alerts" msgid "Overwrite existing alerts"
msgstr "既存のアラートを上書き" msgstr "既存のアラートを上書き"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "Page" msgid "Page"
msgstr "ページ" msgstr "ページ"
@@ -872,6 +908,11 @@ msgstr "読み取り"
msgid "Received" msgid "Received"
msgstr "受信" msgstr "受信"
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
msgid "Refresh"
msgstr "更新"
#: src/components/login/login.tsx #: src/components/login/login.tsx
msgid "Request a one-time password" msgid "Request a one-time password"
msgstr "ワンタイムパスワードをリクエスト" msgstr "ワンタイムパスワードをリクエスト"
@@ -963,6 +1004,7 @@ msgstr "並び替え基準"
msgid "State" msgid "State"
msgstr "状態" msgstr "状態"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Status" msgid "Status"
@@ -977,6 +1019,7 @@ msgid "Swap Usage"
msgstr "スワップ使用量" msgstr "スワップ使用量"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
@@ -1150,6 +1193,10 @@ msgstr "正常"
msgid "Up ({upSystemsLength})" msgid "Up ({upSystemsLength})"
msgstr "正常 ({upSystemsLength})" msgstr "正常 ({upSystemsLength})"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Updated"
msgstr "更新済み"
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Upload" msgid "Upload"
msgstr "アップロード" msgstr "アップロード"

View File

@@ -48,6 +48,10 @@ msgstr "1시간"
msgid "1 min" msgid "1 min"
msgstr "1분" msgstr "1분"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1분"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 week" msgid "1 week"
msgstr "1주" msgstr "1주"
@@ -85,7 +89,7 @@ msgstr "작업"
msgid "Active" msgid "Active"
msgstr "활성" msgstr "활성"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Active Alerts" msgid "Active Alerts"
msgstr "활성화된 알림들" msgstr "활성화된 알림들"
@@ -129,7 +133,15 @@ msgstr "알림 기록"
msgid "Alerts" msgid "Alerts"
msgstr "알림" msgstr "알림"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "모든 컨테이너"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
#: src/components/command-palette.tsx
#: src/components/routes/home.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "All Systems" msgid "All Systems"
@@ -263,6 +275,10 @@ msgstr "자세한 내용은 로그를 확인하세요."
msgid "Check your notification service" msgid "Check your notification service"
msgstr "알림 서비스를 확인하세요." msgstr "알림 서비스를 확인하세요."
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "더 많은 정보를 보려면 컨테이너를 클릭하세요."
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information." msgid "Click on a system to view more information."
msgstr "더 많은 정보를 보려면 시스템을 클릭하세요." msgstr "더 많은 정보를 보려면 시스템을 클릭하세요."
@@ -285,7 +301,7 @@ msgstr "알림을 수신할 방법을 설정하세요."
msgid "Confirm password" msgid "Confirm password"
msgstr "비밀번호 확인" msgstr "비밀번호 확인"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Connection is down" msgid "Connection is down"
msgstr "연결이 끊겼습니다" msgstr "연결이 끊겼습니다"
@@ -344,6 +360,7 @@ msgstr "아래 에이전트의 <0>docker-compose.yml</0> 내용을 복사하거
msgid "Copy YAML" msgid "Copy YAML"
msgstr "YAML 복사" msgstr "YAML 복사"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "CPU" msgid "CPU"
msgstr "CPU" msgstr "CPU"
@@ -381,7 +398,6 @@ msgid "Current state"
msgstr "현재 상태" msgstr "현재 상태"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/home.tsx
msgid "Dashboard" msgid "Dashboard"
msgstr "대시보드" msgstr "대시보드"
@@ -398,6 +414,10 @@ msgstr "삭제"
msgid "Delete fingerprint" msgid "Delete fingerprint"
msgstr "지문 삭제" msgstr "지문 삭제"
#: src/components/containers-table/containers-table.tsx
msgid "Detail"
msgstr "세부사항"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Discharging" msgid "Discharging"
@@ -504,7 +524,7 @@ msgstr "오류"
#. placeholder {0}: alert.value #. placeholder {0}: alert.value
#. placeholder {1}: info.unit #. placeholder {1}: info.unit
#. placeholder {2}: alert.min #. placeholder {2}: alert.min
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}" msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "마지막 {2, plural, one {# 분} other {# 분}} 동안 {0}{1} 초과" msgstr "마지막 {2, plural, one {# 분} other {# 분}} 동안 {0}{1} 초과"
@@ -545,6 +565,7 @@ msgstr "테스트 알림 전송 실패"
msgid "Failed to update alert" msgid "Failed to update alert"
msgstr "알림 수정 실패" msgstr "알림 수정 실패"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
@@ -592,6 +613,10 @@ msgstr "GPU 전원 사용량"
msgid "Grid" msgid "Grid"
msgstr "그리드" msgstr "그리드"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Health"
msgstr "상태"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"
@@ -611,6 +636,11 @@ msgstr "대기"
msgid "If you've lost the password to your admin account, you may reset it using the following command." msgid "If you've lost the password to your admin account, you may reset it using the following command."
msgstr "관리자 계정의 비밀번호를 잃어버린 경우, 다음 명령어를 사용하여 재설정할 수 있습니다." msgstr "관리자 계정의 비밀번호를 잃어버린 경우, 다음 명령어를 사용하여 재설정할 수 있습니다."
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image"
msgstr "이미지"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
msgid "Invalid email address." msgid "Invalid email address."
msgstr "잘못된 이메일 주소입니다." msgstr "잘못된 이메일 주소입니다."
@@ -663,6 +693,7 @@ msgid "Login attempt failed"
msgstr "로그인 실패" msgstr "로그인 실패"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Logs" msgid "Logs"
msgstr "로그" msgstr "로그"
@@ -685,6 +716,7 @@ msgstr "수동 설정 방법"
msgid "Max 1 min" msgid "Max 1 min"
msgstr "1분간 최댓값" msgstr "1분간 최댓값"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Memory" msgid "Memory"
msgstr "메모리" msgstr "메모리"
@@ -700,9 +732,11 @@ msgstr "Docker 컨테이너의 메모리 사용량"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgid "Name" msgid "Name"
msgstr "이름" msgstr "이름"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Net" msgid "Net"
msgstr "네트워크" msgstr "네트워크"
@@ -727,6 +761,7 @@ msgstr "네트워크 단위"
msgid "No results found." msgid "No results found."
msgstr "결과가 없습니다." msgstr "결과가 없습니다."
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "No results." msgid "No results."
msgstr "결과 없음." msgstr "결과 없음."
@@ -768,6 +803,7 @@ msgstr "또는 아래 항목으로 진행하기"
msgid "Overwrite existing alerts" msgid "Overwrite existing alerts"
msgstr "기존 알림 덮어쓰기" msgstr "기존 알림 덮어쓰기"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "Page" msgid "Page"
msgstr "페이지" msgstr "페이지"
@@ -872,6 +908,11 @@ msgstr "읽기"
msgid "Received" msgid "Received"
msgstr "수신됨" msgstr "수신됨"
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
msgid "Refresh"
msgstr "새로고침"
#: src/components/login/login.tsx #: src/components/login/login.tsx
msgid "Request a one-time password" msgid "Request a one-time password"
msgstr "OTP 요청" msgstr "OTP 요청"
@@ -963,6 +1004,7 @@ msgstr "정렬 기준"
msgid "State" msgid "State"
msgstr "상태" msgstr "상태"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Status" msgid "Status"
@@ -977,6 +1019,7 @@ msgid "Swap Usage"
msgstr "스왑 사용량" msgstr "스왑 사용량"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
@@ -1150,6 +1193,10 @@ msgstr "온라인"
msgid "Up ({upSystemsLength})" msgid "Up ({upSystemsLength})"
msgstr "온라인 ({upSystemsLength})" msgstr "온라인 ({upSystemsLength})"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Updated"
msgstr "업데이트됨"
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Upload" msgid "Upload"
msgstr "업로드" msgstr "업로드"

View File

@@ -48,6 +48,10 @@ msgstr "1 uur"
msgid "1 min" msgid "1 min"
msgstr "1 minuut" msgstr "1 minuut"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 minuut"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 week" msgid "1 week"
msgstr "1 week" msgstr "1 week"
@@ -85,7 +89,7 @@ msgstr "Acties"
msgid "Active" msgid "Active"
msgstr "Actief" msgstr "Actief"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Active Alerts" msgid "Active Alerts"
msgstr "Actieve waarschuwingen" msgstr "Actieve waarschuwingen"
@@ -129,7 +133,15 @@ msgstr "Melding geschiedenis"
msgid "Alerts" msgid "Alerts"
msgstr "Waarschuwingen" msgstr "Waarschuwingen"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "Alle containers"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
#: src/components/command-palette.tsx
#: src/components/routes/home.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "All Systems" msgid "All Systems"
@@ -263,6 +275,10 @@ msgstr "Controleer de logs voor meer details."
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Controleer je meldingsservice" msgstr "Controleer je meldingsservice"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "Klik op een container om meer informatie te zien."
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information." msgid "Click on a system to view more information."
msgstr "Klik op een systeem om meer informatie te bekijken." msgstr "Klik op een systeem om meer informatie te bekijken."
@@ -285,7 +301,7 @@ msgstr "Configureer hoe je waarschuwingsmeldingen ontvangt."
msgid "Confirm password" msgid "Confirm password"
msgstr "Bevestig wachtwoord" msgstr "Bevestig wachtwoord"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Connection is down" msgid "Connection is down"
msgstr "Verbinding is niet actief" msgstr "Verbinding is niet actief"
@@ -344,6 +360,7 @@ msgstr "Kopieer de<0>docker-compose.yml</0> inhoud voor de agent hieronder, of r
msgid "Copy YAML" msgid "Copy YAML"
msgstr "YAML kopiëren" msgstr "YAML kopiëren"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "CPU" msgid "CPU"
msgstr "CPU" msgstr "CPU"
@@ -381,7 +398,6 @@ msgid "Current state"
msgstr "Huidige status" msgstr "Huidige status"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/home.tsx
msgid "Dashboard" msgid "Dashboard"
msgstr "Dashboard" msgstr "Dashboard"
@@ -398,6 +414,10 @@ msgstr "Verwijderen"
msgid "Delete fingerprint" msgid "Delete fingerprint"
msgstr "Vingerafdruk verwijderen" msgstr "Vingerafdruk verwijderen"
#: src/components/containers-table/containers-table.tsx
msgid "Detail"
msgstr "Detail"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Discharging" msgid "Discharging"
@@ -504,7 +524,7 @@ msgstr "Fout"
#. placeholder {0}: alert.value #. placeholder {0}: alert.value
#. placeholder {1}: info.unit #. placeholder {1}: info.unit
#. placeholder {2}: alert.min #. placeholder {2}: alert.min
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}" msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "Overschrijdt {0}{1} in de laatste {2, plural, one {# minuut} other {# minuten}}" msgstr "Overschrijdt {0}{1} in de laatste {2, plural, one {# minuut} other {# minuten}}"
@@ -545,6 +565,7 @@ msgstr "Versturen test notificatie mislukt"
msgid "Failed to update alert" msgid "Failed to update alert"
msgstr "Bijwerken waarschuwing mislukt" msgstr "Bijwerken waarschuwing mislukt"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
@@ -592,6 +613,10 @@ msgstr "GPU stroomverbruik"
msgid "Grid" msgid "Grid"
msgstr "Raster" msgstr "Raster"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Health"
msgstr "Gezondheid"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"
@@ -611,6 +636,11 @@ msgstr "Inactief"
msgid "If you've lost the password to your admin account, you may reset it using the following command." msgid "If you've lost the password to your admin account, you may reset it using the following command."
msgstr "Als je het wachtwoord voor je beheerdersaccount bent kwijtgeraakt, kan je het opnieuw instellen met behulp van de volgende opdracht." msgstr "Als je het wachtwoord voor je beheerdersaccount bent kwijtgeraakt, kan je het opnieuw instellen met behulp van de volgende opdracht."
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image"
msgstr "Image"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
msgid "Invalid email address." msgid "Invalid email address."
msgstr "Ongeldig e-mailadres." msgstr "Ongeldig e-mailadres."
@@ -663,6 +693,7 @@ msgid "Login attempt failed"
msgstr "Aanmelding mislukt" msgstr "Aanmelding mislukt"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Logs" msgid "Logs"
msgstr "Logs" msgstr "Logs"
@@ -685,6 +716,7 @@ msgstr "Handmatige installatie-instructies"
msgid "Max 1 min" msgid "Max 1 min"
msgstr "Max 1 min" msgstr "Max 1 min"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Memory" msgid "Memory"
msgstr "Geheugen" msgstr "Geheugen"
@@ -700,9 +732,11 @@ msgstr "Geheugengebruik van docker containers"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgid "Name" msgid "Name"
msgstr "Naam" msgstr "Naam"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Net" msgid "Net"
msgstr "Net" msgstr "Net"
@@ -727,6 +761,7 @@ msgstr "Netwerk eenheid"
msgid "No results found." msgid "No results found."
msgstr "Geen resultaten gevonden." msgstr "Geen resultaten gevonden."
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "No results." msgid "No results."
msgstr "Geen resultaten." msgstr "Geen resultaten."
@@ -768,6 +803,7 @@ msgstr "Of ga verder met"
msgid "Overwrite existing alerts" msgid "Overwrite existing alerts"
msgstr "Overschrijf bestaande waarschuwingen" msgstr "Overschrijf bestaande waarschuwingen"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "Page" msgid "Page"
msgstr "Pagina" msgstr "Pagina"
@@ -872,6 +908,11 @@ msgstr "Lezen"
msgid "Received" msgid "Received"
msgstr "Ontvangen" msgstr "Ontvangen"
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
msgid "Refresh"
msgstr "Vernieuwen"
#: src/components/login/login.tsx #: src/components/login/login.tsx
msgid "Request a one-time password" msgid "Request a one-time password"
msgstr "Eenmalig wachtwoord aanvragen" msgstr "Eenmalig wachtwoord aanvragen"
@@ -963,6 +1004,7 @@ msgstr "Sorteren op"
msgid "State" msgid "State"
msgstr "Status" msgstr "Status"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Status" msgid "Status"
@@ -977,6 +1019,7 @@ msgid "Swap Usage"
msgstr "Swap gebruik" msgstr "Swap gebruik"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
@@ -1150,6 +1193,10 @@ msgstr "Online"
msgid "Up ({upSystemsLength})" msgid "Up ({upSystemsLength})"
msgstr "Online ({upSystemsLength})" msgstr "Online ({upSystemsLength})"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Updated"
msgstr "Bijgewerkt"
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Upload" msgid "Upload"
msgstr "Uploaden" msgstr "Uploaden"

View File

@@ -8,15 +8,15 @@ msgstr ""
"Language: no\n" "Language: no\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-08-28 23:21\n" "PO-Revision-Date: 2025-10-13 17:31\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Norwegian\n" "Language-Team: Norwegian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: beszel\n" "X-Crowdin-Project: beszel\n"
"X-Crowdin-Project-ID: 733311\n" "X-Crowdin-Project-ID: 733311\n"
"X-Crowdin-Language: no\n" "X-Crowdin-Language: no\n"
"X-Crowdin-File: /main/beszel/site/src/locales/en/en.po\n" "X-Crowdin-File: /main/internal/site/src/locales/en/en.po\n"
"X-Crowdin-File-ID: 16\n" "X-Crowdin-File-ID: 32\n"
#. placeholder {0}: Math.trunc(system.info?.u / 86400) #. placeholder {0}: Math.trunc(system.info?.u / 86400)
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
@@ -31,13 +31,13 @@ msgstr "{0, plural, one {# time} other {# timer}}"
#. placeholder {0}: Math.trunc(system.info.u / 60) #. placeholder {0}: Math.trunc(system.info.u / 60)
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "{0, plural, one {# minute} few {# minutes} many {# minutes} other {# minutes}}" msgid "{0, plural, one {# minute} few {# minutes} many {# minutes} other {# minutes}}"
msgstr "" msgstr "{0, plural, one {# minutt} other {# minutter}}"
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length #. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
#. placeholder {1}: table.getFilteredRowModel().rows.length #. placeholder {1}: table.getFilteredRowModel().rows.length
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "{0} of {1} row(s) selected." msgid "{0} of {1} row(s) selected."
msgstr "" msgstr "{0} av {1} rad(er) valgt."
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 hour" msgid "1 hour"
@@ -46,7 +46,11 @@ msgstr "1 time"
#. Load average #. Load average
#: src/components/charts/load-average-chart.tsx #: src/components/charts/load-average-chart.tsx
msgid "1 min" msgid "1 min"
msgstr "" msgstr "1 min"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 minutt"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 week" msgid "1 week"
@@ -59,7 +63,7 @@ msgstr "12 timer"
#. Load average #. Load average
#: src/components/charts/load-average-chart.tsx #: src/components/charts/load-average-chart.tsx
msgid "15 min" msgid "15 min"
msgstr "" msgstr "15 min"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "24 hours" msgid "24 hours"
@@ -72,7 +76,7 @@ msgstr "30 dager"
#. Load average #. Load average
#: src/components/charts/load-average-chart.tsx #: src/components/charts/load-average-chart.tsx
msgid "5 min" msgid "5 min"
msgstr "" msgstr "5 min"
#. Table column #. Table column
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
@@ -83,9 +87,9 @@ msgstr "Handlinger"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Active" msgid "Active"
msgstr "" msgstr "Aktiv"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Active Alerts" msgid "Active Alerts"
msgstr "Aktive Alarmer" msgstr "Aktive Alarmer"
@@ -122,14 +126,22 @@ msgstr "Agent"
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Alert History" msgid "Alert History"
msgstr "" msgstr "Varselhistorikk"
#: src/components/alerts/alert-button.tsx #: src/components/alerts/alert-button.tsx
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
msgid "Alerts" msgid "Alerts"
msgstr "Alarmer" msgstr "Alarmer"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "Alle containere"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
#: src/components/command-palette.tsx
#: src/components/routes/home.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "All Systems" msgid "All Systems"
@@ -141,7 +153,7 @@ msgstr "Er du sikker på at du vil slette {name}?"
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Are you sure?" msgid "Are you sure?"
msgstr "" msgstr "Er du sikker?"
#: src/components/copy-to-clipboard.tsx #: src/components/copy-to-clipboard.tsx
msgid "Automatic copy requires a secure context." msgid "Automatic copy requires a secure context."
@@ -206,12 +218,12 @@ msgstr "Binær"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Bits (Kbps, Mbps, Gbps)" msgid "Bits (Kbps, Mbps, Gbps)"
msgstr "" msgstr "Bits (Kbps, Mbps, Gbps)"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Bytes (KB/s, MB/s, GB/s)" msgid "Bytes (KB/s, MB/s, GB/s)"
msgstr "" msgstr "Bytes (KB/s, MB/s, GB/s)"
#: src/components/charts/mem-chart.tsx #: src/components/charts/mem-chart.tsx
msgid "Cache / Buffers" msgid "Cache / Buffers"
@@ -228,11 +240,11 @@ msgstr "Advarsel - potensielt tap av data"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Celsius (°C)" msgid "Celsius (°C)"
msgstr "" msgstr "Celsius (°C)"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Change display units for metrics." msgid "Change display units for metrics."
msgstr "" msgstr "Endre måleenheter for målinger."
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Change general application options." msgid "Change general application options."
@@ -263,9 +275,13 @@ msgstr "Sjekk loggene for flere detaljer."
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Sjekk din meldingstjeneste" msgstr "Sjekk din meldingstjeneste"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "Klikk på en container for å se mer informasjon."
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information." msgid "Click on a system to view more information."
msgstr "" msgstr "Klikk på et system for å se mer informasjon."
#: src/components/ui/input-copy.tsx #: src/components/ui/input-copy.tsx
msgid "Click to copy" msgid "Click to copy"
@@ -285,9 +301,9 @@ msgstr "Konfigurer hvordan du vil motta alarmvarsler."
msgid "Confirm password" msgid "Confirm password"
msgstr "Bekreft passord" msgstr "Bekreft passord"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Connection is down" msgid "Connection is down"
msgstr "" msgstr "Tilkoblingen er nede"
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
@@ -313,7 +329,7 @@ msgstr "Kopier docker run"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables" msgctxt "Environment variables"
msgid "Copy env" msgid "Copy env"
msgstr "" msgstr "Kopier env"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Copy host" msgid "Copy host"
@@ -334,16 +350,17 @@ msgstr "Kopier tekst"
#: src/components/add-system.tsx #: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>." msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr "" msgstr "Kopier installasjonskommandoen for agenten nedenfor, eller registrer agenter automatisk med en <0>universal token</0>."
#: src/components/add-system.tsx #: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>." msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "" msgstr "Kopier <0>docker-compose.yml</0> for agenten nedenfor, eller registrer agenter automatisk med en <0>universal token</0>."
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML" msgid "Copy YAML"
msgstr "" msgstr "Kopier YAML"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "CPU" msgid "CPU"
msgstr "CPU" msgstr "CPU"
@@ -361,7 +378,7 @@ msgstr "Opprett konto"
#. Context: date created #. Context: date created
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
msgid "Created" msgid "Created"
msgstr "" msgstr "Opprettet"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Critical (%)" msgid "Critical (%)"
@@ -381,7 +398,6 @@ msgid "Current state"
msgstr "Nåværende tilstand" msgstr "Nåværende tilstand"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/home.tsx
msgid "Dashboard" msgid "Dashboard"
msgstr "Dashbord" msgstr "Dashbord"
@@ -396,7 +412,11 @@ msgstr "Slett"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint" msgid "Delete fingerprint"
msgstr "" msgstr "Slett fingeravtrykk"
#: src/components/containers-table/containers-table.tsx
msgid "Detail"
msgstr "Detalj"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
@@ -413,7 +433,7 @@ msgstr "Disk I/O"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Disk unit" msgid "Disk unit"
msgstr "" msgstr "Diskenhet"
#: src/components/charts/disk-chart.tsx #: src/components/charts/disk-chart.tsx
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
@@ -451,7 +471,7 @@ msgstr "Nede"
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Down ({downSystemsLength})" msgid "Down ({downSystemsLength})"
msgstr "" msgstr "Nede ({downSystemsLength})"
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Download" msgid "Download"
@@ -459,7 +479,7 @@ msgstr "Last ned"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
msgid "Duration" msgid "Duration"
msgstr "" msgstr "Varighet"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
@@ -504,7 +524,7 @@ msgstr "Feil"
#. placeholder {0}: alert.value #. placeholder {0}: alert.value
#. placeholder {1}: info.unit #. placeholder {1}: info.unit
#. placeholder {2}: alert.min #. placeholder {2}: alert.min
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}" msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "Overstiger {0}{1} {2, plural, one {det siste minuttet} other {de siste # minuttene}}" msgstr "Overstiger {0}{1} {2, plural, one {det siste minuttet} other {de siste # minuttene}}"
@@ -514,7 +534,7 @@ msgstr "Eksisterende systemer som ikke er er definert i <0>config.yml</0> vil bl
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Export" msgid "Export"
msgstr "" msgstr "Eksporter"
#: src/components/routes/settings/config-yaml.tsx #: src/components/routes/settings/config-yaml.tsx
msgid "Export configuration" msgid "Export configuration"
@@ -526,7 +546,7 @@ msgstr "Eksporter din nåværende systemkonfigurasjon"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Fahrenheit (°F)" msgid "Fahrenheit (°F)"
msgstr "" msgstr "Fahrenheit (°F)"
#: src/lib/api.ts #: src/lib/api.ts
msgid "Failed to authenticate" msgid "Failed to authenticate"
@@ -545,6 +565,7 @@ msgstr "Kunne ikke sende test-varsling"
msgid "Failed to update alert" msgid "Failed to update alert"
msgstr "Kunne ikke oppdatere alarm" msgstr "Kunne ikke oppdatere alarm"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
@@ -553,7 +574,7 @@ msgstr "Filter..."
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Fingerprint" msgid "Fingerprint"
msgstr "" msgstr "Fingeravtrykk"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}" msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
@@ -572,7 +593,7 @@ msgstr "FreeBSD kommando"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Full" msgid "Full"
msgstr "Full" msgstr "Fullt"
#. Context: General settings #. Context: General settings
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
@@ -592,6 +613,10 @@ msgstr "GPU Effektforbruk"
msgid "Grid" msgid "Grid"
msgstr "Rutenett" msgstr "Rutenett"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Health"
msgstr "Helse"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"
@@ -611,6 +636,11 @@ msgstr "Inaktiv"
msgid "If you've lost the password to your admin account, you may reset it using the following command." msgid "If you've lost the password to your admin account, you may reset it using the following command."
msgstr "Dersom du har mistet passordet til admin-kontoen kan du nullstille det med følgende kommando." msgstr "Dersom du har mistet passordet til admin-kontoen kan du nullstille det med følgende kommando."
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image"
msgstr "Image"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
msgid "Invalid email address." msgid "Invalid email address."
msgstr "Ugyldig e-postadresse." msgstr "Ugyldig e-postadresse."
@@ -626,28 +656,28 @@ msgstr "Språk"
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Layout" msgid "Layout"
msgstr "Layout" msgstr "Oppsett"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Load Average" msgid "Load Average"
msgstr "" msgstr "Snittbelastning Last"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Load Average 15m" msgid "Load Average 15m"
msgstr "" msgstr "Snittbelastning 15m"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Load Average 1m" msgid "Load Average 1m"
msgstr "" msgstr "Snittbelastning 1m"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Load Average 5m" msgid "Load Average 5m"
msgstr "" msgstr "Snittbelastning 5m"
#. Short label for load average #. Short label for load average
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Load Avg" msgid "Load Avg"
msgstr "" msgstr "Snittbelastning"
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Log Out" msgid "Log Out"
@@ -663,6 +693,7 @@ msgid "Login attempt failed"
msgstr "Innlogging mislyktes" msgstr "Innlogging mislyktes"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Logs" msgid "Logs"
msgstr "Logger" msgstr "Logger"
@@ -685,6 +716,7 @@ msgstr "Instruks for Manuell Installasjon"
msgid "Max 1 min" msgid "Max 1 min"
msgstr "Maks 1 min" msgstr "Maks 1 min"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Memory" msgid "Memory"
msgstr "Minne" msgstr "Minne"
@@ -700,9 +732,11 @@ msgstr "Minnebruk av docker-konteinere"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgid "Name" msgid "Name"
msgstr "Navn" msgstr "Navn"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Net" msgid "Net"
msgstr "Nett" msgstr "Nett"
@@ -721,15 +755,16 @@ msgstr "Nettverkstrafikk av eksterne nettverksgrensesnitt"
#. Context: Bytes or bits #. Context: Bytes or bits
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Network unit" msgid "Network unit"
msgstr "" msgstr "Nettverksenhet"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "No results found." msgid "No results found."
msgstr "Ingen resultater funnet." msgstr "Ingen resultater funnet."
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "No results." msgid "No results."
msgstr "" msgstr "Ingen resultater."
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
@@ -768,6 +803,7 @@ msgstr "Eller fortsett med"
msgid "Overwrite existing alerts" msgid "Overwrite existing alerts"
msgstr "Overskriv eksisterende alarmer" msgstr "Overskriv eksisterende alarmer"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "Page" msgid "Page"
msgstr "Side" msgstr "Side"
@@ -776,7 +812,7 @@ msgstr "Side"
#. placeholder {1}: table.getPageCount() #. placeholder {1}: table.getPageCount()
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Page {0} of {1}" msgid "Page {0} of {1}"
msgstr "" msgstr "Side {0} av {1}"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "Pages / Settings" msgid "Pages / Settings"
@@ -809,7 +845,7 @@ msgstr "Satt på Pause"
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Paused ({pausedSystemsLength})" msgid "Paused ({pausedSystemsLength})"
msgstr "" msgstr "Pauset ({pausedSystemsLength})"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered." msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
@@ -872,6 +908,11 @@ msgstr "Lesing"
msgid "Received" msgid "Received"
msgstr "Mottatt" msgstr "Mottatt"
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
msgid "Refresh"
msgstr "Oppdater"
#: src/components/login/login.tsx #: src/components/login/login.tsx
msgid "Request a one-time password" msgid "Request a one-time password"
msgstr "Be om engangspassord" msgstr "Be om engangspassord"
@@ -888,7 +929,7 @@ msgstr "Nullstill Passord"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Resolved" msgid "Resolved"
msgstr "" msgstr "Løst"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Resume" msgid "Resume"
@@ -896,11 +937,11 @@ msgstr "Gjenoppta"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token" msgid "Rotate token"
msgstr "" msgstr "Forny token"
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Rows per page" msgid "Rows per page"
msgstr "" msgstr "Rader per side"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications." msgid "Save address using enter key or comma. Leave blank to disable email notifications."
@@ -961,8 +1002,9 @@ msgstr "Sorter Etter"
#. Context: alert state (active or resolved) #. Context: alert state (active or resolved)
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
msgid "State" msgid "State"
msgstr "" msgstr "Tilstand"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Status" msgid "Status"
@@ -977,6 +1019,7 @@ msgid "Swap Usage"
msgstr "Swap-bruk" msgstr "Swap-bruk"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
@@ -985,7 +1028,7 @@ msgstr "System"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "System load averages over time" msgid "System load averages over time"
msgstr "" msgstr "Systembelastning gjennomsnitt over tid"
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Systems" msgid "Systems"
@@ -1011,7 +1054,7 @@ msgstr "Temperatur"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Temperature unit" msgid "Temperature unit"
msgstr "" msgstr "Temperaturenhet"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Temperatures of system sensors" msgid "Temperatures of system sensors"
@@ -1035,7 +1078,7 @@ msgstr "Denne handlingen kan ikke omgjøres. Dette vil slette alle poster for {n
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "This will permanently delete all selected records from the database." msgid "This will permanently delete all selected records from the database."
msgstr "" msgstr "Dette vil permanent slette alle valgte oppføringer fra databasen."
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Throughput of {extraFsName}" msgid "Throughput of {extraFsName}"
@@ -1065,21 +1108,21 @@ msgstr "Tema av/på"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token" msgid "Token"
msgstr "" msgstr "Token"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens & Fingerprints" msgid "Tokens & Fingerprints"
msgstr "" msgstr "Tokens & Fingeravtrykk"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection." msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr "" msgstr "Tokens lar agenter koble til og registrere seg selv. Fingeravtrykk er stabile identifikatorer som er unike for hvert system, og blir satt ved første tilkobling."
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub." msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "" msgstr "Tokens og fingeravtrykk blir brukt for å autentisere WebSocket-tilkoblinger til huben."
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Total data received for each interface" msgid "Total data received for each interface"
@@ -1091,15 +1134,15 @@ msgstr "Totalt sendt data for hvert grensesnitt"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Triggers when 1 minute load average exceeds a threshold" msgid "Triggers when 1 minute load average exceeds a threshold"
msgstr "" msgstr "Slår inn når gjennomsnittsbelastningen over 1 minutt overstiger en grenseverdi"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Triggers when 15 minute load average exceeds a threshold" msgid "Triggers when 15 minute load average exceeds a threshold"
msgstr "" msgstr "Slår inn når gjennomsnittsbelastningen over 15 minutter overstiger en grenseverdi"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Triggers when 5 minute load average exceeds a threshold" msgid "Triggers when 5 minute load average exceeds a threshold"
msgstr "" msgstr "Slår inn når gjennomsnittsbelastningen over 5 minutter overstiger en grenseverdi"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Triggers when any sensor exceeds a threshold" msgid "Triggers when any sensor exceeds a threshold"
@@ -1128,12 +1171,12 @@ msgstr "Slår inn når forbruk av hvilken som helst disk overstiger en grensever
#. Temperature / network units #. Temperature / network units
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Unit preferences" msgid "Unit preferences"
msgstr "" msgstr "Enhetspreferanser"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token" msgid "Universal token"
msgstr "" msgstr "Universal token"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
@@ -1148,7 +1191,11 @@ msgstr "Oppe"
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Up ({upSystemsLength})" msgid "Up ({upSystemsLength})"
msgstr "" msgstr "Oppe ({upSystemsLength})"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Updated"
msgstr "Oppdatert"
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Upload" msgid "Upload"
@@ -1181,7 +1228,7 @@ msgstr "Brukere"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
msgid "Value" msgid "Value"
msgstr "" msgstr "Verdi"
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "View" msgid "View"
@@ -1193,7 +1240,7 @@ msgstr "Se mer"
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "View your 200 most recent alerts." msgid "View your 200 most recent alerts."
msgstr "" msgstr "Vis de 200 siste varslene."
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Visible Fields" msgid "Visible Fields"
@@ -1221,7 +1268,7 @@ msgstr "Webhook / Push-varslinger"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart." msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr "" msgstr "Når aktivert lar denne tokenen agenter registrere seg selv uten å opprettes på systemet først. Utløper etter én time eller når huben starter på nytt."
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx

View File

@@ -48,6 +48,10 @@ msgstr "1 godzina"
msgid "1 min" msgid "1 min"
msgstr "1 min" msgstr "1 min"
#: src/lib/utils.ts
msgid "1 minute"
msgstr "1 minuta"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 week" msgid "1 week"
msgstr "1 tydzień" msgstr "1 tydzień"
@@ -85,7 +89,7 @@ msgstr "Akcje"
msgid "Active" msgid "Active"
msgstr "Aktywny" msgstr "Aktywny"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Active Alerts" msgid "Active Alerts"
msgstr "Aktywne alerty" msgstr "Aktywne alerty"
@@ -129,7 +133,15 @@ msgstr "Historia alertów"
msgid "Alerts" msgid "Alerts"
msgstr "Alerty" msgstr "Alerty"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "Wszystkie kontenery"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
#: src/components/command-palette.tsx
#: src/components/routes/home.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "All Systems" msgid "All Systems"
@@ -263,6 +275,10 @@ msgstr "Sprawdź logi, aby uzyskać więcej informacji."
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Sprawdź swój serwis powiadomień" msgstr "Sprawdź swój serwis powiadomień"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "Kliknij na kontener, aby wyświetlić więcej informacji."
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information." msgid "Click on a system to view more information."
msgstr "Kliknij na system, aby zobaczyć więcej informacji." msgstr "Kliknij na system, aby zobaczyć więcej informacji."
@@ -285,7 +301,7 @@ msgstr "Skonfiguruj sposób otrzymywania powiadomień."
msgid "Confirm password" msgid "Confirm password"
msgstr "Potwierdź hasło" msgstr "Potwierdź hasło"
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Connection is down" msgid "Connection is down"
msgstr "Brak połączenia" msgstr "Brak połączenia"
@@ -344,6 +360,7 @@ msgstr "Skopiuj poniżej zawartość pliku <0>docker-compose.yml</0> dla agenta
msgid "Copy YAML" msgid "Copy YAML"
msgstr "Kopiuj YAML" msgstr "Kopiuj YAML"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "CPU" msgid "CPU"
msgstr "Procesor" msgstr "Procesor"
@@ -381,7 +398,6 @@ msgid "Current state"
msgstr "Aktualny stan" msgstr "Aktualny stan"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/home.tsx
msgid "Dashboard" msgid "Dashboard"
msgstr "Panel kontrolny" msgstr "Panel kontrolny"
@@ -398,6 +414,10 @@ msgstr "Usuń"
msgid "Delete fingerprint" msgid "Delete fingerprint"
msgstr "Usuń odcisk palca" msgstr "Usuń odcisk palca"
#: src/components/containers-table/containers-table.tsx
msgid "Detail"
msgstr "Szczegół"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Discharging" msgid "Discharging"
@@ -504,7 +524,7 @@ msgstr "Błąd"
#. placeholder {0}: alert.value #. placeholder {0}: alert.value
#. placeholder {1}: info.unit #. placeholder {1}: info.unit
#. placeholder {2}: alert.min #. placeholder {2}: alert.min
#: src/components/routes/home.tsx #: src/components/active-alerts.tsx
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}" msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "Przekracza {0}{1} w ciągu ostatnich {2, plural, one {# minuty} other {# minut}}" msgstr "Przekracza {0}{1} w ciągu ostatnich {2, plural, one {# minuty} other {# minut}}"
@@ -545,6 +565,7 @@ msgstr "Nie udało się wysłać testowego powiadomienia"
msgid "Failed to update alert" msgid "Failed to update alert"
msgstr "Nie udało się zaktualizować powiadomienia" msgstr "Nie udało się zaktualizować powiadomienia"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
@@ -592,6 +613,10 @@ msgstr "Moc GPU"
msgid "Grid" msgid "Grid"
msgstr "Siatka" msgstr "Siatka"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Health"
msgstr "Zdrowie"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"
@@ -611,6 +636,11 @@ msgstr "Bezczynna"
msgid "If you've lost the password to your admin account, you may reset it using the following command." msgid "If you've lost the password to your admin account, you may reset it using the following command."
msgstr "Jeśli utraciłeś hasło do swojego konta administratora, możesz je zresetować, używając następującego polecenia." msgstr "Jeśli utraciłeś hasło do swojego konta administratora, możesz je zresetować, używając następującego polecenia."
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image"
msgstr "Obraz"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
msgid "Invalid email address." msgid "Invalid email address."
msgstr "Nieprawidłowy adres e-mail." msgstr "Nieprawidłowy adres e-mail."
@@ -663,6 +693,7 @@ msgid "Login attempt failed"
msgstr "Próba logowania nie powiodła się" msgstr "Próba logowania nie powiodła się"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Logs" msgid "Logs"
msgstr "Logi" msgstr "Logi"
@@ -685,6 +716,7 @@ msgstr "Instrukcja ręcznej konfiguracji"
msgid "Max 1 min" msgid "Max 1 min"
msgstr "Maks. 1 min" msgstr "Maks. 1 min"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Memory" msgid "Memory"
msgstr "Pamięć" msgstr "Pamięć"
@@ -700,9 +732,11 @@ msgstr "Użycie pamięci przez kontenery Docker."
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgid "Name" msgid "Name"
msgstr "Nazwa" msgstr "Nazwa"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Net" msgid "Net"
msgstr "Sieć" msgstr "Sieć"
@@ -727,6 +761,7 @@ msgstr "Jednostka sieciowa"
msgid "No results found." msgid "No results found."
msgstr "Brak wyników." msgstr "Brak wyników."
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
msgid "No results." msgid "No results."
msgstr "Brak wyników." msgstr "Brak wyników."
@@ -768,6 +803,7 @@ msgstr "Lub kontynuuj z"
msgid "Overwrite existing alerts" msgid "Overwrite existing alerts"
msgstr "Nadpisz istniejące alerty" msgstr "Nadpisz istniejące alerty"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "Page" msgid "Page"
msgstr "Strona" msgstr "Strona"
@@ -872,6 +908,11 @@ msgstr "Odczyt"
msgid "Received" msgid "Received"
msgstr "Otrzymane" msgstr "Otrzymane"
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
msgid "Refresh"
msgstr "Odśwież"
#: src/components/login/login.tsx #: src/components/login/login.tsx
msgid "Request a one-time password" msgid "Request a one-time password"
msgstr "Zażądaj jednorazowego hasła" msgstr "Zażądaj jednorazowego hasła"
@@ -963,6 +1004,7 @@ msgstr "Sortuj według"
msgid "State" msgid "State"
msgstr "Stan" msgstr "Stan"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Status" msgid "Status"
@@ -977,6 +1019,7 @@ msgid "Swap Usage"
msgstr "Użycie pamięci wymiany" msgstr "Użycie pamięci wymiany"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
@@ -1150,6 +1193,10 @@ msgstr "Działa"
msgid "Up ({upSystemsLength})" msgid "Up ({upSystemsLength})"
msgstr "Działa ({upSystemsLength})" msgstr "Działa ({upSystemsLength})"
#: src/components/containers-table/containers-table-columns.tsx
msgid "Updated"
msgstr "Zaktualizowano"
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Upload" msgid "Upload"
msgstr "Wysyłanie" msgstr "Wysyłanie"

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