Compare commits

...

47 Commits

Author SHA1 Message Date
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
henrygd
8f23fff1c9 refactor: mdraid comments and organization
also hide serial / firmware in smart details if empty, remove a few
unnecessary ops, and add a few more passed state values
2026-02-27 14:23:10 -05:00
VACInc
02c1a0c13d Add Linux mdraid health monitoring (#1750) 2026-02-27 13:42:47 -05:00
henrygd
69fdcb36ab support ZFS ARC on freebsd 2026-02-26 18:38:54 -05:00
henrygd
b91eb6de40 improve root I/O device detection and fallback (#1772)
- Match FILESYSTEM directly against I/O devices if partition lookup
fails
- Fall back to the most active I/O device if no root device is detected
- Add WARN logs in final fallback case to most active device
2026-02-26 18:11:33 -05:00
henrygd
ec69f6c6e0 improve disk I/O device matching for partition-to-disk mismatches (#1772)
findIoDevice now normalizes device names and falls back to prefix-based
matching when partition names differ from IOCounter names (e.g. nda0p2 →
nda0 on FreeBSD). The most-active prefix-related device is selected,
avoiding the broad "most active of all" heuristic that caused Docker
misattribution in #1737.
2026-02-26 16:59:12 -05:00
henrygd
a86cb91e07 improve install scripts with retries, validation, and better error messages
Add curl retries/timeouts, archive integrity checks, binary existence
checks, and temp dir cleanup on all failure paths. Unify --mirror flag
handling in hub script to match agent. Use cat instead of tee for
systemd service file, quiet systemctl output.
2026-02-26 12:29:05 -05:00
henrygd
004841717a add checks for non-empty CPU times during initialization (#401) 2026-02-25 19:04:29 -05:00
henrygd
096296ba7b fix: ensure rc.d directory exists for minimal FreeBSD installs in install-agent.sh 2026-02-25 16:22:37 -05:00
ilya
b012df5669 Fix volume path in Docker run command (#1764) 2026-02-24 15:47:16 -05:00
henrygd
12545b4b6d fix: dedupe root-mirrored extra filesystems during disk discovery (#1428) 2026-02-24 15:41:29 -05:00
henrygd
9e2296452b fix: compute bandwidth alerts from byte-per-second source (#1770)
Use Info.BandwidthBytes converted to MB/s with float division so
bandwidth alert checks are based on current data without integer
truncation near thresholds.
2026-02-24 13:07:27 -05:00
henrygd
ac79860d4a dev: update biome schema and disable assist/source/organizeImports 2026-02-20 15:50:44 -05:00
henrygd
e13a99fdac ui: add fallback to display language code if no emoji / flag 2026-02-20 15:46:24 -05:00
henrygd
4cfb2a86ad 0.18.4 release 2026-02-20 15:00:15 -05:00
henrygd
191f25f6e0 ui: refactor heartbeat settings page 2026-02-20 14:48:59 -05:00
henrygd
aa8b3711d7 update translations 2026-02-19 19:22:54 -05:00
henrygd
1fb0b25988 testing: improve flaky hub cleanup in agent_connect_test.go 2026-02-19 18:35:31 -05:00
henrygd
04600d83cc refactor: small go 1.26 updates and go fix changes 2026-02-19 18:04:33 -05:00
henrygd
5d8906c9b2 amd gpu: small refactor + trim "series" from device name 2026-02-19 17:39:13 -05:00
henrygd
daac287b9d ui: fix race issue with meter threshold colors
also increase the default container width
2026-02-19 17:37:57 -05:00
henrygd
d526ea61a9 ui: freeze header of smart device details table 2026-02-19 17:35:12 -05:00
henrygd
79616e1662 update translations 2026-02-19 16:21:59 -05:00
Sven van Ginkel
01e8bdf040 feat: allow precise value entry for alerts via text input (#1718) 2026-02-19 13:15:12 -05:00
henrygd
1e3a44e05d agent: improve multiplexed logs detection for podman (#1755) 2026-02-18 17:45:37 -05:00
henrygd
311095cfdd harden against docker api path traversal
Validate container IDs (12-64 hex) in hub container endpoints and agent
Docker requests, and build Docker URLs with escaped path segments. Add
regression tests for traversal/malformed container inputs and safe
endpoint construction.
2026-02-18 17:33:00 -05:00
henrygd
4869c834bb fix(ui): update bandwidth fallback to 0 when data is empty (avoid NaN) 2026-02-18 16:28:18 -05:00
henrygd
e1c1e97f0a chore: update go version / go deps / changelog 2026-02-18 16:17:05 -05:00
henrygd
f6b2824ccc rename gpu_apple_unsupported.go to gpu_darwin_unsupported.go 2026-02-18 15:15:58 -05:00
henrygd
f17ffc21b8 gate apple gpu collectors + revert readme change 2026-02-18 14:57:41 -05:00
Robert Accettura
f792f9b102 Mac GPU Stats (#1747) 2026-02-18 14:51:30 -05:00
henrygd
1def7d8d3a agent: add dockerManager.retrySleep method to mock time.Sleep in tests 2026-02-18 13:45:03 -05:00
Elio Di Nino
ef92b254bf fix(agent): Retry Docker check on non-200 HTTP response (#1754)
The previous behavior only caught some errors including inaccessible
hosts, but not others like failed authentication or service
unavailability. This largely applies when using a socket proxy and
having the retry mitigates some erroneous behavior.
2026-02-18 13:42:58 -05:00
henrygd
10d853c004 heartbeat: tweaks and tests (#1729) 2026-02-17 16:12:29 -05:00
Amir Moradi
cdfd116da0 Add outbound heartbeat monitoring (#1729)
* feat: add outbound heartbeat monitoring to external endpoints

Allow Beszel hub to periodically ping an external monitoring service
(e.g. BetterStack, Uptime Kuma, Healthchecks.io) with system status
summaries, enabling monitoring without exposing Beszel to the internet.

Configuration via environment variables:
- BESZEL_HUB_HEARTBEAT_URL: endpoint to ping (required to enable)
- BESZEL_HUB_HEARTBEAT_INTERVAL: seconds between pings (default: 60)
- BESZEL_HUB_HEARTBEAT_METHOD: HTTP method - POST/GET/HEAD (default: POST)
2026-02-17 15:48:20 -05:00
135 changed files with 6847 additions and 816 deletions

View File

@@ -51,7 +51,6 @@ clean:
lint:
golangci-lint run
test: export GOEXPERIMENT=synctest
test:
go test -tags=testing ./...

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"
@@ -68,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)
@@ -82,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
@@ -103,7 +103,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
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)
@@ -148,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()
@@ -213,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

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package agent

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package agent

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

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package agent

View File

@@ -1,9 +1,9 @@
package agent
import (
"context"
"errors"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
@@ -91,8 +91,8 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
c.eventChan = make(chan ConnectionEvent, 1)
// signal handling for shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
sigCtx, stopSignals := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stopSignals()
c.startWsTicker()
c.connect()
@@ -109,8 +109,8 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
_ = c.startWebSocketConnection()
case <-healthTicker:
_ = health.Update()
case <-sigChan:
slog.Info("Shutting down")
case <-sigCtx.Done():
slog.Info("Shutting down", "cause", context.Cause(sigCtx))
_ = c.agent.StopServer()
c.closeWebSocket()
return health.CleanUp()

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package agent

View File

@@ -14,10 +14,10 @@ var lastPerCoreCpuTimes = make(map[uint16][]cpu.TimesStat)
// init initializes the CPU monitoring by storing the initial CPU times
// for the default 60-second cache interval.
func init() {
if times, err := cpu.Times(false); err == nil {
if times, err := cpu.Times(false); err == nil && len(times) > 0 {
lastCpuTimes[60000] = times[0]
}
if perCoreTimes, err := cpu.Times(true); err == nil {
if perCoreTimes, err := cpu.Times(true); err == nil && len(perCoreTimes) > 0 {
lastPerCoreCpuTimes[60000] = perCoreTimes
}
}
@@ -89,10 +89,7 @@ func getPerCoreCpuUsage(cacheTimeMs uint16) (system.Uint8Slice, error) {
lastTimes := lastPerCoreCpuTimes[cacheTimeMs]
// Limit to the number of cores available in both samples
length := len(perCoreTimes)
if len(lastTimes) < length {
length = len(lastTimes)
}
length := min(len(lastTimes), len(perCoreTimes))
usage := make([]uint8, length)
for i := 0; i < length; i++ {

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

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package agent

View File

@@ -8,6 +8,7 @@ import (
"strings"
"time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/disk"
@@ -37,7 +38,7 @@ func isDockerSpecialMountpoint(mountpoint string) bool {
// Sets up the filesystems to monitor for disk usage and I/O.
func (a *Agent) initializeDiskInfo() {
filesystem, _ := GetEnv("FILESYSTEM")
filesystem, _ := utils.GetEnv("FILESYSTEM")
efPath := "/extra-filesystems"
hasRoot := false
isWindows := runtime.GOOS == "windows"
@@ -78,14 +79,21 @@ func (a *Agent) initializeDiskInfo() {
if _, exists := a.fsStats[key]; !exists {
if root {
slog.Info("Detected root device", "name", key)
// Check if root device is in /proc/diskstats. Do not guess a
// fallback device for root: that can misattribute root I/O to a
// different disk while usage remains tied to root mountpoint.
// 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(filesystem, diskIoCounters); match {
if matchedKey, match := findIoDevice(key, diskIoCounters); match {
key = matchedKey
ioMatch = true
} else {
} 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)
}
}
@@ -114,20 +122,28 @@ func (a *Agent) initializeDiskInfo() {
// Use FILESYSTEM env var to find root filesystem
if filesystem != "" {
for _, p := range partitions {
if strings.HasSuffix(p.Device, filesystem) || p.Mountpoint == filesystem {
if filesystemMatchesPartitionSetting(filesystem, p) {
addFsStat(p.Device, p.Mountpoint, true)
hasRoot = true
break
}
}
if !hasRoot {
slog.Warn("Partition details not found", "filesystem", filesystem)
// 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)
}
}
}
// Add EXTRA_FILESYSTEMS env var values to fsStats
if extraFilesystems, exists := GetEnv("EXTRA_FILESYSTEMS"); exists {
for _, fsEntry := range strings.Split(extraFilesystems, ",") {
if extraFilesystems, exists := utils.GetEnv("EXTRA_FILESYSTEMS"); exists {
for fsEntry := range strings.SplitSeq(extraFilesystems, ",") {
// Parse custom name from format: device__customname
fs, customName := parseFilesystemEntry(fsEntry)
@@ -187,28 +203,180 @@ func (a *Agent) initializeDiskInfo() {
}
}
// If no root filesystem set, use fallback
// 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 := filepath.Base(rootMountPoint)
if _, exists := a.fsStats[rootKey]; exists {
rootKey = "root"
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")
}
slog.Warn("Root device not detected; root I/O disabled", "mountpoint", rootMountPoint)
a.fsStats[rootKey] = &system.FsStats{Root: true, Mountpoint: rootMountPoint}
}
a.pruneDuplicateRootExtraFilesystems()
a.initializeDiskIoStats(diskIoCounters)
}
// Returns matching device from /proc/diskstats.
// bool is true if a match was found.
func findIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat) (string, bool) {
for _, d := range diskIoCounters {
if d.Name == filesystem || (d.Label != "" && d.Label == filesystem) {
return d.Name, true
// Removes extra filesystems that mirror root usage (https://github.com/henrygd/beszel/issues/1428).
func (a *Agent) pruneDuplicateRootExtraFilesystems() {
var rootMountpoint string
for _, stats := range a.fsStats {
if stats != nil && stats.Root {
rootMountpoint = stats.Mountpoint
break
}
}
return "", false
if rootMountpoint == "" {
return
}
rootUsage, err := disk.Usage(rootMountpoint)
if err != nil {
return
}
for name, stats := range a.fsStats {
if stats == nil || stats.Root {
continue
}
extraUsage, err := disk.Usage(stats.Mountpoint)
if err != nil {
continue
}
if hasSameDiskUsage(rootUsage, extraUsage) {
slog.Info("Ignoring duplicate FS", "name", name, "mount", stats.Mountpoint)
delete(a.fsStats, name)
}
}
}
// hasSameDiskUsage compares root/extra usage with a small byte tolerance.
func hasSameDiskUsage(a, b *disk.UsageStat) bool {
if a == nil || b == nil || a.Total == 0 || b.Total == 0 {
return false
}
// Allow minor drift between sequential disk usage calls.
const toleranceBytes uint64 = 16 * 1024 * 1024
return withinUsageTolerance(a.Total, b.Total, toleranceBytes) &&
withinUsageTolerance(a.Used, b.Used, toleranceBytes)
}
// withinUsageTolerance reports whether two byte values differ by at most tolerance.
func withinUsageTolerance(a, b, tolerance uint64) bool {
if a >= b {
return a-b <= tolerance
}
return b-a <= tolerance
}
type ioMatchCandidate struct {
name string
bytes uint64
ops uint64
}
// findIoDevice prefers exact device/label matches, then falls back to a
// prefix-related candidate with the highest recent activity.
func findIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat) (string, bool) {
filesystem = normalizeDeviceName(filesystem)
if filesystem == "" {
return "", false
}
candidates := []ioMatchCandidate{}
for _, d := range diskIoCounters {
if normalizeDeviceName(d.Name) == filesystem || (d.Label != "" && normalizeDeviceName(d.Label) == filesystem) {
return d.Name, true
}
if prefixRelated(normalizeDeviceName(d.Name), filesystem) ||
(d.Label != "" && prefixRelated(normalizeDeviceName(d.Label), filesystem)) {
candidates = append(candidates, ioMatchCandidate{
name: d.Name,
bytes: d.ReadBytes + d.WriteBytes,
ops: d.ReadCount + d.WriteCount,
})
}
}
if len(candidates) == 0 {
return "", false
}
best := candidates[0]
for _, c := range candidates[1:] {
if c.bytes > best.bytes ||
(c.bytes == best.bytes && c.ops > best.ops) ||
(c.bytes == best.bytes && c.ops == best.ops && c.name < best.name) {
best = c
}
}
slog.Info("Using disk I/O fallback", "requested", filesystem, "selected", best.name)
return best.name, true
}
// mostActiveIoDevice returns the device with the highest I/O activity,
// or "" if diskIoCounters is empty.
func mostActiveIoDevice(diskIoCounters map[string]disk.IOCountersStat) string {
var best ioMatchCandidate
for _, d := range diskIoCounters {
c := ioMatchCandidate{
name: d.Name,
bytes: d.ReadBytes + d.WriteBytes,
ops: d.ReadCount + d.WriteCount,
}
if best.name == "" || c.bytes > best.bytes ||
(c.bytes == best.bytes && c.ops > best.ops) ||
(c.bytes == best.bytes && c.ops == best.ops && c.name < best.name) {
best = c
}
}
return best.name
}
// prefixRelated reports whether either identifier is a prefix of the other.
func prefixRelated(a, b string) bool {
if a == "" || b == "" || a == b {
return false
}
return strings.HasPrefix(a, b) || strings.HasPrefix(b, a)
}
// filesystemMatchesPartitionSetting checks whether a FILESYSTEM env var value
// matches a partition by mountpoint, exact device name, or prefix relationship
// (e.g. FILESYSTEM=ada0 matches partition /dev/ada0p2).
func filesystemMatchesPartitionSetting(filesystem string, p disk.PartitionStat) bool {
filesystem = strings.TrimSpace(filesystem)
if filesystem == "" {
return false
}
if p.Mountpoint == filesystem {
return true
}
fsName := normalizeDeviceName(filesystem)
partName := normalizeDeviceName(p.Device)
if fsName == "" || partName == "" {
return false
}
if fsName == partName {
return true
}
return prefixRelated(partName, fsName)
}
// normalizeDeviceName canonicalizes device strings for comparisons.
func normalizeDeviceName(value string) string {
name := filepath.Base(strings.TrimSpace(value))
if name == "." {
return ""
}
return name
}
// Sets start values for disk I/O stats.
@@ -245,12 +413,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)
@@ -303,8 +471,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

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package agent
@@ -117,7 +116,7 @@ func TestFindIoDevice(t *testing.T) {
assert.Equal(t, "sda", device)
})
t.Run("returns no fallback when not found", func(t *testing.T) {
t.Run("returns no match when not found", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
"sda": {Name: "sda"},
"sdb": {Name: "sdb"},
@@ -127,6 +126,106 @@ func TestFindIoDevice(t *testing.T) {
assert.False(t, ok)
assert.Equal(t, "", device)
})
t.Run("uses uncertain unique prefix fallback", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
"nvme0n1": {Name: "nvme0n1"},
"sda": {Name: "sda"},
}
device, ok := findIoDevice("nvme0n1p2", ioCounters)
assert.True(t, ok)
assert.Equal(t, "nvme0n1", device)
})
t.Run("uses dominant activity when prefix matches are ambiguous", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
"sda": {Name: "sda", ReadBytes: 5000, WriteBytes: 5000, ReadCount: 100, WriteCount: 100},
"sdb": {Name: "sdb", ReadBytes: 1000, WriteBytes: 1000, ReadCount: 50, WriteCount: 50},
}
device, ok := findIoDevice("sd", ioCounters)
assert.True(t, ok)
assert.Equal(t, "sda", device)
})
t.Run("uses highest activity when ambiguous without dominance", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
"sda": {Name: "sda", ReadBytes: 3000, WriteBytes: 3000, ReadCount: 50, WriteCount: 50},
"sdb": {Name: "sdb", ReadBytes: 2500, WriteBytes: 2500, ReadCount: 40, WriteCount: 40},
}
device, ok := findIoDevice("sd", ioCounters)
assert.True(t, ok)
assert.Equal(t, "sda", device)
})
t.Run("matches /dev/-prefixed partition to parent disk", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
"nda0": {Name: "nda0", ReadBytes: 1000, WriteBytes: 1000},
}
device, ok := findIoDevice("/dev/nda0p2", ioCounters)
assert.True(t, ok)
assert.Equal(t, "nda0", device)
})
t.Run("uses deterministic name tie-breaker", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
"sdb": {Name: "sdb", ReadBytes: 2000, WriteBytes: 2000, ReadCount: 10, WriteCount: 10},
"sda": {Name: "sda", ReadBytes: 2000, WriteBytes: 2000, ReadCount: 10, WriteCount: 10},
}
device, ok := findIoDevice("sd", ioCounters)
assert.True(t, ok)
assert.Equal(t, "sda", device)
})
}
func TestFilesystemMatchesPartitionSetting(t *testing.T) {
p := disk.PartitionStat{Device: "/dev/ada0p2", Mountpoint: "/"}
t.Run("matches mountpoint setting", func(t *testing.T) {
assert.True(t, filesystemMatchesPartitionSetting("/", p))
})
t.Run("matches exact partition setting", func(t *testing.T) {
assert.True(t, filesystemMatchesPartitionSetting("ada0p2", p))
assert.True(t, filesystemMatchesPartitionSetting("/dev/ada0p2", p))
})
t.Run("matches prefix-style parent setting", func(t *testing.T) {
assert.True(t, filesystemMatchesPartitionSetting("ada0", p))
assert.True(t, filesystemMatchesPartitionSetting("/dev/ada0", p))
})
t.Run("does not match unrelated device", func(t *testing.T) {
assert.False(t, filesystemMatchesPartitionSetting("sda", p))
assert.False(t, filesystemMatchesPartitionSetting("nvme0n1", p))
assert.False(t, filesystemMatchesPartitionSetting("", p))
})
}
func TestMostActiveIoDevice(t *testing.T) {
t.Run("returns most active device", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
"nda0": {Name: "nda0", ReadBytes: 5000, WriteBytes: 5000, ReadCount: 100, WriteCount: 100},
"nda1": {Name: "nda1", ReadBytes: 1000, WriteBytes: 1000, ReadCount: 50, WriteCount: 50},
}
assert.Equal(t, "nda0", mostActiveIoDevice(ioCounters))
})
t.Run("uses deterministic tie-breaker", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
"sdb": {Name: "sdb", ReadBytes: 1000, WriteBytes: 1000, ReadCount: 10, WriteCount: 10},
"sda": {Name: "sda", ReadBytes: 1000, WriteBytes: 1000, ReadCount: 10, WriteCount: 10},
}
assert.Equal(t, "sda", mostActiveIoDevice(ioCounters))
})
t.Run("returns empty for empty map", func(t *testing.T) {
assert.Equal(t, "", mostActiveIoDevice(map[string]disk.IOCountersStat{}))
})
}
func TestIsDockerSpecialMountpoint(t *testing.T) {
@@ -373,3 +472,37 @@ func TestDiskUsageCaching(t *testing.T) {
"lastDiskUsageUpdate should be refreshed when cache expires")
})
}
func TestHasSameDiskUsage(t *testing.T) {
const toleranceBytes uint64 = 16 * 1024 * 1024
t.Run("returns true when totals and usage are equal", func(t *testing.T) {
a := &disk.UsageStat{Total: 100 * 1024 * 1024 * 1024, Used: 42 * 1024 * 1024 * 1024}
b := &disk.UsageStat{Total: 100 * 1024 * 1024 * 1024, Used: 42 * 1024 * 1024 * 1024}
assert.True(t, hasSameDiskUsage(a, b))
})
t.Run("returns true within tolerance", func(t *testing.T) {
a := &disk.UsageStat{Total: 100 * 1024 * 1024 * 1024, Used: 42 * 1024 * 1024 * 1024}
b := &disk.UsageStat{
Total: a.Total + toleranceBytes - 1,
Used: a.Used - toleranceBytes + 1,
}
assert.True(t, hasSameDiskUsage(a, b))
})
t.Run("returns false when total exceeds tolerance", func(t *testing.T) {
a := &disk.UsageStat{Total: 100 * 1024 * 1024 * 1024, Used: 42 * 1024 * 1024 * 1024}
b := &disk.UsageStat{
Total: a.Total + toleranceBytes + 1,
Used: a.Used,
}
assert.False(t, hasSameDiskUsage(a, b))
})
t.Run("returns false for nil or zero total", func(t *testing.T) {
assert.False(t, hasSameDiskUsage(nil, &disk.UsageStat{Total: 1, Used: 1}))
assert.False(t, hasSameDiskUsage(&disk.UsageStat{Total: 1, Used: 1}, nil))
assert.False(t, hasSameDiskUsage(&disk.UsageStat{Total: 0, Used: 0}, &disk.UsageStat{Total: 1, Used: 1}))
})
}

View File

@@ -1,6 +1,7 @@
package agent
import (
"bufio"
"bytes"
"context"
"encoding/binary"
@@ -20,6 +21,7 @@ import (
"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,6 +30,7 @@ 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\\-_]`)
var dockerContainerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
const (
// Docker API timeout in milliseconds
@@ -72,6 +75,7 @@ type dockerManager struct {
// cacheTimeMs -> DeltaTracker for network bytes sent/received
networkSentTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
networkRecvTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
retrySleep func(time.Duration)
}
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
@@ -333,12 +337,12 @@ 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
}
@@ -484,7 +488,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 == "" {
@@ -520,7 +524,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())
@@ -537,7 +541,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)
@@ -565,6 +569,7 @@ func newDockerManager() *dockerManager {
lastCpuReadTime: make(map[uint16]map[string]time.Time),
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
retrySleep: time.Sleep,
}
// If using podman, return client
@@ -574,7 +579,7 @@ func newDockerManager() *dockerManager {
return manager
}
// this can take up to 5 seconds with retry, so run in goroutine
// run version check in goroutine to avoid blocking (server may not be ready and requires retries)
go manager.checkDockerVersion()
// give version check a chance to complete before returning
@@ -594,18 +599,18 @@ func (dm *dockerManager) checkDockerVersion() {
const versionMaxTries = 2
for i := 1; i <= versionMaxTries; i++ {
resp, err = dm.client.Get("http://localhost/version")
if err == nil {
if err == nil && resp.StatusCode == http.StatusOK {
break
}
if resp != nil {
resp.Body.Close()
}
if i < versionMaxTries {
slog.Debug("Failed to get Docker version; retrying", "attempt", i, "error", err)
time.Sleep(5 * time.Second)
slog.Debug("Failed to get Docker version; retrying", "attempt", i, "err", err, "response", resp)
dm.retrySleep(5 * time.Second)
}
}
if err != nil {
if err != nil || resp.StatusCode != http.StatusOK {
return
}
if err := dm.decode(resp, &versionInfo); err != nil {
@@ -647,9 +652,34 @@ func getDockerHost() string {
return scheme + socks[0]
}
func validateContainerID(containerID string) error {
if !dockerContainerIDPattern.MatchString(containerID) {
return fmt.Errorf("invalid container id")
}
return nil
}
func buildDockerContainerEndpoint(containerID, action string, query url.Values) (string, error) {
if err := validateContainerID(containerID); err != nil {
return "", err
}
u := &url.URL{
Scheme: "http",
Host: "localhost",
Path: fmt.Sprintf("/containers/%s/%s", url.PathEscape(containerID), action),
}
if len(query) > 0 {
u.RawQuery = query.Encode()
}
return u.String(), nil
}
// getContainerInfo fetches the inspection data for a container
func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID string) ([]byte, error) {
endpoint := fmt.Sprintf("http://localhost/containers/%s/json", containerID)
endpoint, err := buildDockerContainerEndpoint(containerID, "json", nil)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
@@ -680,7 +710,15 @@ func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID strin
// getLogs fetches the logs for a container
func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (string, error) {
endpoint := fmt.Sprintf("http://localhost/containers/%s/logs?stdout=1&stderr=1&tail=%d", containerID, dockerLogsTail)
query := url.Values{
"stdout": []string{"1"},
"stderr": []string{"1"},
"tail": []string{fmt.Sprintf("%d", dockerLogsTail)},
}
endpoint, err := buildDockerContainerEndpoint(containerID, "logs", query)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", err
@@ -698,8 +736,17 @@ func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (strin
}
var builder strings.Builder
multiplexed := resp.Header.Get("Content-Type") == "application/vnd.docker.multiplexed-stream"
if err := decodeDockerLogStream(resp.Body, &builder, multiplexed); err != nil {
contentType := resp.Header.Get("Content-Type")
multiplexed := strings.HasSuffix(contentType, "multiplexed-stream")
logReader := io.Reader(resp.Body)
if !multiplexed {
// Podman may return multiplexed logs without Content-Type. Sniff the first frame header
// with a small buffered reader only when the header check fails.
bufferedReader := bufio.NewReaderSize(resp.Body, 8)
multiplexed = detectDockerMultiplexedStream(bufferedReader)
logReader = bufferedReader
}
if err := decodeDockerLogStream(logReader, &builder, multiplexed); err != nil {
return "", err
}
@@ -711,6 +758,23 @@ func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (strin
return logs, nil
}
func detectDockerMultiplexedStream(reader *bufio.Reader) bool {
const headerSize = 8
header, err := reader.Peek(headerSize)
if err != nil {
return false
}
if header[0] != 0x01 && header[0] != 0x02 {
return false
}
// Docker's stream framing header reserves bytes 1-3 as zero.
if header[1] != 0 || header[2] != 0 || header[3] != 0 {
return false
}
frameLen := binary.BigEndian.Uint32(header[4:])
return frameLen <= maxLogFrameSize
}
func decodeDockerLogStream(reader io.Reader, builder *strings.Builder, multiplexed bool) error {
if !multiplexed {
_, err := io.Copy(builder, io.LimitReader(reader, maxTotalLogSize))

View File

@@ -1,17 +1,24 @@
//go:build testing
// +build testing
package agent
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"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"
@@ -19,6 +26,37 @@ import (
var defaultCacheTimeMs = uint16(60_000)
type recordingRoundTripper struct {
statusCode int
body string
contentType string
called bool
lastPath string
lastQuery map[string]string
}
func (rt *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
rt.called = true
rt.lastPath = req.URL.EscapedPath()
rt.lastQuery = map[string]string{}
for key, values := range req.URL.Query() {
if len(values) > 0 {
rt.lastQuery[key] = values[0]
}
}
resp := &http.Response{
StatusCode: rt.statusCode,
Status: "200 OK",
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(rt.body)),
Request: req,
}
if rt.contentType != "" {
resp.Header.Set("Content-Type", rt.contentType)
}
return resp, nil
}
// cycleCpuDeltas cycles the CPU tracking data for a specific cache time interval
func (dm *dockerManager) cycleCpuDeltas(cacheTimeMs uint16) {
// Clear the CPU tracking maps for this cache time interval
@@ -110,6 +148,72 @@ func TestCalculateMemoryUsage(t *testing.T) {
}
}
func TestBuildDockerContainerEndpoint(t *testing.T) {
t.Run("valid container ID builds escaped endpoint", func(t *testing.T) {
endpoint, err := buildDockerContainerEndpoint("0123456789ab", "json", nil)
require.NoError(t, err)
assert.Equal(t, "http://localhost/containers/0123456789ab/json", endpoint)
})
t.Run("invalid container ID is rejected", func(t *testing.T) {
_, err := buildDockerContainerEndpoint("../../version", "json", nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid container id")
})
}
func TestContainerDetailsRequestsValidateContainerID(t *testing.T) {
rt := &recordingRoundTripper{
statusCode: 200,
body: `{"Config":{"Env":["SECRET=1"]}}`,
}
dm := &dockerManager{
client: &http.Client{Transport: rt},
}
_, err := dm.getContainerInfo(context.Background(), "../version")
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid container id")
assert.False(t, rt.called, "request should be rejected before dispatching to Docker API")
}
func TestContainerDetailsRequestsUseExpectedDockerPaths(t *testing.T) {
t.Run("container info uses container json endpoint", func(t *testing.T) {
rt := &recordingRoundTripper{
statusCode: 200,
body: `{"Config":{"Env":["SECRET=1"]},"Name":"demo"}`,
}
dm := &dockerManager{
client: &http.Client{Transport: rt},
}
body, err := dm.getContainerInfo(context.Background(), "0123456789ab")
require.NoError(t, err)
assert.True(t, rt.called)
assert.Equal(t, "/containers/0123456789ab/json", rt.lastPath)
assert.NotContains(t, string(body), "SECRET=1", "sensitive env vars should be removed")
})
t.Run("container logs uses expected endpoint and query params", func(t *testing.T) {
rt := &recordingRoundTripper{
statusCode: 200,
body: "line1\nline2\n",
}
dm := &dockerManager{
client: &http.Client{Transport: rt},
}
logs, err := dm.getLogs(context.Background(), "abcdef123456")
require.NoError(t, err)
assert.True(t, rt.called)
assert.Equal(t, "/containers/abcdef123456/logs", rt.lastPath)
assert.Equal(t, "1", rt.lastQuery["stdout"])
assert.Equal(t, "1", rt.lastQuery["stderr"])
assert.Equal(t, "200", rt.lastQuery["tail"])
assert.Equal(t, "line1\nline2\n", logs)
})
}
func TestValidateCpuPercentage(t *testing.T) {
tests := []struct {
name string
@@ -195,48 +299,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),
@@ -379,6 +441,117 @@ func TestDockerManagerCreation(t *testing.T) {
assert.NotNil(t, dm.networkRecvTrackers)
}
func TestCheckDockerVersion(t *testing.T) {
tests := []struct {
name string
responses []struct {
statusCode int
body string
}
expectedGood bool
expectedRequests int
}{
{
name: "200 with good version on first try",
responses: []struct {
statusCode int
body string
}{
{http.StatusOK, `{"Version":"25.0.1"}`},
},
expectedGood: true,
expectedRequests: 1,
},
{
name: "200 with old version on first try",
responses: []struct {
statusCode int
body string
}{
{http.StatusOK, `{"Version":"24.0.7"}`},
},
expectedGood: false,
expectedRequests: 1,
},
{
name: "non-200 then 200 with good version",
responses: []struct {
statusCode int
body string
}{
{http.StatusServiceUnavailable, `"not ready"`},
{http.StatusOK, `{"Version":"25.1.0"}`},
},
expectedGood: true,
expectedRequests: 2,
},
{
name: "non-200 on all retries",
responses: []struct {
statusCode int
body string
}{
{http.StatusInternalServerError, `"error"`},
{http.StatusUnauthorized, `"error"`},
},
expectedGood: false,
expectedRequests: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
idx := requestCount
requestCount++
if idx >= len(tt.responses) {
idx = len(tt.responses) - 1
}
w.WriteHeader(tt.responses[idx].statusCode)
fmt.Fprint(w, tt.responses[idx].body)
}))
defer server.Close()
dm := &dockerManager{
client: &http.Client{
Transport: &http.Transport{
DialContext: func(_ context.Context, network, _ string) (net.Conn, error) {
return net.Dial(network, server.Listener.Addr().String())
},
},
},
retrySleep: func(time.Duration) {},
}
dm.checkDockerVersion()
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
assert.Equal(t, tt.expectedRequests, requestCount)
})
}
t.Run("request error on all retries", func(t *testing.T) {
requestCount := 0
dm := &dockerManager{
client: &http.Client{
Transport: &http.Transport{
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
requestCount++
return nil, errors.New("connection refused")
},
},
},
retrySleep: func(time.Duration) {},
}
dm.checkDockerVersion()
assert.False(t, dm.goodDockerVersion)
assert.Equal(t, 2, requestCount)
})
}
func TestCycleCpuDeltas(t *testing.T) {
dm := &dockerManager{
lastCpuContainer: map[uint16]map[string]uint64{
@@ -691,14 +864,50 @@ 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)
}
func TestGetLogsDetectsMultiplexedWithoutContentType(t *testing.T) {
// Docker multiplexed frame: [stream][0,0,0][len(4 bytes BE)][payload]
frame := []byte{
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05,
'H', 'e', 'l', 'l', 'o',
}
rt := &recordingRoundTripper{
statusCode: 200,
body: string(frame),
// Intentionally omit content type to simulate Podman behavior.
}
dm := &dockerManager{
client: &http.Client{Transport: rt},
}
logs, err := dm.getLogs(context.Background(), "abcdef123456")
require.NoError(t, err)
assert.Equal(t, "Hello", logs)
}
func TestGetLogsDoesNotMisclassifyRawStreamAsMultiplexed(t *testing.T) {
// Starts with 0x01, but doesn't match Docker frame signature (reserved bytes aren't all zero).
raw := []byte{0x01, 0x02, 0x03, 0x04, 'r', 'a', 'w'}
rt := &recordingRoundTripper{
statusCode: 200,
body: string(raw),
}
dm := &dockerManager{
client: &http.Client{Transport: rt},
}
logs, err := dm.getLogs(context.Background(), "abcdef123456")
require.NoError(t, err)
assert.Equal(t, raw, []byte(logs))
}
func TestEdgeCasesWithRealData(t *testing.T) {
// Test with minimal container stats
minimalStats := &container.ApiStats{
@@ -940,13 +1149,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) {

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
}
@@ -199,19 +200,6 @@ func readHexByteFile(path string) (uint8, bool) {
return b, ok
}
func readStringFile(path string) string {
content, _ := readStringFileOK(path)
return content
}
func readStringFileOK(path string) (string, bool) {
b, err := os.ReadFile(path)
if err != nil {
return "", false
}
return strings.TrimSpace(string(b)), true
}
func hasEmmcHealthFiles(deviceDir string) bool {
entries, err := os.ReadDir(deviceDir)
if err != nil {

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package agent

View File

@@ -9,21 +9,25 @@ import (
"maps"
"os/exec"
"regexp"
"runtime"
"strconv"
"strings"
"sync"
"time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
)
const (
// Commands
nvidiaSmiCmd string = "nvidia-smi"
rocmSmiCmd string = "rocm-smi"
tegraStatsCmd string = "tegrastats"
nvtopCmd string = "nvtop"
noGPUFoundMsg string = "no GPU found - see https://beszel.dev/guide/gpu"
nvidiaSmiCmd string = "nvidia-smi"
rocmSmiCmd string = "rocm-smi"
tegraStatsCmd string = "tegrastats"
nvtopCmd string = "nvtop"
powermetricsCmd string = "powermetrics"
macmonCmd string = "macmon"
noGPUFoundMsg string = "no GPU found - see https://beszel.dev/guide/gpu"
// Command retry and timeout constants
retryWaitTime time.Duration = 5 * time.Second
@@ -82,15 +86,18 @@ var errNoValidData = fmt.Errorf("no valid GPU data found") // Error for missing
type collectorSource string
const (
collectorSourceNVTop collectorSource = collectorSource(nvtopCmd)
collectorSourceNVML collectorSource = "nvml"
collectorSourceNvidiaSMI collectorSource = collectorSource(nvidiaSmiCmd)
collectorSourceIntelGpuTop collectorSource = collectorSource(intelGpuStatsCmd)
collectorSourceAmdSysfs collectorSource = "amd_sysfs"
collectorSourceRocmSMI collectorSource = collectorSource(rocmSmiCmd)
collectorGroupNvidia string = "nvidia"
collectorGroupIntel string = "intel"
collectorGroupAmd string = "amd"
collectorSourceNVTop collectorSource = collectorSource(nvtopCmd)
collectorSourceNVML collectorSource = "nvml"
collectorSourceNvidiaSMI collectorSource = collectorSource(nvidiaSmiCmd)
collectorSourceIntelGpuTop collectorSource = collectorSource(intelGpuStatsCmd)
collectorSourceAmdSysfs collectorSource = "amd_sysfs"
collectorSourceRocmSMI collectorSource = collectorSource(rocmSmiCmd)
collectorSourceMacmon collectorSource = collectorSource(macmonCmd)
collectorSourcePowermetrics collectorSource = collectorSource(powermetricsCmd)
collectorGroupNvidia string = "nvidia"
collectorGroupIntel string = "intel"
collectorGroupAmd string = "amd"
collectorGroupApple string = "apple"
)
func isValidCollectorSource(source collectorSource) bool {
@@ -100,7 +107,9 @@ func isValidCollectorSource(source collectorSource) bool {
collectorSourceNvidiaSMI,
collectorSourceIntelGpuTop,
collectorSourceAmdSysfs,
collectorSourceRocmSMI:
collectorSourceRocmSMI,
collectorSourceMacmon,
collectorSourcePowermetrics:
return true
}
return false
@@ -108,12 +117,14 @@ func isValidCollectorSource(source collectorSource) bool {
// gpuCapabilities describes detected GPU tooling and sysfs support on the host.
type gpuCapabilities struct {
hasNvidiaSmi bool
hasRocmSmi bool
hasAmdSysfs bool
hasTegrastats bool
hasIntelGpuTop bool
hasNvtop bool
hasNvidiaSmi bool
hasRocmSmi bool
hasAmdSysfs bool
hasTegrastats bool
hasIntelGpuTop bool
hasNvtop bool
hasMacmon bool
hasPowermetrics bool
}
type collectorDefinition struct {
@@ -281,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++
@@ -356,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
@@ -400,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
@@ -449,11 +460,19 @@ func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
if _, err := exec.LookPath(nvtopCmd); err == nil {
caps.hasNvtop = true
}
if runtime.GOOS == "darwin" {
if _, err := exec.LookPath(macmonCmd); err == nil {
caps.hasMacmon = true
}
if _, err := exec.LookPath(powermetricsCmd); err == nil {
caps.hasPowermetrics = true
}
}
return caps
}
func hasAnyGpuCollector(caps gpuCapabilities) bool {
return caps.hasNvidiaSmi || caps.hasRocmSmi || caps.hasAmdSysfs || caps.hasTegrastats || caps.hasIntelGpuTop || caps.hasNvtop
return caps.hasNvidiaSmi || caps.hasRocmSmi || caps.hasAmdSysfs || caps.hasTegrastats || caps.hasIntelGpuTop || caps.hasNvtop || caps.hasMacmon || caps.hasPowermetrics
}
func (gm *GPUManager) startIntelCollector() {
@@ -567,6 +586,22 @@ func (gm *GPUManager) collectorDefinitions(caps gpuCapabilities) map[collectorSo
return true
},
},
collectorSourceMacmon: {
group: collectorGroupApple,
available: caps.hasMacmon,
start: func(_ func()) bool {
gm.startMacmonCollector()
return true
},
},
collectorSourcePowermetrics: {
group: collectorGroupApple,
available: caps.hasPowermetrics,
start: func(_ func()) bool {
gm.startPowermetricsCollector()
return true
},
},
}
}
@@ -653,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)
@@ -661,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)
@@ -674,7 +709,18 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
priorities = append(priorities, collectorSourceIntelGpuTop)
}
// Keep nvtop as a legacy last resort only when no vendor collector exists.
// Apple collectors are currently opt-in only for testing.
// Enable them with GPU_COLLECTOR=macmon or GPU_COLLECTOR=powermetrics.
// TODO: uncomment below when Apple collectors are confirmed to be working.
//
// Prefer macmon on macOS (no sudo). Fall back to powermetrics if present.
// if caps.hasMacmon {
// priorities = append(priorities, collectorSourceMacmon)
// } else if caps.hasPowermetrics {
// priorities = append(priorities, collectorSourcePowermetrics)
// }
// Keep nvtop as a last resort only when no vendor collector exists.
if len(priorities) == 0 && caps.hasNvtop {
priorities = append(priorities, collectorSourceNVTop)
}
@@ -683,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
@@ -700,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.
@@ -103,10 +103,8 @@ func (gm *GPUManager) updateAmdGpuData(cardPath string) bool {
// Read all sysfs values first (no lock needed - these can be slow)
usage, usageErr := readSysfsFloat(filepath.Join(devicePath, "gpu_busy_percent"))
vramUsed, memUsedErr := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_used"))
vramTotal, _ := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_total"))
memUsed := vramUsed
memTotal := vramTotal
memUsed, memUsedErr := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_used"))
memTotal, _ := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_total"))
// if gtt is present, add it to the memory used and total (https://github.com/henrygd/beszel/issues/1569#issuecomment-3837640484)
if gttUsed, err := readSysfsFloat(filepath.Join(devicePath, "mem_info_gtt_used")); err == nil && gttUsed > 0 {
if gttTotal, err := readSysfsFloat(filepath.Join(devicePath, "mem_info_gtt_total")); err == nil {
@@ -146,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++
@@ -156,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.
@@ -243,7 +241,10 @@ func getCachedAmdgpuName(deviceID, revisionID string) (name string, found bool,
// normalizeAmdgpuName trims standard suffixes from AMDGPU product names.
func normalizeAmdgpuName(name string) string {
return strings.TrimSuffix(strings.TrimSpace(name), " Graphics")
for _, suffix := range []string{" Graphics", " Series"} {
name = strings.TrimSuffix(name, suffix)
}
return name
}
// cacheAmdgpuName stores a resolved AMDGPU name in the lookup cache.
@@ -272,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),
},
}

252
agent/gpu_darwin.go Normal file
View File

@@ -0,0 +1,252 @@
//go:build darwin
package agent
import (
"bufio"
"bytes"
"encoding/json"
"io"
"log/slog"
"os/exec"
"strconv"
"strings"
"time"
"github.com/henrygd/beszel/internal/entities/system"
)
const (
// powermetricsSampleIntervalMs is the sampling interval passed to powermetrics (-i).
powermetricsSampleIntervalMs = 500
// powermetricsPollInterval is how often we run powermetrics to collect a new sample.
powermetricsPollInterval = 2 * time.Second
// macmonIntervalMs is the sampling interval passed to macmon pipe (-i), in milliseconds.
macmonIntervalMs = 2500
)
const appleGPUID = "0"
// startPowermetricsCollector runs powermetrics --samplers gpu_power in a loop and updates
// GPU usage and power. Requires root (sudo) on macOS. A single logical GPU is reported as id "0".
func (gm *GPUManager) startPowermetricsCollector() {
// Ensure single GPU entry for Apple GPU
if _, ok := gm.GpuDataMap[appleGPUID]; !ok {
gm.GpuDataMap[appleGPUID] = &system.GPUData{Name: "Apple GPU"}
}
go func() {
failures := 0
for {
if err := gm.collectPowermetrics(); err != nil {
failures++
if failures > maxFailureRetries {
slog.Warn("powermetrics GPU collector failed repeatedly, stopping", "err", err)
break
}
slog.Warn("Error collecting macOS GPU data via powermetrics (may require sudo)", "err", err)
time.Sleep(retryWaitTime)
continue
}
failures = 0
time.Sleep(powermetricsPollInterval)
}
}()
}
// collectPowermetrics runs powermetrics once and parses GPU usage and power from its output.
func (gm *GPUManager) collectPowermetrics() error {
interval := strconv.Itoa(powermetricsSampleIntervalMs)
cmd := exec.Command(powermetricsCmd, "--samplers", "gpu_power", "-i", interval, "-n", "1")
cmd.Stderr = nil
out, err := cmd.Output()
if err != nil {
return err
}
if !gm.parsePowermetricsData(out) {
return errNoValidData
}
return nil
}
// parsePowermetricsData parses powermetrics gpu_power output and updates GpuDataMap["0"].
// Example output:
//
// **** GPU usage ****
// GPU HW active frequency: 444 MHz
// GPU HW active residency: 0.97% (444 MHz: .97% ...
// GPU idle residency: 99.03%
// GPU Power: 4 mW
func (gm *GPUManager) parsePowermetricsData(output []byte) bool {
var idleResidency, powerMW float64
var gotIdle, gotPower bool
scanner := bufio.NewScanner(bytes.NewReader(output))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "GPU idle residency:") {
// "GPU idle residency: 99.03%"
fields := strings.Fields(strings.TrimPrefix(line, "GPU idle residency:"))
if len(fields) >= 1 {
pct := strings.TrimSuffix(fields[0], "%")
if v, err := strconv.ParseFloat(pct, 64); err == nil {
idleResidency = v
gotIdle = true
}
}
} else if strings.HasPrefix(line, "GPU Power:") {
// "GPU Power: 4 mW"
fields := strings.Fields(strings.TrimPrefix(line, "GPU Power:"))
if len(fields) >= 1 {
if v, err := strconv.ParseFloat(fields[0], 64); err == nil {
powerMW = v
gotPower = true
}
}
}
}
if err := scanner.Err(); err != nil {
return false
}
if !gotIdle && !gotPower {
return false
}
gm.Lock()
defer gm.Unlock()
if _, ok := gm.GpuDataMap[appleGPUID]; !ok {
gm.GpuDataMap[appleGPUID] = &system.GPUData{Name: "Apple GPU"}
}
gpu := gm.GpuDataMap[appleGPUID]
if gotIdle {
// Usage = 100 - idle residency (e.g. 100 - 99.03 = 0.97%)
gpu.Usage += 100 - idleResidency
}
if gotPower {
// mW -> W
gpu.Power += powerMW / milliwattsInAWatt
}
gpu.Count++
return true
}
// startMacmonCollector runs `macmon pipe` in a loop and parses one JSON object per line.
// This collector does not require sudo. A single logical GPU is reported as id "0".
func (gm *GPUManager) startMacmonCollector() {
if _, ok := gm.GpuDataMap[appleGPUID]; !ok {
gm.GpuDataMap[appleGPUID] = &system.GPUData{Name: "Apple GPU"}
}
go func() {
failures := 0
for {
if err := gm.collectMacmonPipe(); err != nil {
failures++
if failures > maxFailureRetries {
slog.Warn("macmon GPU collector failed repeatedly, stopping", "err", err)
break
}
slog.Warn("Error collecting macOS GPU data via macmon", "err", err)
time.Sleep(retryWaitTime)
continue
}
failures = 0
// `macmon pipe` is long-running; if it returns, wait a bit before restarting.
time.Sleep(retryWaitTime)
}
}()
}
type macmonTemp struct {
GPUTempAvg float64 `json:"gpu_temp_avg"`
}
type macmonSample struct {
GPUPower float64 `json:"gpu_power"` // watts (macmon reports fractional values)
GPURAMPower float64 `json:"gpu_ram_power"` // watts
GPUUsage []float64 `json:"gpu_usage"` // [freq_mhz, usage] where usage is typically 0..1
Temp macmonTemp `json:"temp"`
}
func (gm *GPUManager) collectMacmonPipe() (err error) {
cmd := exec.Command(macmonCmd, "pipe", "-i", strconv.Itoa(macmonIntervalMs))
// Avoid blocking if macmon writes to stderr.
cmd.Stderr = io.Discard
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
// Ensure we always reap the child to avoid zombies on any return path and
// propagate a non-zero exit code if no other error was set.
defer func() {
_ = stdout.Close()
if cmd.ProcessState == nil || !cmd.ProcessState.Exited() {
_ = cmd.Process.Kill()
}
if waitErr := cmd.Wait(); err == nil && waitErr != nil {
err = waitErr
}
}()
scanner := bufio.NewScanner(stdout)
var hadSample bool
for scanner.Scan() {
line := bytes.TrimSpace(scanner.Bytes())
if len(line) == 0 {
continue
}
if gm.parseMacmonLine(line) {
hadSample = true
}
}
if scanErr := scanner.Err(); scanErr != nil {
return scanErr
}
if !hadSample {
return errNoValidData
}
return nil
}
// parseMacmonLine parses a single macmon JSON line and updates Apple GPU metrics.
func (gm *GPUManager) parseMacmonLine(line []byte) bool {
var sample macmonSample
if err := json.Unmarshal(line, &sample); err != nil {
return false
}
usage := 0.0
if len(sample.GPUUsage) >= 2 {
usage = sample.GPUUsage[1]
// Heuristic: macmon typically reports 0..1; convert to percentage.
if usage <= 1.0 {
usage *= 100
}
}
// Consider the line valid if it contains at least one GPU metric.
if usage == 0 && sample.GPUPower == 0 && sample.Temp.GPUTempAvg == 0 {
return false
}
gm.Lock()
defer gm.Unlock()
gpu, ok := gm.GpuDataMap[appleGPUID]
if !ok {
gpu = &system.GPUData{Name: "Apple GPU"}
gm.GpuDataMap[appleGPUID] = gpu
}
gpu.Temperature = sample.Temp.GPUTempAvg
gpu.Usage += usage
// macmon reports power in watts; include VRAM power if present.
gpu.Power += sample.GPUPower + sample.GPURAMPower
gpu.Count++
return true
}

81
agent/gpu_darwin_test.go Normal file
View File

@@ -0,0 +1,81 @@
//go:build darwin
package agent
import (
"testing"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParsePowermetricsData(t *testing.T) {
input := `
Machine model: Mac14,10
OS version: 25D125
*** Sampled system activity (Sat Feb 14 00:42:06 2026 -0500) (503.05ms elapsed) ***
**** GPU usage ****
GPU HW active frequency: 444 MHz
GPU HW active residency: 0.97% (444 MHz: .97% 612 MHz: 0% 808 MHz: 0% 968 MHz: 0% 1110 MHz: 0% 1236 MHz: 0% 1338 MHz: 0% 1398 MHz: 0%)
GPU SW requested state: (P1 : 100% P2 : 0% P3 : 0% P4 : 0% P5 : 0% P6 : 0% P7 : 0% P8 : 0%)
GPU idle residency: 99.03%
GPU Power: 4 mW
`
gm := &GPUManager{
GpuDataMap: make(map[string]*system.GPUData),
}
valid := gm.parsePowermetricsData([]byte(input))
require.True(t, valid)
g0, ok := gm.GpuDataMap["0"]
require.True(t, ok)
assert.Equal(t, "Apple GPU", g0.Name)
// Usage = 100 - 99.03 = 0.97
assert.InDelta(t, 0.97, g0.Usage, 0.01)
// 4 mW -> 0.004 W
assert.InDelta(t, 0.004, g0.Power, 0.0001)
assert.Equal(t, 1.0, g0.Count)
}
func TestParsePowermetricsDataPartial(t *testing.T) {
// Only power line (e.g. older macOS or different sampler output)
input := `
**** GPU usage ****
GPU Power: 120 mW
`
gm := &GPUManager{
GpuDataMap: make(map[string]*system.GPUData),
}
valid := gm.parsePowermetricsData([]byte(input))
require.True(t, valid)
g0, ok := gm.GpuDataMap["0"]
require.True(t, ok)
assert.Equal(t, "Apple GPU", g0.Name)
assert.InDelta(t, 0.12, g0.Power, 0.001)
assert.Equal(t, 1.0, g0.Count)
}
func TestParseMacmonLine(t *testing.T) {
input := `{"all_power":0.6468324661254883,"ane_power":0.0,"cpu_power":0.6359732151031494,"ecpu_usage":[2061,0.1726151406764984],"gpu_power":0.010859241709113121,"gpu_ram_power":0.000965250947047025,"gpu_usage":[503,0.013633215799927711],"memory":{"ram_total":17179869184,"ram_usage":12322914304,"swap_total":0,"swap_usage":0},"pcpu_usage":[1248,0.11792058497667313],"ram_power":0.14885640144348145,"sys_power":10.4955415725708,"temp":{"cpu_temp_avg":23.041261672973633,"gpu_temp_avg":29.44516944885254},"timestamp":"2026-02-17T19:34:27.942556+00:00"}`
gm := &GPUManager{
GpuDataMap: make(map[string]*system.GPUData),
}
valid := gm.parseMacmonLine([]byte(input))
require.True(t, valid)
g0, ok := gm.GpuDataMap["0"]
require.True(t, ok)
assert.Equal(t, "Apple GPU", g0.Name)
// macmon reports usage fraction 0..1; expect percent conversion.
assert.InDelta(t, 1.3633, g0.Usage, 0.05)
// power includes gpu_power + gpu_ram_power
assert.InDelta(t, 0.011824, g0.Power, 0.0005)
assert.InDelta(t, 29.445, g0.Temperature, 0.01)
assert.Equal(t, 1.0, g0.Count)
}

View File

@@ -0,0 +1,9 @@
//go:build !darwin
package agent
// startPowermetricsCollector is a no-op on non-darwin platforms; the real implementation is in gpu_darwin.go.
func (gm *GPUManager) startPowermetricsCollector() {}
// startMacmonCollector is a no-op on non-darwin platforms; the real implementation is in gpu_darwin.go.
func (gm *GPUManager) startMacmonCollector() {}

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

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package agent
@@ -11,6 +10,7 @@ import (
"testing"
"time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/stretchr/testify/assert"
@@ -266,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"]
@@ -276,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)
}

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package agent

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package health
@@ -37,7 +36,6 @@ func TestHealth(t *testing.T) {
})
// This test uses synctest to simulate time passing.
// NOTE: This test requires GOEXPERIMENT=synctest to run.
t.Run("check with simulated time", func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// Update the file to set the initial timestamp.

233
agent/mdraid_linux.go Normal file
View File

@@ -0,0 +1,233 @@
//go:build linux
package agent
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/smart"
)
// mdraidSysfsRoot is a test hook; production value is "/sys".
var mdraidSysfsRoot = "/sys"
type mdraidHealth struct {
level string
arrayState string
degraded uint64
raidDisks uint64
syncAction string
syncCompleted string
syncSpeed string
mismatchCnt uint64
capacity uint64
}
// scanMdraidDevices discovers Linux md arrays exposed in sysfs.
func scanMdraidDevices() []*DeviceInfo {
blockDir := filepath.Join(mdraidSysfsRoot, "block")
entries, err := os.ReadDir(blockDir)
if err != nil {
return nil
}
devices := make([]*DeviceInfo, 0, 2)
for _, ent := range entries {
name := ent.Name()
if !isMdraidBlockName(name) {
continue
}
mdDir := filepath.Join(blockDir, name, "md")
if !utils.FileExists(filepath.Join(mdDir, "array_state")) {
continue
}
devPath := filepath.Join("/dev", name)
devices = append(devices, &DeviceInfo{
Name: devPath,
Type: "mdraid",
InfoName: devPath + " [mdraid]",
Protocol: "MD",
})
}
return devices
}
// collectMdraidHealth reads mdraid health and stores it in SmartDataMap.
func (sm *SmartManager) collectMdraidHealth(deviceInfo *DeviceInfo) (bool, error) {
if deviceInfo == nil || deviceInfo.Name == "" {
return false, nil
}
base := filepath.Base(deviceInfo.Name)
if !isMdraidBlockName(base) && !strings.EqualFold(deviceInfo.Type, "mdraid") {
return false, nil
}
health, ok := readMdraidHealth(base)
if !ok {
return false, nil
}
deviceInfo.Type = "mdraid"
key := fmt.Sprintf("mdraid:%s", base)
status := mdraidSmartStatus(health)
attrs := make([]*smart.SmartAttribute, 0, 10)
if health.arrayState != "" {
attrs = append(attrs, &smart.SmartAttribute{Name: "ArrayState", RawString: health.arrayState})
}
if health.level != "" {
attrs = append(attrs, &smart.SmartAttribute{Name: "RaidLevel", RawString: health.level})
}
if health.raidDisks > 0 {
attrs = append(attrs, &smart.SmartAttribute{Name: "RaidDisks", RawValue: health.raidDisks})
}
if health.degraded > 0 {
attrs = append(attrs, &smart.SmartAttribute{Name: "Degraded", RawValue: health.degraded})
}
if health.syncAction != "" {
attrs = append(attrs, &smart.SmartAttribute{Name: "SyncAction", RawString: health.syncAction})
}
if health.syncCompleted != "" {
attrs = append(attrs, &smart.SmartAttribute{Name: "SyncCompleted", RawString: health.syncCompleted})
}
if health.syncSpeed != "" {
attrs = append(attrs, &smart.SmartAttribute{Name: "SyncSpeed", RawString: health.syncSpeed})
}
if health.mismatchCnt > 0 {
attrs = append(attrs, &smart.SmartAttribute{Name: "MismatchCount", RawValue: health.mismatchCnt})
}
sm.Lock()
defer sm.Unlock()
if _, exists := sm.SmartDataMap[key]; !exists {
sm.SmartDataMap[key] = &smart.SmartData{}
}
data := sm.SmartDataMap[key]
data.ModelName = "Linux MD RAID"
if health.level != "" {
data.ModelName = "Linux MD RAID (" + health.level + ")"
}
data.Capacity = health.capacity
data.SmartStatus = status
data.DiskName = filepath.Join("/dev", base)
data.DiskType = "mdraid"
data.Attributes = attrs
return true, nil
}
// readMdraidHealth reads md array health fields from sysfs.
func readMdraidHealth(blockName string) (mdraidHealth, bool) {
var out mdraidHealth
if !isMdraidBlockName(blockName) {
return out, false
}
mdDir := filepath.Join(mdraidSysfsRoot, "block", blockName, "md")
arrayState, okState := utils.ReadStringFileOK(filepath.Join(mdDir, "array_state"))
if !okState {
return out, false
}
out.arrayState = arrayState
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 := utils.ReadUintFile(filepath.Join(mdDir, "raid_disks")); ok {
out.raidDisks = val
}
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "degraded")); ok {
out.degraded = val
}
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "mismatch_cnt")); ok {
out.mismatchCnt = val
}
if capBytes, ok := readMdraidBlockCapacityBytes(blockName, mdraidSysfsRoot); ok {
out.capacity = capBytes
}
return out, true
}
// mdraidSmartStatus maps md state/sync signals to a SMART-like status.
func mdraidSmartStatus(health mdraidHealth) string {
state := strings.ToLower(strings.TrimSpace(health.arrayState))
switch state {
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 syncAction {
case "check", "repair":
return "WARNING"
}
switch state {
case "clean", "active", "active-idle", "write-pending", "read-auto", "readonly":
return "PASSED"
}
return "UNKNOWN"
}
// isMdraidBlockName matches /dev/mdN-style block device names.
func isMdraidBlockName(name string) bool {
if !strings.HasPrefix(name, "md") {
return false
}
suffix := strings.TrimPrefix(name, "md")
if suffix == "" {
return false
}
for _, c := range suffix {
if c < '0' || c > '9' {
return false
}
}
return true
}
// readMdraidBlockCapacityBytes converts block size metadata into bytes.
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 := utils.ReadStringFileOK(sizePath)
if !ok {
return 0, false
}
sectors, err := strconv.ParseUint(sizeStr, 10, 64)
if err != nil || sectors == 0 {
return 0, false
}
logicalBlockSize := uint64(512)
if lbsStr, ok := utils.ReadStringFileOK(lbsPath); ok {
if parsed, err := strconv.ParseUint(lbsStr, 10, 64); err == nil && parsed > 0 {
logicalBlockSize = parsed
}
}
return sectors * logicalBlockSize, true
}

103
agent/mdraid_linux_test.go Normal file
View File

@@ -0,0 +1,103 @@
//go:build linux
package agent
import (
"os"
"path/filepath"
"testing"
"github.com/henrygd/beszel/internal/entities/smart"
)
func TestMdraidMockSysfsScanAndCollect(t *testing.T) {
tmp := t.TempDir()
prev := mdraidSysfsRoot
mdraidSysfsRoot = tmp
t.Cleanup(func() { mdraidSysfsRoot = prev })
mdDir := filepath.Join(tmp, "block", "md0", "md")
queueDir := filepath.Join(tmp, "block", "md0", "queue")
if err := os.MkdirAll(mdDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(queueDir, 0o755); err != nil {
t.Fatal(err)
}
write := func(path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
write(filepath.Join(mdDir, "array_state"), "active\n")
write(filepath.Join(mdDir, "level"), "raid1\n")
write(filepath.Join(mdDir, "raid_disks"), "2\n")
write(filepath.Join(mdDir, "degraded"), "0\n")
write(filepath.Join(mdDir, "sync_action"), "resync\n")
write(filepath.Join(mdDir, "sync_completed"), "10%\n")
write(filepath.Join(mdDir, "sync_speed"), "100M\n")
write(filepath.Join(mdDir, "mismatch_cnt"), "0\n")
write(filepath.Join(queueDir, "logical_block_size"), "512\n")
write(filepath.Join(tmp, "block", "md0", "size"), "2048\n")
devs := scanMdraidDevices()
if len(devs) != 1 {
t.Fatalf("scanMdraidDevices() = %d devices, want 1", len(devs))
}
if devs[0].Name != "/dev/md0" || devs[0].Type != "mdraid" {
t.Fatalf("scanMdraidDevices()[0] = %+v, want Name=/dev/md0 Type=mdraid", devs[0])
}
sm := &SmartManager{SmartDataMap: map[string]*smart.SmartData{}}
ok, err := sm.collectMdraidHealth(devs[0])
if err != nil || !ok {
t.Fatalf("collectMdraidHealth() = (ok=%v, err=%v), want (true,nil)", ok, err)
}
if len(sm.SmartDataMap) != 1 {
t.Fatalf("SmartDataMap len=%d, want 1", len(sm.SmartDataMap))
}
var got *smart.SmartData
for _, v := range sm.SmartDataMap {
got = v
break
}
if got == nil {
t.Fatalf("SmartDataMap value nil")
}
if got.DiskType != "mdraid" || got.DiskName != "/dev/md0" {
t.Fatalf("disk fields = (type=%q name=%q), want (mdraid,/dev/md0)", got.DiskType, got.DiskName)
}
if got.SmartStatus != "WARNING" {
t.Fatalf("SmartStatus=%q, want WARNING", got.SmartStatus)
}
if got.ModelName == "" || got.Capacity == 0 {
t.Fatalf("identity fields = (model=%q cap=%d), want non-empty model and cap>0", got.ModelName, got.Capacity)
}
if len(got.Attributes) < 5 {
t.Fatalf("attributes len=%d, want >= 5", len(got.Attributes))
}
}
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)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", syncAction: "recover"}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(recover) = %q, want WARNING", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean"}); got != "PASSED" {
t.Fatalf("mdraidSmartStatus(clean) = %q, want PASSED", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "unknown"}); got != "UNKNOWN" {
t.Fatalf("mdraidSmartStatus(unknown) = %q, want UNKNOWN", got)
}
}

11
agent/mdraid_stub.go Normal file
View File

@@ -0,0 +1,11 @@
//go:build !linux
package agent
func scanMdraidDevices() []*DeviceInfo {
return nil
}
func (sm *SmartManager) collectMdraidHealth(deviceInfo *DeviceInfo) (bool, error) {
return false, nil
}

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

@@ -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

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package agent

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

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package agent

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 == "" {
@@ -199,6 +200,13 @@ func (sm *SmartManager) ScanDevices(force bool) error {
hasValidScan = true
}
// Add Linux mdraid arrays by reading sysfs health fields. This does not
// require smartctl and does not scan the whole device.
if raidDevices := scanMdraidDevices(); len(raidDevices) > 0 {
scannedDevices = append(scannedDevices, raidDevices...)
hasValidScan = true
}
finalDevices := mergeDeviceLists(currentDevices, scannedDevices, configuredDevices)
finalDevices = sm.filterExcludedDevices(finalDevices)
sm.updateSmartDevices(finalDevices)
@@ -215,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 = ","
}
@@ -253,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, ",") {
@@ -450,6 +458,12 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
return errNoValidSmartData
}
// mdraid health is not exposed via SMART; Linux exposes array state in sysfs.
if deviceInfo != nil {
if ok, err := sm.collectMdraidHealth(deviceInfo); ok {
return err
}
}
// eMMC health is not exposed via SMART on Linux, but the kernel provides
// wear / EOL indicators via sysfs. Prefer that path when available.
if deviceInfo != nil {
@@ -476,7 +490,7 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
output, err := cmd.CombinedOutput()
// Check if device is in standby (exit status 2)
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 2 {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 2 {
if hasExistingData {
// Device is in standby and we have cached data, keep using cache
return nil
@@ -857,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 {
@@ -900,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 {
@@ -924,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
@@ -1146,9 +1163,11 @@ func NewSmartManager() (*SmartManager, error) {
slog.Debug("smartctl", "path", path, "err", err)
if err != nil {
// Keep the previous fail-fast behavior unless this Linux host exposes
// eMMC health via sysfs, in which case smartctl is optional.
if runtime.GOOS == "linux" && len(scanEmmcDevices()) > 0 {
return sm, nil
// eMMC or mdraid health via sysfs, in which case smartctl is optional.
if runtime.GOOS == "linux" {
if len(scanEmmcDevices()) > 0 || len(scanMdraidDevices()) > 0 {
return sm, nil
}
}
return nil, err
}

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package agent
@@ -122,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},
@@ -728,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

View File

@@ -7,12 +7,13 @@ import (
"log/slog"
"os"
"runtime"
"strconv"
"strings"
"time"
"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"
@@ -107,7 +108,7 @@ func (a *Agent) refreshSystemDetails() {
}
// zfs
if _, err := getARCSize(); err != nil {
if _, err := zfs.ARCSize(); err != nil {
slog.Debug("Not monitoring ZFS ARC", "err", err)
} else {
a.zfs = true
@@ -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
@@ -178,16 +179,16 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
// }
// subtract ZFS ARC size from used memory and add as its own category
if a.zfs {
if arcSize, _ := getARCSize(); arcSize > 0 && arcSize < v.Used {
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
@@ -250,32 +251,6 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
return systemStats
}
// Returns the size of the ZFS ARC memory cache in bytes
func getARCSize() (uint64, error) {
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
if err != nil {
return 0, err
}
defer file.Close()
// Scan the lines
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "size") {
// Example line: size 4 15032385536
fields := strings.Fields(line)
if len(fields) < 3 {
return 0, err
}
// Return the size as uint64
return strconv.ParseUint(fields[2], 10, 64)
}
}
return 0, fmt.Errorf("failed to parse size field")
}
// getOsPrettyName attempts to get the pretty OS name from /etc/os-release on Linux systems
func getOsPrettyName() (string, error) {
file, err := os.Open("/etc/os-release")

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,7 +295,7 @@ 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 == "" {

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
}

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

@@ -0,0 +1,165 @@
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) {
os.Setenv(prefixedKey, "prefixed_val")
os.Setenv(key, "unprefixed_val")
defer os.Unsetenv(prefixedKey)
defer os.Unsetenv(key)
val, exists := GetEnv(key)
assert.True(t, exists)
assert.Equal(t, "prefixed_val", val)
})
t.Run("only unprefixed variable exists", func(t *testing.T) {
os.Unsetenv(prefixedKey)
os.Setenv(key, "unprefixed_val")
defer os.Unsetenv(key)
val, exists := GetEnv(key)
assert.True(t, exists)
assert.Equal(t, "unprefixed_val", val)
})
t.Run("neither variable exists", func(t *testing.T) {
os.Unsetenv(prefixedKey)
os.Unsetenv(key)
val, exists := GetEnv(key)
assert.False(t, exists)
assert.Empty(t, val)
})
}

11
agent/zfs/zfs_freebsd.go Normal file
View File

@@ -0,0 +1,11 @@
//go:build freebsd
package zfs
import (
"golang.org/x/sys/unix"
)
func ARCSize() (uint64, error) {
return unix.SysctlUint64("kstat.zfs.misc.arcstats.size")
}

34
agent/zfs/zfs_linux.go Normal file
View File

@@ -0,0 +1,34 @@
//go:build linux
// Package zfs provides functions to read ZFS statistics.
package zfs
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
func ARCSize() (uint64, error) {
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
if err != nil {
return 0, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "size") {
fields := strings.Fields(line)
if len(fields) < 3 {
return 0, fmt.Errorf("unexpected arcstats size format: %s", line)
}
return strconv.ParseUint(fields[2], 10, 64)
}
}
return 0, fmt.Errorf("size field not found in arcstats")
}

View File

@@ -0,0 +1,9 @@
//go:build !linux && !freebsd
package zfs
import "errors"
func ARCSize() (uint64, error) {
return 0, errors.ErrUnsupported
}

View File

@@ -6,7 +6,7 @@ import "github.com/blang/semver"
const (
// Version is the current version of the application.
Version = "0.18.3"
Version = "0.18.4"
// AppName is the name of the application.
AppName = "beszel"
)

30
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/henrygd/beszel
go 1.25.7
go 1.26.1
require (
github.com/blang/semver v3.5.1+incompatible
@@ -11,17 +11,17 @@ require (
github.com/gliderlabs/ssh v0.3.8
github.com/google/uuid v1.6.0
github.com/lxzan/gws v1.8.9
github.com/nicholas-fedor/shoutrrr v0.13.1
github.com/pocketbase/dbx v1.11.0
github.com/pocketbase/pocketbase v0.36.2
github.com/nicholas-fedor/shoutrrr v0.13.2
github.com/pocketbase/dbx v1.12.0
github.com/pocketbase/pocketbase v0.36.4
github.com/shirou/gopsutil/v4 v4.26.1
github.com/spf13/cast v1.10.0
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.47.0
golang.org/x/exp v0.0.0-20260112195511-716be5621a96
golang.org/x/sys v0.40.0
golang.org/x/crypto v0.48.0
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa
golang.org/x/sys v0.41.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -42,8 +42,8 @@ require (
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // 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
@@ -54,15 +54,15 @@ require (
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/image v0.35.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/image v0.36.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
howett.net/plist v1.0.1 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.44.3 // indirect
modernc.org/sqlite v1.45.0 // indirect
)

76
go.sum
View File

@@ -69,14 +69,14 @@ 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/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
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=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
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-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
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/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
@@ -85,19 +85,19 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nicholas-fedor/shoutrrr v0.13.1 h1:llEoHNbnMM4GfQ9+2Ns3n6ssvNfi3NPWluM0AQiicoY=
github.com/nicholas-fedor/shoutrrr v0.13.1/go.mod h1:kU4cFJpEAtTzl3iV0l+XUXmM90OlC5T01b7roM4/pYM=
github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8=
github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/nicholas-fedor/shoutrrr v0.13.2 h1:hfsYBIqSFYGg92pZP5CXk/g7/OJIkLYmiUnRl+AD1IA=
github.com/nicholas-fedor/shoutrrr v0.13.2/go.mod h1:ZqzV3gY/Wj6AvWs1etlO7+yKbh4iptSbeL8avBpMQbA=
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/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=
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.36.2 h1:mzrxnvXKc3yxKlvZdbwoYXkH8kfIETteD0hWdgj0VI4=
github.com/pocketbase/pocketbase v0.36.2/go.mod h1:71vSF8whUDzC8mcLFE10+Qatf9JQdeOGIRWawOuLLKM=
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.36.4 h1:zTjRZbp2WfTOJJfb+pFRWa200UaQwxZYt8RzkFMlAZ4=
github.com/pocketbase/pocketbase v0.36.4/go.mod h1:9CiezhRudd9FZGa5xZa53QZBTNxc5vvw/FGG+diAECI=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -129,20 +129,20 @@ github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQ
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
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=
@@ -150,20 +150,20 @@ golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -195,8 +195,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs=
modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -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 {
@@ -259,13 +265,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
}

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package alerts_test

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

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package alerts_test

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package alerts_test

View File

@@ -11,7 +11,6 @@ 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 {
@@ -38,7 +37,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
case "Memory":
val = data.Info.MemPct
case "Bandwidth":
val = data.Info.Bandwidth
val = float64(data.Info.BandwidthBytes) / (1024 * 1024)
unit = " MB/s"
case "Disk":
maxUsedPct := data.Info.DiskPct
@@ -92,7 +91,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
}
}
min := max(1, cast.ToUint8(alertRecord.Get("min")))
min := max(1, uint8(alertRecord.GetInt("min")))
alert := SystemAlertData{
systemRecord: systemRecord,
@@ -192,22 +191,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 {

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package alerts_test

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package alerts

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

@@ -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"`

View File

@@ -34,7 +34,7 @@ func ColorPrint(color, text string) {
fmt.Println(color + text + colorReset)
}
func ColorPrintf(color, format string, args ...interface{}) {
func ColorPrintf(color, format string, args ...any) {
fmt.Printf(color+format+colorReset+"\n", args...)
}

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package hub
@@ -10,6 +9,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
@@ -35,6 +35,26 @@ func createTestHub(t testing.TB) (*Hub, *pbtests.TestApp, error) {
return NewHub(testApp), testApp, nil
}
// cleanupTestHub stops background system goroutines before tearing down the app.
func cleanupTestHub(hub *Hub, testApp *pbtests.TestApp) {
if hub != nil {
sm := hub.GetSystemManager()
sm.RemoveAllSystems()
// Give updater goroutines a brief window to observe cancellation before DB teardown.
for range 20 {
if sm.GetSystemCount() == 0 {
break
}
runtime.Gosched()
time.Sleep(5 * time.Millisecond)
}
time.Sleep(20 * time.Millisecond)
}
if testApp != nil {
testApp.Cleanup()
}
}
// Helper function to create a test record
func createTestRecord(app core.App, collection string, data map[string]any) (*core.Record, error) {
col, err := app.FindCachedCollectionByNameOrId(collection)
@@ -64,7 +84,7 @@ func TestValidateAgentHeaders(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer testApp.Cleanup()
defer cleanupTestHub(hub, testApp)
testCases := []struct {
name string
@@ -145,7 +165,7 @@ func TestGetAllFingerprintRecordsByToken(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer testApp.Cleanup()
defer cleanupTestHub(hub, testApp)
// create test user
userRecord, err := createTestUser(testApp)
@@ -235,7 +255,7 @@ func TestSetFingerprint(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer testApp.Cleanup()
defer cleanupTestHub(hub, testApp)
// Create test user
userRecord, err := createTestUser(testApp)
@@ -315,7 +335,7 @@ func TestCreateSystemFromAgentData(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer testApp.Cleanup()
defer cleanupTestHub(hub, testApp)
// Create test user
userRecord, err := createTestUser(testApp)
@@ -425,7 +445,7 @@ func TestUniversalTokenFlow(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer testApp.Cleanup()
defer cleanupTestHub(nil, testApp)
// Create test user
userRecord, err := createTestUser(testApp)
@@ -493,7 +513,7 @@ func TestAgentConnect(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer testApp.Cleanup()
defer cleanupTestHub(hub, testApp)
// Create test user
userRecord, err := createTestUser(testApp)
@@ -652,7 +672,7 @@ func TestHandleAgentConnect(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer testApp.Cleanup()
defer cleanupTestHub(hub, testApp)
// Create test user
userRecord, err := createTestUser(testApp)
@@ -737,7 +757,7 @@ func TestAgentWebSocketIntegration(t *testing.T) {
// Create hub and test app
hub, testApp, err := createTestHub(t)
require.NoError(t, err)
defer testApp.Cleanup()
defer cleanupTestHub(hub, testApp)
// Get the hub's SSH key
hubSigner, err := hub.GetSSHKey("")
@@ -897,7 +917,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)
@@ -942,6 +962,8 @@ func TestAgentWebSocketIntegration(t *testing.T) {
}
}
time.Sleep(20 * time.Millisecond)
// Verify fingerprint state by re-reading the specific record
updatedFingerprintRecord, err := testApp.FindRecordById("fingerprints", fingerprintRecord.Id)
require.NoError(t, err)
@@ -976,7 +998,7 @@ func TestMultipleSystemsWithSameUniversalToken(t *testing.T) {
// Create hub and test app
hub, testApp, err := createTestHub(t)
require.NoError(t, err)
defer testApp.Cleanup()
defer cleanupTestHub(hub, testApp)
// Get the hub's SSH key
hubSigner, err := hub.GetSSHKey("")
@@ -1144,6 +1166,8 @@ func TestMultipleSystemsWithSameUniversalToken(t *testing.T) {
assert.Equal(t, systemCount, systemsAfterCount, "Total system count should remain the same")
}
time.Sleep(20 * time.Millisecond)
// Verify that a fingerprint record exists for this fingerprint
fingerprints, err := testApp.FindRecordsByFilter("fingerprints", "token = {:token} && fingerprint = {:fingerprint}", "", -1, 0, map[string]any{
"token": universalToken,
@@ -1176,7 +1200,7 @@ func TestPermanentUniversalTokenFromDB(t *testing.T) {
// Create hub and test app
hub, testApp, err := createTestHub(t)
require.NoError(t, err)
defer testApp.Cleanup()
defer cleanupTestHub(hub, testApp)
// Get the hub's SSH key
hubSigner, err := hub.GetSSHKey("")
@@ -1273,7 +1297,7 @@ verify:
func TestFindOrCreateSystemForToken(t *testing.T) {
hub, testApp, err := createTestHub(t)
require.NoError(t, err)
defer testApp.Cleanup()
defer cleanupTestHub(hub, testApp)
// Create test user
userRecord, err := createTestUser(testApp)

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package config_test

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

@@ -1,10 +1,10 @@
//go:build testing
// +build testing
package expirymap
import (
"testing"
"testing/synctest"
"time"
"github.com/stretchr/testify/assert"
@@ -178,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)
@@ -474,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

@@ -0,0 +1,303 @@
// Package heartbeat sends periodic outbound pings to an external monitoring
// endpoint (e.g. BetterStack, Uptime Kuma, Healthchecks.io) so operators can
// monitor Beszel without exposing it to the internet.
package heartbeat
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/henrygd/beszel"
"github.com/pocketbase/pocketbase/core"
)
// Default values for heartbeat configuration.
const (
defaultInterval = 60 // seconds
httpTimeout = 10 * time.Second
)
// Payload is the JSON body sent with each heartbeat request.
type Payload struct {
// Status is "ok" when all non-paused systems are up, "warn" when alerts
// are triggered but no systems are down, and "error" when any system is down.
Status string `json:"status"`
Timestamp string `json:"timestamp"`
Msg string `json:"msg"`
Systems SystemsSummary `json:"systems"`
Down []SystemInfo `json:"down_systems,omitempty"`
Alerts []AlertInfo `json:"triggered_alerts,omitempty"`
Version string `json:"beszel_version"`
}
// SystemsSummary contains counts of systems by status.
type SystemsSummary struct {
Total int `json:"total"`
Up int `json:"up"`
Down int `json:"down"`
Paused int `json:"paused"`
Pending int `json:"pending"`
}
// SystemInfo identifies a system that is currently down.
type SystemInfo struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Host string `json:"host" db:"host"`
}
// AlertInfo describes a currently triggered alert.
type AlertInfo struct {
SystemID string `json:"system_id"`
SystemName string `json:"system_name"`
AlertName string `json:"alert_name"`
Threshold float64 `json:"threshold"`
}
// Config holds heartbeat settings read from environment variables.
type Config struct {
URL string // endpoint to ping
Interval int // seconds between pings
Method string // HTTP method (GET or POST, default POST)
}
// Heartbeat manages the periodic outbound health check.
type Heartbeat struct {
app core.App
config Config
client *http.Client
}
// New creates a Heartbeat if configuration is present.
// Returns nil if HEARTBEAT_URL is not set (feature disabled).
func New(app core.App, getEnv func(string) (string, bool)) *Heartbeat {
url, _ := getEnv("HEARTBEAT_URL")
url = strings.TrimSpace(url)
if app == nil || url == "" {
return nil
}
interval := defaultInterval
if v, ok := getEnv("HEARTBEAT_INTERVAL"); ok {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
interval = parsed
}
}
method := http.MethodPost
if v, ok := getEnv("HEARTBEAT_METHOD"); ok {
v = strings.ToUpper(strings.TrimSpace(v))
if v == http.MethodGet || v == http.MethodHead {
method = v
}
}
return &Heartbeat{
app: app,
config: Config{
URL: url,
Interval: interval,
Method: method,
},
client: &http.Client{Timeout: httpTimeout},
}
}
// Start begins the heartbeat loop. It blocks and should be called in a goroutine.
// The loop runs until the provided stop channel is closed.
func (hb *Heartbeat) Start(stop <-chan struct{}) {
sanitizedURL := sanitizeHeartbeatURL(hb.config.URL)
hb.app.Logger().Info("Heartbeat enabled",
"url", sanitizedURL,
"interval", fmt.Sprintf("%ds", hb.config.Interval),
"method", hb.config.Method,
)
// Send an initial heartbeat immediately on startup.
hb.send()
ticker := time.NewTicker(time.Duration(hb.config.Interval) * time.Second)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
hb.send()
}
}
}
// Send performs a single heartbeat ping. Exposed for the test-heartbeat API endpoint.
func (hb *Heartbeat) Send() error {
return hb.send()
}
// GetConfig returns the current heartbeat configuration.
func (hb *Heartbeat) GetConfig() Config {
return hb.config
}
func (hb *Heartbeat) send() error {
var req *http.Request
var err error
method := normalizeMethod(hb.config.Method)
if method == http.MethodGet || method == http.MethodHead {
req, err = http.NewRequest(method, hb.config.URL, nil)
} else {
payload, payloadErr := hb.buildPayload()
if payloadErr != nil {
hb.app.Logger().Error("Heartbeat: failed to build payload", "err", payloadErr)
return payloadErr
}
body, jsonErr := json.Marshal(payload)
if jsonErr != nil {
hb.app.Logger().Error("Heartbeat: failed to marshal payload", "err", jsonErr)
return jsonErr
}
req, err = http.NewRequest(http.MethodPost, hb.config.URL, bytes.NewReader(body))
if err == nil {
req.Header.Set("Content-Type", "application/json")
}
}
if err != nil {
hb.app.Logger().Error("Heartbeat: failed to create request", "err", err)
return err
}
req.Header.Set("User-Agent", "Beszel-Heartbeat")
resp, err := hb.client.Do(req)
if err != nil {
hb.app.Logger().Error("Heartbeat: request failed", "url", sanitizeHeartbeatURL(hb.config.URL), "err", err)
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
hb.app.Logger().Warn("Heartbeat: non-success response",
"url", sanitizeHeartbeatURL(hb.config.URL),
"status", resp.StatusCode,
)
return fmt.Errorf("heartbeat endpoint returned status %d", resp.StatusCode)
}
return nil
}
func (hb *Heartbeat) buildPayload() (*Payload, error) {
db := hb.app.DB()
// Count systems by status.
var systemCounts []struct {
Status string `db:"status"`
Count int `db:"cnt"`
}
err := db.NewQuery("SELECT status, COUNT(*) as cnt FROM systems GROUP BY status").All(&systemCounts)
if err != nil {
return nil, fmt.Errorf("query system counts: %w", err)
}
summary := SystemsSummary{}
for _, sc := range systemCounts {
switch sc.Status {
case "up":
summary.Up = sc.Count
case "down":
summary.Down = sc.Count
case "paused":
summary.Paused = sc.Count
case "pending":
summary.Pending = sc.Count
}
summary.Total += sc.Count
}
// Get names of down systems.
var downSystems []SystemInfo
if summary.Down > 0 {
err = db.NewQuery("SELECT id, name, host FROM systems WHERE status = 'down'").All(&downSystems)
if err != nil {
return nil, fmt.Errorf("query down systems: %w", err)
}
}
// Get triggered alerts with system names.
var triggeredAlerts []struct {
SystemID string `db:"system"`
SystemName string `db:"system_name"`
AlertName string `db:"name"`
Value float64 `db:"value"`
}
err = db.NewQuery(`
SELECT a.system, s.name as system_name, a.name, a.value
FROM alerts a
JOIN systems s ON a.system = s.id
WHERE a.triggered = true
`).All(&triggeredAlerts)
if err != nil {
// Non-fatal: alerts info is supplementary.
triggeredAlerts = nil
}
alerts := make([]AlertInfo, 0, len(triggeredAlerts))
for _, ta := range triggeredAlerts {
alerts = append(alerts, AlertInfo{
SystemID: ta.SystemID,
SystemName: ta.SystemName,
AlertName: ta.AlertName,
Threshold: ta.Value,
})
}
// Determine overall status.
status := "ok"
msg := "All systems operational"
if summary.Down > 0 {
status = "error"
names := make([]string, len(downSystems))
for i, ds := range downSystems {
names[i] = ds.Name
}
msg = fmt.Sprintf("%d system(s) down: %s", summary.Down, strings.Join(names, ", "))
} else if len(alerts) > 0 {
status = "warn"
msg = fmt.Sprintf("%d alert(s) triggered", len(alerts))
}
return &Payload{
Status: status,
Timestamp: time.Now().UTC().Format(time.RFC3339),
Msg: msg,
Systems: summary,
Down: downSystems,
Alerts: alerts,
Version: beszel.Version,
}, nil
}
func normalizeMethod(method string) string {
upper := strings.ToUpper(strings.TrimSpace(method))
if upper == http.MethodGet || upper == http.MethodHead || upper == http.MethodPost {
return upper
}
return http.MethodPost
}
func sanitizeHeartbeatURL(rawURL string) string {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "<invalid-url>"
}
return parsed.Scheme + "://" + parsed.Host
}

View File

@@ -0,0 +1,257 @@
//go:build testing
package heartbeat_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/henrygd/beszel/internal/hub/heartbeat"
beszeltests "github.com/henrygd/beszel/internal/tests"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNew(t *testing.T) {
t.Run("returns nil when app is missing", func(t *testing.T) {
hb := heartbeat.New(nil, envGetter(map[string]string{
"HEARTBEAT_URL": "https://heartbeat.example.com/ping",
}))
assert.Nil(t, hb)
})
t.Run("returns nil when URL is missing", func(t *testing.T) {
app := newTestHub(t)
hb := heartbeat.New(app.App, func(string) (string, bool) {
return "", false
})
assert.Nil(t, hb)
})
t.Run("parses and normalizes config values", func(t *testing.T) {
app := newTestHub(t)
env := map[string]string{
"HEARTBEAT_URL": " https://heartbeat.example.com/ping ",
"HEARTBEAT_INTERVAL": "90",
"HEARTBEAT_METHOD": "head",
}
getEnv := func(key string) (string, bool) {
v, ok := env[key]
return v, ok
}
hb := heartbeat.New(app.App, getEnv)
require.NotNil(t, hb)
cfg := hb.GetConfig()
assert.Equal(t, "https://heartbeat.example.com/ping", cfg.URL)
assert.Equal(t, 90, cfg.Interval)
assert.Equal(t, http.MethodHead, cfg.Method)
})
}
func TestSendGETDoesNotRequireAppOrDB(t *testing.T) {
app := newTestHub(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "Beszel-Heartbeat", r.Header.Get("User-Agent"))
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
hb := heartbeat.New(app.App, envGetter(map[string]string{
"HEARTBEAT_URL": server.URL,
"HEARTBEAT_METHOD": "GET",
}))
require.NotNil(t, hb)
require.NoError(t, hb.Send())
}
func TestSendReturnsErrorOnHTTPFailureStatus(t *testing.T) {
app := newTestHub(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
hb := heartbeat.New(app.App, envGetter(map[string]string{
"HEARTBEAT_URL": server.URL,
"HEARTBEAT_METHOD": "GET",
}))
require.NotNil(t, hb)
err := hb.Send()
require.Error(t, err)
assert.ErrorContains(t, err, "heartbeat endpoint returned status 500")
}
func TestSendPOSTBuildsExpectedStatuses(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, app *beszeltests.TestHub, user *core.Record)
expectStatus string
expectMsgPart string
expectDown int
expectAlerts int
expectTotal int
expectUp int
expectPaused int
expectPending int
expectDownSumm int
}{
{
name: "error when at least one system is down",
setup: func(t *testing.T, app *beszeltests.TestHub, user *core.Record) {
downSystem := createTestSystem(t, app, user.Id, "db-1", "10.0.0.1", "down")
_ = createTestSystem(t, app, user.Id, "web-1", "10.0.0.2", "up")
createTriggeredAlert(t, app, user.Id, downSystem.Id, "CPU", 95)
},
expectStatus: "error",
expectMsgPart: "1 system(s) down",
expectDown: 1,
expectAlerts: 1,
expectTotal: 2,
expectUp: 1,
expectDownSumm: 1,
},
{
name: "warn when only alerts are triggered",
setup: func(t *testing.T, app *beszeltests.TestHub, user *core.Record) {
system := createTestSystem(t, app, user.Id, "api-1", "10.1.0.1", "up")
createTriggeredAlert(t, app, user.Id, system.Id, "CPU", 90)
},
expectStatus: "warn",
expectMsgPart: "1 alert(s) triggered",
expectDown: 0,
expectAlerts: 1,
expectTotal: 1,
expectUp: 1,
expectDownSumm: 0,
},
{
name: "ok when no down systems and no alerts",
setup: func(t *testing.T, app *beszeltests.TestHub, user *core.Record) {
_ = createTestSystem(t, app, user.Id, "node-1", "10.2.0.1", "up")
_ = createTestSystem(t, app, user.Id, "node-2", "10.2.0.2", "paused")
_ = createTestSystem(t, app, user.Id, "node-3", "10.2.0.3", "pending")
},
expectStatus: "ok",
expectMsgPart: "All systems operational",
expectDown: 0,
expectAlerts: 0,
expectTotal: 3,
expectUp: 1,
expectPaused: 1,
expectPending: 1,
expectDownSumm: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app := newTestHub(t)
user := createTestUser(t, app)
tt.setup(t, app, user)
type requestCapture struct {
method string
userAgent string
contentType string
payload heartbeat.Payload
}
captured := make(chan requestCapture, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
var payload heartbeat.Payload
require.NoError(t, json.Unmarshal(body, &payload))
captured <- requestCapture{
method: r.Method,
userAgent: r.Header.Get("User-Agent"),
contentType: r.Header.Get("Content-Type"),
payload: payload,
}
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
hb := heartbeat.New(app.App, envGetter(map[string]string{
"HEARTBEAT_URL": server.URL,
"HEARTBEAT_METHOD": "POST",
}))
require.NotNil(t, hb)
require.NoError(t, hb.Send())
req := <-captured
assert.Equal(t, http.MethodPost, req.method)
assert.Equal(t, "Beszel-Heartbeat", req.userAgent)
assert.Equal(t, "application/json", req.contentType)
assert.Equal(t, tt.expectStatus, req.payload.Status)
assert.Contains(t, req.payload.Msg, tt.expectMsgPart)
assert.Equal(t, tt.expectDown, len(req.payload.Down))
assert.Equal(t, tt.expectAlerts, len(req.payload.Alerts))
assert.Equal(t, tt.expectTotal, req.payload.Systems.Total)
assert.Equal(t, tt.expectUp, req.payload.Systems.Up)
assert.Equal(t, tt.expectDownSumm, req.payload.Systems.Down)
assert.Equal(t, tt.expectPaused, req.payload.Systems.Paused)
assert.Equal(t, tt.expectPending, req.payload.Systems.Pending)
})
}
}
func newTestHub(t *testing.T) *beszeltests.TestHub {
t.Helper()
app, err := beszeltests.NewTestHub(t.TempDir())
require.NoError(t, err)
t.Cleanup(app.Cleanup)
return app
}
func createTestUser(t *testing.T, app *beszeltests.TestHub) *core.Record {
t.Helper()
user, err := beszeltests.CreateUser(app.App, "admin@example.com", "password123")
require.NoError(t, err)
return user
}
func createTestSystem(t *testing.T, app *beszeltests.TestHub, userID, name, host, status string) *core.Record {
t.Helper()
system, err := beszeltests.CreateRecord(app.App, "systems", map[string]any{
"name": name,
"host": host,
"port": "45876",
"users": []string{userID},
"status": status,
})
require.NoError(t, err)
return system
}
func createTriggeredAlert(t *testing.T, app *beszeltests.TestHub, userID, systemID, name string, threshold float64) *core.Record {
t.Helper()
alert, err := beszeltests.CreateRecord(app.App, "alerts", map[string]any{
"name": name,
"system": systemID,
"user": userID,
"value": threshold,
"min": 0,
"triggered": true,
})
require.NoError(t, err)
return alert
}
func envGetter(values map[string]string) func(string) (string, bool) {
return func(key string) (string, bool) {
v, ok := values[key]
return v, ok
}
}

View File

@@ -9,12 +9,14 @@ import (
"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"
"github.com/henrygd/beszel/internal/hub/systems"
"github.com/henrygd/beszel/internal/records"
"github.com/henrygd/beszel/internal/users"
@@ -33,11 +35,15 @@ type Hub struct {
um *users.UserManager
rm *records.RecordManager
sm *systems.SystemManager
hb *heartbeat.Heartbeat
hbStop chan struct{}
pubKey string
signer ssh.Signer
appURL string
}
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{}
@@ -48,6 +54,10 @@ func NewHub(app core.App) *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{})
}
return hub
}
@@ -88,6 +98,10 @@ func (h *Hub) StartHub() error {
if err := h.sm.Initialize(); err != nil {
return err
}
// start heartbeat if configured
if h.hb != nil {
go h.hb.Start(h.hbStop)
}
return e.Next()
})
@@ -287,6 +301,9 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
})
// 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
@@ -403,6 +420,42 @@ func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
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")
@@ -411,6 +464,9 @@ func (h *Hub) containerRequestHandler(e *core.RequestEvent, fetchFunc func(*syst
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 {

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package hub_test
@@ -362,6 +361,58 @@ func TestApiRoutesAuthentication(t *testing.T) {
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,
@@ -493,7 +544,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
{
Name: "GET /containers/logs - with auth but invalid system should fail",
Method: http.MethodGet,
URL: "/api/beszel/containers/logs?system=invalid-system&container=test-container",
URL: "/api/beszel/containers/logs?system=invalid-system&container=0123456789ab",
Headers: map[string]string{
"Authorization": userToken,
},
@@ -501,6 +552,39 @@ func TestApiRoutesAuthentication(t *testing.T) {
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
{

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package hub

View File

@@ -48,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 {
@@ -134,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()
}()
}
@@ -221,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)
@@ -703,3 +712,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

@@ -1,5 +1,4 @@
//go:build !testing
// +build !testing
package systems

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package systems_test

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package systems
@@ -114,4 +113,5 @@ func (sm *SystemManager) RemoveAllSystems() {
for _, system := range sm.systems.GetAll() {
sm.RemoveSystem(system.Id)
}
sm.smartFetchMap.StopCleaner()
}

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package ws

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package ws

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package ws

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package records_test

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package records

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.3/schema.json",
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
@@ -12,7 +12,7 @@
"lineWidth": 120,
"formatWithErrors": true
},
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"assist": { "actions": { "source": { "organizeImports": "off" } } },
"linter": {
"enabled": true,
"rules": {

View File

@@ -1,13 +1,14 @@
{
"name": "beszel",
"private": true,
"version": "0.18.3",
"version": "0.18.4",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "lingui extract --overwrite && lingui compile && vite build",
"preview": "vite preview",
"sync": "lingui extract --overwrite && lingui compile",
"sync_no_compile": "lingui extract --overwrite --clean",
"sync_and_purge": "lingui extract --overwrite --clean && lingui compile",
"format": "biome format --write .",
"lint": "biome lint .",

View File

@@ -26,7 +26,7 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
/>
</Button>
</SheetTrigger>
<SheetContent className="max-h-full overflow-auto w-150 !max-w-full p-4 sm:p-6">
<SheetContent className="max-h-full overflow-auto w-160 !max-w-full p-4 sm:p-6">
{opened && <AlertDialogContent system={system} />}
</SheetContent>
</Sheet>

View File

@@ -7,6 +7,7 @@ import { lazy, memo, Suspense, useMemo, useState } from "react"
import { $router, Link } from "@/components/router"
import { Checkbox } from "@/components/ui/checkbox"
import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { toast } from "@/components/ui/use-toast"
@@ -20,7 +21,7 @@ const Slider = lazy(() => import("@/components/ui/slider"))
const endpoint = "/api/beszel/user-alerts"
const alertDebounce = 100
const alertDebounce = 400
const alertKeys = Object.keys(alertInfo) as (keyof typeof alertInfo)[]
@@ -244,7 +245,7 @@ export function AlertContent({
<Suspense fallback={<div className="h-10" />}>
{!singleDescription && (
<div>
<p id={`v${name}`} className="text-sm block h-8">
<p id={`v${name}`} className="text-sm block h-6">
{alertData.invert ? (
<Trans>
Average drops below{" "}
@@ -263,21 +264,38 @@ export function AlertContent({
</Trans>
)}
</p>
<div className="flex gap-3">
<div className="flex gap-3 items-center">
<Slider
aria-labelledby={`v${name}`}
defaultValue={[value]}
value={[value]}
onValueCommit={(val) => sendUpsert(min, val[0])}
onValueChange={(val) => setValue(val[0])}
step={alertData.step ?? 1}
min={alertData.min ?? 1}
max={alertData.max ?? 99}
/>
<Input
type="number"
value={value}
onChange={(e) => {
let val = parseFloat(e.target.value)
if (!Number.isNaN(val)) {
if (alertData.max != null) val = Math.min(val, alertData.max)
if (alertData.min != null) val = Math.max(val, alertData.min)
setValue(val)
sendUpsert(min, val)
}
}}
step={alertData.step ?? 1}
min={alertData.min ?? 1}
max={alertData.max ?? 99}
className="w-16 h-8 text-center px-1"
/>
</div>
</div>
)}
<div className={cn(singleDescription && "col-span-full lowercase")}>
<p id={`t${name}`} className="text-sm block h-8 first-letter:uppercase">
<p id={`t${name}`} className="text-sm block h-6 first-letter:uppercase">
{singleDescription && (
<>
{singleDescription}
@@ -289,15 +307,30 @@ export function AlertContent({
<Plural value={min} one="minute" other="minutes" />
</Trans>
</p>
<div className="flex gap-3">
<div className="flex gap-3 items-center">
<Slider
aria-labelledby={`v${name}`}
defaultValue={[min]}
onValueCommit={(minVal) => sendUpsert(minVal[0], value)}
aria-labelledby={`t${name}`}
value={[min]}
onValueCommit={(val) => sendUpsert(val[0], value)}
onValueChange={(val) => setMin(val[0])}
min={1}
max={60}
/>
<Input
type="number"
value={min}
onChange={(e) => {
let val = parseInt(e.target.value, 10)
if (!Number.isNaN(val)) {
val = Math.max(1, Math.min(val, 60))
setMin(val)
sendUpsert(val, value)
}
}}
min={1}
max={60}
className="w-16 h-8 text-center px-1"
/>
</div>
</div>
</Suspense>

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

@@ -43,7 +43,7 @@ export function copyDockerCompose(port = "45876", publicKey: string, token: stri
export function copyDockerRun(port = "45876", publicKey: string, token: string) {
copyToClipboard(
`docker run -d --name beszel-agent --network host --restart unless-stopped -v /var/run/docker.sock:/var/run/docker.sock:ro -v ./beszel_agent_data:/var/lib/beszel-agent -e KEY="${publicKey}" -e LISTEN=${port} -e TOKEN="${token}" -e HUB_URL="${getHubURL()}" henrygd/beszel-agent`
`docker run -d --name beszel-agent --network host --restart unless-stopped -v /var/run/docker.sock:/var/run/docker.sock:ro -v beszel_agent_data:/var/lib/beszel-agent -e KEY="${publicKey}" -e LISTEN=${port} -e TOKEN="${token}" -e HUB_URL="${getHubURL()}" henrygd/beszel-agent`
)
}

View File

@@ -32,7 +32,10 @@ export function LangToggle() {
className={cn("px-2.5 flex gap-2.5 cursor-pointer", lang === i18n.locale && "bg-accent/70 font-medium")}
onClick={() => dynamicActivate(lang)}
>
<span>{e}</span> {label}
<span>
{e || <code className="font-mono bg-muted text-[.65em] w-5 h-4 grid place-items-center">{lang}</code>}
</span>{" "}
{label}
</DropdownMenuItem>
))}
</DropdownMenuContent>

View File

@@ -70,7 +70,16 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
<SelectContent>
{languages.map(([lang, label, e]) => (
<SelectItem key={lang} value={lang}>
<span className="me-2.5">{e}</span>
<span className="me-2.5">
{e || (
<code
aria-hidden="true"
className="font-mono bg-muted text-[.65em] w-5 h-4 inline-grid place-items-center"
>
{lang}
</code>
)}
</span>
{label}
</SelectItem>
))}

View File

@@ -0,0 +1,219 @@
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import { redirectPage } from "@nanostores/router"
import { LoaderCircleIcon, SendIcon } from "lucide-react"
import { useEffect, useState } from "react"
import { $router } from "@/components/router"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { toast } from "@/components/ui/use-toast"
import { isAdmin, pb } from "@/lib/api"
import { cn } from "@/lib/utils"
interface HeartbeatStatus {
enabled: boolean
url?: string
interval?: number
method?: string
msg?: string
}
export default function HeartbeatSettings() {
const [status, setStatus] = useState<HeartbeatStatus | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isTesting, setIsTesting] = useState(false)
if (!isAdmin()) {
redirectPage($router, "settings", { name: "general" })
}
useEffect(() => {
fetchStatus()
}, [])
async function fetchStatus() {
try {
setIsLoading(true)
const res = await pb.send<HeartbeatStatus>("/api/beszel/heartbeat-status", {})
setStatus(res)
} catch (error: unknown) {
toast({
title: t`Error`,
description: (error as Error).message,
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
async function sendTestHeartbeat() {
setIsTesting(true)
try {
const res = await pb.send<{ err: string | false }>("/api/beszel/test-heartbeat", {
method: "POST",
})
if ("err" in res && !res.err) {
toast({
title: t`Heartbeat sent successfully`,
description: t`Check your monitoring service`,
})
} else {
toast({
title: t`Error`,
description: (res.err as string) ?? t`Failed to send heartbeat`,
variant: "destructive",
})
}
} catch (error: unknown) {
toast({
title: t`Error`,
description: (error as Error).message,
variant: "destructive",
})
} finally {
setIsTesting(false)
}
}
return (
<div>
<div>
<h3 className="text-xl font-medium mb-2">
<Trans>Heartbeat Monitoring</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>
Send periodic outbound pings to an external monitoring service so you can monitor Beszel without exposing it
to the internet.
</Trans>
</p>
</div>
<Separator className="my-4" />
{status?.enabled ? (
<EnabledState status={status} isTesting={isTesting} sendTestHeartbeat={sendTestHeartbeat} />
) : (
<NotEnabledState isLoading={isLoading} />
)}
</div>
)
}
function EnabledState({
status,
isTesting,
sendTestHeartbeat,
}: {
status: HeartbeatStatus
isTesting: boolean
sendTestHeartbeat: () => void
}) {
const TestIcon = isTesting ? LoaderCircleIcon : SendIcon
return (
<div className="space-y-5">
<div className="flex items-center gap-2">
<Badge variant="success">
<Trans>Active</Trans>
</Badge>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<ConfigItem label={t`Endpoint URL`} value={status.url ?? ""} mono />
<ConfigItem label={t`Interval`} value={`${status.interval}s`} />
<ConfigItem label={t`HTTP Method`} value={status.method ?? "POST"} />
</div>
<Separator />
<div>
<h4 className="text-base font-medium mb-1">
<Trans>Test heartbeat</Trans>
</h4>
<p className="text-sm text-muted-foreground leading-relaxed mb-3">
<Trans>Send a single heartbeat ping to verify your endpoint is working.</Trans>
</p>
<Button
type="button"
variant="outline"
className="flex items-center gap-1.5"
onClick={sendTestHeartbeat}
disabled={isTesting}
>
<TestIcon className={cn("size-4", isTesting && "animate-spin")} />
<Trans>Send test heartbeat</Trans>
</Button>
</div>
<Separator />
<div>
<h4 className="text-base font-medium mb-2">
<Trans>Payload format</Trans>
</h4>
<p className="text-sm text-muted-foreground leading-relaxed mb-2">
<Trans>
When using POST, each heartbeat includes a JSON payload with system status summary, list of down systems,
and triggered alerts.
</Trans>
</p>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>
The overall status is <code className="bg-muted rounded-sm px-1 text-primary">ok</code> when all systems are
up, <code className="bg-muted rounded-sm px-1 text-primary">warn</code> when alerts are triggered, and{" "}
<code className="bg-muted rounded-sm px-1 text-primary">error</code> when any system is down.
</Trans>
</p>
</div>
</div>
)
}
function NotEnabledState({ isLoading }: { isLoading?: boolean }) {
return (
<div className={cn("grid gap-4", isLoading && "animate-pulse")}>
<div>
<p className="text-sm text-muted-foreground leading-relaxed mb-3">
<Trans>Set the following environment variables on your Beszel hub to enable heartbeat monitoring:</Trans>
</p>
<div className="grid gap-2.5">
<EnvVarItem
name="HEARTBEAT_URL"
description={t`Endpoint URL to ping (required)`}
example="https://uptime.betterstack.com/api/v1/heartbeat/xxxx"
/>
<EnvVarItem name="HEARTBEAT_INTERVAL" description={t`Seconds between pings (default: 60)`} example="60" />
<EnvVarItem
name="HEARTBEAT_METHOD"
description={t`HTTP method: POST, GET, or HEAD (default: POST)`}
example="POST"
/>
</div>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>After setting the environment variables, restart your Beszel hub for changes to take effect.</Trans>
</p>
</div>
)
}
function ConfigItem({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div>
<p className="text-sm font-medium mb-0.5">{label}</p>
<p className={cn("text-sm text-muted-foreground break-all", mono && "font-mono")}>{value}</p>
</div>
)
}
function EnvVarItem({ name, description, example }: { name: string; description: string; example: string }) {
return (
<div className="bg-muted/50 rounded-md px-3 py-2.5 grid gap-1.5">
<code className="text-sm font-mono text-primary font-medium leading-tight">{name}</code>
<p className="text-sm text-muted-foreground">{description}</p>
<p className="text-xs text-muted-foreground">
<Trans>Example:</Trans> <code className="font-mono">{example}</code>
</p>
</div>
)
}

View File

@@ -2,7 +2,14 @@ import { t } from "@lingui/core/macro"
import { Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { getPagePath, redirectPage } from "@nanostores/router"
import { AlertOctagonIcon, BellIcon, FileSlidersIcon, FingerprintIcon, SettingsIcon } from "lucide-react"
import {
AlertOctagonIcon,
BellIcon,
FileSlidersIcon,
FingerprintIcon,
HeartPulseIcon,
SettingsIcon,
} from "lucide-react"
import { lazy, useEffect } from "react"
import { $router } from "@/components/router.tsx"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx"
@@ -18,12 +25,14 @@ const notificationsSettingsImport = () => import("./notifications.tsx")
const configYamlSettingsImport = () => import("./config-yaml.tsx")
const fingerprintsSettingsImport = () => import("./tokens-fingerprints.tsx")
const alertsHistoryDataTableSettingsImport = () => import("./alerts-history-data-table.tsx")
const heartbeatSettingsImport = () => import("./heartbeat.tsx")
const GeneralSettings = lazy(generalSettingsImport)
const NotificationsSettings = lazy(notificationsSettingsImport)
const ConfigYamlSettings = lazy(configYamlSettingsImport)
const FingerprintsSettings = lazy(fingerprintsSettingsImport)
const AlertsHistoryDataTableSettings = lazy(alertsHistoryDataTableSettingsImport)
const HeartbeatSettings = lazy(heartbeatSettingsImport)
export async function saveSettings(newSettings: Partial<UserSettings>) {
try {
@@ -81,6 +90,13 @@ export default function SettingsLayout() {
icon: AlertOctagonIcon,
preload: alertsHistoryDataTableSettingsImport,
},
{
title: t`Heartbeat`,
href: getPagePath($router, "settings", { name: "heartbeat" }),
icon: HeartPulseIcon,
admin: true,
preload: heartbeatSettingsImport,
},
{
title: t`YAML Config`,
href: getPagePath($router, "settings", { name: "config" }),
@@ -141,5 +157,7 @@ function SettingsContent({ name }: { name: string }) {
return <FingerprintsSettings />
case "alert-history":
return <AlertsHistoryDataTableSettings />
case "heartbeat":
return <HeartbeatSettings />
}
}

View File

@@ -593,7 +593,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
if (showMax) {
return data?.stats?.bm?.[0] ?? (data?.stats?.nsm ?? 0) * 1024 * 1024
}
return data?.stats?.b?.[0] ?? data?.stats?.ns * 1024 * 1024
return data?.stats?.b?.[0] ?? (data?.stats?.ns ?? 0) * 1024 * 1024
},
color: 5,
opacity: 0.2,
@@ -604,7 +604,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
if (showMax) {
return data?.stats?.bm?.[1] ?? (data?.stats?.nrm ?? 0) * 1024 * 1024
}
return data?.stats?.b?.[1] ?? data?.stats?.nr * 1024 * 1024
return data?.stats?.b?.[1] ?? (data?.stats?.nr ?? 0) * 1024 * 1024
},
color: 2,
opacity: 0.2,
@@ -654,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}

View File

@@ -621,8 +621,8 @@ function DiskSheet({
const deviceName = disk?.name || unknown
const model = disk?.model || unknown
const capacity = disk?.capacity ? formatCapacity(disk.capacity) : unknown
const serialNumber = disk?.serial || unknown
const firmwareVersion = disk?.firmware || unknown
const serialNumber = disk?.serial
const firmwareVersion = disk?.firmware
const status = disk?.state || unknown
return (
@@ -636,34 +636,42 @@ function DiskSheet({
{model}
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
{capacity}
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<Tooltip>
<TooltipTrigger asChild>
<span>{serialNumber}</span>
</TooltipTrigger>
<TooltipContent>
<Trans>Serial Number</Trans>
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<Tooltip>
<TooltipTrigger asChild>
<span>{firmwareVersion}</span>
</TooltipTrigger>
<TooltipContent>
<Trans>Firmware</Trans>
</TooltipContent>
</Tooltip>
{serialNumber && (
<>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<Tooltip>
<TooltipTrigger asChild>
<span>{serialNumber}</span>
</TooltipTrigger>
<TooltipContent>
<Trans>Serial Number</Trans>
</TooltipContent>
</Tooltip>
</>
)}
{firmwareVersion && (
<>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<Tooltip>
<TooltipTrigger asChild>
<span>{firmwareVersion}</span>
</TooltipTrigger>
<TooltipContent>
<Trans>Firmware</Trans>
</TooltipContent>
</Tooltip>
</>
)}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-auto p-4 flex flex-col gap-4">
<div className="flex-1 overflow-hidden p-4 flex flex-col gap-4">
{isLoading ? (
<div className="flex justify-center py-8">
<LoaderCircleIcon className="animate-spin size-10 opacity-60" />
</div>
) : (
<>
<Alert className="pb-3">
<Alert className="pb-3 shrink-0">
{status === "PASSED" ? <CheckCircle2Icon className="size-4" /> : <XCircleIcon className="size-4" />}
<AlertTitle>
<Trans>S.M.A.R.T. Self-Test</Trans>: {status}
@@ -675,9 +683,9 @@ function DiskSheet({
)}
</Alert>
{smartAttributes.length > 0 ? (
<div className="rounded-md border overflow-auto">
<div className="rounded-md border min-h-0 flex flex-col">
<Table>
<TableHeader>
<TableHeader className="sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (

View File

@@ -33,7 +33,6 @@ import {
decimalString,
formatBytes,
formatTemperature,
getMeterState,
parseSemVer,
secondsToUptimeString,
} from "@/lib/utils"
@@ -81,6 +80,10 @@ const STATUS_COLORS = {
[SystemStatus.Pending]: "bg-yellow-500",
} as const
function getMeterStateByThresholds(value: number, warn = 65, crit = 90): MeterState {
return value >= crit ? MeterState.Crit : value >= warn ? MeterState.Warn : MeterState.Good
}
/**
* @param viewMode - "table" or "grid"
* @returns - Column definitions for the systems table
@@ -195,36 +198,24 @@ 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
// 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 { major, minor } = parseSemVer(sysInfo.v)
const { colorWarn = 65, colorCrit = 90 } = useStore($userSettings, { keys: ["colorWarn", "colorCrit"] })
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
}
const normalizedLoad = max / (sysInfo.t ?? 1)
const threshold = getMeterState(normalizedLoad * 100)
const threshold = getMeterStateByThresholds(normalizedLoad * 100, colorWarn, colorCrit)
return (
<div className="flex items-center gap-[.35em] w-full tabular-nums tracking-tight">
@@ -244,19 +235,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}
@@ -463,8 +455,9 @@ function sortableHeader(context: HeaderContext<SystemRecord, unknown>) {
}
function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
const { colorWarn = 65, colorCrit = 90 } = useStore($userSettings, { keys: ["colorWarn", "colorCrit"] })
const val = Number(info.getValue()) || 0
const threshold = getMeterState(val)
const threshold = getMeterStateByThresholds(val, colorWarn, colorCrit)
const meterClass = cn(
"h-full",
(info.row.original.status !== SystemStatus.Up && STATUS_COLORS.paused) ||
@@ -483,6 +476,7 @@ function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
}
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 ?? {})
@@ -496,7 +490,7 @@ function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
extraFs.sort((a, b) => b[1] - a[1])
function getIndicatorColor(pct: number) {
const threshold = getMeterState(pct)
const threshold = getMeterStateByThresholds(pct, colorWarn, colorCrit)
return (
(status !== SystemStatus.Up && STATUS_COLORS.paused) ||
(threshold === MeterState.Good && STATUS_COLORS.up) ||
@@ -514,7 +508,9 @@ function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
const extraDiskIndicators =
status !== SystemStatus.Up
? []
: [...new Set(extraFs.map(([, pct]) => getMeterState(pct)))].sort().map((state) => stateColors[state])
: [...new Set(extraFs.map(([, pct]) => getMeterStateByThresholds(pct, colorWarn, colorCrit)))]
.sort()
.map((state) => stateColors[state])
return (
<Tooltip>

View File

@@ -40,7 +40,7 @@ export const alertInfo: Record<string, AlertInfo> = {
unit: " MB/s",
icon: EthernetIcon,
desc: () => t`Triggers when combined up/down exceeds a threshold`,
max: 125,
max: 250,
},
GPU: {
name: () => t`GPU Usage`,

View File

@@ -8,7 +8,7 @@ export default [
["es", "Español", "🇪🇸"],
["fa", "فارسی", "🇮🇷"],
["fr", "Français", "🇫🇷"],
["he", "עברית", "🕎"],
["he", "עברית", ""],
["hr", "Hrvatski", "🇭🇷"],
["hu", "Magyar", "🇭🇺"],
["id", "Indonesia", "🇮🇩"],

View File

@@ -6,7 +6,7 @@ import { useEffect, useState } from "react"
import { twMerge } from "tailwind-merge"
import { toast } from "@/components/ui/use-toast"
import type { ChartTimeData, FingerprintRecord, SemVer, SystemRecord } from "@/types"
import { HourFormat, MeterState, Unit } from "./enums"
import { HourFormat, Unit } from "./enums"
import { $copyContent, $userSettings } from "./stores"
export function cn(...inputs: ClassValue[]) {
@@ -210,7 +210,6 @@ export function useBrowserStorage<T>(key: string, defaultValue: T, storageInterf
const [value, setValue] = useState(() => {
return getStorageValue(key, defaultValue, storageInterface)
})
// biome-ignore lint/correctness/useExhaustiveDependencies: storageInterface won't change
useEffect(() => {
storageInterface?.setItem(key, JSON.stringify(value))
}, [key, value])
@@ -394,12 +393,6 @@ export function compareSemVer(a: SemVer, b: SemVer) {
return a.patch - b.patch
}
/** Get meter state from 0-100 value. Used for color coding meters. */
export function getMeterState(value: number): MeterState {
const { colorWarn = 65, colorCrit = 90 } = $userSettings.get()
return value >= colorCrit ? MeterState.Crit : value >= colorWarn ? MeterState.Warn : MeterState.Good
}
// biome-ignore lint/suspicious/noExplicitAny: any is used to allow any function to be passed in
export function debounce<T extends (...args: any[]) => any>(func: T, wait: number): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout>

View File

@@ -93,6 +93,7 @@ msgstr "إجراءات"
#: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Active"
msgstr "نشط"
@@ -140,6 +141,10 @@ msgstr "مسؤول"
msgid "After"
msgstr "بعد"
#: src/components/routes/settings/heartbeat.tsx
msgid "After setting the environment variables, restart your Beszel hub for changes to take effect."
msgstr "بعد تعيين متغيرات البيئة، أعد تشغيل مركز Beszel الخاص بك لتصبح التغييرات سارية المفعول."
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
msgstr "وكيل"
@@ -350,6 +355,10 @@ msgstr "تحقق من {email} للحصول على رابط إعادة التعي
msgid "Check logs for more details."
msgstr "تحقق من السجلات لمزيد من التفاصيل."
#: src/components/routes/settings/heartbeat.tsx
msgid "Check your monitoring service"
msgstr "تحقق من خدمة المراقبة الخاصة بك"
#: src/components/routes/settings/notifications.tsx
msgid "Check your notification service"
msgstr "تحقق من خدمة الإشعارات الخاصة بك"
@@ -643,6 +652,14 @@ msgstr "فارغة"
msgid "End Time"
msgstr "وقت النهاية"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL"
msgstr "عنوان URL للنقطة النهائية"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL to ping (required)"
msgstr "عنوان URL للنقطة النهائية لل ping (مطلوب)"
#: src/components/login/login.tsx
msgid "Enter email address to reset password"
msgstr "أدخل عنوان البريد الإشباكي لإعادة تعيين كلمة المرور"
@@ -662,6 +679,9 @@ msgstr "مؤقت"
#: src/components/login/auth-form.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -670,6 +690,10 @@ msgstr "مؤقت"
msgid "Error"
msgstr "خطأ"
#: src/components/routes/settings/heartbeat.tsx
msgid "Example:"
msgstr "مثال:"
#. placeholder {0}: alert.value
#. placeholder {1}: info.unit
#. placeholder {2}: alert.min
@@ -727,6 +751,10 @@ msgstr "فشل في المصادقة"
msgid "Failed to save settings"
msgstr "فشل في حفظ الإعدادات"
#: src/components/routes/settings/heartbeat.tsx
msgid "Failed to send heartbeat"
msgstr "فشل في إرسال نبضة القلب"
#: src/components/routes/settings/notifications.tsx
msgid "Failed to send test notification"
msgstr "فشل في إرسال إشعار الاختبار"
@@ -806,6 +834,18 @@ msgstr "شبكة"
msgid "Health"
msgstr "الصحة"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "نبضة القلب"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
msgstr "مراقبة نبضة القلب"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat sent successfully"
msgstr "تم إرسال نبضة القلب بنجاح"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"
@@ -816,6 +856,14 @@ msgstr "أمر Homebrew"
msgid "Host / IP"
msgstr "مضيف / IP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP Method"
msgstr "طريقة HTTP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
msgstr "طريقة HTTP: POST، GET، أو HEAD (الافتراضي: POST)"
#. Context: Battery state
#: src/lib/i18n.ts
msgid "Idle"
@@ -834,6 +882,10 @@ msgstr "صورة"
msgid "Inactive"
msgstr "غير نشط"
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "الفاصل الزمني"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
msgstr "عنوان البريد الإشباكي غير صالح."
@@ -1110,6 +1162,10 @@ msgstr "متوقف مؤقتا"
msgid "Paused ({pausedSystemsLength})"
msgstr "متوقف مؤقتا ({pausedSystemsLength})"
#: src/components/routes/settings/heartbeat.tsx
msgid "Payload format"
msgstr "تنسيق الحمولة"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
@@ -1307,6 +1363,10 @@ msgstr "بحث"
msgid "Search for systems or settings..."
msgstr "البحث عن الأنظمة أو الإعدادات..."
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "ثواني بين ال pings (الافتراضي: 60)"
#: src/components/alerts/alerts-sheet.tsx
msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "راجع <0>إعدادات الإشعارات</0> لتكوين كيفية تلقي التنبيهات."
@@ -1315,6 +1375,18 @@ msgstr "راجع <0>إعدادات الإشعارات</0> لتكوين كيفي
msgid "Select {foo}"
msgstr "تحديد {foo}"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "أرسل ping نبضة قلب واحدة للتحقق من أن نقطة النهاية الخاصة بك تعمل."
#: src/components/routes/settings/heartbeat.tsx
msgid "Send periodic outbound pings to an external monitoring service so you can monitor Beszel without exposing it to the internet."
msgstr "أرسل pings صادرة دورية إلى خدمة مراقبة خارجية حتى تتمكن من مراقبة Beszel دون تعريضه للإنترنت."
#: src/components/routes/settings/heartbeat.tsx
msgid "Send test heartbeat"
msgstr "إرسال نبضة قلب اختبارية"
#: src/components/routes/system.tsx
msgid "Sent"
msgstr "تم الإرسال"
@@ -1335,6 +1407,10 @@ msgstr "الخدمات"
msgid "Set percentage thresholds for meter colors."
msgstr "تعيين عتبات النسبة المئوية لألوان العداد."
#: src/components/routes/settings/heartbeat.tsx
msgid "Set the following environment variables on your Beszel hub to enable heartbeat monitoring:"
msgstr "قم بتعيين متغيرات البيئة التالية على مركز Beszel الخاص بك لتمكين مراقبة نبضة القلب:"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
@@ -1452,10 +1528,18 @@ msgstr "درجات حرارة مستشعرات النظام"
msgid "Test <0>URL</0>"
msgstr "اختبار <0>URL</0>"
#: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat"
msgstr "اختبار نبضة القلب"
#: src/components/routes/settings/notifications.tsx
msgid "Test notification sent"
msgstr "تم إرسال إشعار الاختبار"
#: src/components/routes/settings/heartbeat.tsx
msgid "The overall status is <0>ok</0> when all systems are up, <1>warn</1> when alerts are triggered, and <2>error</2> when any system is down."
msgstr "الحالة العامة هي <0>موافق</0> عندما تكون جميع الأنظمة تعمل، و<1>تحذير</1> عند تشغيل التنبيهات، و<2>خطأ</2> عندما يكون أي نظام معطلاً."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "ثم قم بتسجيل الدخول إلى الواجهة الخلفية وأعد تعيين كلمة مرور حساب المستخدم الخاص بك في جدول المستخدمين."
@@ -1642,6 +1726,7 @@ msgid "Upload"
msgstr "رفع"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime"
msgstr "مدة التشغيل"
@@ -1716,6 +1801,10 @@ msgstr "إشعارات Webhook / Push"
msgid "When enabled, this token allows agents to self-register without prior system creation."
msgstr "عند التفعيل، يسمح هذا الرمز المميز للوكلاء بالتسجيل الذاتي دون إنشاء نظام مسبق."
#: src/components/routes/settings/heartbeat.tsx
msgid "When using POST, each heartbeat includes a JSON payload with system status summary, list of down systems, and triggered alerts."
msgstr "عند استخدام POST، تتضمن كل نبضة قلب حمولة JSON مع ملخص حالة النظام وقائمة الأنظمة المعطلة والتنبيهات التي تم تشغيلها."
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"

View File

@@ -93,6 +93,7 @@ msgstr "Действия"
#: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Active"
msgstr "Активен"
@@ -140,6 +141,10 @@ msgstr "Администратор"
msgid "After"
msgstr "След"
#: src/components/routes/settings/heartbeat.tsx
msgid "After setting the environment variables, restart your Beszel hub for changes to take effect."
msgstr "След като настроите променливите на средата, рестартирайте вашия Beszel hub, за да влязат промените в сила."
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
msgstr "Агент"
@@ -231,7 +236,7 @@ msgstr "Bandwidth на мрежата"
#. Battery label in systems table header
#: src/components/systems-table/systems-table-columns.tsx
msgid "Bat"
msgstr ""
msgstr "Бат"
#: src/components/routes/system.tsx
#: src/lib/alerts.ts
@@ -350,6 +355,10 @@ msgstr "Провери {email} за линк за нулиране."
msgid "Check logs for more details."
msgstr "Провери log-овете за повече информация."
#: src/components/routes/settings/heartbeat.tsx
msgid "Check your monitoring service"
msgstr "Проверете вашата услуга за мониторинг"
#: src/components/routes/settings/notifications.tsx
msgid "Check your notification service"
msgstr "Провери услугата си за удостоверяване"
@@ -621,7 +630,7 @@ msgstr "Редактирай"
#: src/components/add-system.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Edit {foo}"
msgstr ""
msgstr "Редактиране на {foo}"
#: src/components/login/auth-form.tsx
#: src/components/login/forgot-pass-form.tsx
@@ -643,6 +652,14 @@ msgstr "Празна"
msgid "End Time"
msgstr "Крайно време"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL"
msgstr "URL адрес на крайната точка"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL to ping (required)"
msgstr "URL адрес на крайната точка за пинг (задължително)"
#: src/components/login/login.tsx
msgid "Enter email address to reset password"
msgstr "Въведи имейл адрес за да нулираш паролата"
@@ -662,6 +679,9 @@ msgstr "Ефимерен"
#: src/components/login/auth-form.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -670,6 +690,10 @@ msgstr "Ефимерен"
msgid "Error"
msgstr "Грешка"
#: src/components/routes/settings/heartbeat.tsx
msgid "Example:"
msgstr "Пример:"
#. placeholder {0}: alert.value
#. placeholder {1}: info.unit
#. placeholder {2}: alert.min
@@ -679,7 +703,7 @@ msgstr "Надвишава {0}{1} в последните {2, plural, one {# м
#: src/components/systemd-table/systemd-table.tsx
msgid "Exec main PID"
msgstr ""
msgstr "PID на главния изпълнителен процес"
#: src/components/routes/settings/config-yaml.tsx
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
@@ -727,6 +751,10 @@ msgstr "Неуспешно удостоверяване"
msgid "Failed to save settings"
msgstr "Неуспешно запазване на настройки"
#: src/components/routes/settings/heartbeat.tsx
msgid "Failed to send heartbeat"
msgstr "Неуспешно изпращане на heartbeat"
#: src/components/routes/settings/notifications.tsx
msgid "Failed to send test notification"
msgstr "Неуспешно изпрати тестова нотификация"
@@ -806,6 +834,18 @@ msgstr "Мрежово"
msgid "Health"
msgstr "Здраве"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "Heartbeat"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
msgstr "Мониторинг на heartbeat"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat sent successfully"
msgstr "Heartbeat е изпратен успешно"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"
@@ -816,6 +856,14 @@ msgstr "Команда Homebrew"
msgid "Host / IP"
msgstr "Хост / IP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP Method"
msgstr "HTTP метод"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
msgstr "HTTP метод: POST, GET или HEAD (по подразбиране: POST)"
#. Context: Battery state
#: src/lib/i18n.ts
msgid "Idle"
@@ -834,6 +882,10 @@ msgstr "Образ"
msgid "Inactive"
msgstr "Неактивен"
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "Интервал"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
msgstr "Невалиден имейл адрес."
@@ -914,7 +966,7 @@ msgstr "Търсиш къде да създадеш тревоги? Натисн
#: src/components/systemd-table/systemd-table.tsx
msgid "Main PID"
msgstr ""
msgstr "Главен PID"
#: src/components/routes/settings/layout.tsx
msgid "Manage display and notification preferences."
@@ -1110,6 +1162,10 @@ msgstr "На пауза"
msgid "Paused ({pausedSystemsLength})"
msgstr "На пауза ({pausedSystemsLength})"
#: src/components/routes/settings/heartbeat.tsx
msgid "Payload format"
msgstr "Формат на полезния товар"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
@@ -1307,6 +1363,10 @@ msgstr "Търси"
msgid "Search for systems or settings..."
msgstr "Търси за системи или настройки..."
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "Секунди между пинговете (по подразбиране: 60)"
#: src/components/alerts/alerts-sheet.tsx
msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "Виж <0>настройките за нотификациите</0> за да конфигурираш как получаваш тревоги."
@@ -1315,6 +1375,18 @@ msgstr "Виж <0>настройките за нотификациите</0> з
msgid "Select {foo}"
msgstr "Избери {foo}"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "Изпратете единичен heartbeat пинг, за да проверите дали вашата крайна точка работи."
#: src/components/routes/settings/heartbeat.tsx
msgid "Send periodic outbound pings to an external monitoring service so you can monitor Beszel without exposing it to the internet."
msgstr "Изпращайте периодични изходящи пингове към външна услуга за мониторинг, за да можете да наблюдавате Beszel, без да го излагате на интернет."
#: src/components/routes/settings/heartbeat.tsx
msgid "Send test heartbeat"
msgstr "Изпращане на тестов heartbeat"
#: src/components/routes/system.tsx
msgid "Sent"
msgstr "Изпратени"
@@ -1335,6 +1407,10 @@ msgstr "Услуги"
msgid "Set percentage thresholds for meter colors."
msgstr "Задайте процентни прагове за цветовете на измервателните уреди."
#: src/components/routes/settings/heartbeat.tsx
msgid "Set the following environment variables on your Beszel hub to enable heartbeat monitoring:"
msgstr "Задайте следните променливи на средата на вашия Beszel hub, за да активирате мониторинга на heartbeat:"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
@@ -1452,10 +1528,18 @@ msgstr "Температири на системни сензори"
msgid "Test <0>URL</0>"
msgstr "Тествай <0>URL</0>"
#: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat"
msgstr "Тестов heartbeat"
#: src/components/routes/settings/notifications.tsx
msgid "Test notification sent"
msgstr "Тестова нотификация изпратена"
#: src/components/routes/settings/heartbeat.tsx
msgid "The overall status is <0>ok</0> when all systems are up, <1>warn</1> when alerts are triggered, and <2>error</2> when any system is down."
msgstr "Общият статус е <0>ok</0>, когато всички системи работят, <1>warn</1>, когато са задействани предупреждения, и <2>error</2>, когато някоя система е спряла."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "След това влез в backend-а и нулирай паролата за потребителския акаунт в таблицата за потребители."
@@ -1642,8 +1726,9 @@ msgid "Upload"
msgstr "Качване"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime"
msgstr "Време на работа"
msgstr "Uptime"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
@@ -1716,6 +1801,10 @@ msgstr "Webhook / Пуш нотификации"
msgid "When enabled, this token allows agents to self-register without prior system creation."
msgstr "Когато е активиран, този символ позволява на агентите да се регистрират сами без предварително създаване на система."
#: src/components/routes/settings/heartbeat.tsx
msgid "When using POST, each heartbeat includes a JSON payload with system status summary, list of down systems, and triggered alerts."
msgstr "При използване на POST всеки heartbeat включва JSON полезен товар с резюме на състоянието на системата, списък на спрените системи и задействаните предупреждения."
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"

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