Compare commits

..

40 Commits

Author SHA1 Message Date
henrygd
8e71c8ad97 hub: don't retry update check within cache time if request fails 2026-03-22 18:18:31 -04:00
henrygd
97f3b8c61f test(hub): add tests for update endpoint 2026-03-22 17:56:27 -04:00
henrygd
0b0b5d16d7 refactor(hub): move api related code from hub.go to api.go 2026-03-22 17:31:06 -04:00
Sven van Ginkel
b2fd50211e feat(hub): show "update available" notification in hub web UI (#1830)
* refactor, make opt-in, and deprecate /api/beszel/getkey in favor of /api/beszel/info

---------

Co-authored-by: henrygd <hank@henrygd.me>
2026-03-22 17:23:54 -04:00
Sven van Ginkel
c159eaacd1 fix light flashes when refresh in dark mode (#1832) 2026-03-22 13:35:43 -04:00
Sven van Ginkel
441bdd2ec5 fix: correct DST offset handling in daily quiet hours (#1827) 2026-03-22 12:50:36 -04:00
henrygd
ff36138229 fix(hub): add onAfterBootstrapAndMigrations to properly queue fns after migrations
also remove error return from NewHub and improve comments in hub.go
2026-03-20 19:32:59 -04:00
henrygd
be70840609 test: update tests that use os.Setenv to t.Setenv 2026-03-20 15:00:28 -04:00
henrygd
565162ef5f refactor(hub): harden/enforce pb api rules and add tests
- separate collection related code from hub.go
- ensure hub is bootstrapped and collections updated automatically when
calling NewHub
2026-03-20 14:39:05 -04:00
henrygd
adbfe7cfb7 chore: upgrade action and go versions in vulncheck workflow 2026-03-19 11:36:10 -04:00
henrygd
1ff7762c80 test(hub): add status alert tests covering multiple users 2026-03-18 17:44:34 -04:00
henrygd
0ab8a606e0 fix(ui): hooks bug in all systems table disk cell 2026-03-18 17:17:58 -04:00
henrygd
e4e0affbc1 test(hub): add additional tests for all system alerts 2026-03-17 18:48:54 -04:00
henrygd
c3a0e645ee refactor: variable renaming in alerts package 2026-03-17 18:44:46 -04:00
henrygd
c6c3950fb0 refactor: add alertsCache to maintain active alert data in memory 2026-03-17 18:32:57 -04:00
henrygd
48ddc96a0d systemd: allow timer monitoring with SERVICE_PATTERNS (#1820) 2026-03-17 15:11:44 -04:00
henrygd
704cb86de8 refactor: change ExpiryMap.store to be a pointer 2026-03-16 17:44:45 -04:00
henrygd
2854ce882f fix(ui): centralize default layout width and update default setting 2026-03-16 15:23:32 -04:00
henrygd
ed50367f70 fix(agent): add fallback for podman container health (#1475) 2026-03-15 17:59:59 -04:00
henrygd
4ebe869591 ui: virtualize smart table 2026-03-15 15:20:07 -04:00
henrygd
c9bbbe91f2 ui: improve table col widths and hide text showing above header 2026-03-15 14:59:25 -04:00
henrygd
5bfe4f6970 agent: include ip in container port if not 0.0.0.0 or :: 2026-03-15 14:58:21 -04:00
henrygd
380d2b1091 add ports column to containers table (#1481) 2026-03-14 19:29:39 -04:00
henrygd
a7f99e7a8c agent: support new Docker API Health field (#1475) 2026-03-14 15:26:44 -04:00
henrygd
bd94a9d142 agent: improve disk discovery / IO mapping and add tests (#1811) 2026-03-13 16:03:27 -04:00
henrygd
8e2316f845 refactor: simplify/improve status alert handling (#1519)
also adds new functionality to restore any pending down alerts
that were lost by hub restart before creation
2026-03-12 15:53:40 -04:00
Sven van Ginkel
0d3dfcb207 fix(hub): check if status alert is triggered before sending up alert (#1806) 2026-03-12 13:38:42 -04:00
henrygd
b386ce5190 hub: add ExpiryMap.UpdateExpiration and sync SMART fetch intervals (#1800)
- Update smartFetchMap expiration when agent smart interval changes
- Prevent background SMART fetching before initial system details are
loaded
- Add buffer to SMART fetch timing check
- Get rid of unnecessary pointers in expirymap
2026-03-11 16:25:52 -04:00
henrygd
e527534016 ensure deprecated system fields are migrated to newer structures
also removes refs to legacy load avg fields (l1, l5, l15) that were
around for a very short period
2026-03-10 18:46:57 -04:00
Victor Eduardo
ec7ad632a9 fix: Use historical records to average disk usage for extra disk alerts (#1801)
- Introduced a new test file `alerts_disk_test.go` to validate the behavior of disk alerts using historical data for extra filesystems.
- Enhanced the `HandleSystemAlerts` function to correctly calculate disk usage for extra filesystems based on historical records.
- Updated the `SystemAlertStats` struct to include `ExtraFs` for tracking additional filesystem statistics.
2026-03-09 18:32:35 -04:00
VACInc
963fce5a33 agent: mark mdraid rebuild as warning, not failed (#1797) 2026-03-09 17:54:53 -04:00
Sven van Ginkel
d38c0da06d fix: bypass NIC auto-filter when interface is explicitly whitelisted via NICS (#1805)
Co-authored-by: henrygd <hank@henrygd.me>
2026-03-09 17:47:59 -04:00
henrygd
cae6ac4626 update go version to 1.26.1 2026-03-09 16:10:38 -04:00
henrygd
6b1ff264f2 gpu(amd): add workaround for misreported sysfs filesize (#1799) 2026-03-09 14:53:52 -04:00
henrygd
35d0e792ad refactor(expirymap): optimize performance and add StopCleaner method 2026-03-08 19:09:41 -04:00
henrygd
654cd06b19 respect SMART_INTERVAL across agent reconnects (#1800)
Move tracking of the last SMART data fetch from individual System
instances to the SystemManager using a TTL-based ExpiryMap.

This ensures that the SMART_INTERVAL is respected even if an
agent connection is dropped and re-established, preventing
redundant data collection on every reconnect.
2026-03-08 19:03:50 -04:00
henrygd
5e1b028130 refactor(smart): improve perf by skipping ata_device_statistics parsing if unnecessary 2026-03-08 15:19:50 -04:00
henrygd
638e7dc12a fix(smart): handle negative ATA device statistics values (#1791) 2026-03-08 13:34:16 -04:00
henrygd
73c262455d refactor(agent): move GetEnv to utils package 2026-03-07 14:12:17 -05:00
henrygd
0c4d2edd45 refactor(agent): add utils package; rm utils.go and fs_utils.go 2026-03-07 13:50:49 -05:00
96 changed files with 6746 additions and 4133 deletions

View File

@@ -19,11 +19,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: 1.25.x
go-version: 1.26.x
# cached: false
- name: Get official govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest

View File

@@ -6,7 +6,6 @@ package agent
import (
"log/slog"
"os"
"strings"
"sync"
"time"
@@ -14,6 +13,7 @@ import (
"github.com/gliderlabs/ssh"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/agent/deltatracker"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/system"
gossh "golang.org/x/crypto/ssh"
@@ -33,7 +33,6 @@ type Agent struct {
netIoStats map[uint16]system.NetIoStats // Keeps track of bandwidth usage per cache interval
netInterfaceDeltaTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] // Per-cache-time NIC delta trackers
dockerManager *dockerManager // Manages Docker API requests
pveManager *pveManager // Manages Proxmox VE API requests
sensorConfig *SensorConfig // Sensors config
systemInfo system.Info // Host system info (dynamic)
systemDetails system.Details // Host system details (static, once-per-connection)
@@ -69,11 +68,11 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
slog.Info("Data directory", "path", agent.dataDir)
}
agent.memCalc, _ = GetEnv("MEM_CALC")
agent.memCalc, _ = utils.GetEnv("MEM_CALC")
agent.sensorConfig = agent.newSensorConfig()
// Parse disk usage cache duration (e.g., "15m", "1h") to avoid waking sleeping disks
if diskUsageCache, exists := GetEnv("DISK_USAGE_CACHE"); exists {
if diskUsageCache, exists := utils.GetEnv("DISK_USAGE_CACHE"); exists {
if duration, err := time.ParseDuration(diskUsageCache); err == nil {
agent.diskUsageCacheDuration = duration
slog.Info("DISK_USAGE_CACHE", "duration", duration)
@@ -83,7 +82,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
}
// Set up slog with a log level determined by the LOG_LEVEL env var
if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists {
if logLevelStr, exists := utils.GetEnv("LOG_LEVEL"); exists {
switch strings.ToLower(logLevelStr) {
case "debug":
agent.debug = true
@@ -100,14 +99,11 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
// initialize docker manager
agent.dockerManager = newDockerManager()
// initialize pve manager
agent.pveManager = newPVEManager()
// initialize system info
agent.refreshSystemDetails()
// SMART_INTERVAL env var to update smart data at this interval
if smartIntervalEnv, exists := GetEnv("SMART_INTERVAL"); exists {
if smartIntervalEnv, exists := utils.GetEnv("SMART_INTERVAL"); exists {
if duration, err := time.ParseDuration(smartIntervalEnv); err == nil && duration > 0 {
agent.systemDetails.SmartInterval = duration
slog.Info("SMART_INTERVAL", "duration", duration)
@@ -152,15 +148,6 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
return agent, nil
}
// GetEnv retrieves an environment variable with a "BESZEL_AGENT_" prefix, or falls back to the unprefixed key.
func GetEnv(key string) (value string, exists bool) {
if value, exists = os.LookupEnv("BESZEL_AGENT_" + key); exists {
return value, exists
}
// Fallback to the old unprefixed key
return os.LookupEnv(key)
}
func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedData {
a.Lock()
defer a.Unlock()
@@ -193,15 +180,6 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
}
}
if a.pveManager != nil {
if pveStats, err := a.pveManager.getPVEStats(); err == nil {
data.PVEStats = pveStats
slog.Debug("PVE", "data", data.PVEStats)
} else {
slog.Debug("PVE", "err", err)
}
}
// skip updating systemd services if cache time is not the default 60sec interval
if a.systemdManager != nil && cacheTimeMs == 60_000 {
totalCount := uint16(a.systemdManager.getServiceStatsCount())
@@ -226,7 +204,7 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
data.Stats.ExtraFs[key] = stats
// Add percentages to Info struct for dashboard
if stats.DiskTotal > 0 {
pct := twoDecimals((stats.DiskUsed / stats.DiskTotal) * 100)
pct := utils.TwoDecimals((stats.DiskUsed / stats.DiskTotal) * 100)
data.Info.ExtraFsPct[key] = pct
}
}

View File

@@ -14,10 +14,6 @@ import (
)
func createTestCacheData() *system.CombinedData {
var stats = container.Stats{}
stats.Name = "test-container"
stats.Cpu = 10.5
stats.Mem = 1073741824 // 1GB
return &system.CombinedData{
Stats: system.Stats{
Cpu: 50.5,
@@ -28,7 +24,10 @@ func createTestCacheData() *system.CombinedData {
AgentVersion: "0.12.0",
},
Containers: []*container.Stats{
&stats,
{
Name: "test-container",
Cpu: 25.0,
},
},
}
}

View File

@@ -14,6 +14,7 @@ import (
"time"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/common"
"github.com/fxamacker/cbor/v2"
@@ -43,7 +44,7 @@ type WebSocketClient struct {
// newWebSocketClient creates a new WebSocket client for the given agent.
// It reads configuration from environment variables and validates the hub URL.
func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
hubURLStr, exists := GetEnv("HUB_URL")
hubURLStr, exists := utils.GetEnv("HUB_URL")
if !exists {
return nil, errors.New("HUB_URL environment variable not set")
}
@@ -72,12 +73,12 @@ func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
// If neither is set, it returns an error.
func getToken() (string, error) {
// get token from env var
token, _ := GetEnv("TOKEN")
token, _ := utils.GetEnv("TOKEN")
if token != "" {
return token, nil
}
// get token from file
tokenFile, _ := GetEnv("TOKEN_FILE")
tokenFile, _ := utils.GetEnv("TOKEN_FILE")
if tokenFile == "" {
return "", errors.New("must set TOKEN or TOKEN_FILE")
}
@@ -197,7 +198,7 @@ func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.R
}
if authRequest.NeedSysInfo {
response.Name, _ = GetEnv("SYSTEM_NAME")
response.Name, _ = utils.GetEnv("SYSTEM_NAME")
response.Hostname = client.agent.systemDetails.Hostname
serverAddr := client.agent.connectionManager.serverOptions.Addr
_, response.Port, _ = net.SplitHostPort(serverAddr)

View File

@@ -70,19 +70,11 @@ func TestNewWebSocketClient(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
// Set up environment
if tc.hubURL != "" {
os.Setenv("BESZEL_AGENT_HUB_URL", tc.hubURL)
} else {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
t.Setenv("BESZEL_AGENT_HUB_URL", tc.hubURL)
}
if tc.token != "" {
os.Setenv("BESZEL_AGENT_TOKEN", tc.token)
} else {
os.Unsetenv("BESZEL_AGENT_TOKEN")
t.Setenv("BESZEL_AGENT_TOKEN", tc.token)
}
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
client, err := newWebSocketClient(agent)
@@ -138,12 +130,8 @@ func TestWebSocketClient_GetOptions(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Set up environment
os.Setenv("BESZEL_AGENT_HUB_URL", tc.inputURL)
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
t.Setenv("BESZEL_AGENT_HUB_URL", tc.inputURL)
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
client, err := newWebSocketClient(agent)
require.NoError(t, err)
@@ -185,12 +173,8 @@ func TestWebSocketClient_VerifySignature(t *testing.T) {
require.NoError(t, err)
// Set up environment
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
client, err := newWebSocketClient(agent)
require.NoError(t, err)
@@ -258,12 +242,8 @@ func TestWebSocketClient_HandleHubRequest(t *testing.T) {
agent := createTestAgent(t)
// Set up environment
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
client, err := newWebSocketClient(agent)
require.NoError(t, err)
@@ -350,13 +330,8 @@ func TestGetUserAgent(t *testing.T) {
func TestWebSocketClient_Close(t *testing.T) {
agent := createTestAgent(t)
// Set up environment
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
client, err := newWebSocketClient(agent)
require.NoError(t, err)
@@ -371,13 +346,8 @@ func TestWebSocketClient_Close(t *testing.T) {
func TestWebSocketClient_ConnectRateLimit(t *testing.T) {
agent := createTestAgent(t)
// Set up environment
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
client, err := newWebSocketClient(agent)
require.NoError(t, err)
@@ -393,20 +363,10 @@ func TestWebSocketClient_ConnectRateLimit(t *testing.T) {
// TestGetToken tests the getToken function with various scenarios
func TestGetToken(t *testing.T) {
unsetEnvVars := func() {
os.Unsetenv("BESZEL_AGENT_TOKEN")
os.Unsetenv("TOKEN")
os.Unsetenv("BESZEL_AGENT_TOKEN_FILE")
os.Unsetenv("TOKEN_FILE")
}
t.Run("token from TOKEN environment variable", func(t *testing.T) {
unsetEnvVars()
// Set TOKEN env var
expectedToken := "test-token-from-env"
os.Setenv("TOKEN", expectedToken)
defer os.Unsetenv("TOKEN")
t.Setenv("TOKEN", expectedToken)
token, err := getToken()
assert.NoError(t, err)
@@ -414,12 +374,9 @@ func TestGetToken(t *testing.T) {
})
t.Run("token from BESZEL_AGENT_TOKEN environment variable", func(t *testing.T) {
unsetEnvVars()
// Set BESZEL_AGENT_TOKEN env var (should take precedence)
expectedToken := "test-token-from-beszel-env"
os.Setenv("BESZEL_AGENT_TOKEN", expectedToken)
defer os.Unsetenv("BESZEL_AGENT_TOKEN")
t.Setenv("BESZEL_AGENT_TOKEN", expectedToken)
token, err := getToken()
assert.NoError(t, err)
@@ -427,8 +384,6 @@ func TestGetToken(t *testing.T) {
})
t.Run("token from TOKEN_FILE", func(t *testing.T) {
unsetEnvVars()
// Create a temporary token file
expectedToken := "test-token-from-file"
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
@@ -440,8 +395,7 @@ func TestGetToken(t *testing.T) {
tokenFile.Close()
// Set TOKEN_FILE env var
os.Setenv("TOKEN_FILE", tokenFile.Name())
defer os.Unsetenv("TOKEN_FILE")
t.Setenv("TOKEN_FILE", tokenFile.Name())
token, err := getToken()
assert.NoError(t, err)
@@ -449,8 +403,6 @@ func TestGetToken(t *testing.T) {
})
t.Run("token from BESZEL_AGENT_TOKEN_FILE", func(t *testing.T) {
unsetEnvVars()
// Create a temporary token file
expectedToken := "test-token-from-beszel-file"
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
@@ -462,8 +414,7 @@ func TestGetToken(t *testing.T) {
tokenFile.Close()
// Set BESZEL_AGENT_TOKEN_FILE env var (should take precedence)
os.Setenv("BESZEL_AGENT_TOKEN_FILE", tokenFile.Name())
defer os.Unsetenv("BESZEL_AGENT_TOKEN_FILE")
t.Setenv("BESZEL_AGENT_TOKEN_FILE", tokenFile.Name())
token, err := getToken()
assert.NoError(t, err)
@@ -471,8 +422,6 @@ func TestGetToken(t *testing.T) {
})
t.Run("TOKEN takes precedence over TOKEN_FILE", func(t *testing.T) {
unsetEnvVars()
// Create a temporary token file
fileToken := "token-from-file"
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
@@ -485,12 +434,8 @@ func TestGetToken(t *testing.T) {
// Set both TOKEN and TOKEN_FILE
envToken := "token-from-env"
os.Setenv("TOKEN", envToken)
os.Setenv("TOKEN_FILE", tokenFile.Name())
defer func() {
os.Unsetenv("TOKEN")
os.Unsetenv("TOKEN_FILE")
}()
t.Setenv("TOKEN", envToken)
t.Setenv("TOKEN_FILE", tokenFile.Name())
token, err := getToken()
assert.NoError(t, err)
@@ -498,7 +443,10 @@ func TestGetToken(t *testing.T) {
})
t.Run("error when neither TOKEN nor TOKEN_FILE is set", func(t *testing.T) {
unsetEnvVars()
t.Setenv("BESZEL_AGENT_TOKEN", "")
t.Setenv("TOKEN", "")
t.Setenv("BESZEL_AGENT_TOKEN_FILE", "")
t.Setenv("TOKEN_FILE", "")
token, err := getToken()
assert.Error(t, err)
@@ -507,11 +455,8 @@ func TestGetToken(t *testing.T) {
})
t.Run("error when TOKEN_FILE points to non-existent file", func(t *testing.T) {
unsetEnvVars()
// Set TOKEN_FILE to a non-existent file
os.Setenv("TOKEN_FILE", "/non/existent/file.txt")
defer os.Unsetenv("TOKEN_FILE")
t.Setenv("TOKEN_FILE", "/non/existent/file.txt")
token, err := getToken()
assert.Error(t, err)
@@ -520,8 +465,6 @@ func TestGetToken(t *testing.T) {
})
t.Run("handles empty token file", func(t *testing.T) {
unsetEnvVars()
// Create an empty token file
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
require.NoError(t, err)
@@ -529,8 +472,7 @@ func TestGetToken(t *testing.T) {
tokenFile.Close()
// Set TOKEN_FILE env var
os.Setenv("TOKEN_FILE", tokenFile.Name())
defer os.Unsetenv("TOKEN_FILE")
t.Setenv("TOKEN_FILE", tokenFile.Name())
token, err := getToken()
assert.NoError(t, err)
@@ -538,8 +480,6 @@ func TestGetToken(t *testing.T) {
})
t.Run("strips whitespace from TOKEN_FILE", func(t *testing.T) {
unsetEnvVars()
tokenWithWhitespace := " test-token-with-whitespace \n\t"
expectedToken := "test-token-with-whitespace"
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
@@ -550,8 +490,7 @@ func TestGetToken(t *testing.T) {
require.NoError(t, err)
tokenFile.Close()
os.Setenv("TOKEN_FILE", tokenFile.Name())
defer os.Unsetenv("TOKEN_FILE")
t.Setenv("TOKEN_FILE", tokenFile.Name())
token, err := getToken()
assert.NoError(t, err)

View File

@@ -7,7 +7,6 @@ import (
"fmt"
"net"
"net/url"
"os"
"testing"
"time"
@@ -183,10 +182,6 @@ func TestConnectionManager_TickerManagement(t *testing.T) {
// TestConnectionManager_WebSocketConnectionFlow tests WebSocket connection logic
func TestConnectionManager_WebSocketConnectionFlow(t *testing.T) {
if testing.Short() {
t.Skip("Skipping WebSocket connection test in short mode")
}
agent := createTestAgent(t)
cm := agent.connectionManager
@@ -196,19 +191,18 @@ func TestConnectionManager_WebSocketConnectionFlow(t *testing.T) {
assert.Equal(t, Disconnected, cm.State, "State should remain Disconnected after failed connection")
// Test with invalid URL
os.Setenv("BESZEL_AGENT_HUB_URL", "invalid-url")
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
// Test with missing token
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
os.Unsetenv("BESZEL_AGENT_TOKEN")
t.Setenv("BESZEL_AGENT_HUB_URL", "1,33%")
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
_, err2 := newWebSocketClient(agent)
assert.Error(t, err2, "WebSocket client creation should fail without token")
assert.Error(t, err2, "WebSocket client creation should fail with invalid URL")
// Test with missing token
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
t.Setenv("BESZEL_AGENT_TOKEN", "")
_, err3 := newWebSocketClient(agent)
assert.Error(t, err3, "WebSocket client creation should fail without token")
}
// TestConnectionManager_ReconnectionLogic tests reconnection prevention logic
@@ -234,12 +228,8 @@ func TestConnectionManager_ConnectWithRateLimit(t *testing.T) {
cm := agent.connectionManager
// Set up environment for WebSocket client creation
os.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080")
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
t.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080")
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
// Create WebSocket client
wsClient, err := newWebSocketClient(agent)
@@ -285,12 +275,8 @@ func TestConnectionManager_CloseWebSocket(t *testing.T) {
}, "Should not panic when closing nil WebSocket client")
// Set up environment and create WebSocket client
os.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080")
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
t.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080")
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
wsClient, err := newWebSocketClient(agent)
require.NoError(t, err)

View File

@@ -6,6 +6,8 @@ import (
"os"
"path/filepath"
"runtime"
"github.com/henrygd/beszel/agent/utils"
)
// GetDataDir returns the path to the data directory for the agent and an error
@@ -16,7 +18,7 @@ func GetDataDir(dataDirs ...string) (string, error) {
return testDataDirs(dataDirs)
}
dataDir, _ := GetEnv("DATA_DIR")
dataDir, _ := utils.GetEnv("DATA_DIR")
if dataDir != "" {
dataDirs = append(dataDirs, dataDir)
}

View File

@@ -39,17 +39,7 @@ func TestGetDataDir(t *testing.T) {
t.Run("DATA_DIR environment variable", func(t *testing.T) {
tempDir := t.TempDir()
// Set environment variable
oldValue := os.Getenv("DATA_DIR")
defer func() {
if oldValue == "" {
os.Unsetenv("BESZEL_AGENT_DATA_DIR")
} else {
os.Setenv("BESZEL_AGENT_DATA_DIR", oldValue)
}
}()
os.Setenv("BESZEL_AGENT_DATA_DIR", tempDir)
t.Setenv("BESZEL_AGENT_DATA_DIR", tempDir)
result, err := GetDataDir()
require.NoError(t, err)
@@ -65,17 +55,6 @@ func TestGetDataDir(t *testing.T) {
// Test fallback behavior (empty dataDir, no env var)
t.Run("fallback to default directories", func(t *testing.T) {
// Clear DATA_DIR environment variable
oldValue := os.Getenv("DATA_DIR")
defer func() {
if oldValue == "" {
os.Unsetenv("DATA_DIR")
} else {
os.Setenv("DATA_DIR", oldValue)
}
}()
os.Unsetenv("DATA_DIR")
// This will try platform-specific defaults, which may or may not work
// We're mainly testing that it doesn't panic and returns some result
result, err := GetDataDir()

View File

@@ -8,11 +8,31 @@ import (
"strings"
"time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/disk"
)
// fsRegistrationContext holds the shared lookup state needed to resolve a
// filesystem into the tracked fsStats key and metadata.
type fsRegistrationContext struct {
filesystem string // value of optional FILESYSTEM env var
isWindows bool
efPath string // path to extra filesystems (default "/extra-filesystems")
diskIoCounters map[string]disk.IOCountersStat
}
// diskDiscovery groups the transient state for a single initializeDiskInfo run so
// helper methods can share the same partitions, mount paths, and lookup functions
type diskDiscovery struct {
agent *Agent
rootMountPoint string
partitions []disk.PartitionStat
usageFn func(string) (*disk.UsageStat, error)
ctx fsRegistrationContext
}
// 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) {
@@ -26,19 +46,230 @@ func parseFilesystemEntry(entry string) (device, customName string) {
return device, customName
}
// extraFilesystemPartitionInfo derives the I/O device and optional display name
// for a mounted /extra-filesystems partition. Prefer the partition device reported
// by the system and only use the folder name for custom naming metadata.
func extraFilesystemPartitionInfo(p disk.PartitionStat) (device, customName string) {
device = strings.TrimSpace(p.Device)
folderDevice, customName := parseFilesystemEntry(filepath.Base(p.Mountpoint))
if device == "" {
device = folderDevice
}
return device, customName
}
func isDockerSpecialMountpoint(mountpoint string) bool {
switch mountpoint {
case "/etc/hosts", "/etc/resolv.conf", "/etc/hostname":
return true
default:
}
return false
}
// registerFilesystemStats resolves the tracked key and stats payload for a
// filesystem before it is inserted into fsStats.
func registerFilesystemStats(existing map[string]*system.FsStats, device, mountpoint string, root bool, customName string, ctx fsRegistrationContext) (string, *system.FsStats, bool) {
key := device
if !ctx.isWindows {
key = filepath.Base(device)
}
if root {
// Try to map root device to a diskIoCounters entry. First checks for an
// exact key match, then uses findIoDevice for normalized / prefix-based
// matching (e.g. nda0p2 -> nda0), and finally falls back to FILESYSTEM.
if _, ioMatch := ctx.diskIoCounters[key]; !ioMatch {
if matchedKey, match := findIoDevice(key, ctx.diskIoCounters); match {
key = matchedKey
} else if ctx.filesystem != "" {
if matchedKey, match := findIoDevice(ctx.filesystem, ctx.diskIoCounters); match {
key = matchedKey
}
}
if _, ioMatch = ctx.diskIoCounters[key]; !ioMatch {
slog.Warn("Root I/O unmapped; set FILESYSTEM", "device", device, "mountpoint", mountpoint)
}
}
} else {
// Check if non-root has diskstats and prefer the folder device for
// /extra-filesystems mounts when the discovered partition device is a
// mapper path (e.g. luks UUID) that obscures the underlying block device.
if _, ioMatch := ctx.diskIoCounters[key]; !ioMatch {
if strings.HasPrefix(mountpoint, ctx.efPath) {
folderDevice, _ := parseFilesystemEntry(filepath.Base(mountpoint))
if folderDevice != "" {
if matchedKey, match := findIoDevice(folderDevice, ctx.diskIoCounters); match {
key = matchedKey
}
}
}
if _, ioMatch = ctx.diskIoCounters[key]; !ioMatch {
if matchedKey, match := findIoDevice(key, ctx.diskIoCounters); match {
key = matchedKey
}
}
}
}
if _, exists := existing[key]; exists {
return "", nil, false
}
fsStats := &system.FsStats{Root: root, Mountpoint: mountpoint}
if customName != "" {
fsStats.Name = customName
}
return key, fsStats, true
}
// addFsStat inserts a discovered filesystem if it resolves to a new tracking
// key. The key selection itself lives in buildFsStatRegistration so that logic
// can stay directly unit-tested.
func (d *diskDiscovery) addFsStat(device, mountpoint string, root bool, customName string) {
key, fsStats, ok := registerFilesystemStats(d.agent.fsStats, device, mountpoint, root, customName, d.ctx)
if !ok {
return
}
d.agent.fsStats[key] = fsStats
name := key
if customName != "" {
name = customName
}
slog.Info("Detected disk", "name", name, "device", device, "mount", mountpoint, "io", key, "root", root)
}
// addConfiguredRootFs resolves FILESYSTEM against partitions first, then falls
// back to direct diskstats matching for setups like ZFS where partitions do not
// expose the physical device name.
func (d *diskDiscovery) addConfiguredRootFs() bool {
if d.ctx.filesystem == "" {
return false
}
for _, p := range d.partitions {
if filesystemMatchesPartitionSetting(d.ctx.filesystem, p) {
d.addFsStat(p.Device, p.Mountpoint, true, "")
return true
}
}
// FILESYSTEM may name a physical disk absent from partitions (e.g. ZFS lists
// dataset paths like zroot/ROOT/default, not block devices).
if ioKey, match := findIoDevice(d.ctx.filesystem, d.ctx.diskIoCounters); match {
d.agent.fsStats[ioKey] = &system.FsStats{Root: true, Mountpoint: d.rootMountPoint}
return true
}
slog.Warn("Partition details not found", "filesystem", d.ctx.filesystem)
return false
}
func isRootFallbackPartition(p disk.PartitionStat, rootMountPoint string) bool {
return p.Mountpoint == rootMountPoint ||
(isDockerSpecialMountpoint(p.Mountpoint) && strings.HasPrefix(p.Device, "/dev"))
}
// addPartitionRootFs handles the non-configured root fallback path when a
// partition looks like the active root mount but still needs translating to an
// I/O device key.
func (d *diskDiscovery) addPartitionRootFs(device, mountpoint string) bool {
fs, match := findIoDevice(filepath.Base(device), d.ctx.diskIoCounters)
if !match {
return false
}
// The resolved I/O device is already known here, so use it directly to avoid
// a second fallback search inside buildFsStatRegistration.
d.addFsStat(fs, mountpoint, true, "")
return true
}
// addLastResortRootFs is only used when neither FILESYSTEM nor partition-based
// heuristics can identify root, so it picks the busiest I/O device as a final
// fallback and preserves the root mountpoint for usage collection.
func (d *diskDiscovery) addLastResortRootFs() {
rootKey := mostActiveIoDevice(d.ctx.diskIoCounters)
if rootKey != "" {
slog.Warn("Using most active device for root I/O; set FILESYSTEM to override", "device", rootKey)
} else {
rootKey = filepath.Base(d.rootMountPoint)
if _, exists := d.agent.fsStats[rootKey]; exists {
rootKey = "root"
}
slog.Warn("Root I/O device not detected; set FILESYSTEM to override")
}
d.agent.fsStats[rootKey] = &system.FsStats{Root: true, Mountpoint: d.rootMountPoint}
}
// findPartitionByFilesystemSetting matches an EXTRA_FILESYSTEMS entry against a
// discovered partition either by mountpoint or by device suffix.
func findPartitionByFilesystemSetting(filesystem string, partitions []disk.PartitionStat) (disk.PartitionStat, bool) {
for _, p := range partitions {
if strings.HasSuffix(p.Device, filesystem) || p.Mountpoint == filesystem {
return p, true
}
}
return disk.PartitionStat{}, false
}
// addConfiguredExtraFsEntry resolves one EXTRA_FILESYSTEMS entry, preferring a
// discovered partition and falling back to any path that disk.Usage accepts.
func (d *diskDiscovery) addConfiguredExtraFsEntry(filesystem, customName string) {
if p, found := findPartitionByFilesystemSetting(filesystem, d.partitions); found {
d.addFsStat(p.Device, p.Mountpoint, false, customName)
return
}
if _, err := d.usageFn(filesystem); err == nil {
d.addFsStat(filepath.Base(filesystem), filesystem, false, customName)
return
} else {
slog.Error("Invalid filesystem", "name", filesystem, "err", err)
}
}
// addConfiguredExtraFilesystems parses and registers the comma-separated
// EXTRA_FILESYSTEMS env var entries.
func (d *diskDiscovery) addConfiguredExtraFilesystems(extraFilesystems string) {
for fsEntry := range strings.SplitSeq(extraFilesystems, ",") {
filesystem, customName := parseFilesystemEntry(fsEntry)
d.addConfiguredExtraFsEntry(filesystem, customName)
}
}
// addPartitionExtraFs registers partitions mounted under /extra-filesystems so
// their display names can come from the folder name while their I/O keys still
// prefer the underlying partition device.
func (d *diskDiscovery) addPartitionExtraFs(p disk.PartitionStat) {
if !strings.HasPrefix(p.Mountpoint, d.ctx.efPath) {
return
}
device, customName := extraFilesystemPartitionInfo(p)
d.addFsStat(device, p.Mountpoint, false, customName)
}
// addExtraFilesystemFolders handles bare directories under /extra-filesystems
// that may not appear in partition discovery, while skipping mountpoints that
// were already registered from higher-fidelity sources.
func (d *diskDiscovery) addExtraFilesystemFolders(folderNames []string) {
existingMountpoints := make(map[string]bool, len(d.agent.fsStats))
for _, stats := range d.agent.fsStats {
existingMountpoints[stats.Mountpoint] = true
}
for _, folderName := range folderNames {
mountpoint := filepath.Join(d.ctx.efPath, folderName)
slog.Debug("/extra-filesystems", "mountpoint", mountpoint)
if existingMountpoints[mountpoint] {
continue
}
device, customName := parseFilesystemEntry(folderName)
d.addFsStat(device, mountpoint, false, customName)
}
}
// Sets up the filesystems to monitor for disk usage and I/O.
func (a *Agent) initializeDiskInfo() {
filesystem, _ := GetEnv("FILESYSTEM")
efPath := "/extra-filesystems"
filesystem, _ := utils.GetEnv("FILESYSTEM")
hasRoot := false
isWindows := runtime.GOOS == "windows"
@@ -55,167 +286,57 @@ func (a *Agent) initializeDiskInfo() {
}
}
// ioContext := context.WithValue(a.sensorsContext,
// common.EnvKey, common.EnvMap{common.HostProcEnvKey: "/tmp/testproc"},
// )
// diskIoCounters, err := disk.IOCountersWithContext(ioContext)
diskIoCounters, err := disk.IOCounters()
if err != nil {
slog.Error("Error getting diskstats", "err", err)
}
slog.Debug("Disk I/O", "diskstats", diskIoCounters)
// Helper function to add a filesystem to fsStats if it doesn't exist
addFsStat := func(device, mountpoint string, root bool, customName ...string) {
var key string
if isWindows {
key = device
} else {
key = filepath.Base(device)
}
var ioMatch bool
if _, exists := a.fsStats[key]; !exists {
if root {
slog.Info("Detected root device", "name", key)
// Try to map root device to a diskIoCounters entry. First
// checks for an exact key match, then uses findIoDevice for
// normalized / prefix-based matching (e.g. nda0p2 → nda0),
// and finally falls back to the FILESYSTEM env var.
if _, ioMatch = diskIoCounters[key]; !ioMatch {
if matchedKey, match := findIoDevice(key, diskIoCounters); match {
key = matchedKey
ioMatch = true
} else if filesystem != "" {
if matchedKey, match := findIoDevice(filesystem, diskIoCounters); match {
key = matchedKey
ioMatch = true
}
}
if !ioMatch {
slog.Warn("Root I/O unmapped; set FILESYSTEM", "device", device, "mountpoint", mountpoint)
}
}
} else {
// Check if non-root has diskstats and fall back to folder name if not
// Scenario: device is encrypted and named luks-2bcb02be-999d-4417-8d18-5c61e660fb6e - not in /proc/diskstats.
// However, the device can be specified by mounting folder from luks device at /extra-filesystems/sda1
if _, ioMatch = diskIoCounters[key]; !ioMatch {
efBase := filepath.Base(mountpoint)
if _, ioMatch = diskIoCounters[efBase]; ioMatch {
key = efBase
}
}
}
fsStats := &system.FsStats{Root: root, Mountpoint: mountpoint}
if len(customName) > 0 && customName[0] != "" {
fsStats.Name = customName[0]
}
a.fsStats[key] = fsStats
}
ctx := fsRegistrationContext{
filesystem: filesystem,
isWindows: isWindows,
diskIoCounters: diskIoCounters,
efPath: "/extra-filesystems",
}
// Get the appropriate root mount point for this system
rootMountPoint := a.getRootMountPoint()
// Use FILESYSTEM env var to find root filesystem
if filesystem != "" {
for _, p := range partitions {
if filesystemMatchesPartitionSetting(filesystem, p) {
addFsStat(p.Device, p.Mountpoint, true)
hasRoot = true
break
}
}
if !hasRoot {
// FILESYSTEM may name a physical disk absent from partitions (e.g.
// ZFS lists dataset paths like zroot/ROOT/default, not block devices).
// Try matching directly against diskIoCounters.
if ioKey, match := findIoDevice(filesystem, diskIoCounters); match {
a.fsStats[ioKey] = &system.FsStats{Root: true, Mountpoint: rootMountPoint}
hasRoot = true
} else {
slog.Warn("Partition details not found", "filesystem", filesystem)
}
}
discovery := diskDiscovery{
agent: a,
rootMountPoint: a.getRootMountPoint(),
partitions: partitions,
usageFn: disk.Usage,
ctx: ctx,
}
// Add EXTRA_FILESYSTEMS env var values to fsStats
if extraFilesystems, exists := GetEnv("EXTRA_FILESYSTEMS"); exists {
for fsEntry := range strings.SplitSeq(extraFilesystems, ",") {
// Parse custom name from format: device__customname
fs, customName := parseFilesystemEntry(fsEntry)
hasRoot = discovery.addConfiguredRootFs()
found := false
for _, p := range partitions {
if strings.HasSuffix(p.Device, fs) || p.Mountpoint == fs {
addFsStat(p.Device, p.Mountpoint, false, customName)
found = true
break
}
}
// if not in partitions, test if we can get disk usage
if !found {
if _, err := disk.Usage(fs); err == nil {
addFsStat(filepath.Base(fs), fs, false, customName)
} else {
slog.Error("Invalid filesystem", "name", fs, "err", err)
}
}
}
// Add EXTRA_FILESYSTEMS env var values to fsStats
if extraFilesystems, exists := utils.GetEnv("EXTRA_FILESYSTEMS"); exists {
discovery.addConfiguredExtraFilesystems(extraFilesystems)
}
// Process partitions for various mount points
for _, p := range partitions {
// fmt.Println(p.Device, p.Mountpoint)
// Binary root fallback or docker root fallback
if !hasRoot && (p.Mountpoint == rootMountPoint || (isDockerSpecialMountpoint(p.Mountpoint) && strings.HasPrefix(p.Device, "/dev"))) {
fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters)
if match {
addFsStat(fs, p.Mountpoint, true)
hasRoot = true
}
}
// Check if device is in /extra-filesystems
if strings.HasPrefix(p.Mountpoint, efPath) {
device, customName := parseFilesystemEntry(p.Mountpoint)
addFsStat(device, p.Mountpoint, false, customName)
if !hasRoot && isRootFallbackPartition(p, discovery.rootMountPoint) {
hasRoot = discovery.addPartitionRootFs(p.Device, p.Mountpoint)
}
discovery.addPartitionExtraFs(p)
}
// Check all folders in /extra-filesystems and add them if not already present
if folders, err := os.ReadDir(efPath); err == nil {
existingMountpoints := make(map[string]bool)
for _, stats := range a.fsStats {
existingMountpoints[stats.Mountpoint] = true
}
if folders, err := os.ReadDir(discovery.ctx.efPath); err == nil {
folderNames := make([]string, 0, len(folders))
for _, folder := range folders {
if folder.IsDir() {
mountpoint := filepath.Join(efPath, folder.Name())
slog.Debug("/extra-filesystems", "mountpoint", mountpoint)
if !existingMountpoints[mountpoint] {
device, customName := parseFilesystemEntry(folder.Name())
addFsStat(device, mountpoint, false, customName)
}
folderNames = append(folderNames, folder.Name())
}
}
discovery.addExtraFilesystemFolders(folderNames)
}
// If no root filesystem set, try the most active I/O device as a last
// resort (e.g. ZFS where dataset names are unrelated to disk names).
if !hasRoot {
rootKey := mostActiveIoDevice(diskIoCounters)
if rootKey != "" {
slog.Warn("Using most active device for root I/O; set FILESYSTEM to override", "device", rootKey)
} else {
rootKey = filepath.Base(rootMountPoint)
if _, exists := a.fsStats[rootKey]; exists {
rootKey = "root"
}
slog.Warn("Root I/O device not detected; set FILESYSTEM to override")
}
a.fsStats[rootKey] = &system.FsStats{Root: true, Mountpoint: rootMountPoint}
discovery.addLastResortRootFs()
}
a.pruneDuplicateRootExtraFilesystems()
@@ -380,6 +501,8 @@ func normalizeDeviceName(value string) string {
// Sets start values for disk I/O stats.
func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) {
a.fsNames = a.fsNames[:0]
now := time.Now()
for device, stats := range a.fsStats {
// skip if not in diskIoCounters
d, exists := diskIoCounters[device]
@@ -388,7 +511,7 @@ func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersS
continue
}
// populate initial values
stats.Time = time.Now()
stats.Time = now
stats.TotalRead = d.ReadBytes
stats.TotalWrite = d.WriteBytes
// add to list of valid io device names
@@ -412,12 +535,12 @@ func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
continue
}
if d, err := disk.Usage(stats.Mountpoint); err == nil {
stats.DiskTotal = bytesToGigabytes(d.Total)
stats.DiskUsed = bytesToGigabytes(d.Used)
stats.DiskTotal = utils.BytesToGigabytes(d.Total)
stats.DiskUsed = utils.BytesToGigabytes(d.Used)
if stats.Root {
systemStats.DiskTotal = bytesToGigabytes(d.Total)
systemStats.DiskUsed = bytesToGigabytes(d.Used)
systemStats.DiskPct = twoDecimals(d.UsedPercent)
systemStats.DiskTotal = utils.BytesToGigabytes(d.Total)
systemStats.DiskUsed = utils.BytesToGigabytes(d.Used)
systemStats.DiskPct = utils.TwoDecimals(d.UsedPercent)
}
} else {
// reset stats if error (likely unmounted)
@@ -470,8 +593,8 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
diskIORead := (d.ReadBytes - prev.readBytes) * 1000 / msElapsed
diskIOWrite := (d.WriteBytes - prev.writeBytes) * 1000 / msElapsed
readMbPerSecond := bytesToMegabytes(float64(diskIORead))
writeMbPerSecond := bytesToMegabytes(float64(diskIOWrite))
readMbPerSecond := utils.BytesToMegabytes(float64(diskIORead))
writeMbPerSecond := utils.BytesToMegabytes(float64(diskIOWrite))
// validate values
if readMbPerSecond > 50_000 || writeMbPerSecond > 50_000 {

View File

@@ -93,6 +93,443 @@ func TestParseFilesystemEntry(t *testing.T) {
}
}
func TestExtraFilesystemPartitionInfo(t *testing.T) {
t.Run("uses partition device for label-only mountpoint", func(t *testing.T) {
device, customName := extraFilesystemPartitionInfo(disk.PartitionStat{
Device: "/dev/sdc",
Mountpoint: "/extra-filesystems/Share",
})
assert.Equal(t, "/dev/sdc", device)
assert.Equal(t, "", customName)
})
t.Run("uses custom name from mountpoint suffix", func(t *testing.T) {
device, customName := extraFilesystemPartitionInfo(disk.PartitionStat{
Device: "/dev/sdc",
Mountpoint: "/extra-filesystems/sdc__Share",
})
assert.Equal(t, "/dev/sdc", device)
assert.Equal(t, "Share", customName)
})
t.Run("falls back to folder device when partition device is unavailable", func(t *testing.T) {
device, customName := extraFilesystemPartitionInfo(disk.PartitionStat{
Mountpoint: "/extra-filesystems/sdc__Share",
})
assert.Equal(t, "sdc", device)
assert.Equal(t, "Share", customName)
})
t.Run("supports custom name without folder device prefix", func(t *testing.T) {
device, customName := extraFilesystemPartitionInfo(disk.PartitionStat{
Device: "/dev/sdc",
Mountpoint: "/extra-filesystems/__Share",
})
assert.Equal(t, "/dev/sdc", device)
assert.Equal(t, "Share", customName)
})
}
func TestBuildFsStatRegistration(t *testing.T) {
t.Run("uses basename for non-windows exact io match", func(t *testing.T) {
key, stats, ok := registerFilesystemStats(
map[string]*system.FsStats{},
"/dev/sda1",
"/mnt/data",
false,
"archive",
fsRegistrationContext{
isWindows: false,
diskIoCounters: map[string]disk.IOCountersStat{
"sda1": {Name: "sda1"},
},
},
)
assert.True(t, ok)
assert.Equal(t, "sda1", key)
assert.Equal(t, "/mnt/data", stats.Mountpoint)
assert.Equal(t, "archive", stats.Name)
assert.False(t, stats.Root)
})
t.Run("maps root partition to io device by prefix", func(t *testing.T) {
key, stats, ok := registerFilesystemStats(
map[string]*system.FsStats{},
"/dev/ada0p2",
"/",
true,
"",
fsRegistrationContext{
isWindows: false,
diskIoCounters: map[string]disk.IOCountersStat{
"ada0": {Name: "ada0", ReadBytes: 1000, WriteBytes: 1000},
},
},
)
assert.True(t, ok)
assert.Equal(t, "ada0", key)
assert.True(t, stats.Root)
assert.Equal(t, "/", stats.Mountpoint)
})
t.Run("uses filesystem setting as root fallback", func(t *testing.T) {
key, _, ok := registerFilesystemStats(
map[string]*system.FsStats{},
"overlay",
"/",
true,
"",
fsRegistrationContext{
filesystem: "nvme0n1p2",
isWindows: false,
diskIoCounters: map[string]disk.IOCountersStat{
"nvme0n1": {Name: "nvme0n1", ReadBytes: 1000, WriteBytes: 1000},
},
},
)
assert.True(t, ok)
assert.Equal(t, "nvme0n1", key)
})
t.Run("prefers parsed extra-filesystems device over mapper device", func(t *testing.T) {
key, stats, ok := registerFilesystemStats(
map[string]*system.FsStats{},
"/dev/mapper/luks-2bcb02be-999d-4417-8d18-5c61e660fb6e",
"/extra-filesystems/nvme0n1p2__Archive",
false,
"Archive",
fsRegistrationContext{
isWindows: false,
diskIoCounters: map[string]disk.IOCountersStat{
"dm-1": {Name: "dm-1", Label: "luks-2bcb02be-999d-4417-8d18-5c61e660fb6e"},
"nvme0n1p2": {Name: "nvme0n1p2"},
},
},
)
assert.True(t, ok)
assert.Equal(t, "nvme0n1p2", key)
assert.Equal(t, "Archive", stats.Name)
})
t.Run("falls back to mapper io device when folder device cannot be resolved", func(t *testing.T) {
key, stats, ok := registerFilesystemStats(
map[string]*system.FsStats{},
"/dev/mapper/luks-2bcb02be-999d-4417-8d18-5c61e660fb6e",
"/extra-filesystems/Archive",
false,
"Archive",
fsRegistrationContext{
isWindows: false,
diskIoCounters: map[string]disk.IOCountersStat{
"dm-1": {Name: "dm-1", Label: "luks-2bcb02be-999d-4417-8d18-5c61e660fb6e"},
},
},
)
assert.True(t, ok)
assert.Equal(t, "dm-1", key)
assert.Equal(t, "Archive", stats.Name)
})
t.Run("uses full device name on windows", func(t *testing.T) {
key, _, ok := registerFilesystemStats(
map[string]*system.FsStats{},
`C:`,
`C:\\`,
false,
"",
fsRegistrationContext{
isWindows: true,
diskIoCounters: map[string]disk.IOCountersStat{
`C:`: {Name: `C:`},
},
},
)
assert.True(t, ok)
assert.Equal(t, `C:`, key)
})
t.Run("skips existing key", func(t *testing.T) {
key, stats, ok := registerFilesystemStats(
map[string]*system.FsStats{"sda1": {Mountpoint: "/existing"}},
"/dev/sda1",
"/mnt/data",
false,
"",
fsRegistrationContext{
isWindows: false,
diskIoCounters: map[string]disk.IOCountersStat{
"sda1": {Name: "sda1"},
},
},
)
assert.False(t, ok)
assert.Empty(t, key)
assert.Nil(t, stats)
})
}
func TestAddConfiguredRootFs(t *testing.T) {
t.Run("adds root from matching partition", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
discovery := diskDiscovery{
agent: agent,
rootMountPoint: "/",
partitions: []disk.PartitionStat{{Device: "/dev/ada0p2", Mountpoint: "/"}},
ctx: fsRegistrationContext{
filesystem: "/dev/ada0p2",
isWindows: false,
diskIoCounters: map[string]disk.IOCountersStat{
"ada0": {Name: "ada0", ReadBytes: 1000, WriteBytes: 1000},
},
},
}
ok := discovery.addConfiguredRootFs()
assert.True(t, ok)
stats, exists := agent.fsStats["ada0"]
assert.True(t, exists)
assert.True(t, stats.Root)
assert.Equal(t, "/", stats.Mountpoint)
})
t.Run("adds root from io device when partition is missing", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
discovery := diskDiscovery{
agent: agent,
rootMountPoint: "/sysroot",
ctx: fsRegistrationContext{
filesystem: "zroot",
isWindows: false,
diskIoCounters: map[string]disk.IOCountersStat{
"nda0": {Name: "nda0", Label: "zroot", ReadBytes: 1000, WriteBytes: 1000},
},
},
}
ok := discovery.addConfiguredRootFs()
assert.True(t, ok)
stats, exists := agent.fsStats["nda0"]
assert.True(t, exists)
assert.True(t, stats.Root)
assert.Equal(t, "/sysroot", stats.Mountpoint)
})
t.Run("returns false when filesystem cannot be resolved", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
discovery := diskDiscovery{
agent: agent,
rootMountPoint: "/",
ctx: fsRegistrationContext{
filesystem: "missing-disk",
isWindows: false,
diskIoCounters: map[string]disk.IOCountersStat{},
},
}
ok := discovery.addConfiguredRootFs()
assert.False(t, ok)
assert.Empty(t, agent.fsStats)
})
}
func TestAddPartitionRootFs(t *testing.T) {
t.Run("adds root from fallback partition candidate", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
discovery := diskDiscovery{
agent: agent,
ctx: fsRegistrationContext{
isWindows: false,
diskIoCounters: map[string]disk.IOCountersStat{
"nvme0n1": {Name: "nvme0n1", ReadBytes: 1000, WriteBytes: 1000},
},
},
}
ok := discovery.addPartitionRootFs("/dev/nvme0n1p2", "/")
assert.True(t, ok)
stats, exists := agent.fsStats["nvme0n1"]
assert.True(t, exists)
assert.True(t, stats.Root)
assert.Equal(t, "/", stats.Mountpoint)
})
t.Run("returns false when no io device matches", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
discovery := diskDiscovery{agent: agent, ctx: fsRegistrationContext{diskIoCounters: map[string]disk.IOCountersStat{}}}
ok := discovery.addPartitionRootFs("/dev/mapper/root", "/")
assert.False(t, ok)
assert.Empty(t, agent.fsStats)
})
}
func TestAddLastResortRootFs(t *testing.T) {
t.Run("uses most active io device when available", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
discovery := diskDiscovery{agent: agent, rootMountPoint: "/", ctx: fsRegistrationContext{diskIoCounters: map[string]disk.IOCountersStat{
"sda": {Name: "sda", ReadBytes: 5000, WriteBytes: 5000},
"sdb": {Name: "sdb", ReadBytes: 1000, WriteBytes: 1000},
}}}
discovery.addLastResortRootFs()
stats, exists := agent.fsStats["sda"]
assert.True(t, exists)
assert.True(t, stats.Root)
})
t.Run("falls back to root key when mountpoint basename collides", func(t *testing.T) {
agent := &Agent{fsStats: map[string]*system.FsStats{
"sysroot": {Mountpoint: "/extra-filesystems/sysroot"},
}}
discovery := diskDiscovery{agent: agent, rootMountPoint: "/sysroot", ctx: fsRegistrationContext{diskIoCounters: map[string]disk.IOCountersStat{}}}
discovery.addLastResortRootFs()
stats, exists := agent.fsStats["root"]
assert.True(t, exists)
assert.True(t, stats.Root)
assert.Equal(t, "/sysroot", stats.Mountpoint)
})
}
func TestAddConfiguredExtraFsEntry(t *testing.T) {
t.Run("uses matching partition when present", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
discovery := diskDiscovery{
agent: agent,
partitions: []disk.PartitionStat{{Device: "/dev/sdb1", Mountpoint: "/mnt/backup"}},
usageFn: func(string) (*disk.UsageStat, error) {
t.Fatal("usage fallback should not be called when partition matches")
return nil, nil
},
ctx: fsRegistrationContext{
isWindows: false,
diskIoCounters: map[string]disk.IOCountersStat{
"sdb1": {Name: "sdb1"},
},
},
}
discovery.addConfiguredExtraFsEntry("sdb1", "backup")
stats, exists := agent.fsStats["sdb1"]
assert.True(t, exists)
assert.Equal(t, "/mnt/backup", stats.Mountpoint)
assert.Equal(t, "backup", stats.Name)
})
t.Run("falls back to usage-validated path", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
discovery := diskDiscovery{
agent: agent,
usageFn: func(path string) (*disk.UsageStat, error) {
assert.Equal(t, "/srv/archive", path)
return &disk.UsageStat{}, nil
},
ctx: fsRegistrationContext{
isWindows: false,
diskIoCounters: map[string]disk.IOCountersStat{
"archive": {Name: "archive"},
},
},
}
discovery.addConfiguredExtraFsEntry("/srv/archive", "archive")
stats, exists := agent.fsStats["archive"]
assert.True(t, exists)
assert.Equal(t, "/srv/archive", stats.Mountpoint)
assert.Equal(t, "archive", stats.Name)
})
t.Run("ignores invalid filesystem entry", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
discovery := diskDiscovery{
agent: agent,
usageFn: func(string) (*disk.UsageStat, error) {
return nil, os.ErrNotExist
},
}
discovery.addConfiguredExtraFsEntry("/missing/archive", "")
assert.Empty(t, agent.fsStats)
})
}
func TestAddConfiguredExtraFilesystems(t *testing.T) {
t.Run("parses and registers multiple configured filesystems", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
discovery := diskDiscovery{
agent: agent,
partitions: []disk.PartitionStat{{Device: "/dev/sda1", Mountpoint: "/mnt/fast"}},
usageFn: func(path string) (*disk.UsageStat, error) {
if path == "/srv/archive" {
return &disk.UsageStat{}, nil
}
return nil, os.ErrNotExist
},
ctx: fsRegistrationContext{
isWindows: false,
diskIoCounters: map[string]disk.IOCountersStat{
"sda1": {Name: "sda1"},
"archive": {Name: "archive"},
},
},
}
discovery.addConfiguredExtraFilesystems("sda1__fast,/srv/archive__cold")
assert.Contains(t, agent.fsStats, "sda1")
assert.Equal(t, "fast", agent.fsStats["sda1"].Name)
assert.Contains(t, agent.fsStats, "archive")
assert.Equal(t, "cold", agent.fsStats["archive"].Name)
})
}
func TestAddExtraFilesystemFolders(t *testing.T) {
t.Run("adds missing folders and skips existing mountpoints", func(t *testing.T) {
agent := &Agent{fsStats: map[string]*system.FsStats{
"existing": {Mountpoint: "/extra-filesystems/existing"},
}}
discovery := diskDiscovery{
agent: agent,
ctx: fsRegistrationContext{
isWindows: false,
efPath: "/extra-filesystems",
diskIoCounters: map[string]disk.IOCountersStat{
"newdisk": {Name: "newdisk"},
},
},
}
discovery.addExtraFilesystemFolders([]string{"existing", "newdisk__Archive"})
assert.Len(t, agent.fsStats, 2)
stats, exists := agent.fsStats["newdisk"]
assert.True(t, exists)
assert.Equal(t, "/extra-filesystems/newdisk__Archive", stats.Mountpoint)
assert.Equal(t, "Archive", stats.Name)
})
}
func TestFindIoDevice(t *testing.T) {
t.Run("matches by device name", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
@@ -250,18 +687,8 @@ func TestIsDockerSpecialMountpoint(t *testing.T) {
}
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")
t.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
@@ -289,7 +716,7 @@ func TestInitializeDiskInfoWithCustomNames(t *testing.T) {
for _, tc := range testCases {
t.Run("env_"+tc.envValue, func(t *testing.T) {
os.Setenv("EXTRA_FILESYSTEMS", tc.envValue)
t.Setenv("EXTRA_FILESYSTEMS", tc.envValue)
// Create mock partitions that would match our test cases
partitions := []disk.PartitionStat{}
@@ -310,7 +737,7 @@ func TestInitializeDiskInfoWithCustomNames(t *testing.T) {
// 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, ",") {
for fsEntry := range strings.SplitSeq(extraFilesystems, ",") {
// Parse the entry
fsEntry = strings.TrimSpace(fsEntry)
var fs, customName string
@@ -506,3 +933,33 @@ func TestHasSameDiskUsage(t *testing.T) {
assert.False(t, hasSameDiskUsage(&disk.UsageStat{Total: 0, Used: 0}, &disk.UsageStat{Total: 1, Used: 1}))
})
}
func TestInitializeDiskIoStatsResetsTrackedDevices(t *testing.T) {
agent := &Agent{
fsStats: map[string]*system.FsStats{
"sda": {},
"sdb": {},
},
fsNames: []string{"stale", "sda"},
}
agent.initializeDiskIoStats(map[string]disk.IOCountersStat{
"sda": {Name: "sda", ReadBytes: 10, WriteBytes: 20},
"sdb": {Name: "sdb", ReadBytes: 30, WriteBytes: 40},
})
assert.ElementsMatch(t, []string{"sda", "sdb"}, agent.fsNames)
assert.Len(t, agent.fsNames, 2)
assert.Equal(t, uint64(10), agent.fsStats["sda"].TotalRead)
assert.Equal(t, uint64(20), agent.fsStats["sda"].TotalWrite)
assert.False(t, agent.fsStats["sda"].Time.IsZero())
assert.False(t, agent.fsStats["sdb"].Time.IsZero())
agent.initializeDiskIoStats(map[string]disk.IOCountersStat{
"sdb": {Name: "sdb", ReadBytes: 50, WriteBytes: 60},
})
assert.Equal(t, []string{"sdb"}, agent.fsNames)
assert.Equal(t, uint64(50), agent.fsStats["sdb"].TotalRead)
assert.Equal(t, uint64(60), agent.fsStats["sdb"].TotalWrite)
}

View File

@@ -16,11 +16,14 @@ import (
"os"
"path"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/henrygd/beszel/agent/deltatracker"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/blang/semver"
@@ -28,10 +31,8 @@ import (
// ansiEscapePattern matches ANSI escape sequences (colors, cursor movement, etc.)
// This includes CSI sequences like \x1b[...m and simple escapes like \x1b[K
var (
ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[@-Z\\-_]`)
dockerContainerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
)
var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[@-Z\\-_]`)
var dockerContainerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
const (
// Docker API timeout in milliseconds
@@ -338,15 +339,48 @@ func validateCpuPercentage(cpuPct float64, containerName string) error {
// 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.Cpu = utils.TwoDecimals(cpuPct)
stats.Mem = utils.BytesToMegabytes(float64(usedMemory))
stats.Bandwidth = [2]uint64{sent_delta, recv_delta}
// TODO(0.19+): stop populating NetworkSent/NetworkRecv (deprecated in 0.18.3)
stats.NetworkSent = bytesToMegabytes(float64(sent_delta))
stats.NetworkRecv = bytesToMegabytes(float64(recv_delta))
stats.NetworkSent = utils.BytesToMegabytes(float64(sent_delta))
stats.NetworkRecv = utils.BytesToMegabytes(float64(recv_delta))
stats.PrevReadTime = readTime
}
// convertContainerPortsToString formats the ports of a container into a sorted, deduplicated string.
// ctr.Ports is nilled out after processing so the slice is not accidentally reused.
func convertContainerPortsToString(ctr *container.ApiInfo) string {
if len(ctr.Ports) == 0 {
return ""
}
sort.Slice(ctr.Ports, func(i, j int) bool {
return ctr.Ports[i].PublicPort < ctr.Ports[j].PublicPort
})
var builder strings.Builder
seenPorts := make(map[uint16]struct{})
for _, p := range ctr.Ports {
_, ok := seenPorts[p.PublicPort]
if p.PublicPort == 0 || ok {
continue
}
seenPorts[p.PublicPort] = struct{}{}
if builder.Len() > 0 {
builder.WriteString(", ")
}
switch p.IP {
case "0.0.0.0", "::":
default:
builder.WriteString(p.IP)
builder.WriteByte(':')
}
builder.WriteString(strconv.Itoa(int(p.PublicPort)))
}
// clear ports slice so it doesn't get reused and blend into next response
ctr.Ports = nil
return builder.String()
}
func parseDockerStatus(status string) (string, container.DockerHealth) {
trimmed := strings.TrimSpace(status)
if trimmed == "" {
@@ -366,22 +400,60 @@ func parseDockerStatus(status string) (string, container.DockerHealth) {
statusText = trimmed
}
healthText := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(trimmed[openIdx+1:], ")")))
healthText := 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])
prefix := strings.ToLower(strings.TrimSpace(healthText[:colonIdx]))
if prefix == "health" || prefix == "health status" {
healthText = strings.TrimSpace(healthText[colonIdx+1:])
}
}
if health, ok := container.DockerHealthStrings[healthText]; ok {
if health, ok := parseDockerHealthStatus(healthText); ok {
return statusText, health
}
return trimmed, container.DockerHealthNone
}
// parseDockerHealthStatus maps Docker health status strings to container.DockerHealth values
func parseDockerHealthStatus(status string) (container.DockerHealth, bool) {
health, ok := container.DockerHealthStrings[strings.ToLower(strings.TrimSpace(status))]
return health, ok
}
// getPodmanContainerHealth fetches container health status from the container inspect endpoint.
// Used for Podman which doesn't provide health status in the /containers/json endpoint as of March 2026.
// https://github.com/containers/podman/issues/27786
func (dm *dockerManager) getPodmanContainerHealth(containerID string) (container.DockerHealth, error) {
resp, err := dm.client.Get(fmt.Sprintf("http://localhost/containers/%s/json", url.PathEscape(containerID)))
if err != nil {
return container.DockerHealthNone, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return container.DockerHealthNone, fmt.Errorf("container inspect request failed: %s", resp.Status)
}
var inspectInfo struct {
State struct {
Health struct {
Status string
}
}
}
if err := json.NewDecoder(resp.Body).Decode(&inspectInfo); err != nil {
return container.DockerHealthNone, err
}
if health, ok := parseDockerHealthStatus(inspectInfo.State.Health.Status); ok {
return health, nil
}
return container.DockerHealthNone, nil
}
// 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:]
@@ -391,23 +463,39 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
return err
}
statusText, health := parseDockerStatus(ctr.Status)
// Docker exposes Health.Status on /containers/json in API 1.52+.
// Podman currently requires falling back to the inspect endpoint as of March 2026.
// https://github.com/containers/podman/issues/27786
if ctr.Health.Status != "" {
if h, ok := parseDockerHealthStatus(ctr.Health.Status); ok {
health = h
}
} else if dm.usingPodman {
if podmanHealth, err := dm.getPodmanContainerHealth(ctr.IdShort); err == nil {
health = podmanHealth
}
}
dm.containerStatsMutex.Lock()
defer dm.containerStatsMutex.Unlock()
// add empty values if they doesn't exist in map
stats, initialized := dm.containerStatsMap[ctr.IdShort]
if !initialized {
stats = &container.Stats{Image: ctr.Image}
stats = &container.Stats{Name: name, Id: ctr.IdShort, Image: ctr.Image}
dm.containerStatsMap[ctr.IdShort] = stats
}
stats.Id = ctr.IdShort
stats.Name = name
statusText, health := parseDockerStatus(ctr.Status)
stats.Status = statusText
stats.Health = health
if len(ctr.Ports) > 0 {
stats.Ports = convertContainerPortsToString(ctr)
}
// reset current stats
stats.Cpu = 0
stats.Mem = 0
@@ -490,7 +578,7 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
// Creates a new http client for Docker or Podman API
func newDockerManager() *dockerManager {
dockerHost, exists := GetEnv("DOCKER_HOST")
dockerHost, exists := utils.GetEnv("DOCKER_HOST")
if exists {
// return nil if set to empty string
if dockerHost == "" {
@@ -526,7 +614,7 @@ func newDockerManager() *dockerManager {
// configurable timeout
timeout := time.Millisecond * time.Duration(dockerTimeoutMs)
if t, set := GetEnv("DOCKER_TIMEOUT"); set {
if t, set := utils.GetEnv("DOCKER_TIMEOUT"); set {
timeout, err = time.ParseDuration(t)
if err != nil {
slog.Error(err.Error())
@@ -543,7 +631,7 @@ func newDockerManager() *dockerManager {
// Read container exclusion patterns from environment variable
var excludeContainers []string
if excludeStr, set := GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" {
if excludeStr, set := utils.GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" {
parts := strings.SplitSeq(excludeStr, ",")
for part := range parts {
trimmed := strings.TrimSpace(part)

View File

@@ -18,6 +18,7 @@ import (
"time"
"github.com/henrygd/beszel/agent/deltatracker"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -34,6 +35,12 @@ type recordingRoundTripper struct {
lastQuery map[string]string
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
func (rt *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
rt.called = true
rt.lastPath = req.URL.EscapedPath()
@@ -213,6 +220,28 @@ func TestContainerDetailsRequestsUseExpectedDockerPaths(t *testing.T) {
})
}
func TestGetPodmanContainerHealth(t *testing.T) {
called := false
dm := &dockerManager{
client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
called = true
assert.Equal(t, "/containers/0123456789ab/json", req.URL.EscapedPath())
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"State":{"Health":{"Status":"healthy"}}}`)),
Request: req,
}, nil
})},
}
health, err := dm.getPodmanContainerHealth("0123456789ab")
require.NoError(t, err)
assert.True(t, called)
assert.Equal(t, container.DockerHealthHealthy, health)
}
func TestValidateCpuPercentage(t *testing.T) {
tests := []struct {
name string
@@ -269,16 +298,17 @@ func TestValidateCpuPercentage(t *testing.T) {
}
func TestUpdateContainerStatsValues(t *testing.T) {
var stats = container.Stats{}
stats.Name = "test-container"
stats.Cpu = 0.0
stats.Mem = 0.0
stats.NetworkSent = 0.0
stats.NetworkRecv = 0.0
stats.PrevReadTime = time.Time{}
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)
updateContainerStatsValues(stats, 75.5, 1048576, 524288, 262144, testTime)
// Check CPU percentage (should be rounded to 2 decimals)
assert.Equal(t, 75.5, stats.Cpu)
@@ -297,48 +327,6 @@ func TestUpdateContainerStatsValues(t *testing.T) {
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),
@@ -445,11 +433,12 @@ func TestCalculateNetworkStats(t *testing.T) {
},
}
var stats = container.Stats{}
stats.PrevReadTime = time.Now().Add(-time.Second) // 1 second ago
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)
sent, recv := dm.calculateNetworkStats(ctr, apiStats, stats, true, "test-container", cacheTimeMs)
// Should return calculated byte rates per second
assert.GreaterOrEqual(t, sent, uint64(0))
@@ -458,7 +447,7 @@ func TestCalculateNetworkStats(t *testing.T) {
// 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)
sent, recv = dm.calculateNetworkStats(ctr, apiStats, stats, true, "test-container", cacheTimeMs)
assert.Greater(t, sent, uint64(0))
assert.Equal(t, uint64(0), recv)
}
@@ -724,8 +713,7 @@ func TestMemoryStatsEdgeCases(t *testing.T) {
}
func TestContainerStatsInitialization(t *testing.T) {
var stats = container.Stats{}
stats.Name = "test-container"
stats := &container.Stats{Name: "test-container"}
// Verify initial values
assert.Equal(t, "test-container", stats.Name)
@@ -737,7 +725,7 @@ func TestContainerStatsInitialization(t *testing.T) {
// Test updating values
testTime := time.Now()
updateContainerStatsValues(&stats, 45.67, 2097152, 1048576, 524288, testTime)
updateContainerStatsValues(stats, 45.67, 2097152, 1048576, 524288, testTime)
assert.Equal(t, 45.67, stats.Cpu)
assert.Equal(t, 2.0, stats.Mem)
@@ -815,11 +803,12 @@ func TestNetworkStatsCalculationWithRealData(t *testing.T) {
// Use exact timing for deterministic results
exactly1000msAgo := time.Now().Add(-1000 * time.Millisecond)
var stats = container.Stats{}
stats.PrevReadTime = exactly1000msAgo
stats := &container.Stats{
PrevReadTime: exactly1000msAgo,
}
// First call sets baseline
sent1, recv1 := dm.calculateNetworkStats(ctr, apiStats1, &stats, true, "test", cacheTimeMs)
sent1, recv1 := dm.calculateNetworkStats(ctr, apiStats1, stats, true, "test", cacheTimeMs)
assert.Equal(t, uint64(0), sent1)
assert.Equal(t, uint64(0), recv1)
@@ -834,7 +823,7 @@ func TestNetworkStatsCalculationWithRealData(t *testing.T) {
expectedRecvRate := deltaRecv * 1000 / expectedElapsedMs // Should be exactly 1000000
// Second call with changed data
sent2, recv2 := dm.calculateNetworkStats(ctr, apiStats2, &stats, true, "test", cacheTimeMs)
sent2, recv2 := dm.calculateNetworkStats(ctr, apiStats2, stats, true, "test", cacheTimeMs)
// Should be exactly the expected rates (no tolerance needed)
assert.Equal(t, expectedSentRate, sent2)
@@ -845,9 +834,9 @@ func TestNetworkStatsCalculationWithRealData(t *testing.T) {
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.calculateNetworkStats(ctr, apiStats1, stats, true, "test", cacheTimeMs) // baseline
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
sent3, recv3 := dm.calculateNetworkStats(ctr, apiStats2, &stats, true, "test", cacheTimeMs)
sent3, recv3 := dm.calculateNetworkStats(ctr, apiStats2, stats, true, "test", cacheTimeMs)
assert.Equal(t, uint64(0), sent3)
assert.Equal(t, uint64(0), recv3)
}
@@ -881,9 +870,8 @@ func TestContainerStatsEndToEndWithRealData(t *testing.T) {
}
// Initialize container stats
var stats = container.Stats{}
stats.Name = "jellyfin"
dm.containerStatsMap[ctr.IdShort] = &stats
stats := &container.Stats{Name: "jellyfin"}
dm.containerStatsMap[ctr.IdShort] = stats
// Test individual components that we can verify
usedMemory, memErr := calculateMemoryUsage(&apiStats, false)
@@ -904,11 +892,11 @@ func TestContainerStatsEndToEndWithRealData(t *testing.T) {
updateContainerStatsValues(testStats, cpuPct, usedMemory, 1000000, 500000, testTime)
assert.Equal(t, cpuPct, testStats.Cpu)
assert.Equal(t, bytesToMegabytes(float64(usedMemory)), testStats.Mem)
assert.Equal(t, utils.BytesToMegabytes(float64(usedMemory)), testStats.Mem)
assert.Equal(t, [2]uint64{1000000, 500000}, testStats.Bandwidth)
// Deprecated fields still populated for backward compatibility with older hubs
assert.Equal(t, bytesToMegabytes(1000000), testStats.NetworkSent)
assert.Equal(t, bytesToMegabytes(500000), testStats.NetworkRecv)
assert.Equal(t, utils.BytesToMegabytes(1000000), testStats.NetworkSent)
assert.Equal(t, utils.BytesToMegabytes(500000), testStats.NetworkRecv)
assert.Equal(t, testTime, testStats.PrevReadTime)
}
@@ -1169,6 +1157,18 @@ func TestParseDockerStatus(t *testing.T) {
expectedStatus: "",
expectedHealth: container.DockerHealthNone,
},
{
name: "status health with health: prefix",
input: "Up 5 minutes (health: starting)",
expectedStatus: "Up 5 minutes",
expectedHealth: container.DockerHealthStarting,
},
{
name: "status health with health status: prefix",
input: "Up 10 minutes (health status: unhealthy)",
expectedStatus: "Up 10 minutes",
expectedHealth: container.DockerHealthUnhealthy,
},
}
for _, tt := range tests {
@@ -1180,6 +1180,84 @@ func TestParseDockerStatus(t *testing.T) {
}
}
func TestParseDockerHealthStatus(t *testing.T) {
tests := []struct {
input string
expectedHealth container.DockerHealth
expectedOk bool
}{
{"healthy", container.DockerHealthHealthy, true},
{"unhealthy", container.DockerHealthUnhealthy, true},
{"starting", container.DockerHealthStarting, true},
{"none", container.DockerHealthNone, true},
{" Healthy ", container.DockerHealthHealthy, true},
{"unknown", container.DockerHealthNone, false},
{"", container.DockerHealthNone, false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
health, ok := parseDockerHealthStatus(tt.input)
assert.Equal(t, tt.expectedHealth, health)
assert.Equal(t, tt.expectedOk, ok)
})
}
}
func TestUpdateContainerStatsUsesPodmanInspectHealthFallback(t *testing.T) {
var requestedPaths []string
dm := &dockerManager{
client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
requestedPaths = append(requestedPaths, req.URL.EscapedPath())
switch req.URL.EscapedPath() {
case "/containers/0123456789ab/stats":
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{
"read":"2026-03-15T21:26:59Z",
"cpu_stats":{"cpu_usage":{"total_usage":1000},"system_cpu_usage":2000},
"memory_stats":{"usage":1048576,"stats":{"inactive_file":262144}},
"networks":{"eth0":{"rx_bytes":0,"tx_bytes":0}}
}`)),
Request: req,
}, nil
case "/containers/0123456789ab/json":
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"State":{"Health":{"Status":"healthy"}}}`)),
Request: req,
}, nil
default:
return nil, fmt.Errorf("unexpected path: %s", req.URL.EscapedPath())
}
})},
containerStatsMap: make(map[string]*container.Stats),
apiStats: &container.ApiStats{},
usingPodman: true,
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]),
}
ctr := &container.ApiInfo{
IdShort: "0123456789ab",
Names: []string{"/beszel"},
Status: "Up 2 minutes",
Image: "beszel:latest",
}
err := dm.updateContainerStats(ctr, defaultCacheTimeMs)
require.NoError(t, err)
assert.Equal(t, []string{"/containers/0123456789ab/stats", "/containers/0123456789ab/json"}, requestedPaths)
assert.Equal(t, container.DockerHealthHealthy, dm.containerStatsMap[ctr.IdShort].Health)
assert.Equal(t, "Up 2 minutes", dm.containerStatsMap[ctr.IdShort].Status)
}
func TestConstantsAndUtilityFunctions(t *testing.T) {
// Test constants are properly defined
assert.Equal(t, uint16(60000), defaultCacheTimeMs)
@@ -1189,13 +1267,13 @@ func TestConstantsAndUtilityFunctions(t *testing.T) {
assert.Equal(t, 5*1024*1024, maxTotalLogSize) // 5MB
// 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.5, utils.TwoDecimals(1.499))
assert.Equal(t, 1.5, utils.TwoDecimals(1.5))
assert.Equal(t, 1.5, utils.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))
assert.Equal(t, 1.0, utils.BytesToMegabytes(1048576)) // 1 MB
assert.Equal(t, 0.5, utils.BytesToMegabytes(524288)) // 512 KB
assert.Equal(t, 0.0, utils.BytesToMegabytes(0))
}
func TestDecodeDockerLogStream(t *testing.T) {
@@ -1495,3 +1573,99 @@ func TestAnsiEscapePattern(t *testing.T) {
})
}
}
func TestConvertContainerPortsToString(t *testing.T) {
type port = struct {
PublicPort uint16
IP string
}
tests := []struct {
name string
ports []port
expected string
}{
{
name: "empty ports",
ports: nil,
expected: "",
},
{
name: "single port",
ports: []port{
{PublicPort: 80, IP: "0.0.0.0"},
},
expected: "80",
},
{
name: "single port with non-default IP",
ports: []port{
{PublicPort: 80, IP: "1.2.3.4"},
},
expected: "1.2.3.4:80",
},
{
name: "ipv6 default ip",
ports: []port{
{PublicPort: 80, IP: "::"},
},
expected: "80",
},
{
name: "zero PublicPort is skipped",
ports: []port{
{PublicPort: 0, IP: "0.0.0.0"},
{PublicPort: 80, IP: "0.0.0.0"},
},
expected: "80",
},
{
name: "ports sorted ascending by PublicPort",
ports: []port{
{PublicPort: 443, IP: "0.0.0.0"},
{PublicPort: 80, IP: "0.0.0.0"},
{PublicPort: 8080, IP: "0.0.0.0"},
},
expected: "80, 443, 8080",
},
{
name: "duplicates are deduplicated",
ports: []port{
{PublicPort: 80, IP: "0.0.0.0"},
{PublicPort: 80, IP: "0.0.0.0"},
{PublicPort: 443, IP: "0.0.0.0"},
},
expected: "80, 443",
},
{
name: "multiple ports with different IPs",
ports: []port{
{PublicPort: 80, IP: "0.0.0.0"},
{PublicPort: 443, IP: "1.2.3.4"},
},
expected: "80, 1.2.3.4:443",
},
{
name: "ports slice is nilled after call",
ports: []port{
{PublicPort: 8080, IP: "0.0.0.0"},
},
expected: "8080",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctr := &container.ApiInfo{}
for _, p := range tt.ports {
ctr.Ports = append(ctr.Ports, struct {
PublicPort uint16
IP string
}{PublicPort: p.PublicPort, IP: p.IP})
}
result := convertContainerPortsToString(ctr)
assert.Equal(t, tt.expected, result)
// Ports slice must be cleared to prevent bleed-over into the next response
assert.Nil(t, ctr.Ports, "ctr.Ports should be nil after formatContainerPorts")
})
}
}

View File

@@ -8,6 +8,7 @@ import (
"strconv"
"strings"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/smart"
)
@@ -141,9 +142,9 @@ func readEmmcHealth(blockName string) (emmcHealth, bool) {
out.lifeA = lifeA
out.lifeB = lifeB
out.model = readStringFile(filepath.Join(deviceDir, "name"))
out.serial = readStringFile(filepath.Join(deviceDir, "serial"))
out.revision = readStringFile(filepath.Join(deviceDir, "prv"))
out.model = utils.ReadStringFile(filepath.Join(deviceDir, "name"))
out.serial = utils.ReadStringFile(filepath.Join(deviceDir, "serial"))
out.revision = utils.ReadStringFile(filepath.Join(deviceDir, "prv"))
if capBytes, ok := readBlockCapacityBytes(blockName); ok {
out.capacity = capBytes
@@ -153,7 +154,7 @@ func readEmmcHealth(blockName string) (emmcHealth, bool) {
}
func readLifeTime(deviceDir string) (uint8, uint8, bool) {
if content, ok := readStringFileOK(filepath.Join(deviceDir, "life_time")); ok {
if content, ok := utils.ReadStringFileOK(filepath.Join(deviceDir, "life_time")); ok {
a, b, ok := parseHexBytePair(content)
return a, b, ok
}
@@ -170,7 +171,7 @@ func readBlockCapacityBytes(blockName string) (uint64, bool) {
sizePath := filepath.Join(emmcSysfsRoot, "class", "block", blockName, "size")
lbsPath := filepath.Join(emmcSysfsRoot, "class", "block", blockName, "queue", "logical_block_size")
sizeStr, ok := readStringFileOK(sizePath)
sizeStr, ok := utils.ReadStringFileOK(sizePath)
if !ok {
return 0, false
}
@@ -179,7 +180,7 @@ func readBlockCapacityBytes(blockName string) (uint64, bool) {
return 0, false
}
lbsStr, ok := readStringFileOK(lbsPath)
lbsStr, ok := utils.ReadStringFileOK(lbsPath)
logicalBlockSize := uint64(512)
if ok {
if parsed, err := strconv.ParseUint(lbsStr, 10, 64); err == nil && parsed > 0 {
@@ -191,7 +192,7 @@ func readBlockCapacityBytes(blockName string) (uint64, bool) {
}
func readHexByteFile(path string) (uint8, bool) {
content, ok := readStringFileOK(path)
content, ok := utils.ReadStringFileOK(path)
if !ok {
return 0, false
}

View File

@@ -1,41 +0,0 @@
package agent
import (
"os"
"strconv"
"strings"
)
// readStringFile returns trimmed file contents or empty string on error.
func readStringFile(path string) string {
content, _ := readStringFileOK(path)
return content
}
// readStringFileOK returns trimmed file contents and read success.
func readStringFileOK(path string) (string, bool) {
b, err := os.ReadFile(path)
if err != nil {
return "", false
}
return strings.TrimSpace(string(b)), true
}
// fileExists reports whether the given path exists.
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// readUintFile parses a decimal uint64 value from a file.
func readUintFile(path string) (uint64, bool) {
raw, ok := readStringFileOK(path)
if !ok {
return 0, false
}
parsed, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return 0, false
}
return parsed, true
}

View File

@@ -15,6 +15,7 @@ import (
"sync"
"time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
)
@@ -291,8 +292,8 @@ func (gm *GPUManager) parseAmdData(output []byte) bool {
}
gpu := gm.GpuDataMap[id]
gpu.Temperature, _ = strconv.ParseFloat(v.Temperature, 64)
gpu.MemoryUsed = bytesToMegabytes(memoryUsage)
gpu.MemoryTotal = bytesToMegabytes(totalMemory)
gpu.MemoryUsed = utils.BytesToMegabytes(memoryUsage)
gpu.MemoryTotal = utils.BytesToMegabytes(totalMemory)
gpu.Usage += usage
gpu.Power += power
gpu.Count++
@@ -366,16 +367,16 @@ func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheK
gpuAvg := *gpu
deltaUsage, deltaPower, deltaPowerPkg := gm.calculateDeltas(gpu, lastSnapshot)
gpuAvg.Power = twoDecimals(deltaPower / float64(deltaCount))
gpuAvg.Power = utils.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))
gpuAvg.PowerPkg = utils.TwoDecimals(deltaPowerPkg / float64(deltaCount))
} else {
gpuAvg.Usage = twoDecimals(deltaUsage / float64(deltaCount))
gpuAvg.Usage = utils.TwoDecimals(deltaUsage / float64(deltaCount))
}
gm.lastAvgData[id] = gpuAvg
@@ -410,17 +411,17 @@ func (gm *GPUManager) calculateIntelGPUUsage(gpuAvg, gpu *system.GPUData, lastSn
} else {
deltaEngine = engine
}
gpuAvg.Engines[name] = twoDecimals(deltaEngine / float64(deltaCount))
gpuAvg.Engines[name] = utils.TwoDecimals(deltaEngine / float64(deltaCount))
maxEngineUsage = max(maxEngineUsage, deltaEngine/float64(deltaCount))
}
return twoDecimals(maxEngineUsage)
return utils.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)
gpuAvg.Temperature = utils.TwoDecimals(gpu.Temperature)
gpuAvg.MemoryUsed = utils.TwoDecimals(gpu.MemoryUsed)
gpuAvg.MemoryTotal = utils.TwoDecimals(gpu.MemoryTotal)
}
// storeSnapshot saves the current GPU state for this cache key
@@ -687,7 +688,7 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
priorities := make([]collectorSource, 0, 4)
if caps.hasNvidiaSmi && !caps.hasTegrastats {
if nvml, _ := GetEnv("NVML"); nvml == "true" {
if nvml, _ := utils.GetEnv("NVML"); nvml == "true" {
priorities = append(priorities, collectorSourceNVML, collectorSourceNvidiaSMI)
} else {
priorities = append(priorities, collectorSourceNvidiaSMI)
@@ -695,7 +696,7 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
}
if caps.hasRocmSmi {
if val, _ := GetEnv("AMD_SYSFS"); val == "true" {
if val, _ := utils.GetEnv("AMD_SYSFS"); val == "true" {
priorities = append(priorities, collectorSourceAmdSysfs)
} else {
priorities = append(priorities, collectorSourceRocmSMI)
@@ -728,7 +729,7 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
// NewGPUManager creates and initializes a new GPUManager
func NewGPUManager() (*GPUManager, error) {
if skipGPU, _ := GetEnv("SKIP_GPU"); skipGPU == "true" {
if skipGPU, _ := utils.GetEnv("SKIP_GPU"); skipGPU == "true" {
return nil, nil
}
var gm GPUManager
@@ -745,7 +746,7 @@ func NewGPUManager() (*GPUManager, error) {
}
// if GPU_COLLECTOR is set, start user-defined collectors.
if collectorConfig, ok := GetEnv("GPU_COLLECTOR"); ok && strings.TrimSpace(collectorConfig) != "" {
if collectorConfig, ok := utils.GetEnv("GPU_COLLECTOR"); ok && strings.TrimSpace(collectorConfig) != "" {
priorities := parseCollectorPriority(collectorConfig)
if gm.startCollectorsByPriority(priorities, caps) == 0 {
return nil, fmt.Errorf("no configured GPU collectors are available")

View File

@@ -13,6 +13,7 @@ import (
"sync"
"time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
)
@@ -32,8 +33,8 @@ func (gm *GPUManager) hasAmdSysfs() bool {
return false
}
for _, vendorPath := range cards {
vendor, err := os.ReadFile(vendorPath)
if err == nil && strings.TrimSpace(string(vendor)) == "0x1002" {
vendor, err := utils.ReadStringFileLimited(vendorPath, 64)
if err == nil && vendor == "0x1002" {
return true
}
}
@@ -87,12 +88,11 @@ func (gm *GPUManager) collectAmdStats() error {
// isAmdGpu checks whether a DRM card path belongs to AMD vendor ID 0x1002.
func isAmdGpu(cardPath string) bool {
vendorPath := filepath.Join(cardPath, "device/vendor")
vendor, err := os.ReadFile(vendorPath)
vendor, err := utils.ReadStringFileLimited(filepath.Join(cardPath, "device/vendor"), 64)
if err != nil {
return false
}
return strings.TrimSpace(string(vendor)) == "0x1002"
return vendor == "0x1002"
}
// updateAmdGpuData reads GPU metrics from sysfs and updates the GPU data map.
@@ -144,8 +144,8 @@ func (gm *GPUManager) updateAmdGpuData(cardPath string) bool {
if usageErr == nil {
gpu.Usage += usage
}
gpu.MemoryUsed = bytesToMegabytes(memUsed)
gpu.MemoryTotal = bytesToMegabytes(memTotal)
gpu.MemoryUsed = utils.BytesToMegabytes(memUsed)
gpu.MemoryTotal = utils.BytesToMegabytes(memTotal)
gpu.Temperature = temp
gpu.Power += power
gpu.Count++
@@ -154,11 +154,11 @@ func (gm *GPUManager) updateAmdGpuData(cardPath string) bool {
// readSysfsFloat reads and parses a numeric value from a sysfs file.
func readSysfsFloat(path string) (float64, error) {
val, err := os.ReadFile(path)
val, err := utils.ReadStringFileLimited(path, 64)
if err != nil {
return 0, err
}
return strconv.ParseFloat(strings.TrimSpace(string(val)), 64)
return strconv.ParseFloat(val, 64)
}
// normalizeHexID normalizes hex IDs by trimming spaces, lowercasing, and dropping 0x.
@@ -273,16 +273,16 @@ func cacheMissingAmdgpuName(deviceID, revisionID string) {
// Falls back to showing the raw device ID if not found in the lookup table.
func getAmdGpuName(devicePath string) string {
// Try product_name first (works for some enterprise GPUs)
if prod, err := os.ReadFile(filepath.Join(devicePath, "product_name")); err == nil {
return strings.TrimSpace(string(prod))
if prod, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "product_name"), 128); err == nil {
return prod
}
// Read PCI device ID and look it up
if deviceID, err := os.ReadFile(filepath.Join(devicePath, "device")); err == nil {
id := normalizeHexID(string(deviceID))
if deviceID, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "device"), 64); err == nil {
id := normalizeHexID(deviceID)
revision := ""
if revBytes, revErr := os.ReadFile(filepath.Join(devicePath, "revision")); revErr == nil {
revision = normalizeHexID(string(revBytes))
if rev, revErr := utils.ReadStringFileLimited(filepath.Join(devicePath, "revision"), 64); revErr == nil {
revision = normalizeHexID(rev)
}
if name, found, done := getCachedAmdgpuName(id, revision); found {

View File

@@ -7,6 +7,7 @@ import (
"path/filepath"
"testing"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -128,14 +129,14 @@ func TestUpdateAmdGpuDataWithFakeSysfs(t *testing.T) {
{
name: "sums vram and gtt when gtt is present",
writeGTT: true,
wantMemoryUsed: bytesToMegabytes(1073741824 + 536870912),
wantMemoryTotal: bytesToMegabytes(2147483648 + 4294967296),
wantMemoryUsed: utils.BytesToMegabytes(1073741824 + 536870912),
wantMemoryTotal: utils.BytesToMegabytes(2147483648 + 4294967296),
},
{
name: "falls back to vram when gtt is missing",
writeGTT: false,
wantMemoryUsed: bytesToMegabytes(1073741824),
wantMemoryTotal: bytesToMegabytes(2147483648),
wantMemoryUsed: utils.BytesToMegabytes(1073741824),
wantMemoryTotal: utils.BytesToMegabytes(2147483648),
},
}

View File

@@ -7,6 +7,7 @@ import (
"strconv"
"strings"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
)
@@ -52,7 +53,7 @@ func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool {
func (gm *GPUManager) collectIntelStats() (err error) {
// Build command arguments, optionally selecting a device via -d
args := []string{"-s", intelGpuStatsInterval, "-l"}
if dev, ok := GetEnv("INTEL_GPU_DEVICE"); ok && dev != "" {
if dev, ok := utils.GetEnv("INTEL_GPU_DEVICE"); ok && dev != "" {
args = append(args, "-d", dev)
}
cmd := exec.Command(intelGpuStatsCmd, args...)

View File

@@ -9,6 +9,7 @@ import (
"strings"
"time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
)
@@ -80,10 +81,10 @@ func (gm *GPUManager) updateNvtopSnapshots(snapshots []nvtopSnapshot) bool {
gpu.Temperature = parseNvtopNumber(*sample.Temp)
}
if sample.MemUsed != nil {
gpu.MemoryUsed = bytesToMegabytes(parseNvtopNumber(*sample.MemUsed))
gpu.MemoryUsed = utils.BytesToMegabytes(parseNvtopNumber(*sample.MemUsed))
}
if sample.MemTotal != nil {
gpu.MemoryTotal = bytesToMegabytes(parseNvtopNumber(*sample.MemTotal))
gpu.MemoryTotal = utils.BytesToMegabytes(parseNvtopNumber(*sample.MemTotal))
}
if sample.GpuUtil != nil {
gpu.Usage += parseNvtopNumber(*sample.GpuUtil)

View File

@@ -10,6 +10,7 @@ import (
"testing"
"time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/stretchr/testify/assert"
@@ -265,8 +266,8 @@ func TestParseNvtopData(t *testing.T) {
assert.Equal(t, 48.0, g0.Temperature)
assert.Equal(t, 5.0, g0.Usage)
assert.Equal(t, 13.0, g0.Power)
assert.Equal(t, bytesToMegabytes(349372416), g0.MemoryUsed)
assert.Equal(t, bytesToMegabytes(4294967296), g0.MemoryTotal)
assert.Equal(t, utils.BytesToMegabytes(349372416), g0.MemoryUsed)
assert.Equal(t, utils.BytesToMegabytes(4294967296), g0.MemoryTotal)
assert.Equal(t, 1.0, g0.Count)
g1, ok := gm.GpuDataMap["n1"]
@@ -275,8 +276,8 @@ func TestParseNvtopData(t *testing.T) {
assert.Equal(t, 48.0, g1.Temperature)
assert.Equal(t, 12.0, g1.Usage)
assert.Equal(t, 9.0, g1.Power)
assert.Equal(t, bytesToMegabytes(1213784064), g1.MemoryUsed)
assert.Equal(t, bytesToMegabytes(16929173504), g1.MemoryTotal)
assert.Equal(t, utils.BytesToMegabytes(1213784064), g1.MemoryUsed)
assert.Equal(t, utils.BytesToMegabytes(16929173504), g1.MemoryTotal)
assert.Equal(t, 1.0, g1.Count)
}
@@ -1082,8 +1083,6 @@ func TestCalculateGPUAverage(t *testing.T) {
func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
// Save original PATH
origPath := os.Getenv("PATH")
defer os.Setenv("PATH", origPath)
hasAmdSysfs := (&GPUManager{}).hasAmdSysfs()
tests := []struct {
@@ -1177,7 +1176,7 @@ echo "[]"`
{
name: "no gpu tools available",
setupCommands: func(_ string) error {
os.Setenv("PATH", "")
t.Setenv("PATH", "")
return nil
},
wantErr: true,
@@ -1187,7 +1186,7 @@ echo "[]"`
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
os.Setenv("PATH", tempDir)
t.Setenv("PATH", tempDir)
if err := tt.setupCommands(tempDir); err != nil {
t.Fatal(err)
}
@@ -1233,13 +1232,9 @@ echo "[]"`
}
func TestCollectorStartHelpers(t *testing.T) {
// Save original PATH
origPath := os.Getenv("PATH")
defer os.Setenv("PATH", origPath)
// Set up temp dir with the commands
dir := t.TempDir()
os.Setenv("PATH", dir)
t.Setenv("PATH", dir)
tests := []struct {
name string
@@ -1369,11 +1364,8 @@ echo '[{"device_name":"NVIDIA Test GPU","temp":"52C","power_draw":"31W","gpu_uti
}
func TestNewGPUManagerPriorityNvtopFallback(t *testing.T) {
origPath := os.Getenv("PATH")
defer os.Setenv("PATH", origPath)
dir := t.TempDir()
os.Setenv("PATH", dir)
t.Setenv("PATH", dir)
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvtop,nvidia-smi")
nvtopPath := filepath.Join(dir, "nvtop")
@@ -1398,11 +1390,8 @@ echo "0, NVIDIA Priority GPU, 45, 512, 2048, 12, 25"`
}
func TestNewGPUManagerPriorityMixedCollectors(t *testing.T) {
origPath := os.Getenv("PATH")
defer os.Setenv("PATH", origPath)
dir := t.TempDir()
os.Setenv("PATH", dir)
t.Setenv("PATH", dir)
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "intel_gpu_top,rocm-smi")
intelPath := filepath.Join(dir, "intel_gpu_top")
@@ -1432,11 +1421,8 @@ echo '{"card0": {"Temperature (Sensor edge) (C)": "49.0", "Current Socket Graphi
}
func TestNewGPUManagerPriorityNvmlFallbackToNvidiaSmi(t *testing.T) {
origPath := os.Getenv("PATH")
defer os.Setenv("PATH", origPath)
dir := t.TempDir()
os.Setenv("PATH", dir)
t.Setenv("PATH", dir)
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvml,nvidia-smi")
nvidiaPath := filepath.Join(dir, "nvidia-smi")
@@ -1455,11 +1441,8 @@ echo "0, NVIDIA Fallback GPU, 41, 256, 1024, 8, 14"`
}
func TestNewGPUManagerConfiguredCollectorsMustStart(t *testing.T) {
origPath := os.Getenv("PATH")
defer os.Setenv("PATH", origPath)
dir := t.TempDir()
os.Setenv("PATH", dir)
t.Setenv("PATH", dir)
t.Run("configured valid collector unavailable", func(t *testing.T) {
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvidia-smi")
@@ -1479,11 +1462,8 @@ func TestNewGPUManagerConfiguredCollectorsMustStart(t *testing.T) {
}
func TestNewGPUManagerJetsonIgnoresCollectorConfig(t *testing.T) {
origPath := os.Getenv("PATH")
defer os.Setenv("PATH", origPath)
dir := t.TempDir()
os.Setenv("PATH", dir)
t.Setenv("PATH", dir)
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvidia-smi")
tegraPath := filepath.Join(dir, "tegrastats")
@@ -1718,12 +1698,8 @@ func TestIntelUpdateFromStats(t *testing.T) {
}
func TestIntelCollectorStreaming(t *testing.T) {
// Save and override PATH
origPath := os.Getenv("PATH")
defer os.Setenv("PATH", origPath)
dir := t.TempDir()
os.Setenv("PATH", dir)
t.Setenv("PATH", dir)
// Create a fake intel_gpu_top that prints -l format with four samples (first will be skipped) and exits
scriptPath := filepath.Join(dir, "intel_gpu_top")

View File

@@ -9,6 +9,7 @@ import (
"strconv"
"strings"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/smart"
)
@@ -42,7 +43,7 @@ func scanMdraidDevices() []*DeviceInfo {
continue
}
mdDir := filepath.Join(blockDir, name, "md")
if !fileExists(filepath.Join(mdDir, "array_state")) {
if !utils.FileExists(filepath.Join(mdDir, "array_state")) {
continue
}
@@ -134,24 +135,24 @@ func readMdraidHealth(blockName string) (mdraidHealth, bool) {
}
mdDir := filepath.Join(mdraidSysfsRoot, "block", blockName, "md")
arrayState, okState := readStringFileOK(filepath.Join(mdDir, "array_state"))
arrayState, okState := utils.ReadStringFileOK(filepath.Join(mdDir, "array_state"))
if !okState {
return out, false
}
out.arrayState = arrayState
out.level = readStringFile(filepath.Join(mdDir, "level"))
out.syncAction = readStringFile(filepath.Join(mdDir, "sync_action"))
out.syncCompleted = readStringFile(filepath.Join(mdDir, "sync_completed"))
out.syncSpeed = readStringFile(filepath.Join(mdDir, "sync_speed"))
out.level = utils.ReadStringFile(filepath.Join(mdDir, "level"))
out.syncAction = utils.ReadStringFile(filepath.Join(mdDir, "sync_action"))
out.syncCompleted = utils.ReadStringFile(filepath.Join(mdDir, "sync_completed"))
out.syncSpeed = utils.ReadStringFile(filepath.Join(mdDir, "sync_speed"))
if val, ok := readUintFile(filepath.Join(mdDir, "raid_disks")); ok {
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "raid_disks")); ok {
out.raidDisks = val
}
if val, ok := readUintFile(filepath.Join(mdDir, "degraded")); ok {
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "degraded")); ok {
out.degraded = val
}
if val, ok := readUintFile(filepath.Join(mdDir, "mismatch_cnt")); ok {
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "mismatch_cnt")); ok {
out.mismatchCnt = val
}
@@ -169,11 +170,18 @@ func mdraidSmartStatus(health mdraidHealth) string {
case "inactive", "faulty", "broken", "stopped":
return "FAILED"
}
// During rebuild/recovery, arrays are often temporarily degraded; report as
// warning instead of hard failure while synchronization is in progress.
syncAction := strings.ToLower(strings.TrimSpace(health.syncAction))
switch syncAction {
case "resync", "recover", "reshape":
return "WARNING"
}
if health.degraded > 0 {
return "FAILED"
}
switch strings.ToLower(strings.TrimSpace(health.syncAction)) {
case "resync", "recover", "reshape", "check", "repair":
switch syncAction {
case "check", "repair":
return "WARNING"
}
switch state {
@@ -205,7 +213,7 @@ func readMdraidBlockCapacityBytes(blockName, root string) (uint64, bool) {
sizePath := filepath.Join(root, "block", blockName, "size")
lbsPath := filepath.Join(root, "block", blockName, "queue", "logical_block_size")
sizeStr, ok := readStringFileOK(sizePath)
sizeStr, ok := utils.ReadStringFileOK(sizePath)
if !ok {
return 0, false
}
@@ -215,7 +223,7 @@ func readMdraidBlockCapacityBytes(blockName, root string) (uint64, bool) {
}
logicalBlockSize := uint64(512)
if lbsStr, ok := readStringFileOK(lbsPath); ok {
if lbsStr, ok := utils.ReadStringFileOK(lbsPath); ok {
if parsed, err := strconv.ParseUint(lbsStr, 10, 64); err == nil && parsed > 0 {
logicalBlockSize = parsed
}

View File

@@ -85,6 +85,9 @@ func TestMdraidSmartStatus(t *testing.T) {
if got := mdraidSmartStatus(mdraidHealth{arrayState: "inactive"}); got != "FAILED" {
t.Fatalf("mdraidSmartStatus(inactive) = %q, want FAILED", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1, syncAction: "recover"}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(degraded+recover) = %q, want WARNING", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1}); got != "FAILED" {
t.Fatalf("mdraidSmartStatus(degraded) = %q, want FAILED", got)
}

View File

@@ -8,6 +8,7 @@ import (
"time"
"github.com/henrygd/beszel/agent/deltatracker"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
psutilNet "github.com/shirou/gopsutil/v4/net"
)
@@ -94,7 +95,7 @@ func (a *Agent) initializeNetIoStats() {
a.netInterfaces = make(map[string]struct{}, 0)
// parse NICS env var for whitelist / blacklist
nicsEnvVal, nicsEnvExists := GetEnv("NICS")
nicsEnvVal, nicsEnvExists := utils.GetEnv("NICS")
var nicCfg *NicConfig
if nicsEnvExists {
nicCfg = newNicConfig(nicsEnvVal)
@@ -103,10 +104,7 @@ func (a *Agent) initializeNetIoStats() {
// get current network I/O stats and record valid interfaces
if netIO, err := psutilNet.IOCounters(true); err == nil {
for _, v := range netIO {
if nicsEnvExists && !isValidNic(v.Name, nicCfg) {
continue
}
if a.skipNetworkInterface(v) {
if skipNetworkInterface(v, nicCfg) {
continue
}
slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv)
@@ -215,10 +213,8 @@ func (a *Agent) applyNetworkTotals(
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)
if bytesSentPerSecond > 10_000_000_000 || bytesRecvPerSecond > 10_000_000_000 {
slog.Warn("Invalid net stats. Resetting.", "sent", bytesSentPerSecond, "recv", bytesRecvPerSecond)
for _, v := range netIO {
if _, exists := a.netInterfaces[v.Name]; !exists {
continue
@@ -228,21 +224,29 @@ func (a *Agent) applyNetworkTotals(
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 {
// skipNetworkInterface returns true if the network interface should be ignored.
func skipNetworkInterface(v psutilNet.IOCountersStat, nicCfg *NicConfig) bool {
if nicCfg != nil {
if !isValidNic(v.Name, nicCfg) {
return true
}
// In whitelist mode, we honor explicit inclusion without auto-filtering.
if !nicCfg.isBlacklist {
return false
}
// In blacklist mode, still apply the auto-filter below.
}
switch {
case strings.HasPrefix(v.Name, "lo"),
strings.HasPrefix(v.Name, "docker"),

View File

@@ -261,6 +261,39 @@ func TestNewNicConfig(t *testing.T) {
})
}
}
func TestSkipNetworkInterface(t *testing.T) {
tests := []struct {
name string
nic psutilNet.IOCountersStat
nicCfg *NicConfig
expectSkip bool
}{
{"loopback lo", psutilNet.IOCountersStat{Name: "lo", BytesSent: 100, BytesRecv: 100}, nil, true},
{"loopback lo0", psutilNet.IOCountersStat{Name: "lo0", BytesSent: 100, BytesRecv: 100}, nil, true},
{"docker prefix", psutilNet.IOCountersStat{Name: "docker0", BytesSent: 100, BytesRecv: 100}, nil, true},
{"br- prefix", psutilNet.IOCountersStat{Name: "br-lan", BytesSent: 100, BytesRecv: 100}, nil, true},
{"veth prefix", psutilNet.IOCountersStat{Name: "veth0abc", BytesSent: 100, BytesRecv: 100}, nil, true},
{"bond prefix", psutilNet.IOCountersStat{Name: "bond0", BytesSent: 100, BytesRecv: 100}, nil, true},
{"cali prefix", psutilNet.IOCountersStat{Name: "cali1234", BytesSent: 100, BytesRecv: 100}, nil, true},
{"zero BytesRecv", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 100, BytesRecv: 0}, nil, true},
{"zero BytesSent", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 0, BytesRecv: 100}, nil, true},
{"both zero", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 0, BytesRecv: 0}, nil, true},
{"normal eth0", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 100, BytesRecv: 200}, nil, false},
{"normal wlan0", psutilNet.IOCountersStat{Name: "wlan0", BytesSent: 1, BytesRecv: 1}, nil, false},
{"whitelist overrides skip (docker)", psutilNet.IOCountersStat{Name: "docker0", BytesSent: 100, BytesRecv: 100}, newNicConfig("docker0"), false},
{"whitelist overrides skip (lo)", psutilNet.IOCountersStat{Name: "lo", BytesSent: 100, BytesRecv: 100}, newNicConfig("lo"), false},
{"whitelist exclusion", psutilNet.IOCountersStat{Name: "eth1", BytesSent: 100, BytesRecv: 100}, newNicConfig("eth0"), true},
{"blacklist skip lo", psutilNet.IOCountersStat{Name: "lo", BytesSent: 100, BytesRecv: 100}, newNicConfig("-eth0"), true},
{"blacklist explicit eth0", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 100, BytesRecv: 100}, newNicConfig("-eth0"), true},
{"blacklist allow eth1", psutilNet.IOCountersStat{Name: "eth1", BytesSent: 100, BytesRecv: 100}, newNicConfig("-eth0"), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expectSkip, skipNetworkInterface(tt.nic, tt.nicCfg))
})
}
}
func TestEnsureNetworkInterfacesMap(t *testing.T) {
var a Agent
var stats system.Stats
@@ -383,8 +416,6 @@ func TestApplyNetworkTotals(t *testing.T) {
totalBytesSent uint64
totalBytesRecv uint64
expectReset bool
expectedNetworkSent float64
expectedNetworkRecv float64
expectedBandwidthSent uint64
expectedBandwidthRecv uint64
}{
@@ -395,8 +426,6 @@ func TestApplyNetworkTotals(t *testing.T) {
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,
},
@@ -424,18 +453,6 @@ func TestApplyNetworkTotals(t *testing.T) {
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,
@@ -443,8 +460,6 @@ func TestApplyNetworkTotals(t *testing.T) {
totalBytesSent: 0,
totalBytesRecv: 0,
expectReset: false,
expectedNetworkSent: 0.0,
expectedNetworkRecv: 0.0,
expectedBandwidthSent: 0,
expectedBandwidthRecv: 0,
},
@@ -481,14 +496,10 @@ func TestApplyNetworkTotals(t *testing.T) {
// 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])

View File

@@ -1,177 +0,0 @@
package agent
import (
"context"
"crypto/tls"
"errors"
"log/slog"
"net/http"
"time"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/luthermonson/go-proxmox"
)
type pveManager struct {
client *proxmox.Client // Client to query PVE API
nodeName string // Cluster node name
cpuCount int // CPU count on node
nodeStatsMap map[string]*container.PveNodeStats // Keeps track of pve node stats
lastInitTry time.Time // Last time node initialization was attempted
}
// newPVEManager creates a new PVE manager - may return nil if required environment variables
// are not set or if there is an error connecting to the API
func newPVEManager() *pveManager {
url, exists := GetEnv("PROXMOX_URL")
if !exists {
url = "https://localhost:8006/api2/json"
}
const nodeEnvVar = "PROXMOX_NODE"
const tokenIDEnvVar = "PROXMOX_TOKENID"
const secretEnvVar = "PROXMOX_SECRET"
nodeName, nodeNameExists := GetEnv(nodeEnvVar)
tokenID, tokenIDExists := GetEnv(tokenIDEnvVar)
secret, secretExists := GetEnv(secretEnvVar)
if !nodeNameExists || !tokenIDExists || !secretExists {
slog.Debug("Proxmox env vars unset", nodeEnvVar, nodeNameExists, tokenIDEnvVar, tokenIDExists, secretEnvVar, secretExists)
return nil
}
// PROXMOX_INSECURE_TLS defaults to true; set to "false" to enable TLS verification
insecureTLS := true
if val, exists := GetEnv("PROXMOX_INSECURE_TLS"); exists {
insecureTLS = val != "false"
}
httpClient := http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: insecureTLS,
},
},
}
client := proxmox.NewClient(url,
proxmox.WithHTTPClient(&httpClient),
proxmox.WithAPIToken(tokenID, secret),
)
pveManager := pveManager{
client: client,
nodeName: nodeName,
nodeStatsMap: make(map[string]*container.PveNodeStats),
}
return &pveManager
}
// ensureInitialized checks if the PVE manager is initialized and attempts to initialize it if not.
// It returns an error if initialization fails or if a retry is pending.
func (pm *pveManager) ensureInitialized(ctx context.Context) error {
if pm.client == nil {
return errors.New("PVE client not configured")
}
if pm.cpuCount > 0 {
return nil
}
if time.Since(pm.lastInitTry) < 30*time.Second {
return errors.New("PVE initialization retry pending")
}
pm.lastInitTry = time.Now()
node, err := pm.client.Node(ctx, pm.nodeName)
if err != nil {
return err
}
if node.CPUInfo.CPUs <= 0 {
return errors.New("node returned zero CPUs")
}
pm.cpuCount = node.CPUInfo.CPUs
return nil
}
// getPVEStats returns stats for all running VMs/LXCs
func (pm *pveManager) getPVEStats() ([]*container.PveNodeStats, error) {
if err := pm.ensureInitialized(context.Background()); err != nil {
slog.Warn("Proxmox API unavailable", "err", err)
return nil, err
}
cluster, err := pm.client.Cluster(context.Background())
if err != nil {
slog.Error("Error getting cluster", "err", err)
return nil, err
}
resources, err := cluster.Resources(context.Background(), "vm")
if err != nil {
slog.Error("Error getting resources", "err", err, "resources", resources)
return nil, err
}
containersLength := len(resources)
resourceIds := make(map[string]struct{}, containersLength)
// only include running vms and lxcs on selected node
for _, resource := range resources {
if resource.Node == pm.nodeName && resource.Status == "running" {
resourceIds[resource.ID] = struct{}{}
}
}
// remove invalid container stats
for id := range pm.nodeStatsMap {
if _, exists := resourceIds[id]; !exists {
delete(pm.nodeStatsMap, id)
}
}
// populate stats
stats := make([]*container.PveNodeStats, 0, len(resourceIds))
for _, resource := range resources {
if _, exists := resourceIds[resource.ID]; !exists {
continue
}
resourceStats, initialized := pm.nodeStatsMap[resource.ID]
if !initialized {
resourceStats = &container.PveNodeStats{}
pm.nodeStatsMap[resource.ID] = resourceStats
}
resourceStats.Name = resource.Name
resourceStats.Id = resource.ID
resourceStats.Type = resource.Type
resourceStats.MaxCPU = resource.MaxCPU
resourceStats.MaxMem = resource.MaxMem
resourceStats.Uptime = resource.Uptime
resourceStats.DiskRead = resource.DiskRead
resourceStats.DiskWrite = resource.DiskWrite
resourceStats.Disk = resource.MaxDisk
// prevent first run from sending all prev sent/recv bytes
total_sent := resource.NetOut
total_recv := resource.NetIn
var sent_delta, recv_delta float64
if initialized {
secondsElapsed := time.Since(resourceStats.PrevReadTime).Seconds()
if secondsElapsed > 0 {
sent_delta = float64(total_sent-resourceStats.PrevNet.Sent) / secondsElapsed
recv_delta = float64(total_recv-resourceStats.PrevNet.Recv) / secondsElapsed
}
}
resourceStats.PrevNet.Sent = total_sent
resourceStats.PrevNet.Recv = total_recv
resourceStats.PrevReadTime = time.Now()
// Update final stats values
resourceStats.Cpu = twoDecimals(100.0 * resource.CPU * float64(resource.MaxCPU) / float64(pm.cpuCount))
resourceStats.Mem = bytesToMegabytes(float64(resource.Mem))
resourceStats.Bandwidth = [2]uint64{uint64(sent_delta), uint64(recv_delta)}
resourceStats.NetOut = total_sent
resourceStats.NetIn = total_recv
stats = append(stats, resourceStats)
}
return stats, nil
}

View File

@@ -1,92 +0,0 @@
package agent
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/luthermonson/go-proxmox"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewPVEManagerDoesNotConnectAtStartup(t *testing.T) {
t.Setenv("BESZEL_AGENT_PROXMOX_URL", "https://127.0.0.1:1/api2/json")
t.Setenv("BESZEL_AGENT_PROXMOX_NODE", "pve")
t.Setenv("BESZEL_AGENT_PROXMOX_TOKENID", "root@pam!test")
t.Setenv("BESZEL_AGENT_PROXMOX_SECRET", "secret")
pm := newPVEManager()
require.NotNil(t, pm)
assert.Zero(t, pm.cpuCount)
}
func TestPVEManagerRetriesInitialization(t *testing.T) {
var nodeRequests atomic.Int32
var clusterRequests atomic.Int32
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api2/json/nodes/pve/status":
nodeRequests.Add(1)
fmt.Fprint(w, `{"data":{"cpuinfo":{"cpus":8}}}`)
case "/api2/json/cluster/status":
fmt.Fprint(w, `{"data":[{"type":"cluster","name":"test-cluster","id":"test-cluster","version":1,"quorate":1}]}`)
case "/api2/json/cluster/resources":
clusterRequests.Add(1)
fmt.Fprint(w, `{"data":[{"id":"qemu/101","type":"qemu","node":"pve","status":"running","name":"vm-101","cpu":0.5,"maxcpu":4,"maxmem":4096,"mem":2048,"netin":1024,"netout":2048,"diskread":10,"diskwrite":20,"maxdisk":8192,"uptime":60}]}`)
default:
t.Fatalf("unexpected path: %s", r.URL.Path)
}
}))
defer server.Close()
pm := &pveManager{
client: proxmox.NewClient(server.URL+"/api2/json",
proxmox.WithHTTPClient(&http.Client{
Transport: &failOnceRoundTripper{
base: server.Client().Transport,
},
}),
proxmox.WithAPIToken("root@pam!test", "secret"),
),
nodeName: "pve",
nodeStatsMap: make(map[string]*container.PveNodeStats),
}
stats, err := pm.getPVEStats()
require.Error(t, err)
assert.Nil(t, stats)
assert.Zero(t, pm.cpuCount)
pm.lastInitTry = time.Now().Add(-31 * time.Second)
stats, err = pm.getPVEStats()
require.NoError(t, err)
require.Len(t, stats, 1)
assert.Equal(t, int32(1), nodeRequests.Load())
assert.Equal(t, int32(1), clusterRequests.Load())
assert.Equal(t, 8, pm.cpuCount)
assert.Equal(t, "qemu/101", stats[0].Id)
assert.Equal(t, 25.0, stats[0].Cpu)
assert.Equal(t, uint64(1024), stats[0].NetIn)
assert.Equal(t, uint64(2048), stats[0].NetOut)
}
type failOnceRoundTripper struct {
base http.RoundTripper
failed atomic.Bool
}
func (rt *failOnceRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if req.URL.Path == "/api2/json/nodes/pve/status" && !rt.failed.Swap(true) {
return nil, errors.New("dial tcp 127.0.0.1:8006: connect: connection refused")
}
return rt.base.RoundTrip(req)
}
var _ http.RoundTripper = (*failOnceRoundTripper)(nil)

View File

@@ -10,6 +10,7 @@ import (
"strings"
"unicode/utf8"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/common"
@@ -26,9 +27,9 @@ type SensorConfig struct {
}
func (a *Agent) newSensorConfig() *SensorConfig {
primarySensor, _ := GetEnv("PRIMARY_SENSOR")
sysSensors, _ := GetEnv("SYS_SENSORS")
sensorsEnvVal, sensorsSet := GetEnv("SENSORS")
primarySensor, _ := utils.GetEnv("PRIMARY_SENSOR")
sysSensors, _ := utils.GetEnv("SYS_SENSORS")
sensorsEnvVal, sensorsSet := utils.GetEnv("SENSORS")
skipCollection := sensorsSet && sensorsEnvVal == ""
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, skipCollection)
@@ -135,7 +136,7 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
case sensorName:
a.systemInfo.DashboardTemp = sensor.Temperature
}
systemStats.Temperatures[sensorName] = twoDecimals(sensor.Temperature)
systemStats.Temperatures[sensorName] = utils.TwoDecimals(sensor.Temperature)
}
}

View File

@@ -5,7 +5,6 @@ package agent
import (
"context"
"fmt"
"os"
"testing"
"github.com/henrygd/beszel/internal/entities/system"
@@ -329,34 +328,10 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
}
func TestNewSensorConfig(t *testing.T) {
// Save original environment variables
originalPrimary, hasPrimary := os.LookupEnv("BESZEL_AGENT_PRIMARY_SENSOR")
originalSys, hasSys := os.LookupEnv("BESZEL_AGENT_SYS_SENSORS")
originalSensors, hasSensors := os.LookupEnv("BESZEL_AGENT_SENSORS")
// Restore environment variables after the test
defer func() {
// Clean up test environment variables
os.Unsetenv("BESZEL_AGENT_PRIMARY_SENSOR")
os.Unsetenv("BESZEL_AGENT_SYS_SENSORS")
os.Unsetenv("BESZEL_AGENT_SENSORS")
// Restore original values if they existed
if hasPrimary {
os.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", originalPrimary)
}
if hasSys {
os.Setenv("BESZEL_AGENT_SYS_SENSORS", originalSys)
}
if hasSensors {
os.Setenv("BESZEL_AGENT_SENSORS", originalSensors)
}
}()
// Set test environment variables
os.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", "test_primary")
os.Setenv("BESZEL_AGENT_SYS_SENSORS", "/test/path")
os.Setenv("BESZEL_AGENT_SENSORS", "test_sensor1,test_*,test_sensor3")
t.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", "test_primary")
t.Setenv("BESZEL_AGENT_SYS_SENSORS", "/test/path")
t.Setenv("BESZEL_AGENT_SENSORS", "test_sensor1,test_*,test_sensor3")
agent := &Agent{}
result := agent.newSensorConfig()

View File

@@ -12,6 +12,7 @@ import (
"time"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/system"
@@ -36,7 +37,7 @@ var hubVersions map[string]semver.Version
// and begins listening for connections. Returns an error if the server
// is already running or if there's an issue starting the server.
func (a *Agent) StartServer(opts ServerOptions) error {
if disableSSH, _ := GetEnv("DISABLE_SSH"); disableSSH == "true" {
if disableSSH, _ := utils.GetEnv("DISABLE_SSH"); disableSSH == "true" {
return errors.New("SSH disabled")
}
if a.server != nil {
@@ -238,11 +239,11 @@ func ParseKeys(input string) ([]gossh.PublicKey, error) {
// and finally defaults to ":45876".
func GetAddress(addr string) string {
if addr == "" {
addr, _ = GetEnv("LISTEN")
addr, _ = utils.GetEnv("LISTEN")
}
if addr == "" {
// Legacy PORT environment variable support
addr, _ = GetEnv("PORT")
addr, _ = utils.GetEnv("PORT")
}
if addr == "" {
return ":45876"
@@ -258,7 +259,7 @@ func GetAddress(addr string) string {
// It checks the NETWORK environment variable first, then infers from
// the address format: addresses starting with "/" are "unix", others are "tcp".
func GetNetwork(addr string) string {
if network, ok := GetEnv("NETWORK"); ok && network != "" {
if network, ok := utils.GetEnv("NETWORK"); ok && network != "" {
return network
}
if strings.HasPrefix(addr, "/") {

View File

@@ -183,8 +183,7 @@ func TestStartServer(t *testing.T) {
}
func TestStartServerDisableSSH(t *testing.T) {
os.Setenv("BESZEL_AGENT_DISABLE_SSH", "true")
defer os.Unsetenv("BESZEL_AGENT_DISABLE_SSH")
t.Setenv("BESZEL_AGENT_DISABLE_SSH", "true")
agent, err := NewAgent("")
require.NoError(t, err)
@@ -559,10 +558,6 @@ func TestWriteToSessionEncoding(t *testing.T) {
// Helper function to create test data for encoding tests
func createTestCombinedData() *system.CombinedData {
var stats = container.Stats{}
stats.Name = "test-container"
stats.Cpu = 10.5
stats.Mem = 1073741824 // 1GB
return &system.CombinedData{
Stats: system.Stats{
Cpu: 25.5,
@@ -581,7 +576,11 @@ func createTestCombinedData() *system.CombinedData {
AgentVersion: "0.12.0",
},
Containers: []*container.Stats{
&stats,
{
Name: "test-container",
Cpu: 10.5,
Mem: 1073741824, // 1GB
},
},
}
}

View File

@@ -18,6 +18,7 @@ import (
"sync"
"time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/smart"
)
@@ -156,7 +157,7 @@ func (sm *SmartManager) ScanDevices(force bool) error {
currentDevices := sm.devicesSnapshot()
var configuredDevices []*DeviceInfo
if configuredRaw, ok := GetEnv("SMART_DEVICES"); ok {
if configuredRaw, ok := utils.GetEnv("SMART_DEVICES"); ok {
slog.Info("SMART_DEVICES", "value", configuredRaw)
config := strings.TrimSpace(configuredRaw)
if config == "" {
@@ -222,7 +223,7 @@ func (sm *SmartManager) ScanDevices(force bool) error {
}
func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, error) {
splitChar := os.Getenv("SMART_DEVICES_SEPARATOR")
splitChar, _ := utils.GetEnv("SMART_DEVICES_SEPARATOR")
if splitChar == "" {
splitChar = ","
}
@@ -260,7 +261,7 @@ func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, er
}
func (sm *SmartManager) refreshExcludedDevices() {
rawValue, _ := GetEnv("EXCLUDE_SMART")
rawValue, _ := utils.GetEnv("EXCLUDE_SMART")
sm.excludedDevices = make(map[string]struct{})
for entry := range strings.SplitSeq(rawValue, ",") {
@@ -870,15 +871,18 @@ func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) {
smartData.FirmwareVersion = data.FirmwareVersion
smartData.Capacity = data.UserCapacity.Bytes
smartData.Temperature = data.Temperature.Current
if smartData.Temperature == 0 {
if temp, ok := temperatureFromAtaDeviceStatistics(data.AtaDeviceStatistics); ok {
smartData.Temperature = temp
}
}
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
smartData.DiskName = data.Device.Name
smartData.DiskType = data.Device.Type
// get values from ata_device_statistics if necessary
var ataDeviceStats smart.AtaDeviceStatistics
if smartData.Temperature == 0 {
if temp := findAtaDeviceStatisticsValue(&data, &ataDeviceStats, 5, "Current Temperature", 0, 255); temp != nil {
smartData.Temperature = uint8(*temp)
}
}
// update SmartAttributes
smartData.Attributes = make([]*smart.SmartAttribute, 0, len(data.AtaSmartAttributes.Table))
for _, attr := range data.AtaSmartAttributes.Table {
@@ -913,23 +917,20 @@ func getSmartStatus(temperature uint8, passed bool) string {
}
}
func temperatureFromAtaDeviceStatistics(stats smart.AtaDeviceStatistics) (uint8, bool) {
entry := findAtaDeviceStatisticsEntry(stats, 5, "Current Temperature")
if entry == nil || entry.Value == nil {
return 0, false
}
if *entry.Value > 255 {
return 0, false
}
return uint8(*entry.Value), true
}
// findAtaDeviceStatisticsEntry centralizes ATA devstat lookups so additional
// metrics can be pulled from the same structure in the future.
func findAtaDeviceStatisticsEntry(stats smart.AtaDeviceStatistics, pageNumber uint8, entryName string) *smart.AtaDeviceStatisticsEntry {
for pageIdx := range stats.Pages {
page := &stats.Pages[pageIdx]
if page.Number != pageNumber {
func findAtaDeviceStatisticsValue(data *smart.SmartInfoForSata, ataDeviceStats *smart.AtaDeviceStatistics, entryNumber uint8, entryName string, minValue, maxValue int64) *int64 {
if len(ataDeviceStats.Pages) == 0 {
if len(data.AtaDeviceStatistics) == 0 {
return nil
}
if err := json.Unmarshal(data.AtaDeviceStatistics, ataDeviceStats); err != nil {
return nil
}
}
for pageIdx := range ataDeviceStats.Pages {
page := &ataDeviceStats.Pages[pageIdx]
if page.Number != entryNumber {
continue
}
for entryIdx := range page.Table {
@@ -937,7 +938,10 @@ func findAtaDeviceStatisticsEntry(stats smart.AtaDeviceStatistics, pageNumber ui
if !strings.EqualFold(entry.Name, entryName) {
continue
}
return entry
if entry.Value == nil || *entry.Value < minValue || *entry.Value > maxValue {
return nil
}
return entry.Value
}
}
return nil

View File

@@ -121,6 +121,78 @@ func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) {
assert.Equal(t, uint8(22), deviceData.Temperature)
}
func TestParseSmartForSataAtaDeviceStatistics(t *testing.T) {
// tests that ata_device_statistics values are parsed correctly
jsonPayload := []byte(`{
"smartctl": {"exit_status": 0},
"device": {"name": "/dev/sdb", "type": "sat"},
"model_name": "SanDisk SSD U110 16GB",
"serial_number": "lksjfh23lhj",
"firmware_version": "U21B001",
"user_capacity": {"bytes": 16013942784},
"smart_status": {"passed": true},
"ata_smart_attributes": {"table": []},
"ata_device_statistics": {
"pages": [
{
"number": 5,
"name": "Temperature Statistics",
"table": [
{"name": "Current Temperature", "value": 43, "flags": {"valid": true}},
{"name": "Specified Minimum Operating Temperature", "value": -20, "flags": {"valid": true}}
]
}
]
}
}`)
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
require.True(t, hasData)
assert.Equal(t, 0, exitStatus)
deviceData, ok := sm.SmartDataMap["lksjfh23lhj"]
require.True(t, ok, "expected smart data entry for serial lksjfh23lhj")
assert.Equal(t, uint8(43), deviceData.Temperature)
}
func TestParseSmartForSataNegativeDeviceStatistics(t *testing.T) {
// Tests that negative values in ata_device_statistics (e.g. min operating temp)
// do not cause the entire SAT parser to fail.
jsonPayload := []byte(`{
"smartctl": {"exit_status": 0},
"device": {"name": "/dev/sdb", "type": "sat"},
"model_name": "SanDisk SSD U110 16GB",
"serial_number": "NEGATIVE123",
"firmware_version": "U21B001",
"user_capacity": {"bytes": 16013942784},
"smart_status": {"passed": true},
"temperature": {"current": 38},
"ata_smart_attributes": {"table": []},
"ata_device_statistics": {
"pages": [
{
"number": 5,
"name": "Temperature Statistics",
"table": [
{"name": "Current Temperature", "value": 38, "flags": {"valid": true}},
{"name": "Specified Minimum Operating Temperature", "value": -20, "flags": {"valid": true}}
]
}
]
}
}`)
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
require.True(t, hasData)
assert.Equal(t, 0, exitStatus)
deviceData, ok := sm.SmartDataMap["NEGATIVE123"]
require.True(t, ok, "expected smart data entry for serial NEGATIVE123")
assert.Equal(t, uint8(38), deviceData.Temperature)
}
func TestParseSmartForSataParentheticalRawValue(t *testing.T) {
jsonPayload := []byte(`{
"smartctl": {"exit_status": 0},
@@ -727,6 +799,182 @@ func TestIsVirtualDeviceScsi(t *testing.T) {
}
}
func TestFindAtaDeviceStatisticsValue(t *testing.T) {
val42 := int64(42)
val100 := int64(100)
valMinus20 := int64(-20)
tests := []struct {
name string
data smart.SmartInfoForSata
ataDeviceStats smart.AtaDeviceStatistics
entryNumber uint8
entryName string
minValue int64
maxValue int64
expectedValue *int64
}{
{
name: "value in ataDeviceStats",
ataDeviceStats: smart.AtaDeviceStatistics{
Pages: []smart.AtaDeviceStatisticsPage{
{
Number: 5,
Table: []smart.AtaDeviceStatisticsEntry{
{Name: "Current Temperature", Value: &val42},
},
},
},
},
entryNumber: 5,
entryName: "Current Temperature",
minValue: 0,
maxValue: 100,
expectedValue: &val42,
},
{
name: "value unmarshaled from data",
data: smart.SmartInfoForSata{
AtaDeviceStatistics: []byte(`{"pages":[{"number":5,"table":[{"name":"Current Temperature","value":100}]}]}`),
},
entryNumber: 5,
entryName: "Current Temperature",
minValue: 0,
maxValue: 255,
expectedValue: &val100,
},
{
name: "value out of range (too high)",
ataDeviceStats: smart.AtaDeviceStatistics{
Pages: []smart.AtaDeviceStatisticsPage{
{
Number: 5,
Table: []smart.AtaDeviceStatisticsEntry{
{Name: "Current Temperature", Value: &val100},
},
},
},
},
entryNumber: 5,
entryName: "Current Temperature",
minValue: 0,
maxValue: 50,
expectedValue: nil,
},
{
name: "value out of range (too low)",
ataDeviceStats: smart.AtaDeviceStatistics{
Pages: []smart.AtaDeviceStatisticsPage{
{
Number: 5,
Table: []smart.AtaDeviceStatisticsEntry{
{Name: "Min Temp", Value: &valMinus20},
},
},
},
},
entryNumber: 5,
entryName: "Min Temp",
minValue: 0,
maxValue: 100,
expectedValue: nil,
},
{
name: "no statistics available",
data: smart.SmartInfoForSata{},
entryNumber: 5,
entryName: "Current Temperature",
minValue: 0,
maxValue: 255,
expectedValue: nil,
},
{
name: "wrong page number",
ataDeviceStats: smart.AtaDeviceStatistics{
Pages: []smart.AtaDeviceStatisticsPage{
{
Number: 1,
Table: []smart.AtaDeviceStatisticsEntry{
{Name: "Current Temperature", Value: &val42},
},
},
},
},
entryNumber: 5,
entryName: "Current Temperature",
minValue: 0,
maxValue: 100,
expectedValue: nil,
},
{
name: "wrong entry name",
ataDeviceStats: smart.AtaDeviceStatistics{
Pages: []smart.AtaDeviceStatisticsPage{
{
Number: 5,
Table: []smart.AtaDeviceStatisticsEntry{
{Name: "Other Stat", Value: &val42},
},
},
},
},
entryNumber: 5,
entryName: "Current Temperature",
minValue: 0,
maxValue: 100,
expectedValue: nil,
},
{
name: "case insensitive name match",
ataDeviceStats: smart.AtaDeviceStatistics{
Pages: []smart.AtaDeviceStatisticsPage{
{
Number: 5,
Table: []smart.AtaDeviceStatisticsEntry{
{Name: "CURRENT TEMPERATURE", Value: &val42},
},
},
},
},
entryNumber: 5,
entryName: "Current Temperature",
minValue: 0,
maxValue: 100,
expectedValue: &val42,
},
{
name: "entry value is nil",
ataDeviceStats: smart.AtaDeviceStatistics{
Pages: []smart.AtaDeviceStatisticsPage{
{
Number: 5,
Table: []smart.AtaDeviceStatisticsEntry{
{Name: "Current Temperature", Value: nil},
},
},
},
},
entryNumber: 5,
entryName: "Current Temperature",
minValue: 0,
maxValue: 100,
expectedValue: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := findAtaDeviceStatisticsValue(&tt.data, &tt.ataDeviceStats, tt.entryNumber, tt.entryName, tt.minValue, tt.maxValue)
if tt.expectedValue == nil {
assert.Nil(t, result)
} else {
require.NotNil(t, result)
assert.Equal(t, *tt.expectedValue, *result)
}
})
}
}
func TestRefreshExcludedDevices(t *testing.T) {
tests := []struct {
name string
@@ -787,7 +1035,7 @@ func TestRefreshExcludedDevices(t *testing.T) {
t.Setenv("EXCLUDE_SMART", tt.envValue)
} else {
// Ensure env var is not set for empty test
os.Unsetenv("EXCLUDE_SMART")
t.Setenv("EXCLUDE_SMART", "")
}
sm := &SmartManager{}

View File

@@ -12,6 +12,7 @@ import (
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/agent/battery"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/agent/zfs"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/system"
@@ -127,13 +128,13 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
// cpu metrics
cpuMetrics, err := getCpuMetrics(cacheTimeMs)
if err == nil {
systemStats.Cpu = twoDecimals(cpuMetrics.Total)
systemStats.Cpu = utils.TwoDecimals(cpuMetrics.Total)
systemStats.CpuBreakdown = []float64{
twoDecimals(cpuMetrics.User),
twoDecimals(cpuMetrics.System),
twoDecimals(cpuMetrics.Iowait),
twoDecimals(cpuMetrics.Steal),
twoDecimals(cpuMetrics.Idle),
utils.TwoDecimals(cpuMetrics.User),
utils.TwoDecimals(cpuMetrics.System),
utils.TwoDecimals(cpuMetrics.Iowait),
utils.TwoDecimals(cpuMetrics.Steal),
utils.TwoDecimals(cpuMetrics.Idle),
}
} else {
slog.Error("Error getting cpu metrics", "err", err)
@@ -157,8 +158,8 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
// memory
if v, err := mem.VirtualMemory(); err == nil {
// swap
systemStats.Swap = bytesToGigabytes(v.SwapTotal)
systemStats.SwapUsed = bytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached)
systemStats.Swap = utils.BytesToGigabytes(v.SwapTotal)
systemStats.SwapUsed = utils.BytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached)
// cache + buffers value for default mem calculation
// note: gopsutil automatically adds SReclaimable to v.Cached
cacheBuff := v.Cached + v.Buffers - v.Shared
@@ -181,13 +182,13 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < v.Used {
v.Used = v.Used - arcSize
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
systemStats.MemZfsArc = bytesToGigabytes(arcSize)
systemStats.MemZfsArc = utils.BytesToGigabytes(arcSize)
}
}
systemStats.Mem = bytesToGigabytes(v.Total)
systemStats.MemBuffCache = bytesToGigabytes(cacheBuff)
systemStats.MemUsed = bytesToGigabytes(v.Used)
systemStats.MemPct = twoDecimals(v.UsedPercent)
systemStats.Mem = utils.BytesToGigabytes(v.Total)
systemStats.MemBuffCache = utils.BytesToGigabytes(cacheBuff)
systemStats.MemUsed = utils.BytesToGigabytes(v.Used)
systemStats.MemPct = utils.TwoDecimals(v.UsedPercent)
}
// disk usage

View File

@@ -15,6 +15,7 @@ import (
"time"
"github.com/coreos/go-systemd/v22/dbus"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/systemd"
)
@@ -49,7 +50,7 @@ func isSystemdAvailable() bool {
// newSystemdManager creates a new systemdManager.
func newSystemdManager() (*systemdManager, error) {
if skipSystemd, _ := GetEnv("SKIP_SYSTEMD"); skipSystemd == "true" {
if skipSystemd, _ := utils.GetEnv("SKIP_SYSTEMD"); skipSystemd == "true" {
return nil, nil
}
@@ -294,13 +295,13 @@ func unescapeServiceName(name string) string {
// otherwise defaults to "*service".
func getServicePatterns() []string {
patterns := []string{}
if envPatterns, _ := GetEnv("SERVICE_PATTERNS"); envPatterns != "" {
if envPatterns, _ := utils.GetEnv("SERVICE_PATTERNS"); envPatterns != "" {
for pattern := range strings.SplitSeq(envPatterns, ",") {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
continue
}
if !strings.HasSuffix(pattern, ".service") {
if !strings.HasSuffix(pattern, "timer") && !strings.HasSuffix(pattern, ".service") {
pattern += ".service"
}
patterns = append(patterns, pattern)

View File

@@ -156,20 +156,23 @@ func TestGetServicePatterns(t *testing.T) {
expected: []string{"*nginx*.service", "*apache*.service"},
cleanupEnvVars: true,
},
{
name: "opt into timer monitoring",
prefixedEnv: "nginx.service,docker,apache.timer",
unprefixedEnv: "",
expected: []string{"nginx.service", "docker.service", "apache.timer"},
cleanupEnvVars: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Clean up any existing env vars
os.Unsetenv("BESZEL_AGENT_SERVICE_PATTERNS")
os.Unsetenv("SERVICE_PATTERNS")
// Set up environment variables
if tt.prefixedEnv != "" {
os.Setenv("BESZEL_AGENT_SERVICE_PATTERNS", tt.prefixedEnv)
t.Setenv("BESZEL_AGENT_SERVICE_PATTERNS", tt.prefixedEnv)
}
if tt.unprefixedEnv != "" {
os.Setenv("SERVICE_PATTERNS", tt.unprefixedEnv)
t.Setenv("SERVICE_PATTERNS", tt.unprefixedEnv)
}
// Run the function
@@ -177,12 +180,6 @@ func TestGetServicePatterns(t *testing.T) {
// Verify results
assert.Equal(t, tt.expected, result, "Patterns should match expected values")
// Cleanup
if tt.cleanupEnvVars {
os.Unsetenv("BESZEL_AGENT_SERVICE_PATTERNS")
os.Unsetenv("SERVICE_PATTERNS")
}
})
}
}

View File

@@ -1,15 +0,0 @@
package agent
import "math"
func bytesToMegabytes(b float64) float64 {
return twoDecimals(b / 1048576)
}
func bytesToGigabytes(b uint64) float64 {
return twoDecimals(float64(b) / 1073741824)
}
func twoDecimals(value float64) float64 {
return math.Round(value*100) / 100
}

88
agent/utils/utils.go Normal file
View File

@@ -0,0 +1,88 @@
package utils
import (
"io"
"math"
"os"
"strconv"
"strings"
)
// GetEnv retrieves an environment variable with a "BESZEL_AGENT_" prefix, or falls back to the unprefixed key.
func GetEnv(key string) (value string, exists bool) {
if value, exists = os.LookupEnv("BESZEL_AGENT_" + key); exists {
return value, exists
}
return os.LookupEnv(key)
}
// BytesToMegabytes converts bytes to megabytes and rounds to two decimal places.
func BytesToMegabytes(b float64) float64 {
return TwoDecimals(b / 1048576)
}
// BytesToGigabytes converts bytes to gigabytes and rounds to two decimal places.
func BytesToGigabytes(b uint64) float64 {
return TwoDecimals(float64(b) / 1073741824)
}
// TwoDecimals rounds a float64 value to two decimal places.
func TwoDecimals(value float64) float64 {
return math.Round(value*100) / 100
}
// func RoundFloat(val float64, precision uint) float64 {
// ratio := math.Pow(10, float64(precision))
// return math.Round(val*ratio) / ratio
// }
// ReadStringFile returns trimmed file contents or empty string on error.
func ReadStringFile(path string) string {
content, _ := ReadStringFileOK(path)
return content
}
// ReadStringFileOK returns trimmed file contents and read success.
func ReadStringFileOK(path string) (string, bool) {
b, err := os.ReadFile(path)
if err != nil {
return "", false
}
return strings.TrimSpace(string(b)), true
}
// ReadStringFileLimited reads a file into a string with a maximum size (in bytes) to avoid
// allocating large buffers and potential panics with pseudo-files when the size is misreported.
func ReadStringFileLimited(path string, maxSize int) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
buf := make([]byte, maxSize)
n, err := f.Read(buf)
if err != nil && err != io.EOF {
return "", err
}
return strings.TrimSpace(string(buf[:n])), nil
}
// FileExists reports whether the given path exists.
func FileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// ReadUintFile parses a decimal uint64 value from a file.
func ReadUintFile(path string) (uint64, bool) {
raw, ok := ReadStringFileOK(path)
if !ok {
return 0, false
}
parsed, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return 0, false
}
return parsed, true
}

158
agent/utils/utils_test.go Normal file
View File

@@ -0,0 +1,158 @@
package utils
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
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 TestBytesToGigabytes(t *testing.T) {
tests := []struct {
name string
input uint64
expected float64
}{
{"1 GB", 1073741824, 1.0},
{"512 MB", 536870912, 0.5},
{"0 GB", 0, 0},
{"2 GB", 2147483648, 2.0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := BytesToGigabytes(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestFileFunctions(t *testing.T) {
tmpDir := t.TempDir()
testFilePath := filepath.Join(tmpDir, "test.txt")
testContent := "hello world"
// Test FileExists (false)
assert.False(t, FileExists(testFilePath))
// Test ReadStringFileOK (false)
content, ok := ReadStringFileOK(testFilePath)
assert.False(t, ok)
assert.Empty(t, content)
// Test ReadStringFile (empty)
assert.Empty(t, ReadStringFile(testFilePath))
// Write file
err := os.WriteFile(testFilePath, []byte(testContent+"\n "), 0644)
assert.NoError(t, err)
// Test FileExists (true)
assert.True(t, FileExists(testFilePath))
// Test ReadStringFileOK (true)
content, ok = ReadStringFileOK(testFilePath)
assert.True(t, ok)
assert.Equal(t, testContent, content)
// Test ReadStringFile (content)
assert.Equal(t, testContent, ReadStringFile(testFilePath))
}
func TestReadUintFile(t *testing.T) {
tmpDir := t.TempDir()
t.Run("valid uint", func(t *testing.T) {
path := filepath.Join(tmpDir, "uint.txt")
os.WriteFile(path, []byte(" 12345\n"), 0644)
val, ok := ReadUintFile(path)
assert.True(t, ok)
assert.Equal(t, uint64(12345), val)
})
t.Run("invalid uint", func(t *testing.T) {
path := filepath.Join(tmpDir, "invalid.txt")
os.WriteFile(path, []byte("abc"), 0644)
val, ok := ReadUintFile(path)
assert.False(t, ok)
assert.Equal(t, uint64(0), val)
})
t.Run("missing file", func(t *testing.T) {
path := filepath.Join(tmpDir, "missing.txt")
val, ok := ReadUintFile(path)
assert.False(t, ok)
assert.Equal(t, uint64(0), val)
})
}
func TestGetEnv(t *testing.T) {
key := "TEST_VAR"
prefixedKey := "BESZEL_AGENT_" + key
t.Run("prefixed variable exists", func(t *testing.T) {
t.Setenv(prefixedKey, "prefixed_val")
t.Setenv(key, "unprefixed_val")
val, exists := GetEnv(key)
assert.True(t, exists)
assert.Equal(t, "prefixed_val", val)
})
t.Run("only unprefixed variable exists", func(t *testing.T) {
t.Setenv(key, "unprefixed_val")
val, exists := GetEnv(key)
assert.True(t, exists)
assert.Equal(t, "unprefixed_val", val)
})
t.Run("neither variable exists", func(t *testing.T) {
val, exists := GetEnv(key)
assert.False(t, exists)
assert.Empty(t, val)
})
}

9
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/henrygd/beszel
go 1.26.0
go 1.26.1
require (
github.com/blang/semver v3.5.1+incompatible
@@ -10,7 +10,6 @@ require (
github.com/fxamacker/cbor/v2 v2.9.0
github.com/gliderlabs/ssh v0.3.8
github.com/google/uuid v1.6.0
github.com/luthermonson/go-proxmox v0.4.0
github.com/lxzan/gws v1.8.9
github.com/nicholas-fedor/shoutrrr v0.13.2
github.com/pocketbase/dbx v1.12.0
@@ -29,11 +28,8 @@ require (
require (
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/buger/goterm v1.0.4 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/diskfs/go-diskfs v1.7.0 // indirect
github.com/djherbis/times v1.6.0 // indirect
github.com/dolthub/maphash v0.1.0 // indirect
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -45,12 +41,9 @@ require (
github.com/go-sql-driver/mysql v1.9.1 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jinzhu/copier v0.3.4 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
github.com/magefile/mage v1.14.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect

34
go.sum
View File

@@ -2,8 +2,6 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs=
github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
@@ -11,8 +9,6 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY=
github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -21,12 +17,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/diskfs/go-diskfs v1.7.0 h1:vonWmt5CMowXwUc79jWyGrf2DIMeoOjkLlMnQYGVOs8=
github.com/diskfs/go-diskfs v1.7.0/go.mod h1:LhQyXqOugWFRahYUSw47NyZJPezFzB9UELwhpszLP/k=
github.com/distatus/battery v0.11.0 h1:KJk89gz90Iq/wJtbjjM9yUzBXV+ASV/EG2WOOL7N8lc=
github.com/distatus/battery v0.11.0/go.mod h1:KmVkE8A8hpIX4T78QRdMktYpEp35QfOL8A8dwZBxq2k=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
@@ -35,8 +27,6 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab h1:h1UgjJdAAhj+uPL68n7XASS6bU+07ZX1WJvVS2eyoeY=
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -61,8 +51,6 @@ github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtS
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
@@ -74,12 +62,6 @@ github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/v
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE=
github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -87,8 +69,6 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jinzhu/copier v0.3.4 h1:mfU6jI9PtCeUjkjQ322dlff9ELjGDu975C2p/nrubVI=
github.com/jinzhu/copier v0.3.4/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -97,12 +77,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/luthermonson/go-proxmox v0.4.0 h1:LKXpG9d64zTaQF79wV0kfOnnSwIcdG39m7sc4ga+XZs=
github.com/luthermonson/go-proxmox v0.4.0/go.mod h1:U6dAkJ+iiwaeb1g/LMWpWuWN4nmvWeXhmoMuYJMumS4=
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -115,10 +91,6 @@ github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc=
github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/xattr v0.4.9 h1:5883YPCtkSd8LFbs13nXplj9g9tlrwoJRjgpgMu1/fE=
github.com/pkg/xattr v0.4.9/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -135,8 +107,6 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo=
github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc=
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0=
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
@@ -152,8 +122,6 @@ github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYI
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8=
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
@@ -180,8 +148,6 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=

View File

@@ -21,9 +21,9 @@ type hubLike interface {
type AlertManager struct {
hub hubLike
alertQueue chan alertTask
stopChan chan struct{}
stopOnce sync.Once
pendingAlerts sync.Map
alertsCache *AlertsCache
}
type AlertMessageData struct {
@@ -40,16 +40,22 @@ type UserNotificationSettings struct {
Webhooks []string `json:"webhooks"`
}
type SystemAlertFsStats struct {
DiskTotal float64 `json:"d"`
DiskUsed float64 `json:"du"`
}
// Values pulled from system_stats.stats that are relevant to alerts.
type SystemAlertStats struct {
Cpu float64 `json:"cpu"`
Mem float64 `json:"mp"`
Disk float64 `json:"dp"`
NetSent float64 `json:"ns"`
NetRecv float64 `json:"nr"`
Bandwidth [2]uint64 `json:"b"`
GPU map[string]SystemAlertGPUData `json:"g"`
Temperatures map[string]float32 `json:"t"`
LoadAvg [3]float64 `json:"la"`
Battery [2]uint8 `json:"bat"`
ExtraFs map[string]SystemAlertFsStats `json:"efs"`
}
type SystemAlertGPUData struct {
@@ -58,7 +64,7 @@ type SystemAlertGPUData struct {
type SystemAlertData struct {
systemRecord *core.Record
alertRecord *core.Record
alertData CachedAlertData
name string
unit string
val float64
@@ -92,12 +98,10 @@ var supportsTitle = map[string]struct{}{
// NewAlertManager creates a new AlertManager instance.
func NewAlertManager(app hubLike) *AlertManager {
am := &AlertManager{
hub: app,
alertQueue: make(chan alertTask, 5),
stopChan: make(chan struct{}),
hub: app,
alertsCache: NewAlertsCache(app),
}
am.bindEvents()
go am.startWorker()
return am
}
@@ -106,6 +110,19 @@ func (am *AlertManager) bindEvents() {
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
am.hub.OnRecordAfterUpdateSuccess("smart_devices").BindFunc(am.handleSmartDeviceAlert)
am.hub.OnServe().BindFunc(func(e *core.ServeEvent) error {
// Populate all alerts into cache on startup
_ = am.alertsCache.PopulateFromDB(true)
if err := resolveStatusAlerts(e.App); err != nil {
e.App.Logger().Error("Failed to resolve stale status alerts", "err", err)
}
if err := am.restorePendingStatusAlerts(); err != nil {
e.App.Logger().Error("Failed to restore pending status alerts", "err", err)
}
return e.Next()
})
}
// IsNotificationSilenced checks if a notification should be silenced based on configured quiet hours
@@ -259,13 +276,14 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
}
// Add link
if scheme == "ntfy" {
switch scheme {
case "ntfy":
queryParams.Add("Actions", fmt.Sprintf("view, %s, %s", linkText, link))
} else if scheme == "lark" {
case "lark":
queryParams.Add("link", link)
} else if scheme == "bark" {
case "bark":
queryParams.Add("url", link)
} else {
default:
message += "\n\n" + link
}
@@ -298,3 +316,13 @@ func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
}
return e.JSON(200, map[string]bool{"err": false})
}
// setAlertTriggered updates the "triggered" status of an alert record in the database
func (am *AlertManager) setAlertTriggered(alert CachedAlertData, triggered bool) error {
alertRecord, err := am.hub.FindRecordById("alerts", alert.Id)
if err != nil {
return err
}
alertRecord.Set("triggered", triggered)
return am.hub.Save(alertRecord)
}

View File

@@ -0,0 +1,177 @@
package alerts
import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/store"
)
// CachedAlertData represents the relevant fields of an alert record for status checking and updates.
type CachedAlertData struct {
Id string
SystemID string
UserID string
Name string
Value float64
Triggered bool
Min uint8
// Created types.DateTime
}
func (a *CachedAlertData) PopulateFromRecord(record *core.Record) {
a.Id = record.Id
a.SystemID = record.GetString("system")
a.UserID = record.GetString("user")
a.Name = record.GetString("name")
a.Value = record.GetFloat("value")
a.Triggered = record.GetBool("triggered")
a.Min = uint8(record.GetInt("min"))
// a.Created = record.GetDateTime("created")
}
// AlertsCache provides an in-memory cache for system alerts.
type AlertsCache struct {
app core.App
store *store.Store[string, *store.Store[string, CachedAlertData]]
populated bool
}
// NewAlertsCache creates a new instance of SystemAlertsCache.
func NewAlertsCache(app core.App) *AlertsCache {
c := AlertsCache{
app: app,
store: store.New(map[string]*store.Store[string, CachedAlertData]{}),
}
return c.bindEvents()
}
// bindEvents sets up event listeners to keep the cache in sync with database changes.
func (c *AlertsCache) bindEvents() *AlertsCache {
c.app.OnRecordAfterUpdateSuccess("alerts").BindFunc(func(e *core.RecordEvent) error {
// c.Delete(e.Record.Original()) // this would be needed if the system field on an existing alert was changed, however we don't currently allow that in the UI so we'll leave it commented out
c.Update(e.Record)
return e.Next()
})
c.app.OnRecordAfterDeleteSuccess("alerts").BindFunc(func(e *core.RecordEvent) error {
c.Delete(e.Record)
return e.Next()
})
c.app.OnRecordAfterCreateSuccess("alerts").BindFunc(func(e *core.RecordEvent) error {
c.Update(e.Record)
return e.Next()
})
return c
}
// PopulateFromDB clears current entries and loads all alerts from the database into the cache.
func (c *AlertsCache) PopulateFromDB(force bool) error {
if !force && c.populated {
return nil
}
records, err := c.app.FindAllRecords("alerts")
if err != nil {
return err
}
c.store.RemoveAll()
for _, record := range records {
c.Update(record)
}
c.populated = true
return nil
}
// Update adds or updates an alert record in the cache.
func (c *AlertsCache) Update(record *core.Record) {
systemID := record.GetString("system")
if systemID == "" {
return
}
systemStore, ok := c.store.GetOk(systemID)
if !ok {
systemStore = store.New(map[string]CachedAlertData{})
c.store.Set(systemID, systemStore)
}
var ca CachedAlertData
ca.PopulateFromRecord(record)
systemStore.Set(record.Id, ca)
}
// Delete removes an alert record from the cache.
func (c *AlertsCache) Delete(record *core.Record) {
systemID := record.GetString("system")
if systemID == "" {
return
}
if systemStore, ok := c.store.GetOk(systemID); ok {
systemStore.Remove(record.Id)
}
}
// GetSystemAlerts returns all alerts for the specified system, lazy-loading if necessary.
func (c *AlertsCache) GetSystemAlerts(systemID string) []CachedAlertData {
systemStore, ok := c.store.GetOk(systemID)
if !ok {
// Populate cache for this system
records, err := c.app.FindAllRecords("alerts", dbx.NewExp("system={:system}", dbx.Params{"system": systemID}))
if err != nil {
return nil
}
systemStore = store.New(map[string]CachedAlertData{})
for _, record := range records {
var ca CachedAlertData
ca.PopulateFromRecord(record)
systemStore.Set(record.Id, ca)
}
c.store.Set(systemID, systemStore)
}
all := systemStore.GetAll()
alerts := make([]CachedAlertData, 0, len(all))
for _, alert := range all {
alerts = append(alerts, alert)
}
return alerts
}
// GetAlert returns a specific alert by its ID from the cache.
func (c *AlertsCache) GetAlert(systemID, alertID string) (CachedAlertData, bool) {
if systemStore, ok := c.store.GetOk(systemID); ok {
return systemStore.GetOk(alertID)
}
return CachedAlertData{}, false
}
// GetAlertsByName returns all alerts of a specific type for the specified system.
func (c *AlertsCache) GetAlertsByName(systemID, alertName string) []CachedAlertData {
allAlerts := c.GetSystemAlerts(systemID)
var alerts []CachedAlertData
for _, record := range allAlerts {
if record.Name == alertName {
alerts = append(alerts, record)
}
}
return alerts
}
// GetAlertsExcludingNames returns all alerts for the specified system excluding the given types.
func (c *AlertsCache) GetAlertsExcludingNames(systemID string, excludedNames ...string) []CachedAlertData {
excludeMap := make(map[string]struct{})
for _, name := range excludedNames {
excludeMap[name] = struct{}{}
}
allAlerts := c.GetSystemAlerts(systemID)
var alerts []CachedAlertData
for _, record := range allAlerts {
if _, excluded := excludeMap[record.Name]; !excluded {
alerts = append(alerts, record)
}
}
return alerts
}
// Refresh returns the latest cached copy for an alert snapshot if it still exists.
func (c *AlertsCache) Refresh(alert CachedAlertData) (CachedAlertData, bool) {
if alert.Id == "" {
return CachedAlertData{}, false
}
return c.GetAlert(alert.SystemID, alert.Id)
}

View File

@@ -0,0 +1,215 @@
//go:build testing
package alerts_test
import (
"testing"
"github.com/henrygd/beszel/internal/alerts"
beszelTests "github.com/henrygd/beszel/internal/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSystemAlertsCachePopulateAndFilter(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
systems, err := beszelTests.CreateSystems(hub, 2, user.Id, "up")
require.NoError(t, err)
system1 := systems[0]
system2 := systems[1]
statusAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Status",
"system": system1.Id,
"user": user.Id,
"min": 1,
})
require.NoError(t, err)
cpuAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "CPU",
"system": system1.Id,
"user": user.Id,
"value": 80,
"min": 1,
})
require.NoError(t, err)
memoryAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Memory",
"system": system2.Id,
"user": user.Id,
"value": 90,
"min": 1,
})
require.NoError(t, err)
cache := alerts.NewAlertsCache(hub)
cache.PopulateFromDB(false)
statusAlerts := cache.GetAlertsByName(system1.Id, "Status")
require.Len(t, statusAlerts, 1)
assert.Equal(t, statusAlert.Id, statusAlerts[0].Id)
nonStatusAlerts := cache.GetAlertsExcludingNames(system1.Id, "Status")
require.Len(t, nonStatusAlerts, 1)
assert.Equal(t, cpuAlert.Id, nonStatusAlerts[0].Id)
system2Alerts := cache.GetSystemAlerts(system2.Id)
require.Len(t, system2Alerts, 1)
assert.Equal(t, memoryAlert.Id, system2Alerts[0].Id)
}
func TestSystemAlertsCacheLazyLoadUpdateAndDelete(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
require.NoError(t, err)
systemRecord := systems[0]
statusAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Status",
"system": systemRecord.Id,
"user": user.Id,
"min": 1,
})
require.NoError(t, err)
cache := alerts.NewAlertsCache(hub)
require.Len(t, cache.GetSystemAlerts(systemRecord.Id), 1, "first lookup should lazy-load alerts for the system")
cpuAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "CPU",
"system": systemRecord.Id,
"user": user.Id,
"value": 80,
"min": 1,
})
require.NoError(t, err)
cache.Update(cpuAlert)
nonStatusAlerts := cache.GetAlertsExcludingNames(systemRecord.Id, "Status")
require.Len(t, nonStatusAlerts, 1)
assert.Equal(t, cpuAlert.Id, nonStatusAlerts[0].Id)
cache.Delete(statusAlert)
assert.Empty(t, cache.GetAlertsByName(systemRecord.Id, "Status"), "deleted alerts should be removed from the in-memory cache")
}
func TestSystemAlertsCacheRefreshReturnsLatestCopy(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
require.NoError(t, err)
system := systems[0]
alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Status",
"system": system.Id,
"user": user.Id,
"min": 1,
"triggered": false,
})
require.NoError(t, err)
cache := alerts.NewAlertsCache(hub)
snapshot := cache.GetSystemAlerts(system.Id)[0]
assert.False(t, snapshot.Triggered)
alert.Set("triggered", true)
require.NoError(t, hub.Save(alert))
refreshed, ok := cache.Refresh(snapshot)
require.True(t, ok)
assert.Equal(t, snapshot.Id, refreshed.Id)
assert.True(t, refreshed.Triggered, "refresh should return the updated cached value rather than the stale snapshot")
require.NoError(t, hub.Delete(alert))
_, ok = cache.Refresh(snapshot)
assert.False(t, ok, "refresh should report false when the cached alert no longer exists")
}
func TestAlertManagerCacheLifecycle(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
require.NoError(t, err)
system := systems[0]
// Create an alert
alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "CPU",
"system": system.Id,
"user": user.Id,
"value": 80,
"min": 1,
})
require.NoError(t, err)
am := hub.AlertManager
cache := am.GetSystemAlertsCache()
// Verify it's in cache (it should be since CreateRecord triggers the event)
assert.Len(t, cache.GetSystemAlerts(system.Id), 1)
assert.Equal(t, alert.Id, cache.GetSystemAlerts(system.Id)[0].Id)
assert.EqualValues(t, 80, cache.GetSystemAlerts(system.Id)[0].Value)
// Update the alert through PocketBase to trigger events
alert.Set("value", 85)
require.NoError(t, hub.Save(alert))
// Check if updated value is reflected (or at least that it's still there)
cachedAlerts := cache.GetSystemAlerts(system.Id)
assert.Len(t, cachedAlerts, 1)
assert.EqualValues(t, 85, cachedAlerts[0].Value)
// Delete the alert through PocketBase to trigger events
require.NoError(t, hub.Delete(alert))
// Verify it's removed from cache
assert.Empty(t, cache.GetSystemAlerts(system.Id), "alert should be removed from cache after PocketBase delete")
}
// func TestAlertManagerCacheMovesAlertToNewSystemOnUpdate(t *testing.T) {
// hub, user := beszelTests.GetHubWithUser(t)
// defer hub.Cleanup()
// systems, err := beszelTests.CreateSystems(hub, 2, user.Id, "up")
// require.NoError(t, err)
// system1 := systems[0]
// system2 := systems[1]
// alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
// "name": "CPU",
// "system": system1.Id,
// "user": user.Id,
// "value": 80,
// "min": 1,
// })
// require.NoError(t, err)
// am := hub.AlertManager
// cache := am.GetSystemAlertsCache()
// // Initially in system1 cache
// assert.Len(t, cache.Get(system1.Id), 1)
// assert.Empty(t, cache.Get(system2.Id))
// // Move alert to system2
// alert.Set("system", system2.Id)
// require.NoError(t, hub.Save(alert))
// // DEBUG: print if it is found
// // fmt.Printf("system1 alerts after update: %v\n", cache.Get(system1.Id))
// // Should be removed from system1 and present in system2
// assert.Empty(t, cache.GetType(system1.Id, "CPU"), "updated alerts should be evicted from the previous system cache")
// require.Len(t, cache.Get(system2.Id), 1)
// assert.Equal(t, alert.Id, cache.Get(system2.Id)[0].Id)
// }

View File

@@ -0,0 +1,155 @@
//go:build testing
package alerts_test
import (
"encoding/json"
"testing"
"time"
"github.com/henrygd/beszel/internal/entities/system"
beszelTests "github.com/henrygd/beszel/internal/tests"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestDiskAlertExtraFsMultiMinute tests that multi-minute disk alerts correctly use
// historical per-minute values for extra (non-root) filesystems, not the current live snapshot.
func TestDiskAlertExtraFsMultiMinute(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
require.NoError(t, err)
systemRecord := systems[0]
// Disk alert: threshold 80%, min=2 (requires historical averaging)
diskAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Disk",
"system": systemRecord.Id,
"user": user.Id,
"value": 80, // threshold: 80%
"min": 2, // 2 minutes - requires historical averaging
})
require.NoError(t, err)
assert.False(t, diskAlert.GetBool("triggered"), "Alert should not be triggered initially")
am := hub.GetAlertManager()
now := time.Now().UTC()
extraFsHigh := map[string]*system.FsStats{
"/mnt/data": {DiskTotal: 1000, DiskUsed: 920}, // 92% - above threshold
}
// Insert 4 historical records spread over 3 minutes (same pattern as battery tests).
// The oldest record must predate (now - 2min) so the alert time window is valid.
recordTimes := []time.Duration{
-180 * time.Second, // 3 min ago - anchors oldest record before alert.time
-90 * time.Second,
-60 * time.Second,
-30 * time.Second,
}
for _, offset := range recordTimes {
stats := system.Stats{
DiskPct: 30, // root disk at 30% - below threshold
ExtraFs: extraFsHigh,
}
statsJSON, _ := json.Marshal(stats)
recordTime := now.Add(offset)
record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{
"system": systemRecord.Id,
"type": "1m",
"stats": string(statsJSON),
})
require.NoError(t, err)
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
err = hub.SaveNoValidate(record)
require.NoError(t, err)
}
combinedDataHigh := &system.CombinedData{
Stats: system.Stats{
DiskPct: 30,
ExtraFs: extraFsHigh,
},
Info: system.Info{
DiskPct: 30,
},
}
systemRecord.Set("updated", now)
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
require.NoError(t, err)
time.Sleep(20 * time.Millisecond)
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
require.NoError(t, err)
assert.True(t, diskAlert.GetBool("triggered"),
"Alert SHOULD be triggered when extra disk average (92%%) exceeds threshold (80%%)")
// --- Resolution: extra disk drops to 50%, alert should resolve ---
extraFsLow := map[string]*system.FsStats{
"/mnt/data": {DiskTotal: 1000, DiskUsed: 500}, // 50% - below threshold
}
newNow := now.Add(2 * time.Minute)
recordTimesLow := []time.Duration{
-180 * time.Second,
-90 * time.Second,
-60 * time.Second,
-30 * time.Second,
}
for _, offset := range recordTimesLow {
stats := system.Stats{
DiskPct: 30,
ExtraFs: extraFsLow,
}
statsJSON, _ := json.Marshal(stats)
recordTime := newNow.Add(offset)
record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{
"system": systemRecord.Id,
"type": "1m",
"stats": string(statsJSON),
})
require.NoError(t, err)
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
err = hub.SaveNoValidate(record)
require.NoError(t, err)
}
combinedDataLow := &system.CombinedData{
Stats: system.Stats{
DiskPct: 30,
ExtraFs: extraFsLow,
},
Info: system.Info{
DiskPct: 30,
},
}
systemRecord.Set("updated", newNow)
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
require.NoError(t, err)
time.Sleep(20 * time.Millisecond)
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
require.NoError(t, err)
assert.False(t, diskAlert.GetBool("triggered"),
"Alert should be resolved when extra disk average (50%%) drops below threshold (80%%)")
}

View File

@@ -49,7 +49,7 @@ func TestAlertSilencedOneTime(t *testing.T) {
// Get alert manager
am := alerts.NewAlertManager(hub)
defer am.StopWorker()
defer am.Stop()
// Test that alert is silenced
silenced := am.IsNotificationSilenced(user.Id, system.Id)
@@ -106,7 +106,7 @@ func TestAlertSilencedDaily(t *testing.T) {
// Get alert manager
am := alerts.NewAlertManager(hub)
defer am.StopWorker()
defer am.Stop()
// Get current hour and create a window that includes current time
now := time.Now().UTC()
@@ -170,7 +170,7 @@ func TestAlertSilencedDailyMidnightCrossing(t *testing.T) {
// Get alert manager
am := alerts.NewAlertManager(hub)
defer am.StopWorker()
defer am.Stop()
// Create a window that crosses midnight: 22:00 - 02:00
startTime := time.Date(2000, 1, 1, 22, 0, 0, 0, time.UTC)
@@ -211,7 +211,7 @@ func TestAlertSilencedGlobal(t *testing.T) {
// Get alert manager
am := alerts.NewAlertManager(hub)
defer am.StopWorker()
defer am.Stop()
// Create a global quiet hours window (no system specified)
now := time.Now().UTC()
@@ -250,7 +250,7 @@ func TestAlertSilencedSystemSpecific(t *testing.T) {
// Get alert manager
am := alerts.NewAlertManager(hub)
defer am.StopWorker()
defer am.Stop()
// Create a system-specific quiet hours window for system1 only
now := time.Now().UTC()
@@ -296,7 +296,7 @@ func TestAlertSilencedMultiUser(t *testing.T) {
// Get alert manager
am := alerts.NewAlertManager(hub)
defer am.StopWorker()
defer am.Stop()
// Create a quiet hours window for user1 only
now := time.Now().UTC()
@@ -417,7 +417,7 @@ func TestAlertSilencedNoWindows(t *testing.T) {
// Get alert manager
am := alerts.NewAlertManager(hub)
defer am.StopWorker()
defer am.Stop()
// Without any quiet hours windows, alert should NOT be silenced
silenced := am.IsNotificationSilenced(user.Id, system.Id)

View File

@@ -5,67 +5,28 @@ import (
"strings"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
type alertTask struct {
action string // "schedule" or "cancel"
systemName string
alertRecord *core.Record
delay time.Duration
}
type alertInfo struct {
systemName string
alertRecord *core.Record
expireTime time.Time
systemName string
alertData CachedAlertData
expireTime time.Time
timer *time.Timer
}
// startWorker is a long-running goroutine that processes alert tasks
// every x seconds. It must be running to process status alerts.
func (am *AlertManager) startWorker() {
processPendingAlerts := time.Tick(15 * time.Second)
// check for status alerts that are not resolved when system comes up
// (can be removed if we figure out core bug in #1052)
checkStatusAlerts := time.Tick(561 * time.Second)
for {
select {
case <-am.stopChan:
return
case task := <-am.alertQueue:
switch task.action {
case "schedule":
am.pendingAlerts.Store(task.alertRecord.Id, &alertInfo{
systemName: task.systemName,
alertRecord: task.alertRecord,
expireTime: time.Now().Add(task.delay),
})
case "cancel":
am.pendingAlerts.Delete(task.alertRecord.Id)
// Stop cancels all pending status alert timers.
func (am *AlertManager) Stop() {
am.stopOnce.Do(func() {
am.pendingAlerts.Range(func(key, value any) bool {
info := value.(*alertInfo)
if info.timer != nil {
info.timer.Stop()
}
case <-checkStatusAlerts:
resolveStatusAlerts(am.hub)
case <-processPendingAlerts:
// Check for expired alerts every tick
now := time.Now()
for key, value := range am.pendingAlerts.Range {
info := value.(*alertInfo)
if now.After(info.expireTime) {
// Downtime delay has passed, process alert
am.sendStatusAlert("down", info.systemName, info.alertRecord)
am.pendingAlerts.Delete(key)
}
}
}
}
}
// StopWorker shuts down the AlertManager.worker goroutine
func (am *AlertManager) StopWorker() {
close(am.stopChan)
am.pendingAlerts.Delete(key)
return true
})
})
}
// HandleStatusAlerts manages the logic when system status changes.
@@ -74,82 +35,104 @@ func (am *AlertManager) HandleStatusAlerts(newStatus string, systemRecord *core.
return nil
}
alertRecords, err := am.getSystemStatusAlerts(systemRecord.Id)
if err != nil {
return err
}
if len(alertRecords) == 0 {
alerts := am.alertsCache.GetAlertsByName(systemRecord.Id, "Status")
if len(alerts) == 0 {
return nil
}
systemName := systemRecord.GetString("name")
if newStatus == "down" {
am.handleSystemDown(systemName, alertRecords)
am.handleSystemDown(systemName, alerts)
} else {
am.handleSystemUp(systemName, alertRecords)
am.handleSystemUp(systemName, alerts)
}
return nil
}
// getSystemStatusAlerts retrieves all "Status" alert records for a given system ID.
func (am *AlertManager) getSystemStatusAlerts(systemID string) ([]*core.Record, error) {
alertRecords, err := am.hub.FindAllRecords("alerts", dbx.HashExp{
"system": systemID,
"name": "Status",
})
if err != nil {
return nil, err
// handleSystemDown manages the logic when a system status changes to "down". It schedules pending alerts for each alert record.
func (am *AlertManager) handleSystemDown(systemName string, alerts []CachedAlertData) {
for _, alertData := range alerts {
min := max(1, int(alertData.Min))
am.schedulePendingStatusAlert(systemName, alertData, time.Duration(min)*time.Minute)
}
return alertRecords, nil
}
// Schedules delayed "down" alerts for each alert record.
func (am *AlertManager) handleSystemDown(systemName string, alertRecords []*core.Record) {
for _, alertRecord := range alertRecords {
// Continue if alert is already scheduled
if _, exists := am.pendingAlerts.Load(alertRecord.Id); exists {
continue
}
// Schedule by adding to queue
min := max(1, alertRecord.GetInt("min"))
am.alertQueue <- alertTask{
action: "schedule",
systemName: systemName,
alertRecord: alertRecord,
delay: time.Duration(min) * time.Minute,
}
// schedulePendingStatusAlert sets up a timer to send a "down" alert after the specified delay if the system is still down.
// It returns true if the alert was scheduled, or false if an alert was already pending for the given alert record.
func (am *AlertManager) schedulePendingStatusAlert(systemName string, alertData CachedAlertData, delay time.Duration) bool {
alert := &alertInfo{
systemName: systemName,
alertData: alertData,
expireTime: time.Now().Add(delay),
}
storedAlert, loaded := am.pendingAlerts.LoadOrStore(alertData.Id, alert)
if loaded {
return false
}
stored := storedAlert.(*alertInfo)
stored.timer = time.AfterFunc(time.Until(stored.expireTime), func() {
am.processPendingAlert(alertData.Id)
})
return true
}
// handleSystemUp manages the logic when a system status changes to "up".
// It cancels any pending alerts and sends "up" alerts.
func (am *AlertManager) handleSystemUp(systemName string, alertRecords []*core.Record) {
for _, alertRecord := range alertRecords {
alertRecordID := alertRecord.Id
func (am *AlertManager) handleSystemUp(systemName string, alerts []CachedAlertData) {
for _, alertData := range alerts {
// If alert exists for record, delete and continue (down alert not sent)
if _, exists := am.pendingAlerts.Load(alertRecordID); exists {
am.alertQueue <- alertTask{
action: "cancel",
alertRecord: alertRecord,
}
if am.cancelPendingAlert(alertData.Id) {
continue
}
// No alert scheduled for this record, send "up" alert
if err := am.sendStatusAlert("up", systemName, alertRecord); err != nil {
if !alertData.Triggered {
continue
}
if err := am.sendStatusAlert("up", systemName, alertData); err != nil {
am.hub.Logger().Error("Failed to send alert", "err", err)
}
}
}
// sendStatusAlert sends a status alert ("up" or "down") to the users associated with the alert records.
func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, alertRecord *core.Record) error {
switch alertStatus {
case "up":
alertRecord.Set("triggered", false)
case "down":
alertRecord.Set("triggered", true)
// cancelPendingAlert stops the timer and removes the pending alert for the given alert ID. Returns true if a pending alert was found and cancelled.
func (am *AlertManager) cancelPendingAlert(alertID string) bool {
value, loaded := am.pendingAlerts.LoadAndDelete(alertID)
if !loaded {
return false
}
info := value.(*alertInfo)
if info.timer != nil {
info.timer.Stop()
}
return true
}
// processPendingAlert sends a "down" alert if the pending alert has expired and the system is still down.
func (am *AlertManager) processPendingAlert(alertID string) {
value, loaded := am.pendingAlerts.LoadAndDelete(alertID)
if !loaded {
return
}
info := value.(*alertInfo)
refreshedAlertData, ok := am.alertsCache.Refresh(info.alertData)
if !ok || refreshedAlertData.Triggered {
return
}
if err := am.sendStatusAlert("down", info.systemName, refreshedAlertData); err != nil {
am.hub.Logger().Error("Failed to send alert", "err", err)
}
}
// sendStatusAlert sends a status alert ("up" or "down") to the users associated with the alert records.
func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, alertData CachedAlertData) error {
// Update trigger state for alert record before sending alert
triggered := alertStatus == "down"
if err := am.setAlertTriggered(alertData, triggered); err != nil {
return err
}
am.hub.Save(alertRecord)
var emoji string
if alertStatus == "up" {
@@ -162,10 +145,10 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
message := strings.TrimSuffix(title, emoji)
// Get system ID for the link
systemID := alertRecord.GetString("system")
systemID := alertData.SystemID
return am.SendAlert(AlertMessageData{
UserID: alertRecord.GetString("user"),
UserID: alertData.UserID,
SystemID: systemID,
Title: title,
Message: message,
@@ -174,8 +157,8 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
})
}
// resolveStatusAlerts resolves any status alerts that weren't resolved
// when system came up (https://github.com/henrygd/beszel/issues/1052)
// resolveStatusAlerts resolves any triggered status alerts that weren't resolved
// when system came up (https://github.com/henrygd/beszel/issues/1052).
func resolveStatusAlerts(app core.App) error {
db := app.DB()
// Find all active status alerts where the system is actually up
@@ -205,3 +188,40 @@ func resolveStatusAlerts(app core.App) error {
}
return nil
}
// restorePendingStatusAlerts re-queues untriggered status alerts for systems that
// are still down after a hub restart. This rebuilds the lost in-memory timer state.
func (am *AlertManager) restorePendingStatusAlerts() error {
type pendingStatusAlert struct {
AlertID string `db:"alert_id"`
SystemID string `db:"system_id"`
SystemName string `db:"system_name"`
}
var pending []pendingStatusAlert
err := am.hub.DB().NewQuery(`
SELECT a.id AS alert_id, a.system AS system_id, s.name AS system_name
FROM alerts a
JOIN systems s ON a.system = s.id
WHERE a.name = 'Status'
AND a.triggered = false
AND s.status = 'down'
`).All(&pending)
if err != nil {
return err
}
// Make sure cache is populated before trying to restore pending alerts
_ = am.alertsCache.PopulateFromDB(false)
for _, item := range pending {
alertData, ok := am.alertsCache.GetAlert(item.SystemID, item.AlertID)
if !ok {
continue
}
min := max(1, int(alertData.Min))
am.schedulePendingStatusAlert(item.SystemName, alertData, time.Duration(min)*time.Minute)
}
return nil
}

View File

@@ -0,0 +1,943 @@
//go:build testing
package alerts_test
import (
"testing"
"testing/synctest"
"time"
"github.com/henrygd/beszel/internal/alerts"
beszelTests "github.com/henrygd/beszel/internal/tests"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setStatusAlertEmail(t *testing.T, hub core.App, userID, email string) {
t.Helper()
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": userID})
require.NoError(t, err)
userSettings.Set("settings", map[string]any{
"emails": []string{email},
"webhooks": []string{},
})
require.NoError(t, hub.Save(userSettings))
}
func TestStatusAlerts(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
systems, err := beszelTests.CreateSystems(hub, 4, user.Id, "paused")
assert.NoError(t, err)
var alerts []*core.Record
for i, system := range systems {
alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Status",
"system": system.Id,
"user": user.Id,
"min": i + 1,
})
assert.NoError(t, err)
alerts = append(alerts, alert)
}
time.Sleep(10 * time.Millisecond)
for _, alert := range alerts {
assert.False(t, alert.GetBool("triggered"), "Alert should not be triggered immediately")
}
if hub.TestMailer.TotalSend() != 0 {
assert.Zero(t, hub.TestMailer.TotalSend(), "Expected 0 messages, got %d", hub.TestMailer.TotalSend())
}
for _, system := range systems {
assert.EqualValues(t, "paused", system.GetString("status"), "System should be paused")
}
for _, system := range systems {
system.Set("status", "up")
err = hub.SaveNoValidate(system)
assert.NoError(t, err)
}
time.Sleep(time.Second)
assert.EqualValues(t, 0, hub.GetPendingAlertsCount(), "should have 0 alerts in the pendingAlerts map")
for _, system := range systems {
system.Set("status", "down")
err = hub.SaveNoValidate(system)
assert.NoError(t, err)
}
// after 30 seconds, should have 4 alerts in the pendingAlerts map, no triggered alerts
time.Sleep(time.Second * 30)
assert.EqualValues(t, 4, hub.GetPendingAlertsCount(), "should have 4 alerts in the pendingAlerts map")
triggeredCount, err := hub.CountRecords("alerts", dbx.HashExp{"triggered": true})
assert.NoError(t, err)
assert.EqualValues(t, 0, triggeredCount, "should have 0 alert triggered")
assert.EqualValues(t, 0, hub.TestMailer.TotalSend(), "should have 0 messages sent")
// after 1:30 seconds, should have 1 triggered alert and 3 pending alerts
time.Sleep(time.Second * 60)
assert.EqualValues(t, 3, hub.GetPendingAlertsCount(), "should have 3 alerts in the pendingAlerts map")
triggeredCount, err = hub.CountRecords("alerts", dbx.HashExp{"triggered": true})
assert.NoError(t, err)
assert.EqualValues(t, 1, triggeredCount, "should have 1 alert triggered")
assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 messages sent")
// after 2:30 seconds, should have 2 triggered alerts and 2 pending alerts
time.Sleep(time.Second * 60)
assert.EqualValues(t, 2, hub.GetPendingAlertsCount(), "should have 2 alerts in the pendingAlerts map")
triggeredCount, err = hub.CountRecords("alerts", dbx.HashExp{"triggered": true})
assert.NoError(t, err)
assert.EqualValues(t, 2, triggeredCount, "should have 2 alert triggered")
assert.EqualValues(t, 2, hub.TestMailer.TotalSend(), "should have 2 messages sent")
// now we will bring the remaning systems back up
for _, system := range systems {
system.Set("status", "up")
err = hub.SaveNoValidate(system)
assert.NoError(t, err)
}
time.Sleep(time.Second)
// should have 0 alerts in the pendingAlerts map and 0 alerts triggered
assert.EqualValues(t, 0, hub.GetPendingAlertsCount(), "should have 0 alerts in the pendingAlerts map")
triggeredCount, err = hub.CountRecords("alerts", dbx.HashExp{"triggered": true})
assert.NoError(t, err)
assert.Zero(t, triggeredCount, "should have 0 alert triggered")
// 4 messages sent, 2 down alerts and 2 up alerts for first 2 systems
assert.EqualValues(t, 4, hub.TestMailer.TotalSend(), "should have 4 messages sent")
})
}
func TestStatusAlertRecoveryBeforeDeadline(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Ensure user settings have an email
userSettings, _ := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
userSettings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`)
hub.Save(userSettings)
// Initial email count
initialEmailCount := hub.TestMailer.TotalSend()
systemCollection, _ := hub.FindCollectionByNameOrId("systems")
system := core.NewRecord(systemCollection)
system.Set("name", "test-system")
system.Set("status", "up")
system.Set("host", "127.0.0.1")
system.Set("users", []string{user.Id})
hub.Save(system)
alertCollection, _ := hub.FindCollectionByNameOrId("alerts")
alert := core.NewRecord(alertCollection)
alert.Set("user", user.Id)
alert.Set("system", system.Id)
alert.Set("name", "Status")
alert.Set("triggered", false)
alert.Set("min", 1)
hub.Save(alert)
am := hub.AlertManager
// 1. System goes down
am.HandleStatusAlerts("down", system)
assert.Equal(t, 1, am.GetPendingAlertsCount(), "Alert should be scheduled")
// 2. System goes up BEFORE delay expires
// Triggering HandleStatusAlerts("up") SHOULD NOT send an alert.
am.HandleStatusAlerts("up", system)
assert.Equal(t, 0, am.GetPendingAlertsCount(), "Alert should be canceled if system recovers before delay expires")
// Verify that NO email was sent.
assert.Equal(t, initialEmailCount, hub.TestMailer.TotalSend(), "Recovery notification should not be sent if system never went down")
}
func TestStatusAlertNormalRecovery(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Ensure user settings have an email
userSettings, _ := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
userSettings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`)
hub.Save(userSettings)
systemCollection, _ := hub.FindCollectionByNameOrId("systems")
system := core.NewRecord(systemCollection)
system.Set("name", "test-system")
system.Set("status", "up")
system.Set("host", "127.0.0.1")
system.Set("users", []string{user.Id})
hub.Save(system)
alertCollection, _ := hub.FindCollectionByNameOrId("alerts")
alert := core.NewRecord(alertCollection)
alert.Set("user", user.Id)
alert.Set("system", system.Id)
alert.Set("name", "Status")
alert.Set("triggered", true) // System was confirmed DOWN
hub.Save(alert)
am := hub.AlertManager
initialEmailCount := hub.TestMailer.TotalSend()
// System goes up
am.HandleStatusAlerts("up", system)
// Verify that an email WAS sent (normal recovery).
assert.Equal(t, initialEmailCount+1, hub.TestMailer.TotalSend(), "Recovery notification should be sent if system was triggered as down")
}
func TestHandleStatusAlertsDoesNotSendRecoveryWhileDownIsOnlyPending(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
require.NoError(t, err)
userSettings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`)
require.NoError(t, hub.Save(userSettings))
systemCollection, err := hub.FindCollectionByNameOrId("systems")
require.NoError(t, err)
system := core.NewRecord(systemCollection)
system.Set("name", "test-system")
system.Set("status", "up")
system.Set("host", "127.0.0.1")
system.Set("users", []string{user.Id})
require.NoError(t, hub.Save(system))
alertCollection, err := hub.FindCollectionByNameOrId("alerts")
require.NoError(t, err)
alert := core.NewRecord(alertCollection)
alert.Set("user", user.Id)
alert.Set("system", system.Id)
alert.Set("name", "Status")
alert.Set("triggered", false)
alert.Set("min", 1)
require.NoError(t, hub.Save(alert))
initialEmailCount := hub.TestMailer.TotalSend()
am := alerts.NewTestAlertManagerWithoutWorker(hub)
require.NoError(t, am.HandleStatusAlerts("down", system))
assert.Equal(t, 1, am.GetPendingAlertsCount(), "down transition should register a pending alert immediately")
require.NoError(t, am.HandleStatusAlerts("up", system))
assert.Zero(t, am.GetPendingAlertsCount(), "recovery should cancel the pending down alert")
assert.Equal(t, initialEmailCount, hub.TestMailer.TotalSend(), "recovery notification should not be sent before a down alert triggers")
alertRecord, err := hub.FindRecordById("alerts", alert.Id)
require.NoError(t, err)
assert.False(t, alertRecord.GetBool("triggered"), "alert should remain untriggered when downtime never matured")
}
func TestStatusAlertTimerCancellationPreventsBoundaryDelivery(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
require.NoError(t, err)
userSettings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`)
require.NoError(t, hub.Save(userSettings))
systemCollection, err := hub.FindCollectionByNameOrId("systems")
require.NoError(t, err)
system := core.NewRecord(systemCollection)
system.Set("name", "test-system")
system.Set("status", "up")
system.Set("host", "127.0.0.1")
system.Set("users", []string{user.Id})
require.NoError(t, hub.Save(system))
alertCollection, err := hub.FindCollectionByNameOrId("alerts")
require.NoError(t, err)
alert := core.NewRecord(alertCollection)
alert.Set("user", user.Id)
alert.Set("system", system.Id)
alert.Set("name", "Status")
alert.Set("triggered", false)
alert.Set("min", 1)
require.NoError(t, hub.Save(alert))
initialEmailCount := hub.TestMailer.TotalSend()
am := alerts.NewTestAlertManagerWithoutWorker(hub)
require.NoError(t, am.HandleStatusAlerts("down", system))
assert.Equal(t, 1, am.GetPendingAlertsCount(), "down transition should register a pending alert immediately")
require.True(t, am.ResetPendingAlertTimer(alert.Id, 25*time.Millisecond), "test should shorten the pending alert timer")
time.Sleep(10 * time.Millisecond)
require.NoError(t, am.HandleStatusAlerts("up", system))
assert.Zero(t, am.GetPendingAlertsCount(), "recovery should remove the pending alert before the timer callback runs")
time.Sleep(40 * time.Millisecond)
assert.Equal(t, initialEmailCount, hub.TestMailer.TotalSend(), "timer callback should not deliver after recovery cancels the pending alert")
alertRecord, err := hub.FindRecordById("alerts", alert.Id)
require.NoError(t, err)
assert.False(t, alertRecord.GetBool("triggered"), "alert should remain untriggered when cancellation wins the timer race")
time.Sleep(time.Minute)
synctest.Wait()
})
}
func TestStatusAlertDownFiresAfterDelayExpires(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
require.NoError(t, err)
userSettings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`)
require.NoError(t, hub.Save(userSettings))
systemCollection, err := hub.FindCollectionByNameOrId("systems")
require.NoError(t, err)
system := core.NewRecord(systemCollection)
system.Set("name", "test-system")
system.Set("status", "up")
system.Set("host", "127.0.0.1")
system.Set("users", []string{user.Id})
require.NoError(t, hub.Save(system))
alertCollection, err := hub.FindCollectionByNameOrId("alerts")
require.NoError(t, err)
alert := core.NewRecord(alertCollection)
alert.Set("user", user.Id)
alert.Set("system", system.Id)
alert.Set("name", "Status")
alert.Set("triggered", false)
alert.Set("min", 1)
require.NoError(t, hub.Save(alert))
initialEmailCount := hub.TestMailer.TotalSend()
am := alerts.NewTestAlertManagerWithoutWorker(hub)
require.NoError(t, am.HandleStatusAlerts("down", system))
assert.Equal(t, 1, am.GetPendingAlertsCount(), "alert should be pending after system goes down")
// Expire the pending alert and process it
am.ForceExpirePendingAlerts()
processed, err := am.ProcessPendingAlerts()
require.NoError(t, err)
assert.Len(t, processed, 1, "one alert should have been processed")
assert.Equal(t, 0, am.GetPendingAlertsCount(), "pending alert should be consumed after processing")
// Verify down email was sent
assert.Equal(t, initialEmailCount+1, hub.TestMailer.TotalSend(), "down notification should be sent after delay expires")
// Verify triggered flag is set in the DB
alertRecord, err := hub.FindRecordById("alerts", alert.Id)
require.NoError(t, err)
assert.True(t, alertRecord.GetBool("triggered"), "alert should be marked triggered after downtime matures")
}
func TestStatusAlertMultipleUsersRespectDifferentMinutes(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
hub, user1 := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
setStatusAlertEmail(t, hub, user1.Id, "user1@example.com")
user2, err := beszelTests.CreateUser(hub, "user2@example.com", "password")
require.NoError(t, err)
_, err = beszelTests.CreateRecord(hub, "user_settings", map[string]any{
"user": user2.Id,
"settings": map[string]any{
"emails": []string{"user2@example.com"},
"webhooks": []string{},
},
})
require.NoError(t, err)
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "shared-system",
"users": []string{user1.Id, user2.Id},
"host": "127.0.0.1",
})
require.NoError(t, err)
system.Set("status", "up")
require.NoError(t, hub.SaveNoValidate(system))
alertUser1, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Status",
"system": system.Id,
"user": user1.Id,
"min": 1,
})
require.NoError(t, err)
alertUser2, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Status",
"system": system.Id,
"user": user2.Id,
"min": 2,
})
require.NoError(t, err)
time.Sleep(10 * time.Millisecond)
system.Set("status", "down")
require.NoError(t, hub.SaveNoValidate(system))
assert.Equal(t, 2, hub.GetPendingAlertsCount(), "both user alerts should be pending after the system goes down")
time.Sleep(59 * time.Second)
synctest.Wait()
assert.Zero(t, hub.TestMailer.TotalSend(), "no messages should be sent before the earliest alert minute elapses")
time.Sleep(2 * time.Second)
synctest.Wait()
messages := hub.TestMailer.Messages()
require.Len(t, messages, 1, "only the first user's alert should send after one minute")
require.Len(t, messages[0].To, 1)
assert.Equal(t, "user1@example.com", messages[0].To[0].Address)
assert.Contains(t, messages[0].Subject, "Connection to shared-system is down")
assert.Equal(t, 1, hub.GetPendingAlertsCount(), "the later user alert should still be pending")
time.Sleep(58 * time.Second)
synctest.Wait()
assert.Equal(t, 1, hub.TestMailer.TotalSend(), "the second user's alert should still be waiting before two minutes")
time.Sleep(2 * time.Second)
synctest.Wait()
messages = hub.TestMailer.Messages()
require.Len(t, messages, 2, "both users should eventually receive their own status alert")
require.Len(t, messages[1].To, 1)
assert.Equal(t, "user2@example.com", messages[1].To[0].Address)
assert.Contains(t, messages[1].Subject, "Connection to shared-system is down")
assert.Zero(t, hub.GetPendingAlertsCount(), "all pending alerts should be consumed after both timers fire")
alertUser1, err = hub.FindRecordById("alerts", alertUser1.Id)
require.NoError(t, err)
assert.True(t, alertUser1.GetBool("triggered"), "user1 alert should be marked triggered after delivery")
alertUser2, err = hub.FindRecordById("alerts", alertUser2.Id)
require.NoError(t, err)
assert.True(t, alertUser2.GetBool("triggered"), "user2 alert should be marked triggered after delivery")
})
}
func TestStatusAlertMultipleUsersRecoveryBetweenMinutesOnlyAlertsEarlierUser(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
hub, user1 := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
setStatusAlertEmail(t, hub, user1.Id, "user1@example.com")
user2, err := beszelTests.CreateUser(hub, "user2@example.com", "password")
require.NoError(t, err)
_, err = beszelTests.CreateRecord(hub, "user_settings", map[string]any{
"user": user2.Id,
"settings": map[string]any{
"emails": []string{"user2@example.com"},
"webhooks": []string{},
},
})
require.NoError(t, err)
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "shared-system",
"users": []string{user1.Id, user2.Id},
"host": "127.0.0.1",
})
require.NoError(t, err)
system.Set("status", "up")
require.NoError(t, hub.SaveNoValidate(system))
alertUser1, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Status",
"system": system.Id,
"user": user1.Id,
"min": 1,
})
require.NoError(t, err)
alertUser2, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Status",
"system": system.Id,
"user": user2.Id,
"min": 2,
})
require.NoError(t, err)
time.Sleep(10 * time.Millisecond)
system.Set("status", "down")
require.NoError(t, hub.SaveNoValidate(system))
time.Sleep(61 * time.Second)
synctest.Wait()
messages := hub.TestMailer.Messages()
require.Len(t, messages, 1, "the first user's down alert should send before recovery")
require.Len(t, messages[0].To, 1)
assert.Equal(t, "user1@example.com", messages[0].To[0].Address)
assert.Contains(t, messages[0].Subject, "Connection to shared-system is down")
assert.Equal(t, 1, hub.GetPendingAlertsCount(), "the second user's alert should still be pending")
system.Set("status", "up")
require.NoError(t, hub.SaveNoValidate(system))
time.Sleep(time.Second)
synctest.Wait()
messages = hub.TestMailer.Messages()
require.Len(t, messages, 2, "recovery should notify only the user whose down alert had already triggered")
for _, message := range messages {
require.Len(t, message.To, 1)
assert.Equal(t, "user1@example.com", message.To[0].Address)
}
assert.Contains(t, messages[1].Subject, "Connection to shared-system is up")
assert.Zero(t, hub.GetPendingAlertsCount(), "recovery should cancel the later user's pending alert")
time.Sleep(61 * time.Second)
synctest.Wait()
messages = hub.TestMailer.Messages()
require.Len(t, messages, 2, "user2 should never receive a down alert once recovery cancels the pending timer")
alertUser1, err = hub.FindRecordById("alerts", alertUser1.Id)
require.NoError(t, err)
assert.False(t, alertUser1.GetBool("triggered"), "user1 alert should be cleared after recovery")
alertUser2, err = hub.FindRecordById("alerts", alertUser2.Id)
require.NoError(t, err)
assert.False(t, alertUser2.GetBool("triggered"), "user2 alert should remain untriggered because it never fired")
})
}
func TestStatusAlertDuplicateDownCallIsIdempotent(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
require.NoError(t, err)
userSettings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`)
require.NoError(t, hub.Save(userSettings))
systemCollection, err := hub.FindCollectionByNameOrId("systems")
require.NoError(t, err)
system := core.NewRecord(systemCollection)
system.Set("name", "test-system")
system.Set("status", "up")
system.Set("host", "127.0.0.1")
system.Set("users", []string{user.Id})
require.NoError(t, hub.Save(system))
alertCollection, err := hub.FindCollectionByNameOrId("alerts")
require.NoError(t, err)
alert := core.NewRecord(alertCollection)
alert.Set("user", user.Id)
alert.Set("system", system.Id)
alert.Set("name", "Status")
alert.Set("triggered", false)
alert.Set("min", 5)
require.NoError(t, hub.Save(alert))
am := alerts.NewTestAlertManagerWithoutWorker(hub)
require.NoError(t, am.HandleStatusAlerts("down", system))
require.NoError(t, am.HandleStatusAlerts("down", system))
require.NoError(t, am.HandleStatusAlerts("down", system))
assert.Equal(t, 1, am.GetPendingAlertsCount(), "repeated down calls should not schedule duplicate pending alerts")
}
func TestStatusAlertNoAlertRecord(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
systemCollection, err := hub.FindCollectionByNameOrId("systems")
require.NoError(t, err)
system := core.NewRecord(systemCollection)
system.Set("name", "test-system")
system.Set("status", "up")
system.Set("host", "127.0.0.1")
system.Set("users", []string{user.Id})
require.NoError(t, hub.Save(system))
// No Status alert record created for this system
initialEmailCount := hub.TestMailer.TotalSend()
am := alerts.NewTestAlertManagerWithoutWorker(hub)
require.NoError(t, am.HandleStatusAlerts("down", system))
assert.Equal(t, 0, am.GetPendingAlertsCount(), "no pending alert when no alert record exists")
require.NoError(t, am.HandleStatusAlerts("up", system))
assert.Equal(t, initialEmailCount, hub.TestMailer.TotalSend(), "no email when no alert record exists")
}
func TestRestorePendingStatusAlertsRequeuesDownSystemsAfterRestart(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
require.NoError(t, err)
userSettings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`)
require.NoError(t, hub.Save(userSettings))
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "down")
require.NoError(t, err)
system := systems[0]
alertCollection, err := hub.FindCollectionByNameOrId("alerts")
require.NoError(t, err)
alert := core.NewRecord(alertCollection)
alert.Set("user", user.Id)
alert.Set("system", system.Id)
alert.Set("name", "Status")
alert.Set("triggered", false)
alert.Set("min", 1)
require.NoError(t, hub.Save(alert))
initialEmailCount := hub.TestMailer.TotalSend()
am := alerts.NewTestAlertManagerWithoutWorker(hub)
require.NoError(t, am.RestorePendingStatusAlerts())
assert.Equal(t, 1, am.GetPendingAlertsCount(), "startup restore should requeue a pending down alert for a system still marked down")
am.ForceExpirePendingAlerts()
processed, err := am.ProcessPendingAlerts()
require.NoError(t, err)
assert.Len(t, processed, 1, "restored pending alert should be processable after the delay expires")
assert.Equal(t, initialEmailCount+1, hub.TestMailer.TotalSend(), "restored pending alert should send the down notification")
alertRecord, err := hub.FindRecordById("alerts", alert.Id)
require.NoError(t, err)
assert.True(t, alertRecord.GetBool("triggered"), "restored pending alert should mark the alert as triggered once delivered")
}
func TestRestorePendingStatusAlertsSkipsNonDownOrAlreadyTriggeredAlerts(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
systemsDown, err := beszelTests.CreateSystems(hub, 2, user.Id, "down")
require.NoError(t, err)
systemDownPending := systemsDown[0]
systemDownTriggered := systemsDown[1]
systemUp, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "up-system",
"users": []string{user.Id},
"host": "127.0.0.2",
"status": "up",
})
require.NoError(t, err)
_, err = beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Status",
"system": systemDownPending.Id,
"user": user.Id,
"min": 1,
"triggered": false,
})
require.NoError(t, err)
_, err = beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Status",
"system": systemUp.Id,
"user": user.Id,
"min": 1,
"triggered": false,
})
require.NoError(t, err)
_, err = beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Status",
"system": systemDownTriggered.Id,
"user": user.Id,
"min": 1,
"triggered": true,
})
require.NoError(t, err)
am := alerts.NewTestAlertManagerWithoutWorker(hub)
require.NoError(t, am.RestorePendingStatusAlerts())
assert.Equal(t, 1, am.GetPendingAlertsCount(), "only untriggered alerts for currently down systems should be restored")
}
func TestRestorePendingStatusAlertsIsIdempotent(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "down")
require.NoError(t, err)
system := systems[0]
_, err = beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Status",
"system": system.Id,
"user": user.Id,
"min": 1,
"triggered": false,
})
require.NoError(t, err)
am := alerts.NewTestAlertManagerWithoutWorker(hub)
require.NoError(t, am.RestorePendingStatusAlerts())
require.NoError(t, am.RestorePendingStatusAlerts())
assert.Equal(t, 1, am.GetPendingAlertsCount(), "restoring twice should not create duplicate pending alerts")
am.ForceExpirePendingAlerts()
processed, err := am.ProcessPendingAlerts()
require.NoError(t, err)
assert.Len(t, processed, 1, "restored alert should still be processable exactly once")
assert.Zero(t, am.GetPendingAlertsCount(), "processing the restored alert should empty the pending map")
}
func TestResolveStatusAlertsFixesStaleTriggered(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// CreateSystems uses SaveNoValidate after initial save to bypass the
// onRecordCreate hook that forces status = "pending".
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
require.NoError(t, err)
system := systems[0]
alertCollection, err := hub.FindCollectionByNameOrId("alerts")
require.NoError(t, err)
alert := core.NewRecord(alertCollection)
alert.Set("user", user.Id)
alert.Set("system", system.Id)
alert.Set("name", "Status")
alert.Set("triggered", true) // Stale: system is up but alert still says triggered
require.NoError(t, hub.Save(alert))
// resolveStatusAlerts should clear the stale triggered flag
require.NoError(t, alerts.ResolveStatusAlerts(hub))
alertRecord, err := hub.FindRecordById("alerts", alert.Id)
require.NoError(t, err)
assert.False(t, alertRecord.GetBool("triggered"), "stale triggered flag should be cleared when system is up")
}
func TestResolveStatusAlerts(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a systemUp
systemUp, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"users": []string{user.Id},
"host": "127.0.0.1",
"status": "up",
})
assert.NoError(t, err)
systemDown, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system-2",
"users": []string{user.Id},
"host": "127.0.0.2",
"status": "up",
})
assert.NoError(t, err)
// Create a status alertUp for the system
alertUp, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Status",
"system": systemUp.Id,
"user": user.Id,
"min": 1,
})
assert.NoError(t, err)
alertDown, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Status",
"system": systemDown.Id,
"user": user.Id,
"min": 1,
})
assert.NoError(t, err)
// Verify alert is not triggered initially
assert.False(t, alertUp.GetBool("triggered"), "Alert should not be triggered initially")
// Set the system to 'up' (this should not trigger the alert)
systemUp.Set("status", "up")
err = hub.SaveNoValidate(systemUp)
assert.NoError(t, err)
systemDown.Set("status", "down")
err = hub.SaveNoValidate(systemDown)
assert.NoError(t, err)
// Wait a moment for any processing
time.Sleep(10 * time.Millisecond)
// Verify alertUp is still not triggered after setting system to up
alertUp, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": alertUp.Id})
assert.NoError(t, err)
assert.False(t, alertUp.GetBool("triggered"), "Alert should not be triggered when system is up")
// Manually set both alerts triggered to true
alertUp.Set("triggered", true)
err = hub.SaveNoValidate(alertUp)
assert.NoError(t, err)
alertDown.Set("triggered", true)
err = hub.SaveNoValidate(alertDown)
assert.NoError(t, err)
// Verify we have exactly one alert with triggered true
triggeredCount, err := hub.CountRecords("alerts", dbx.HashExp{"triggered": true})
assert.NoError(t, err)
assert.EqualValues(t, 2, triggeredCount, "Should have exactly two alerts with triggered true")
// Verify the specific alertUp is triggered
alertUp, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": alertUp.Id})
assert.NoError(t, err)
assert.True(t, alertUp.GetBool("triggered"), "Alert should be triggered")
// Verify we have two unresolved alert history records
alertHistoryCount, err := hub.CountRecords("alerts_history", dbx.HashExp{"resolved": ""})
assert.NoError(t, err)
assert.EqualValues(t, 2, alertHistoryCount, "Should have exactly two unresolved alert history records")
err = alerts.ResolveStatusAlerts(hub)
assert.NoError(t, err)
// Verify alertUp is not triggered after resolving
alertUp, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": alertUp.Id})
assert.NoError(t, err)
assert.False(t, alertUp.GetBool("triggered"), "Alert should not be triggered after resolving")
// Verify alertDown is still triggered
alertDown, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": alertDown.Id})
assert.NoError(t, err)
assert.True(t, alertDown.GetBool("triggered"), "Alert should still be triggered after resolving")
// Verify we have one unresolved alert history record
alertHistoryCount, err = hub.CountRecords("alerts_history", dbx.HashExp{"resolved": ""})
assert.NoError(t, err)
assert.EqualValues(t, 1, alertHistoryCount, "Should have exactly one unresolved alert history record")
}
func TestAlertsHistoryStatus(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
assert.NoError(t, err)
system := systems[0]
// Create a status alertRecord for the system
alertRecord, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Status",
"system": system.Id,
"user": user.Id,
"min": 1,
})
assert.NoError(t, err)
// Verify alert is not triggered initially
assert.False(t, alertRecord.GetBool("triggered"), "Alert should not be triggered initially")
// Set the system to 'down' (this should trigger the alert)
system.Set("status", "down")
err = hub.Save(system)
assert.NoError(t, err)
time.Sleep(time.Second * 30)
synctest.Wait()
alertFresh, _ := hub.FindRecordById("alerts", alertRecord.Id)
assert.False(t, alertFresh.GetBool("triggered"), "Alert should not be triggered after 30 seconds")
time.Sleep(time.Minute)
synctest.Wait()
// Verify alert is triggered after setting system to down
alertFresh, err = hub.FindRecordById("alerts", alertRecord.Id)
assert.NoError(t, err)
assert.True(t, alertFresh.GetBool("triggered"), "Alert should be triggered after one minute")
// Verify we have one unresolved alert history record
alertHistoryCount, err := hub.CountRecords("alerts_history", dbx.HashExp{"resolved": ""})
assert.NoError(t, err)
assert.EqualValues(t, 1, alertHistoryCount, "Should have exactly one unresolved alert history record")
// Set the system back to 'up' (this should resolve the alert)
system.Set("status", "up")
err = hub.Save(system)
assert.NoError(t, err)
time.Sleep(time.Second)
synctest.Wait()
// Verify alert is not triggered after setting system back to up
alertFresh, err = hub.FindRecordById("alerts", alertRecord.Id)
assert.NoError(t, err)
assert.False(t, alertFresh.GetBool("triggered"), "Alert should not be triggered after system recovers")
// Verify the alert history record is resolved
alertHistoryCount, err = hub.CountRecords("alerts_history", dbx.HashExp{"resolved": ""})
assert.NoError(t, err)
assert.EqualValues(t, 0, alertHistoryCount, "Should have no unresolved alert history records")
})
}
func TestStatusAlertClearedBeforeSend(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
assert.NoError(t, err)
system := systems[0]
// Ensure user settings have an email
userSettings, _ := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
userSettings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`)
hub.Save(userSettings)
// Initial email count
initialEmailCount := hub.TestMailer.TotalSend()
// Create a status alertRecord for the system
alertRecord, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Status",
"system": system.Id,
"user": user.Id,
"min": 1,
})
assert.NoError(t, err)
// Verify alert is not triggered initially
assert.False(t, alertRecord.GetBool("triggered"), "Alert should not be triggered initially")
// Set the system to 'down' (this should trigger the alert)
system.Set("status", "down")
err = hub.Save(system)
assert.NoError(t, err)
time.Sleep(time.Second * 30)
synctest.Wait()
// Set system back up to clear the pending alert before it triggers
system.Set("status", "up")
err = hub.Save(system)
assert.NoError(t, err)
time.Sleep(time.Minute)
synctest.Wait()
// Verify that we have not sent any emails since the system recovered before the alert triggered
assert.Equal(t, initialEmailCount, hub.TestMailer.TotalSend(), "No email should be sent if system recovers before alert triggers")
// Verify alert is not triggered after setting system back to up
alertFresh, err := hub.FindRecordById("alerts", alertRecord.Id)
assert.NoError(t, err)
assert.False(t, alertFresh.GetBool("triggered"), "Alert should not be triggered after system recovers")
// Verify that no alert history record was created since the alert never triggered
alertHistoryCount, err := hub.CountRecords("alerts_history")
assert.NoError(t, err)
assert.EqualValues(t, 0, alertHistoryCount, "Should have no unresolved alert history records since alert never triggered")
})
}

View File

@@ -11,15 +11,11 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error {
alertRecords, err := am.hub.FindAllRecords("alerts",
dbx.NewExp("system={:system} AND name!='Status'", dbx.Params{"system": systemRecord.Id}),
)
if err != nil || len(alertRecords) == 0 {
// log.Println("no alerts found for system")
alerts := am.alertsCache.GetAlertsExcludingNames(systemRecord.Id, "Status")
if len(alerts) == 0 {
return nil
}
@@ -27,8 +23,8 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
now := systemRecord.GetDateTime("updated").Time().UTC()
oldestTime := now
for _, alertRecord := range alertRecords {
name := alertRecord.GetString("name")
for _, alertData := range alerts {
name := alertData.Name
var val float64
unit := "%"
@@ -73,8 +69,8 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
val = float64(data.Stats.Battery[0])
}
triggered := alertRecord.GetBool("triggered")
threshold := alertRecord.GetFloat("value")
triggered := alertData.Triggered
threshold := alertData.Value
// Battery alert has inverted logic: trigger when value is BELOW threshold
lowAlert := isLowAlert(name)
@@ -92,11 +88,11 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
}
}
min := max(1, cast.ToUint8(alertRecord.Get("min")))
min := max(1, alertData.Min)
alert := SystemAlertData{
systemRecord: systemRecord,
alertRecord: alertRecord,
alertData: alertData,
name: name,
unit: unit,
val: val,
@@ -129,7 +125,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
Created types.DateTime `db:"created"`
}{}
err = am.hub.DB().
err := am.hub.DB().
Select("stats", "created").
From("system_stats").
Where(dbx.NewExp(
@@ -192,22 +188,24 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
case "Memory":
alert.val += stats.Mem
case "Bandwidth":
alert.val += stats.NetSent + stats.NetRecv
alert.val += float64(stats.Bandwidth[0]+stats.Bandwidth[1]) / (1024 * 1024)
case "Disk":
if alert.mapSums == nil {
alert.mapSums = make(map[string]float32, len(data.Stats.ExtraFs)+1)
alert.mapSums = make(map[string]float32, len(stats.ExtraFs)+1)
}
// add root disk
if _, ok := alert.mapSums["root"]; !ok {
alert.mapSums["root"] = 0.0
}
alert.mapSums["root"] += float32(stats.Disk)
// add extra disks
for key, fs := range data.Stats.ExtraFs {
if _, ok := alert.mapSums[key]; !ok {
alert.mapSums[key] = 0.0
// add extra disks from historical record
for key, fs := range stats.ExtraFs {
if fs.DiskTotal > 0 {
if _, ok := alert.mapSums[key]; !ok {
alert.mapSums[key] = 0.0
}
alert.mapSums[key] += float32(fs.DiskUsed / fs.DiskTotal * 100)
}
alert.mapSums[key] += float32(fs.DiskUsed / fs.DiskTotal * 100)
}
case "Temperature":
if alert.mapSums == nil {
@@ -342,13 +340,12 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
}
body := fmt.Sprintf("%s averaged %.2f%s for the previous %v %s.", alert.descriptor, alert.val, alert.unit, alert.min, minutesLabel)
alert.alertRecord.Set("triggered", alert.triggered)
if err := am.hub.Save(alert.alertRecord); err != nil {
if err := am.setAlertTriggered(alert.alertData, alert.triggered); err != nil {
// app.Logger().Error("failed to save alert record", "err", err)
return
}
am.SendAlert(AlertMessageData{
UserID: alert.alertRecord.GetString("user"),
UserID: alert.alertData.UserID,
SystemID: alert.systemRecord.Id,
Title: subject,
Message: body,

View File

@@ -0,0 +1,218 @@
//go:build testing
package alerts_test
import (
"testing"
"testing/synctest"
"time"
"github.com/henrygd/beszel/internal/entities/system"
beszelTests "github.com/henrygd/beszel/internal/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type systemAlertValueSetter[T any] func(info *system.Info, stats *system.Stats, value T)
type systemAlertTestFixture struct {
hub *beszelTests.TestHub
alertID string
submit func(*system.CombinedData) error
}
func createCombinedData[T any](value T, setValue systemAlertValueSetter[T]) *system.CombinedData {
var data system.CombinedData
setValue(&data.Info, &data.Stats, value)
return &data
}
func newSystemAlertTestFixture(t *testing.T, alertName string, min int, threshold float64) *systemAlertTestFixture {
t.Helper()
hub, user := beszelTests.GetHubWithUser(t)
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
require.NoError(t, err)
systemRecord := systems[0]
sysManagerSystem, err := hub.GetSystemManager().GetSystemFromStore(systemRecord.Id)
require.NoError(t, err)
require.NotNil(t, sysManagerSystem)
sysManagerSystem.StopUpdater()
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
require.NoError(t, err)
userSettings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`)
require.NoError(t, hub.Save(userSettings))
alertRecord, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": alertName,
"system": systemRecord.Id,
"user": user.Id,
"min": min,
"value": threshold,
})
require.NoError(t, err)
assert.False(t, alertRecord.GetBool("triggered"), "Alert should not be triggered initially")
alertsCache := hub.GetAlertManager().GetSystemAlertsCache()
cachedAlerts := alertsCache.GetAlertsExcludingNames(systemRecord.Id, "Status")
assert.Len(t, cachedAlerts, 1, "Alert should be in cache")
return &systemAlertTestFixture{
hub: hub,
alertID: alertRecord.Id,
submit: func(data *system.CombinedData) error {
_, err := sysManagerSystem.CreateRecords(data)
return err
},
}
}
func (fixture *systemAlertTestFixture) cleanup() {
fixture.hub.Cleanup()
}
func submitValue[T any](fixture *systemAlertTestFixture, t *testing.T, value T, setValue systemAlertValueSetter[T]) {
t.Helper()
require.NoError(t, fixture.submit(createCombinedData(value, setValue)))
}
func (fixture *systemAlertTestFixture) assertTriggered(t *testing.T, triggered bool, message string) {
t.Helper()
alertRecord, err := fixture.hub.FindRecordById("alerts", fixture.alertID)
require.NoError(t, err)
assert.Equal(t, triggered, alertRecord.GetBool("triggered"), message)
}
func waitForSystemAlert(d time.Duration) {
time.Sleep(d)
synctest.Wait()
}
func testOneMinuteSystemAlert[T any](t *testing.T, alertName string, threshold float64, setValue systemAlertValueSetter[T], triggerValue, resolveValue T) {
t.Helper()
synctest.Test(t, func(t *testing.T) {
fixture := newSystemAlertTestFixture(t, alertName, 1, threshold)
defer fixture.cleanup()
submitValue(fixture, t, triggerValue, setValue)
waitForSystemAlert(time.Second)
fixture.assertTriggered(t, true, "Alert should be triggered")
assert.Equal(t, 1, fixture.hub.TestMailer.TotalSend(), "An email should have been sent")
submitValue(fixture, t, resolveValue, setValue)
waitForSystemAlert(time.Second)
fixture.assertTriggered(t, false, "Alert should be untriggered")
assert.Equal(t, 2, fixture.hub.TestMailer.TotalSend(), "A second email should have been sent for untriggering the alert")
waitForSystemAlert(time.Minute)
})
}
func testMultiMinuteSystemAlert[T any](t *testing.T, alertName string, threshold float64, min int, setValue systemAlertValueSetter[T], baselineValue, triggerValue, resolveValue T) {
t.Helper()
synctest.Test(t, func(t *testing.T) {
fixture := newSystemAlertTestFixture(t, alertName, min, threshold)
defer fixture.cleanup()
submitValue(fixture, t, baselineValue, setValue)
waitForSystemAlert(time.Minute + time.Second)
fixture.assertTriggered(t, false, "Alert should not be triggered yet")
submitValue(fixture, t, triggerValue, setValue)
waitForSystemAlert(time.Minute)
fixture.assertTriggered(t, false, "Alert should not be triggered until the history window is full")
submitValue(fixture, t, triggerValue, setValue)
waitForSystemAlert(time.Second)
fixture.assertTriggered(t, true, "Alert should be triggered")
assert.Equal(t, 1, fixture.hub.TestMailer.TotalSend(), "An email should have been sent")
submitValue(fixture, t, resolveValue, setValue)
waitForSystemAlert(time.Second)
fixture.assertTriggered(t, false, "Alert should be untriggered")
assert.Equal(t, 2, fixture.hub.TestMailer.TotalSend(), "A second email should have been sent for untriggering the alert")
})
}
func setCPUAlertValue(info *system.Info, stats *system.Stats, value float64) {
info.Cpu = value
stats.Cpu = value
}
func setMemoryAlertValue(info *system.Info, stats *system.Stats, value float64) {
info.MemPct = value
stats.MemPct = value
}
func setDiskAlertValue(info *system.Info, stats *system.Stats, value float64) {
info.DiskPct = value
stats.DiskPct = value
}
func setBandwidthAlertValue(info *system.Info, stats *system.Stats, value [2]uint64) {
info.BandwidthBytes = value[0] + value[1]
stats.Bandwidth = value
}
func megabytesToBytes(mb uint64) uint64 {
return mb * 1024 * 1024
}
func setGPUAlertValue(info *system.Info, stats *system.Stats, value float64) {
info.GpuPct = value
stats.GPUData = map[string]system.GPUData{
"GPU0": {Usage: value},
}
}
func setTemperatureAlertValue(info *system.Info, stats *system.Stats, value float64) {
info.DashboardTemp = value
stats.Temperatures = map[string]float64{
"Temp0": value,
}
}
func setLoadAvgAlertValue(info *system.Info, stats *system.Stats, value [3]float64) {
info.LoadAvg = value
stats.LoadAvg = value
}
func setBatteryAlertValue(info *system.Info, stats *system.Stats, value [2]uint8) {
info.Battery = value
stats.Battery = value
}
func TestSystemAlertsOneMin(t *testing.T) {
testOneMinuteSystemAlert(t, "CPU", 50, setCPUAlertValue, 51, 49)
testOneMinuteSystemAlert(t, "Memory", 50, setMemoryAlertValue, 51, 49)
testOneMinuteSystemAlert(t, "Disk", 50, setDiskAlertValue, 51, 49)
testOneMinuteSystemAlert(t, "Bandwidth", 50, setBandwidthAlertValue, [2]uint64{megabytesToBytes(26), megabytesToBytes(25)}, [2]uint64{megabytesToBytes(25), megabytesToBytes(24)})
testOneMinuteSystemAlert(t, "GPU", 50, setGPUAlertValue, 51, 49)
testOneMinuteSystemAlert(t, "Temperature", 70, setTemperatureAlertValue, 71, 69)
testOneMinuteSystemAlert(t, "LoadAvg1", 4, setLoadAvgAlertValue, [3]float64{4.1, 0, 0}, [3]float64{3.9, 0, 0})
testOneMinuteSystemAlert(t, "LoadAvg5", 4, setLoadAvgAlertValue, [3]float64{0, 4.1, 0}, [3]float64{0, 3.9, 0})
testOneMinuteSystemAlert(t, "LoadAvg15", 4, setLoadAvgAlertValue, [3]float64{0, 0, 4.1}, [3]float64{0, 0, 3.9})
testOneMinuteSystemAlert(t, "Battery", 20, setBatteryAlertValue, [2]uint8{19, 0}, [2]uint8{21, 0})
}
func TestSystemAlertsTwoMin(t *testing.T) {
testMultiMinuteSystemAlert(t, "CPU", 50, 2, setCPUAlertValue, 10, 51, 48)
testMultiMinuteSystemAlert(t, "Memory", 50, 2, setMemoryAlertValue, 10, 51, 48)
testMultiMinuteSystemAlert(t, "Disk", 50, 2, setDiskAlertValue, 10, 51, 48)
testMultiMinuteSystemAlert(t, "Bandwidth", 50, 2, setBandwidthAlertValue, [2]uint64{megabytesToBytes(10), megabytesToBytes(10)}, [2]uint64{megabytesToBytes(26), megabytesToBytes(25)}, [2]uint64{megabytesToBytes(10), megabytesToBytes(10)})
testMultiMinuteSystemAlert(t, "GPU", 50, 2, setGPUAlertValue, 10, 51, 48)
testMultiMinuteSystemAlert(t, "Temperature", 70, 2, setTemperatureAlertValue, 10, 71, 67)
testMultiMinuteSystemAlert(t, "LoadAvg1", 4, 2, setLoadAvgAlertValue, [3]float64{0, 0, 0}, [3]float64{4.1, 0, 0}, [3]float64{3.5, 0, 0})
testMultiMinuteSystemAlert(t, "LoadAvg5", 4, 2, setLoadAvgAlertValue, [3]float64{0, 2, 0}, [3]float64{0, 4.1, 0}, [3]float64{0, 3.5, 0})
testMultiMinuteSystemAlert(t, "LoadAvg15", 4, 2, setLoadAvgAlertValue, [3]float64{0, 0, 2}, [3]float64{0, 0, 4.1}, [3]float64{0, 0, 3.5})
testMultiMinuteSystemAlert(t, "Battery", 20, 2, setBatteryAlertValue, [2]uint8{21, 0}, [2]uint8{19, 0}, [2]uint8{25, 1})
}

View File

@@ -12,9 +12,9 @@ import (
"testing/synctest"
"time"
"github.com/henrygd/beszel/internal/alerts"
beszelTests "github.com/henrygd/beszel/internal/tests"
"github.com/henrygd/beszel/internal/alerts"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
pbTests "github.com/pocketbase/pocketbase/tests"
@@ -369,87 +369,6 @@ func TestUserAlertsApi(t *testing.T) {
}
}
func TestStatusAlerts(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
systems, err := beszelTests.CreateSystems(hub, 4, user.Id, "paused")
assert.NoError(t, err)
var alerts []*core.Record
for i, system := range systems {
alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Status",
"system": system.Id,
"user": user.Id,
"min": i + 1,
})
assert.NoError(t, err)
alerts = append(alerts, alert)
}
time.Sleep(10 * time.Millisecond)
for _, alert := range alerts {
assert.False(t, alert.GetBool("triggered"), "Alert should not be triggered immediately")
}
if hub.TestMailer.TotalSend() != 0 {
assert.Zero(t, hub.TestMailer.TotalSend(), "Expected 0 messages, got %d", hub.TestMailer.TotalSend())
}
for _, system := range systems {
assert.EqualValues(t, "paused", system.GetString("status"), "System should be paused")
}
for _, system := range systems {
system.Set("status", "up")
err = hub.SaveNoValidate(system)
assert.NoError(t, err)
}
time.Sleep(time.Second)
assert.EqualValues(t, 0, hub.GetPendingAlertsCount(), "should have 0 alerts in the pendingAlerts map")
for _, system := range systems {
system.Set("status", "down")
err = hub.SaveNoValidate(system)
assert.NoError(t, err)
}
// after 30 seconds, should have 4 alerts in the pendingAlerts map, no triggered alerts
time.Sleep(time.Second * 30)
assert.EqualValues(t, 4, hub.GetPendingAlertsCount(), "should have 4 alerts in the pendingAlerts map")
triggeredCount, err := hub.CountRecords("alerts", dbx.HashExp{"triggered": true})
assert.NoError(t, err)
assert.EqualValues(t, 0, triggeredCount, "should have 0 alert triggered")
assert.EqualValues(t, 0, hub.TestMailer.TotalSend(), "should have 0 messages sent")
// after 1:30 seconds, should have 1 triggered alert and 3 pending alerts
time.Sleep(time.Second * 60)
assert.EqualValues(t, 3, hub.GetPendingAlertsCount(), "should have 3 alerts in the pendingAlerts map")
triggeredCount, err = hub.CountRecords("alerts", dbx.HashExp{"triggered": true})
assert.NoError(t, err)
assert.EqualValues(t, 1, triggeredCount, "should have 1 alert triggered")
assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 messages sent")
// after 2:30 seconds, should have 2 triggered alerts and 2 pending alerts
time.Sleep(time.Second * 60)
assert.EqualValues(t, 2, hub.GetPendingAlertsCount(), "should have 2 alerts in the pendingAlerts map")
triggeredCount, err = hub.CountRecords("alerts", dbx.HashExp{"triggered": true})
assert.NoError(t, err)
assert.EqualValues(t, 2, triggeredCount, "should have 2 alert triggered")
assert.EqualValues(t, 2, hub.TestMailer.TotalSend(), "should have 2 messages sent")
// now we will bring the remaning systems back up
for _, system := range systems {
system.Set("status", "up")
err = hub.SaveNoValidate(system)
assert.NoError(t, err)
}
time.Sleep(time.Second)
// should have 0 alerts in the pendingAlerts map and 0 alerts triggered
assert.EqualValues(t, 0, hub.GetPendingAlertsCount(), "should have 0 alerts in the pendingAlerts map")
triggeredCount, err = hub.CountRecords("alerts", dbx.HashExp{"triggered": true})
assert.NoError(t, err)
assert.Zero(t, triggeredCount, "should have 0 alert triggered")
// 4 messages sent, 2 down alerts and 2 up alerts for first 2 systems
assert.EqualValues(t, 4, hub.TestMailer.TotalSend(), "should have 4 messages sent")
})
}
func TestAlertsHistory(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
@@ -578,102 +497,46 @@ func TestAlertsHistory(t *testing.T) {
assert.EqualValues(t, 2, totalHistoryCount, "Should have 2 total alert history records")
})
}
func TestResolveStatusAlerts(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
func TestSetAlertTriggered(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
// Create a systemUp
systemUp, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"users": []string{user.Id},
"host": "127.0.0.1",
"status": "up",
hub.StartHub()
user, _ := beszelTests.CreateUser(hub, "test@example.com", "password")
system, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"users": []string{user.Id},
"host": "127.0.0.1",
})
assert.NoError(t, err)
systemDown, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system-2",
"users": []string{user.Id},
"host": "127.0.0.2",
"status": "up",
alertRecord, _ := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "CPU",
"system": system.Id,
"user": user.Id,
"value": 80,
"triggered": false,
})
am := alerts.NewAlertManager(hub)
var alert alerts.CachedAlertData
alert.PopulateFromRecord(alertRecord)
// Test triggering the alert
err := am.SetAlertTriggered(alert, true)
assert.NoError(t, err)
// Create a status alertUp for the system
alertUp, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Status",
"system": systemUp.Id,
"user": user.Id,
"min": 1,
})
updatedRecord, err := hub.FindRecordById("alerts", alert.Id)
assert.NoError(t, err)
assert.True(t, updatedRecord.GetBool("triggered"))
// Test un-triggering the alert
err = am.SetAlertTriggered(alert, false)
assert.NoError(t, err)
alertDown, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Status",
"system": systemDown.Id,
"user": user.Id,
"min": 1,
})
updatedRecord, err = hub.FindRecordById("alerts", alert.Id)
assert.NoError(t, err)
// Verify alert is not triggered initially
assert.False(t, alertUp.GetBool("triggered"), "Alert should not be triggered initially")
// Set the system to 'up' (this should not trigger the alert)
systemUp.Set("status", "up")
err = hub.SaveNoValidate(systemUp)
assert.NoError(t, err)
systemDown.Set("status", "down")
err = hub.SaveNoValidate(systemDown)
assert.NoError(t, err)
// Wait a moment for any processing
time.Sleep(10 * time.Millisecond)
// Verify alertUp is still not triggered after setting system to up
alertUp, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": alertUp.Id})
assert.NoError(t, err)
assert.False(t, alertUp.GetBool("triggered"), "Alert should not be triggered when system is up")
// Manually set both alerts triggered to true
alertUp.Set("triggered", true)
err = hub.SaveNoValidate(alertUp)
assert.NoError(t, err)
alertDown.Set("triggered", true)
err = hub.SaveNoValidate(alertDown)
assert.NoError(t, err)
// Verify we have exactly one alert with triggered true
triggeredCount, err := hub.CountRecords("alerts", dbx.HashExp{"triggered": true})
assert.NoError(t, err)
assert.EqualValues(t, 2, triggeredCount, "Should have exactly two alerts with triggered true")
// Verify the specific alertUp is triggered
alertUp, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": alertUp.Id})
assert.NoError(t, err)
assert.True(t, alertUp.GetBool("triggered"), "Alert should be triggered")
// Verify we have two unresolved alert history records
alertHistoryCount, err := hub.CountRecords("alerts_history", dbx.HashExp{"resolved": ""})
assert.NoError(t, err)
assert.EqualValues(t, 2, alertHistoryCount, "Should have exactly two unresolved alert history records")
err = alerts.ResolveStatusAlerts(hub)
assert.NoError(t, err)
// Verify alertUp is not triggered after resolving
alertUp, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": alertUp.Id})
assert.NoError(t, err)
assert.False(t, alertUp.GetBool("triggered"), "Alert should not be triggered after resolving")
// Verify alertDown is still triggered
alertDown, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": alertDown.Id})
assert.NoError(t, err)
assert.True(t, alertDown.GetBool("triggered"), "Alert should still be triggered after resolving")
// Verify we have one unresolved alert history record
alertHistoryCount, err = hub.CountRecords("alerts_history", dbx.HashExp{"resolved": ""})
assert.NoError(t, err)
assert.EqualValues(t, 1, alertHistoryCount, "Should have exactly one unresolved alert history record")
assert.False(t, updatedRecord.GetBool("triggered"))
}

View File

@@ -9,6 +9,18 @@ import (
"github.com/pocketbase/pocketbase/core"
)
func NewTestAlertManagerWithoutWorker(app hubLike) *AlertManager {
return &AlertManager{
hub: app,
alertsCache: NewAlertsCache(app),
}
}
// GetSystemAlertsCache returns the internal system alerts cache.
func (am *AlertManager) GetSystemAlertsCache() *AlertsCache {
return am.alertsCache
}
func (am *AlertManager) GetAlertManager() *AlertManager {
return am
}
@@ -27,19 +39,18 @@ func (am *AlertManager) GetPendingAlertsCount() int {
}
// ProcessPendingAlerts manually processes all expired alerts (for testing)
func (am *AlertManager) ProcessPendingAlerts() ([]*core.Record, error) {
func (am *AlertManager) ProcessPendingAlerts() ([]CachedAlertData, error) {
now := time.Now()
var lastErr error
var processedAlerts []*core.Record
var processedAlerts []CachedAlertData
am.pendingAlerts.Range(func(key, value any) bool {
info := value.(*alertInfo)
if now.After(info.expireTime) {
// Downtime delay has passed, process alert
if err := am.sendStatusAlert("down", info.systemName, info.alertRecord); err != nil {
lastErr = err
if info.timer != nil {
info.timer.Stop()
}
processedAlerts = append(processedAlerts, info.alertRecord)
am.pendingAlerts.Delete(key)
am.processPendingAlert(key.(string))
processedAlerts = append(processedAlerts, info.alertData)
}
return true
})
@@ -56,6 +67,31 @@ func (am *AlertManager) ForceExpirePendingAlerts() {
})
}
func (am *AlertManager) ResetPendingAlertTimer(alertID string, delay time.Duration) bool {
value, loaded := am.pendingAlerts.Load(alertID)
if !loaded {
return false
}
info := value.(*alertInfo)
if info.timer != nil {
info.timer.Stop()
}
info.expireTime = time.Now().Add(delay)
info.timer = time.AfterFunc(delay, func() {
am.processPendingAlert(alertID)
})
return true
}
func ResolveStatusAlerts(app core.App) error {
return resolveStatusAlerts(app)
}
func (am *AlertManager) RestorePendingStatusAlerts() error {
return am.restorePendingStatusAlerts()
}
func (am *AlertManager) SetAlertTriggered(alert CachedAlertData, triggered bool) error {
return am.setAlertTriggered(alert, triggered)
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/agent"
"github.com/henrygd/beszel/agent/health"
"github.com/henrygd/beszel/agent/utils"
"github.com/spf13/pflag"
"golang.org/x/crypto/ssh"
)
@@ -116,12 +117,12 @@ func (opts *cmdOptions) loadPublicKeys() ([]ssh.PublicKey, error) {
}
// Try environment variable
if key, ok := agent.GetEnv("KEY"); ok && key != "" {
if key, ok := utils.GetEnv("KEY"); ok && key != "" {
return agent.ParseKeys(key)
}
// Try key file
keyFile, ok := agent.GetEnv("KEY_FILE")
keyFile, ok := utils.GetEnv("KEY_FILE")
if !ok {
return nil, fmt.Errorf("no key provided: must set -key flag, KEY env var, or KEY_FILE env var. Use 'beszel-agent help' for usage")
}

View File

@@ -28,8 +28,8 @@ func main() {
}
baseApp := getBaseApp()
h := hub.NewHub(baseApp)
if err := h.StartHub(); err != nil {
hub := hub.NewHub(baseApp)
if err := hub.StartHub(); err != nil {
log.Fatal(err)
}
}

View File

@@ -10,10 +10,19 @@ type ApiInfo struct {
Status string
State string
Image string
Health struct {
Status string
// FailingStreak int
}
Ports []struct {
// PrivatePort uint16
PublicPort uint16
IP string
// Type string
}
// ImageID string
// Command string
// Created int64
// Ports []Port
// SizeRw int64 `json:",omitempty"`
// SizeRootFs int64 `json:",omitempty"`
// Labels map[string]string
@@ -127,43 +136,23 @@ var DockerHealthStrings = map[string]DockerHealth{
"unhealthy": DockerHealthUnhealthy,
}
// SharedCoreMetrics contains fields that are common to both container Stats and PveNodeStats
type SharedCoreMetrics struct {
Name string `json:"n" cbor:"0,keyasint"`
Cpu float64 `json:"c" cbor:"1,keyasint"`
Mem float64 `json:"m" cbor:"2,keyasint"`
NetworkSent float64 `json:"ns,omitzero" cbor:"3,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
NetworkRecv float64 `json:"nr,omitzero" cbor:"4,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
Id string `json:"-" cbor:"7,keyasint"`
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"9,keyasint,omitzero"` // [sent bytes, recv bytes]
PrevNet prevNetStats `json:"-"`
PrevReadTime time.Time `json:"-"`
}
// Stats holds data specific to docker containers for the containers table
// Docker container stats
type Stats struct {
SharedCoreMetrics // used to populate stats field in container_stats
// fields used for containers table
Name string `json:"n" cbor:"0,keyasint"`
Cpu float64 `json:"c" cbor:"1,keyasint"`
Mem float64 `json:"m" cbor:"2,keyasint"`
NetworkSent float64 `json:"ns,omitzero" cbor:"3,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
NetworkRecv float64 `json:"nr,omitzero" cbor:"4,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"9,keyasint,omitzero"` // [sent bytes, recv bytes]
Health DockerHealth `json:"-" cbor:"5,keyasint"`
Status string `json:"-" cbor:"6,keyasint"`
Id string `json:"-" cbor:"7,keyasint"`
Image string `json:"-" cbor:"8,keyasint"`
}
// PveNodeStats holds data specific to PVE nodes for the pve_vms table
type PveNodeStats struct {
SharedCoreMetrics // used to populate stats field in pve_stats
// fields used for pve_vms table
MaxCPU uint64 `json:"-" cbor:"10,keyasint,omitzero"` // PVE: max vCPU count
MaxMem uint64 `json:"-" cbor:"11,keyasint,omitzero"` // PVE: max memory bytes
Uptime uint64 `json:"-" cbor:"12,keyasint,omitzero"` // PVE: uptime in seconds
Type string `json:"-" cbor:"13,keyasint,omitzero"` // PVE: resource type (e.g. "qemu" or "lxc")
DiskRead uint64 `json:"-" cbor:"14,keyasint,omitzero"` // PVE: cumulative disk read bytes
DiskWrite uint64 `json:"-" cbor:"15,keyasint,omitzero"` // PVE: cumulative disk write bytes
Disk uint64 `json:"-" cbor:"16,keyasint,omitzero"` // PVE: allocated disk size in bytes
NetOut uint64 `json:"-" cbor:"17,keyasint,omitzero"` // PVE: cumulative bytes sent by VM
NetIn uint64 `json:"-" cbor:"18,keyasint,omitzero"` // PVE: cumulative bytes received by VM
Ports string `json:"-" cbor:"10,keyasint"`
// PrevCpu [2]uint64 `json:"-"`
CpuSystem uint64 `json:"-"`
CpuContainer uint64 `json:"-"`
PrevNet prevNetStats `json:"-"`
PrevReadTime time.Time `json:"-"`
}

View File

@@ -143,8 +143,8 @@ type AtaDeviceStatisticsPage struct {
}
type AtaDeviceStatisticsEntry struct {
Name string `json:"name"`
Value *uint64 `json:"value,omitempty"`
Name string `json:"name"`
Value *int64 `json:"value,omitempty"`
}
type AtaSmartAttribute struct {
@@ -356,8 +356,8 @@ type SmartInfoForSata struct {
SmartStatus SmartStatusInfo `json:"smart_status"`
// AtaSmartData AtaSmartData `json:"ata_smart_data"`
// AtaSctCapabilities AtaSctCapabilities `json:"ata_sct_capabilities"`
AtaSmartAttributes AtaSmartAttributes `json:"ata_smart_attributes"`
AtaDeviceStatistics AtaDeviceStatistics `json:"ata_device_statistics"`
AtaSmartAttributes AtaSmartAttributes `json:"ata_smart_attributes"`
AtaDeviceStatistics json.RawMessage `json:"ata_device_statistics"`
// PowerOnTime PowerOnTimeInfo `json:"power_on_time"`
// PowerCycleCount uint16 `json:"power_cycle_count"`
Temperature TemperatureInfo `json:"temperature"`

View File

@@ -12,8 +12,9 @@ import (
type Stats struct {
Cpu float64 `json:"cpu" cbor:"0,keyasint"`
MaxCpu float64 `json:"cpum,omitempty" cbor:"1,keyasint,omitempty"`
MaxCpu float64 `json:"cpum,omitempty" cbor:"-"`
Mem float64 `json:"m" cbor:"2,keyasint"`
MaxMem float64 `json:"mm,omitempty" cbor:"-"`
MemUsed float64 `json:"mu" cbor:"3,keyasint"`
MemPct float64 `json:"mp" cbor:"4,keyasint"`
MemBuffCache float64 `json:"mb" cbor:"5,keyasint"`
@@ -23,26 +24,25 @@ type Stats struct {
DiskTotal float64 `json:"d" cbor:"9,keyasint"`
DiskUsed float64 `json:"du" cbor:"10,keyasint"`
DiskPct float64 `json:"dp" cbor:"11,keyasint"`
DiskReadPs float64 `json:"dr" cbor:"12,keyasint"`
DiskWritePs float64 `json:"dw" cbor:"13,keyasint"`
MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"14,keyasint,omitempty"`
MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"15,keyasint,omitempty"`
DiskReadPs float64 `json:"dr,omitzero" cbor:"12,keyasint,omitzero"`
DiskWritePs float64 `json:"dw,omitzero" cbor:"13,keyasint,omitzero"`
MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"-"`
MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"-"`
NetworkSent float64 `json:"ns,omitzero" cbor:"16,keyasint,omitzero"`
NetworkRecv float64 `json:"nr,omitzero" cbor:"17,keyasint,omitzero"`
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"18,keyasint,omitempty"`
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"19,keyasint,omitempty"`
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"-"`
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"-"`
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"`
LoadAvg5 float64 `json:"l5,omitempty" cbor:"24,keyasint,omitempty"`
LoadAvg15 float64 `json:"l15,omitempty" cbor:"25,keyasint,omitempty"`
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"26,keyasint,omitzero"` // [sent bytes, recv bytes]
MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"27,keyasint,omitzero"` // [sent bytes, recv bytes]
// LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"`
// LoadAvg5 float64 `json:"l5,omitempty" cbor:"24,keyasint,omitempty"`
// LoadAvg15 float64 `json:"l15,omitempty" cbor:"25,keyasint,omitempty"`
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"26,keyasint,omitzero"` // [sent bytes, recv bytes]
MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"-"` // [sent bytes, recv bytes]
// TODO: remove other load fields in future release in favor of load avg array
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
MaxMem float64 `json:"mm,omitempty" cbor:"30,keyasint,omitempty"`
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
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]
@@ -90,8 +90,8 @@ type FsStats struct {
TotalWrite uint64 `json:"-"`
DiskReadPs float64 `json:"r" cbor:"2,keyasint"`
DiskWritePs float64 `json:"w" cbor:"3,keyasint"`
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"4,keyasint,omitempty"`
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"5,keyasint,omitempty"`
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"-"`
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"-"`
// 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"`
@@ -129,23 +129,23 @@ type Info struct {
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"` // deprecated - moved to Details struct
Cores int `json:"c,omitzero" cbor:"2,keyasint,omitzero"` // deprecated - moved to Details struct
// Threads is needed in Info struct to calculate load average thresholds
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
CpuModel string `json:"m,omitempty" cbor:"4,keyasint,omitempty"` // deprecated - moved to Details struct
Uptime uint64 `json:"u" cbor:"5,keyasint"`
Cpu float64 `json:"cpu" cbor:"6,keyasint"`
MemPct float64 `json:"mp" cbor:"7,keyasint"`
DiskPct float64 `json:"dp" cbor:"8,keyasint"`
Bandwidth float64 `json:"b" cbor:"9,keyasint"`
AgentVersion string `json:"v" cbor:"10,keyasint"`
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"` // deprecated - moved to Details struct
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
Os Os `json:"os,omitempty" cbor:"14,keyasint,omitempty"` // deprecated - moved to Details struct
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"` // deprecated - use `la` array instead
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"` // deprecated - use `la` array instead
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"` // deprecated - use `la` array instead
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
CpuModel string `json:"m,omitempty" cbor:"4,keyasint,omitempty"` // deprecated - moved to Details struct
Uptime uint64 `json:"u" cbor:"5,keyasint"`
Cpu float64 `json:"cpu" cbor:"6,keyasint"`
MemPct float64 `json:"mp" cbor:"7,keyasint"`
DiskPct float64 `json:"dp" cbor:"8,keyasint"`
Bandwidth float64 `json:"b,omitzero" cbor:"9,keyasint"` // deprecated in favor of BandwidthBytes
AgentVersion string `json:"v" cbor:"10,keyasint"`
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"` // deprecated - moved to Details struct
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
Os Os `json:"os,omitempty" cbor:"14,keyasint,omitempty"` // deprecated - moved to Details struct
// LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"` // deprecated - use `la` array instead
// LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"` // deprecated - use `la` array instead
// LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"` // deprecated - use `la` array instead
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
ExtraFsPct map[string]float64 `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
@@ -170,10 +170,9 @@ type Details struct {
// Final data structure to return to the hub
type CombinedData struct {
Stats Stats `json:"stats" cbor:"0,keyasint"`
Info Info `json:"info" cbor:"1,keyasint"`
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
Details *Details `cbor:"4,keyasint,omitempty"`
PVEStats []*container.PveNodeStats `json:"pve,omitempty" cbor:"5,keyasint,omitempty"`
Stats Stats `json:"stats" cbor:"0,keyasint"`
Info Info `json:"info" cbor:"1,keyasint"`
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
Details *Details `cbor:"4,keyasint,omitempty"`
}

View File

@@ -110,21 +110,13 @@ func (p *updater) update() (updated bool, err error) {
}
var latest *release
var useMirror bool
// Determine the API endpoint based on UseMirror flag
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", p.config.Owner, p.config.Repo)
apiURL := getApiURL(p.config.UseMirror, p.config.Owner, p.config.Repo)
if p.config.UseMirror {
useMirror = true
apiURL = fmt.Sprintf("https://gh.beszel.dev/repos/%s/%s/releases/latest?api=true", p.config.Owner, p.config.Repo)
ColorPrint(ColorYellow, "Using mirror for update.")
}
latest, err = fetchLatestRelease(
p.config.Context,
p.config.HttpClient,
apiURL,
)
latest, err = FetchLatestRelease(p.config.Context, p.config.HttpClient, apiURL)
if err != nil {
return false, err
}
@@ -150,7 +142,7 @@ func (p *updater) update() (updated bool, err error) {
// download the release asset
assetPath := filepath.Join(releaseDir, asset.Name)
if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetPath, useMirror); err != nil {
if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetPath, p.config.UseMirror); err != nil {
return false, err
}
@@ -226,11 +218,11 @@ func (p *updater) update() (updated bool, err error) {
return true, nil
}
func fetchLatestRelease(
ctx context.Context,
client HttpClient,
url string,
) (*release, error) {
func FetchLatestRelease(ctx context.Context, client HttpClient, url string) (*release, error) {
if url == "" {
url = getApiURL(false, "henrygd", "beszel")
}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
@@ -375,3 +367,10 @@ func isGlibc() bool {
}
return false
}
func getApiURL(useMirror bool, owner, repo string) string {
if useMirror {
return fmt.Sprintf("https://gh.beszel.dev/repos/%s/%s/releases/latest?api=true", owner, repo)
}
return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
}

View File

@@ -32,7 +32,7 @@ func createTestHub(t testing.TB) (*Hub, *pbtests.TestApp, error) {
if err != nil {
return nil, nil, err
}
return NewHub(testApp), testApp, nil
return NewHub(testApp), testApp, err
}
// cleanupTestHub stops background system goroutines before tearing down the app.
@@ -897,12 +897,8 @@ func TestAgentWebSocketIntegration(t *testing.T) {
require.NoError(t, err)
// Set up environment variables for the agent
os.Setenv("BESZEL_AGENT_HUB_URL", ts.URL)
os.Setenv("BESZEL_AGENT_TOKEN", tc.agentToken)
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
t.Setenv("BESZEL_AGENT_HUB_URL", ts.URL)
t.Setenv("BESZEL_AGENT_TOKEN", tc.agentToken)
// Start agent in background
done := make(chan error, 1)
@@ -917,7 +913,7 @@ func TestAgentWebSocketIntegration(t *testing.T) {
// Wait for connection result
maxWait := 2 * time.Second
time.Sleep(20 * time.Millisecond)
time.Sleep(40 * time.Millisecond)
checkInterval := 20 * time.Millisecond
timeout := time.After(maxWait)
ticker := time.Tick(checkInterval)
@@ -1080,12 +1076,8 @@ func TestMultipleSystemsWithSameUniversalToken(t *testing.T) {
require.NoError(t, err)
// Set up environment variables for the agent
os.Setenv("BESZEL_AGENT_HUB_URL", ts.URL)
os.Setenv("BESZEL_AGENT_TOKEN", universalToken)
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
t.Setenv("BESZEL_AGENT_HUB_URL", ts.URL)
t.Setenv("BESZEL_AGENT_TOKEN", universalToken)
// Count systems before connection
systemsBefore, err := testApp.FindRecordsByFilter("systems", "users ~ {:userId}", "", -1, 0, map[string]any{"userId": userRecord.Id})
@@ -1243,12 +1235,8 @@ func TestPermanentUniversalTokenFromDB(t *testing.T) {
require.NoError(t, err)
// Set up environment variables for the agent
os.Setenv("BESZEL_AGENT_HUB_URL", ts.URL)
os.Setenv("BESZEL_AGENT_TOKEN", universalToken)
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
t.Setenv("BESZEL_AGENT_HUB_URL", ts.URL)
t.Setenv("BESZEL_AGENT_TOKEN", universalToken)
// Start agent in background
done := make(chan error, 1)

361
internal/hub/api.go Normal file
View File

@@ -0,0 +1,361 @@
package hub
import (
"context"
"net/http"
"strings"
"time"
"github.com/blang/semver"
"github.com/google/uuid"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/alerts"
"github.com/henrygd/beszel/internal/ghupdate"
"github.com/henrygd/beszel/internal/hub/config"
"github.com/henrygd/beszel/internal/hub/systems"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
)
// UpdateInfo holds information about the latest update check
type UpdateInfo struct {
lastCheck time.Time
Version string `json:"v"`
Url string `json:"url"`
}
// registerMiddlewares registers custom middlewares
func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
// authorizes request with user matching the provided email
authorizeRequestWithEmail := func(e *core.RequestEvent, email string) (err error) {
if e.Auth != nil || email == "" {
return e.Next()
}
isAuthRefresh := e.Request.URL.Path == "/api/collections/users/auth-refresh" && e.Request.Method == http.MethodPost
e.Auth, err = e.App.FindFirstRecordByData("users", "email", email)
if err != nil || !isAuthRefresh {
return e.Next()
}
// auth refresh endpoint, make sure token is set in header
token, _ := e.Auth.NewAuthToken()
e.Request.Header.Set("Authorization", token)
return e.Next()
}
// authenticate with trusted header
if autoLogin, _ := GetEnv("AUTO_LOGIN"); autoLogin != "" {
se.Router.BindFunc(func(e *core.RequestEvent) error {
return authorizeRequestWithEmail(e, autoLogin)
})
}
// authenticate with trusted header
if trustedHeader, _ := GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" {
se.Router.BindFunc(func(e *core.RequestEvent) error {
return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader))
})
}
}
// registerApiRoutes registers custom API routes
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
// auth protected routes
apiAuth := se.Router.Group("/api/beszel")
apiAuth.Bind(apis.RequireAuth())
// auth optional routes
apiNoAuth := se.Router.Group("/api/beszel")
// create first user endpoint only needed if no users exist
if totalUsers, _ := se.App.CountRecords("users"); totalUsers == 0 {
apiNoAuth.POST("/create-user", h.um.CreateFirstUser)
}
// check if first time setup on login page
apiNoAuth.GET("/first-run", func(e *core.RequestEvent) error {
total, err := e.App.CountRecords("users")
return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0})
})
// get public key and version
apiAuth.GET("/info", h.getInfo)
apiAuth.GET("/getkey", h.getInfo) // deprecated - keep for compatibility w/ integrations
// check for updates
if optIn, _ := GetEnv("CHECK_UPDATES"); optIn == "true" {
var updateInfo UpdateInfo
apiAuth.GET("/update", updateInfo.getUpdate)
}
// send test notification
apiAuth.POST("/test-notification", h.SendTestNotification)
// heartbeat status and test
apiAuth.GET("/heartbeat-status", h.getHeartbeatStatus)
apiAuth.POST("/test-heartbeat", h.testHeartbeat)
// get config.yml content
apiAuth.GET("/config-yaml", config.GetYamlConfig)
// handle agent websocket connection
apiNoAuth.GET("/agent-connect", h.handleAgentConnect)
// get or create universal tokens
apiAuth.GET("/universal-token", h.getUniversalToken)
// update / delete user alerts
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
// refresh SMART devices for a system
apiAuth.POST("/smart/refresh", h.refreshSmartData)
// get systemd service details
apiAuth.GET("/systemd/info", h.getSystemdInfo)
// /containers routes
if enabled, _ := GetEnv("CONTAINER_DETAILS"); enabled != "false" {
// get container logs
apiAuth.GET("/containers/logs", h.getContainerLogs)
// get container info
apiAuth.GET("/containers/info", h.getContainerInfo)
}
return nil
}
// getInfo returns data needed by authenticated users, such as the public key and current version
func (h *Hub) getInfo(e *core.RequestEvent) error {
type infoResponse struct {
Key string `json:"key"`
Version string `json:"v"`
CheckUpdate bool `json:"cu"`
}
info := infoResponse{
Key: h.pubKey,
Version: beszel.Version,
}
if optIn, _ := GetEnv("CHECK_UPDATES"); optIn == "true" {
info.CheckUpdate = true
}
return e.JSON(http.StatusOK, info)
}
// getUpdate checks for the latest release on GitHub and returns update info if a newer version is available
func (info *UpdateInfo) getUpdate(e *core.RequestEvent) error {
if time.Since(info.lastCheck) < 6*time.Hour {
return e.JSON(http.StatusOK, info)
}
info.lastCheck = time.Now()
latestRelease, err := ghupdate.FetchLatestRelease(context.Background(), http.DefaultClient, "")
if err != nil {
return err
}
currentVersion, err := semver.Parse(strings.TrimPrefix(beszel.Version, "v"))
if err != nil {
return err
}
latestVersion, err := semver.Parse(strings.TrimPrefix(latestRelease.Tag, "v"))
if err != nil {
return err
}
if latestVersion.GT(currentVersion) {
info.Version = strings.TrimPrefix(latestRelease.Tag, "v")
info.Url = latestRelease.Url
}
return e.JSON(http.StatusOK, info)
}
// GetUniversalToken handles the universal token API endpoint (create, read, delete)
func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
tokenMap := universalTokenMap.GetMap()
userID := e.Auth.Id
query := e.Request.URL.Query()
token := query.Get("token")
enable := query.Get("enable")
permanent := query.Get("permanent")
// helper for deleting any existing permanent token record for this user
deletePermanent := func() error {
rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID})
if err != nil {
return nil // no record
}
return h.Delete(rec)
}
// helper for upserting a permanent token record for this user
upsertPermanent := func(token string) error {
rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID})
if err == nil {
rec.Set("token", token)
return h.Save(rec)
}
col, err := h.FindCachedCollectionByNameOrId("universal_tokens")
if err != nil {
return err
}
newRec := core.NewRecord(col)
newRec.Set("user", userID)
newRec.Set("token", token)
return h.Save(newRec)
}
// Disable universal tokens (both ephemeral and permanent)
if enable == "0" {
tokenMap.RemovebyValue(userID)
_ = deletePermanent()
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": false, "permanent": false})
}
// Enable universal token (ephemeral or permanent)
if enable == "1" {
if token == "" {
token = uuid.New().String()
}
if permanent == "1" {
// make token permanent (persist across restarts)
tokenMap.RemovebyValue(userID)
if err := upsertPermanent(token); err != nil {
return err
}
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": true})
}
// default: ephemeral mode (1 hour)
_ = deletePermanent()
tokenMap.Set(token, userID, time.Hour)
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": false})
}
// Read current state
// Prefer permanent token if it exists.
if rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID}); err == nil {
dbToken := rec.GetString("token")
// If no token was provided, or the caller is asking about their permanent token, return it.
if token == "" || token == dbToken {
return e.JSON(http.StatusOK, map[string]any{"token": dbToken, "active": true, "permanent": true})
}
// Token doesn't match their permanent token (avoid leaking other info)
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": false, "permanent": false})
}
// No permanent token; fall back to ephemeral token map.
if token == "" {
// return existing token if it exists
if token, _, ok := tokenMap.GetByValue(userID); ok {
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": false})
}
// if no token is provided, generate a new one
token = uuid.New().String()
}
// Token is considered active only if it belongs to the current user.
activeUser, ok := tokenMap.GetOk(token)
active := ok && activeUser == userID
response := map[string]any{"token": token, "active": active, "permanent": false}
return e.JSON(http.StatusOK, response)
}
// getHeartbeatStatus returns current heartbeat configuration and whether it's enabled
func (h *Hub) getHeartbeatStatus(e *core.RequestEvent) error {
if e.Auth.GetString("role") != "admin" {
return e.ForbiddenError("Requires admin role", nil)
}
if h.hb == nil {
return e.JSON(http.StatusOK, map[string]any{
"enabled": false,
"msg": "Set HEARTBEAT_URL to enable outbound heartbeat monitoring",
})
}
cfg := h.hb.GetConfig()
return e.JSON(http.StatusOK, map[string]any{
"enabled": true,
"url": cfg.URL,
"interval": cfg.Interval,
"method": cfg.Method,
})
}
// testHeartbeat triggers a single heartbeat ping and returns the result
func (h *Hub) testHeartbeat(e *core.RequestEvent) error {
if e.Auth.GetString("role") != "admin" {
return e.ForbiddenError("Requires admin role", nil)
}
if h.hb == nil {
return e.JSON(http.StatusOK, map[string]any{
"err": "Heartbeat not configured. Set HEARTBEAT_URL environment variable.",
})
}
if err := h.hb.Send(); err != nil {
return e.JSON(http.StatusOK, map[string]any{"err": err.Error()})
}
return e.JSON(http.StatusOK, map[string]any{"err": false})
}
// 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"})
}
if !containerIDPattern.MatchString(containerID) {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "invalid container parameter"})
}
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.StatusNotFound, 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")
}
// getSystemdInfo handles GET /api/beszel/systemd/info requests
func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
query := e.Request.URL.Query()
systemID := query.Get("system")
serviceName := query.Get("service")
if systemID == "" || serviceName == "" {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and service parameters are required"})
}
system, err := h.sm.GetSystem(systemID)
if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
}
details, err := system.FetchSystemdInfoFromAgent(serviceName)
if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
e.Response.Header().Set("Cache-Control", "public, max-age=60")
return e.JSON(http.StatusOK, map[string]any{"details": details})
}
// refreshSmartData handles POST /api/beszel/smart/refresh requests
// Fetches fresh SMART data from the agent and updates the collection
func (h *Hub) refreshSmartData(e *core.RequestEvent) error {
systemID := e.Request.URL.Query().Get("system")
if systemID == "" {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system parameter is required"})
}
system, err := h.sm.GetSystem(systemID)
if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
}
// Fetch and save SMART devices
if err := system.FetchAndSaveSmartDevices(); err != nil {
return e.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
}

780
internal/hub/api_test.go Normal file
View File

@@ -0,0 +1,780 @@
package hub_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"testing"
beszelTests "github.com/henrygd/beszel/internal/tests"
"github.com/henrygd/beszel/internal/migrations"
"github.com/pocketbase/pocketbase/core"
pbTests "github.com/pocketbase/pocketbase/tests"
"github.com/stretchr/testify/require"
)
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
func jsonReader(v any) io.Reader {
data, err := json.Marshal(v)
if err != nil {
panic(err)
}
return bytes.NewReader(data)
}
func TestApiRoutesAuthentication(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
hub.StartHub()
// Create test user and get auth token
user, err := beszelTests.CreateUser(hub, "testuser@example.com", "password123")
require.NoError(t, err, "Failed to create test user")
adminUser, err := beszelTests.CreateRecord(hub, "users", map[string]any{
"email": "admin@example.com",
"password": "password123",
"role": "admin",
})
require.NoError(t, err, "Failed to create admin user")
adminUserToken, err := adminUser.NewAuthToken()
// superUser, err := beszelTests.CreateRecord(hub, core.CollectionNameSuperusers, map[string]any{
// "email": "superuser@example.com",
// "password": "password123",
// })
// require.NoError(t, err, "Failed to create superuser")
userToken, err := user.NewAuthToken()
require.NoError(t, err, "Failed to create auth token")
// Create test system for user-alerts endpoints
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"users": []string{user.Id},
"host": "127.0.0.1",
})
require.NoError(t, err, "Failed to create test system")
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
// Auth Protected Routes - Should require authentication
{
Name: "POST /test-notification - no auth should fail",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1",
}),
},
{
Name: "POST /test-notification - with auth should succeed",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": userToken,
},
Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1",
}),
ExpectedStatus: 200,
ExpectedContent: []string{"sending message"},
},
{
Name: "GET /config-yaml - no auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/config-yaml",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /config-yaml - with user auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/config-yaml",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 403,
ExpectedContent: []string{"Requires admin"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /config-yaml - with admin auth should succeed",
Method: http.MethodGet,
URL: "/api/beszel/config-yaml",
Headers: map[string]string{
"Authorization": adminUserToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"test-system"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /heartbeat-status - no auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/heartbeat-status",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /heartbeat-status - with user auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/heartbeat-status",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 403,
ExpectedContent: []string{"Requires admin role"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /heartbeat-status - with admin auth should succeed",
Method: http.MethodGet,
URL: "/api/beszel/heartbeat-status",
Headers: map[string]string{
"Authorization": adminUserToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{`"enabled":false`},
TestAppFactory: testAppFactory,
},
{
Name: "POST /test-heartbeat - with user auth should fail",
Method: http.MethodPost,
URL: "/api/beszel/test-heartbeat",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 403,
ExpectedContent: []string{"Requires admin role"},
TestAppFactory: testAppFactory,
},
{
Name: "POST /test-heartbeat - with admin auth should report disabled state",
Method: http.MethodPost,
URL: "/api/beszel/test-heartbeat",
Headers: map[string]string{
"Authorization": adminUserToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"Heartbeat not configured"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /universal-token - no auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/universal-token",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /universal-token - with auth should succeed",
Method: http.MethodGet,
URL: "/api/beszel/universal-token",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"active", "token", "permanent"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /universal-token - enable permanent should succeed",
Method: http.MethodGet,
URL: "/api/beszel/universal-token?enable=1&permanent=1&token=permanent-token-123",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"permanent\":true", "permanent-token-123"},
TestAppFactory: testAppFactory,
},
{
Name: "POST /user-alerts - no auth should fail",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 80,
"min": 10,
"systems": []string{system.Id},
}),
},
{
Name: "POST /user-alerts - with auth should succeed",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 80,
"min": 10,
"systems": []string{system.Id},
}),
},
{
Name: "DELETE /user-alerts - no auth should fail",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"systems": []string{system.Id},
}),
},
{
Name: "DELETE /user-alerts - with auth should succeed",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"systems": []string{system.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
// Create an alert to delete
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system.Id,
"user": user.Id,
"value": 80,
"min": 10,
})
},
},
{
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=0123456789ab",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 404,
ExpectedContent: []string{"system not found"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/logs - traversal container should fail validation",
Method: http.MethodGet,
URL: "/api/beszel/containers/logs?system=" + system.Id + "&container=..%2F..%2Fversion",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"invalid container parameter"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/info - traversal container should fail validation",
Method: http.MethodGet,
URL: "/api/beszel/containers/info?system=" + system.Id + "&container=../../version?x=",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"invalid container parameter"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/info - non-hex container should fail validation",
Method: http.MethodGet,
URL: "/api/beszel/containers/info?system=" + system.Id + "&container=container_name",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"invalid container parameter"},
TestAppFactory: testAppFactory,
},
// Auth Optional Routes - Should work without authentication
{
Name: "GET /getkey - no auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /getkey - with auth should also succeed",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"key\":", "\"v\":"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /info - should return the same as /getkey",
Method: http.MethodGet,
URL: "/api/beszel/info",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"key\":", "\"v\":"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /first-run - no auth should succeed",
Method: http.MethodGet,
URL: "/api/beszel/first-run",
ExpectedStatus: 200,
ExpectedContent: []string{"\"firstRun\":false"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /first-run - with auth should also succeed",
Method: http.MethodGet,
URL: "/api/beszel/first-run",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"firstRun\":false"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /agent-connect - no auth should succeed (websocket upgrade fails but route is accessible)",
Method: http.MethodGet,
URL: "/api/beszel/agent-connect",
ExpectedStatus: 400,
ExpectedContent: []string{},
TestAppFactory: testAppFactory,
},
{
Name: "POST /test-notification - invalid auth token should fail",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1",
}),
Headers: map[string]string{
"Authorization": "invalid-token",
},
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "POST /user-alerts - invalid auth token should fail",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": "invalid-token",
},
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 80,
"min": 10,
"systems": []string{system.Id},
}),
},
{
Name: "GET /update - shouldn't exist without CHECK_UPDATES env var",
Method: http.MethodGet,
URL: "/api/beszel/update",
ExpectedStatus: 502,
TestAppFactory: testAppFactory,
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestFirstUserCreation(t *testing.T) {
t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
hub.StartHub()
testAppFactoryExisting := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
{
Name: "POST /create-user - should be available when no users exist",
Method: http.MethodPost,
URL: "/api/beszel/create-user",
Body: jsonReader(map[string]any{
"email": "firstuser@example.com",
"password": "password123",
}),
ExpectedStatus: 200,
ExpectedContent: []string{"User created"},
TestAppFactory: testAppFactoryExisting,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
userCount, err := hub.CountRecords("users")
require.NoError(t, err)
require.Zero(t, userCount, "Should start with no users")
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
require.NoError(t, err)
require.EqualValues(t, 1, len(superusers), "Should start with one temporary superuser")
require.EqualValues(t, migrations.TempAdminEmail, superusers[0].GetString("email"), "Should have created one temporary superuser")
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
userCount, err := hub.CountRecords("users")
require.NoError(t, err)
require.EqualValues(t, 1, userCount, "Should have created one user")
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
require.NoError(t, err)
require.EqualValues(t, 1, len(superusers), "Should have created one superuser")
require.EqualValues(t, "firstuser@example.com", superusers[0].GetString("email"), "Should have created one superuser")
},
},
{
Name: "POST /create-user - should not be available when users exist",
Method: http.MethodPost,
URL: "/api/beszel/create-user",
Body: jsonReader(map[string]any{
"email": "firstuser@example.com",
"password": "password123",
}),
ExpectedStatus: 404,
ExpectedContent: []string{"wasn't found"},
TestAppFactory: testAppFactoryExisting,
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
})
t.Run("CreateUserEndpoint not available when USER_EMAIL, USER_PASSWORD are set", func(t *testing.T) {
t.Setenv("BESZEL_HUB_USER_EMAIL", "me@example.com")
t.Setenv("BESZEL_HUB_USER_PASSWORD", "password123")
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
hub.StartHub()
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenario := beszelTests.ApiScenario{
Name: "POST /create-user - should not be available when USER_EMAIL, USER_PASSWORD are set",
Method: http.MethodPost,
URL: "/api/beszel/create-user",
ExpectedStatus: 404,
ExpectedContent: []string{"wasn't found"},
TestAppFactory: testAppFactory,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
users, err := hub.FindAllRecords("users")
require.NoError(t, err)
require.EqualValues(t, 1, len(users), "Should start with one user")
require.EqualValues(t, "me@example.com", users[0].GetString("email"), "Should have created one user")
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
require.NoError(t, err)
require.EqualValues(t, 1, len(superusers), "Should start with one superuser")
require.EqualValues(t, "me@example.com", superusers[0].GetString("email"), "Should have created one superuser")
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
users, err := hub.FindAllRecords("users")
require.NoError(t, err)
require.EqualValues(t, 1, len(users), "Should still have one user")
require.EqualValues(t, "me@example.com", users[0].GetString("email"), "Should have created one user")
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
require.NoError(t, err)
require.EqualValues(t, 1, len(superusers), "Should still have one superuser")
require.EqualValues(t, "me@example.com", superusers[0].GetString("email"), "Should have created one superuser")
},
}
scenario.Test(t)
})
}
func TestCreateUserEndpointAvailability(t *testing.T) {
t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
// Ensure no users exist
userCount, err := hub.CountRecords("users")
require.NoError(t, err)
require.Zero(t, userCount, "Should start with no users")
hub.StartHub()
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenario := beszelTests.ApiScenario{
Name: "POST /create-user - should be available when no users exist",
Method: http.MethodPost,
URL: "/api/beszel/create-user",
Body: jsonReader(map[string]any{
"email": "firstuser@example.com",
"password": "password123",
}),
ExpectedStatus: 200,
ExpectedContent: []string{"User created"},
TestAppFactory: testAppFactory,
}
scenario.Test(t)
// Verify user was created
userCount, err = hub.CountRecords("users")
require.NoError(t, err)
require.EqualValues(t, 1, userCount, "Should have created one user")
})
t.Run("CreateUserEndpoint not available when users exist", func(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
// Create a user first
_, err := beszelTests.CreateUser(hub, "existing@example.com", "password")
require.NoError(t, err)
hub.StartHub()
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenario := beszelTests.ApiScenario{
Name: "POST /create-user - should not be available when users exist",
Method: http.MethodPost,
URL: "/api/beszel/create-user",
Body: jsonReader(map[string]any{
"email": "another@example.com",
"password": "password123",
}),
ExpectedStatus: 404,
ExpectedContent: []string{"wasn't found"},
TestAppFactory: testAppFactory,
}
scenario.Test(t)
})
}
func TestAutoLoginMiddleware(t *testing.T) {
var hubs []*beszelTests.TestHub
defer func() {
for _, hub := range hubs {
hub.Cleanup()
}
}()
t.Setenv("AUTO_LOGIN", "user@test.com")
testAppFactory := func(t testing.TB) *pbTests.TestApp {
hub, _ := beszelTests.NewTestHub(t.TempDir())
hubs = append(hubs, hub)
hub.StartHub()
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
{
Name: "GET /getkey - without auto login should fail",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /getkey - with auto login should fail if no matching user",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /getkey - with auto login should succeed",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
ExpectedStatus: 200,
ExpectedContent: []string{"\"key\":", "\"v\":"},
TestAppFactory: testAppFactory,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.CreateUser(app, "user@test.com", "password123")
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestTrustedHeaderMiddleware(t *testing.T) {
var hubs []*beszelTests.TestHub
defer func() {
for _, hub := range hubs {
hub.Cleanup()
}
}()
t.Setenv("TRUSTED_AUTH_HEADER", "X-Beszel-Trusted")
testAppFactory := func(t testing.TB) *pbTests.TestApp {
hub, _ := beszelTests.NewTestHub(t.TempDir())
hubs = append(hubs, hub)
hub.StartHub()
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
{
Name: "GET /getkey - without trusted header should fail",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /getkey - with trusted header should fail if no matching user",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
Headers: map[string]string{
"X-Beszel-Trusted": "user@test.com",
},
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /getkey - with trusted header should succeed",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
Headers: map[string]string{
"X-Beszel-Trusted": "user@test.com",
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"key\":", "\"v\":"},
TestAppFactory: testAppFactory,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.CreateUser(app, "user@test.com", "password123")
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestUpdateEndpoint(t *testing.T) {
t.Setenv("CHECK_UPDATES", "true")
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
hub.StartHub()
// Create test user and get auth token
// user, err := beszelTests.CreateUser(hub, "testuser@example.com", "password123")
// require.NoError(t, err, "Failed to create test user")
// userToken, err := user.NewAuthToken()
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
{
Name: "update endpoint shouldn't work without auth",
Method: http.MethodGet,
URL: "/api/beszel/update",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
// leave this out for now since it actually makes a request to github
// {
// Name: "GET /update - with valid auth should succeed",
// Method: http.MethodGet,
// URL: "/api/beszel/update",
// Headers: map[string]string{
// "Authorization": userToken,
// },
// ExpectedStatus: 200,
// ExpectedContent: []string{`"v":`},
// TestAppFactory: testAppFactory,
// },
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}

128
internal/hub/collections.go Normal file
View File

@@ -0,0 +1,128 @@
package hub
import "github.com/pocketbase/pocketbase/core"
type collectionRules struct {
list *string
view *string
create *string
update *string
delete *string
}
// setCollectionAuthSettings applies Beszel's collection auth settings.
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 {
return err
}
// disable email auth if DISABLE_PASSWORD_AUTH env var is set
disablePasswordAuth, _ := GetEnv("DISABLE_PASSWORD_AUTH")
usersCollection.PasswordAuth.Enabled = disablePasswordAuth != "true"
usersCollection.PasswordAuth.IdentityFields = []string{"email"}
// allow oauth user creation if USER_CREATION is set
if userCreation, _ := GetEnv("USER_CREATION"); userCreation == "true" {
cr := "@request.context = 'oauth2'"
usersCollection.CreateRule = &cr
} else {
usersCollection.CreateRule = 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
}
// When SHARE_ALL_SYSTEMS is enabled, any authenticated user can read
// system-scoped data. Write rules continue to block readonly users.
shareAllSystems, _ := GetEnv("SHARE_ALL_SYSTEMS")
authenticatedRule := "@request.auth.id != \"\""
systemsMemberRule := authenticatedRule + " && users.id ?= @request.auth.id"
systemMemberRule := authenticatedRule + " && system.users.id ?= @request.auth.id"
systemsReadRule := systemsMemberRule
systemScopedReadRule := systemMemberRule
if shareAllSystems == "true" {
systemsReadRule = authenticatedRule
systemScopedReadRule = authenticatedRule
}
systemsWriteRule := systemsReadRule + " && @request.auth.role != \"readonly\""
systemScopedWriteRule := systemScopedReadRule + " && @request.auth.role != \"readonly\""
if err := applyCollectionRules(app, []string{"systems"}, collectionRules{
list: &systemsReadRule,
view: &systemsReadRule,
create: &systemsWriteRule,
update: &systemsWriteRule,
delete: &systemsWriteRule,
}); err != nil {
return err
}
if err := applyCollectionRules(app, []string{"containers", "container_stats", "system_stats", "systemd_services"}, collectionRules{
list: &systemScopedReadRule,
}); err != nil {
return err
}
if err := applyCollectionRules(app, []string{"smart_devices"}, collectionRules{
list: &systemScopedReadRule,
view: &systemScopedReadRule,
delete: &systemScopedWriteRule,
}); err != nil {
return err
}
if err := applyCollectionRules(app, []string{"fingerprints"}, collectionRules{
list: &systemScopedReadRule,
view: &systemScopedReadRule,
create: &systemScopedWriteRule,
update: &systemScopedWriteRule,
delete: &systemScopedWriteRule,
}); err != nil {
return err
}
if err := applyCollectionRules(app, []string{"system_details"}, collectionRules{
list: &systemScopedReadRule,
view: &systemScopedReadRule,
}); err != nil {
return err
}
return nil
}
func applyCollectionRules(app core.App, collectionNames []string, rules collectionRules) error {
for _, collectionName := range collectionNames {
collection, err := app.FindCollectionByNameOrId(collectionName)
if err != nil {
return err
}
collection.ListRule = rules.list
collection.ViewRule = rules.view
collection.CreateRule = rules.create
collection.UpdateRule = rules.update
collection.DeleteRule = rules.delete
if err := app.Save(collection); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,527 @@
//go:build testing
package hub_test
import (
"fmt"
"net/http"
"testing"
beszelTests "github.com/henrygd/beszel/internal/tests"
"github.com/pocketbase/pocketbase/core"
pbTests "github.com/pocketbase/pocketbase/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCollectionRulesDefault(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
const isUserMatchesUser = `@request.auth.id != "" && user = @request.auth.id`
const isUserInUsers = `@request.auth.id != "" && users.id ?= @request.auth.id`
const isUserInUsersNotReadonly = `@request.auth.id != "" && users.id ?= @request.auth.id && @request.auth.role != "readonly"`
const isUserInSystemUsers = `@request.auth.id != "" && system.users.id ?= @request.auth.id`
const isUserInSystemUsersNotReadonly = `@request.auth.id != "" && system.users.id ?= @request.auth.id && @request.auth.role != "readonly"`
// users collection
usersCollection, err := hub.FindCollectionByNameOrId("users")
assert.NoError(t, err, "Failed to find users collection")
assert.True(t, usersCollection.PasswordAuth.Enabled)
assert.Equal(t, usersCollection.PasswordAuth.IdentityFields, []string{"email"})
assert.Nil(t, usersCollection.CreateRule)
assert.False(t, usersCollection.MFA.Enabled)
// superusers collection
superusersCollection, err := hub.FindCollectionByNameOrId(core.CollectionNameSuperusers)
assert.NoError(t, err, "Failed to find superusers collection")
assert.True(t, superusersCollection.PasswordAuth.Enabled)
assert.Equal(t, superusersCollection.PasswordAuth.IdentityFields, []string{"email"})
assert.Nil(t, superusersCollection.CreateRule)
assert.False(t, superusersCollection.MFA.Enabled)
// alerts collection
alertsCollection, err := hub.FindCollectionByNameOrId("alerts")
require.NoError(t, err, "Failed to find alerts collection")
assert.Equal(t, isUserMatchesUser, *alertsCollection.ListRule)
assert.Nil(t, alertsCollection.ViewRule)
assert.Equal(t, isUserMatchesUser, *alertsCollection.CreateRule)
assert.Equal(t, isUserMatchesUser, *alertsCollection.UpdateRule)
assert.Equal(t, isUserMatchesUser, *alertsCollection.DeleteRule)
// alerts_history collection
alertsHistoryCollection, err := hub.FindCollectionByNameOrId("alerts_history")
require.NoError(t, err, "Failed to find alerts_history collection")
assert.Equal(t, isUserMatchesUser, *alertsHistoryCollection.ListRule)
assert.Nil(t, alertsHistoryCollection.ViewRule)
assert.Nil(t, alertsHistoryCollection.CreateRule)
assert.Nil(t, alertsHistoryCollection.UpdateRule)
assert.Equal(t, isUserMatchesUser, *alertsHistoryCollection.DeleteRule)
// containers collection
containersCollection, err := hub.FindCollectionByNameOrId("containers")
require.NoError(t, err, "Failed to find containers collection")
assert.Equal(t, isUserInSystemUsers, *containersCollection.ListRule)
assert.Nil(t, containersCollection.ViewRule)
assert.Nil(t, containersCollection.CreateRule)
assert.Nil(t, containersCollection.UpdateRule)
assert.Nil(t, containersCollection.DeleteRule)
// container_stats collection
containerStatsCollection, err := hub.FindCollectionByNameOrId("container_stats")
require.NoError(t, err, "Failed to find container_stats collection")
assert.Equal(t, isUserInSystemUsers, *containerStatsCollection.ListRule)
assert.Nil(t, containerStatsCollection.ViewRule)
assert.Nil(t, containerStatsCollection.CreateRule)
assert.Nil(t, containerStatsCollection.UpdateRule)
assert.Nil(t, containerStatsCollection.DeleteRule)
// fingerprints collection
fingerprintsCollection, err := hub.FindCollectionByNameOrId("fingerprints")
require.NoError(t, err, "Failed to find fingerprints collection")
assert.Equal(t, isUserInSystemUsers, *fingerprintsCollection.ListRule)
assert.Equal(t, isUserInSystemUsers, *fingerprintsCollection.ViewRule)
assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.CreateRule)
assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.UpdateRule)
assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.DeleteRule)
// quiet_hours collection
quietHoursCollection, err := hub.FindCollectionByNameOrId("quiet_hours")
require.NoError(t, err, "Failed to find quiet_hours collection")
assert.Equal(t, isUserMatchesUser, *quietHoursCollection.ListRule)
assert.Equal(t, isUserMatchesUser, *quietHoursCollection.ViewRule)
assert.Equal(t, isUserMatchesUser, *quietHoursCollection.CreateRule)
assert.Equal(t, isUserMatchesUser, *quietHoursCollection.UpdateRule)
assert.Equal(t, isUserMatchesUser, *quietHoursCollection.DeleteRule)
// smart_devices collection
smartDevicesCollection, err := hub.FindCollectionByNameOrId("smart_devices")
require.NoError(t, err, "Failed to find smart_devices collection")
assert.Equal(t, isUserInSystemUsers, *smartDevicesCollection.ListRule)
assert.Equal(t, isUserInSystemUsers, *smartDevicesCollection.ViewRule)
assert.Nil(t, smartDevicesCollection.CreateRule)
assert.Nil(t, smartDevicesCollection.UpdateRule)
assert.Equal(t, isUserInSystemUsersNotReadonly, *smartDevicesCollection.DeleteRule)
// system_details collection
systemDetailsCollection, err := hub.FindCollectionByNameOrId("system_details")
require.NoError(t, err, "Failed to find system_details collection")
assert.Equal(t, isUserInSystemUsers, *systemDetailsCollection.ListRule)
assert.Equal(t, isUserInSystemUsers, *systemDetailsCollection.ViewRule)
assert.Nil(t, systemDetailsCollection.CreateRule)
assert.Nil(t, systemDetailsCollection.UpdateRule)
assert.Nil(t, systemDetailsCollection.DeleteRule)
// system_stats collection
systemStatsCollection, err := hub.FindCollectionByNameOrId("system_stats")
require.NoError(t, err, "Failed to find system_stats collection")
assert.Equal(t, isUserInSystemUsers, *systemStatsCollection.ListRule)
assert.Nil(t, systemStatsCollection.ViewRule)
assert.Nil(t, systemStatsCollection.CreateRule)
assert.Nil(t, systemStatsCollection.UpdateRule)
assert.Nil(t, systemStatsCollection.DeleteRule)
// systemd_services collection
systemdServicesCollection, err := hub.FindCollectionByNameOrId("systemd_services")
require.NoError(t, err, "Failed to find systemd_services collection")
assert.Equal(t, isUserInSystemUsers, *systemdServicesCollection.ListRule)
assert.Nil(t, systemdServicesCollection.ViewRule)
assert.Nil(t, systemdServicesCollection.CreateRule)
assert.Nil(t, systemdServicesCollection.UpdateRule)
assert.Nil(t, systemdServicesCollection.DeleteRule)
// systems collection
systemsCollection, err := hub.FindCollectionByNameOrId("systems")
require.NoError(t, err, "Failed to find systems collection")
assert.Equal(t, isUserInUsers, *systemsCollection.ListRule)
assert.Equal(t, isUserInUsers, *systemsCollection.ViewRule)
assert.Equal(t, isUserInUsersNotReadonly, *systemsCollection.CreateRule)
assert.Equal(t, isUserInUsersNotReadonly, *systemsCollection.UpdateRule)
assert.Equal(t, isUserInUsersNotReadonly, *systemsCollection.DeleteRule)
// universal_tokens collection
universalTokensCollection, err := hub.FindCollectionByNameOrId("universal_tokens")
require.NoError(t, err, "Failed to find universal_tokens collection")
assert.Nil(t, universalTokensCollection.ListRule)
assert.Nil(t, universalTokensCollection.ViewRule)
assert.Nil(t, universalTokensCollection.CreateRule)
assert.Nil(t, universalTokensCollection.UpdateRule)
assert.Nil(t, universalTokensCollection.DeleteRule)
// user_settings collection
userSettingsCollection, err := hub.FindCollectionByNameOrId("user_settings")
require.NoError(t, err, "Failed to find user_settings collection")
assert.Equal(t, isUserMatchesUser, *userSettingsCollection.ListRule)
assert.Nil(t, userSettingsCollection.ViewRule)
assert.Equal(t, isUserMatchesUser, *userSettingsCollection.CreateRule)
assert.Equal(t, isUserMatchesUser, *userSettingsCollection.UpdateRule)
assert.Nil(t, userSettingsCollection.DeleteRule)
}
func TestCollectionRulesShareAllSystems(t *testing.T) {
t.Setenv("SHARE_ALL_SYSTEMS", "true")
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
const isUser = `@request.auth.id != ""`
const isUserNotReadonly = `@request.auth.id != "" && @request.auth.role != "readonly"`
const isUserMatchesUser = `@request.auth.id != "" && user = @request.auth.id`
// alerts collection
alertsCollection, err := hub.FindCollectionByNameOrId("alerts")
require.NoError(t, err, "Failed to find alerts collection")
assert.Equal(t, isUserMatchesUser, *alertsCollection.ListRule)
assert.Nil(t, alertsCollection.ViewRule)
assert.Equal(t, isUserMatchesUser, *alertsCollection.CreateRule)
assert.Equal(t, isUserMatchesUser, *alertsCollection.UpdateRule)
assert.Equal(t, isUserMatchesUser, *alertsCollection.DeleteRule)
// alerts_history collection
alertsHistoryCollection, err := hub.FindCollectionByNameOrId("alerts_history")
require.NoError(t, err, "Failed to find alerts_history collection")
assert.Equal(t, isUserMatchesUser, *alertsHistoryCollection.ListRule)
assert.Nil(t, alertsHistoryCollection.ViewRule)
assert.Nil(t, alertsHistoryCollection.CreateRule)
assert.Nil(t, alertsHistoryCollection.UpdateRule)
assert.Equal(t, isUserMatchesUser, *alertsHistoryCollection.DeleteRule)
// containers collection
containersCollection, err := hub.FindCollectionByNameOrId("containers")
require.NoError(t, err, "Failed to find containers collection")
assert.Equal(t, isUser, *containersCollection.ListRule)
assert.Nil(t, containersCollection.ViewRule)
assert.Nil(t, containersCollection.CreateRule)
assert.Nil(t, containersCollection.UpdateRule)
assert.Nil(t, containersCollection.DeleteRule)
// container_stats collection
containerStatsCollection, err := hub.FindCollectionByNameOrId("container_stats")
require.NoError(t, err, "Failed to find container_stats collection")
assert.Equal(t, isUser, *containerStatsCollection.ListRule)
assert.Nil(t, containerStatsCollection.ViewRule)
assert.Nil(t, containerStatsCollection.CreateRule)
assert.Nil(t, containerStatsCollection.UpdateRule)
assert.Nil(t, containerStatsCollection.DeleteRule)
// fingerprints collection
fingerprintsCollection, err := hub.FindCollectionByNameOrId("fingerprints")
require.NoError(t, err, "Failed to find fingerprints collection")
assert.Equal(t, isUser, *fingerprintsCollection.ListRule)
assert.Equal(t, isUser, *fingerprintsCollection.ViewRule)
assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.CreateRule)
assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.UpdateRule)
assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.DeleteRule)
// quiet_hours collection
quietHoursCollection, err := hub.FindCollectionByNameOrId("quiet_hours")
require.NoError(t, err, "Failed to find quiet_hours collection")
assert.Equal(t, isUserMatchesUser, *quietHoursCollection.ListRule)
assert.Equal(t, isUserMatchesUser, *quietHoursCollection.ViewRule)
assert.Equal(t, isUserMatchesUser, *quietHoursCollection.CreateRule)
assert.Equal(t, isUserMatchesUser, *quietHoursCollection.UpdateRule)
assert.Equal(t, isUserMatchesUser, *quietHoursCollection.DeleteRule)
// smart_devices collection
smartDevicesCollection, err := hub.FindCollectionByNameOrId("smart_devices")
require.NoError(t, err, "Failed to find smart_devices collection")
assert.Equal(t, isUser, *smartDevicesCollection.ListRule)
assert.Equal(t, isUser, *smartDevicesCollection.ViewRule)
assert.Nil(t, smartDevicesCollection.CreateRule)
assert.Nil(t, smartDevicesCollection.UpdateRule)
assert.Equal(t, isUserNotReadonly, *smartDevicesCollection.DeleteRule)
// system_details collection
systemDetailsCollection, err := hub.FindCollectionByNameOrId("system_details")
require.NoError(t, err, "Failed to find system_details collection")
assert.Equal(t, isUser, *systemDetailsCollection.ListRule)
assert.Equal(t, isUser, *systemDetailsCollection.ViewRule)
assert.Nil(t, systemDetailsCollection.CreateRule)
assert.Nil(t, systemDetailsCollection.UpdateRule)
assert.Nil(t, systemDetailsCollection.DeleteRule)
// system_stats collection
systemStatsCollection, err := hub.FindCollectionByNameOrId("system_stats")
require.NoError(t, err, "Failed to find system_stats collection")
assert.Equal(t, isUser, *systemStatsCollection.ListRule)
assert.Nil(t, systemStatsCollection.ViewRule)
assert.Nil(t, systemStatsCollection.CreateRule)
assert.Nil(t, systemStatsCollection.UpdateRule)
assert.Nil(t, systemStatsCollection.DeleteRule)
// systemd_services collection
systemdServicesCollection, err := hub.FindCollectionByNameOrId("systemd_services")
require.NoError(t, err, "Failed to find systemd_services collection")
assert.Equal(t, isUser, *systemdServicesCollection.ListRule)
assert.Nil(t, systemdServicesCollection.ViewRule)
assert.Nil(t, systemdServicesCollection.CreateRule)
assert.Nil(t, systemdServicesCollection.UpdateRule)
assert.Nil(t, systemdServicesCollection.DeleteRule)
// systems collection
systemsCollection, err := hub.FindCollectionByNameOrId("systems")
require.NoError(t, err, "Failed to find systems collection")
assert.Equal(t, isUser, *systemsCollection.ListRule)
assert.Equal(t, isUser, *systemsCollection.ViewRule)
assert.Equal(t, isUserNotReadonly, *systemsCollection.CreateRule)
assert.Equal(t, isUserNotReadonly, *systemsCollection.UpdateRule)
assert.Equal(t, isUserNotReadonly, *systemsCollection.DeleteRule)
// universal_tokens collection
universalTokensCollection, err := hub.FindCollectionByNameOrId("universal_tokens")
require.NoError(t, err, "Failed to find universal_tokens collection")
assert.Nil(t, universalTokensCollection.ListRule)
assert.Nil(t, universalTokensCollection.ViewRule)
assert.Nil(t, universalTokensCollection.CreateRule)
assert.Nil(t, universalTokensCollection.UpdateRule)
assert.Nil(t, universalTokensCollection.DeleteRule)
// user_settings collection
userSettingsCollection, err := hub.FindCollectionByNameOrId("user_settings")
require.NoError(t, err, "Failed to find user_settings collection")
assert.Equal(t, isUserMatchesUser, *userSettingsCollection.ListRule)
assert.Nil(t, userSettingsCollection.ViewRule)
assert.Equal(t, isUserMatchesUser, *userSettingsCollection.CreateRule)
assert.Equal(t, isUserMatchesUser, *userSettingsCollection.UpdateRule)
assert.Nil(t, userSettingsCollection.DeleteRule)
}
func TestDisablePasswordAuth(t *testing.T) {
t.Setenv("DISABLE_PASSWORD_AUTH", "true")
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
usersCollection, err := hub.FindCollectionByNameOrId("users")
assert.NoError(t, err)
assert.False(t, usersCollection.PasswordAuth.Enabled)
}
func TestUserCreation(t *testing.T) {
t.Setenv("USER_CREATION", "true")
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
usersCollection, err := hub.FindCollectionByNameOrId("users")
assert.NoError(t, err)
assert.Equal(t, "@request.context = 'oauth2'", *usersCollection.CreateRule)
}
func TestMFAOtp(t *testing.T) {
t.Setenv("MFA_OTP", "true")
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
usersCollection, err := hub.FindCollectionByNameOrId("users")
assert.NoError(t, err)
assert.True(t, usersCollection.OTP.Enabled)
assert.True(t, usersCollection.MFA.Enabled)
superusersCollection, err := hub.FindCollectionByNameOrId(core.CollectionNameSuperusers)
assert.NoError(t, err)
assert.True(t, superusersCollection.OTP.Enabled)
assert.True(t, superusersCollection.MFA.Enabled)
}
func TestApiCollectionsAuthRules(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
hub.StartHub()
user1, _ := beszelTests.CreateUser(hub, "user1@example.com", "password")
user1Token, _ := user1.NewAuthToken()
user2, _ := beszelTests.CreateUser(hub, "user2@example.com", "password")
// user2Token, _ := user2.NewAuthToken()
userReadonly, _ := beszelTests.CreateUserWithRole(hub, "userreadonly@example.com", "password", "readonly")
userReadonlyToken, _ := userReadonly.NewAuthToken()
userOneSystem, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "system1",
"users": []string{user1.Id},
"host": "127.0.0.1",
})
sharedSystem, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "system2",
"users": []string{user1.Id, user2.Id},
"host": "127.0.0.2",
})
userTwoSystem, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "system3",
"users": []string{user2.Id},
"host": "127.0.0.2",
})
userRecords, _ := hub.CountRecords("users")
assert.EqualValues(t, 3, userRecords, "all users should be created")
systemRecords, _ := hub.CountRecords("systems")
assert.EqualValues(t, 3, systemRecords, "all systems should be created")
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
{
Name: "Unauthorized user cannot list systems",
Method: http.MethodGet,
URL: "/api/collections/systems/records",
ExpectedStatus: 200, // https://github.com/pocketbase/pocketbase/discussions/1570
TestAppFactory: testAppFactory,
ExpectedContent: []string{`"items":[]`, `"totalItems":0`},
NotExpectedContent: []string{userOneSystem.Id, sharedSystem.Id, userTwoSystem.Id},
},
{
Name: "Unauthorized user cannot delete a system",
Method: http.MethodDelete,
URL: fmt.Sprintf("/api/collections/systems/records/%s", userOneSystem.Id),
ExpectedStatus: 404,
TestAppFactory: testAppFactory,
ExpectedContent: []string{"resource wasn't found"},
NotExpectedContent: []string{userOneSystem.Id},
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
systemsCount, _ := app.CountRecords("systems")
assert.EqualValues(t, 3, systemsCount, "should have 3 systems before deletion")
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
systemsCount, _ := app.CountRecords("systems")
assert.EqualValues(t, 3, systemsCount, "should still have 3 systems after failed deletion")
},
},
{
Name: "User 1 can list their own systems",
Method: http.MethodGet,
URL: "/api/collections/systems/records",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{userOneSystem.Id, sharedSystem.Id},
NotExpectedContent: []string{userTwoSystem.Id},
TestAppFactory: testAppFactory,
},
{
Name: "User 1 cannot list user 2's system",
Method: http.MethodGet,
URL: "/api/collections/systems/records",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{userOneSystem.Id, sharedSystem.Id},
NotExpectedContent: []string{userTwoSystem.Id},
TestAppFactory: testAppFactory,
},
{
Name: "User 1 can see user 2's system if SHARE_ALL_SYSTEMS is enabled",
Method: http.MethodGet,
URL: "/api/collections/systems/records",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{userOneSystem.Id, sharedSystem.Id, userTwoSystem.Id},
TestAppFactory: testAppFactory,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
t.Setenv("SHARE_ALL_SYSTEMS", "true")
hub.SetCollectionAuthSettings()
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
t.Setenv("SHARE_ALL_SYSTEMS", "")
hub.SetCollectionAuthSettings()
},
},
{
Name: "User 1 can delete their own system",
Method: http.MethodDelete,
URL: fmt.Sprintf("/api/collections/systems/records/%s", userOneSystem.Id),
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 204,
TestAppFactory: testAppFactory,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
systemsCount, _ := app.CountRecords("systems")
assert.EqualValues(t, 3, systemsCount, "should have 3 systems before deletion")
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
systemsCount, _ := app.CountRecords("systems")
assert.EqualValues(t, 2, systemsCount, "should have 2 systems after deletion")
},
},
{
Name: "User 1 cannot delete user 2's system",
Method: http.MethodDelete,
URL: fmt.Sprintf("/api/collections/systems/records/%s", userTwoSystem.Id),
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 404,
TestAppFactory: testAppFactory,
ExpectedContent: []string{"resource wasn't found"},
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
systemsCount, _ := app.CountRecords("systems")
assert.EqualValues(t, 2, systemsCount)
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
systemsCount, _ := app.CountRecords("systems")
assert.EqualValues(t, 2, systemsCount)
},
},
{
Name: "Readonly cannot delete a system even if SHARE_ALL_SYSTEMS is enabled",
Method: http.MethodDelete,
URL: fmt.Sprintf("/api/collections/systems/records/%s", sharedSystem.Id),
Headers: map[string]string{
"Authorization": userReadonlyToken,
},
ExpectedStatus: 404,
ExpectedContent: []string{"resource wasn't found"},
TestAppFactory: testAppFactory,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
t.Setenv("SHARE_ALL_SYSTEMS", "true")
hub.SetCollectionAuthSettings()
systemsCount, _ := app.CountRecords("systems")
assert.EqualValues(t, 2, systemsCount)
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
t.Setenv("SHARE_ALL_SYSTEMS", "")
hub.SetCollectionAuthSettings()
systemsCount, _ := app.CountRecords("systems")
assert.EqualValues(t, 2, systemsCount)
},
},
{
Name: "User 1 can delete user 2's system if SHARE_ALL_SYSTEMS is enabled",
Method: http.MethodDelete,
URL: fmt.Sprintf("/api/collections/systems/records/%s", userTwoSystem.Id),
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 204,
TestAppFactory: testAppFactory,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
t.Setenv("SHARE_ALL_SYSTEMS", "true")
hub.SetCollectionAuthSettings()
systemsCount, _ := app.CountRecords("systems")
assert.EqualValues(t, 2, systemsCount)
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
t.Setenv("SHARE_ALL_SYSTEMS", "")
hub.SetCollectionAuthSettings()
systemsCount, _ := app.CountRecords("systems")
assert.EqualValues(t, 1, systemsCount)
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}

View File

@@ -1,35 +1,39 @@
// Package expirymap provides a thread-safe map with expiring entries.
// It supports TTL-based expiration with both lazy cleanup on access
// and periodic background cleanup.
package expirymap
import (
"reflect"
"sync"
"time"
"github.com/pocketbase/pocketbase/tools/store"
)
type val[T any] struct {
type val[T comparable] struct {
value T
expires time.Time
}
type ExpiryMap[T any] struct {
store *store.Store[string, *val[T]]
cleanupInterval time.Duration
type ExpiryMap[T comparable] struct {
store *store.Store[string, val[T]]
stopChan chan struct{}
stopOnce sync.Once
}
// New creates a new expiry map with custom cleanup interval
func New[T any](cleanupInterval time.Duration) *ExpiryMap[T] {
func New[T comparable](cleanupInterval time.Duration) *ExpiryMap[T] {
m := &ExpiryMap[T]{
store: store.New(map[string]*val[T]{}),
cleanupInterval: cleanupInterval,
store: store.New(map[string]val[T]{}),
stopChan: make(chan struct{}),
}
m.startCleaner()
go m.startCleaner(cleanupInterval)
return m
}
// Set stores a value with the given TTL
func (m *ExpiryMap[T]) Set(key string, value T, ttl time.Duration) {
m.store.Set(key, &val[T]{
m.store.Set(key, val[T]{
value: value,
expires: time.Now().Add(ttl),
})
@@ -55,7 +59,7 @@ func (m *ExpiryMap[T]) GetOk(key string) (T, bool) {
// GetByValue retrieves a value by value
func (m *ExpiryMap[T]) GetByValue(val T) (key string, value T, ok bool) {
for key, v := range m.store.GetAll() {
if reflect.DeepEqual(v.value, val) {
if v.value == val {
// check if expired
if v.expires.Before(time.Now()) {
m.store.Remove(key)
@@ -75,7 +79,7 @@ func (m *ExpiryMap[T]) Remove(key string) {
// RemovebyValue removes a value by value
func (m *ExpiryMap[T]) RemovebyValue(value T) (T, bool) {
for key, val := range m.store.GetAll() {
if reflect.DeepEqual(val.value, value) {
if val.value == value {
m.store.Remove(key)
return val.value, true
}
@@ -84,13 +88,23 @@ func (m *ExpiryMap[T]) RemovebyValue(value T) (T, bool) {
}
// startCleaner runs the background cleanup process
func (m *ExpiryMap[T]) startCleaner() {
go func() {
tick := time.Tick(m.cleanupInterval)
for range tick {
func (m *ExpiryMap[T]) startCleaner(interval time.Duration) {
tick := time.Tick(interval)
for {
select {
case <-tick:
m.cleanup()
case <-m.stopChan:
return
}
}()
}
}
// StopCleaner stops the background cleanup process
func (m *ExpiryMap[T]) StopCleaner() {
m.stopOnce.Do(func() {
close(m.stopChan)
})
}
// cleanup removes all expired entries
@@ -102,3 +116,12 @@ func (m *ExpiryMap[T]) cleanup() {
}
}
}
// UpdateExpiration updates the expiration time of a key
func (m *ExpiryMap[T]) UpdateExpiration(key string, ttl time.Duration) {
value, ok := m.store.GetOk(key)
if ok {
value.expires = time.Now().Add(ttl)
m.store.Set(key, value)
}
}

View File

@@ -4,6 +4,7 @@ package expirymap
import (
"testing"
"testing/synctest"
"time"
"github.com/stretchr/testify/assert"
@@ -177,6 +178,33 @@ func TestExpiryMap_GenericTypes(t *testing.T) {
})
}
func TestExpiryMap_UpdateExpiration(t *testing.T) {
em := New[string](time.Hour)
// Set a value with short TTL
em.Set("key1", "value1", time.Millisecond*50)
// Verify it exists
assert.True(t, em.Has("key1"))
// Update expiration to a longer TTL
em.UpdateExpiration("key1", time.Hour)
// Wait for the original TTL to pass
time.Sleep(time.Millisecond * 100)
// Should still exist because expiration was updated
assert.True(t, em.Has("key1"))
value, ok := em.GetOk("key1")
assert.True(t, ok)
assert.Equal(t, "value1", value)
// Try updating non-existent key (should not panic)
assert.NotPanics(t, func() {
em.UpdateExpiration("nonexistent", time.Hour)
})
}
func TestExpiryMap_ZeroValues(t *testing.T) {
em := New[string](time.Hour)
@@ -473,3 +501,52 @@ func TestExpiryMap_ValueOperations_Integration(t *testing.T) {
assert.Equal(t, "unique", value)
assert.Equal(t, "key2", key)
}
func TestExpiryMap_Cleaner(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
em := New[string](time.Second)
defer em.StopCleaner()
em.Set("test", "value", 500*time.Millisecond)
// Wait 600ms, value is expired but cleaner hasn't run yet (interval is 1s)
time.Sleep(600 * time.Millisecond)
synctest.Wait()
// Map should still hold the value in its internal store before lazy access or cleaner
assert.Equal(t, 1, len(em.store.GetAll()), "store should still have 1 item before cleaner runs")
// Wait another 500ms so cleaner (1s interval) runs
time.Sleep(500 * time.Millisecond)
synctest.Wait() // Wait for background goroutine to process the tick
assert.Equal(t, 0, len(em.store.GetAll()), "store should be empty after cleaner runs")
})
}
func TestExpiryMap_StopCleaner(t *testing.T) {
em := New[string](time.Hour)
// Initially, stopChan is open, reading would block
select {
case <-em.stopChan:
t.Fatal("stopChan should be open initially")
default:
// success
}
em.StopCleaner()
// After StopCleaner, stopChan is closed, reading returns immediately
select {
case <-em.stopChan:
// success
default:
t.Fatal("stopChan was not closed by StopCleaner")
}
// Calling StopCleaner again should NOT panic thanks to sync.Once
assert.NotPanics(t, func() {
em.StopCleaner()
})
}

View File

@@ -4,16 +4,14 @@ package hub
import (
"crypto/ed25519"
"encoding/pem"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path"
"regexp"
"strings"
"time"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/alerts"
"github.com/henrygd/beszel/internal/hub/config"
"github.com/henrygd/beszel/internal/hub/heartbeat"
@@ -21,14 +19,12 @@ import (
"github.com/henrygd/beszel/internal/records"
"github.com/henrygd/beszel/internal/users"
"github.com/google/uuid"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"golang.org/x/crypto/ssh"
)
// Hub is the application. It embeds the PocketBase app and keeps references to subcomponents.
type Hub struct {
core.App
*alerts.AlertManager
@@ -46,18 +42,16 @@ var containerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
// NewHub creates a new Hub instance with default configuration
func NewHub(app core.App) *Hub {
hub := &Hub{}
hub.App = app
hub := &Hub{App: app}
hub.AlertManager = alerts.NewAlertManager(hub)
hub.um = users.NewUserManager(hub)
hub.rm = records.NewRecordManager(hub)
hub.sm = systems.NewSystemManager(hub)
hub.appURL, _ = GetEnv("APP_URL")
hub.hb = heartbeat.New(app, GetEnv)
if hub.hb != nil {
hub.hbStop = make(chan struct{})
}
_ = onAfterBootstrapAndMigrations(app, hub.initialize)
return hub
}
@@ -70,12 +64,28 @@ func GetEnv(key string) (value string, exists bool) {
return os.LookupEnv(key)
}
func (h *Hub) StartHub() error {
h.App.OnServe().BindFunc(func(e *core.ServeEvent) error {
// initialize settings / collections
if err := h.initialize(e); err != nil {
// onAfterBootstrapAndMigrations ensures the provided function runs after the database is set up and migrations are applied.
// This is a workaround for behavior in PocketBase where onBootstrap runs before migrations, forcing use of onServe for this purpose.
// However, PB's tests.TestApp is already bootstrapped, generally doesn't serve, but does handle migrations.
// So this ensures that the provided function runs at the right time either way, after DB is ready and migrations are done.
func onAfterBootstrapAndMigrations(app core.App, fn func(app core.App) error) error {
// pb tests.TestApp is already bootstrapped and doesn't serve
if app.IsBootstrapped() {
return fn(app)
}
// Must use OnServe because OnBootstrap appears to run before migrations, even if calling e.Next() before anything else
app.OnServe().BindFunc(func(e *core.ServeEvent) error {
if err := fn(e.App); err != nil {
return err
}
return e.Next()
})
return nil
}
// StartHub sets up event handlers and starts the PocketBase server
func (h *Hub) StartHub() error {
h.App.OnServe().BindFunc(func(e *core.ServeEvent) error {
// sync systems with config
if err := config.SyncSystems(e); err != nil {
return err
@@ -110,132 +120,29 @@ func (h *Hub) StartHub() error {
h.App.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole)
h.App.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings)
if pb, ok := h.App.(*pocketbase.PocketBase); ok {
// log.Println("Starting pocketbase")
err := pb.Start()
if err != nil {
return err
}
pb, ok := h.App.(*pocketbase.PocketBase)
if !ok {
return errors.New("not a pocketbase app")
}
return nil
return pb.Start()
}
// initialize sets up initial configuration (collections, settings, etc.)
func (h *Hub) initialize(e *core.ServeEvent) error {
func (h *Hub) initialize(app core.App) error {
// set general settings
settings := e.App.Settings()
// batch requests (for global alerts)
settings := app.Settings()
// batch requests (for alerts)
settings.Batch.Enabled = true
// set URL if BASE_URL env is set
if h.appURL != "" {
settings.Meta.AppURL = h.appURL
// set URL if APP_URL env is set
if appURL, isSet := GetEnv("APP_URL"); isSet {
h.appURL = appURL
settings.Meta.AppURL = appURL
}
if err := e.App.Save(settings); err != nil {
if err := app.Save(settings); err != nil {
return err
}
// set auth settings
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 {
return err
}
// disable email auth if DISABLE_PASSWORD_AUTH env var is set
disablePasswordAuth, _ := GetEnv("DISABLE_PASSWORD_AUTH")
usersCollection.PasswordAuth.Enabled = disablePasswordAuth != "true"
usersCollection.PasswordAuth.IdentityFields = []string{"email"}
// allow oauth user creation if USER_CREATION is set
if userCreation, _ := GetEnv("USER_CREATION"); userCreation == "true" {
cr := "@request.context = 'oauth2'"
usersCollection.CreateRule = &cr
} else {
usersCollection.CreateRule = 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
}
shareAllSystems, _ := GetEnv("SHARE_ALL_SYSTEMS")
// allow all users to access systems if SHARE_ALL_SYSTEMS is set
systemsCollection, err := app.FindCollectionByNameOrId("systems")
if err != nil {
return err
}
var systemsReadRule string
if shareAllSystems == "true" {
systemsReadRule = "@request.auth.id != \"\""
} else {
systemsReadRule = "@request.auth.id != \"\" && users.id ?= @request.auth.id"
}
updateDeleteRule := systemsReadRule + " && @request.auth.role != \"readonly\""
systemsCollection.ListRule = &systemsReadRule
systemsCollection.ViewRule = &systemsReadRule
systemsCollection.UpdateRule = &updateDeleteRule
systemsCollection.DeleteRule = &updateDeleteRule
if err := app.Save(systemsCollection); err != nil {
return err
}
// allow all users to access all containers if SHARE_ALL_SYSTEMS is set
containersCollection, err := app.FindCollectionByNameOrId("containers")
if err != nil {
return err
}
containersListRule := strings.Replace(systemsReadRule, "users.id", "system.users.id", 1)
containersCollection.ListRule = &containersListRule
if err := app.Save(containersCollection); err != nil {
return err
}
// allow all users to access system-related collections if SHARE_ALL_SYSTEMS is set
// these collections all have a "system" relation field
systemRelatedCollections := []string{"system_details", "smart_devices", "systemd_services"}
for _, collectionName := range systemRelatedCollections {
collection, err := app.FindCollectionByNameOrId(collectionName)
if err != nil {
return err
}
collection.ListRule = &containersListRule
// set viewRule for collections that need it (system_details, smart_devices)
if collection.ViewRule != nil {
collection.ViewRule = &containersListRule
}
// set deleteRule for smart_devices (allows user to dismiss disk warnings)
if collectionName == "smart_devices" {
deleteRule := containersListRule + " && @request.auth.role != \"readonly\""
collection.DeleteRule = &deleteRule
}
if err := app.Save(collection); err != nil {
return err
}
}
return nil
return setCollectionAuthSettings(app)
}
// registerCronJobs sets up scheduled tasks
@@ -247,296 +154,7 @@ func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
return nil
}
// custom middlewares
func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
// authorizes request with user matching the provided email
authorizeRequestWithEmail := func(e *core.RequestEvent, email string) (err error) {
if e.Auth != nil || email == "" {
return e.Next()
}
isAuthRefresh := e.Request.URL.Path == "/api/collections/users/auth-refresh" && e.Request.Method == http.MethodPost
e.Auth, err = e.App.FindFirstRecordByData("users", "email", email)
if err != nil || !isAuthRefresh {
return e.Next()
}
// auth refresh endpoint, make sure token is set in header
token, _ := e.Auth.NewAuthToken()
e.Request.Header.Set("Authorization", token)
return e.Next()
}
// authenticate with trusted header
if autoLogin, _ := GetEnv("AUTO_LOGIN"); autoLogin != "" {
se.Router.BindFunc(func(e *core.RequestEvent) error {
return authorizeRequestWithEmail(e, autoLogin)
})
}
// authenticate with trusted header
if trustedHeader, _ := GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" {
se.Router.BindFunc(func(e *core.RequestEvent) error {
return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader))
})
}
}
// custom api routes
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
// auth protected routes
apiAuth := se.Router.Group("/api/beszel")
apiAuth.Bind(apis.RequireAuth())
// auth optional routes
apiNoAuth := se.Router.Group("/api/beszel")
// create first user endpoint only needed if no users exist
if totalUsers, _ := se.App.CountRecords("users"); totalUsers == 0 {
apiNoAuth.POST("/create-user", h.um.CreateFirstUser)
}
// check if first time setup on login page
apiNoAuth.GET("/first-run", func(e *core.RequestEvent) error {
total, err := e.App.CountRecords("users")
return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0})
})
// get public key and version
apiAuth.GET("/getkey", func(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version})
})
// send test notification
apiAuth.POST("/test-notification", h.SendTestNotification)
// heartbeat status and test
apiAuth.GET("/heartbeat-status", h.getHeartbeatStatus)
apiAuth.POST("/test-heartbeat", h.testHeartbeat)
// get config.yml content
apiAuth.GET("/config-yaml", config.GetYamlConfig)
// handle agent websocket connection
apiNoAuth.GET("/agent-connect", h.handleAgentConnect)
// get or create universal tokens
apiAuth.GET("/universal-token", h.getUniversalToken)
// update / delete user alerts
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
// refresh SMART devices for a system
apiAuth.POST("/smart/refresh", h.refreshSmartData)
// get systemd service details
apiAuth.GET("/systemd/info", h.getSystemdInfo)
// /containers routes
if enabled, _ := GetEnv("CONTAINER_DETAILS"); enabled != "false" {
// get container logs
apiAuth.GET("/containers/logs", h.getContainerLogs)
// get container info
apiAuth.GET("/containers/info", h.getContainerInfo)
}
return nil
}
// Handler for universal token API endpoint (create, read, delete)
func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
tokenMap := universalTokenMap.GetMap()
userID := e.Auth.Id
query := e.Request.URL.Query()
token := query.Get("token")
enable := query.Get("enable")
permanent := query.Get("permanent")
// helper for deleting any existing permanent token record for this user
deletePermanent := func() error {
rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID})
if err != nil {
return nil // no record
}
return h.Delete(rec)
}
// helper for upserting a permanent token record for this user
upsertPermanent := func(token string) error {
rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID})
if err == nil {
rec.Set("token", token)
return h.Save(rec)
}
col, err := h.FindCachedCollectionByNameOrId("universal_tokens")
if err != nil {
return err
}
newRec := core.NewRecord(col)
newRec.Set("user", userID)
newRec.Set("token", token)
return h.Save(newRec)
}
// Disable universal tokens (both ephemeral and permanent)
if enable == "0" {
tokenMap.RemovebyValue(userID)
_ = deletePermanent()
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": false, "permanent": false})
}
// Enable universal token (ephemeral or permanent)
if enable == "1" {
if token == "" {
token = uuid.New().String()
}
if permanent == "1" {
// make token permanent (persist across restarts)
tokenMap.RemovebyValue(userID)
if err := upsertPermanent(token); err != nil {
return err
}
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": true})
}
// default: ephemeral mode (1 hour)
_ = deletePermanent()
tokenMap.Set(token, userID, time.Hour)
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": false})
}
// Read current state
// Prefer permanent token if it exists.
if rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID}); err == nil {
dbToken := rec.GetString("token")
// If no token was provided, or the caller is asking about their permanent token, return it.
if token == "" || token == dbToken {
return e.JSON(http.StatusOK, map[string]any{"token": dbToken, "active": true, "permanent": true})
}
// Token doesn't match their permanent token (avoid leaking other info)
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": false, "permanent": false})
}
// No permanent token; fall back to ephemeral token map.
if token == "" {
// return existing token if it exists
if token, _, ok := tokenMap.GetByValue(userID); ok {
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": false})
}
// if no token is provided, generate a new one
token = uuid.New().String()
}
// Token is considered active only if it belongs to the current user.
activeUser, ok := tokenMap.GetOk(token)
active := ok && activeUser == userID
response := map[string]any{"token": token, "active": active, "permanent": false}
return e.JSON(http.StatusOK, response)
}
// getHeartbeatStatus returns current heartbeat configuration and whether it's enabled
func (h *Hub) getHeartbeatStatus(e *core.RequestEvent) error {
if e.Auth.GetString("role") != "admin" {
return e.ForbiddenError("Requires admin role", nil)
}
if h.hb == nil {
return e.JSON(http.StatusOK, map[string]any{
"enabled": false,
"msg": "Set HEARTBEAT_URL to enable outbound heartbeat monitoring",
})
}
cfg := h.hb.GetConfig()
return e.JSON(http.StatusOK, map[string]any{
"enabled": true,
"url": cfg.URL,
"interval": cfg.Interval,
"method": cfg.Method,
})
}
// testHeartbeat triggers a single heartbeat ping and returns the result
func (h *Hub) testHeartbeat(e *core.RequestEvent) error {
if e.Auth.GetString("role") != "admin" {
return e.ForbiddenError("Requires admin role", nil)
}
if h.hb == nil {
return e.JSON(http.StatusOK, map[string]any{
"err": "Heartbeat not configured. Set HEARTBEAT_URL environment variable.",
})
}
if err := h.hb.Send(); err != nil {
return e.JSON(http.StatusOK, map[string]any{"err": err.Error()})
}
return e.JSON(http.StatusOK, map[string]any{"err": false})
}
// 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"})
}
if !containerIDPattern.MatchString(containerID) {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "invalid container parameter"})
}
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.StatusNotFound, 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")
}
// getSystemdInfo handles GET /api/beszel/systemd/info requests
func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
query := e.Request.URL.Query()
systemID := query.Get("system")
serviceName := query.Get("service")
if systemID == "" || serviceName == "" {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and service parameters are required"})
}
system, err := h.sm.GetSystem(systemID)
if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
}
details, err := system.FetchSystemdInfoFromAgent(serviceName)
if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
e.Response.Header().Set("Cache-Control", "public, max-age=60")
return e.JSON(http.StatusOK, map[string]any{"details": details})
}
// refreshSmartData handles POST /api/beszel/smart/refresh requests
// Fetches fresh SMART data from the agent and updates the collection
func (h *Hub) refreshSmartData(e *core.RequestEvent) error {
systemID := e.Request.URL.Query().Get("system")
if systemID == "" {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system parameter is required"})
}
system, err := h.sm.GetSystem(systemID)
if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
}
// Fetch and save SMART devices
if err := system.FetchAndSaveSmartDevices(); err != nil {
return e.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
}
// generates key pair if it doesn't exist and returns signer
// GetSSHKey generates key pair if it doesn't exist and returns signer
func (h *Hub) GetSSHKey(dataDir string) (ssh.Signer, error) {
if h.signer != nil {
return h.signer, nil

View File

@@ -3,36 +3,20 @@
package hub_test
import (
"bytes"
"crypto/ed25519"
"encoding/json"
"encoding/pem"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/henrygd/beszel/internal/migrations"
beszelTests "github.com/henrygd/beszel/internal/tests"
"github.com/pocketbase/pocketbase/core"
pbTests "github.com/pocketbase/pocketbase/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
)
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
func jsonReader(v any) io.Reader {
data, err := json.Marshal(v)
if err != nil {
panic(err)
}
return bytes.NewReader(data)
}
func TestMakeLink(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
@@ -265,699 +249,20 @@ func TestGetSSHKey(t *testing.T) {
})
}
func TestApiRoutesAuthentication(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
hub.StartHub()
// Create test user and get auth token
user, err := beszelTests.CreateUser(hub, "testuser@example.com", "password123")
require.NoError(t, err, "Failed to create test user")
adminUser, err := beszelTests.CreateRecord(hub, "users", map[string]any{
"email": "admin@example.com",
"password": "password123",
"role": "admin",
})
require.NoError(t, err, "Failed to create admin user")
adminUserToken, err := adminUser.NewAuthToken()
// superUser, err := beszelTests.CreateRecord(hub, core.CollectionNameSuperusers, map[string]any{
// "email": "superuser@example.com",
// "password": "password123",
// })
// require.NoError(t, err, "Failed to create superuser")
userToken, err := user.NewAuthToken()
require.NoError(t, err, "Failed to create auth token")
// Create test system for user-alerts endpoints
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"users": []string{user.Id},
"host": "127.0.0.1",
})
require.NoError(t, err, "Failed to create test system")
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
// Auth Protected Routes - Should require authentication
{
Name: "POST /test-notification - no auth should fail",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1",
}),
},
{
Name: "POST /test-notification - with auth should succeed",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": userToken,
},
Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1",
}),
ExpectedStatus: 200,
ExpectedContent: []string{"sending message"},
},
{
Name: "GET /config-yaml - no auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/config-yaml",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /config-yaml - with user auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/config-yaml",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 403,
ExpectedContent: []string{"Requires admin"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /config-yaml - with admin auth should succeed",
Method: http.MethodGet,
URL: "/api/beszel/config-yaml",
Headers: map[string]string{
"Authorization": adminUserToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"test-system"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /heartbeat-status - no auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/heartbeat-status",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /heartbeat-status - with user auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/heartbeat-status",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 403,
ExpectedContent: []string{"Requires admin role"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /heartbeat-status - with admin auth should succeed",
Method: http.MethodGet,
URL: "/api/beszel/heartbeat-status",
Headers: map[string]string{
"Authorization": adminUserToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{`"enabled":false`},
TestAppFactory: testAppFactory,
},
{
Name: "POST /test-heartbeat - with user auth should fail",
Method: http.MethodPost,
URL: "/api/beszel/test-heartbeat",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 403,
ExpectedContent: []string{"Requires admin role"},
TestAppFactory: testAppFactory,
},
{
Name: "POST /test-heartbeat - with admin auth should report disabled state",
Method: http.MethodPost,
URL: "/api/beszel/test-heartbeat",
Headers: map[string]string{
"Authorization": adminUserToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"Heartbeat not configured"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /universal-token - no auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/universal-token",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /universal-token - with auth should succeed",
Method: http.MethodGet,
URL: "/api/beszel/universal-token",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"active", "token", "permanent"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /universal-token - enable permanent should succeed",
Method: http.MethodGet,
URL: "/api/beszel/universal-token?enable=1&permanent=1&token=permanent-token-123",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"permanent\":true", "permanent-token-123"},
TestAppFactory: testAppFactory,
},
{
Name: "POST /user-alerts - no auth should fail",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 80,
"min": 10,
"systems": []string{system.Id},
}),
},
{
Name: "POST /user-alerts - with auth should succeed",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 80,
"min": 10,
"systems": []string{system.Id},
}),
},
{
Name: "DELETE /user-alerts - no auth should fail",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"systems": []string{system.Id},
}),
},
{
Name: "DELETE /user-alerts - with auth should succeed",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"systems": []string{system.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
// Create an alert to delete
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system.Id,
"user": user.Id,
"value": 80,
"min": 10,
})
},
},
{
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=0123456789ab",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 404,
ExpectedContent: []string{"system not found"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/logs - traversal container should fail validation",
Method: http.MethodGet,
URL: "/api/beszel/containers/logs?system=" + system.Id + "&container=..%2F..%2Fversion",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"invalid container parameter"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/info - traversal container should fail validation",
Method: http.MethodGet,
URL: "/api/beszel/containers/info?system=" + system.Id + "&container=../../version?x=",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"invalid container parameter"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/info - non-hex container should fail validation",
Method: http.MethodGet,
URL: "/api/beszel/containers/info?system=" + system.Id + "&container=container_name",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"invalid container parameter"},
TestAppFactory: testAppFactory,
},
// Auth Optional Routes - Should work without authentication
{
Name: "GET /getkey - no auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /getkey - with auth should also succeed",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"key\":", "\"v\":"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /first-run - no auth should succeed",
Method: http.MethodGet,
URL: "/api/beszel/first-run",
ExpectedStatus: 200,
ExpectedContent: []string{"\"firstRun\":false"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /first-run - with auth should also succeed",
Method: http.MethodGet,
URL: "/api/beszel/first-run",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"firstRun\":false"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /agent-connect - no auth should succeed (websocket upgrade fails but route is accessible)",
Method: http.MethodGet,
URL: "/api/beszel/agent-connect",
ExpectedStatus: 400,
ExpectedContent: []string{},
TestAppFactory: testAppFactory,
},
{
Name: "POST /test-notification - invalid auth token should fail",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1",
}),
Headers: map[string]string{
"Authorization": "invalid-token",
},
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "POST /user-alerts - invalid auth token should fail",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": "invalid-token",
},
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 80,
"min": 10,
"systems": []string{system.Id},
}),
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestFirstUserCreation(t *testing.T) {
t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
func TestAppUrl(t *testing.T) {
t.Run("no APP_URL does't change app url", func(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
hub.StartHub()
testAppFactoryExisting := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
{
Name: "POST /create-user - should be available when no users exist",
Method: http.MethodPost,
URL: "/api/beszel/create-user",
Body: jsonReader(map[string]any{
"email": "firstuser@example.com",
"password": "password123",
}),
ExpectedStatus: 200,
ExpectedContent: []string{"User created"},
TestAppFactory: testAppFactoryExisting,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
userCount, err := hub.CountRecords("users")
require.NoError(t, err)
require.Zero(t, userCount, "Should start with no users")
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
require.NoError(t, err)
require.EqualValues(t, 1, len(superusers), "Should start with one temporary superuser")
require.EqualValues(t, migrations.TempAdminEmail, superusers[0].GetString("email"), "Should have created one temporary superuser")
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
userCount, err := hub.CountRecords("users")
require.NoError(t, err)
require.EqualValues(t, 1, userCount, "Should have created one user")
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
require.NoError(t, err)
require.EqualValues(t, 1, len(superusers), "Should have created one superuser")
require.EqualValues(t, "firstuser@example.com", superusers[0].GetString("email"), "Should have created one superuser")
},
},
{
Name: "POST /create-user - should not be available when users exist",
Method: http.MethodPost,
URL: "/api/beszel/create-user",
Body: jsonReader(map[string]any{
"email": "firstuser@example.com",
"password": "password123",
}),
ExpectedStatus: 404,
ExpectedContent: []string{"wasn't found"},
TestAppFactory: testAppFactoryExisting,
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
settings := hub.Settings()
assert.Equal(t, "http://localhost:8090", settings.Meta.AppURL)
})
t.Run("CreateUserEndpoint not available when USER_EMAIL, USER_PASSWORD are set", func(t *testing.T) {
os.Setenv("BESZEL_HUB_USER_EMAIL", "me@example.com")
os.Setenv("BESZEL_HUB_USER_PASSWORD", "password123")
defer os.Unsetenv("BESZEL_HUB_USER_EMAIL")
defer os.Unsetenv("BESZEL_HUB_USER_PASSWORD")
t.Run("APP_URL changes app url", func(t *testing.T) {
t.Setenv("APP_URL", "http://example.com/app")
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
hub.StartHub()
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenario := beszelTests.ApiScenario{
Name: "POST /create-user - should not be available when USER_EMAIL, USER_PASSWORD are set",
Method: http.MethodPost,
URL: "/api/beszel/create-user",
ExpectedStatus: 404,
ExpectedContent: []string{"wasn't found"},
TestAppFactory: testAppFactory,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
users, err := hub.FindAllRecords("users")
require.NoError(t, err)
require.EqualValues(t, 1, len(users), "Should start with one user")
require.EqualValues(t, "me@example.com", users[0].GetString("email"), "Should have created one user")
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
require.NoError(t, err)
require.EqualValues(t, 1, len(superusers), "Should start with one superuser")
require.EqualValues(t, "me@example.com", superusers[0].GetString("email"), "Should have created one superuser")
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
users, err := hub.FindAllRecords("users")
require.NoError(t, err)
require.EqualValues(t, 1, len(users), "Should still have one user")
require.EqualValues(t, "me@example.com", users[0].GetString("email"), "Should have created one user")
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
require.NoError(t, err)
require.EqualValues(t, 1, len(superusers), "Should still have one superuser")
require.EqualValues(t, "me@example.com", superusers[0].GetString("email"), "Should have created one superuser")
},
}
scenario.Test(t)
settings := hub.Settings()
assert.Equal(t, "http://example.com/app", settings.Meta.AppURL)
})
}
func TestCreateUserEndpointAvailability(t *testing.T) {
t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
// Ensure no users exist
userCount, err := hub.CountRecords("users")
require.NoError(t, err)
require.Zero(t, userCount, "Should start with no users")
hub.StartHub()
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenario := beszelTests.ApiScenario{
Name: "POST /create-user - should be available when no users exist",
Method: http.MethodPost,
URL: "/api/beszel/create-user",
Body: jsonReader(map[string]any{
"email": "firstuser@example.com",
"password": "password123",
}),
ExpectedStatus: 200,
ExpectedContent: []string{"User created"},
TestAppFactory: testAppFactory,
}
scenario.Test(t)
// Verify user was created
userCount, err = hub.CountRecords("users")
require.NoError(t, err)
require.EqualValues(t, 1, userCount, "Should have created one user")
})
t.Run("CreateUserEndpoint not available when users exist", func(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
// Create a user first
_, err := beszelTests.CreateUser(hub, "existing@example.com", "password")
require.NoError(t, err)
hub.StartHub()
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenario := beszelTests.ApiScenario{
Name: "POST /create-user - should not be available when users exist",
Method: http.MethodPost,
URL: "/api/beszel/create-user",
Body: jsonReader(map[string]any{
"email": "another@example.com",
"password": "password123",
}),
ExpectedStatus: 404,
ExpectedContent: []string{"wasn't found"},
TestAppFactory: testAppFactory,
}
scenario.Test(t)
})
}
func TestAutoLoginMiddleware(t *testing.T) {
var hubs []*beszelTests.TestHub
defer func() {
defer os.Unsetenv("AUTO_LOGIN")
for _, hub := range hubs {
hub.Cleanup()
}
}()
os.Setenv("AUTO_LOGIN", "user@test.com")
testAppFactory := func(t testing.TB) *pbTests.TestApp {
hub, _ := beszelTests.NewTestHub(t.TempDir())
hubs = append(hubs, hub)
hub.StartHub()
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
{
Name: "GET /getkey - without auto login should fail",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /getkey - with auto login should fail if no matching user",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /getkey - with auto login should succeed",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
ExpectedStatus: 200,
ExpectedContent: []string{"\"key\":", "\"v\":"},
TestAppFactory: testAppFactory,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.CreateUser(app, "user@test.com", "password123")
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestTrustedHeaderMiddleware(t *testing.T) {
var hubs []*beszelTests.TestHub
defer func() {
defer os.Unsetenv("TRUSTED_AUTH_HEADER")
for _, hub := range hubs {
hub.Cleanup()
}
}()
os.Setenv("TRUSTED_AUTH_HEADER", "X-Beszel-Trusted")
testAppFactory := func(t testing.TB) *pbTests.TestApp {
hub, _ := beszelTests.NewTestHub(t.TempDir())
hubs = append(hubs, hub)
hub.StartHub()
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
{
Name: "GET /getkey - without trusted header should fail",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /getkey - with trusted header should fail if no matching user",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
Headers: map[string]string{
"X-Beszel-Trusted": "user@test.com",
},
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /getkey - with trusted header should succeed",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
Headers: map[string]string{
"X-Beszel-Trusted": "user@test.com",
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"key\":", "\"v\":"},
TestAppFactory: testAppFactory,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.CreateUser(app, "user@test.com", "password123")
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}

View File

@@ -2,7 +2,9 @@
package hub
import "github.com/henrygd/beszel/internal/hub/systems"
import (
"github.com/henrygd/beszel/internal/hub/systems"
)
// TESTING ONLY: GetSystemManager returns the system manager
func (h *Hub) GetSystemManager() *systems.SystemManager {
@@ -18,3 +20,7 @@ func (h *Hub) GetPubkey() string {
func (h *Hub) SetPubkey(pubkey string) {
h.pubKey = pubkey
}
func (h *Hub) SetCollectionAuthSettings() error {
return setCollectionAuthSettings(h)
}

View File

@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"hash/fnv"
"log/slog"
"math/rand"
"net"
"strings"
@@ -49,7 +48,6 @@ type System struct {
detailsFetched atomic.Bool // True if static system details have been fetched and saved
smartFetching atomic.Bool // True if SMART devices are currently being fetched
smartInterval time.Duration // Interval for periodic SMART data updates
lastSmartFetch atomic.Int64 // Unix milliseconds of last SMART data fetch
}
func (sm *SystemManager) NewSystem(systemId string) *System {
@@ -135,19 +133,34 @@ func (sys *System) update() error {
return err
}
// ensure deprecated fields from older agents are migrated to current fields
migrateDeprecatedFields(data, !sys.detailsFetched.Load())
// create system records
_, err = sys.createRecords(data)
// if details were included and fetched successfully, mark details as fetched and update smart interval if set by agent
if err == nil && data.Details != nil {
sys.detailsFetched.Store(true)
// update smart interval if it's set on the agent side
if data.Details.SmartInterval > 0 {
sys.smartInterval = data.Details.SmartInterval
// make sure we reset expiration of lastFetch to remain as long as the new smart interval
// to prevent premature expiration leading to new fetch if interval is different.
sys.manager.smartFetchMap.UpdateExpiration(sys.Id, sys.smartInterval+time.Minute)
}
}
// Fetch and save SMART devices when system first comes online or at intervals
if backgroundSmartFetchEnabled() {
if backgroundSmartFetchEnabled() && sys.detailsFetched.Load() {
if sys.smartInterval <= 0 {
sys.smartInterval = time.Hour
}
lastFetch := sys.lastSmartFetch.Load()
if time.Since(time.UnixMilli(lastFetch)) >= sys.smartInterval && sys.smartFetching.CompareAndSwap(false, true) {
lastFetch, _ := sys.manager.smartFetchMap.GetOk(sys.Id)
if time.Since(time.UnixMilli(lastFetch-1e4)) >= sys.smartInterval && sys.smartFetching.CompareAndSwap(false, true) {
go func() {
defer sys.smartFetching.Store(false)
sys.lastSmartFetch.Store(time.Now().UnixMilli())
sys.manager.smartFetchMap.Set(sys.Id, time.Now().UnixMilli(), sys.smartInterval+time.Minute)
_ = sys.FetchAndSaveSmartDevices()
}()
}
@@ -210,28 +223,6 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
}
}
// add pve_vms and pve_stats records
if len(data.PVEStats) > 0 {
if data.PVEStats[0].Id != "" {
if err := createPVEVMRecords(txApp, data.PVEStats, sys.Id); err != nil {
slog.Error("Error creating PVE VM records", "err", err)
return err
}
}
pveStatsCollection, err := txApp.FindCachedCollectionByNameOrId("pve_stats")
if err != nil {
return err
}
pveStatsRecord := core.NewRecord(pveStatsCollection)
pveStatsRecord.Set("system", systemRecord.Id)
pveStatsRecord.Set("stats", data.PVEStats)
pveStatsRecord.Set("type", "1m")
if err := txApp.SaveNoValidate(pveStatsRecord); err != nil {
slog.Error("Error creating PVE stats records", "err", err)
return err
}
}
// add new systemd_stats record
if len(data.SystemdServices) > 0 {
if err := createSystemdStatsRecords(txApp, data.SystemdServices, sys.Id); err != nil {
@@ -244,11 +235,6 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
if err := createSystemDetailsRecord(txApp, data.Details, sys.Id); err != nil {
return err
}
sys.detailsFetched.Store(true)
// update smart interval if it's set on the agent side
if data.Details.SmartInterval > 0 {
sys.smartInterval = data.Details.SmartInterval
}
}
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
@@ -332,10 +318,11 @@ func createContainerRecords(app core.App, data []*container.Stats, systemId stri
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))
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:image%[1]s}, {:ports%[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["ports"+suffix] = container.Ports
params["status"+suffix] = container.Status
params["health"+suffix] = container.Health
params["cpu"+suffix] = container.Cpu
@@ -347,43 +334,7 @@ func createContainerRecords(app core.App, data []*container.Stats, systemId stri
params["net"+suffix] = netBytes
}
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
}
// createPVEVMRecords creates or updates pve_vms records
func createPVEVMRecords(app core.App, data []*container.PveNodeStats, systemId string) error {
if len(data) == 0 {
return nil
}
// shared params for all records
params := dbx.Params{
"system": systemId,
"updated": time.Now().UTC().UnixMilli(),
}
valueStrings := make([]string, 0, len(data))
for i, vm := range data {
suffix := fmt.Sprintf("%d", i)
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:type%[1]s}, {:cpu%[1]s}, {:mem%[1]s}, {:netout%[1]s}, {:netin%[1]s}, {:maxcpu%[1]s}, {:maxmem%[1]s}, {:uptime%[1]s}, {:diskread%[1]s}, {:diskwrite%[1]s}, {:disk%[1]s}, {:updated})", suffix))
params["id"+suffix] = makeStableHashId(systemId, vm.Id)
params["name"+suffix] = vm.Name
params["type"+suffix] = vm.Type // "qemu" or "lxc"
params["cpu"+suffix] = vm.Cpu
params["mem"+suffix] = vm.Mem
params["maxcpu"+suffix] = vm.MaxCPU
params["maxmem"+suffix] = vm.MaxMem
params["uptime"+suffix] = vm.Uptime
params["diskread"+suffix] = vm.DiskRead
params["diskwrite"+suffix] = vm.DiskWrite
params["disk"+suffix] = vm.Disk
params["netout"+suffix] = vm.NetOut // cumulative bytes sent by VM
params["netin"+suffix] = vm.NetIn // cumulative bytes received by VM
}
queryString := fmt.Sprintf(
"INSERT INTO pve_vms (id, system, name, type, cpu, mem, netout, netin, maxcpu, maxmem, uptime, diskread, diskwrite, disk, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system=excluded.system, name=excluded.name, type=excluded.type, cpu=excluded.cpu, mem=excluded.mem, netout=excluded.netout, netin=excluded.netin, maxcpu=excluded.maxcpu, maxmem=excluded.maxmem, uptime=excluded.uptime, diskread=excluded.diskread, diskwrite=excluded.diskwrite, disk=excluded.disk, updated=excluded.updated",
"INSERT INTO containers (id, system, name, image, ports, status, health, cpu, memory, net, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, image = excluded.image, ports = excluded.ports, 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()
@@ -391,6 +342,7 @@ func createPVEVMRecords(app core.App, data []*container.PveNodeStats, systemId s
}
// getRecord retrieves the system record from the database.
// If the record is not found, it removes the system from the manager.
func (sys *System) getRecord() (*core.Record, error) {
record, err := sys.manager.hub.FindRecordById("systems", sys.Id)
if err != nil || record == nil {
@@ -761,3 +713,50 @@ func getJitter() <-chan time.Time {
msDelay := (interval * minPercent / 100) + rand.Intn(interval*jitterRange/100)
return time.After(time.Duration(msDelay) * time.Millisecond)
}
// migrateDeprecatedFields moves values from deprecated fields to their new locations if the new
// fields are not already populated. Deprecated fields and refs may be removed at least 30 days
// and one minor version release after the release that includes the migration.
//
// This is run when processing incoming system data from agents, which may be on older versions.
func migrateDeprecatedFields(cd *system.CombinedData, createDetails bool) {
// migration added 0.19.0
if cd.Stats.Bandwidth[0] == 0 && cd.Stats.Bandwidth[1] == 0 {
cd.Stats.Bandwidth[0] = uint64(cd.Stats.NetworkSent * 1024 * 1024)
cd.Stats.Bandwidth[1] = uint64(cd.Stats.NetworkRecv * 1024 * 1024)
cd.Stats.NetworkSent, cd.Stats.NetworkRecv = 0, 0
}
// migration added 0.19.0
if cd.Info.BandwidthBytes == 0 {
cd.Info.BandwidthBytes = uint64(cd.Info.Bandwidth * 1024 * 1024)
cd.Info.Bandwidth = 0
}
// migration added 0.19.0
if cd.Stats.DiskIO[0] == 0 && cd.Stats.DiskIO[1] == 0 {
cd.Stats.DiskIO[0] = uint64(cd.Stats.DiskReadPs * 1024 * 1024)
cd.Stats.DiskIO[1] = uint64(cd.Stats.DiskWritePs * 1024 * 1024)
cd.Stats.DiskReadPs, cd.Stats.DiskWritePs = 0, 0
}
// migration added 0.19.0 - Move deprecated Info fields to Details struct
if cd.Details == nil && cd.Info.Hostname != "" {
if createDetails {
cd.Details = &system.Details{
Hostname: cd.Info.Hostname,
Kernel: cd.Info.KernelVersion,
Cores: cd.Info.Cores,
Threads: cd.Info.Threads,
CpuModel: cd.Info.CpuModel,
Podman: cd.Info.Podman,
Os: cd.Info.Os,
MemoryTotal: uint64(cd.Stats.Mem * 1024 * 1024 * 1024),
}
}
// zero the deprecated fields to prevent saving them in systems.info DB json payload
cd.Info.Hostname = ""
cd.Info.KernelVersion = ""
cd.Info.Cores = 0
cd.Info.CpuModel = ""
cd.Info.Podman = false
cd.Info.Os = 0
}
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/henrygd/beszel/internal/hub/ws"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/hub/expirymap"
"github.com/henrygd/beszel/internal/common"
@@ -40,9 +41,10 @@ var errSystemExists = errors.New("system exists")
// SystemManager manages a collection of monitored systems and their connections.
// It handles system lifecycle, status updates, and maintains both SSH and WebSocket connections.
type SystemManager struct {
hub hubLike // Hub interface for database and alert operations
systems *store.Store[string, *System] // Thread-safe store of active systems
sshConfig *ssh.ClientConfig // SSH client configuration for system connections
hub hubLike // Hub interface for database and alert operations
systems *store.Store[string, *System] // Thread-safe store of active systems
sshConfig *ssh.ClientConfig // SSH client configuration for system connections
smartFetchMap *expirymap.ExpiryMap[int64] // Stores last SMART fetch time per system ID
}
// hubLike defines the interface requirements for the hub dependency.
@@ -58,8 +60,9 @@ type hubLike interface {
// The hub must implement the hubLike interface to provide database and alert functionality.
func NewSystemManager(hub hubLike) *SystemManager {
return &SystemManager{
systems: store.New(map[string]*System{}),
hub: hub,
systems: store.New(map[string]*System{}),
hub: hub,
smartFetchMap: expirymap.New[int64](time.Hour),
}
}

View File

@@ -0,0 +1,159 @@
//go:build testing
package systems
import (
"testing"
"github.com/henrygd/beszel/internal/entities/system"
)
func TestCombinedData_MigrateDeprecatedFields(t *testing.T) {
t.Run("Migrate NetworkSent and NetworkRecv to Bandwidth", func(t *testing.T) {
cd := &system.CombinedData{
Stats: system.Stats{
NetworkSent: 1.5, // 1.5 MB
NetworkRecv: 2.5, // 2.5 MB
},
}
migrateDeprecatedFields(cd, true)
expectedSent := uint64(1.5 * 1024 * 1024)
expectedRecv := uint64(2.5 * 1024 * 1024)
if cd.Stats.Bandwidth[0] != expectedSent {
t.Errorf("expected Bandwidth[0] %d, got %d", expectedSent, cd.Stats.Bandwidth[0])
}
if cd.Stats.Bandwidth[1] != expectedRecv {
t.Errorf("expected Bandwidth[1] %d, got %d", expectedRecv, cd.Stats.Bandwidth[1])
}
if cd.Stats.NetworkSent != 0 || cd.Stats.NetworkRecv != 0 {
t.Errorf("expected NetworkSent and NetworkRecv to be reset, got %f, %f", cd.Stats.NetworkSent, cd.Stats.NetworkRecv)
}
})
t.Run("Migrate Info.Bandwidth to Info.BandwidthBytes", func(t *testing.T) {
cd := &system.CombinedData{
Info: system.Info{
Bandwidth: 10.0, // 10 MB
},
}
migrateDeprecatedFields(cd, true)
expected := uint64(10 * 1024 * 1024)
if cd.Info.BandwidthBytes != expected {
t.Errorf("expected BandwidthBytes %d, got %d", expected, cd.Info.BandwidthBytes)
}
if cd.Info.Bandwidth != 0 {
t.Errorf("expected Info.Bandwidth to be reset, got %f", cd.Info.Bandwidth)
}
})
t.Run("Migrate DiskReadPs and DiskWritePs to DiskIO", func(t *testing.T) {
cd := &system.CombinedData{
Stats: system.Stats{
DiskReadPs: 3.0, // 3 MB
DiskWritePs: 4.0, // 4 MB
},
}
migrateDeprecatedFields(cd, true)
expectedRead := uint64(3 * 1024 * 1024)
expectedWrite := uint64(4 * 1024 * 1024)
if cd.Stats.DiskIO[0] != expectedRead {
t.Errorf("expected DiskIO[0] %d, got %d", expectedRead, cd.Stats.DiskIO[0])
}
if cd.Stats.DiskIO[1] != expectedWrite {
t.Errorf("expected DiskIO[1] %d, got %d", expectedWrite, cd.Stats.DiskIO[1])
}
if cd.Stats.DiskReadPs != 0 || cd.Stats.DiskWritePs != 0 {
t.Errorf("expected DiskReadPs and DiskWritePs to be reset, got %f, %f", cd.Stats.DiskReadPs, cd.Stats.DiskWritePs)
}
})
t.Run("Migrate Info fields to Details struct", func(t *testing.T) {
cd := &system.CombinedData{
Stats: system.Stats{
Mem: 16.0, // 16 GB
},
Info: system.Info{
Hostname: "test-host",
KernelVersion: "6.8.0",
Cores: 8,
Threads: 16,
CpuModel: "Intel i7",
Podman: true,
Os: system.Linux,
},
}
migrateDeprecatedFields(cd, true)
if cd.Details == nil {
t.Fatal("expected Details struct to be created")
}
if cd.Details.Hostname != "test-host" {
t.Errorf("expected Hostname 'test-host', got '%s'", cd.Details.Hostname)
}
if cd.Details.Kernel != "6.8.0" {
t.Errorf("expected Kernel '6.8.0', got '%s'", cd.Details.Kernel)
}
if cd.Details.Cores != 8 {
t.Errorf("expected Cores 8, got %d", cd.Details.Cores)
}
if cd.Details.Threads != 16 {
t.Errorf("expected Threads 16, got %d", cd.Details.Threads)
}
if cd.Details.CpuModel != "Intel i7" {
t.Errorf("expected CpuModel 'Intel i7', got '%s'", cd.Details.CpuModel)
}
if cd.Details.Podman != true {
t.Errorf("expected Podman true, got %v", cd.Details.Podman)
}
if cd.Details.Os != system.Linux {
t.Errorf("expected Os Linux, got %d", cd.Details.Os)
}
expectedMem := uint64(16 * 1024 * 1024 * 1024)
if cd.Details.MemoryTotal != expectedMem {
t.Errorf("expected MemoryTotal %d, got %d", expectedMem, cd.Details.MemoryTotal)
}
if cd.Info.Hostname != "" || cd.Info.KernelVersion != "" || cd.Info.Cores != 0 || cd.Info.CpuModel != "" || cd.Info.Podman != false || cd.Info.Os != 0 {
t.Errorf("expected Info fields to be reset, got %+v", cd.Info)
}
})
t.Run("Do not migrate if Details already exists", func(t *testing.T) {
cd := &system.CombinedData{
Details: &system.Details{Hostname: "existing-host"},
Info: system.Info{
Hostname: "deprecated-host",
},
}
migrateDeprecatedFields(cd, true)
if cd.Details.Hostname != "existing-host" {
t.Errorf("expected Hostname 'existing-host', got '%s'", cd.Details.Hostname)
}
if cd.Info.Hostname != "deprecated-host" {
t.Errorf("expected Info.Hostname to remain 'deprecated-host', got '%s'", cd.Info.Hostname)
}
})
t.Run("Do not create details if migrateDetails is false", func(t *testing.T) {
cd := &system.CombinedData{
Info: system.Info{
Hostname: "deprecated-host",
},
}
migrateDeprecatedFields(cd, false)
if cd.Details != nil {
t.Fatal("expected Details struct to not be created")
}
if cd.Info.Hostname != "" {
t.Errorf("expected Info.Hostname to be reset, got '%s'", cd.Info.Hostname)
}
})
}

View File

@@ -7,6 +7,7 @@ import (
"fmt"
entities "github.com/henrygd/beszel/internal/entities/system"
"github.com/pocketbase/pocketbase/core"
)
// The hub integration tests create/replace systems and cleanup the test apps quickly.
@@ -113,4 +114,14 @@ func (sm *SystemManager) RemoveAllSystems() {
for _, system := range sm.systems.GetAll() {
sm.RemoveSystem(system.Id)
}
sm.smartFetchMap.StopCleaner()
}
func (s *System) StopUpdater() {
s.cancel()
}
func (s *System) CreateRecords(data *entities.CombinedData) (*core.Record, error) {
s.data = data
return s.createRecords(data)
}

View File

@@ -11,11 +11,11 @@ func init() {
jsonData := `[
{
"id": "elngm8x1l60zi2v",
"listRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"viewRule": "",
"createRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"listRule": "@request.auth.id != \"\" && user = @request.auth.id",
"viewRule": null,
"createRule": "@request.auth.id != \"\" && user = @request.auth.id",
"updateRule": "@request.auth.id != \"\" && user = @request.auth.id",
"deleteRule": "@request.auth.id != \"\" && user = @request.auth.id",
"name": "alerts",
"type": "base",
"fields": [
@@ -143,11 +143,11 @@ func init() {
},
{
"id": "pbc_1697146157",
"listRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"viewRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"listRule": "@request.auth.id != \"\" && user = @request.auth.id",
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"deleteRule": "@request.auth.id != \"\" && user = @request.auth.id",
"name": "alerts_history",
"type": "base",
"fields": [
@@ -261,7 +261,7 @@ func init() {
},
{
"id": "juohu4jipgc13v7",
"listRule": "@request.auth.id != \"\"",
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
@@ -351,10 +351,10 @@ func init() {
},
{
"id": "pbc_3663931638",
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
"viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
"createRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id && @request.auth.role != \"readonly\"",
"updateRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id && @request.auth.role != \"readonly\"",
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"name": "fingerprints",
"type": "base",
@@ -433,7 +433,7 @@ func init() {
},
{
"id": "ej9oowivz8b2mht",
"listRule": "@request.auth.id != \"\"",
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
@@ -523,10 +523,10 @@ func init() {
},
{
"id": "4afacsdnlu8q8r2",
"listRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"listRule": "@request.auth.id != \"\" && user = @request.auth.id",
"viewRule": null,
"createRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"createRule": "@request.auth.id != \"\" && user = @request.auth.id",
"updateRule": "@request.auth.id != \"\" && user = @request.auth.id",
"deleteRule": null,
"name": "user_settings",
"type": "base",
@@ -596,11 +596,11 @@ func init() {
},
{
"id": "2hz5ncl8tizk5nx",
"listRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id",
"viewRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id",
"createRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id && @request.auth.role != \"readonly\"",
"updateRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id && @request.auth.role != \"readonly\"",
"deleteRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id && @request.auth.role != \"readonly\"",
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"name": "systems",
"type": "base",
"fields": [
@@ -866,7 +866,7 @@ func init() {
},
{
"id": "pbc_1864144027",
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
@@ -977,18 +977,6 @@ func init() {
"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,
@@ -1002,6 +990,32 @@ func init() {
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2308952269",
"max": 0,
"min": 0,
"name": "ports",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "number3332085495",
"max": null,
"min": null,
"name": "updated",
"onlyInt": true,
"presentable": false,
"required": true,
"system": false,
"type": "number"
}
],
"indexes": [
@@ -1145,7 +1159,7 @@ func init() {
"CREATE INDEX ` + "`" + `idx_4Z7LuLNdQb` + "`" + ` ON ` + "`" + `systemd_services` + "`" + ` (` + "`" + `system` + "`" + `)",
"CREATE INDEX ` + "`" + `idx_pBp1fF837e` + "`" + ` ON ` + "`" + `systemd_services` + "`" + ` (` + "`" + `updated` + "`" + `)"
],
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
"listRule": null,
"name": "systemd_services",
"system": false,
"type": "base",
@@ -1153,8 +1167,8 @@ func init() {
"viewRule": null
},
{
"createRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"createRule": "@request.auth.id != \"\" && user = @request.auth.id",
"deleteRule": "@request.auth.id != \"\" && user = @request.auth.id",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{10}",
@@ -1238,16 +1252,16 @@ func init() {
"CREATE INDEX ` + "`" + `idx_q0iKnRP9v8` + "`" + ` ON ` + "`" + `quiet_hours` + "`" + ` (\n ` + "`" + `user` + "`" + `,\n ` + "`" + `system` + "`" + `\n)",
"CREATE INDEX ` + "`" + `idx_6T7ljT7FJd` + "`" + ` ON ` + "`" + `quiet_hours` + "`" + ` (\n ` + "`" + `type` + "`" + `,\n ` + "`" + `end` + "`" + `\n)"
],
"listRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"listRule": "@request.auth.id != \"\" && user = @request.auth.id",
"name": "quiet_hours",
"system": false,
"type": "base",
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"viewRule": "@request.auth.id != \"\" && user.id = @request.auth.id"
"updateRule": "@request.auth.id != \"\" && user = @request.auth.id",
"viewRule": "@request.auth.id != \"\" && user = @request.auth.id"
},
{
"createRule": null,
"deleteRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{10}",
@@ -1433,16 +1447,16 @@ func init() {
"indexes": [
"CREATE INDEX ` + "`" + `idx_DZ9yhvgl44` + "`" + ` ON ` + "`" + `smart_devices` + "`" + ` (` + "`" + `system` + "`" + `)"
],
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
"listRule": null,
"name": "smart_devices",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id"
"viewRule": null
},
{
"createRule": "",
"deleteRule": "",
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
@@ -1611,12 +1625,12 @@ func init() {
],
"id": "pbc_3116237454",
"indexes": [],
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
"name": "system_details",
"system": false,
"type": "base",
"updateRule": "",
"viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id"
"updateRule": null,
"listRule": null,
"viewRule": null
},
{
"createRule": null,
@@ -1685,300 +1699,6 @@ func init() {
"type": "base",
"updateRule": null,
"viewRule": null
},
{
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{10}",
"hidden": false,
"id": "text3208210256",
"max": 10,
"min": 10,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "2hz5ncl8tizk5nx",
"hidden": false,
"id": "pve_stats_sys01",
"maxSelect": 1,
"minSelect": 0,
"name": "system",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "pve_stats_json1",
"maxSize": 2000000,
"name": "stats",
"presentable": false,
"required": true,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "pve_stats_type1",
"maxSelect": 1,
"name": "type",
"presentable": false,
"required": true,
"system": false,
"type": "select",
"values": [
"1m",
"10m",
"20m",
"120m",
"480m"
]
},
{
"hidden": false,
"id": "pve_stats_crt1",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "pve_stats_upd1",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pvestats",
"indexes": [
"CREATE INDEX ` + "`" + `idx_pve_stats_sys_type_created` + "`" + ` ON ` + "`" + `pve_stats` + "`" + ` (\n ` + "`" + `system` + "`" + `,\n ` + "`" + `type` + "`" + `,\n ` + "`" + `created` + "`" + `\n)"
],
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
"name": "pve_stats",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
},
{
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-f0-9]{8}",
"hidden": false,
"id": "text3208210256",
"max": 8,
"min": 8,
"name": "id",
"pattern": "^[a-f0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "2hz5ncl8tizk5nx",
"hidden": false,
"id": "pve_vms_sys001",
"maxSelect": 1,
"minSelect": 0,
"name": "system",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "pve_vms_name01",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "pve_vms_type01",
"max": 0,
"min": 0,
"name": "type",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "pve_vms_cpu001",
"max": 100,
"min": 0,
"name": "cpu",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number1253106325",
"max": null,
"min": null,
"name": "maxcpu",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "pve_vms_mem001",
"max": null,
"min": 0,
"name": "mem",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number1693954525",
"max": null,
"min": null,
"name": "maxmem",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number208985346",
"max": null,
"min": null,
"name": "disk",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number4125810518",
"max": null,
"min": null,
"name": "diskread",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number752404475",
"max": null,
"min": null,
"name": "diskwrite",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number1880667380",
"max": null,
"min": null,
"name": "netout",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number2702533949",
"max": null,
"min": null,
"name": "netin",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number1563400775",
"max": null,
"min": null,
"name": "uptime",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "pve_vms_upd001",
"max": null,
"min": null,
"name": "updated",
"onlyInt": true,
"presentable": false,
"required": true,
"system": false,
"type": "number"
}
],
"id": "pvevms",
"indexes": [
"CREATE INDEX ` + "`" + `idx_pve_vms_updated` + "`" + ` ON ` + "`" + `pve_vms` + "`" + ` (` + "`" + `updated` + "`" + `)",
"CREATE INDEX ` + "`" + `idx_pve_vms_system` + "`" + ` ON ` + "`" + `pve_vms` + "`" + ` (` + "`" + `system` + "`" + `)"
],
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
"name": "pve_vms",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
}
]`

View File

@@ -42,11 +42,11 @@ type StatsRecord struct {
// global variables for reusing allocations
var (
statsRecord StatsRecord
containerStats []container.SharedCoreMetrics
containerStats []container.Stats
sumStats system.Stats
tempStats system.Stats
queryParams = make(dbx.Params, 1)
containerSums = make(map[string]*container.SharedCoreMetrics)
containerSums = make(map[string]*container.Stats)
)
// Create longer records by averaging shorter records
@@ -82,7 +82,7 @@ func (rm *RecordManager) CreateLongerRecords() {
// wrap the operations in a transaction
rm.app.RunInTransaction(func(txApp core.App) error {
var err error
collections := [3]*core.Collection{}
collections := [2]*core.Collection{}
collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats")
if err != nil {
return err
@@ -91,10 +91,6 @@ func (rm *RecordManager) CreateLongerRecords() {
if err != nil {
return err
}
collections[2], err = txApp.FindCachedCollectionByNameOrId("pve_stats")
if err != nil {
return err
}
var systems RecordIds
db := txApp.DB()
@@ -154,9 +150,8 @@ func (rm *RecordManager) CreateLongerRecords() {
case "system_stats":
longerRecord.Set("stats", rm.AverageSystemStats(db, recordIds))
case "container_stats":
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds, "container_stats"))
case "pve_stats":
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds, "pve_stats"))
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds))
}
if err := txApp.SaveNoValidate(longerRecord); err != nil {
log.Println("failed to save longer record", "err", err)
@@ -440,8 +435,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
return sum
}
// Calculate the average stats of a list of container_stats or pve_stats records
func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds, collectionName string) []container.SharedCoreMetrics {
// Calculate the average stats of a list of container_stats records
func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds) []container.Stats {
// Clear global map for reuse
for k := range containerSums {
delete(containerSums, k)
@@ -458,15 +453,15 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
containerStats = nil
queryParams["id"] = id
db.NewQuery(fmt.Sprintf("SELECT stats FROM %s WHERE id = {:id}", collectionName)).Bind(queryParams).One(&statsRecord)
db.NewQuery("SELECT stats FROM container_stats WHERE id = {:id}").Bind(queryParams).One(&statsRecord)
if err := json.Unmarshal(statsRecord.Stats, &containerStats); err != nil {
return []container.SharedCoreMetrics{}
return []container.Stats{}
}
for i := range containerStats {
stat := containerStats[i]
if _, ok := sums[stat.Name]; !ok {
sums[stat.Name] = &container.SharedCoreMetrics{Name: stat.Name}
sums[stat.Name] = &container.Stats{Name: stat.Name}
}
sums[stat.Name].Cpu += stat.Cpu
sums[stat.Name].Mem += stat.Mem
@@ -481,9 +476,9 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
}
}
result := make([]container.SharedCoreMetrics, 0, len(sums))
result := make([]container.Stats, 0, len(sums))
for _, value := range sums {
result = append(result, container.SharedCoreMetrics{
result = append(result, container.Stats{
Name: value.Name,
Cpu: twoDecimals(value.Cpu / count),
Mem: twoDecimals(value.Mem / count),
@@ -504,10 +499,6 @@ func (rm *RecordManager) DeleteOldRecords() {
if err != nil {
return err
}
err = deleteOldPVEVMRecords(txApp)
if err != nil {
return err
}
err = deleteOldSystemdServiceRecords(txApp)
if err != nil {
return err
@@ -546,7 +537,7 @@ func deleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int)
// Deletes system_stats records older than what is displayed in the UI
func deleteOldSystemStats(app core.App) error {
// Collections to process
collections := [3]string{"system_stats", "container_stats", "pve_stats"}
collections := [2]string{"system_stats", "container_stats"}
// Record types and their retention periods
type RecordDeletionData struct {
@@ -599,19 +590,6 @@ func deleteOldSystemdServiceRecords(app core.App) error {
return nil
}
// Deletes pve_vms records that haven't been updated in the last 10 minutes
func deleteOldPVEVMRecords(app core.App) error {
now := time.Now().UTC()
tenMinutesAgo := now.Add(-10 * time.Minute)
_, err := app.DB().NewQuery("DELETE FROM pve_vms WHERE updated < {:updated}").Bind(dbx.Params{"updated": tenMinutesAgo.UnixMilli()}).Execute()
if err != nil {
return fmt.Errorf("failed to delete old pve_vms records: %v", err)
}
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()

View File

@@ -7,6 +7,19 @@
<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>
<style>
.dark { background: hsl(220 5.5% 9%); color-scheme: dark; }
</style>
<script>
(function() {
try {
var theme = localStorage.getItem('ui-theme');
var isDark = theme === 'dark' ||
(theme !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.add(isDark ? 'dark' : 'light');
} catch (e) {}
})();
</script>
<script>
globalThis.BESZEL = {
BASE_PATH: "%BASE_URL%",

View File

@@ -23,16 +23,14 @@ export default memo(function ContainerChart({
chartType,
chartConfig,
unit = "%",
filterStore = $containerFilter,
}: {
dataKey: string
chartData: ChartData
chartType: ChartType
chartConfig: ChartConfig
unit?: string
filterStore?: typeof $containerFilter
}) {
const filter = useStore(filterStore)
const filter = useStore($containerFilter)
const userSettings = useStore($userSettings)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()

View File

@@ -16,19 +16,16 @@ import { useYAxisWidth } from "./hooks"
export default memo(function LoadAverageChart({ chartData }: { chartData: ChartData }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const keys: { legacy: keyof SystemStats; color: string; label: string }[] = [
const keys: { color: string; label: string }[] = [
{
legacy: "l1",
color: "hsl(271, 81%, 60%)", // Purple
label: t({ message: `1 min`, comment: "Load average" }),
},
{
legacy: "l5",
color: "hsl(217, 91%, 60%)", // Blue
label: t({ message: `5 min`, comment: "Load average" }),
},
{
legacy: "l15",
color: "hsl(25, 95%, 53%)", // Orange
label: t({ message: `15 min`, comment: "Load average" }),
},
@@ -66,27 +63,18 @@ export default memo(function LoadAverageChart({ chartData }: { chartData: ChartD
/>
}
/>
{keys.map(({ legacy, color, label }, i) => {
const dataKey = (value: { stats: SystemStats }) => {
const { minor, patch } = chartData.agentVersion
if (minor <= 12 && patch < 1) {
return value.stats?.[legacy]
}
return value.stats?.la?.[i] ?? value.stats?.[legacy]
}
return (
<Line
key={label}
dataKey={dataKey}
name={label}
type="monotoneX"
dot={false}
strokeWidth={1.5}
stroke={color}
isAnimationActive={false}
/>
)
})}
{keys.map(({ color, label }, i) => (
<Line
key={label}
dataKey={(value: { stats: SystemStats }) => value.stats?.la?.[i]}
name={label}
type="monotoneX"
dot={false}
strokeWidth={1.5}
stroke={color}
isAnimationActive={false}
/>
))}
<ChartLegend content={<ChartLegendContent />} />
</LineChart>
</ChartContainer>

View File

@@ -4,7 +4,6 @@ import { cn, decimalString, formatBytes, hourWithSeconds } from "@/lib/utils"
import type { ContainerRecord } from "@/types"
import { ContainerHealth, ContainerHealthLabels } from "@/lib/enums"
import {
ArrowUpDownIcon,
ClockIcon,
ContainerIcon,
CpuIcon,
@@ -13,11 +12,12 @@ import {
ServerIcon,
ShieldCheckIcon,
} from "lucide-react"
import { EthernetIcon, HourglassIcon } from "../ui/icons"
import { EthernetIcon, HourglassIcon, SquareArrowRightEnterIcon } from "../ui/icons"
import { Badge } from "../ui/badge"
import { t } from "@lingui/core/macro"
import { $allSystemsById } from "@/lib/stores"
import { $allSystemsById, $longestSystemNameLen } from "@/lib/stores"
import { useStore } from "@nanostores/react"
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"
// Unit names and their corresponding number of seconds for converting docker status strings
const unitSeconds = [
@@ -63,7 +63,12 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
cell: ({ getValue }) => {
const allSystems = useStore($allSystemsById)
return <span className="ms-1.5 xl:w-34 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
const longestName = useStore($longestSystemNameLen)
return (
<div className="ms-1 max-w-40 truncate" style={{ width: `${longestName / 1.05}ch` }}>
{allSystems[getValue() as string]?.name ?? ""}
</div>
)
},
},
// {
@@ -82,7 +87,7 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
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>
return <span className="ms-1 tabular-nums">{`${decimalString(val, val >= 10 ? 1 : 2)}%`}</span>
},
},
{
@@ -94,7 +99,7 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
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>
<span className="ms-1 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
)
},
},
@@ -103,11 +108,12 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
accessorFn: (record) => record.net,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Net`} Icon={EthernetIcon} />,
minSize: 112,
cell: ({ getValue }) => {
const val = getValue() as number
const formatted = formatBytes(val, true, undefined, false)
return (
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
<div className="ms-1 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</div>
)
},
},
@@ -116,6 +122,7 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
invertSorting: true,
accessorFn: (record) => record.health,
header: ({ column }) => <HeaderButton column={column} name={t`Health`} Icon={ShieldCheckIcon} />,
minSize: 121,
cell: ({ getValue }) => {
const healthValue = getValue() as number
const healthStatus = ContainerHealthLabels[healthValue] || "Unknown"
@@ -134,6 +141,35 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
)
},
},
{
id: "ports",
accessorFn: (record) => record.ports || undefined,
header: ({ column }) => (
<HeaderButton
column={column}
name={t({ message: "Ports", context: "Container ports" })}
Icon={SquareArrowRightEnterIcon}
/>
),
sortingFn: (a, b) => getPortValue(a.original.ports) - getPortValue(b.original.ports),
minSize: 147,
cell: ({ getValue }) => {
const val = getValue() as string | undefined
if (!val) {
return <div className="ms-1.5 text-muted-foreground">-</div>
}
const className = "ms-1 w-27 block truncate tabular-nums"
if (val.length > 14) {
return (
<Tooltip>
<TooltipTrigger className={className}>{val}</TooltipTrigger>
<TooltipContent>{val}</TooltipContent>
</Tooltip>
)
}
return <span className={className}>{val}</span>
},
},
{
id: "image",
sortingFn: (a, b) => a.original.image.localeCompare(b.original.image),
@@ -142,7 +178,12 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
<HeaderButton column={column} name={t({ message: "Image", context: "Docker image" })} Icon={LayersIcon} />
),
cell: ({ getValue }) => {
return <span className="ms-1.5 xl:w-40 block truncate">{getValue() as string}</span>
const val = getValue() as string
return (
<div className="ms-1 xl:w-40 truncate" title={val}>
{val}
</div>
)
},
},
{
@@ -152,7 +193,7 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
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>
return <span className="ms-1 w-25 block truncate">{getValue() as string}</span>
},
},
{
@@ -162,7 +203,7 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
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>
return <span className="ms-1 tabular-nums">{hourWithSeconds(new Date(timestamp).toISOString())}</span>
},
},
]
@@ -188,7 +229,21 @@ function HeaderButton({
>
{Icon && <Icon className="size-4" />}
{name}
<ArrowUpDownIcon className="size-4" />
{/* <ArrowUpDownIcon className="size-4" /> */}
</Button>
)
}
/**
* Convert port string to a number for sorting.
* Handles formats like "80", "127.0.0.1:80", and "80, 443" (takes the first mapping).
*/
function getPortValue(ports: string | undefined): number {
if (!ports) {
return 0
}
const first = ports.includes(",") ? ports.substring(0, ports.indexOf(",")) : ports
const colonIndex = first.lastIndexOf(":")
const portStr = colonIndex === -1 ? first : first.substring(colonIndex + 1)
return Number(portStr) || 0
}

View File

@@ -1,3 +1,4 @@
/** biome-ignore-all lint/security/noDangerouslySetInnerHtml: html comes directly from docker via agent */
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import {
@@ -13,7 +14,7 @@ import {
type VisibilityState,
} from "@tanstack/react-table"
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
import { memo, RefObject, useEffect, useRef, useState } from "react"
import { memo, type 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"
@@ -44,6 +45,20 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
)
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
// Hide ports column if no ports are present
useEffect(() => {
if (data) {
const hasPorts = data.some((container) => container.ports)
setColumnVisibility((prev) => {
if (prev.ports === hasPorts) {
return prev
}
return { ...prev, ports: hasPorts }
})
}
}, [data])
const [rowSelection, setRowSelection] = useState({})
const [globalFilter, setGlobalFilter] = useState("")
@@ -51,7 +66,7 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
function fetchData(systemId?: string) {
pb.collection<ContainerRecord>("containers")
.getList(0, 2000, {
fields: "id,name,image,cpu,memory,net,health,status,system,updated",
fields: "id,name,image,ports,cpu,memory,net,health,status,system,updated",
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
})
.then(({ items }) => {
@@ -67,7 +82,7 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
setData((curItems) => {
const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0)
const containerIds = new Set()
const newItems = []
const newItems: ContainerRecord[] = []
for (const item of items) {
if (Math.abs(lastUpdated - item.updated) < 70_000) {
containerIds.add(item.id)
@@ -134,7 +149,8 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
const status = container.status ?? ""
const healthLabel = ContainerHealthLabels[container.health as ContainerHealth] ?? ""
const image = container.image ?? ""
const searchString = `${systemName} ${id} ${name} ${healthLabel} ${status} ${image}`.toLowerCase()
const ports = container.ports ?? ""
const searchString = `${systemName} ${id} ${name} ${healthLabel} ${status} ${image} ${ports}`.toLowerCase()
return (filterValue as string)
.toLowerCase()
@@ -300,9 +316,6 @@ function ContainerSheet({
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)
@@ -310,6 +323,8 @@ function ContainerSheet({
const [isRefreshingLogs, setIsRefreshingLogs] = useState<boolean>(false)
const logsContainerRef = useRef<HTMLDivElement>(null)
const container = activeContainer.current
function scrollLogsToBottom() {
if (logsContainerRef.current) {
logsContainerRef.current.scrollTo({ top: logsContainerRef.current.scrollHeight })
@@ -317,6 +332,7 @@ function ContainerSheet({
}
const refreshLogs = async () => {
if (!container) return
setIsRefreshingLogs(true)
const startTime = Date.now()
@@ -348,6 +364,8 @@ function ContainerSheet({
})()
}, [container])
if (!container) return null
return (
<>
<LogsFullscreenDialog
@@ -378,8 +396,14 @@ function ContainerSheet({
{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]}
{/* {container.ports && (
<>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
{container.ports}
</>
)} */}
{/* <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">
@@ -438,11 +462,12 @@ function ContainerSheet({
function ContainersTableHead({ table }: { table: TableType<ContainerRecord> }) {
return (
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
<div className="absolute -top-2 left-0 w-full h-4 bg-table-header z-50"></div>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead className="px-2" key={header.id}>
<TableHead className="px-2" key={header.id} style={{ width: header.getSize() }}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
@@ -474,6 +499,7 @@ const ContainerTableRow = memo(function ContainerTableRow({
className="py-0 ps-4.5"
style={{
height: virtualRow.size,
width: cell.column.getSize(),
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}

View File

@@ -1,7 +1,11 @@
import { useStore } from "@nanostores/react"
import { GithubIcon } from "lucide-react"
import { $newVersion } from "@/lib/stores"
import { Separator } from "./ui/separator"
import { Trans } from "@lingui/react/macro"
export function FooterRepoLink() {
const newVersion = useStore($newVersion)
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
@@ -21,6 +25,19 @@ export function FooterRepoLink() {
>
Beszel {globalThis.BESZEL.HUB_VERSION}
</a>
{newVersion?.v && (
<>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<a
href={newVersion.url}
target="_blank"
className="text-yellow-500 hover:text-yellow-400 duration-75"
rel="noopener"
>
<Trans context="New version available">{newVersion.v} available</Trans>
</a>
</>
)}
</div>
)
}

View File

@@ -1,5 +1,4 @@
import { Trans } from "@lingui/react/macro"
import { t } from "@lingui/core/macro"
import { getPagePath } from "@nanostores/router"
import {
ContainerIcon,
@@ -32,7 +31,6 @@ import { Logo } from "./logo"
import { ModeToggle } from "./mode-toggle"
import { $router, basePath, Link, prependBasePath } from "./router"
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
import { ProxmoxIcon } from "./ui/icons"
const CommandPalette = lazy(() => import("./command-palette"))
@@ -79,20 +77,6 @@ export default function Navbar() {
</TooltipTrigger>
<TooltipContent>S.M.A.R.T.</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Link
href={getPagePath($router, "proxmox")}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
aria-label={t`Proxmox`}
>
<ProxmoxIcon className="h-[1.2rem] w-[1.2rem] opacity-90" />
</Link>
</TooltipTrigger>
<TooltipContent>
<Trans>Proxmox</Trans>
</TooltipContent>
</Tooltip>
<LangToggle />
<ModeToggle />
<Tooltip>

View File

@@ -1,215 +0,0 @@
import type { Column, ColumnDef } from "@tanstack/react-table"
import { Button } from "@/components/ui/button"
import { cn, decimalString, formatBytes, hourWithSeconds, toFixedFloat } from "@/lib/utils"
import type { PveVmRecord } from "@/types"
import {
ClockIcon,
CpuIcon,
HardDriveIcon,
MemoryStickIcon,
MonitorIcon,
ServerIcon,
TagIcon,
TimerIcon,
} from "lucide-react"
import { EthernetIcon } from "../ui/icons"
import { Badge } from "../ui/badge"
import { t } from "@lingui/core/macro"
import { $allSystemsById } from "@/lib/stores"
import { useStore } from "@nanostores/react"
/** Format uptime in seconds to a human-readable string */
export function formatUptime(seconds: number): string {
if (seconds < 60) return `${seconds}s`
if (seconds < 3600) {
const m = Math.floor(seconds / 60)
return `${m}m`
}
if (seconds < 86400) {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
return m > 0 ? `${h}h ${m}m` : `${h}h`
}
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
return h > 0 ? `${d}d ${h}h` : `${d}d`
}
export const pveVmCols: ColumnDef<PveVmRecord>[] = [
{
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={MonitorIcon} />,
cell: ({ getValue }) => {
return <span className="ms-1 max-w-48 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 max-w-34 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
},
},
{
id: "type",
accessorFn: (record) => record.type,
sortingFn: (a, b) => a.original.type.localeCompare(b.original.type),
header: ({ column }) => <HeaderButton column={column} name={t`Type`} Icon={TagIcon} />,
cell: ({ getValue }) => {
const type = getValue() as string
return (
<Badge variant="outline" className="dark:border-white/12 ms-1">
{type}
</Badge>
)
},
},
{
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 tabular-nums">{`${decimalString(val, val >= 10 ? 1 : 2)}%`}</span>
},
},
{
id: "mem",
accessorFn: (record) => record.mem,
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 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
)
},
},
{
id: "maxmem",
accessorFn: (record) => record.maxmem,
header: ({ column }) => <HeaderButton column={column} name={t`Max`} Icon={MemoryStickIcon} />,
invertSorting: true,
cell: ({ getValue }) => {
// maxmem is stored in bytes; convert to MB for formatBytes
const formatted = formatBytes(getValue() as number, false, undefined, false)
return <span className="ms-1 tabular-nums">{`${toFixedFloat(formatted.value, 2)} ${formatted.unit}`}</span>
},
},
{
id: "disk",
accessorFn: (record) => record.disk,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Disk`} Icon={HardDriveIcon} />,
cell: ({ getValue }) => {
const formatted = formatBytes(getValue() as number, false, undefined, false)
return <span className="ms-1 tabular-nums">{`${toFixedFloat(formatted.value, 2)} ${formatted.unit}`}</span>
},
},
{
id: "diskread",
accessorFn: (record) => record.diskread,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Read`} Icon={HardDriveIcon} />,
cell: ({ getValue }) => {
const val = getValue() as number
const formatted = formatBytes(val, false, undefined, false)
return <span className="ms-1 tabular-nums">{`${toFixedFloat(formatted.value, 2)} ${formatted.unit}`}</span>
},
},
{
id: "diskwrite",
accessorFn: (record) => record.diskwrite,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Write`} Icon={HardDriveIcon} />,
cell: ({ getValue }) => {
const val = getValue() as number
const formatted = formatBytes(val, false, undefined, false)
return <span className="ms-1 tabular-nums">{`${toFixedFloat(formatted.value, 2)} ${formatted.unit}`}</span>
},
},
{
id: "netin",
accessorFn: (record) => record.netin,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Download`} Icon={EthernetIcon} />,
cell: ({ getValue }) => {
const val = getValue() as number
const formatted = formatBytes(val, false, undefined, false)
return (
<span className="ms-1 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
)
},
},
{
id: "netout",
accessorFn: (record) => record.netout,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Upload`} Icon={EthernetIcon} />,
cell: ({ getValue }) => {
const val = getValue() as number
const formatted = formatBytes(val, false, undefined, false)
return (
<span className="ms-1 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
)
},
},
{
id: "maxcpu",
accessorFn: (record) => record.maxcpu,
header: ({ column }) => <HeaderButton column={column} name="vCPUs" Icon={CpuIcon} />,
invertSorting: true,
cell: ({ getValue }) => {
return <span className="ms-1 tabular-nums">{getValue() as number}</span>
},
},
{
id: "uptime",
accessorFn: (record) => record.uptime,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Uptime`} Icon={TimerIcon} />,
cell: ({ getValue }) => {
return <span className="ms-1">{formatUptime(getValue() as number)}</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 tabular-nums">{hourWithSeconds(new Date(timestamp).toISOString())}</span>
},
},
]
function HeaderButton({ column, name, Icon }: { column: Column<PveVmRecord>; 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

@@ -1,391 +0,0 @@
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 { PveVmRecord } from "@/types"
import { pveVmCols, formatUptime } from "@/components/pve-table/pve-table-columns"
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { cn, decimalString, formatBytes, useBrowserStorage } from "@/lib/utils"
import { Sheet, SheetTitle, SheetHeader, SheetContent, SheetDescription } from "../ui/sheet"
import { $allSystemsById } from "@/lib/stores"
import { LoaderCircleIcon, XIcon } from "lucide-react"
import { Separator } from "../ui/separator"
import { $router, Link } from "../router"
import { listenKeys } from "nanostores"
import { getPagePath } from "@nanostores/router"
export default function PveTable({ systemId }: { systemId?: string }) {
const loadTime = Date.now()
const [data, setData] = useState<PveVmRecord[] | undefined>(undefined)
const [sorting, setSorting] = useBrowserStorage<SortingState>(
`sort-pve-${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(() => {
function fetchData(systemId?: string) {
pb.collection<PveVmRecord>("pve_vms")
.getList(0, 2000, {
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
})
.then(({ items }) => {
if (items.length === 0) {
setData((curItems) => {
if (systemId) {
return curItems?.filter((item) => item.system !== systemId) ?? []
}
return []
})
return
}
setData((curItems) => {
const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0)
const vmIds = new Set<string>()
const newItems: PveVmRecord[] = []
for (const item of items) {
if (Math.abs(lastUpdated - item.updated) < 70_000) {
vmIds.add(item.id)
newItems.push(item)
}
}
for (const item of curItems ?? []) {
if (!vmIds.has(item.id) && lastUpdated - item.updated < 70_000) {
newItems.push(item)
}
}
return newItems
})
})
}
// initial load
fetchData(systemId)
// if no systemId, pull pve vms after every system update
if (!systemId) {
return $allSystemsById.listen((_value, _oldValue, systemId) => {
// exclude initial load of systems
if (Date.now() - loadTime > 500) {
fetchData(systemId)
}
})
}
// if systemId, fetch pve vms after the system is updated
return listenKeys($allSystemsById, [systemId], (_newSystems) => {
fetchData(systemId)
})
}, [])
const table = useReactTable({
data: data ?? [],
columns: pveVmCols.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 vm = row.original
const systemName = $allSystemsById.get()[vm.system]?.name ?? ""
const id = vm.id ?? ""
const name = vm.name ?? ""
const type = vm.type ?? ""
const searchString = `${systemName} ${id} ${name} ${type}`.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>Proxmox Resources</Trans>
</CardTitle>
<CardDescription className="flex">
<Trans>CPU is percent of overall host CPU usage.</Trans>
</CardDescription>
</div>
<div className="relative ms-auto w-full max-w-full md:w-64">
<Input
placeholder={t`Filter...`}
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="ps-4 pe-10 w-full"
/>
{globalFilter && (
<button
type="button"
aria-label={t`Clear`}
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 flex items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() => setGlobalFilter("")}
>
<XIcon className="h-4 w-4" />
</button>
)}
</div>
</div>
</CardHeader>
<div className="rounded-md">
<AllPveTable table={table} rows={rows} colLength={visibleColumns.length} data={data} />
</div>
</Card>
)
}
const AllPveTable = memo(function AllPveTable({
table,
rows,
colLength,
data,
}: {
table: TableType<PveVmRecord>
rows: Row<PveVmRecord>[]
colLength: number
data: PveVmRecord[] | undefined
}) {
const scrollRef = useRef<HTMLDivElement>(null)
const activeVm = useRef<PveVmRecord | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const openSheet = (vm: PveVmRecord) => {
activeVm.current = vm
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",
(!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">
<PveTableHead table={table} />
<TableBody>
{rows.length ? (
virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
return <PveTableRow key={row.id} row={row} virtualRow={virtualRow} openSheet={openSheet} />
})
) : (
<TableRow>
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
{data ? (
<Trans>No results.</Trans>
) : (
<LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />
)}
</TableCell>
</TableRow>
)}
</TableBody>
</table>
</div>
<PveVmSheet sheetOpen={sheetOpen} setSheetOpen={setSheetOpen} activeVm={activeVm} />
</div>
)
})
function PveVmSheet({
sheetOpen,
setSheetOpen,
activeVm,
}: {
sheetOpen: boolean
setSheetOpen: (open: boolean) => void
activeVm: RefObject<PveVmRecord | null>
}) {
const vm = activeVm.current
if (!vm) return null
const memFormatted = formatBytes(vm.mem, false, undefined, true)
const maxMemFormatted = formatBytes(vm.maxmem, false, undefined, false)
const netoutFormatted = formatBytes(vm.netout, false, undefined, false)
const netinFormatted = formatBytes(vm.netin, false, undefined, false)
const diskReadFormatted = formatBytes(vm.diskread, false, undefined, false)
const diskWriteFormatted = formatBytes(vm.diskwrite, false, undefined, false)
const diskFormatted = formatBytes(vm.disk, false, undefined, false)
return (
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent className="w-full sm:max-w-120 p-2">
<SheetHeader>
<SheetTitle>{vm.name}</SheetTitle>
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Link className="hover:underline" href={getPagePath($router, "system", { id: vm.system })}>
{$allSystemsById.get()[vm.system]?.name ?? ""}
</Link>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
{vm.type}
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<Trans>Up {formatUptime(vm.uptime)}</Trans>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
{vm.id}
</SheetDescription>
</SheetHeader>
<div className="px-3 pb-3 -mt-2 flex flex-col gap-3">
<h3 className="text-sm font-medium">
<Trans>Details</Trans>
</h3>
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt className="text-muted-foreground">
<Trans>CPU Usage</Trans>
</dt>
<dd className="tabular-nums">{`${decimalString(vm.cpu, vm.cpu >= 10 ? 1 : 2)}%`}</dd>
<dt className="text-muted-foreground">
<Trans>Memory Used</Trans>
</dt>
<dd className="tabular-nums">{`${decimalString(memFormatted.value, memFormatted.value >= 10 ? 1 : 2)} ${memFormatted.unit}`}</dd>
<dt className="text-muted-foreground">
<Trans>Upload</Trans>
</dt>
<dd className="tabular-nums">{`${decimalString(netoutFormatted.value, netoutFormatted.value >= 10 ? 1 : 2)} ${netoutFormatted.unit}`}</dd>
<dt className="text-muted-foreground">
<Trans>Download</Trans>
</dt>
<dd className="tabular-nums">{`${decimalString(netinFormatted.value, netinFormatted.value >= 10 ? 1 : 2)} ${netinFormatted.unit}`}</dd>
<dt className="text-muted-foreground">
<Trans>vCPUs</Trans>
</dt>
<dd className="tabular-nums">{vm.maxcpu}</dd>
<dt className="text-muted-foreground">
<Trans>Max Memory</Trans>
</dt>
<dd className="tabular-nums">{`${decimalString(maxMemFormatted.value, maxMemFormatted.value >= 10 ? 1 : 2)} ${maxMemFormatted.unit}`}</dd>
<dt className="text-muted-foreground">
<Trans>Disk Read</Trans>
</dt>
<dd className="tabular-nums">{`${decimalString(diskReadFormatted.value, diskReadFormatted.value >= 10 ? 1 : 2)} ${diskReadFormatted.unit}`}</dd>
<dt className="text-muted-foreground">
<Trans>Disk Write</Trans>
</dt>
<dd className="tabular-nums">{`${decimalString(diskWriteFormatted.value, diskWriteFormatted.value >= 10 ? 1 : 2)} ${diskWriteFormatted.unit}`}</dd>
<dt className="text-muted-foreground">
<Trans>Disk Size</Trans>
</dt>
<dd className="tabular-nums">{`${decimalString(diskFormatted.value, diskFormatted.value >= 10 ? 1 : 2)} ${diskFormatted.unit}`}</dd>
<dt className="text-muted-foreground">
<Trans>Uptime</Trans>
</dt>
<dd className="tabular-nums">{formatUptime(vm.uptime)}</dd>
</dl>
</div>
</SheetContent>
</Sheet>
)
}
function PveTableHead({ table }: { table: TableType<PveVmRecord> }) {
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 PveTableRow = memo(function PveTableRow({
row,
virtualRow,
openSheet,
}: {
row: Row<PveVmRecord>
virtualRow: VirtualItem
openSheet: (vm: PveVmRecord) => 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 ps-4.5"
style={{
height: virtualRow.size,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
})

View File

@@ -3,7 +3,6 @@ import { createRouter } from "@nanostores/router"
const routes = {
home: "/",
containers: "/containers",
proxmox: "/proxmox",
smart: "/smart",
system: `/system/:id`,
settings: `/settings/:name?`,

View File

@@ -1,26 +0,0 @@
import { useLingui } from "@lingui/react/macro"
import { memo, useEffect, useMemo } from "react"
import PveTable from "@/components/pve-table/pve-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 Proxmox VMs`} / Beszel`
}, [t])
return useMemo(
() => (
<>
<div className="grid gap-4">
<ActiveAlerts />
<PveTable />
</div>
<FooterRepoLink />
</>
),
[]
)
})

View File

@@ -12,7 +12,7 @@ import Slider from "@/components/ui/slider"
import { HourFormat, Unit } from "@/lib/enums"
import { dynamicActivate } from "@/lib/i18n"
import languages from "@/lib/languages"
import { $userSettings } from "@/lib/stores"
import { $userSettings, defaultLayoutWidth } from "@/lib/stores"
import { chartTimeData, currentHour12 } from "@/lib/utils"
import type { UserSettings } from "@/types"
import { saveSettings } from "./layout"
@@ -21,7 +21,7 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
const [isLoading, setIsLoading] = useState(false)
const { i18n } = useLingui()
const currentUserSettings = useStore($userSettings)
const layoutWidth = currentUserSettings.layoutWidth ?? 1500
const layoutWidth = currentUserSettings.layoutWidth ?? defaultLayoutWidth
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()

View File

@@ -134,10 +134,10 @@ export function QuietHours() {
const startMinutes = startDate.getUTCHours() * 60 + startDate.getUTCMinutes()
const endMinutes = endDate.getUTCHours() * 60 + endDate.getUTCMinutes()
// Convert UTC to local time offset
const offset = now.getTimezoneOffset()
const localStartMinutes = (startMinutes - offset + 1440) % 1440
const localEndMinutes = (endMinutes - offset + 1440) % 1440
// Convert UTC to local time using the stored date's offset, not the current date's offset
// This avoids DST mismatch when records were saved in a different DST period
const localStartMinutes = (startMinutes - startDate.getTimezoneOffset() + 1440) % 1440
const localEndMinutes = (endMinutes - endDate.getTimezoneOffset() + 1440) % 1440
// Handle cases where window spans midnight
if (localStartMinutes <= localEndMinutes) {
@@ -347,12 +347,13 @@ function QuietHoursDialog({
if (windowType === "daily") {
// For daily windows, convert local time to UTC
// Create a date with the time in local timezone, then convert to UTC
const startDate = new Date(`2000-01-01T${startTime}:00`)
// Use today's date so the current DST offset is applied (not a fixed historical date)
const today = new Date().toISOString().split("T")[0]
const startDate = new Date(`${today}T${startTime}:00`)
startValue = startDate.toISOString()
if (endTime) {
const endDate = new Date(`2000-01-01T${endTime}:00`)
const endDate = new Date(`${today}T${endTime}:00`)
endValue = endDate.toISOString()
}
} else {

View File

@@ -25,7 +25,6 @@ import {
$containerFilter,
$direction,
$maxValues,
$pveFilter,
$systems,
$temperatureFilter,
$userSettings,
@@ -161,7 +160,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
const [system, setSystem] = useState({} as SystemRecord)
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
const [containerData, setContainerData] = useState([] as ChartData["containerData"])
const [pveData, setPveData] = useState([] as ChartData["containerData"])
const temperatureChartRef = useRef<HTMLDivElement>(null)
const persistChartTime = useRef(false)
const [bottomSpacing, setBottomSpacing] = useState(0)
@@ -179,10 +177,8 @@ export default memo(function SystemDetail({ id }: { id: string }) {
persistChartTime.current = false
setSystemStats([])
setContainerData([])
setPveData([])
setDetails({} as SystemDetailsRecord)
$containerFilter.set("")
$pveFilter.set("")
}
}, [id])
@@ -281,10 +277,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
// Share chart config computation for all container charts
const containerChartConfigs = useContainerChartConfigs(containerData)
// PVE chart data and configs
const pveSyntheticChartData = useMemo(() => ({ ...chartData, containerData: pveData }), [chartData, pveData])
const pveChartConfigs = useContainerChartConfigs(pveData)
// make container stats for charts
const makeContainerData = useCallback((containers: ContainerStatsRecord[]) => {
const containerData = [] as ChartData["containerData"]
@@ -315,8 +307,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
Promise.allSettled([
getStats<SystemStatsRecord>("system_stats", system, chartTime),
getStats<ContainerStatsRecord>("container_stats", system, chartTime),
getStats<ContainerStatsRecord>("pve_stats", system, chartTime),
]).then(([systemStats, containerStats, pveStats]) => {
]).then(([systemStats, containerStats]) => {
// loading: false
setChartLoading(false)
@@ -343,17 +334,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
cache.set(cs_cache_key, containerData)
}
setContainerData(makeContainerData(containerData))
// make new pve stats
const ps_cache_key = `${system.id}_${chartTime}_pve_stats`
let pveRecords = (cache.get(ps_cache_key) || []) as ContainerStatsRecord[]
if (pveStats.status === "fulfilled" && pveStats.value.length) {
pveRecords = pveRecords.concat(addEmptyValues(pveRecords, pveStats.value, expectedInterval))
if (pveRecords.length > 120) {
pveRecords = pveRecords.slice(-100)
}
cache.set(ps_cache_key, pveRecords)
}
setPveData(makeContainerData(pveRecords))
})
}, [system, chartTime])
@@ -419,7 +399,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
const showMax = maxValues && isLongerChart
const containerFilterBar = containerData.length ? <FilterBar /> : null
const pveFilterBar = pveData.length ? <FilterBar store={$pveFilter} /> : null
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
const lastGpus = systemStats.at(-1)?.stats?.g
@@ -514,24 +493,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
</ChartCard>
)}
{pveFilterBar && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Proxmox CPU Usage`}
description={t`Average CPU utilization of VMs and containers`}
cornerEl={pveFilterBar}
>
<ContainerChart
chartData={pveSyntheticChartData}
dataKey="c"
chartType={ChartType.CPU}
chartConfig={pveChartConfigs.cpu}
filterStore={$pveFilter}
/>
</ChartCard>
)}
<ChartCard
empty={dataEmpty}
grid={grid}
@@ -559,24 +520,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
</ChartCard>
)}
{pveFilterBar && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Proxmox Memory Usage`}
description={t`Memory usage of Proxmox VMs and containers`}
cornerEl={pveFilterBar}
>
<ContainerChart
chartData={pveSyntheticChartData}
dataKey="m"
chartType={ChartType.Memory}
chartConfig={pveChartConfigs.memory}
filterStore={$pveFilter}
/>
</ChartCard>
)}
<ChartCard empty={dataEmpty} grid={grid} title={t`Disk Usage`} description={t`Usage of root partition`}>
<DiskChart chartData={chartData} dataKey="stats.du" diskSize={systemStats.at(-1)?.stats.d ?? NaN} />
</ChartCard>
@@ -698,24 +641,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
</ChartCard>
)}
{pveFilterBar && pveData.length > 0 && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Proxmox Network I/O`}
description={t`Network traffic of Proxmox VMs and containers`}
cornerEl={pveFilterBar}
>
<ContainerChart
chartData={pveSyntheticChartData}
chartType={ChartType.Network}
dataKey="n"
chartConfig={pveChartConfigs.network}
filterStore={$pveFilter}
/>
</ChartCard>
)}
{/* Swap chart */}
{(systemStats.at(-1)?.stats.su ?? 0) > 0 && (
<ChartCard
@@ -729,7 +654,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
)}
{/* Load Average chart */}
{chartData.agentVersion?.minor >= 12 && (
{chartData.agentVersion?.minor > 12 && (
<ChartCard
empty={dataEmpty}
grid={grid}
@@ -952,8 +877,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<LazyContainersTable systemId={system.id} />
)}
{pveData.length > 0 && <LazyPveTable systemId={system.id} />}
{isLinux && compareSemVer(chartData.agentVersion, parseSemVer("0.16.0")) >= 0 && (
<LazySystemdTable systemId={system.id} />
)}
@@ -1128,17 +1051,6 @@ function LazyContainersTable({ systemId }: { systemId: string }) {
)
}
const PveTable = lazy(() => import("../pve-table/pve-table"))
function LazyPveTable({ systemId }: { systemId: string }) {
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })
return (
<div ref={ref} className={cn(isIntersecting && "contents")}>
{isIntersecting && <PveTable systemId={systemId} />}
</div>
)
}
const SmartTable = lazy(() => import("./system/smart-table"))
function LazySmartTable({ systemId }: { systemId: string }) {

View File

@@ -3,13 +3,16 @@ import {
type ColumnDef,
type ColumnFiltersState,
type Column,
type Row,
type SortingState,
type Table as TableType,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
import {
Activity,
Box,
@@ -40,6 +43,7 @@ import {
toFixedFloat,
formatTemperature,
cn,
getVisualStringWidth,
secondsToString,
hourWithSeconds,
formatShortDate,
@@ -57,7 +61,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useCallback, useMemo, useEffect, useState } from "react"
import { memo, useCallback, useMemo, useEffect, useRef, useState } from "react"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
// Column definition for S.M.A.R.T. attributes table
@@ -101,7 +105,11 @@ function formatCapacity(bytes: number): string {
const SMART_DEVICE_FIELDS = "id,system,name,model,state,capacity,temp,type,hours,cycles,updated"
export const columns: ColumnDef<SmartDeviceRecord>[] = [
export const createColumns = (
longestName: number,
longestModel: number,
longestDevice: number
): ColumnDef<SmartDeviceRecord>[] => [
{
id: "system",
accessorFn: (record) => record.system,
@@ -114,7 +122,11 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
cell: ({ getValue }) => {
const allSystems = useStore($allSystemsById)
return <span className="ms-1.5 xl:w-30 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
return (
<div className="ms-1.5 max-w-40 block truncate" style={{ width: `${longestName / 1.05}ch` }}>
{allSystems[getValue() as string]?.name ?? ""}
</div>
)
},
},
{
@@ -122,7 +134,11 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
header: ({ column }) => <HeaderButton column={column} name={t`Device`} Icon={HardDrive} />,
cell: ({ getValue }) => (
<div className="font-medium max-w-40 truncate ms-1.5" title={getValue() as string}>
<div
className="font-medium max-w-40 truncate ms-1"
title={getValue() as string}
style={{ width: `${longestDevice / 1.05}ch` }}
>
{getValue() as string}
</div>
),
@@ -132,7 +148,11 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
sortingFn: (a, b) => a.original.model.localeCompare(b.original.model),
header: ({ column }) => <HeaderButton column={column} name={t`Model`} Icon={Box} />,
cell: ({ getValue }) => (
<div className="max-w-48 truncate ms-1.5" title={getValue() as string}>
<div
className="max-w-48 truncate ms-1"
title={getValue() as string}
style={{ width: `${longestModel / 1.05}ch` }}
>
{getValue() as string}
</div>
),
@@ -141,7 +161,7 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
accessorKey: "capacity",
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Capacity`} Icon={BinaryIcon} />,
cell: ({ getValue }) => <span className="ms-1.5">{formatCapacity(getValue() as number)}</span>,
cell: ({ getValue }) => <span className="ms-1">{formatCapacity(getValue() as number)}</span>,
},
{
accessorKey: "state",
@@ -149,9 +169,9 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
cell: ({ getValue }) => {
const status = getValue() as string
return (
<div className="ms-1.5">
<Badge variant={status === "PASSED" ? "success" : status === "FAILED" ? "danger" : "warning"}>{status}</Badge>
</div>
<Badge className="ms-1" variant={status === "PASSED" ? "success" : status === "FAILED" ? "danger" : "warning"}>
{status}
</Badge>
)
},
},
@@ -160,11 +180,9 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
sortingFn: (a, b) => a.original.type.localeCompare(b.original.type),
header: ({ column }) => <HeaderButton column={column} name={t`Type`} Icon={ArrowLeftRightIcon} />,
cell: ({ getValue }) => (
<div className="ms-1.5">
<Badge variant="outline" className="uppercase">
{getValue() as string}
</Badge>
</div>
<Badge variant="outline" className="ms-1 uppercase">
{getValue() as string}
</Badge>
),
},
{
@@ -176,11 +194,11 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
cell: ({ getValue }) => {
const hours = getValue() as number | undefined
if (hours == null) {
return <div className="text-sm text-muted-foreground ms-1.5">N/A</div>
return <div className="text-sm text-muted-foreground ms-1">N/A</div>
}
const seconds = hours * 3600
return (
<div className="text-sm ms-1.5">
<div className="text-sm ms-1">
<div>{secondsToString(seconds, "hour")}</div>
<div className="text-muted-foreground text-xs">{secondsToString(seconds, "day")}</div>
</div>
@@ -196,9 +214,9 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
cell: ({ getValue }) => {
const cycles = getValue() as number | undefined
if (cycles == null) {
return <div className="text-muted-foreground ms-1.5">N/A</div>
return <div className="text-muted-foreground ms-1">N/A</div>
}
return <span className="ms-1.5">{cycles.toLocaleString()}</span>
return <span className="ms-1">{cycles.toLocaleString()}</span>
},
},
{
@@ -208,10 +226,10 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
cell: ({ getValue }) => {
const temp = getValue() as number | null | undefined
if (!temp) {
return <div className="text-muted-foreground ms-1.5">N/A</div>
return <div className="text-muted-foreground ms-1">N/A</div>
}
const { value, unit } = formatTemperature(temp)
return <span className="ms-1.5">{`${value} ${unit}`}</span>
return <span className="ms-1">{`${value} ${unit}`}</span>
},
},
// {
@@ -236,7 +254,7 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
// if today, use hourWithSeconds, otherwise use formatShortDate
const formatter =
new Date(timestamp).toDateString() === new Date().toDateString() ? hourWithSeconds : formatShortDate
return <span className="ms-1.5 tabular-nums">{formatter(timestamp)}</span>
return <span className="ms-1 tabular-nums">{formatter(timestamp)}</span>
},
},
]
@@ -275,6 +293,36 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
const [sheetOpen, setSheetOpen] = useState(false)
const [rowActionState, setRowActionState] = useState<{ type: "refresh" | "delete"; id: string } | null>(null)
const [globalFilter, setGlobalFilter] = useState("")
const allSystems = useStore($allSystemsById)
// duplicate the devices to test with more rows
// if (
// smartDevices?.length &&
// smartDevices.length < 50 &&
// typeof window !== "undefined" &&
// window.location.hostname === "localhost"
// ) {
// setSmartDevices([...smartDevices, ...smartDevices, ...smartDevices])
// }
// Calculate the right width for the columns based on the longest strings among the displayed devices
const { longestName, longestModel, longestDevice } = useMemo(() => {
const result = { longestName: 0, longestModel: 0, longestDevice: 0 }
if (!smartDevices || Object.keys(allSystems).length === 0) {
return result
}
const seenSystems = new Set<string>()
for (const device of smartDevices) {
if (!systemId && !seenSystems.has(device.system)) {
seenSystems.add(device.system)
const name = allSystems[device.system]?.name ?? ""
result.longestName = Math.max(result.longestName, getVisualStringWidth(name))
}
result.longestModel = Math.max(result.longestModel, getVisualStringWidth(device.model ?? ""))
result.longestDevice = Math.max(result.longestDevice, getVisualStringWidth(device.name ?? ""))
}
return result
}, [smartDevices, systemId, allSystems])
const openSheet = (disk: SmartDeviceRecord) => {
setActiveDiskId(disk.id)
@@ -440,9 +488,10 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
// Filter columns based on whether systemId is provided
const tableColumns = useMemo(() => {
const columns = createColumns(longestName, longestModel, longestDevice)
const baseColumns = systemId ? columns.filter((col) => col.id !== "system") : columns
return [...baseColumns, actionColumn]
}, [systemId, actionColumn])
}, [systemId, actionColumn, longestName, longestModel, longestDevice])
const table = useReactTable({
data: smartDevices || ([] as SmartDeviceRecord[]),
@@ -474,6 +523,7 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
.every((term) => searchString.includes(term))
},
})
const rows = table.getRowModel().rows
// Hide the table on system pages if there's no data, but always show on global page
if (systemId && !smartDevices?.length && !columnFilters.length) {
@@ -513,57 +563,123 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
</div>
</div>
</CardHeader>
<div className="rounded-md border text-nowrap">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} className="px-2">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer"
onClick={() => openSheet(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="md:ps-5">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={tableColumns.length} className="h-24 text-center">
{smartDevices ? (
t`No results.`
) : (
<LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<SmartDevicesTable
table={table}
rows={rows}
colLength={tableColumns.length}
data={smartDevices}
openSheet={openSheet}
/>
</Card>
<DiskSheet diskId={activeDiskId} open={sheetOpen} onOpenChange={setSheetOpen} />
</div>
)
}
const SmartDevicesTable = memo(function SmartDevicesTable({
table,
rows,
colLength,
data,
openSheet,
}: {
table: TableType<SmartDeviceRecord>
rows: Row<SmartDeviceRecord>[]
colLength: number
data: SmartDeviceRecord[] | undefined
openSheet: (disk: SmartDeviceRecord) => void
}) {
const scrollRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
count: rows.length,
estimateSize: () => 65,
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 rounded-md border",
(!rows.length || rows.length > 2) && "min-h-50"
)}
ref={scrollRef}
>
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
<table className="w-full text-sm text-nowrap">
<SmartTableHead table={table} />
<TableBody>
{rows.length ? (
virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
return <SmartDeviceTableRow key={row.id} row={row} virtualRow={virtualRow} openSheet={openSheet} />
})
) : (
<TableRow>
<TableCell colSpan={colLength} className="h-24 text-center pointer-events-none">
{data ? t`No results.` : <LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />}
</TableCell>
</TableRow>
)}
</TableBody>
</table>
</div>
</div>
)
})
function SmartTableHead({ table }: { table: TableType<SmartDeviceRecord> }) {
return (
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
<div className="absolute -top-2 left-0 w-full h-4 bg-table-header z-50"></div>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="px-2">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
)
}
const SmartDeviceTableRow = memo(function SmartDeviceTableRow({
row,
virtualRow,
openSheet,
}: {
row: Row<SmartDeviceRecord>
virtualRow: VirtualItem
openSheet: (disk: SmartDeviceRecord) => void
}) {
return (
<TableRow
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer"
onClick={() => openSheet(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="md:ps-5 py-0"
style={{
height: virtualRow.size,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
})
function DiskSheet({
diskId,
open,

View File

@@ -46,7 +46,6 @@ export default function SystemdTable({ systemId }: { systemId?: string }) {
return setData([])
}, [systemId])
useEffect(() => {
const lastUpdated = data[0]?.updated ?? 0
@@ -360,15 +359,9 @@ function SystemdSheet({
return (
<>
{hasCurrent ? current : notAvailable}
{hasMax && (
<span className="text-muted-foreground ms-1.5">
{`(${t`limit`}: ${max})`}
</span>
)}
{hasMax && <span className="text-muted-foreground ms-1.5">{`(${t`limit`}: ${max})`}</span>}
{max === null && (
<span className="text-muted-foreground ms-1.5">
{`(${t`limit`}: ${t`Unlimited`.toLowerCase()})`}
</span>
<span className="text-muted-foreground ms-1.5">{`(${t`limit`}: ${t`Unlimited`.toLowerCase()})`}</span>
)}
</>
)
@@ -435,7 +428,7 @@ function SystemdSheet({
</tr>
)
}
const capitalize = (str: string) => `${str.charAt(0).toUpperCase()}${str.slice(1).toLowerCase()}`
return (
@@ -621,6 +614,7 @@ function SystemdSheet({
function SystemdTableHead({ table }: { table: TableType<SystemdRecord> }) {
return (
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
<div className="absolute -top-2 left-0 w-full h-4 bg-table-header z-50"></div>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {

View File

@@ -184,7 +184,8 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
accessorFn: ({ info }) => info.dp || undefined,
id: "disk",
name: () => t`Disk`,
cell: DiskCellWithMultiple,
cell: (info: CellContext<SystemRecord, unknown>) =>
info.row.original.info.efs ? DiskCellWithMultiple(info) : TableCellWithMeter(info),
Icon: HardDriveIcon,
header: sortableHeader,
},
@@ -198,32 +199,19 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
},
{
id: "loadAverage",
accessorFn: ({ info }) => {
const sum = info.la?.reduce((acc, curr) => acc + curr, 0)
// TODO: remove this in future release in favor of la array
if (!sum) {
return (info.l1 ?? 0) + (info.l5 ?? 0) + (info.l15 ?? 0) || undefined
}
return sum || undefined
},
accessorFn: ({ info }) => info.la?.reduce((acc, curr) => acc + curr, 0),
name: () => t({ message: "Load Avg", comment: "Short label for load average" }),
size: 0,
Icon: HourglassIcon,
header: sortableHeader,
cell(info: CellContext<SystemRecord, unknown>) {
const { info: sysInfo, status } = info.row.original
const { major, minor } = parseSemVer(sysInfo.v)
const { colorWarn = 65, colorCrit = 90 } = useStore($userSettings, { keys: ["colorWarn", "colorCrit"] })
// agent version
const { minor, patch } = parseSemVer(sysInfo.v)
let loadAverages = sysInfo.la
// use legacy load averages if agent version is less than 12.1.0
if (!loadAverages || (minor === 12 && patch < 1)) {
loadAverages = [sysInfo.l1 ?? 0, sysInfo.l5 ?? 0, sysInfo.l15 ?? 0]
}
const loadAverages = sysInfo.la || []
const max = Math.max(...loadAverages)
if (max === 0 && (status === SystemStatus.Paused || minor < 12)) {
if (max === 0 && (status === SystemStatus.Paused || (major < 1 && minor < 13))) {
return null
}
@@ -248,19 +236,20 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
},
},
{
accessorFn: ({ info }) => info.bb || (info.b || 0) * 1024 * 1024 || undefined,
accessorFn: ({ info, status }) => (status !== SystemStatus.Up ? undefined : info.bb),
id: "net",
name: () => t`Net`,
size: 0,
Icon: EthernetIcon,
header: sortableHeader,
sortUndefined: "last",
cell(info) {
const sys = info.row.original
const userSettings = useStore($userSettings, { keys: ["unitNet"] })
if (sys.status === SystemStatus.Paused) {
const val = info.getValue() as number | undefined
if (val === undefined) {
return null
}
const { value, unit } = formatBytes((info.getValue() || 0) as number, true, userSettings.unitNet, false)
const userSettings = useStore($userSettings, { keys: ["unitNet"] })
const { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
return (
<span className="tabular-nums whitespace-nowrap">
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
@@ -491,11 +480,6 @@ function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
const { colorWarn = 65, colorCrit = 90 } = useStore($userSettings, { keys: ["colorWarn", "colorCrit"] })
const { info: sysInfo, status, id } = info.row.original
const extraFs = Object.entries(sysInfo.efs ?? {})
if (extraFs.length === 0) {
return TableCellWithMeter(info)
}
const rootDiskPct = sysInfo.dp
// sort extra disks by percentage descending

View File

@@ -391,6 +391,7 @@ function SystemsTableHead({ table }: { table: TableType<SystemRecord> }) {
const { t } = useLingui()
return (
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
<div className="absolute -top-2 left-0 w-full h-4 bg-table-header z-50"></div>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {

View File

@@ -186,11 +186,13 @@ export function PlugChargingIcon(props: SVGProps<SVGSVGElement>) {
)
}
// simple-icons (CC0) https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md
export function ProxmoxIcon(props: SVGProps<SVGSVGElement>) {
// Lucide Icons (ISC) - used for ports
export function SquareArrowRightEnterIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" {...props} stroke="currentColor" strokeWidth="1.5" fill="none">
<path d="M5 1.8c-1.2.6-1.2.7-.1 1.8l7 7.8c.2 0 8-8.6 8.1-8.8l-.5-.5q-.5-.5-1.7-.5c-1.6-.1-2.2.2-4.1 2.4L12 6 10.4 4 8 1.9c-.8-.4-2.4-.5-3.2 0M1.2 4.4q-1.2.5-1.3.8l3 3.5L5.8 12l-3 3.3L0 18.8c.1.5 1.5 1 2.6 1 1.7 0 2-.2 5.6-4.1l3.2-3.7a74 74 0 0 0-7.1-7.5c-.9-.4-2.2-.5-3-.1m18.5 0q-.7.4-4 4L12.6 12l3.3 3.7c3.5 3.9 3.9 4.2 5.6 4.2 1 0 2.4-.6 2.5-1 0-.2-1.3-1.8-2.9-3.6L18 12l3-3.3c1.6-1.8 3-3.3 2.9-3.5 0-.4-1.4-1-2.5-1q-1 0-1.7.3M8 17l-4 4.4.5.6q.6.4 1.7.4c1.6.1 2.2-.2 4.2-2.5l1.6-1.8 1.7 1.8c2 2.3 2.5 2.6 4 2.5q1.3 0 1.8-.4t.5-.6c0-.2-7.9-8.8-8-8.8z" />
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" {...props}>
<path d="m10 16 4-4-4-4" />
<path d="M3 12h11" />
<path d="M3 8V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-3" />
</svg>
)
}

View File

@@ -1,8 +1,11 @@
import { atom, computed, listenKeys, map, type ReadableAtom } from "nanostores"
import type { AlertMap, ChartTimes, SystemRecord, UserSettings } from "@/types"
import type { AlertMap, ChartTimes, SystemRecord, UpdateInfo, UserSettings } from "@/types"
import { pb } from "./api"
import { Unit } from "./enums"
/** Default layout width. Used as fallback when user setting is unset. */
export const defaultLayoutWidth = 1580
/** Store if user is authenticated */
export const $authenticated = atom(pb.authStore.isValid)
@@ -25,6 +28,9 @@ export const $alerts = map<AlertMap>({})
/** SSH public key */
export const $publicKey = atom("")
/** New version info if an update is available, otherwise undefined */
export const $newVersion = atom<UpdateInfo | undefined>()
/** Chart time period */
export const $chartTime = atom<ChartTimes>("1h")
@@ -55,9 +61,6 @@ listenKeys($userSettings, ["chartTime"], ({ chartTime }) => $chartTime.set(chart
/** Container chart filter */
export const $containerFilter = atom("")
/** PVE chart filter */
export const $pveFilter = atom("")
/** Temperature chart filter */
export const $temperatureFilter = atom("")

View File

@@ -12,15 +12,23 @@ import Settings from "@/components/routes/settings/layout.tsx"
import { ThemeProvider } from "@/components/theme-provider.tsx"
import { Toaster } from "@/components/ui/toaster.tsx"
import { alertManager } from "@/lib/alerts"
import { pb, updateUserSettings } from "@/lib/api.ts"
import { isAdmin, pb, updateUserSettings } from "@/lib/api.ts"
import { dynamicActivate, getLocale } from "@/lib/i18n"
import { $authenticated, $copyContent, $direction, $publicKey, $userSettings } from "@/lib/stores.ts"
import {
$authenticated,
$copyContent,
$direction,
$newVersion,
$publicKey,
$userSettings,
defaultLayoutWidth,
} from "@/lib/stores.ts"
import * as systemsManager from "@/lib/systemsManager.ts"
import type { BeszelInfo, UpdateInfo } from "./types"
const LoginPage = lazy(() => import("@/components/login/login.tsx"))
const Home = lazy(() => import("@/components/routes/home.tsx"))
const Containers = lazy(() => import("@/components/routes/containers.tsx"))
const Proxmox = lazy(() => import("@/components/routes/proxmox.tsx"))
const Smart = lazy(() => import("@/components/routes/smart.tsx"))
const SystemDetail = lazy(() => import("@/components/routes/system.tsx"))
const CopyToClipboardDialog = lazy(() => import("@/components/copy-to-clipboard.tsx"))
@@ -33,9 +41,13 @@ const App = memo(() => {
pb.authStore.onChange(() => {
$authenticated.set(pb.authStore.isValid)
})
// get version / public key
pb.send("/api/beszel/getkey", {}).then((data) => {
// get general info for authenticated users, such as public key and version
pb.send<BeszelInfo>("/api/beszel/info", {}).then((data) => {
$publicKey.set(data.key)
// check for updates if enabled
if (data.cu && isAdmin()) {
pb.send<UpdateInfo>("/api/beszel/update", {}).then($newVersion.set)
}
})
// get user settings
updateUserSettings()
@@ -64,8 +76,6 @@ const App = memo(() => {
return <SystemDetail id={page.params.id} />
} else if (page.route === "containers") {
return <Containers />
} else if (page.route === "proxmox") {
return <Proxmox />
} else if (page.route === "smart") {
return <Smart />
} else if (page.route === "settings") {
@@ -103,7 +113,7 @@ const Layout = () => {
<LoginPage />
</Suspense>
) : (
<div style={{ "--container": `${userSettings.layoutWidth ?? 1580}px` } as React.CSSProperties}>
<div style={{ "--container": `${userSettings.layoutWidth ?? defaultLayoutWidth}px` } as React.CSSProperties}>
<div className="container">
<Navbar />
</div>

View File

@@ -45,12 +45,6 @@ export interface SystemInfo {
c: number
/** cpu model */
m: string
/** load average 1 minute */
l1?: number
/** load average 5 minutes */
l5?: number
/** load average 15 minutes */
l15?: number
/** load average */
la?: [number, number, number]
/** operating system */
@@ -94,13 +88,6 @@ export interface SystemStats {
cpub?: number[]
/** per-core cpu usage [CPU0..] (0-100 integers) */
cpus?: number[]
// TODO: remove these in future release in favor of la
/** load average 1 minute */
l1?: number
/** load average 5 minutes */
l5?: number
/** load average 15 minutes */
l15?: number
/** load average */
la?: [number, number, number]
/** total memory (gb) */
@@ -267,6 +254,7 @@ export interface ContainerRecord extends RecordModel {
system: string
name: string
image: string
ports: string
cpu: number
memory: number
net: number
@@ -275,36 +263,6 @@ export interface ContainerRecord extends RecordModel {
updated: number
}
export interface PveVmRecord extends RecordModel {
id: string
system: string
name: string
/** "qemu" or "lxc" */
type: string
/** CPU usage percent (0100, relative to host) */
cpu: number
/** Memory used (MB) */
mem: number
/** Total upload (bytes, sent by VM) */
netout: number
/** Total download (bytes, received by VM) */
netin: number
/** Max vCPU count */
maxcpu: number
/** Max memory (bytes) */
maxmem: number
/** Uptime (seconds) */
uptime: number
/** Cumulative disk read (bytes) */
diskread: number
/** Cumulative disk write (bytes) */
diskwrite: number
/** Allocated disk size (bytes) */
disk: number
/** Unix timestamp (ms) */
updated: number
}
export type ChartTimes = "1m" | "1h" | "12h" | "24h" | "1w" | "30d"
export interface ChartTimeData {
@@ -455,116 +413,127 @@ export interface SystemdRecord extends RecordModel {
}
export interface SystemdServiceDetails {
AccessSELinuxContext: string
ActivationDetails: any[]
ActiveEnterTimestamp: number
ActiveEnterTimestampMonotonic: number
ActiveExitTimestamp: number
ActiveExitTimestampMonotonic: number
ActiveState: string
After: string[]
AllowIsolate: boolean
AssertResult: boolean
AssertTimestamp: number
AssertTimestampMonotonic: number
Asserts: any[]
Before: string[]
BindsTo: any[]
BoundBy: any[]
CPUUsageNSec: number
CanClean: any[]
CanFreeze: boolean
CanIsolate: boolean
CanLiveMount: boolean
CanReload: boolean
CanStart: boolean
CanStop: boolean
CollectMode: string
ConditionResult: boolean
ConditionTimestamp: number
ConditionTimestampMonotonic: number
Conditions: any[]
ConflictedBy: any[]
Conflicts: string[]
ConsistsOf: any[]
DebugInvocation: boolean
DefaultDependencies: boolean
Description: string
Documentation: string[]
DropInPaths: any[]
ExecMainPID: number
FailureAction: string
FailureActionExitStatus: number
Following: string
FragmentPath: string
FreezerState: string
Id: string
IgnoreOnIsolate: boolean
InactiveEnterTimestamp: number
InactiveEnterTimestampMonotonic: number
InactiveExitTimestamp: number
InactiveExitTimestampMonotonic: number
InvocationID: string
Job: Array<number | string>
JobRunningTimeoutUSec: number
JobTimeoutAction: string
JobTimeoutRebootArgument: string
JobTimeoutUSec: number
JoinsNamespaceOf: any[]
LoadError: string[]
LoadState: string
MainPID: number
Markers: any[]
MemoryCurrent: number
MemoryLimit: number
MemoryPeak: number
NRestarts: number
Names: string[]
NeedDaemonReload: boolean
OnFailure: any[]
OnFailureJobMode: string
OnFailureOf: any[]
OnSuccess: any[]
OnSuccessJobMode: string
OnSuccessOf: any[]
PartOf: any[]
Perpetual: boolean
PropagatesReloadTo: any[]
PropagatesStopTo: any[]
RebootArgument: string
Refs: any[]
RefuseManualStart: boolean
RefuseManualStop: boolean
ReloadPropagatedFrom: any[]
RequiredBy: any[]
Requires: string[]
RequiresMountsFor: any[]
Requisite: any[]
RequisiteOf: any[]
Result: string
SliceOf: any[]
SourcePath: string
StartLimitAction: string
StartLimitBurst: number
StartLimitIntervalUSec: number
StateChangeTimestamp: number
StateChangeTimestampMonotonic: number
StopPropagatedFrom: any[]
StopWhenUnneeded: boolean
SubState: string
SuccessAction: string
SuccessActionExitStatus: number
SurviveFinalKillSignal: boolean
TasksCurrent: number
TasksMax: number
Transient: boolean
TriggeredBy: string[]
Triggers: any[]
UnitFilePreset: string
UnitFileState: string
UpheldBy: any[]
Upholds: any[]
WantedBy: any[]
Wants: string[]
WantsMountsFor: any[]
AccessSELinuxContext: string;
ActivationDetails: any[];
ActiveEnterTimestamp: number;
ActiveEnterTimestampMonotonic: number;
ActiveExitTimestamp: number;
ActiveExitTimestampMonotonic: number;
ActiveState: string;
After: string[];
AllowIsolate: boolean;
AssertResult: boolean;
AssertTimestamp: number;
AssertTimestampMonotonic: number;
Asserts: any[];
Before: string[];
BindsTo: any[];
BoundBy: any[];
CPUUsageNSec: number;
CanClean: any[];
CanFreeze: boolean;
CanIsolate: boolean;
CanLiveMount: boolean;
CanReload: boolean;
CanStart: boolean;
CanStop: boolean;
CollectMode: string;
ConditionResult: boolean;
ConditionTimestamp: number;
ConditionTimestampMonotonic: number;
Conditions: any[];
ConflictedBy: any[];
Conflicts: string[];
ConsistsOf: any[];
DebugInvocation: boolean;
DefaultDependencies: boolean;
Description: string;
Documentation: string[];
DropInPaths: any[];
ExecMainPID: number;
FailureAction: string;
FailureActionExitStatus: number;
Following: string;
FragmentPath: string;
FreezerState: string;
Id: string;
IgnoreOnIsolate: boolean;
InactiveEnterTimestamp: number;
InactiveEnterTimestampMonotonic: number;
InactiveExitTimestamp: number;
InactiveExitTimestampMonotonic: number;
InvocationID: string;
Job: Array<number | string>;
JobRunningTimeoutUSec: number;
JobTimeoutAction: string;
JobTimeoutRebootArgument: string;
JobTimeoutUSec: number;
JoinsNamespaceOf: any[];
LoadError: string[];
LoadState: string;
MainPID: number;
Markers: any[];
MemoryCurrent: number;
MemoryLimit: number;
MemoryPeak: number;
NRestarts: number;
Names: string[];
NeedDaemonReload: boolean;
OnFailure: any[];
OnFailureJobMode: string;
OnFailureOf: any[];
OnSuccess: any[];
OnSuccessJobMode: string;
OnSuccessOf: any[];
PartOf: any[];
Perpetual: boolean;
PropagatesReloadTo: any[];
PropagatesStopTo: any[];
RebootArgument: string;
Refs: any[];
RefuseManualStart: boolean;
RefuseManualStop: boolean;
ReloadPropagatedFrom: any[];
RequiredBy: any[];
Requires: string[];
RequiresMountsFor: any[];
Requisite: any[];
RequisiteOf: any[];
Result: string;
SliceOf: any[];
SourcePath: string;
StartLimitAction: string;
StartLimitBurst: number;
StartLimitIntervalUSec: number;
StateChangeTimestamp: number;
StateChangeTimestampMonotonic: number;
StopPropagatedFrom: any[];
StopWhenUnneeded: boolean;
SubState: string;
SuccessAction: string;
SuccessActionExitStatus: number;
SurviveFinalKillSignal: boolean;
TasksCurrent: number;
TasksMax: number;
Transient: boolean;
TriggeredBy: string[];
Triggers: any[];
UnitFilePreset: string;
UnitFileState: string;
UpheldBy: any[];
Upholds: any[];
WantedBy: any[];
Wants: string[];
WantsMountsFor: any[];
}
export interface BeszelInfo {
key: string // public key
v: string // version
cu: boolean // check updates
}
export interface UpdateInfo {
v: string // new version
url: string // url to new version
}

View File

@@ -77,6 +77,16 @@ func CreateUser(app core.App, email string, password string) (*core.Record, erro
return user, app.Save(user)
}
func CreateUserWithRole(app core.App, email string, password string, roleName string) (*core.Record, error) {
user, err := CreateUser(app, email, password)
if err != nil {
return nil, err
}
user.Set("role", roleName)
return user, app.Save(user)
}
// Helper function to create a test record
func CreateRecord(app core.App, collectionName string, fields map[string]any) (*core.Record, error) {
collection, err := app.FindCachedCollectionByNameOrId(collectionName)
@@ -98,7 +108,7 @@ func ClearCollection(t testing.TB, app core.App, collectionName string) error {
}
func (h *TestHub) Cleanup() {
h.GetAlertManager().StopWorker()
h.GetAlertManager().Stop()
h.GetSystemManager().RemoveAllSystems()
h.TestApp.Cleanup()
}